aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShulhan <ms@kilabit.info>2024-03-19 00:25:26 +0700
committerShulhan <ms@kilabit.info>2024-03-19 00:25:26 +0700
commit57c0833153f9cc87ae72954f8876ea438d2552ae (patch)
treeab56416f0a30988a62f4cc1bfb2a9a93e414a865
parent2e619db979d76c3e478d0b0592a569a005dc18c0 (diff)
downloadhaminer-57c0833153f9cc87ae72954f8876ea438d2552ae.tar.xz
[wip] _wui: implement web user interface
-rw-r--r--Makefile18
-rw-r--r--_wui/.gitignore1
-rw-r--r--_wui/index.ts19
-rw-r--r--_wui/tsconfig.json6
-rw-r--r--cmd/haminer/haminer.conf4
-rw-r--r--cmd/haminer/main.go5
-rw-r--r--config.go6
-rw-r--r--haminer.go48
-rw-r--r--http_server.go168
-rw-r--r--internal/cmd/memfs/main.go30
-rw-r--r--memfs_wui.go98
11 files changed, 393 insertions, 10 deletions
diff --git a/Makefile b/Makefile
index b903567..3ea3fca 100644
--- a/Makefile
+++ b/Makefile
@@ -1,12 +1,17 @@
## SPDX-FileCopyrightText: 2018 M. Shulhan <ms@kilabit.info>
## SPDX-License-Identifier: GPL-3.0-or-later
-.PHONY: all build lint serve-doc
+.PHONY: all lint serve-doc
all: build lint test
embed:
go run ./internal/cmd/memfs
+.PHONY: build-wui
+build-wui:
+ tsc --project _wui
+
+.PHONY: build
build: embed
go build -o ./_bin/ ./cmd/...
@@ -45,6 +50,15 @@ install:
serve-doc:
ciigo serve _doc
+
+##---- Run haminer for local development.
+
+.PHONY: dev
+dev:
+ go run ./cmd/haminer -dev \
+ -config _ops/haminer-test/mkosi.extra/etc/haminer.conf
+
+
##---- Initialize local development by creating image using mkosi.
## NOTE: only works on GNU/Linux OS.
@@ -64,7 +78,7 @@ init-local-dev: build haminer-dummy-backend
@echo ">>> Building container $(MACHINE_NAME) ..."
sudo mkosi --directory=_ops/$(MACHINE_NAME)/ --force build
- sudo machinectl --force import-tar _ops/$(MACHINE_NAME)/$(MACHINE_NAME)
+ sudo machinectl --force import-fs _ops/$(MACHINE_NAME)/$(MACHINE_NAME)
sudo machinectl start $(MACHINE_NAME)
## Once the container is imported, we can enable and run them any
diff --git a/_wui/.gitignore b/_wui/.gitignore
new file mode 100644
index 0000000..dcaffc0
--- /dev/null
+++ b/_wui/.gitignore
@@ -0,0 +1 @@
+/*.js
diff --git a/_wui/index.ts b/_wui/index.ts
new file mode 100644
index 0000000..74fd18f
--- /dev/null
+++ b/_wui/index.ts
@@ -0,0 +1,19 @@
+class Haminer {
+ apiLogTail(id: string) {
+ var comp = document.getElementById(id);
+
+ const evtSource = new EventSource("/api/log/tail");
+
+ evtSource.onmessage = (event) => {
+ const elLog = document.createElement("div");
+
+ console.log(`${event.data}`);
+
+ elLog.textContent = event.data;
+
+ comp.prepend(elLog);
+ };
+ }
+}
+
+let haminer = new Haminer();
diff --git a/_wui/tsconfig.json b/_wui/tsconfig.json
new file mode 100644
index 0000000..efd6312
--- /dev/null
+++ b/_wui/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "compilerOptions": {},
+ "files": [
+ "index.ts"
+ ]
+}
diff --git a/cmd/haminer/haminer.conf b/cmd/haminer/haminer.conf
index 20b7a3c..0fe01cf 100644
--- a/cmd/haminer/haminer.conf
+++ b/cmd/haminer/haminer.conf
@@ -68,6 +68,10 @@
##
#forward_interval = 15s
+## The address to serve for web user interface.
+
+#wui_address = 127.0.0.1:15140
+
##
## Pre-process tag by replacing its value using regular expression.
## Each pre-process rules is run from top to bottom, which means if we have
diff --git a/cmd/haminer/main.go b/cmd/haminer/main.go
index ec7cd8b..3813093 100644
--- a/cmd/haminer/main.go
+++ b/cmd/haminer/main.go
@@ -22,17 +22,16 @@ const (
func main() {
var (
chSignal = make(chan os.Signal, 1)
+ cfg = haminer.NewConfig()
- cfg *haminer.Config
err error
flagConfig string
)
log.SetPrefix(defLogPrefix)
- cfg = haminer.NewConfig()
-
flag.StringVar(&flagConfig, `config`, defConfig, `Path to configuration`)
+ flag.BoolVar(&cfg.IsDevelopment, `dev`, false, `Enable development mode`)
flag.Parse()
diff --git a/config.go b/config.go
index cc7e9d5..88b36e1 100644
--- a/config.go
+++ b/config.go
@@ -29,6 +29,9 @@ type Config struct {
listenAddr string
+ // WuiAddress the address to serve for web user interface.
+ WuiAddress string `ini:"haminer::wui_address"`
+
// AcceptBackend list of backend to be filtered.
AcceptBackend []string `ini:"haminer::accept_backend"`
@@ -45,6 +48,9 @@ type Config struct {
ForwardInterval time.Duration `ini:"haminer::forward_interval"`
listenPort int
+
+ // IsDevelopment only enabled during local development.
+ IsDevelopment bool
}
// NewConfig will create, initialize, and return new config with default
diff --git a/haminer.go b/haminer.go
index eeec52b..fca1458 100644
--- a/haminer.go
+++ b/haminer.go
@@ -11,6 +11,7 @@ import (
"time"
"git.sr.ht/~shulhan/pakakeh.go/lib/memfs"
+ "git.sr.ht/~shulhan/pakakeh.go/lib/mlog"
)
const (
@@ -29,8 +30,11 @@ var memfsDatabase *memfs.MemFS
// Haminer define the log consumer and producer.
type Haminer struct {
- cfg *Config
- udpConn *net.UDPConn
+ cfg *Config
+ udpConn *net.UDPConn
+
+ httpd *httpServer
+
httpLogq chan *HTTPLog
ff []Forwarder
isRunning bool
@@ -65,6 +69,13 @@ func NewHaminer(cfg *Config) (h *Haminer, err error) {
initHostname()
+ if len(cfg.WuiAddress) != 0 {
+ h.httpd, err = newHTTPServer(cfg)
+ if err != nil {
+ return nil, fmt.Errorf(`%s: %w`, logp, err)
+ }
+ }
+
err = h.createForwarder()
if err != nil {
return nil, fmt.Errorf(`%s: %w`, logp, err)
@@ -131,14 +142,20 @@ func (h *Haminer) createForwarder() (err error) {
// Start will listen for UDP packet and start consuming log, parse, and
// publish it to analytic server.
func (h *Haminer) Start() (err error) {
- udpAddr := &net.UDPAddr{
+ var logp = `Start`
+
+ var udpAddr = &net.UDPAddr{
IP: net.ParseIP(h.cfg.listenAddr),
Port: h.cfg.listenPort,
}
h.udpConn, err = net.ListenUDP("udp", udpAddr)
if err != nil {
- return
+ return fmt.Errorf(`%s: %w`, logp, err)
+ }
+
+ if h.httpd != nil {
+ h.httpd.start()
}
h.isRunning = true
@@ -182,6 +199,14 @@ func (h *Haminer) consume() {
continue
}
+ if h.httpd != nil {
+ select {
+ case h.httpd.rawlogq <- string(packet[:n]):
+ default:
+ // Log queue is full.
+ }
+ }
+
halog = ParseUDPPacket(packet[:n], h.cfg.RequestHeaders)
if halog == nil {
continue
@@ -229,10 +254,23 @@ func (h *Haminer) produce() {
// Stop will close UDP server and clear all resources.
func (h *Haminer) Stop() {
+ var (
+ logp = `Stop`
+
+ err error
+ )
+
+ if h.httpd != nil {
+ err = h.httpd.Stop(1 * time.Second)
+ if err != nil {
+ mlog.Errf(`%s: %s`, logp, err)
+ }
+ }
+
h.isRunning = false
if h.udpConn != nil {
- err := h.udpConn.Close()
+ err = h.udpConn.Close()
if err != nil {
log.Println(err)
}
diff --git a/http_server.go b/http_server.go
new file mode 100644
index 0000000..64d9a30
--- /dev/null
+++ b/http_server.go
@@ -0,0 +1,168 @@
+// SPDX-FileCopyrightText: 2024 Shulhan <ms@kilabit.info>
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package haminer
+
+import (
+ "fmt"
+ "sync"
+
+ libhttp "git.sr.ht/~shulhan/pakakeh.go/lib/http"
+ "git.sr.ht/~shulhan/pakakeh.go/lib/memfs"
+ "git.sr.ht/~shulhan/pakakeh.go/lib/mlog"
+)
+
+const (
+ pathAPILogTail = `/api/log/tail`
+)
+
+var memfsWUI *memfs.MemFS
+
+type httpServer struct {
+ *libhttp.Server
+
+ // rawlogq channel that receive raw log to be published by HTTP API
+ // apiLogTail.
+ rawlogq chan string
+
+ tailer map[int64]chan string
+ tailerIdx int64
+ tailerMtx sync.Mutex
+}
+
+func newHTTPServer(cfg *Config) (httpd *httpServer, err error) {
+ var logp = `newHTTPServer`
+
+ if memfsWUI != nil {
+ memfsWUI.Opts.TryDirect = cfg.IsDevelopment
+ }
+
+ httpd = &httpServer{
+ rawlogq: make(chan string, 512),
+ tailer: make(map[int64]chan string),
+ }
+
+ var opts = libhttp.ServerOptions{
+ Memfs: memfsWUI,
+ Address: cfg.WuiAddress,
+ }
+
+ httpd.Server, err = libhttp.NewServer(opts)
+ if err != nil {
+ return nil, fmt.Errorf(`%s: %w`, logp, err)
+ }
+
+ err = httpd.registerEndpoints()
+ if err != nil {
+ return nil, fmt.Errorf(`%s: %w`, logp, err)
+ }
+
+ return httpd, nil
+}
+
+func (httpd *httpServer) logPublisher() {
+ var (
+ rawlog string
+ tailer chan string
+ )
+ for rawlog = range httpd.rawlogq {
+ httpd.tailerMtx.Lock()
+ for _, tailer = range httpd.tailer {
+ tailer <- rawlog
+ }
+ httpd.tailerMtx.Unlock()
+ }
+}
+
+func (httpd *httpServer) registerEndpoints() (err error) {
+ var logp = `registerEndpoints`
+
+ err = httpd.RegisterSSE(libhttp.SSEEndpoint{
+ Call: httpd.apiLogTail,
+ Path: pathAPILogTail,
+ })
+ if err != nil {
+ return fmt.Errorf(`%s: %w`, logp, err)
+ }
+ return nil
+}
+
+func (httpd *httpServer) registerTailer() (idx int64, tailer chan string) {
+ var ok bool
+
+ httpd.tailerMtx.Lock()
+
+ for {
+ _, ok = httpd.tailer[httpd.tailerIdx]
+ if !ok {
+ // Index not exist, use it.
+ break
+ }
+ httpd.tailerIdx++
+ }
+ idx = httpd.tailerIdx
+ tailer = make(chan string, 512)
+ httpd.tailer[idx] = tailer
+
+ httpd.tailerMtx.Unlock()
+
+ return idx, tailer
+}
+
+func (httpd *httpServer) unregisterTailer(idx int64) {
+ var (
+ tailer chan string
+ ok bool
+ )
+
+ httpd.tailerMtx.Lock()
+
+ tailer, ok = httpd.tailer[idx]
+ if ok {
+ close(tailer)
+ delete(httpd.tailer, idx)
+ }
+
+ httpd.tailerMtx.Unlock()
+}
+
+func (httpd *httpServer) start() (err error) {
+ var logp = `start`
+
+ mlog.Outf(`%s: starting HTTP server at http://%s`, logp, httpd.Options.Address)
+
+ go func() {
+ err = httpd.Server.Start()
+ if err != nil {
+ mlog.Errf(`%s: %s`, logp, err)
+ }
+ }()
+ go httpd.logPublisher()
+
+ return nil
+}
+
+// apiLogTail tail the log using Server-Sent event.
+func (httpd *httpServer) apiLogTail(sse *libhttp.SSEConn) {
+ var (
+ logp = `apiLogTail`
+
+ tailer chan string
+ rawlog string
+ idx int64
+ err error
+ )
+
+ idx, tailer = httpd.registerTailer()
+
+ for rawlog = range tailer {
+ mlog.Outf(`%s: %s`, logp, rawlog)
+
+ err = sse.WriteEvent(``, rawlog, nil)
+ if err != nil {
+ mlog.Errf(`%s: %s`, logp, err)
+ httpd.unregisterTailer(idx)
+ return
+ }
+ }
+}
diff --git a/internal/cmd/memfs/main.go b/internal/cmd/memfs/main.go
index 59b8fdd..341fd98 100644
--- a/internal/cmd/memfs/main.go
+++ b/internal/cmd/memfs/main.go
@@ -9,6 +9,7 @@ import (
func main() {
embedDatabase()
+ embedWui()
}
func embedDatabase() {
@@ -39,3 +40,32 @@ func embedDatabase() {
log.Fatal(os.Args[0], err)
}
}
+
+func embedWui() {
+ var memfsOpts = memfs.Options{
+ Embed: memfs.EmbedOptions{
+ PackageName: `haminer`,
+ VarName: `memfsWUI`,
+ GoFileName: `memfs_wui.go`,
+ },
+ Root: `_wui`,
+ Includes: []string{
+ `.*\.(html|js)$`,
+ },
+ }
+
+ var (
+ mfs *memfs.MemFS
+ err error
+ )
+
+ mfs, err = memfs.New(&memfsOpts)
+ if err != nil {
+ log.Fatal(os.Args[0], err)
+ }
+
+ err = mfs.GoEmbed()
+ if err != nil {
+ log.Fatal(os.Args[0], err)
+ }
+}
diff --git a/memfs_wui.go b/memfs_wui.go
new file mode 100644
index 0000000..b6bed09
--- /dev/null
+++ b/memfs_wui.go
@@ -0,0 +1,98 @@
+// Code generated by git.sr.ht/~shulhan/pakakeh.go/lib/memfs DO NOT EDIT.
+
+package haminer
+
+import (
+ "git.sr.ht/~shulhan/pakakeh.go/lib/memfs"
+)
+
+func generate__wui() *memfs.Node {
+ var node = &memfs.Node{
+ SysPath: "_wui",
+ Path: "/",
+ ContentType: "",
+ GenFuncName: "generate__wui",
+ }
+ node.SetMode(2147484141)
+ node.SetModTimeUnix(1710773644, 866595879)
+ node.SetName("/")
+ node.SetSize(0)
+ node.AddChild(_memfsWUI_getNode(memfsWUI, "/index.html", generate__wui_index_html))
+ node.AddChild(_memfsWUI_getNode(memfsWUI, "/index.js", generate__wui_index_js))
+ return node
+}
+
+func generate__wui_index_html() *memfs.Node {
+ var node = &memfs.Node{
+ SysPath: "_wui/index.html",
+ Path: "/index.html",
+ ContentType: "text/html; charset=utf-8",
+ GenFuncName: "generate__wui_index_html",
+ Content: []byte("\x3C\x68\x74\x6D\x6C\x20\x6C\x61\x6E\x67\x3D\x22\x65\x6E\x22\x3E\x0A\x0A\x3C\x68\x65\x61\x64\x3E\x0A\x20\x20\x20\x20\x3C\x73\x63\x72\x69\x70\x74\x20\x73\x72\x63\x3D\x22\x2F\x69\x6E\x64\x65\x78\x2E\x6A\x73\x22\x3E\x3C\x2F\x73\x63\x72\x69\x70\x74\x3E\x0A\x3C\x2F\x68\x65\x61\x64\x3E\x0A\x0A\x3C\x62\x6F\x64\x79\x3E\x0A\x20\x20\x20\x20\x54\x65\x73\x74\x2E\x0A\x20\x20\x20\x20\x3C\x64\x69\x76\x20\x69\x64\x3D\x22\x6C\x6F\x67\x2D\x74\x61\x69\x6C\x22\x3E\x0A\x20\x20\x20\x20\x3C\x2F\x64\x69\x76\x3E\x0A\x0A\x20\x20\x20\x20\x3C\x73\x63\x72\x69\x70\x74\x3E\x0A\x20\x20\x20\x20\x20\x20\x20\x20\x68\x61\x6D\x69\x6E\x65\x72\x2E\x61\x70\x69\x4C\x6F\x67\x54\x61\x69\x6C\x28\x22\x6C\x6F\x67\x2D\x74\x61\x69\x6C\x22\x29\x3B\x0A\x20\x20\x20\x20\x3C\x2F\x73\x63\x72\x69\x70\x74\x3E\x0A\x3C\x2F\x62\x6F\x64\x79\x3E\x0A\x0A\x3C\x2F\x68\x74\x6D\x6C\x3E\x0A"),
+ }
+ node.SetMode(420)
+ node.SetModTimeUnix(1710708775, 925427055)
+ node.SetName("index.html")
+ node.SetSize(209)
+ return node
+}
+
+func generate__wui_index_js() *memfs.Node {
+ var node = &memfs.Node{
+ SysPath: "_wui/index.js",
+ Path: "/index.js",
+ ContentType: "text/javascript; charset=utf-8",
+ GenFuncName: "generate__wui_index_js",
+ Content: []byte("\x76\x61\x72\x20\x48\x61\x6D\x69\x6E\x65\x72\x20\x3D\x20\x2F\x2A\x2A\x20\x40\x63\x6C\x61\x73\x73\x20\x2A\x2F\x20\x28\x66\x75\x6E\x63\x74\x69\x6F\x6E\x20\x28\x29\x20\x7B\x0A\x20\x20\x20\x20\x66\x75\x6E\x63\x74\x69\x6F\x6E\x20\x48\x61\x6D\x69\x6E\x65\x72\x28\x29\x20\x7B\x0A\x20\x20\x20\x20\x7D\x0A\x20\x20\x20\x20\x48\x61\x6D\x69\x6E\x65\x72\x2E\x70\x72\x6F\x74\x6F\x74\x79\x70\x65\x2E\x61\x70\x69\x4C\x6F\x67\x54\x61\x69\x6C\x20\x3D\x20\x66\x75\x6E\x63\x74\x69\x6F\x6E\x20\x28\x69\x64\x29\x20\x7B\x0A\x20\x20\x20\x20\x20\x20\x20\x20\x76\x61\x72\x20\x63\x6F\x6D\x70\x20\x3D\x20\x64\x6F\x63\x75\x6D\x65\x6E\x74\x2E\x67\x65\x74\x45\x6C\x65\x6D\x65\x6E\x74\x42\x79\x49\x64\x28\x69\x64\x29\x3B\x0A\x20\x20\x20\x20\x20\x20\x20\x20\x76\x61\x72\x20\x65\x76\x74\x53\x6F\x75\x72\x63\x65\x20\x3D\x20\x6E\x65\x77\x20\x45\x76\x65\x6E\x74\x53\x6F\x75\x72\x63\x65\x28\x22\x2F\x61\x70\x69\x2F\x6C\x6F\x67\x2F\x74\x61\x69\x6C\x22\x29\x3B\x0A\x20\x20\x20\x20\x20\x20\x20\x20\x65\x76\x74\x53\x6F\x75\x72\x63\x65\x2E\x6F\x6E\x6D\x65\x73\x73\x61\x67\x65\x20\x3D\x20\x66\x75\x6E\x63\x74\x69\x6F\x6E\x20\x28\x65\x76\x65\x6E\x74\x29\x20\x7B\x0A\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x76\x61\x72\x20\x65\x6C\x4C\x6F\x67\x20\x3D\x20\x64\x6F\x63\x75\x6D\x65\x6E\x74\x2E\x63\x72\x65\x61\x74\x65\x45\x6C\x65\x6D\x65\x6E\x74\x28\x22\x64\x69\x76\x22\x29\x3B\x0A\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x63\x6F\x6E\x73\x6F\x6C\x65\x2E\x6C\x6F\x67\x28\x22\x22\x2E\x63\x6F\x6E\x63\x61\x74\x28\x65\x76\x65\x6E\x74\x2E\x64\x61\x74\x61\x29\x29\x3B\x0A\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x65\x6C\x4C\x6F\x67\x2E\x74\x65\x78\x74\x43\x6F\x6E\x74\x65\x6E\x74\x20\x3D\x20\x65\x76\x65\x6E\x74\x2E\x64\x61\x74\x61\x3B\x0A\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x63\x6F\x6D\x70\x2E\x70\x72\x65\x70\x65\x6E\x64\x28\x65\x6C\x4C\x6F\x67\x29\x3B\x0A\x20\x20\x20\x20\x20\x20\x20\x20\x7D\x3B\x0A\x20\x20\x20\x20\x7D\x3B\x0A\x20\x20\x20\x20\x72\x65\x74\x75\x72\x6E\x20\x48\x61\x6D\x69\x6E\x65\x72\x3B\x0A\x7D\x28\x29\x29\x3B\x0A\x76\x61\x72\x20\x68\x61\x6D\x69\x6E\x65\x72\x20\x3D\x20\x6E\x65\x77\x20\x48\x61\x6D\x69\x6E\x65\x72\x28\x29\x3B\x0A"),
+ }
+ node.SetMode(420)
+ node.SetModTimeUnix(1710709901, 923580725)
+ node.SetName("index.js")
+ node.SetSize(533)
+ return node
+}
+
+// _memfsWUI_getNode is internal function to minimize duplicate node
+// created on Node.AddChild() and on generatedPathNode.Set().
+func _memfsWUI_getNode(mfs *memfs.MemFS, path string, fn func() *memfs.Node) (node *memfs.Node) {
+ node = mfs.PathNodes.Get(path)
+ if node != nil {
+ return node
+ }
+ return fn()
+}
+
+func init() {
+ memfsWUI = &memfs.MemFS{
+ PathNodes: memfs.NewPathNode(),
+ Opts: &memfs.Options{
+ Root: "_wui",
+ MaxFileSize: 5242880,
+ Includes: []string{
+ `.*\.(html|js)$`,
+ },
+ Excludes: []string{
+ },
+ Embed: memfs.EmbedOptions{
+ CommentHeader: ``,
+ PackageName: "haminer",
+ VarName: "memfsWUI",
+ GoFileName: "memfs_wui.go",
+ WithoutModTime: false,
+ },
+ },
+ }
+ memfsWUI.PathNodes.Set("/",
+ _memfsWUI_getNode(memfsWUI, "/", generate__wui))
+ memfsWUI.PathNodes.Set("/index.html",
+ _memfsWUI_getNode(memfsWUI, "/index.html", generate__wui_index_html))
+ memfsWUI.PathNodes.Set("/index.js",
+ _memfsWUI_getNode(memfsWUI, "/index.js", generate__wui_index_js))
+
+ memfsWUI.Root = memfsWUI.PathNodes.Get("/")
+
+ var err = memfsWUI.Init()
+ if err != nil {
+ panic("memfsWUI: " + err.Error())
+ }
+}