mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
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:
@@ -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-") {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 & 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}}
|
||||
Reference in New Issue
Block a user