v0.26.3 web viewer improvement

This commit is contained in:
lixen committed 2026-10-05 03:33:24 -04:00
1 parent d745a128a2
commit c00c076d9b
14 files changed
+159 -85

No files matched your search

+2 -1
View File
@@ -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, - `Node`: origin node label for chained topologies; stamped at the first hop,
preserved by relays. preserved by relays.
- `Source`: origin identifier within the node (filename, plugin id, …). - `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. - `Message`: log content.
- `Fields`: optional structured metadata as raw JSON. - `Fields`: optional structured metadata as raw JSON.
- `RawSize`: original byte size, used by the entry-size cap. - `RawSize`: original byte size, used by the entry-size cap.
+7 -5
View File
@@ -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. characters, such as the joiners emoji and Indic scripts need, are kept.
`never` keeps an application's own ANSI colours; under `auto`, pipes and `never` keeps an application's own ANSI colours; under `auto`, pipes and
files get the bytes unchanged. files get the bytes unchanged.
- Color: the first whole word in a line, in any case, that names the entry's - Color: the first word in a line naming the entry's level, by the names the
level is painted: `DEBUG`/`DBG` cyan (blue is hard to read on black), [sources](sources.md#file) read, is painted: `DEBUG` cyan (blue is hard to
`INFO`/`INF` green, `WARNING`/`WARN` yellow, `ERROR`/`ERR`/`FATAL` bold red, read on black), `INFO` green, `WARN` yellow, `ERROR` bold red, `TRACE`
`TRACE` grey. The colors are ANSI 16, so the terminal's theme picks the 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 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 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: 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; to: `/` is refused like any other path;
- an endpoint at `/` keeps the root, and the viewer stays at `/auth/view`. - 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 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 - Refusals are logged at WARN and counted in `auth_rejected`. The authorized
identity is recorded in the client's session as `auth_method` / identity is recorded in the client's session as `auth_method` /
`auth_identity`. `auth_identity`.
+5 -2
View File
@@ -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 - 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 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, 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 is kept whole as text, because parsing it would drop the rest.
(`[ERROR]`, `WARN:`, and so on), 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, - `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 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 level is inferred from the text as for any unparsed line. Paired with
+3 -7
View File
@@ -65,11 +65,7 @@ identity, and both skeletons download the tagged source.
Smaller items found along the way, each independent of the others. Smaller items found along the way, each independent of the others.
1. Level detection. `source.ExtractLogLevel` needs a delimiter after the name 1. Command line:
(`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:
- a positional word at the top level (`lw tail`, `lw -t FILE`) still reaches - 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 config with an unclear message; name it ("unexpected argument X; the file
is -c 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 - `lw tls cert --host` could become `--hosts` (keeping `--host`), to match
`tls.hosts` and the preset key; `tls.hosts` and the preset key;
- top-level usage errors exit 1, subcommand ones 2. - 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 `self_signed` listener also matches an `issuer` listener of the same
process; document it or key per listener. Pins are checked in process; document it or key per listener. Pins are checked in
`VerifyPeerCertificate`, which Go skips on resumption: guard it if a dialer `VerifyPeerCertificate`, which Go skips on resumption: guard it if a dialer
ever keeps a `ClientSessionCache`. ever keeps a `ClientSessionCache`.
4. `core.ShutdownTimeout` is unused. 3. `core.ShutdownTimeout` is unused.
+39
View File
@@ -2,6 +2,7 @@ package core
import ( import (
"encoding/json" "encoding/json"
"strings"
"time" "time"
) )
@@ -24,3 +25,41 @@ type TransportEvent struct {
// Structured entry for re-serializing sinks (chain links). Zero Time => absent // Structured entry for re-serializing sinks (chain links). Zero Time => absent
Entry LogEntry 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 == '_'
}
+24
View File
@@ -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)
}
}
}
+17 -40
View File
@@ -69,18 +69,14 @@ const (
DefaultConsoleColor = "auto" DefaultConsoleColor = "auto"
) )
// levelColors paints the first of a level's names in an entry, ANSI 16 so // levelStyles colors the levels in ANSI 16, so the terminal's theme picks the
// the terminal's theme picks the shades and a text console renders them. // shades and a text console renders them; cyan, not blue, stays readable on black
// Names run longest first; cyan, not blue, stays readable on black. var levelStyles = map[string]inline.Style{
var levelColors = map[string]struct { "ERROR": inline.FgANSI(color.ANSIRed).Bold(),
names []string "WARN": inline.FgANSI(color.ANSIYellow),
style inline.Style "INFO": inline.FgANSI(color.ANSIGreen),
}{ "DEBUG": inline.FgANSI(color.ANSICyan),
"ERROR": {[]string{"ERROR", "FATAL", "ERR"}, inline.FgANSI(color.ANSIRed).Bold()}, "TRACE": inline.FgANSI(color.ANSIBrightBlack),
"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)},
} }
// NewConsoleSinkPlugin creates a console sink through plugin factory // NewConsoleSinkPlugin creates a console sink through plugin factory
@@ -273,42 +269,23 @@ func (cs *ConsoleSink) write(event core.TransportEvent) {
cs.lastProcessed.Store(time.Now()) cs.lastProcessed.Store(time.Now())
} }
// paintLevel colors the first whole-word, case-insensitive occurrence of a // paintLevel colors the first word naming the entry's level, after escaping:
// name of level, after escaping: the escape sequences are the sink's own. // the escape sequences are the sink's own
func paintLevel(p *inline.Printer, payload []byte, level string) []byte { func paintLevel(p *inline.Printer, payload []byte, level string) []byte {
lc, ok := levelColors[level] level, _, _ = core.LevelWord(level, "")
style, ok := levelStyles[level]
if !ok { if !ok {
return payload return payload
} }
for _, name := range lc.names { if _, i, j := core.LevelWord(payload, level); i < j {
for i := 0; i+len(name) <= len(payload); i++ { out := make([]byte, 0, len(payload)+16)
j := i + len(name) out = append(out, payload[:i]...)
if !nameAt(payload[i:j], name) || i > 0 && word(payload[i-1]) || j < len(payload) && word(payload[j]) { out = append(out, p.Paint(string(payload[i:j]), style)...)
continue return append(out, payload[j:]...)
}
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:]...)
}
} }
return payload 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 // 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 // controls but tab, DEL, bidi controls, line separators, invalid UTF-8) as
// <hex>, keeping the final newline: a log line cannot drive the terminal. // <hex>, keeping the final newline: a log line cannot drive the terminal.
+3 -2
View File
@@ -29,8 +29,8 @@ func TestEscapeControlsKeepsOnlyLayout(t *testing.T) {
} }
} }
// The entry's level is painted where its line first names it as a whole // The entry's level, under any of its names, is painted where its line first
// word, in any case, longest name first; other words and levels stay plain. // names it as a whole word, in any case; other words and levels stay plain.
func TestPaintLevelColorsTheFirstWholeName(t *testing.T) { func TestPaintLevelColorsTheFirstWholeName(t *testing.T) {
p := inline.New(io.Discard) p := inline.New(io.Discard)
p.SetColor(true) p.SetColor(true)
@@ -38,6 +38,7 @@ func TestPaintLevelColorsTheFirstWholeName(t *testing.T) {
for _, c := range []struct{ in, level, want string }{ for _, c := range []struct{ in, level, want string }{
{"2026 ERROR boom ERROR\n", "ERROR", "2026 " + red + "ERROR\x1b[0m boom ERROR\n"}, {"2026 ERROR boom ERROR\n", "ERROR", "2026 " + red + "ERROR\x1b[0m boom ERROR\n"},
{"[warning] disk\n", "WARN", "[" + yellow + "warning\x1b[0m] disk\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"}, {"terror_x ERR:1\n", "ERROR", "terror_x " + red + "ERR\x1b[0m:1\n"},
{"INFOS informal\n", "INFO", "INFOS informal\n"}, {"INFOS informal\n", "INFO", "INFOS informal\n"},
{"ERROR without level\n", "", "ERROR without level\n"}, {"ERROR without level\n", "", "ERROR without level\n"},
+4 -1
View File
@@ -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 // 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 // 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) { 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"}) 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) { 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"`)) { if file == "view" && !bytes.Contains(body, []byte(`content="api/status"`)) {
t.Error("the viewer page does not carry the custom status path") t.Error("the viewer page does not carry the custom status path")
} }
if file == "view" && !bytes.Contains(body, []byte(`&#34;WARNING&#34;`)) {
t.Error("the viewer page does not carry the level table")
}
} }
} }
+6 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"embed" "embed"
"encoding/json"
"errors" "errors"
"html" "html"
"net/http" "net/http"
@@ -13,6 +14,7 @@ import (
"github.com/lixenwraith/logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/authz"
"github.com/lixenwraith/logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/netacl" "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 // 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 // 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 // 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) { func webHandlers(o *config.HTTPSinkOptions, p *authz.Policy) (map[string]http.Handler, error) {
open := !p.NeedsLogin() open := !p.NeedsLogin()
on := map[string]bool{"": open || p.BehindProxy(), "login": o.LoginPage, "view": open || o.ViewerPage} 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 { if open {
data = bytes.Replace(data, []byte(`name="logwisp-login" content="scram"`), []byte(`name="logwisp-login" content="none"`), 1) 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)]) handlers[chain.AuthPath+"/"+r.name] = serveWebFile(data, webTypes[path.Ext(r.file)])
} }
+3 -1
View File
@@ -67,7 +67,8 @@ label {
color: var(--muted); color: var(--muted);
} }
input { input,
select {
font: inherit; font: inherit;
padding: 0.45rem 0.6rem; padding: 0.45rem 0.6rem;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -95,6 +96,7 @@ progress {
.viewer header { .viewer header {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
+3
View File
@@ -6,6 +6,7 @@
<title>LogWisp stream</title> <title>LogWisp stream</title>
<meta name="logwisp-status" content="status"> <meta name="logwisp-status" content="status">
<meta name="logwisp-login" content="scram"> <meta name="logwisp-login" content="scram">
<meta name="logwisp-levels" content="">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<link rel="icon" href="favicon.svg" type="image/svg+xml"> <link rel="icon" href="favicon.svg" type="image/svg+xml">
<script type="module" src="view.js"></script> <script type="module" src="view.js"></script>
@@ -13,6 +14,8 @@
<body class="viewer"> <body class="viewer">
<header> <header>
<h1>LogWisp</h1> <h1>LogWisp</h1>
<input id="search" type="search" placeholder="Search" aria-label="Show lines containing">
<select id="level" aria-label="Lowest level shown"><option value="">All lines</option></select>
<span id="state" role="status">connecting…</span> <span id="state" role="status">connecting…</span>
<button id="logout" type="button">Sign out</button> <button id="logout" type="button">Sign out</button>
</header> </header>
+40 -2
View File
@@ -13,6 +13,13 @@ const signOut = document.getElementById("logout");
const form = document.getElementById("login"); const form = document.getElementById("login");
const progress = document.getElementById("progress"); const progress = document.getElementById("progress");
const status = document.getElementById("status"); 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 // A page without a login needs no WebCrypto, so plain http works too
const unavailable = open ? "" : loginUnavailable(); const unavailable = open ? "" : loginUnavailable();
@@ -27,10 +34,35 @@ if (fresh) history.replaceState(null, "", location.pathname + location.search);
let stopStream = null; let stopStream = null;
let timer = 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 atBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 4;
const row = document.createElement("div"); const row = document.createElement("div");
row.textContent = line; row.textContent = line;
row.dataset.level = level;
row.hidden = !shown(row);
log.append(row); log.append(row);
while (log.childElementCount > MAX_LINES) log.firstElementChild.remove(); while (log.childElementCount > MAX_LINES) log.firstElementChild.remove();
if (atBottom) log.scrollTop = log.scrollHeight; if (atBottom) log.scrollTop = log.scrollHeight;
@@ -73,7 +105,10 @@ function onEvent(type, data) {
stopStream(); stopStream();
retry("server shut down"); 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) { function streamWithCookie(url) {
@@ -129,6 +164,9 @@ function retry(reason) {
timer = setTimeout(connect, 5000); timer = setTimeout(connect, 5000);
} }
search.addEventListener("input", refilter);
lowest.addEventListener("change", refilter);
form.addEventListener("submit", async (event) => { form.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const fields = new FormData(form); const fields = new FormData(form);
+3 -23
View File
@@ -1,7 +1,6 @@
package source package source
import ( import (
"strings"
"time" "time"
"github.com/lixenwraith/logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/core"
@@ -36,27 +35,8 @@ type SourceStats struct {
Details map[string]any 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 { func ExtractLogLevel(line string) string {
patterns := []struct { level, _, _ := core.LevelWord(line, "")
patterns []string return level
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 ""
} }