webui: add Maintenance > Diagnostics

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 <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-15 19:45:23 +02:00
parent b1146cbb31
commit dc2d429093
9 changed files with 1104 additions and 2 deletions
+1 -1
View File
@@ -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-") {
+495
View File
@@ -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 <idx> <txid>` record for every probe
// SENT (not just replies), so loss is exact: 100*(sent-recv)/sent.
//
// x <idx> <txid> probe sent for hop idx
// h <idx> <address> host discovered at hop idx
// p <idx> <usec> <txid> 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})
}
+8
View File
@@ -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)
+86
View File
@@ -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
<PRI>). 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
+1
View File
@@ -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 |
@@ -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="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/></svg>

After

Width:  |  Height:  |  Size: 322 B

+352
View File
@@ -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 =
'<td class="diag-mtr-hop"></td><td class="diag-mtr-host"></td>' +
'<td class="diag-mtr-loss"></td><td></td><td></td><td></td><td></td><td></td>';
// 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);
});
}
});
})();
+11 -1
View File
@@ -291,7 +291,7 @@
</details>
<details class="nav-group-top"
{{if or (eq .ActivePage "software") (eq .ActivePage "logs") (eq .ActivePage "backup") (eq .ActivePage "system-control")}}open{{end}}>
{{if or (eq .ActivePage "software") (eq .ActivePage "logs") (eq .ActivePage "diagnostics") (eq .ActivePage "backup") (eq .ActivePage "system-control")}}open{{end}}>
<summary class="nav-group-summary-top">Maintenance</summary>
<ul class="nav-group-items">
<li>
@@ -314,6 +314,16 @@
Logs
</a>
</li>
<li>
<a href="/maintenance/diagnostics"
hx-get="/maintenance/diagnostics"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "diagnostics"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/diagnostics.svg')"></span>
Diagnostics
</a>
</li>
<li>
<a href="/maintenance/backup"
hx-get="/maintenance/backup"
+149
View File
@@ -0,0 +1,149 @@
{{define "diagnostics.html"}}
{{template "base" .}}
{{end}}
{{define "content"}}
{{template "diag-card" .}}
{{end}}
{{/* Tabs are client-side here (unlike Logs): switching tool only swaps
which option fields show, there's no server data to fetch. The Run
button opens an EventSource to /maintenance/diagnostics/run; Stop
closes it, which cancels the request context and kills the tool. */}}
{{define "diag-card"}}
<section class="info-card diag-card" data-active="{{.Active}}">
<div class="card-header">Diagnostics</div>
<div class="diag-tabs" role="tablist">
<button class="diag-tab active" type="button" role="tab" data-tool="ping">Ping</button>
<button class="diag-tab" type="button" role="tab" data-tool="traceroute">Traceroute</button>
<button class="diag-tab" type="button" role="tab" data-tool="mtr">MTR</button>
<button class="diag-tab" type="button" role="tab" data-tool="nmap">Nmap</button>
<button class="diag-tab" type="button" role="tab" data-tool="dns">DNS lookup</button>
</div>
<form class="diag-form" autocomplete="off" onsubmit="return false">
{{/* One wrapping row: the target grows to fill the left; the per-tool
option fields flow to its right, and Source interface + Address
family always come last on the line. Per-tool fields show/hide
via the [hidden] attr driven by JS keyed on data-tool. */}}
<div class="diag-row">
<label class="diag-field diag-field-grow">
<span class="diag-label" data-target-label>Target host or address</span>
<input type="text" class="cfg-input diag-target" name="target"
placeholder="hostname or IP" list="diag-history">
</label>
<label class="diag-field diag-opt" data-tool="ping">
<span class="diag-label">Count</span>
<input type="number" class="cfg-input diag-count" name="count" value="3" min="1" max="100">
</label>
<label class="diag-field diag-opt" data-tool="ping">
<span class="diag-label">Size</span>
<input type="number" class="cfg-input diag-size" name="size" value="56" min="0" max="65500">
</label>
<label class="diag-field diag-opt" data-tool="traceroute">
<span class="diag-label">Max hops</span>
<input type="number" class="cfg-input diag-maxhops" name="maxhops" value="30" min="1" max="64">
</label>
{{/* mtr's cycle/size only appear when continuous mode is off; the
toggle itself lives on its own row below so the revealed
fields don't intermingle with the checkbox. */}}
<label class="diag-field diag-opt diag-mtr-param" data-tool="mtr">
<span class="diag-label">Cycles</span>
<input type="number" class="cfg-input diag-mtr-count" value="10" min="1" max="1000">
</label>
<label class="diag-field diag-opt diag-mtr-param" data-tool="mtr">
<span class="diag-label">Size</span>
<input type="number" class="cfg-input diag-mtr-size" value="56" min="0" max="65500">
</label>
<label class="diag-field diag-opt" data-tool="nmap">
<span class="diag-label">Scan type</span>
<select class="cfg-input diag-scan" name="scan">
<option value="quick">Quick (top 100 ports)</option>
<option value="standard">Standard (top 1000 ports)</option>
<option value="services">Service &amp; version (-sV)</option>
<option value="ping">Host discovery / ping sweep</option>
</select>
</label>
<label class="diag-field diag-only-net">
<span class="diag-label">Source interface</span>
<select class="cfg-input diag-iface" name="iface">
<option value="">Auto</option>
{{range .Interfaces}}<option value="{{.}}">{{.}}</option>{{end}}
</select>
</label>
<label class="diag-field">
<span class="diag-label">Address family</span>
<select class="cfg-input diag-family" name="family">
<option value="auto">Auto</option>
<option value="4">IPv4</option>
<option value="6">IPv6</option>
</select>
</label>
</div>
{{/* MTR run mode lives on its own row, below the options, so toggling
it off reveals Cycles/Size cleanly above without reflowing
around the checkbox. */}}
<div class="diag-row diag-opt diag-mode-row" data-tool="mtr">
<label class="diag-check"><input type="checkbox" class="diag-mtr-continuous" checked> Run continuously (uncheck to set cycles)</label>
</div>
<div class="diag-actions">
<button type="button" class="btn btn-primary diag-run">Run</button>
<button type="button" class="btn diag-stop" hidden>Stop</button>
<button type="button" class="btn btn-sm diag-clear">Clear</button>
<span class="diag-status" aria-live="polite"></span>
</div>
</form>
{{/* Shared host history for the target field, persisted in
localStorage and rebuilt by JS on each run. */}}
<datalist id="diag-history"></datalist>
{{/* Two output surfaces share the card: a text pane for ping /
traceroute / dns, and a live table for mtr. JS toggles which is
visible per tool. */}}
<div class="diag-output">
<pre class="diag-text" tabindex="0" hidden></pre>
<table class="diag-mtr" hidden>
<thead>
<tr>
<th class="diag-mtr-hop">Hop</th>
<th class="diag-mtr-host">Host</th>
<th>Loss%</th>
<th>Snt</th>
<th>Last</th>
<th>Avg</th>
<th>Best</th>
<th>Worst</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="diag-dns" hidden></div>
<p class="diag-placeholder">Enter a target and press Run.</p>
</div>
</section>
{{end}}
{{/* DNS lookup result fragment, swapped into .diag-dns. */}}
{{define "diag-dns-result"}}
{{if .Error}}
<p class="diag-dns-err">Lookup failed for <code>{{.Name}}</code>: {{.Error}}</p>
{{else}}
<p class="diag-dns-head"><code>{{.Name}}</code> resolves to:</p>
<ul class="diag-dns-list">
{{range .Addrs}}<li><code>{{.}}</code></li>{{end}}
</ul>
{{end}}
{{end}}