From c00c076d9b6d3942d2133da7ca6bc244d2c84bda83a5c8af39c9ebe7bbe77f61 Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Mon, 5 Oct 2026 03:33:24 -0400 Subject: [PATCH] v0.26.3 web viewer improvement --- doc/architecture.md | 3 +- doc/sinks.md | 12 +++--- doc/sources.md | 7 +++- doc/todo.md | 10 ++--- internal/core/flow.go | 39 ++++++++++++++++++ internal/core/flow_test.go | 24 +++++++++++ internal/sink/console/console.go | 57 ++++++++------------------- internal/sink/console/console_test.go | 5 ++- internal/sink/http/http_test.go | 5 ++- internal/sink/http/web.go | 7 +++- internal/sink/http/web/style.css | 4 +- internal/sink/http/web/view.html | 3 ++ internal/sink/http/web/view.js | 42 +++++++++++++++++++- internal/source/source.go | 26 ++---------- 14 files changed, 159 insertions(+), 85 deletions(-) create mode 100644 internal/core/flow_test.go diff --git a/doc/architecture.md b/doc/architecture.md index a8a32d9..7b68b82 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -83,7 +83,8 @@ registry rejects a second instance of any such type. - `Node`: origin node label for chained topologies; stamped at the first hop, preserved by relays. - `Source`: origin identifier within the node (filename, plugin id, …). -- `Level`: `DEBUG`, `INFO`, `WARN`, `ERROR` or `TRACE`, when detected. +- `Level`: `TRACE`, `DEBUG`, `INFO`, `WARN` or `ERROR`, when detected; the + words naming each are one table, `core.Levels`. - `Message`: log content. - `Fields`: optional structured metadata as raw JSON. - `RawSize`: original byte size, used by the entry-size cap. diff --git a/doc/sinks.md b/doc/sinks.md index 05f9e5b..7be59e8 100644 --- a/doc/sinks.md +++ b/doc/sinks.md @@ -54,10 +54,10 @@ Payloads are written as the formatter made them, one record per line. characters, such as the joiners emoji and Indic scripts need, are kept. `never` keeps an application's own ANSI colours; under `auto`, pipes and files get the bytes unchanged. -- Color: the first whole word in a line, in any case, that names the entry's - level is painted: `DEBUG`/`DBG` cyan (blue is hard to read on black), - `INFO`/`INF` green, `WARNING`/`WARN` yellow, `ERROR`/`ERR`/`FATAL` bold red, - `TRACE` grey. The colors are ANSI 16, so the terminal's theme picks the +- Color: the first word in a line naming the entry's level, by the names the + [sources](sources.md#file) read, is painted: `DEBUG` cyan (blue is hard to + read on black), `INFO` green, `WARN` yellow, `ERROR` bold red, `TRACE` + grey. The colors are ANSI 16, so the terminal's theme picks the shades and a Linux or BSD text console renders them; the codes are the sink's own, written after escaping. A line whose source found no level stays plain. The codes go into whatever the format makes, JSON included: @@ -216,7 +216,9 @@ Options, each as type and default: to: `/` is refused like any other path; - an endpoint at `/` keeps the root, and the viewer stays at `/auth/view`. The viewer starts with the `replay_lines` backlog, holds one stream against - `max_connections`, and says so when all are taken (`server full`). + `max_connections`, and says so when all are taken (`server full`). Its + search keeps the lines containing a text, in any case, and its level list + the entries at or above a level, read as the sources read it. - Refusals are logged at WARN and counted in `auth_rejected`. The authorized identity is recorded in the client's session as `auth_method` / `auth_identity`. diff --git a/doc/sources.md b/doc/sources.md index 1dc378c..136c377 100644 --- a/doc/sources.md +++ b/doc/sources.md @@ -68,8 +68,11 @@ Options, each as type and default: - A line is parsed as JSON only when it is an object whose top-level keys are all drawn from `time`, `level`, `msg` and `fields` — the four an entry can carry. `time` is read as RFC3339Nano. Any other key, and any non-object line, - is kept whole as text with the level inferred from common markers - (`[ERROR]`, `WARN:`, and so on), because parsing it would drop the rest. + is kept whole as text, because parsing it would drop the rest. +- A text line's level is its first whole word, in any case, naming one: + `TRACE`; `DEBUG`, `DBG`; `INFO`, `INF`; `WARN`, `WARNING`; `ERROR`, `ERR`, + `FATAL`. `warning: disk full` is WARN, `INFO retry after error` INFO. The + console sink paints that word and the http sink's viewer filters on it. - `raw = true` skips the JSON branch entirely. The line, plus its newline, becomes the message; `fields` stays empty, the time is the read time, and the level is inferred from the text as for any unparsed line. Paired with diff --git a/doc/todo.md b/doc/todo.md index 004fecd..1e37d9b 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -65,11 +65,7 @@ identity, and both skeletons download the tagged source. Smaller items found along the way, each independent of the others. -1. Level detection. `source.ExtractLogLevel` needs a delimiter after the name - (`ERROR:`, `[WARN]`, ` INFO `), so `warning disk full` or `DBG x` get no - level, hence no color. Match the names as words, as the console sink's - painter does, with one table for both. -2. Command line: +1. Command line: - a positional word at the top level (`lw tail`, `lw -t FILE`) still reaches config with an unclear message; name it ("unexpected argument X; the file is -c FILE"); @@ -79,9 +75,9 @@ Smaller items found along the way, each independent of the others. - `lw tls cert --host` could become `--hosts` (keeping `--host`), to match `tls.hosts` and the preset key; - top-level usage errors exit 1, subcommand ones 2. -3. TLS: the generated-certificate key is one per process, so a pin taken from a +2. TLS: the generated-certificate key is one per process, so a pin taken from a `self_signed` listener also matches an `issuer` listener of the same process; document it or key per listener. Pins are checked in `VerifyPeerCertificate`, which Go skips on resumption: guard it if a dialer ever keeps a `ClientSessionCache`. -4. `core.ShutdownTimeout` is unused. +3. `core.ShutdownTimeout` is unused. diff --git a/internal/core/flow.go b/internal/core/flow.go index 96dce3f..cd9781a 100644 --- a/internal/core/flow.go +++ b/internal/core/flow.go @@ -2,6 +2,7 @@ package core import ( "encoding/json" + "strings" "time" ) @@ -24,3 +25,41 @@ type TransportEvent struct { // Structured entry for re-serializing sinks (chain links). Zero Time => absent Entry LogEntry } + +// Levels are the entry levels by rising severity, each with the words naming +// it in a line: the sources, the console sink's painter and the viewer share them +var Levels = []struct { + Level string `json:"level"` + Names []string `json:"names"` +}{ + {"TRACE", []string{"TRACE"}}, + {"DEBUG", []string{"DEBUG", "DBG"}}, + {"INFO", []string{"INFO", "INF"}}, + {"WARN", []string{"WARN", "WARNING"}}, + {"ERROR", []string{"ERROR", "ERR", "FATAL"}}, +} + +// LevelWord finds the first whole word of text, in any case, that names a +// level, or names want when it is set; it returns the level and the word's +// bounds, and "" when no word does. +func LevelWord[T string | []byte](text T, want string) (level string, start, end int) { + for i := 0; i < len(text); i = end { + for i < len(text) && !wordByte(text[i]) { + i++ + } + for end = i; end < len(text) && wordByte(text[end]); end++ { + } + for _, l := range Levels { + for _, name := range l.Names { + if end-i == len(name) && (want == "" || want == l.Level) && strings.EqualFold(string(text[i:end]), name) { + return l.Level, i, end + } + } + } + } + return "", 0, 0 +} + +func wordByte(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' +} diff --git a/internal/core/flow_test.go b/internal/core/flow_test.go new file mode 100644 index 0000000..abfa345 --- /dev/null +++ b/internal/core/flow_test.go @@ -0,0 +1,24 @@ +package core + +import "testing" + +// A line's level is its first whole word, in any case, naming a level, not the +// most severe one named; a wanted level skips the names of the others. +func TestLevelIsTheFirstWordNamingOne(t *testing.T) { + for _, c := range []struct { + text, want, level string + start, end int + }{ + {"warning disk full", "", "WARN", 0, 7}, + {"DBG x", "", "DEBUG", 0, 3}, + {"2026-10-05 [Info] retry after error", "", "INFO", 12, 16}, + {`{"level":"fatal","msg":"x"}`, "", "ERROR", 10, 15}, + {"terror informal x_ERR", "", "", 0, 0}, + {"INFO then ERR", "ERROR", "ERROR", 10, 13}, + } { + level, start, end := LevelWord(c.text, c.want) + if level != c.level || start != c.start || end != c.end { + t.Errorf("%q want %q: got %q [%d:%d], want %q [%d:%d]", c.text, c.want, level, start, end, c.level, c.start, c.end) + } + } +} diff --git a/internal/sink/console/console.go b/internal/sink/console/console.go index cc28b60..99e4ebb 100644 --- a/internal/sink/console/console.go +++ b/internal/sink/console/console.go @@ -69,18 +69,14 @@ const ( DefaultConsoleColor = "auto" ) -// levelColors paints the first of a level's names in an entry, ANSI 16 so -// the terminal's theme picks the shades and a text console renders them. -// Names run longest first; cyan, not blue, stays readable on black. -var levelColors = map[string]struct { - names []string - style inline.Style -}{ - "ERROR": {[]string{"ERROR", "FATAL", "ERR"}, inline.FgANSI(color.ANSIRed).Bold()}, - "WARN": {[]string{"WARNING", "WARN"}, inline.FgANSI(color.ANSIYellow)}, - "INFO": {[]string{"INFO", "INF"}, inline.FgANSI(color.ANSIGreen)}, - "DEBUG": {[]string{"DEBUG", "DBG"}, inline.FgANSI(color.ANSICyan)}, - "TRACE": {[]string{"TRACE"}, inline.FgANSI(color.ANSIBrightBlack)}, +// levelStyles colors the levels in ANSI 16, so the terminal's theme picks the +// shades and a text console renders them; cyan, not blue, stays readable on black +var levelStyles = map[string]inline.Style{ + "ERROR": inline.FgANSI(color.ANSIRed).Bold(), + "WARN": inline.FgANSI(color.ANSIYellow), + "INFO": inline.FgANSI(color.ANSIGreen), + "DEBUG": inline.FgANSI(color.ANSICyan), + "TRACE": inline.FgANSI(color.ANSIBrightBlack), } // NewConsoleSinkPlugin creates a console sink through plugin factory @@ -273,42 +269,23 @@ func (cs *ConsoleSink) write(event core.TransportEvent) { cs.lastProcessed.Store(time.Now()) } -// paintLevel colors the first whole-word, case-insensitive occurrence of a -// name of level, after escaping: the escape sequences are the sink's own. +// paintLevel colors the first word naming the entry's level, after escaping: +// the escape sequences are the sink's own func paintLevel(p *inline.Printer, payload []byte, level string) []byte { - lc, ok := levelColors[level] + level, _, _ = core.LevelWord(level, "") + style, ok := levelStyles[level] if !ok { return payload } - for _, name := range lc.names { - for i := 0; i+len(name) <= len(payload); i++ { - j := i + len(name) - if !nameAt(payload[i:j], name) || i > 0 && word(payload[i-1]) || j < len(payload) && word(payload[j]) { - continue - } - out := make([]byte, 0, len(payload)+16) - out = append(out, payload[:i]...) - out = append(out, p.Paint(string(payload[i:j]), lc.style)...) - return append(out, payload[j:]...) - } + if _, i, j := core.LevelWord(payload, level); i < j { + out := make([]byte, 0, len(payload)+16) + out = append(out, payload[:i]...) + out = append(out, p.Paint(string(payload[i:j]), style)...) + return append(out, payload[j:]...) } return payload } -// nameAt compares ASCII letters without case; name is upper case -func nameAt(b []byte, name string) bool { - for k := range len(name) { - if c := b[k]; c != name[k] && c != name[k]+'a'-'A' { - return false - } - } - return true -} - -func word(c byte) bool { - return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' -} - // escapeControls writes what a terminal would act on or reorder (C0 and C1 // controls but tab, DEL, bidi controls, line separators, invalid UTF-8) as // , keeping the final newline: a log line cannot drive the terminal. diff --git a/internal/sink/console/console_test.go b/internal/sink/console/console_test.go index 25c0005..257fd0c 100644 --- a/internal/sink/console/console_test.go +++ b/internal/sink/console/console_test.go @@ -29,8 +29,8 @@ func TestEscapeControlsKeepsOnlyLayout(t *testing.T) { } } -// The entry's level is painted where its line first names it as a whole -// word, in any case, longest name first; other words and levels stay plain. +// The entry's level, under any of its names, is painted where its line first +// names it as a whole word, in any case; other words and levels stay plain. func TestPaintLevelColorsTheFirstWholeName(t *testing.T) { p := inline.New(io.Discard) p.SetColor(true) @@ -38,6 +38,7 @@ func TestPaintLevelColorsTheFirstWholeName(t *testing.T) { for _, c := range []struct{ in, level, want string }{ {"2026 ERROR boom ERROR\n", "ERROR", "2026 " + red + "ERROR\x1b[0m boom ERROR\n"}, {"[warning] disk\n", "WARN", "[" + yellow + "warning\x1b[0m] disk\n"}, + {"INFO: WARN disk\n", "warning", "INFO: " + yellow + "WARN\x1b[0m disk\n"}, {"terror_x ERR:1\n", "ERROR", "terror_x " + red + "ERR\x1b[0m:1\n"}, {"INFOS informal\n", "INFO", "INFOS informal\n"}, {"ERROR without level\n", "", "ERROR without level\n"}, diff --git a/internal/sink/http/http_test.go b/internal/sink/http/http_test.go index 6722512..118a490 100644 --- a/internal/sink/http/http_test.go +++ b/internal/sink/http/http_test.go @@ -220,7 +220,7 @@ func proxySink(t *testing.T, proxies []any, overrides map[string]any) *HTTPSink // Behind a proxy the sink serves the client library and the enabled pages // under /auth/, each with its type and the pages' CSP; the viewer learns a -// custom status path from its meta tag. +// custom status path and the level table from its meta tags. func TestProxyModeServesBrowserFiles(t *testing.T) { h := proxySink(t, []any{"127.0.0.1"}, map[string]any{"login_page": true, "viewer_page": true, "status_path": "/api/status"}) if !slices.Contains(h.Capabilities(), core.CapProxyTLS) { @@ -245,6 +245,9 @@ func TestProxyModeServesBrowserFiles(t *testing.T) { if file == "view" && !bytes.Contains(body, []byte(`content="api/status"`)) { t.Error("the viewer page does not carry the custom status path") } + if file == "view" && !bytes.Contains(body, []byte(`"WARNING"`)) { + t.Error("the viewer page does not carry the level table") + } } } diff --git a/internal/sink/http/web.go b/internal/sink/http/web.go index 7584f49..6e57156 100644 --- a/internal/sink/http/web.go +++ b/internal/sink/http/web.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "embed" + "encoding/json" "errors" "html" "net/http" @@ -13,6 +14,7 @@ import ( "github.com/lixenwraith/logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/config" + "github.com/lixenwraith/logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/netacl" ) @@ -49,7 +51,8 @@ var webRoutes = []struct { // webHandlers maps each GET path a browser uses to its handler. Without a // login (no auth, or mtls: the certificate is the login) the viewer is always // served; under scram the pages need proxy mode. The viewer learns the status -// path and whether it logs in from meta tags, as the CSP allows no inline script. +// path, whether it logs in and the level table from meta tags, as the CSP +// allows no inline script. func webHandlers(o *config.HTTPSinkOptions, p *authz.Policy) (map[string]http.Handler, error) { open := !p.NeedsLogin() on := map[string]bool{"": open || p.BehindProxy(), "login": o.LoginPage, "view": open || o.ViewerPage} @@ -68,6 +71,8 @@ func webHandlers(o *config.HTTPSinkOptions, p *authz.Policy) (map[string]http.Ha if open { data = bytes.Replace(data, []byte(`name="logwisp-login" content="scram"`), []byte(`name="logwisp-login" content="none"`), 1) } + levels, _ := json.Marshal(core.Levels) + data = bytes.Replace(data, []byte(`name="logwisp-levels" content=""`), []byte(`name="logwisp-levels" content="`+html.EscapeString(string(levels))+`"`), 1) } handlers[chain.AuthPath+"/"+r.name] = serveWebFile(data, webTypes[path.Ext(r.file)]) } diff --git a/internal/sink/http/web/style.css b/internal/sink/http/web/style.css index b06ec7a..73feee4 100644 --- a/internal/sink/http/web/style.css +++ b/internal/sink/http/web/style.css @@ -67,7 +67,8 @@ label { color: var(--muted); } -input { +input, +select { font: inherit; padding: 0.45rem 0.6rem; border: 1px solid var(--line); @@ -95,6 +96,7 @@ progress { .viewer header { display: flex; + flex-wrap: wrap; align-items: center; gap: 1rem; padding: 0.6rem 1rem; diff --git a/internal/sink/http/web/view.html b/internal/sink/http/web/view.html index ac3599a..2cb32d0 100644 --- a/internal/sink/http/web/view.html +++ b/internal/sink/http/web/view.html @@ -6,6 +6,7 @@ LogWisp stream + @@ -13,6 +14,8 @@

LogWisp

+ + connecting…
diff --git a/internal/sink/http/web/view.js b/internal/sink/http/web/view.js index 2f7fb89..8a7ea76 100644 --- a/internal/sink/http/web/view.js +++ b/internal/sink/http/web/view.js @@ -13,6 +13,13 @@ const signOut = document.getElementById("logout"); const form = document.getElementById("login"); const progress = document.getElementById("progress"); const status = document.getElementById("status"); +const search = document.getElementById("search"); +const lowest = document.getElementById("level"); +// The server's levels by rising severity, each with the words naming it +const levels = JSON.parse(meta("levels")); +const named = new Map(levels.flatMap(({ level, names }) => names.map((name) => [name, level]))); +const rank = new Map(levels.map(({ level }, i) => [level, i])); +for (const { level } of levels) lowest.add(new Option(`${level} and above`, level)); // A page without a login needs no WebCrypto, so plain http works too const unavailable = open ? "" : loginUnavailable(); @@ -27,10 +34,35 @@ if (fresh) history.replaceState(null, "", location.pathname + location.search); let stopStream = null; let timer = null; -function append(line) { +// An entry's level is its first word naming one, as the server reads a line +function levelOf(text) { + for (const [word] of text.matchAll(/\w+/g)) { + const level = named.get(word.toUpperCase()); + if (level) return level; + } + return ""; +} + +// A line shows when it contains the search, in any case, and its entry's level +// reaches the lowest chosen; with none chosen, lines without a level show too +function shown(row) { + const floor = rank.get(lowest.value); + if (floor !== undefined && !(rank.get(row.dataset.level) >= floor)) return false; + const text = search.value.trim().toLowerCase(); + return !text || row.textContent.toLowerCase().includes(text); +} + +function refilter() { + for (const row of log.children) row.hidden = !shown(row); + log.scrollTop = log.scrollHeight; +} + +function append(line, level) { const atBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 4; const row = document.createElement("div"); row.textContent = line; + row.dataset.level = level; + row.hidden = !shown(row); log.append(row); while (log.childElementCount > MAX_LINES) log.firstElementChild.remove(); if (atBottom) log.scrollTop = log.scrollHeight; @@ -73,7 +105,10 @@ function onEvent(type, data) { stopStream(); retry("server shut down"); } - if (type === "message") data.split("\n").forEach(append); + if (type === "message") { + const level = levelOf(data); + for (const line of data.split("\n")) append(line, level); + } } function streamWithCookie(url) { @@ -129,6 +164,9 @@ function retry(reason) { timer = setTimeout(connect, 5000); } +search.addEventListener("input", refilter); +lowest.addEventListener("change", refilter); + form.addEventListener("submit", async (event) => { event.preventDefault(); const fields = new FormData(form); diff --git a/internal/source/source.go b/internal/source/source.go index 516e93a..339548a 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -1,7 +1,6 @@ package source import ( - "strings" "time" "github.com/lixenwraith/logwisp/internal/core" @@ -36,27 +35,8 @@ type SourceStats struct { Details map[string]any } -// ExtractLogLevel heuristically determines the log level from a line of text +// ExtractLogLevel returns the level the first word naming one marks, or "" func ExtractLogLevel(line string) string { - patterns := []struct { - patterns []string - level string - }{ - {[]string{"[ERROR]", "ERROR:", " ERROR ", "ERR:", "[ERR]", "FATAL:", "[FATAL]"}, "ERROR"}, - {[]string{"[WARN]", "WARN:", " WARN ", "WARNING:", "[WARNING]"}, "WARN"}, - {[]string{"[INFO]", "INFO:", " INFO ", "[INF]", "INF:"}, "INFO"}, - {[]string{"[DEBUG]", "DEBUG:", " DEBUG ", "[DBG]", "DBG:"}, "DEBUG"}, - {[]string{"[TRACE]", "TRACE:", " TRACE "}, "TRACE"}, - } - - upperLine := strings.ToUpper(line) - for _, group := range patterns { - for _, pattern := range group.patterns { - if strings.Contains(upperLine, pattern) { - return group.level - } - } - } - - return "" + level, _, _ := core.LevelWord(line, "") + return level }