diff options
| author | Shulhan <ms@kilabit.info> | 2024-03-19 00:25:26 +0700 |
|---|---|---|
| committer | Shulhan <ms@kilabit.info> | 2024-03-19 00:25:26 +0700 |
| commit | 57c0833153f9cc87ae72954f8876ea438d2552ae (patch) | |
| tree | ab56416f0a30988a62f4cc1bfb2a9a93e414a865 | |
| parent | 2e619db979d76c3e478d0b0592a569a005dc18c0 (diff) | |
| download | haminer-57c0833153f9cc87ae72954f8876ea438d2552ae.tar.xz | |
[wip] _wui: implement web user interface
| -rw-r--r-- | Makefile | 18 | ||||
| -rw-r--r-- | _wui/.gitignore | 1 | ||||
| -rw-r--r-- | _wui/index.ts | 19 | ||||
| -rw-r--r-- | _wui/tsconfig.json | 6 | ||||
| -rw-r--r-- | cmd/haminer/haminer.conf | 4 | ||||
| -rw-r--r-- | cmd/haminer/main.go | 5 | ||||
| -rw-r--r-- | config.go | 6 | ||||
| -rw-r--r-- | haminer.go | 48 | ||||
| -rw-r--r-- | http_server.go | 168 | ||||
| -rw-r--r-- | internal/cmd/memfs/main.go | 30 | ||||
| -rw-r--r-- | memfs_wui.go | 98 |
11 files changed, 393 insertions, 10 deletions
@@ -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() @@ -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 @@ -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()) + } +} |
