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 @@