From b1146cbb31104e4a8a96fbf2117d965c7165bd23 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 13 Jun 2026 14:49:32 +0200 Subject: [PATCH] webui: add Maintenance > Logs viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Logs page under Maintenance presents the on-disk syslog files (messages, syslog, kern, auth, routing, firewall, upgrade, debug, container, mail) with a tab per source and an (empty) chip for files with no content. The buffer loads the last 250 lines and pins to the bottom on open and on tab change, with keyboard focus on the pane so PageUp/PageDown work immediately. Load earlier pages back 250 lines at a time, preserving the reader's scroll position as older lines fill in above; once the current file is exhausted the button re-labels to "Load previous from " and walks back through rotated and gzipped archives. Live tail streams new entries over SSE. The server runs tail -F -n 0 and debounces output; the client follows the bottom unless the reader has scrolled up. A status pill shows connecting/live/disconnected. nginx gets a no-buffering location for the tail stream, mirroring the software progress endpoint. Download returns the full archive — current file plus every rotated and gzipped predecessor — concatenated chronologically. Severity is coloured by a keyword heuristic since sysklogd writes no to disk; BSD-format lines are shown unconditionally. Filter is client-side substring or /regex/, focusable with Ctrl/Cmd-F. Signed-off-by: Joachim Wiberg --- package/webui/webui.conf | 11 + src/webui/internal/handlers/common.go | 2 +- src/webui/internal/handlers/logs.go | 556 +++++++++++++++++++++++ src/webui/internal/server/server.go | 10 + src/webui/static/css/style.css | 169 +++++++ src/webui/static/img/icons/README.md | 2 + src/webui/static/img/icons/download.svg | 1 + src/webui/static/img/icons/logs.svg | 6 + src/webui/static/js/app.js | 269 ++++++++++- src/webui/templates/layouts/sidebar.html | 12 +- src/webui/templates/pages/logs.html | 90 ++++ 11 files changed, 1125 insertions(+), 3 deletions(-) create mode 100644 src/webui/internal/handlers/logs.go create mode 100644 src/webui/static/img/icons/download.svg create mode 100644 src/webui/static/img/icons/logs.svg create mode 100644 src/webui/templates/pages/logs.html diff --git a/package/webui/webui.conf b/package/webui/webui.conf index d6c44654..6723b92d 100644 --- a/package/webui/webui.conf +++ b/package/webui/webui.conf @@ -34,6 +34,17 @@ location = /software/progress { include /etc/nginx/webui-proxy.conf; } +# SSE live-tail stream for Maintenance > Logs. Same buffering / timeout +# concerns as the software progress stream: nginx must not buffer the +# event frames, and the connection has to stay open through quiet logs. +# The Go side sends a 15 s heartbeat, so 600 s of read timeout is plenty +# of safety margin. +location ~ ^/maintenance/logs/[^/]+/tail$ { + proxy_read_timeout 600s; + proxy_buffering off; + include /etc/nginx/webui-proxy.conf; +} + # Liveness probe — nginx-only, no upstream call. Used by the watchdog # div in base.html and the reboot-overlay poller. location = /device-status { diff --git a/src/webui/internal/handlers/common.go b/src/webui/internal/handlers/common.go index cc024852..3699a8c1 100644 --- a/src/webui/internal/handlers/common.go +++ b/src/webui/internal/handlers/common.go @@ -38,7 +38,7 @@ func csrfToken(ctx context.Context) string { // where it lives in the sidebar. func pageContext(page string) string { switch page { - case "software", "backup", "system-control": + case "software", "logs", "backup", "system-control": return "Maintenance" } if strings.HasPrefix(page, "configure-") { diff --git a/src/webui/internal/handlers/logs.go b/src/webui/internal/handlers/logs.go new file mode 100644 index 00000000..740c4dc6 --- /dev/null +++ b/src/webui/internal/handlers/logs.go @@ -0,0 +1,556 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "bufio" + "compress/gzip" + "context" + "fmt" + "html/template" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// LogsHandler serves the Maintenance > Logs page. Reads from a fixed +// allow-list of /var/log files; the file path is never user-controlled, +// so no traversal risk. Live tail spawns `tail -F` whose lifetime is +// tied to the SSE request context — client disconnect kills the child. +type LogsHandler struct { + Template *template.Template +} + +// ─── Source allow-list ─────────────────────────────────────────────── + +type logSource struct { + Key string + Label string + Path string +} + +var logSources = []logSource{ + {"messages", "Messages", "/var/log/messages"}, + {"syslog", "Syslog", "/var/log/syslog"}, + {"kern", "Kernel", "/var/log/kern.log"}, + {"auth", "Auth", "/var/log/auth.log"}, + {"routing", "Routing", "/var/log/routing"}, + {"firewall", "Firewall", "/var/log/firewall.log"}, + {"upgrade", "Upgrade", "/var/log/upgrade.log"}, + {"debug", "Debug", "/var/log/debug"}, + {"container", "Container", "/var/log/container"}, + {"mail", "Mail", "/var/log/mail.log"}, +} + +const ( + initialLines = 250 + earlierLines = 250 + tailFlushDelay = 200 * time.Millisecond + tailMaxBatch = 50 + tailHeartbeatMs = 15 * time.Second +) + +func lookupSource(key string) (logSource, bool) { + for _, s := range logSources { + if s.Key == key { + return s, true + } + } + return logSource{}, false +} + +// ─── Template data ─────────────────────────────────────────────────── + +type logsPageData struct { + PageData + Sources []sourceMeta + Active string + Source sourceContent +} + +type sourceMeta struct { + Key string + Label string + Path string + Empty bool +} + +// loadCursor describes the next Load earlier / Load previous action. +// File is "current" while still paging within the active log file, or +// "rot0", "rot1", … once the user has crossed into older rotations. +// FileLabel is the rotation file's basename, shown on the button when +// IsPrev is true. A nil cursor means "no more older content." +type loadCursor struct { + File string + Skip int + FileLabel string + IsPrev bool +} + +type sourceContent struct { + sourceMeta + Lines []logLine + Next *loadCursor // nil when no older content remains +} + +type logLine struct { + Text string + Class string +} + +// earlierData mirrors sourceContent's field names for the logs-earlier +// template so the same fragment renders both the initial buffer and +// the Earlier-handler response. +type earlierData struct { + Key string + Lines []logLine + Next *loadCursor +} + +// ─── Classification ────────────────────────────────────────────────── + +func classify(text string) string { + lower := strings.ToLower(text) + for _, kw := range []string{"panic", "kernel: bug", "fatal", "error", "fail", "crit"} { + if strings.Contains(lower, kw) { + return "err" + } + } + for _, kw := range []string{"warning", "warn:", "warn ", "deprecated"} { + if strings.Contains(lower, kw) { + return "warn" + } + } + return "" +} + +func classifyLines(raw []string) []logLine { + out := make([]logLine, len(raw)) + for i, t := range raw { + out[i] = logLine{Text: t, Class: classify(t)} + } + return out +} + +// ─── File reading ──────────────────────────────────────────────────── + +func openReader(path string) (io.Reader, func(), error) { + f, err := os.Open(path) + if err != nil { + return nil, nil, err + } + if strings.HasSuffix(path, ".gz") { + zr, err := gzip.NewReader(f) + if err != nil { + f.Close() + return nil, nil, err + } + return zr, func() { zr.Close(); f.Close() }, nil + } + return f, func() { f.Close() }, nil +} + +func readAllLines(path string) ([]string, error) { + r, done, err := openReader(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer done() + + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 256*1024), 1024*1024) + + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines, scanner.Err() +} + +// rotationsFor returns rotation files for a source, newest first. The +// current file is NOT included. Suffix order matches logrotate +// convention: .0 = most recent rotation, .1 / .1.gz next, and so on. +// Files that don't match the numeric suffix pattern are ignored. +func rotationsFor(path string) []string { + matches, err := filepath.Glob(path + ".*") + if err != nil { + return nil + } + type rot struct { + Path string + N int + } + var rots []rot + for _, m := range matches { + rest := strings.TrimPrefix(m, path+".") + rest = strings.TrimSuffix(rest, ".gz") + if n, err := strconv.Atoi(rest); err == nil { + rots = append(rots, rot{Path: m, N: n}) + } + } + sort.Slice(rots, func(i, j int) bool { return rots[i].N < rots[j].N }) + + out := make([]string, len(rots)) + for i, r := range rots { + out[i] = r.Path + } + return out +} + +// resolveCursor maps a cursor's File field to the actual filesystem +// path. Returns the empty string if the cursor points past the end of +// available history (e.g. "rot3" on a source with only two rotations). +func resolveCursor(src logSource, file string) string { + if file == "" || file == "current" { + return src.Path + } + if !strings.HasPrefix(file, "rot") { + return "" + } + idx, err := strconv.Atoi(strings.TrimPrefix(file, "rot")) + if err != nil || idx < 0 { + return "" + } + rots := rotationsFor(src.Path) + if idx >= len(rots) { + return "" + } + return rots[idx] +} + +// nextCursor computes the cursor for the click AFTER the one we're +// about to serve. `file` is the file just read; `skip+returned` is +// where in that file the next paging click would resume. When the +// file is exhausted we cross into the next older rotation; when no +// more rotations exist, returns nil. +func nextCursor(src logSource, file string, skip, returned, total int) *loadCursor { + nextSkip := skip + returned + if nextSkip < total { + // Same file — continuing back through it. + c := &loadCursor{File: file, Skip: nextSkip} + if file != "current" { + c.IsPrev = true + c.FileLabel = filepath.Base(resolveCursor(src, file)) + } + return c + } + + // Current/this file exhausted. Cross into next older rotation. + var nextIdx int + if file == "current" { + nextIdx = 0 + } else { + idx, _ := strconv.Atoi(strings.TrimPrefix(file, "rot")) + nextIdx = idx + 1 + } + rots := rotationsFor(src.Path) + if nextIdx >= len(rots) { + return nil + } + return &loadCursor{ + File: "rot" + strconv.Itoa(nextIdx), + Skip: 0, + FileLabel: filepath.Base(rots[nextIdx]), + IsPrev: true, + } +} + +// readFileWindow returns up to `limit` lines from the end of `path`, +// skipping the last `skip` lines, plus the file's total line count. +func readFileWindow(path string, skip, limit int) (lines []string, total int, err error) { + all, err := readAllLines(path) + if err != nil { + return nil, 0, err + } + total = len(all) + end := total - skip + if end < 0 { + end = 0 + } + start := end - limit + if start < 0 { + start = 0 + } + return all[start:end], total, nil +} + +func sourceMetas() []sourceMeta { + out := make([]sourceMeta, 0, len(logSources)) + for _, s := range logSources { + empty := true + if info, err := os.Stat(s.Path); err == nil { + empty = info.Size() == 0 + } + out = append(out, sourceMeta{Key: s.Key, Label: s.Label, Path: s.Path, Empty: empty}) + } + return out +} + +func buildContent(src logSource) sourceContent { + lines, total, err := readFileWindow(src.Path, 0, initialLines) + if err != nil { + log.Printf("logs read %s: %v", src.Key, err) + } + return sourceContent{ + sourceMeta: sourceMeta{Key: src.Key, Label: src.Label, Path: src.Path, Empty: total == 0}, + Lines: classifyLines(lines), + Next: nextCursor(src, "current", 0, len(lines), total), + } +} + +// ─── Handlers ──────────────────────────────────────────────────────── + +func (h *LogsHandler) Overview(w http.ResponseWriter, r *http.Request) { + src, _ := lookupSource("messages") + data := logsPageData{ + PageData: newPageData(w, r, "logs", "Logs"), + Sources: sourceMetas(), + Active: "messages", + Source: buildContent(src), + } + tmplName := "logs.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +func (h *LogsHandler) Fragment(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("name") + src, ok := lookupSource(key) + if !ok { + http.NotFound(w, r) + return + } + data := logsPageData{ + Sources: sourceMetas(), + Active: key, + Source: buildContent(src), + } + if err := h.Template.ExecuteTemplate(w, "logs-card", data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// Earlier returns the next page of older lines plus the button to load +// the page after that. The button does an outerHTML swap on itself so +// the OLD button is replaced by [NEW button, NEW lines], keeping +// chronological order in the DOM. +// GET /maintenance/logs/{name}/earlier?file=current|rot0|…&skip=N +func (h *LogsHandler) Earlier(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("name") + src, ok := lookupSource(key) + if !ok { + http.NotFound(w, r) + return + } + file := r.URL.Query().Get("file") + if file == "" { + file = "current" + } + path := resolveCursor(src, file) + if path == "" { + http.NotFound(w, r) + return + } + skip, _ := strconv.Atoi(r.URL.Query().Get("skip")) + + lines, total, err := readFileWindow(path, skip, earlierLines) + if err != nil { + log.Printf("logs earlier %s: %v", path, err) + } + data := earlierData{ + Key: key, + Lines: classifyLines(lines), + Next: nextCursor(src, file, skip, len(lines), total), + } + if err := h.Template.ExecuteTemplate(w, "logs-earlier", data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// Tail streams new lines as Server-Sent Events. `tail -F -n 0` +// handles rotation reopens and starts from "now" — the client already +// has the existing buffer rendered. Lines are debounced and emitted +// in HTML fragments matching the regular logs-line markup, so the +// client just appends. +// GET /maintenance/logs/{name}/tail +func (h *LogsHandler) Tail(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("name") + src, ok := lookupSource(key) + if !ok { + http.NotFound(w, r) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + // Send an immediate comment so the response body has a few bytes on + // the wire right away. Some intermediate proxies hold the headers + // until they see enough body content to commit to a buffering + // strategy; without this nudge, EventSource onopen can wait many + // seconds before firing on the client side. + fmt.Fprint(w, ": stream opened\n\n") + flusher.Flush() + + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + cmd := exec.CommandContext(ctx, "tail", "-F", "-n", "0", src.Path) + stdout, err := cmd.StdoutPipe() + if err != nil { + log.Printf("logs tail stdout %s: %v", key, err) + return + } + if err := cmd.Start(); err != nil { + log.Printf("logs tail start %s: %v", key, err) + return + } + // Without this Wait, every client reconnect leaves the killed tail + // child as a zombie until webui itself exits — the SSE auto-reconnect + // cycle accumulated 50+ tails in a few minutes of idle. + // cmd.Wait blocks until the process is reaped; CommandContext SIGKILLs + // it on ctx cancel, so Wait returns "signal: killed" which we ignore. + defer func() { _ = cmd.Wait() }() + + linesCh := make(chan string, 200) + go func() { + defer close(linesCh) + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 256*1024), 1024*1024) + for scanner.Scan() { + select { + case linesCh <- scanner.Text(): + case <-ctx.Done(): + return + } + } + }() + + flush := func(pending []string) { + if len(pending) == 0 { + return + } + var buf strings.Builder + for _, text := range pending { + cls := classify(text) + if cls != "" { + fmt.Fprintf(&buf, `
%s
`, cls, template.HTMLEscapeString(text)) + } else { + fmt.Fprintf(&buf, `
%s
`, template.HTMLEscapeString(text)) + } + } + payload := strings.ReplaceAll(buf.String(), "\n", " ") + fmt.Fprintf(w, "event: lines\ndata: %s\n\n", payload) + flusher.Flush() + } + + heartbeat := time.NewTicker(tailHeartbeatMs) + defer heartbeat.Stop() + + var pending []string + var batchTimer *time.Timer + var batchC <-chan time.Time + + for { + select { + case <-ctx.Done(): + return + case text, ok := <-linesCh: + if !ok { + flush(pending) + return + } + pending = append(pending, text) + if len(pending) >= tailMaxBatch { + if batchTimer != nil { + batchTimer.Stop() + batchTimer = nil + batchC = nil + } + flush(pending) + pending = pending[:0] + } else if batchTimer == nil { + batchTimer = time.NewTimer(tailFlushDelay) + batchC = batchTimer.C + } + case <-batchC: + batchTimer = nil + batchC = nil + flush(pending) + pending = pending[:0] + case <-heartbeat.C: + fmt.Fprint(w, ": keep-alive\n\n") + flusher.Flush() + } + } +} + +// Download streams the full archive for a source as a single +// text/plain attachment — every rotation file (oldest first, gunzipped +// on the fly) followed by the current file. Users running this from a +// web browser want one click and a complete chronological log; if they +// need just the current file there's always `cat /var/log/X` over SSH. +// GET /maintenance/logs/{name}/download +func (h *LogsHandler) Download(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("name") + src, ok := lookupSource(key) + if !ok { + http.NotFound(w, r) + return + } + + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "device" + } + ts := time.Now().UTC().Format("20060102-1504") + fname := fmt.Sprintf("%s-%s-%s.log", key, hostname, ts) + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, fname)) + + // Concatenate oldest-first: highest-N rotation down through .0, then + // current. rotationsFor returns newest-first so iterate in reverse. + rots := rotationsFor(src.Path) + for i := len(rots) - 1; i >= 0; i-- { + streamFile(w, rots[i]) + } + streamFile(w, src.Path) +} + +func streamFile(w io.Writer, path string) { + rd, done, err := openReader(path) + if err != nil { + if !os.IsNotExist(err) { + log.Printf("logs stream %s: %v", path, err) + } + return + } + defer done() + io.Copy(w, rd) +} diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index f4d24a6c..9d544ac3 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -65,6 +65,10 @@ func New( if err != nil { return nil, err } + logsTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/logs.html") + if err != nil { + return nil, err + } routingTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/routing.html") if err != nil { return nil, err @@ -235,6 +239,7 @@ func New( SysCtrlTmpl: sysCtrlTmpl, BackupTmpl: backupTmpl, } + logs := &handlers.LogsHandler{Template: logsTmpl} routing := &handlers.RoutingHandler{Template: routingTmpl, RC: rc} wifi := &handlers.WiFiHandler{Template: wifiTmpl, RC: rc} @@ -300,6 +305,11 @@ func New( mux.HandleFunc("POST /software/boot-order", sys.SetBootOrder) mux.HandleFunc("POST /reboot", sys.Reboot) // kept for software page "Reboot to activate" mux.HandleFunc("GET /config", sys.DownloadConfig) + mux.HandleFunc("GET /maintenance/logs", logs.Overview) + mux.HandleFunc("GET /maintenance/logs/{name}", logs.Fragment) + mux.HandleFunc("GET /maintenance/logs/{name}/earlier", logs.Earlier) + mux.HandleFunc("GET /maintenance/logs/{name}/tail", logs.Tail) + mux.HandleFunc("GET /maintenance/logs/{name}/download", logs.Download) mux.HandleFunc("GET /maintenance/backup", sys.Backup) mux.HandleFunc("POST /maintenance/backup/restore", sys.RestoreConfig) mux.HandleFunc("GET /maintenance/system", sys.SystemControl) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 9b68bdc8..7091dd3e 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -1452,6 +1452,175 @@ details[open].nav-group-top > summary.nav-group-summary-top::before { border-left: 3px solid var(--primary); } +/* Logs viewer. Tab strip selects the source file, content area is a + monospaced log buffer with client-side text/regex filter and best-effort + severity colouring (heuristic, since on-disk sysklogd doesn't store + ). Live tail, rotations and download wire up in the next step. */ +/* On the logs page, #content holds only the logs card — turn it into a + flex column and clip its own overflow so the card fills the available + height and the *buffer* scrolls, not the page. :has scopes this to + the logs page without touching any other view. */ +#content:has(.logs-card) { + display: flex; + flex-direction: column; + overflow: hidden; +} +.logs-card { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + margin-bottom: 0; /* no trailing gap when the card fills #content */ +} +.logs-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.logs-tab { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.3rem 0.75rem; + border: 1px solid var(--border); + border-radius: 4px; + background: transparent; + color: var(--fg-muted); + font-size: 0.85rem; + cursor: pointer; + transition: background 0.1s, color 0.1s, border-color 0.1s; +} +.logs-tab:hover { color: var(--fg); border-color: var(--fg-muted); } +.logs-tab.active { + background: var(--primary); + color: white; + border-color: var(--primary); +} +.logs-tab-empty { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.6; +} +.logs-body { + display: flex; + flex-direction: column; + flex: 1; /* fill the card below the tabs so .logs-content can grow */ + min-height: 0; + padding: 0.75rem 1rem; + gap: 0.6rem; +} +.logs-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; +} +.logs-toolbar .cfg-input { flex: 1; min-width: 14rem; } +.logs-toggle { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.85rem; + color: var(--fg-muted); + cursor: pointer; + user-select: none; +} +.logs-toggle input[type="checkbox"]:disabled + * { opacity: 0.5; } +.logs-disabled-link { + pointer-events: none; + opacity: 0.5; +} +.logs-content { + font-family: var(--font-mono); + font-size: 0.78rem; + line-height: 1.35; + background: var(--surface-deep, var(--surface)); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.5rem 0.75rem; + /* Height is governed by the flex chain below (#content:has(.logs-card) + → .logs-card → .logs-body → here), not a viewport calc — so the + buffer fills exactly the space left by the card chrome and #content + never grows its own scrollbar. min-height:0 lets this flex item + shrink below its content size so overflow stays internal. */ + flex: 1; + min-height: 0; + overflow: auto; + /* No `white-space: pre` here — the rule belongs on `.logs-line` only, + because the parent's trailing newline text node (left over from the + range template) would otherwise render as a blank line above any + JS-appended live-tail content. */ +} +.logs-load-earlier { + display: block; + margin: 0 auto 0.5rem; +} +.logs-download { + display: inline-flex; + align-items: center; + gap: 0.35rem; + text-decoration: none; +} +.logs-download .nav-icon { + width: 0.9rem; + height: 0.9rem; +} +.logs-no-more { + display: block; + text-align: center; + font-size: 0.78rem; + color: var(--fg-muted); + font-family: var(--font-body); + padding: 0.25rem 0 0.5rem; +} +.logs-tail-status { + font-size: 0.72rem; + font-family: var(--font-body); + color: var(--fg-muted); + margin-left: 0.4rem; + letter-spacing: 0.02em; +} +.logs-tail-status:not(:empty)::before { + content: "●"; + margin-right: 0.25rem; + font-size: 0.85em; +} +.logs-tail-status.pending::before { color: var(--warning, #c97a00); animation: logs-tail-pulse 1s ease-in-out infinite; } +.logs-tail-status.live::before { color: var(--success, #2c8a4a); animation: logs-tail-pulse 1.6s ease-in-out infinite; } +.logs-tail-status.err::before { color: var(--danger, #b03030); } +@keyframes logs-tail-pulse { + 0%, 100% { opacity: 0.45; } + 50% { opacity: 1; } +} +.logs-line { + white-space: pre; + border-left: 3px solid transparent; + padding: 0 0 0 0.4rem; +} +.logs-line.logs-err { + color: var(--danger); + border-left-color: var(--danger); + background: color-mix(in srgb, var(--danger) 7%, transparent); +} +.logs-line.logs-warn { + color: var(--warning); + border-left-color: var(--warning); + background: color-mix(in srgb, var(--warning) 6%, transparent); +} +.logs-status { + font-size: 0.8rem; + color: var(--fg-muted); +} +.logs-status code { + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--fg); +} + /* Software & Reboot */ .sw-install-grid { display: grid; diff --git a/src/webui/static/img/icons/README.md b/src/webui/static/img/icons/README.md index 06861d18..9db3df68 100644 --- a/src/webui/static/img/icons/README.md +++ b/src/webui/static/img/icons/README.md @@ -18,11 +18,13 @@ untouched. | dashboard.svg | circle-gauge | | dhcp.svg | network | | dns.svg | globe | +| download.svg | download | | firewall.svg | shield-check | | hardware.svg | cpu | | interfaces.svg | ethernet-port | | keystore.svg | key-round | | lldp.svg | radio-tower | +| logs.svg | scroll-text | | mdns.svg | radio | | nacm.svg | users | | ntp.svg | clock | diff --git a/src/webui/static/img/icons/download.svg b/src/webui/static/img/icons/download.svg new file mode 100644 index 00000000..b4666418 --- /dev/null +++ b/src/webui/static/img/icons/download.svg @@ -0,0 +1 @@ + diff --git a/src/webui/static/img/icons/logs.svg b/src/webui/static/img/icons/logs.svg new file mode 100644 index 00000000..58935b78 --- /dev/null +++ b/src/webui/static/img/icons/logs.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 54b7b403..f2345582 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -154,6 +154,203 @@ initRestoreCheckbox(root); initYangTree(root); initMultiDropdown(root); + initLogsFilter(root); + initLogsTail(root); + } + + // Active EventSource for the live-tail stream, if any. Module-scoped + // so the htmx:afterSwap handler can tear it down on tab change — the + // old tail-checkbox is replaced by the new card, but the underlying + // stream would otherwise keep running until the server times out. + var logsTailES = null; + + // Latched true between a Load earlier click and the swap completing. + // Stays true for a 500ms grace window after the click, then auto-clears + // — htmx can fire htmx:afterSwap more than once per request (OOB swaps, + // settle, etc.), and consuming the flag on the first fire would leave + // a subsequent fire to scroll-to-bottom and undo the preservation. + // The grace window comfortably covers a normal request RTT but expires + // quickly enough that a real tab swap right afterwards behaves correctly. + var loadEarlierInFlight = false; + var loadEarlierClearTimer = null; + + function teardownLogsTail() { + if (logsTailES) { + logsTailES.close(); + logsTailES = null; + } + } + + // Helper: returns true when the user is at (or very near) the bottom + // of a scrollable element. Used so live-tail only auto-scrolls when + // the user is already reading the tail; if they've scrolled up to + // inspect old context, new lines append silently in the background. + function nearBottom(el) { + return (el.scrollHeight - el.scrollTop - el.clientHeight) < 40; + } + + function setTailStatus(label, text, cls) { + if (!label) return; + var status = label.querySelector('.logs-tail-status'); + if (!status) return; + status.textContent = text; + status.className = 'logs-tail-status' + (cls ? ' ' + cls : ''); + } + + function initLogsTail(root) { + var scope = root || document; + scope.querySelectorAll('.logs-tail-cb:not([data-init])').forEach(function (cb) { + cb.dataset.init = 'true'; + var label = cb.closest('label'); + + cb.addEventListener('change', function () { + teardownLogsTail(); + if (!cb.checked) { + setTailStatus(label, '', ''); + return; + } + + var body = cb.closest('.logs-body'); + var srcKey = body && body.getAttribute('data-source'); + var content = body && body.querySelector('.logs-content'); + if (!srcKey || !content) { + // Surface the failure rather than silently no-op — empty log + // files have no .logs-content element, and any future markup + // tweak that drops the data-source attribute would otherwise + // make tail appear broken without any clue why. + console.warn('[logs] live-tail aborted', { body: !!body, srcKey: srcKey, content: !!content }); + setTailStatus(label, 'nothing to tail', 'err'); + cb.checked = false; + return; + } + + // Toggling tail on always jumps to the end — the user opted in + // to "show me new stuff," even if they were scrolled up. Defer + // to the next frame so the jump lands after the layout settles + // (the status pill flips to "connecting…" on the same tick). + var stickToBottom = true; + requestAnimationFrame(function () { + content.scrollTop = content.scrollHeight; + }); + + setTailStatus(label, 'connecting…', 'pending'); + + logsTailES = new EventSource('/maintenance/logs/' + encodeURIComponent(srcKey) + '/tail'); + + // The native open event fires on successful HTTP handshake, + // which is enough to confirm the TCP/TLS path works even if no + // log lines are streaming yet. Useful when nginx buffering + // would otherwise leave the user staring at a blank tail. + logsTailES.onopen = function () { + setTailStatus(label, 'live', 'live'); + }; + logsTailES.addEventListener('lines', function (evt) { + // Stick to the bottom for the first batch unconditionally (the + // user just opted in), then fall back to the "only follow if + // already at the bottom" rule so scrolling up to read pauses + // the auto-follow. + var follow = stickToBottom || nearBottom(content); + stickToBottom = false; + content.insertAdjacentHTML('beforeend', evt.data); + if (follow) { + content.scrollTop = content.scrollHeight; + } + setTailStatus(label, 'live', 'live'); + }); + logsTailES.onerror = function () { + // EventSource auto-reconnects on transient drops — fired + // commonly under nginx proxy in front of our handler. Only + // touch the status when the browser has given up (CLOSED is + // terminal); leave "live" alone during the auto-reconnect + // cycle so the badge doesn't flicker between green and amber + // while new lines are still arriving correctly. + if (logsTailES && logsTailES.readyState === EventSource.CLOSED) { + teardownLogsTail(); + cb.checked = false; + setTailStatus(label, 'disconnected', 'err'); + } + }; + }); + }); + } + + // Pin the log buffer to the bottom on initial render and after every + // htmx swap (tab change). The Load earlier button sits at the top of + // the same scroll container, so this naturally hides it until the user + // scrolls back up. Always query against `document`: with our + // outerHTML tab swap, htmx hands us the OLD .logs-card as the scope, + // which is detached from the DOM by the time afterSwap fires. Note + // for the SSE wiring step: when the user toggles Live tail on, call + // this here too. + function scrollLogsToBottom() { + document.querySelectorAll('.logs-content').forEach(function (el) { + el.scrollTop = el.scrollHeight; + }); + } + + // Move keyboard focus onto the log buffer so PageUp / PageDown / + // Home / End / arrow keys all work without the user having to click + // first. Uses preventScroll because the buffer was just scrolled to + // the bottom and the default focus() would scroll it back to make + // the element-top visible. + function focusLogsContent() { + var content = document.querySelector('.logs-content'); + if (!content) return; + try { content.focus({ preventScroll: true }); } catch (_) { content.focus(); } + } + + // Steal Ctrl-F (and ⌘-F on macOS) when a log filter is on-screen so + // searching jumps into the filter input rather than opening the + // browser's native find — the filter actually understands the + // structured log buffer (regex, severity colors stay aligned). When + // no filter is in the DOM the binding does nothing and the browser + // behaves normally. + document.addEventListener('keydown', function (evt) { + if (!evt.key || evt.key.toLowerCase() !== 'f') return; + if (!(evt.ctrlKey || evt.metaKey)) return; + if (evt.altKey || evt.shiftKey) return; + var filter = document.querySelector('.logs-filter'); + if (!filter) return; + evt.preventDefault(); + filter.focus(); + filter.select(); + }); + + // Filter the visible log buffer client-side. Plain substring by default; + // /…/ delimiters switch to JS regex (case-insensitive). Empty query + // shows everything. Debounced so per-keystroke regex compile doesn't + // hitch the UI on a big buffer. + function initLogsFilter(root) { + var scope = root || document; + scope.querySelectorAll('.logs-filter:not([data-init])').forEach(function (input) { + input.dataset.init = 'true'; + var content = input.closest('.logs-body') && input.closest('.logs-body').querySelector('.logs-content'); + if (!content) return; + + function apply() { + var q = input.value; + var re = null; + var trimmed = q.trim(); + if (trimmed.length >= 2 && trimmed.charAt(0) === '/' && trimmed.charAt(trimmed.length - 1) === '/') { + try { re = new RegExp(trimmed.slice(1, -1), 'i'); } catch (_) { re = null; } + } + var sub = q.toLowerCase(); + content.querySelectorAll('.logs-line').forEach(function (line) { + var t = line.textContent; + var show; + if (re) show = re.test(t); + else if (q) show = t.toLowerCase().indexOf(sub) !== -1; + else show = true; + line.style.display = show ? '' : 'none'; + }); + } + + var timer = null; + input.addEventListener('input', function () { + clearTimeout(timer); + timer = setTimeout(apply, 120); + }); + }); } // The
-based multi-select dropdown needs JS to behave like a @@ -622,11 +819,38 @@ initDeviceStatusBanner(); initDynamicUI(document); initSoftwareProgress(document); + // On first paint we want the buffer pinned to the most recent entries + // and keyboard focus on the buffer so PageUp/PageDown work straight away. + scrollLogsToBottom(); + focusLogsContent(); // Attach the htmx swap listener here, not at IIFE parse time — at parse // time the script runs inside before exists, so the // document.body guard would silently skip the listener and subsequent // navigations wouldn't re-init dynamic UI. + // Set the Load earlier flag at click capture time — runs before htmx + // sees the click, before any other listener, and works regardless of + // whether htmx's beforeRequest event detail matches our expectations. + // While we're here, snapshot the scroll state on the .logs-content so + // afterSwap can preserve the user's visual position: the line they + // were looking at should stay put after new (older) content gets + // prepended, not jump to top or bottom. + document.body.addEventListener('click', function (evt) { + var btn = evt.target && evt.target.closest && evt.target.closest('.logs-load-earlier'); + if (!btn) return; + loadEarlierInFlight = true; + if (loadEarlierClearTimer) clearTimeout(loadEarlierClearTimer); + loadEarlierClearTimer = setTimeout(function () { + loadEarlierInFlight = false; + loadEarlierClearTimer = null; + }, 500); + var content = btn.closest('.logs-content'); + if (content) { + content._preLoadHeight = content.scrollHeight; + content._preLoadTop = content.scrollTop; + } + }, true); + if (window.htmx) { document.body.addEventListener('htmx:afterSwap', function (evt) { // Close any open SSE stream if the software install progress card is no longer present. @@ -634,8 +858,51 @@ swEventSource.close(); swEventSource = null; } - var scope = (evt.detail && evt.detail.target) || document; + + // Read but DON'T reset — the timeout in the click handler clears + // it after the grace window, so successive htmx:afterSwap fires + // within one Load earlier round trip all take the same branch. + var isLoadEarlier = loadEarlierInFlight; + + // Tab change replaces the whole logs card, which removes the + // tail checkbox without giving us a chance to react. Kill any + // open stream so it doesn't run on after navigation. Skip on + // Load earlier — that swap stays within the current tab and + // shouldn't touch the live-tail stream. + if (!isLoadEarlier) { + teardownLogsTail(); + } + + // Scope to document, not evt.detail.target: tab swaps use + // hx-swap="outerHTML", so afterSwap hands us the OLD (now detached) + // .logs-card as the target. Initialising against it misses the new + // tab's Live-tail checkbox — which is why tailing only worked on the + // tab rendered at page load. The init helpers all guard on + // :not([data-init]), so re-scanning the document is idempotent. + var scope = document; initDynamicUI(scope); + + if (isLoadEarlier) { + // Content-anchored scroll preservation: keep the line the user + // was looking at in the same vertical position by shifting + // scrollTop by exactly the height of the newly-prepended + // content. The pre-swap measurements were captured in the + // click-capture handler so they reflect the buffer's state + // before htmx mutated it. Without this, the browser falls + // back to scrollTop=0 because the OLD anchor element (the + // clicked button) no longer exists in the DOM. Idempotent + // across multiple afterSwap fires — recomputing from saved + // snapshots always yields the same scrollTop. + document.querySelectorAll('.logs-content').forEach(function (content) { + if (typeof content._preLoadHeight !== 'number') return; + var added = content.scrollHeight - content._preLoadHeight; + content.scrollTop = content._preLoadTop + added; + }); + } else { + scrollLogsToBottom(); + focusLogsContent(); + } + initSoftwareProgress(scope); }); } diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html index c39f99b0..713ddd54 100644 --- a/src/webui/templates/layouts/sidebar.html +++ b/src/webui/templates/layouts/sidebar.html @@ -291,7 +291,7 @@