mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
webui: add Maintenance > Logs viewer
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 <rotation>" 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 <PRI> 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 <troglobit@gmail.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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-") {
|
||||
|
||||
@@ -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+ <defunct> 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, `<div class="logs-line logs-%s">%s</div>`, cls, template.HTMLEscapeString(text))
|
||||
} else {
|
||||
fmt.Fprintf(&buf, `<div class="logs-line">%s</div>`, 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
<PRI>). 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;
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
|
||||
|
After Width: | Height: | Size: 316 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 12h-5" />
|
||||
<path d="M15 8h-5" />
|
||||
<path d="M19 17V5a2 2 0 0 0-2-2H4" />
|
||||
<path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
+268
-1
@@ -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 <details>-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 <head> before <body> 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@
|
||||
</details>
|
||||
|
||||
<details class="nav-group-top"
|
||||
{{if or (eq .ActivePage "software") (eq .ActivePage "backup") (eq .ActivePage "system-control")}}open{{end}}>
|
||||
{{if or (eq .ActivePage "software") (eq .ActivePage "logs") (eq .ActivePage "backup") (eq .ActivePage "system-control")}}open{{end}}>
|
||||
<summary class="nav-group-summary-top">Maintenance</summary>
|
||||
<ul class="nav-group-items">
|
||||
<li>
|
||||
@@ -304,6 +304,16 @@
|
||||
Software
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/maintenance/logs"
|
||||
hx-get="/maintenance/logs"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "logs"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/logs.svg')"></span>
|
||||
Logs
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/maintenance/backup"
|
||||
hx-get="/maintenance/backup"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
{{define "logs.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{template "logs-card" .}}
|
||||
{{end}}
|
||||
|
||||
{{/* The whole card is the htmx swap target for tab changes — server
|
||||
re-renders the strip with the right tab marked active each time,
|
||||
no JS state to sync. Side effect: filter clears on tab change,
|
||||
which matches "new log, fresh look" intent.
|
||||
|
||||
Load earlier walks back through the current file; once exhausted,
|
||||
the button re-labels to "Load previous from <rotation>" and
|
||||
starts paging into rotated archives. Live tail is JS-driven via
|
||||
EventSource and survives only within a tab; switching tabs closes
|
||||
the stream. */}}
|
||||
{{define "logs-card"}}
|
||||
<section class="info-card logs-card">
|
||||
<div class="card-header">Logs</div>
|
||||
|
||||
<div class="logs-tabs" role="tablist">
|
||||
{{range .Sources}}
|
||||
<button class="logs-tab {{if eq .Key $.Active}}active{{end}}"
|
||||
type="button" role="tab"
|
||||
hx-get="/maintenance/logs/{{.Key}}"
|
||||
hx-target="closest .logs-card"
|
||||
hx-swap="outerHTML"
|
||||
{{if eq .Key $.Active}}aria-selected="true"{{end}}>
|
||||
<span class="logs-tab-label">{{.Label}}</span>
|
||||
{{if .Empty}}<span class="logs-tab-empty">empty</span>{{end}}
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{template "logs-body" .}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "logs-body"}}
|
||||
<div id="logs-body" class="logs-body" data-source="{{.Source.Key}}">
|
||||
<div class="logs-toolbar">
|
||||
<input type="text" class="cfg-input logs-filter"
|
||||
placeholder="Filter (Ctrl/⌘-F) — text or /regex/, case-insensitive">
|
||||
<label class="logs-toggle"
|
||||
title="Stream new entries as they're written. Auto-scrolls only while you're at the bottom of the buffer.">
|
||||
<input type="checkbox" name="tail" class="logs-tail-cb"> Live tail
|
||||
<span class="logs-tail-status" aria-live="polite"></span>
|
||||
</label>
|
||||
<a href="/maintenance/logs/{{.Source.Key}}/download"
|
||||
class="btn btn-sm logs-download"
|
||||
hx-boost="false"
|
||||
title="Download the full archive — current file plus every rotated/compressed predecessor, concatenated chronologically.">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/download.svg')"></span>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{if .Source.Lines}}
|
||||
<div class="logs-content" tabindex="0">
|
||||
{{/* sourceContent's field names mirror earlierData so the same
|
||||
logs-earlier template renders both the initial buffer (.Source)
|
||||
and the Earlier-handler response. */}}
|
||||
{{template "logs-earlier" .Source}}
|
||||
</div>
|
||||
<div class="logs-status">
|
||||
Source: <code>{{.Source.Path}}</code>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty-message">No entries in <code>{{.Source.Path}}</code> yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Combined load-more button + lines fragment. Used both as the
|
||||
initial buffer and as the response to /earlier requests — the old
|
||||
button is replaced via outerHTML by [new button, new lines],
|
||||
keeping chronology in the DOM correct.
|
||||
|
||||
The button's label depends on whether the next click stays in the
|
||||
current file ("Load earlier") or crosses into a rotation
|
||||
("Load previous from messages.0"). */}}
|
||||
{{define "logs-earlier"}}
|
||||
{{with .Next}}<button type="button" class="btn btn-sm logs-load-earlier"
|
||||
hx-get="/maintenance/logs/{{$.Key}}/earlier?file={{.File}}&skip={{.Skip}}"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML">{{if .IsPrev}}Load previous from <code>{{.FileLabel}}</code>{{else}}Load earlier{{end}}</button>
|
||||
{{end}}{{range .Lines}}<div class="logs-line {{with .Class}}logs-{{.}}{{end}}">{{.Text}}</div>
|
||||
{{end}}{{end}}
|
||||
Reference in New Issue
Block a user