From dc2d4290937630683c192ca6a2e899f696d11ff5 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 13 Jun 2026 16:27:39 +0200 Subject: [PATCH] webui: add Maintenance > Diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Diagnostics page under Maintenance runs ping, traceroute, mtr and nmap with live output, plus a one-shot DNS lookup. A tab per tool shares a target field, source-interface and address-family selectors, and per-tool options; the field is focused on load and Enter runs the active tool from any field. Ping and traceroute stream their output line by line over SSE. mtr runs --raw and its records are translated into a live hop table — Loss%, Snt, Last, Avg, Best, Worst updating in place — running continuously until Stop, or for a set number of cycles. nmap offers a fixed set of scan profiles (quick, standard, service/version, host discovery); a CIDR target sweeps a subnet. Address family is forced with an explicit -4/-6 flag on the base binary, so a dual-stack hostname no longer follows the system's IPv6-first resolver preference on hosts that only have IPv4 routes. The streamer hands the child a real OS pipe and reaps it on exit, so a client disconnect (Stop closing the EventSource) kills the tool without leaking processes. User input is guarded: targets reject a leading dash and whitespace, the source interface is allow-listed against configured interfaces, and arguments are always passed as a slice. The target field keeps a history in localStorage. Signed-off-by: Joachim Wiberg --- src/webui/internal/handlers/common.go | 2 +- src/webui/internal/handlers/diagnostics.go | 495 +++++++++++++++++++++ src/webui/internal/server/server.go | 8 + src/webui/static/css/style.css | 86 ++++ src/webui/static/img/icons/README.md | 1 + src/webui/static/img/icons/diagnostics.svg | 1 + src/webui/static/js/app.js | 352 +++++++++++++++ src/webui/templates/layouts/sidebar.html | 12 +- src/webui/templates/pages/diagnostics.html | 149 +++++++ 9 files changed, 1104 insertions(+), 2 deletions(-) create mode 100644 src/webui/internal/handlers/diagnostics.go create mode 100644 src/webui/static/img/icons/diagnostics.svg create mode 100644 src/webui/templates/pages/diagnostics.html diff --git a/src/webui/internal/handlers/common.go b/src/webui/internal/handlers/common.go index 3699a8c1..6a513867 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", "logs", "backup", "system-control": + case "software", "logs", "diagnostics", "backup", "system-control": return "Maintenance" } if strings.HasPrefix(page, "configure-") { diff --git a/src/webui/internal/handlers/diagnostics.go b/src/webui/internal/handlers/diagnostics.go new file mode 100644 index 00000000..6b499bcf --- /dev/null +++ b/src/webui/internal/handlers/diagnostics.go @@ -0,0 +1,495 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "html/template" + "io" + "log" + "net/http" + "net/url" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "infix/webui/internal/restconf" +) + +// DiagnosticsHandler serves the Maintenance > Diagnostics page: ping, +// traceroute, mtr (interactive hop table) and DNS lookup. Long-running +// tools stream over SSE, mirroring the software-progress and logs-tail +// handlers; client disconnect (the Stop button closing the EventSource) +// cancels the request context, which CommandContext turns into a kill + +// reap of the spawned tool. +type DiagnosticsHandler struct { + RC *restconf.Client + Template *template.Template +} + +type diagPageData struct { + PageData + Interfaces []string + Active string +} + +// Overview renders the Diagnostics page with Ping pre-selected. +// GET /maintenance/diagnostics +func (h *DiagnosticsHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := diagPageData{ + PageData: newPageData(w, r, "diagnostics", "Diagnostics"), + Interfaces: h.interfaceNames(r), + Active: "ping", + } + tmplName := "diagnostics.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) + } +} + +// interfaceNames returns configured interface names for the source +// dropdown, best-effort: an error just yields an empty list (the form +// still offers "Auto"). +func (h *DiagnosticsHandler) interfaceNames(r *http.Request) []string { + if h.RC == nil { + return nil + } + var wrap interfacesWrapper + if err := h.RC.Get(r.Context(), "/data/ietf-interfaces:interfaces", &wrap); err != nil { + log.Printf("diagnostics: interface list: %v", err) + return nil + } + names := make([]string, 0, len(wrap.Interfaces.Interface)) + for _, i := range wrap.Interfaces.Interface { + names = append(names, i.Name) + } + return names +} + +// validIface returns iface only if it's a real configured interface; +// otherwise "" (treated as Auto). An allow-list is the safe way to let +// a user-supplied string reach a -I/-i flag. +func (h *DiagnosticsHandler) validIface(r *http.Request, iface string) string { + if iface == "" { + return "" + } + for _, n := range h.interfaceNames(r) { + if n == iface { + return iface + } + } + return "" +} + +// ─── Input validation ──────────────────────────────────────────────── + +// validTarget guards the host/target argument before it reaches a +// spawned tool. Rejects empty input, anything with whitespace, and a +// leading '-' (which a tool would parse as a flag — argument injection). +// Args are always passed as an explicit slice, never a shell string, so +// this plus the leading-dash check is sufficient. +func validTarget(s string) bool { + if s == "" || len(s) > 255 { + return false + } + if strings.HasPrefix(s, "-") { + return false + } + for _, r := range s { + if r <= ' ' || r == 0x7f { + return false + } + } + return true +} + +func clampInt(s string, def, min, max int) int { + n, err := strconv.Atoi(s) + if err != nil { + return def + } + if n < min { + return min + } + if n > max { + return max + } + return n +} + +// ─── SSE helpers ───────────────────────────────────────────────────── + +func sseLine(w io.Writer, f http.Flusher, s string) { + // SSE data fields must be single-line; collapse any CR/LF. + s = strings.ReplaceAll(strings.ReplaceAll(s, "\r", ""), "\n", " ") + fmt.Fprintf(w, "event: line\ndata: %s\n\n", s) + f.Flush() +} + +func sseDone(w io.Writer, f http.Flusher) { + fmt.Fprint(w, "event: done\ndata: end\n\n") + f.Flush() +} + +// ─── Run ───────────────────────────────────────────────────────────── + +// diagCommand maps the request to a binary + argument slice, or ok=false +// for an unknown tool. +// +// Address family is forced with an explicit -4/-6 flag on the BASE binary +// rather than via the ping6/traceroute6 name. A dual-stack hostname (A + +// AAAA) otherwise follows the system's getaddrinfo preference — usually +// IPv6 — so "IPv4" never actually took effect on hosts that only have v4 +// routes. ping, traceroute and mtr all accept -4/-6; nmap defaults to v4 +// and only needs -6. "auto" passes no flag and lets the tool decide +// (IPv6 literals and v4-only names resolve correctly without help). +func diagCommand(tool, target, family, iface string, q url.Values) (bin string, args []string, ok bool) { + fam := "" + switch family { + case "4": + fam = "-4" + case "6": + fam = "-6" + } + + switch tool { + case "ping": + bin = "ping" + args = []string{"-c", strconv.Itoa(clampInt(q.Get("count"), 3, 1, 100))} + if fam != "" { + args = append(args, fam) + } + if sz := q.Get("size"); sz != "" { + args = append(args, "-s", strconv.Itoa(clampInt(sz, 56, 0, 65500))) + } + if iface != "" { + args = append(args, "-I", iface) + } + args = append(args, "-W", "2", target) + return bin, args, true + + case "traceroute": + bin = "traceroute" + args = []string{"-n", "-m", strconv.Itoa(clampInt(q.Get("maxhops"), 30, 1, 64))} + if fam != "" { + args = append(args, fam) + } + if iface != "" { + args = append(args, "-i", iface) + } + args = append(args, target) + return bin, args, true + + case "mtr": + bin = "mtr" + args = []string{"--raw", "-n"} + if fam != "" { + args = append(args, fam) + } + // A count means the user opted out of the default run-forever + // mode; otherwise mtr streams continuously until Stop. + if c := q.Get("count"); c != "" { + args = append(args, "-c", strconv.Itoa(clampInt(c, 10, 1, 1000))) + } + if sz := q.Get("size"); sz != "" { + args = append(args, "-s", strconv.Itoa(clampInt(sz, 56, 0, 65500))) + } + if iface != "" { + args = append(args, "-I", iface) + } + args = append(args, target) + return bin, args, true + + case "nmap": + bin = "nmap" + // -n: skip rDNS so scans stay quick and don't hang on slow + // resolvers. Scan profiles map to a fixed flag set — we never + // pass free-form nmap options from the client. + args = []string{"-n"} + if family == "6" { + args = append(args, "-6") + } + switch q.Get("scan") { + case "quick": + args = append(args, "-F") // top 100 ports + case "services": + args = append(args, "-sV") // service/version detection + case "ping": + args = append(args, "-sn") // host discovery only + case "standard": + // default top-1000 port scan, no extra flag + } + if iface != "" { + args = append(args, "-e", iface) + } + args = append(args, target) + return bin, args, true + } + return "", nil, false +} + +// Run streams tool output as SSE. Event protocol: +// +// event: line — a text line to append (ping, traceroute) +// event: hop — JSON hop stats to upsert into the mtr table +// event: done — the tool exited; client closes the stream +// : comment — keep-alive +// +// GET /maintenance/diagnostics/run?tool=&target=&iface=&family=&... +func (h *DiagnosticsHandler) Run(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + q := r.URL.Query() + tool := q.Get("tool") + target := q.Get("target") + family := q.Get("family") + if family == "" { + family = "auto" + } + + 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) + // A few bytes up front so the proxy commits and EventSource.onopen + // fires promptly even before the tool emits anything. + fmt.Fprint(w, ": stream opened\n\n") + flusher.Flush() + + if !validTarget(target) { + sseLine(w, flusher, "Error: invalid target") + sseDone(w, flusher) + return + } + iface := h.validIface(r, q.Get("iface")) + + bin, args, ok := diagCommand(tool, target, family, iface, q) + if !ok { + sseLine(w, flusher, "Error: unknown tool") + sseDone(w, flusher) + return + } + + if tool == "mtr" { + h.streamMtr(w, r, flusher, bin, args) + return + } + h.streamLines(w, r, flusher, bin, args, func(line string) { + sseLine(w, flusher, line) + }) +} + +// streamLines spawns bin/args and invokes onLine for each output line, +// emitting a done event on exit. stdout and stderr are merged so tool +// errors ("Network is unreachable", "unknown host") reach the user. +func (h *DiagnosticsHandler) streamLines(w http.ResponseWriter, r *http.Request, flusher http.Flusher, bin string, args []string, onLine func(string)) { + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + cmd := exec.CommandContext(ctx, bin, args...) + + // Real OS pipe (not io.Pipe): the write-end fd is handed directly to + // the child, so there's no os/exec copy goroutine that could deadlock + // when the reader stops draining. When the child dies — including + // the CommandContext SIGKILL on ctx cancel — every write end closes + // and our read sees EOF, no matter whether anyone is reading. + pr, pw, err := os.Pipe() + if err != nil { + sseLine(w, flusher, "Error: "+err.Error()) + sseDone(w, flusher) + return + } + cmd.Stdout = pw + cmd.Stderr = pw + if err := cmd.Start(); err != nil { + pw.Close() + pr.Close() + sseLine(w, flusher, "Error: "+err.Error()) + sseDone(w, flusher) + return + } + pw.Close() // child holds the only write end now + defer pr.Close() // unblocks the scanner if we leave first + // Reap the child so it doesn't linger as a zombie; ctx cancel kills + // it, Wait then returns. + go cmd.Wait() //nolint:errcheck + + lines := make(chan string, 128) + go func() { + defer close(lines) + sc := bufio.NewScanner(pr) + sc.Buffer(make([]byte, 64*1024), 256*1024) + for sc.Scan() { + select { + case lines <- sc.Text(): + case <-ctx.Done(): + return + } + } + }() + + heartbeat := time.NewTicker(15 * time.Second) + defer heartbeat.Stop() + for { + select { + case <-ctx.Done(): + return + case line, ok := <-lines: + if !ok { + sseDone(w, flusher) + return + } + onLine(line) + case <-heartbeat.C: + fmt.Fprint(w, ": keep-alive\n\n") + flusher.Flush() + } + } +} + +// mtrHop accumulates per-hop statistics from the raw record stream. +type mtrHop struct { + host string + sent, recv int + last, best, worst float64 + sum float64 +} + +// streamMtr runs mtr --raw and translates its record stream into hop +// updates. Infix's mtr emits an `x ` record for every probe +// SENT (not just replies), so loss is exact: 100*(sent-recv)/sent. +// +// x probe sent for hop idx +// h
host discovered at hop idx +// p reply for hop idx, RTT in microseconds +func (h *DiagnosticsHandler) streamMtr(w http.ResponseWriter, r *http.Request, flusher http.Flusher, bin string, args []string) { + hops := map[int]*mtrHop{} + get := func(idx int) *mtrHop { + hop := hops[idx] + if hop == nil { + hop = &mtrHop{host: "???"} + hops[idx] = hop + } + return hop + } + emit := func(idx int, hop *mtrHop) { + loss := 0.0 + if hop.sent > 0 { + loss = 100 * float64(hop.sent-hop.recv) / float64(hop.sent) + } + avg := 0.0 + if hop.recv > 0 { + avg = hop.sum / float64(hop.recv) + } + payload, _ := json.Marshal(map[string]any{ + "idx": idx, "host": hop.host, "loss": loss, "snt": hop.sent, + "last": hop.last, "avg": avg, "best": hop.best, "worst": hop.worst, + }) + fmt.Fprintf(w, "event: hop\ndata: %s\n\n", payload) + flusher.Flush() + } + + h.streamLines(w, r, flusher, bin, args, func(line string) { + f := strings.Fields(line) + if len(f) < 2 { + return + } + idx, err := strconv.Atoi(f[1]) + if err != nil { + return + } + switch f[0] { + case "x": // probe sent + hop := get(idx) + hop.sent++ + emit(idx, hop) + case "h": // host discovered + if len(f) >= 3 { + get(idx).host = f[2] + } + case "p": // reply received + if len(f) >= 3 { + usec, err := strconv.Atoi(f[2]) + if err != nil { + return + } + rtt := float64(usec) / 1000 + hop := get(idx) + hop.recv++ + hop.last = rtt + hop.sum += rtt + if hop.recv == 1 || rtt < hop.best { + hop.best = rtt + } + if rtt > hop.worst { + hop.worst = rtt + } + emit(idx, hop) + } + } + }) +} + +// ─── DNS lookup ────────────────────────────────────────────────────── + +// Resolve does a one-shot name lookup via getent and returns an HTML +// fragment for the client to swap into the result pane. +// GET /maintenance/diagnostics/resolve?name=&family= +func (h *DiagnosticsHandler) Resolve(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + render := func(data map[string]any) { + if err := h.Template.ExecuteTemplate(w, "diag-dns-result", data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + } + if !validTarget(name) { + render(map[string]any{"Name": name, "Error": "invalid name"}) + return + } + + prog := "ahosts" + switch r.URL.Query().Get("family") { + case "4": + prog = "ahostsv4" + case "6": + prog = "ahostsv6" + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + out, _ := exec.CommandContext(ctx, "getent", prog, name).Output() + + // getent ahosts prints each address three times (STREAM/DGRAM/RAW); + // keep the first occurrence of each, preserving order. + var addrs []string + seen := map[string]bool{} + sc := bufio.NewScanner(strings.NewReader(string(out))) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) == 0 || seen[fields[0]] { + continue + } + seen[fields[0]] = true + addrs = append(addrs, fields[0]) + } + + if len(addrs) == 0 { + render(map[string]any{"Name": name, "Error": "no addresses found"}) + return + } + render(map[string]any{"Name": name, "Addrs": addrs}) +} diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 9d544ac3..9036ef6a 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -69,6 +69,10 @@ func New( if err != nil { return nil, err } + diagTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/diagnostics.html") + if err != nil { + return nil, err + } routingTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/routing.html") if err != nil { return nil, err @@ -240,6 +244,7 @@ func New( BackupTmpl: backupTmpl, } logs := &handlers.LogsHandler{Template: logsTmpl} + diag := &handlers.DiagnosticsHandler{RC: rc, Template: diagTmpl} routing := &handlers.RoutingHandler{Template: routingTmpl, RC: rc} wifi := &handlers.WiFiHandler{Template: wifiTmpl, RC: rc} @@ -310,6 +315,9 @@ func New( 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/diagnostics", diag.Overview) + mux.HandleFunc("GET /maintenance/diagnostics/run", diag.Run) + mux.HandleFunc("GET /maintenance/diagnostics/resolve", diag.Resolve) 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 7091dd3e..d0ac4406 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -1456,6 +1456,92 @@ details[open].nav-group-top > summary.nav-group-summary-top::before { 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. */ +/* ========================================================================== + Maintenance > Diagnostics + ========================================================================== */ +.diag-card { display: flex; flex-direction: column; } +.diag-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.diag-tab { + 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; +} +.diag-tab:hover { color: var(--fg); border-color: var(--fg-muted); } +.diag-tab.active { background: var(--primary); color: #fff; border-color: var(--primary); } + +.diag-form { padding: 0.9rem 1rem 0.4rem; } +/* The [hidden] attribute is how JS shows per-tool option fields, but the + author `display:flex` on .diag-field outranks the UA [hidden] rule, so + hidden fields would still render. Restore it for everything inside the + form (specificity class+attr beats the bare class). */ +.diag-form [hidden] { display: none; } +.diag-row { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 0.75rem; } +.diag-row:empty { margin: 0; } +.diag-field { display: flex; flex-direction: column; gap: 0.25rem; } +.diag-field-grow { flex: 1 1 16rem; } +.diag-label { font-size: 0.78rem; color: var(--fg-muted); } +.diag-hint { font-size: 0.8rem; color: var(--fg-muted); align-self: center; } +.diag-opts:empty { display: none; } +.diag-count, .diag-size, .diag-maxhops, .diag-mtr-count, .diag-mtr-size { width: 7rem; } +.diag-check { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.85rem; white-space: nowrap; padding: 0.15rem 0; } +.diag-check input { margin: 0; } + +.diag-actions { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0 0.5rem; } +.diag-status { font-size: 0.78rem; color: var(--fg-muted); margin-left: 0.4rem; letter-spacing: 0.02em; } +.diag-status:not(:empty)::before { content: "●"; margin-right: 0.25rem; font-size: 0.85em; } +.diag-status.pending::before { color: var(--warning, #c97a00); animation: logs-tail-pulse 1s ease-in-out infinite; } +.diag-status.err::before { color: var(--danger, #b03030); } + +.diag-output { padding: 0.25rem 1rem 1rem; } +.diag-placeholder { color: var(--fg-muted); font-size: 0.85rem; padding: 1rem 0; } +.diag-text { + font-family: var(--font-mono); + font-size: 0.78rem; + line-height: 1.4; + background: var(--surface-deep, var(--surface)); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.5rem 0.75rem; + margin: 0; + max-height: 55vh; + overflow: auto; + white-space: pre-wrap; + word-break: break-all; +} +.diag-mtr { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} +.diag-mtr th, .diag-mtr td { + text-align: right; + padding: 0.3rem 0.6rem; + border-bottom: 1px solid var(--border); + font-variant-numeric: tabular-nums; +} +.diag-mtr th { color: var(--fg-muted); font-weight: 600; font-size: 0.76rem; } +.diag-mtr-hop { text-align: right; width: 3rem; } +.diag-mtr-host { text-align: left; font-family: var(--font-mono); } +.diag-mtr tbody tr:hover { background: var(--surface); } +.diag-loss-bad { color: var(--danger); font-weight: 600; } + +.diag-dns-head { font-size: 0.9rem; margin: 0.5rem 0; } +.diag-dns-list { list-style: none; padding: 0; margin: 0; } +.diag-dns-list li { padding: 0.2rem 0; font-family: var(--font-mono); font-size: 0.85rem; } +.diag-dns-err { color: var(--danger); font-size: 0.9rem; } + /* 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 diff --git a/src/webui/static/img/icons/README.md b/src/webui/static/img/icons/README.md index 9db3df68..4b54b184 100644 --- a/src/webui/static/img/icons/README.md +++ b/src/webui/static/img/icons/README.md @@ -17,6 +17,7 @@ untouched. | containers.svg | container | | dashboard.svg | circle-gauge | | dhcp.svg | network | +| diagnostics.svg | activity | | dns.svg | globe | | download.svg | download | | firewall.svg | shield-check | diff --git a/src/webui/static/img/icons/diagnostics.svg b/src/webui/static/img/icons/diagnostics.svg new file mode 100644 index 00000000..903be414 --- /dev/null +++ b/src/webui/static/img/icons/diagnostics.svg @@ -0,0 +1 @@ + diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index f2345582..22f9e971 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -2211,3 +2211,355 @@ function renderCfgLog() { } }); })(); + +// ─── Maintenance > Diagnostics ───────────────────────────────────────────── +// Ping / traceroute / mtr / DNS lookup. Tabs are client-side: switching +// tool only changes which option fields and which output surface show. +// Run opens an EventSource to /maintenance/diagnostics/run and renders +// streamed `line` / `hop` / `done` events; Stop closes it, which cancels +// the server request context and kills the spawned tool. DNS lookup is a +// one-shot fetch instead of a stream. +(function () { + var diagES = null; // active stream, module-scoped so afterSwap can kill it + + function diagTeardown() { + if (diagES) { + diagES.close(); + diagES = null; + } + } + + // Tools that take a network target + source interface (everything but + // DNS, which resolves a name and has no source-interface concept here). + function isNetTool(tool) { + return tool === 'ping' || tool === 'traceroute' || tool === 'mtr' || tool === 'nmap'; + } + + function setActiveTool(card, tool) { + card.dataset.active = tool; + + card.querySelectorAll('.diag-tab').forEach(function (t) { + t.classList.toggle('active', t.getAttribute('data-tool') === tool); + }); + + // Show only the option fields belonging to this tool. + card.querySelectorAll('.diag-opt').forEach(function (o) { + o.hidden = o.getAttribute('data-tool') !== tool; + }); + + // Source interface only applies to the network tools. + card.querySelectorAll('.diag-only-net').forEach(function (el) { + el.hidden = !isNetTool(tool); + }); + + // mtr's cycle/size fields only make sense when not running forever. + if (tool === 'mtr') applyMtrContinuous(card); + + // Retarget the input label/placeholder per tool. + var label = card.querySelector('[data-target-label]'); + var input = card.querySelector('.diag-target'); + if (tool === 'dns') { + if (label) label.textContent = 'Name to resolve'; + if (input) input.placeholder = 'hostname'; + } else if (tool === 'nmap') { + if (label) label.textContent = 'Target host, address, or CIDR'; + if (input) input.placeholder = 'host, IP, or 192.168.0.0/24'; + } else { + if (label) label.textContent = 'Target host or address'; + if (input) input.placeholder = 'hostname or IP'; + } + + // Reset to the ready state: kill any stream, hide all output + // surfaces, show the placeholder hint. The matching surface is + // revealed on Run. Switching tool is a context change, so dropping + // the previous tool's output is the expected "fresh start". + diagTeardown(); + card.querySelector('.diag-text').hidden = true; + card.querySelector('.diag-mtr').hidden = true; + card.querySelector('.diag-dns').hidden = true; + card.querySelector('.diag-placeholder').hidden = false; + diagSetStatus(card, '', ''); + diagSetRunning(card, false); + } + + function diagSetStatus(card, text, cls) { + var s = card.querySelector('.diag-status'); + if (!s) return; + s.textContent = text || ''; + s.className = 'diag-status' + (cls ? ' ' + cls : ''); + } + + function diagSetRunning(card, running) { + card.querySelector('.diag-run').hidden = running; + card.querySelector('.diag-stop').hidden = !running; + } + + // Return focus to the target field when a run finishes so the user can + // type the next address immediately. Guard on visibility so a run that + // completes while the tab is backgrounded doesn't yank the window + // forward (the run was started, then the user looked away). + function diagFocusTarget(card) { + var input = card.querySelector('.diag-target'); + if (input && document.visibilityState !== 'hidden') { + input.focus({ preventScroll: true }); + } + } + + // Hide mtr's Cycles/Size fields while "Run continuously" is checked. + function applyMtrContinuous(card) { + var cb = card.querySelector('.diag-mtr-continuous'); + var continuous = cb ? cb.checked : true; + card.querySelectorAll('.diag-mtr-param').forEach(function (el) { + el.hidden = continuous; + }); + } + + // ── Target history (shared across tools, persisted in localStorage) ── + var DIAG_HIST_KEY = 'infix.diag.history'; + + function diagLoadHistory() { + try { return JSON.parse(localStorage.getItem(DIAG_HIST_KEY)) || []; } + catch (_) { return []; } + } + + function diagRenderHistory() { + var dl = document.getElementById('diag-history'); + if (!dl) return; + dl.innerHTML = ''; + diagLoadHistory().forEach(function (h) { + var o = document.createElement('option'); + o.value = h; + dl.appendChild(o); + }); + } + + function diagPushHistory(target) { + if (!target) return; + var list = diagLoadHistory().filter(function (h) { return h !== target; }); + list.unshift(target); + if (list.length > 25) list = list.slice(0, 25); + try { localStorage.setItem(DIAG_HIST_KEY, JSON.stringify(list)); } catch (_) {} + diagRenderHistory(); + } + + // Upsert one mtr hop row keyed by hop index. + function diagUpsertHop(tbody, hop) { + var row = tbody.querySelector('tr[data-idx="' + hop.idx + '"]'); + if (!row) { + row = document.createElement('tr'); + row.setAttribute('data-idx', hop.idx); + row.innerHTML = + '' + + ''; + // Keep rows ordered by hop index even if events arrive out of order. + var next = null; + tbody.querySelectorAll('tr').forEach(function (r) { + if (next === null && parseInt(r.getAttribute('data-idx'), 10) > hop.idx) next = r; + }); + tbody.insertBefore(row, next); + } + var c = row.children; + c[0].textContent = hop.idx + 1; + c[1].textContent = hop.host; + c[2].textContent = hop.loss.toFixed(1); + c[2].classList.toggle('diag-loss-bad', hop.loss > 0); + c[3].textContent = hop.snt; + c[4].textContent = hop.last.toFixed(1); + c[5].textContent = hop.avg.toFixed(1); + c[6].textContent = hop.best.toFixed(1); + c[7].textContent = hop.worst.toFixed(1); + } + + function diagBuildURL(card) { + var tool = card.dataset.active; + var q = new URLSearchParams(); + q.set('tool', tool); + q.set('target', card.querySelector('.diag-target').value.trim()); + q.set('family', card.querySelector('.diag-family').value); + if (isNetTool(tool)) { + q.set('iface', card.querySelector('.diag-iface').value); + } + if (tool === 'ping') { + var count = card.querySelector('.diag-count'); + var size = card.querySelector('.diag-size'); + if (count) q.set('count', count.value); + if (size) q.set('size', size.value); + } else if (tool === 'traceroute') { + var maxhops = card.querySelector('.diag-maxhops'); + if (maxhops) q.set('maxhops', maxhops.value); + } else if (tool === 'mtr') { + // Omitting count tells the backend to run forever; only send the + // cycle/size knobs when the user has opted out of continuous mode. + var cont = card.querySelector('.diag-mtr-continuous'); + if (cont && !cont.checked) { + var mc = card.querySelector('.diag-mtr-count'); + var ms = card.querySelector('.diag-mtr-size'); + if (mc) q.set('count', mc.value); + if (ms) q.set('size', ms.value); + } + } else if (tool === 'nmap') { + var scan = card.querySelector('.diag-scan'); + if (scan) q.set('scan', scan.value); + } + return '/maintenance/diagnostics/run?' + q.toString(); + } + + function diagRunDNS(card) { + var name = card.querySelector('.diag-target').value.trim(); + if (!name) { + diagSetStatus(card, 'enter a name', 'err'); + return; + } + var dns = card.querySelector('.diag-dns'); + var family = card.querySelector('.diag-family').value; + diagSetStatus(card, 'resolving…', 'pending'); + card.querySelector('.diag-placeholder').hidden = true; + dns.hidden = false; + fetch('/maintenance/diagnostics/resolve?name=' + encodeURIComponent(name) + + '&family=' + encodeURIComponent(family), { headers: { 'HX-Request': 'true' } }) + .then(function (r) { return r.text(); }) + .then(function (html) { dns.innerHTML = html; diagSetStatus(card, '', ''); diagFocusTarget(card); }) + .catch(function () { diagSetStatus(card, 'lookup failed', 'err'); }); + } + + function diagRunStream(card) { + var tool = card.dataset.active; + var target = card.querySelector('.diag-target').value.trim(); + if (!target) { + diagSetStatus(card, 'enter a target', 'err'); + return; + } + + diagTeardown(); + card.querySelector('.diag-placeholder').hidden = true; + + var textPane = card.querySelector('.diag-text'); + var mtrTable = card.querySelector('.diag-mtr'); + var mtrBody = mtrTable.querySelector('tbody'); + if (tool === 'mtr') { + mtrBody.innerHTML = ''; + mtrTable.hidden = false; + textPane.hidden = true; + } else { + textPane.textContent = ''; + textPane.hidden = false; + mtrTable.hidden = true; + } + + diagSetStatus(card, 'running…', 'pending'); + diagSetRunning(card, true); + + diagES = new EventSource(diagBuildURL(card)); + diagES.addEventListener('line', function (evt) { + textPane.textContent += evt.data + '\n'; + textPane.scrollTop = textPane.scrollHeight; + }); + diagES.addEventListener('hop', function (evt) { + try { diagUpsertHop(mtrBody, JSON.parse(evt.data)); } catch (_) {} + }); + diagES.addEventListener('done', function () { + diagTeardown(); + diagSetStatus(card, 'done', ''); + diagSetRunning(card, false); + diagFocusTarget(card); + }); + diagES.onerror = function () { + // Transient drops auto-reconnect; only react to a terminal close. + if (diagES && diagES.readyState === EventSource.CLOSED) { + diagTeardown(); + diagSetStatus(card, 'disconnected', 'err'); + diagSetRunning(card, false); + } + }; + } + + function diagRun(card) { + var input = card.querySelector('.diag-target'); + var target = input.value.trim(); + if (target) diagPushHistory(target); + // Blur the target so Firefox dismisses the datalist popup, which it + // otherwise leaves hovering over the results after a selection. + if (input) input.blur(); + if (card.dataset.active === 'dns') { + diagRunDNS(card); + } else { + diagRunStream(card); + } + } + + function diagStop(card) { + diagTeardown(); + diagSetStatus(card, 'stopped', ''); + diagSetRunning(card, false); + diagFocusTarget(card); + } + + function diagClear(card) { + card.querySelector('.diag-text').textContent = ''; + card.querySelector('.diag-mtr tbody').innerHTML = ''; + card.querySelector('.diag-dns').innerHTML = ''; + card.querySelector('.diag-text').hidden = true; + card.querySelector('.diag-mtr').hidden = true; + card.querySelector('.diag-dns').hidden = true; + card.querySelector('.diag-placeholder').hidden = false; + diagSetStatus(card, '', ''); + } + + function initDiagnostics(scope) { + var root = scope || document; + root.querySelectorAll('.diag-card:not([data-init])').forEach(function (card) { + card.dataset.init = 'true'; + + card.querySelectorAll('.diag-tab').forEach(function (tab) { + tab.addEventListener('click', function () { + setActiveTool(card, tab.getAttribute('data-tool')); + // Focus the target field so the user can type straight away. + var input = card.querySelector('.diag-target'); + if (input) input.focus(); + }); + }); + + card.querySelector('.diag-run').addEventListener('click', function () { diagRun(card); }); + card.querySelector('.diag-stop').addEventListener('click', function () { diagStop(card); }); + card.querySelector('.diag-clear').addEventListener('click', function () { diagClear(card); }); + + var cont = card.querySelector('.diag-mtr-continuous'); + if (cont) cont.addEventListener('change', function () { applyMtrContinuous(card); }); + + // Enter in ANY form field (target, count, family, …) runs the + // active tool — Run is the natural default action. Buttons are + // excluded so Enter on Run/Stop/Clear keeps their own behavior + // and doesn't fire twice. + card.querySelector('.diag-form').addEventListener('keydown', function (e) { + if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') { + e.preventDefault(); + diagRun(card); + } + }); + + diagRenderHistory(); + setActiveTool(card, card.dataset.active || 'ping'); + + // Focus the target on load too (tab clicks already do). Guard on + // visibility so a background load doesn't yank the window forward + // under X11 WMs — same reasoning as the data-autofocus handler. + var target = card.querySelector('.diag-target'); + if (target && document.visibilityState !== 'hidden') { + target.focus({ preventScroll: true }); + } + }); + } + + document.addEventListener('DOMContentLoaded', function () { + initDiagnostics(document); + if (window.htmx) { + document.body.addEventListener('htmx:afterSwap', function (evt) { + // Navigating away from the diagnostics page removes the card; + // make sure any running stream is torn down server-side. + if (!document.querySelector('.diag-card')) diagTeardown(); + var s = (evt.detail && evt.detail.target) || document; + initDiagnostics(s); + }); + } + }); +})(); diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html index 713ddd54..7f9e9505 100644 --- a/src/webui/templates/layouts/sidebar.html +++ b/src/webui/templates/layouts/sidebar.html @@ -291,7 +291,7 @@