v0.26.3 web viewer improvement
This commit is contained in:
1 parent
d745a128a2
commit
c00c076d9b
14 files changed
+156
-82
No files matched your search
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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 == '_'
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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++ {
|
|
||||||
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 := make([]byte, 0, len(payload)+16)
|
||||||
out = append(out, payload[:i]...)
|
out = append(out, payload[:i]...)
|
||||||
out = append(out, p.Paint(string(payload[i:j]), lc.style)...)
|
out = append(out, p.Paint(string(payload[i:j]), style)...)
|
||||||
return append(out, payload[j:]...)
|
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.
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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(`"WARNING"`)) {
|
||||||
|
t.Error("the viewer page does not carry the level table")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 ""
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user