Tech Scroll 110 - An honest signal in a noisy net -BreathNetStatus

Tech Scroll 110 - An honest signal in a noisy net -BreathNetStatus
Old shop proverb: “Light the path and hide no traps.”
When every packet gets a clear answer, scanners wander off and operators sleep.

BreathNetStatus exists so networks can breathe, not wheeze. IPv6 is vast—that’s a feature, not a flaw. This tiny tool keeps a calm, honest pulse on your estate: no duct-taped daemons, no rentals, no drama. It’s MIT-licensed because sharing good tools makes the internet nicer.

What you get

  • A simple JSON API
  • A clean, dark HTML dashboard
  • ICMPv6 latency pings, port “chips”, gentle sparklines
  • An optional local agent that reports service status from OpenRC or systemd
  • Built to sit quietly on the wire, tell the truth, and keep doing so when links flap
    (Service first. Support before self.)

We’re not hoarding secrets. Use it, fork it, or ship it inside your own stack. If you improve it, send a postcard—or a pull request. May your routes stay short and your logs stay boring.

Parable for network folks: “As breath fills the chest, so clarity fills the graph.”
Those with ears will hear; those with eyes to see will see the ports go green.

Tagline: An IPv6-first, self-hosted availability and service status monitor with a tiny JSON API, optional local agent, and a lightweight HTML dashboard. No rentals, no central cloud—just a single binary and a YAML file.

License: MIT.


Why we wrote it

We needed something that:

  • Treats IPv6 as a first‑class citizen (ICMPv6, TCP probes, clean signals).
  • Works in private estates where agents are internal‑only and WAN access is forbidden.
  • Keeps operations simple: one small binary, one YAML file, SQLite history, a static dashboard.
  • Plays well with OpenRC (Alpine) and systemd (Ubuntu/Debian/RHEL).
  • Keeps logs quiet by default but easy to surface when needed.

Features

  • Polling server that pings and TCP‑probes targets at a fixed interval.
  • Optional Agent HTTP endpoint that reports host facts and service status.
  • JSON API for the dashboard and your own scripts.
  • Embedded HTML dashboard (polling, no websockets) with hot‑reloadable override.
  • SQLite ring‑buffer style history (WAL), used to pre‑warm uptime & RTT sparklines.
  • Anti‑flap state machine (consecutive up/down samples before a status flip).
  • Designed to sit behind your own firewall.


Quick start

  1. Install Go and build:
# Alpine / Arch
apk add go git   # or: pacman -S go git

# Inside the source tree
go mod tidy
# Small binary; strip symbols
go build -trimpath -ldflags "-s -w" -o /usr/local/bin/breathnetstatus

# Optional (so non‑root can send ICMPv6 echo)
setcap 'cap_net_raw+ep' /usr/local/bin/breathnetstatus
  1. Create config:
install -d -m 0755 /etc/breath
$EDITOR /etc/breath/status.yaml

Example status.yaml (server + agent in one process):

# how often to probe
interval: 2s

# run just the API/UI server, just the agent, or both
launch: both  # server | agent | both

# listeners
server_listen: ":8081"
agent_listen: ":9100"

# identity (for /api/agent and UI labels)
agent_name: route
agent_site: Hampshire DC1

# optional: serve a custom dashboard HTML instead of the built‑in page
# if you provide a body fragment, it's injected at <!--BREATH_CUSTOM-->
# if it's a full HTML document, it's served as‑is
# dashboard_path: /etc/breath/dashboard.html

# SQLite history (creates DB if missing)
db_path: /var/lib/breathnetstatus/probes.db
history: 360       # samples contributing to uptime %
rtt_points: 60     # samples drawn in RTT sparkline

# default ports to probe if a target omits its own list
ports: [80, 443]

# prefer HTTPS for agent URLs (targets can override per‑entry)
ssl: false

# logging
log_level: silent  # silent|warn|info|debug

# probe timeouts (milliseconds)
icmp_timeout_ms: 1000
tcp_timeout_ms: 800

# anti‑flap thresholds (consecutive samples)
flip_up_min: 3
flip_down_min: 6

# Agent extras (if you want service status on /api/agent)
system_type: openrc         # openrc | systemd
services: [nftables, haproxy, radvd, dhcpcd, wg-quick.wg0, pppd]

# Who to probe (IPv6 shown here)
targets:
  - name: route.breathtechnology.co.uk
    site: Hampshire DC1
    addr: 2a02:8012:bc57::1
    agent: :9100         # see "Agent URL forms" below
    ports: [80, 443]

  - name: homer.breathtechnology.co.uk
    site: Hampshire DC1
    addr: 2a02:8012:bc57:3::2
    agent: https://[2a02:8012:bc57:3::2]:9100/api/agent
    ports: [80, 443, 873]

Agent URL forms:

  • "" — no agent link for this target.
  • ":9100" or "9100" — build http(s)://[addr]:9100/api/agent automatically.
  • "host:port" or "[v6]:port" — build http(s)://host:port/api/agent.
  • "http[s]://…" — used as‑is.
  1. Run it:
breathnetstatus -config /etc/breath/status.yaml
# UI → http://[::1]:8081/  (or your bound address)
# API → http://[::1]:8081/api/summary
# Agent → http://[::1]:9100/api/agent

Configuration reference

Top‑level keys in status.yaml:

Key Type Default Notes
interval duration 2s Probe cadence.
launch string server server | agent | both.
server_listen string ":8081" Where the UI/API listens.
agent_listen string ":9100" Where the Agent listens.
agent_name string hostname Display name returned by Agent.
agent_site string "" Site label for Agent.
dashboard_path string "" Optional HTML override for the built‑in dashboard.
db_path string "" SQLite file for probe history (optional but recommended).
history int 360 Samples used for uptime %.
rtt_points int 60 Samples shown in sparkline.
ports [int] [80,443] Default probe ports.
public_ui bool false (Reserved; prefer firewall to enforce.)
ssl bool false Prefer HTTPS when forming Agent URLs.
ssl_cert/ssl_key/ssl_ca string "" If set for Agent, runs TLS.
log_level string silent silent|warn|info|debug.
icmp_timeout_ms int 1000 ICMPv6 echo timeout.
tcp_timeout_ms int 800 TCP connect timeout.
flip_up_min int 3 Consecutive good samples to flip UP.
flip_down_min int 6 Consecutive bad samples to flip DOWN.
system_type string "" openrc or systemd for service checks.
services [string] [] List of service names to display in Agent response.
targets list See below.

Each targets item:

Key Type Notes
name string Display name (e.g. FQDN).
site string Site/role label.
addr string Host IPv6/IPv4 (v6 may be [addr] or bare).
agent string See "Agent URL forms" above.
ports [int] Overrides default ports.
ssl bool Prefer HTTPS for this target’s Agent link.

API

GET /api/summary

Returns JSON like:

{
  "rows": [
    {
      "name": "route.breathtechnology.co.uk",
      "site": "Hampshire DC1",
      "addr": "2a02:8012:bc57::1",
      "up": true,
      "ping_ms": 0.75,
      "uptime_pct": 100.0,
      "ports": [{"port":80,"state":"open"},{"port":443,"state":"open"}],
      "agent": "http://[2a02:8012:bc57::1]:9100/api/agent",
      "rtt_spark": [0.71,0.80,0.77]
    }
  ]
}

Port states: open | closed | filtered | error.

GET /api/agent

Agent JSON fields:

{
  "name":"route","site":"Hampshire DC1","host":"route.breathtechnology.co.uk",
  "os":"linux","arch":"amd64","go":"go1.24.x",
  "now":"2025-08-16T23:59:59Z",
  "uptime_sec": 3600,
  "host_uptime_sec": 90000,
  "ipv6":["2a02:8012:bc57::1","2a02:8012:bc57:3::1"],
  "load1":0.42,"load5":0.33,"load15":0.30,
  "mem_total_bytes":34359738368,"mem_free_bytes":1234567890,
  "system_type":"openrc",
  "services":[{"name":"nftables","state":"* status: started","active":true}]
}

Dashboard customisation

Provide a file via dashboard_path. Two modes:

  • Full document: if your file contains <html> it is served as‑is.
  • Fragment injection: if it is a body fragment, it is injected at the marker <!--BREATH_CUSTOM--> inside the built‑in page.

You can ship a minimal card (e.g. a link to your runbook) or a full replacement with your own branding.


OpenRC & systemd service units

OpenRC (Alpine)

Create /etc/init.d/breathnetstatus:

#!/sbin/openrc-run
name="breathnetstatus"
description="Breath IPv6 status monitor"
command="/usr/local/bin/breathnetstatus"
command_args="-config /etc/breath/status.yaml"
pidfile="/run/${RC_SVCNAME}.pid"
command_background=yes
output_log="/var/log/${RC_SVCNAME}.log"
error_log="/var/log/${RC_SVCNAME}.err"

depend() { need net localmount; use dns logger }
start_pre() { checkpath -d -m 0755 /var/lib/breathnetstatus; checkpath -f -m 0644 /var/log/${RC_SVCNAME}.log /var/log/${RC_SVCNAME}.err; }

Enable & start:

rc-update add breathnetstatus default
rc-service breathnetstatus start

If you want a separate Agent service on another host, you can reuse the same script and set launch: agent in that host’s /etc/breath/status.yaml.

systemd

Create /etc/systemd/system/breathnetstatus.service:

[Unit]
Description=Breath IPv6 status monitor
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/breathnetstatus -config /etc/breath/status.yaml
WorkingDirectory=/
User=root
AmbientCapabilities=CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_RAW
NoNewPrivileges=true
Restart=on-failure
RestartSec=2s

[Install]
WantedBy=multi-user.target

Then:

systemctl daemon-reload
systemctl enable --now breathnetstatus

Build notes (static & portable)

Plain build (glibc or musl host)

go build -trimpath -ldflags "-s -w" -o breathnetstatus

Mostly‑static for Alpine (musl)

apk add build-base musl-dev
CGO_ENABLED=1 CC=musl-gcc \
  go build -tags 'osusergo,netgo,sqlite_omit_load_extension' \
  -ldflags='-s -w -extldflags "-static"' \
  -o breathnetstatus

Notes:

  • The project uses the modernc.org/sqlite driver (no system SQLite needed). A fully static build on every platform may require CGO; the recipe above works well on Alpine.
  • If you do not need SQLite history, set db_path: "" and you can build without CGO.

Firewall examples (nftables)

Keep agents internal. Permit status polling from specific sources only; do not expose 8081/9100 on WAN. Examples assume ppp0 is your WAN interface.

Allow internal pollers, block WAN entirely

# Public WAN must NOT reach dashboard/agent
iifname "ppp0" tcp dport {8081,9100} \
  log prefix "nftables: drop WAN to status " flags all drop

# Allow the router/ops boxes to read status
ip6 saddr { 2a02:8012:bc57::1, 2a02:8012:bc57:1::1, 2a02:8012:bc57:3::1 } tcp dport 9100 \
  accept
ip6 saddr { 2a02:8012:bc57::1, 2a02:8012:bc57:1::1, 2a02:8012:bc57:3::1 } tcp dport 8081 \
  accept

Minimal WAN posture for HAProxy on the router

iifname "ppp0" ip daddr 217.155.241.55 tcp dport {80,443} accept  # public v4
# BreathGSLB on v4/v6 (if running on the router)
iifname "ppp0" ip  daddr 217.155.241.55  udp dport 53 accept
iifname "ppp0" ip  daddr 217.155.241.55  tcp dport 53 accept
iifname "ppp0" ip6 daddr 2a02:8012:bc57::1 udp dport 53 accept
iifname "ppp0" ip6 daddr 2a02:8012:bc57::1 tcp dport 53 accept
# Never allow SSH/MySQL from WAN → inside
iifname "ppp0" oifname { "eth0", "eth1", "eth3" } tcp dport {22,3306} \
  log prefix "nftables: reject WAN SSH/MySQL fwd " flags all \
  reject with icmpx type admin-prohibited

Make sure all your log prefixes begin with nftables: so they land in your nftables log.


Troubleshooting

  • Agent shows fewer services than expected: ensure system_type matches the host (openrc vs systemd) and the service names are correct (e.g. wg-quick.wg0).
  • Permission error sending ICMPv6 as non‑root: add cap_net_raw to the binary or run as root.
  • UI is blank: check the browser console for failed /api/summary fetches. If you set dashboard_path, verify the file path and that it’s readable.
  • History not loading after restart: ensure db_path directory exists and is writable by the service account.

The code - main.go

This will be moved to our git repo once fully setup and ready, until then the pleasure is below. The instructions are above and here you have a free dashboard to monitor your hosts, physical or virtual and its a two in one solution client, server or both.

To use the external dashboard.html mentioned above, simply scroll to the bottom of the code. find the indexHTML portion and save that as dashboard.html and place it in /etc/breath/dashboard.html and ensure you compile the entire block below. the external dashboard is fully optional and designed so the application is not fixed once compiled.

// BreathNetStatus — IPv6 status monitor (polling-only JSON API, graphs, SQLite, anti-flap, optional Agent)
//
// Changes in this build:
//   - Removed SSE entirely. Pure polling frontend.
//   - /api/summary always returns JSON (no HTML fallback).
//   - Stronger anti-flap defaults (flip_up_min=3, flip_down_min=6).
//   - Quiet by default; clean shutdown on Ctrl+C.
//   - SQLite logging (WAL) unchanged.
//   - NEW: Built-in Agent HTTP server (launch: agent or launch: both) at /api/agent.
//   - Agent now reports **host uptime** (kernel uptime) as `host_uptime_sec`, and **agent process uptime** as `uptime_sec`.
//   - Agent can optionally report **service status** for systemd or OpenRC based on config.
//   - UI: card grid, per-target port chips (green=open, red=closed, amber=filtered/error), sparklines, modal details,
//     name split into host + domain (display only), and better layout.
//   - External dashboard override supported via `dashboard_path` in YAML; hot‑reloads when the file changes.
//
// Build (Alpine/Arch):
//
//	apk add go git              |   pacman -S go git
//	go mod init akadata/breathnetstatus
//	go get gopkg.in/yaml.v3@v3 golang.org/x/net@latest modernc.org/sqlite@latest
//	go build -trimpath -ldflags="-s -w" -o /usr/local/bin/breathnetstatus
//	# optional to allow unprivileged ICMPv6 echo
//	setcap 'cap_net_raw+ep' /usr/local/bin/breathnetstatus
//
// Run:
//
//	/usr/local/bin/breathnetstatus -config /etc/breath/status.yaml
package main

import (
	"bytes"
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"os/exec"
	"os/signal"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"
	"sync"
	"syscall"
	"time"

	"golang.org/x/net/icmp"
	"golang.org/x/net/ipv6"
	"gopkg.in/yaml.v3"

	_ "modernc.org/sqlite" // driver name: "sqlite"
)

// ===== Config =====

type Config struct {
	Interval      Duration    `yaml:"interval"`
	Launch        string      `yaml:"launch"` // "server" | "agent" | "both" (default server)
	ServerListen  string      `yaml:"server_listen"`
	AgentListen   string      `yaml:"agent_listen"`
	AgentName     string      `yaml:"agent_name"`
	AgentSite     string      `yaml:"agent_site"`
	DashboardPath string      `yaml:"dashboard_path"`
	DBPath        string      `yaml:"db_path"`
	History       int         `yaml:"history"`    // uptime window (probes)
	RTTPoints     int         `yaml:"rtt_points"` // sparkline length
	Ports         []int       `yaml:"ports"`      // default ports
	PublicUI      bool        `yaml:"public_ui"`
	SSL           bool        `yaml:"ssl"`       // default: prefer HTTPS for agent URLs
	SSLCert       string      `yaml:"ssl_cert"`  // agent TLS cert (PEM)
	SSLKey        string      `yaml:"ssl_key"`   // agent TLS key (PEM)
	SSLCACert     string      `yaml:"ssl_ca"`    // (optional) chain/CA for agent cert
	LogLevel      string      `yaml:"log_level"` // silent|warn|info|debug (default silent)
	ICMPTimeoutMs int         `yaml:"icmp_timeout_ms"`
	TCPTimeoutMs  int         `yaml:"tcp_timeout_ms"`
	FlipUpMin     int         `yaml:"flip_up_min"`   // consecutive good samples to flip UP
	FlipDownMin   int         `yaml:"flip_down_min"` // consecutive bad samples to flip DOWN
	Targets       []TargetCfg `yaml:"targets"`

	// Agent extras
	SystemType string   `yaml:"system_type"` // "systemd" | "openrc" (optional)
	Services   []string `yaml:"services"`    // list of services to show status for (optional)
}

type TargetCfg struct {
	Name  string `yaml:"name"`
	Site  string `yaml:"site"`
	Addr  string `yaml:"addr"`
	Agent string `yaml:"agent"`
	Ports []int  `yaml:"ports"`
	SSL   bool   `yaml:"ssl"`
}

// Duration wrapper to parse YAML like "2s"

type Duration struct{ time.Duration }

func (d *Duration) UnmarshalYAML(fn func(any) error) error {
	var s string
	if err := fn(&s); err == nil {
		t, err := time.ParseDuration(s)
		if err != nil {
			return err
		}
		d.Duration = t
		return nil
	}
	var n int64
	if err := fn(&n); err == nil {
		d.Duration = time.Duration(n)
		return nil
	}
	return errors.New("invalid duration")
}

// ===== Runtime state =====

type PortStatus struct {
	Port  int    `json:"port"`
	State string `json:"state"` // open|closed|filtered|error
}

type Row struct {
	Name      string       `json:"name"`
	Site      string       `json:"site"`
	Addr      string       `json:"addr"`
	Up        bool         `json:"up"`
	PingMs    *float64     `json:"ping_ms"`
	UptimePct *float64     `json:"uptime_pct"`
	Ports     []PortStatus `json:"ports"`
	Agent     string       `json:"agent"`
	RTTSpark  []float64    `json:"rtt_spark,omitempty"`
}

type targetState struct {
	cfg        TargetCfg
	mu         sync.Mutex
	ping       *float64 // last RTT ms
	ports      []PortStatus
	hist       []bool    // uptime ring
	rttHist    []float64 // ring of RTT points (ms, -1 for N/A)
	i          int       // ring index
	up         bool
	consecUp   int
	consecDown int
	warm       int // warm-up samples taken
}

func (t *targetState) record(alive bool, rttMs *float64, ports []PortStatus, window, rttPoints, flipUp, flipDown int) {
	mu := &t.mu
	mu.Lock()
	defer mu.Unlock()

	// Debounce + warmup (avoid instant DOWN at startup)
	t.warm++
	if alive {
		t.consecUp++
		t.consecDown = 0
	} else {
		t.consecDown++
		t.consecUp = 0
	}
	newUp := t.up
	if t.warm < min(flipUp, flipDown) {
		// hold current up value during warmup; default stays false until stability is known
	} else {
		if !t.up && t.consecUp >= flipUp {
			newUp = true
		}
		if t.up && t.consecDown >= flipDown {
			newUp = false
		}
	}

	// Keep ports (reuse slice)
	out := t.ports[:0]
	out = append(out, ports...)
	t.ports = out
	t.up = newUp
	t.ping = rttMs

	if window <= 0 {
		window = 1
	}
	if t.hist == nil || len(t.hist) != window {
		t.hist = make([]bool, window)
		t.i = 0
	}
	t.hist[t.i%window] = newUp

	if rttPoints <= 0 {
		rttPoints = 60
	}
	if t.rttHist == nil || len(t.rttHist) != rttPoints {
		t.rttHist = make([]float64, rttPoints)
		for i := range t.rttHist {
			t.rttHist[i] = -1
		}
	}
	if rttMs != nil {
		t.rttHist[t.i%rttPoints] = *rttMs
	} else {
		t.rttHist[t.i%rttPoints] = -1
	}

	t.i++
}

func (t *targetState) snapshot(window, rttPoints int, httpsDefault bool) Row {
	// present a thread-safe snapshot of current target state
	mu := &t.mu
	mu.Lock()
	defer mu.Unlock()

	// uptime percentage from ring
	var pct *float64
	if n := len(t.hist); n > 0 {
		upCount := 0
		for _, v := range t.hist {
			if v {
				upCount++
			}
		}
		p := float64(upCount) * 100.0 / float64(n)
		pct = &p
	}

	// copy ports
	ports := make([]PortStatus, len(t.ports))
	copy(ports, t.ports)

	// sparkline data (chronological order)
	var spark []float64
	if n := len(t.rttHist); n > 0 {
		spark = make([]float64, n)
		for i := 0; i < n; i++ {
			spark[i] = t.rttHist[(t.i+i)%n]
		}
	}

	// agent URL preference: target SSL overrides global default
	preferHTTPS := httpsDefault
	if t.cfg.SSL {
		preferHTTPS = true
	}
	agentURL := normalizeAgentURL(trimBrackets(t.cfg.Addr), t.cfg.Agent, preferHTTPS)

	return Row{
		Name:      t.cfg.Name,
		Site:      t.cfg.Site,
		Addr:      trimBrackets(t.cfg.Addr),
		Up:        t.up,
		PingMs:    t.ping,
		UptimePct: pct,
		Ports:     ports,
		Agent:     agentURL,
		RTTSpark:  spark,
	}
}

// ===== Monitoring =====

type probeResult struct {
	When   time.Time
	Name   string
	Site   string
	Addr   string
	Alive  bool
	PingMs *float64
	Ports  []PortStatus
}

func monitor(ctx context.Context, st *targetState, cfg *Config, dbch chan<- probeResult) {
	addr := trimBrackets(st.cfg.Addr)
	ports := st.cfg.Ports
	if len(ports) == 0 {
		ports = cfg.Ports
	}
	icmpTO := durMs(cfg.ICMPTimeoutMs, 1000)
	tcpTO := durMs(cfg.TCPTimeoutMs, 800)
	flipUp := max(1, cfg.FlipUpMin)
	flipDown := max(2, cfg.FlipDownMin) // ensure some hysteresis

	for {
		select {
		case <-ctx.Done():
			return
		default:
		}
		// ICMPv6
		rtt, ok := icmpv6Once(addr, icmpTO)
		var rttPtr *float64
		if ok {
			ms := float64(rtt.Microseconds()) / 1000.0
			rttPtr = &ms
		}
		// TCP ports
		var ps []PortStatus
		anyOpen := false
		var tcpRTT *float64
		for _, p := range ports {
			stp, ms := tcpProbe(addr, p, tcpTO)
			if stp == "open" {
				anyOpen = true
			}
			if rttPtr == nil && ms != nil && tcpRTT == nil {
				tcpRTT = ms
			}
			ps = append(ps, PortStatus{Port: p, State: stp})
		}
		if rttPtr == nil && tcpRTT != nil {
			rttPtr = tcpRTT
		}
		alive := ok || anyOpen
		st.record(alive, rttPtr, ps, cfg.History, cfg.RTTPoints, flipUp, flipDown)
		if dbch != nil {
			select {
			case dbch <- probeResult{When: time.Now(), Name: st.cfg.Name, Site: st.cfg.Site, Addr: st.cfg.Addr, Alive: alive, PingMs: rttPtr, Ports: ps}:
			default:
			}
		}
		time.Sleep(cfg.Interval.Duration)
	}
}

func trimBrackets(s string) string {
	s = strings.TrimSpace(s)
	s = strings.TrimPrefix(s, "[")
	s = strings.TrimSuffix(s, "]")
	return s
}

// Build a usable Agent URL from config. Accepts:
//   - empty string => no agent
//   - digits or ":digits" => http://[addr]:port/api/agent
//   - bare host (with optional brackets/port) => http://<host>/api/agent
//   - full http(s) URL => as-is
func normalizeAgentURL(addr, agent string, https bool) string {
	s := strings.TrimSpace(agent)
	if s == "" {
		return ""
	}
	// already a full URL
	if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
		return s
	}

	scheme := "http"
	if https {
		scheme = "https"
	}

	// port-only forms: "9100" or ":9100"
	ps := s
	if strings.HasPrefix(ps, ":") {
		ps = ps[1:]
	}
	if ps != "" {
		if _, err := strconv.Atoi(ps); err == nil && strings.Trim(ps, "0123456789") == "" {
			port := s
			if !strings.HasPrefix(port, ":") {
				port = ":" + port
			}
			host := trimBrackets(addr)
			if host == "" {
				return ""
			}
			return fmt.Sprintf("%s://[%s]%s/api/agent", scheme, host, port)
		}
	}

	// otherwise treat as host[:port][/path]
	if strings.Contains(s, "://") {
		return s
	}
	// if no path, append /api/agent
	if !strings.Contains(s, "/") {
		return scheme + "://" + s + "/api/agent"
	}
	return scheme + "://" + s
}

func tcpProbe(addr string, port int, to time.Duration) (string, *float64) {
	gt := net.JoinHostPort(addr, strconv.Itoa(port))
	d := net.Dialer{Timeout: to}
	start := time.Now()
	c, err := d.Dial("tcp", gt)
	elapsed := time.Since(start)
	if err != nil {
		// Categorise
		if ne, ok := err.(net.Error); ok && ne.Timeout() {
			return "filtered", nil
		}
		msg := strings.ToLower(err.Error())
		if strings.Contains(msg, "refused") { // RST -> still a measurable RTT
			ms := float64(elapsed.Microseconds()) / 1000.0
			return "closed", &ms
		}
		if strings.Contains(msg, "no route") || strings.Contains(msg, "unreachable") {
			return "filtered", nil
		}
		return "error", nil
	}
	_ = c.Close()
	ms := float64(elapsed.Microseconds()) / 1000.0
	return "open", &ms
}

// One ICMPv6 Echo Request/Reply; returns rtt and ok
func icmpv6Once(addr string, to time.Duration) (time.Duration, bool) {
	c, err := icmp.ListenPacket("ip6:ipv6-icmp", "::")
	if err != nil {
		return 0, false
	}
	defer c.Close()
	_ = c.SetDeadline(time.Now().Add(to))
	w := icmp.Message{Type: ipv6.ICMPTypeEchoRequest, Code: 0, Body: &icmp.Echo{ID: os.Getpid() & 0xffff, Seq: int(time.Now().UnixNano() & 0xffff), Data: []byte("bt")}}
	b, _ := w.Marshal(nil)
	sent := time.Now()
	_, err = c.WriteTo(b, &net.IPAddr{IP: net.ParseIP(addr)})
	if err != nil {
		return 0, false
	}
	buf := make([]byte, 1500)
	n, _, err := c.ReadFrom(buf)
	if err != nil {
		return 0, false
	}
	// 58 is the IPv6-ICMP protocol number
	rm, err := icmp.ParseMessage(58, buf[:n])
	if err != nil {
		return 0, false
	}
	if rm.Type == ipv6.ICMPTypeEchoReply {
		return time.Since(sent), true
	}
	return 0, false
}

// ===== DB writer =====

func preloadFromDB(db *sql.DB, states []*targetState, cfg *Config) {
	if db == nil {
		return
	}
	m := map[string]*targetState{}
	for _, s := range states {
		m[s.cfg.Name] = s
	}
	for name, st := range m {
		window := cfg.History
		rttPoints := cfg.RTTPoints
		N := window
		if rttPoints > N {
			N = rttPoints
		}
		rows, err := db.Query(`SELECT up, ping_ms FROM probes WHERE name=? ORDER BY ts DESC LIMIT ?`, name, N)
		if err != nil {
			continue
		}
		recsUp := make([]int, 0, N)
		recsPing := make([]sql.NullFloat64, 0, N)
		for rows.Next() {
			var up int
			var ping sql.NullFloat64
			if err := rows.Scan(&up, &ping); err == nil {
				recsUp = append(recsUp, up)
				recsPing = append(recsPing, ping)
			}
		}
		rows.Close()
		// reverse to oldest->newest
		for i, j := 0, len(recsUp)-1; i < j; i, j = i+1, j-1 {
			recsUp[i], recsUp[j] = recsUp[j], recsUp[i]
			recsPing[i], recsPing[j] = recsPing[j], recsPing[i]
		}
		st.mu.Lock()
		st.hist = make([]bool, window)
		st.rttHist = make([]float64, rttPoints)
		for i := range st.rttHist {
			st.rttHist[i] = -1
		}
		idx := 0
		for k := 0; k < len(recsUp); k++ {
			if idx < window {
				st.hist[idx] = (recsUp[k] != 0)
			}
			if idx < rttPoints {
				if recsPing[k].Valid {
					st.rttHist[idx] = recsPing[k].Float64
				}
			}
			st.up = (recsUp[k] != 0)
			idx++
		}
		st.i = idx % max(window, rttPoints)
		st.mu.Unlock()
	}
}

func startDB(path string) (*sql.DB, chan<- probeResult, func(context.Context) error, error) {
	if strings.TrimSpace(path) == "" {
		return nil, nil, func(context.Context) error { return nil }, nil
	}
	dir := filepath.Dir(path)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return nil, nil, nil, err
	}
	dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&cache=shared"
	db, err := sql.Open("sqlite", dsn)
	if err != nil {
		return nil, nil, nil, err
	}
	_, _ = db.Exec(`PRAGMA synchronous=NORMAL; PRAGMA temp_store=MEMORY;`)
	if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS probes(
		ts INTEGER NOT NULL,
		name TEXT, site TEXT, addr TEXT,
		up INTEGER NOT NULL,
		ping_ms REAL,
		ports TEXT
	);`); err != nil {
		return nil, nil, nil, err
	}
	ins, err := db.Prepare(`INSERT INTO probes(ts,name,site,addr,up,ping_ms,ports) VALUES(?,?,?,?,?,?,?)`)
	if err != nil {
		return nil, nil, nil, err
	}
	ch := make(chan probeResult, 2048)
	go func() {
		buf := make([]probeResult, 0, 128)
		t := time.NewTicker(500 * time.Millisecond)
		defer t.Stop()
		for {
			select {
			case pr, ok := <-ch:
				if !ok {
					return
				}
				buf = append(buf, pr)
			case <-t.C:
				if len(buf) == 0 {
					continue
				}
				tx, err := db.Begin()
				if err != nil {
					buf = buf[:0]
					continue
				}
				for _, r := range buf {
					var ports string
					if b, err := json.Marshal(r.Ports); err == nil {
						ports = string(b)
					}
					var p *float64 = r.PingMs
					var pv any
					if p == nil {
						pv = nil
					} else {
						pv = *p
					}
					_, _ = tx.Stmt(ins).Exec(r.When.Unix(), r.Name, r.Site, r.Addr, boolToInt(r.Alive), pv, ports)
				}
				_ = tx.Commit()
				buf = buf[:0]
			}
		}
	}()
	stop := func(ctx context.Context) error { close(ch); return db.Close() }
	return db, ch, stop, nil
}

func boolToInt(b bool) int {
	if b {
		return 1
	}
	return 0
}
func durMs(v int, def int) time.Duration {
	if v <= 0 {
		v = def
	}
	return time.Duration(v) * time.Millisecond
}
func max(a, b int) int {
	if a > b {
		return a
	}
	return b
}
func min(a, b int) int {
	if a < b {
		return a
	}
	return b
}

// ===== HTTP: Monitor UI/API =====

type Summary struct {
	Rows []Row `json:"rows"`
}

func serveHTTP(cfg *Config, states []*targetState) *http.Server {
	mux := http.NewServeMux()

	// dashboard loader with hot-reload on mtime change
	var dashMu sync.RWMutex
	var dashCached []byte
	var dashM time.Time
	getIndex := func() []byte {
		path := strings.TrimSpace(cfg.DashboardPath)
		if path == "" {
			return []byte(indexHTML)
		}
		fi, err := os.Stat(path)
		if err != nil {
			return []byte(indexHTML)
		}
		dashMu.RLock()
		cached, mtime := dashCached, dashM
		dashMu.RUnlock()
		if cached != nil && !fi.ModTime().After(mtime) {
			return cached
		}
		b, err := os.ReadFile(path)
		if err != nil {
			return []byte(indexHTML)
		}
		build := func(body []byte) []byte {
			lb := bytes.ToLower(body)
			if bytes.Contains(lb, []byte("<html")) {
				return body // full document override
			}
			// treat as body fragment; inject at placeholder or before </body>
			if strings.Contains(indexHTML, "<!--BREATH_CUSTOM-->") {
				return []byte(strings.Replace(indexHTML, "<!--BREATH_CUSTOM-->", string(body), 1))
			}
			return []byte(strings.Replace(indexHTML, "</body>", string(body)+"</body>", 1))
		}
		out := build(b)
		dashMu.Lock()
		dashCached = out
		dashM = fi.ModTime()
		dashMu.Unlock()
		return out
	}

	// JSON API (strict path)
	mux.HandleFunc("/api/summary", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("Cache-Control", "no-store")
		w.Header().Set("X-Content-Type-Options", "nosniff")
		w.Header().Set("Access-Control-Allow-Origin", "*")
		rows := make([]Row, 0, len(states))
		for _, s := range states {
			rows = append(rows, s.snapshot(cfg.History, cfg.RTTPoints, cfg.SSL))
		}
		_ = json.NewEncoder(w).Encode(Summary{Rows: rows})
	})
	// Root UI (serves embedded page or external override)
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		w.Header().Set("Cache-Control", "no-store")
		_, _ = w.Write(getIndex())
	})

	addr := cfg.ServerListen
	if strings.TrimSpace(addr) == "" {
		addr = ":8081"
	}
	return &http.Server{Addr: addr, Handler: mux}
}

// ===== HTTP: Agent API =====

type ServiceStatus struct {
	Name   string `json:"name"`
	State  string `json:"state"`
	Active bool   `json:"active"`
}

type AgentInfo struct {
	Name       string          `json:"name"`
	Site       string          `json:"site"`
	Host       string          `json:"host"`
	OS         string          `json:"os"`
	Arch       string          `json:"arch"`
	Go         string          `json:"go"`
	Now        time.Time       `json:"now"`
	UptimeSec  int64           `json:"uptime_sec"`      // agent process uptime
	HostUptime int64           `json:"host_uptime_sec"` // system uptime (Linux /proc/uptime)
	IPv6       []string        `json:"ipv6"`
	Load1      *float64        `json:"load1,omitempty"`
	Load5      *float64        `json:"load5,omitempty"`
	Load15     *float64        `json:"load15,omitempty"`
	MemTotalB  *uint64         `json:"mem_total_bytes,omitempty"`
	MemFreeB   *uint64         `json:"mem_free_bytes,omitempty"`
	SystemType string          `json:"system_type,omitempty"`
	Services   []ServiceStatus `json:"services,omitempty"`
}

var processStarted = time.Now()

func serveAgent(cfg *Config) *http.Server {
	mux := http.NewServeMux()
	mux.HandleFunc("/api/agent", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("Cache-Control", "no-store")
		w.Header().Set("X-Content-Type-Options", "nosniff")
		w.Header().Set("Access-Control-Allow-Origin", "*")
		host, _ := os.Hostname()
		ai := AgentInfo{
			Name:       firstNonEmpty(cfg.AgentName, host),
			Site:       cfg.AgentSite,
			Host:       host,
			OS:         runtime.GOOS,
			Arch:       runtime.GOARCH,
			Go:         runtime.Version(),
			Now:        time.Now().UTC(),
			UptimeSec:  int64(time.Since(processStarted).Seconds()),
			HostUptime: linuxHostUptimeSec(),
			IPv6:       localIPv6List(),
			SystemType: strings.ToLower(strings.TrimSpace(cfg.SystemType)),
		}
		l1, l5, l15 := linuxLoad()
		ai.Load1, ai.Load5, ai.Load15 = l1, l5, l15
		tb, fb := linuxMem()
		ai.MemTotalB, ai.MemFreeB = tb, fb
		if len(cfg.Services) > 0 {
			ai.Services = serviceStatuses(ai.SystemType, cfg.Services)
		}
		_ = json.NewEncoder(w).Encode(ai)
	})
	addr := cfg.AgentListen
	if strings.TrimSpace(addr) == "" {
		addr = ":9100"
	}
	return &http.Server{Addr: addr, Handler: mux}
}

func serviceStatuses(sys string, names []string) []ServiceStatus {
	if len(names) == 0 {
		return nil
	}
	out := make([]ServiceStatus, 0, len(names))
	sys = strings.ToLower(strings.TrimSpace(sys))
	for _, n := range names {
		st := ServiceStatus{Name: n}
		var cmd *exec.Cmd
		switch sys {
		case "systemd":
			cmd = exec.Command("systemctl", "is-active", n)
		case "openrc", "rc", "init.d", "initd", "open-rc", "open_rc":
			cmd = exec.Command("rc-service", n, "status")
		default:
			out = append(out, ServiceStatus{Name: n, State: "unknown", Active: false})
			continue
		}
		var bout, berr bytes.Buffer
		cmd.Stdout = &bout
		cmd.Stderr = &berr
		_ = cmd.Run()
		res := strings.TrimSpace(bout.String())
		low := strings.ToLower(res)
		if sys == "systemd" {
			st.State = res
			st.Active = strings.HasPrefix(low, "active")
		} else {
			st.State = res
			st.Active = strings.Contains(low, "started") || strings.Contains(low, "running")
		}
		out = append(out, st)
	}
	return out
}

func firstNonEmpty(a, b string) string {
	if strings.TrimSpace(a) != "" {
		return a
	}
	return b
}

func localIPv6List() []string {
	var out []string
	ifs, _ := net.Interfaces()
	for _, it := range ifs {
		addrs, _ := it.Addrs()
		for _, a := range addrs {
			var ip net.IP
			switch v := a.(type) {
			case *net.IPNet:
				ip = v.IP
			case *net.IPAddr:
				ip = v.IP
			}
			if ip == nil {
				continue
			}
			if ip.To16() == nil || ip.To4() != nil {
				continue
			} // only v6
			if ip.IsLoopback() {
				continue
			}
			if !ip.IsGlobalUnicast() {
				continue
			}
			out = append(out, ip.String())
		}
	}
	return out
}

func linuxLoad() (*float64, *float64, *float64) {
	b, err := os.ReadFile("/proc/loadavg")
	if err != nil {
		return nil, nil, nil
	}
	parts := strings.Fields(string(b))
	if len(parts) < 3 {
		return nil, nil, nil
	}
	p := func(s string) *float64 {
		if v, err := strconv.ParseFloat(s, 64); err == nil {
			return &v
		}
		return nil
	}
	return p(parts[0]), p(parts[1]), p(parts[2])
}

func linuxMem() (*uint64, *uint64) {
	b, err := os.ReadFile("/proc/meminfo")
	if err != nil {
		return nil, nil
	}
	var tot, free uint64
	lines := strings.Split(string(b), "\n")
	for _, ln := range lines {
		if strings.HasPrefix(ln, "MemTotal:") {
			fields := strings.Fields(ln)
			if len(fields) >= 2 {
				v, _ := strconv.ParseUint(fields[1], 10, 64)
				tot = v * 1024
			}
		} else if strings.HasPrefix(ln, "MemFree:") {
			fields := strings.Fields(ln)
			if len(fields) >= 2 {
				v, _ := strconv.ParseUint(fields[1], 10, 64)
				free = v * 1024
			}
		}
	}
	var pt, pf *uint64
	if tot > 0 {
		pt = &tot
	}
	if free > 0 {
		pf = &free
	}
	return pt, pf
}

func linuxHostUptimeSec() int64 {
	b, err := os.ReadFile("/proc/uptime")
	if err != nil {
		return 0
	}
	fields := strings.Fields(string(b))
	if len(fields) == 0 {
		return 0
	}
	f, err := strconv.ParseFloat(fields[0], 64)
	if err != nil {
		return 0
	}
	return int64(f)
}

// ===== Main =====

var cfgPath = flag.String("config", "/etc/breath/status.yaml", "path to YAML config")

func main() {
	flag.Parse()
	cfg, err := loadConfig(*cfgPath)
	if err != nil {
		fatal(err)
	}
	// Defaults
	if cfg.Interval.Duration == 0 {
		cfg.Interval.Duration = 2 * time.Second
	}
	if cfg.History <= 0 {
		cfg.History = 360
	}
	if cfg.RTTPoints <= 0 {
		cfg.RTTPoints = 60
	}
	if cfg.ServerListen == "" {
		cfg.ServerListen = ":8081"
	}
	if cfg.AgentListen == "" {
		cfg.AgentListen = ":9100"
	}
	if cfg.FlipUpMin <= 0 {
		cfg.FlipUpMin = 3
	}
	if cfg.FlipDownMin <= 0 {
		cfg.FlipDownMin = 6
	}

	// Logging: quiet by default
	switch strings.ToLower(cfg.LogLevel) {
	case "debug":
		// keep default stderr
	case "info":
		// keep default stderr
	default:
		// silent/warn: only fatal will exit; drop other logs
		logSetSilent()
	}

	mode := strings.ToLower(strings.TrimSpace(cfg.Launch))
	if mode == "" {
		mode = "server"
	}
	runServer := (mode == "server" || mode == "both")
	runAgent := (mode == "agent" || mode == "both")

	// DB writer
	var db *sql.DB
	var dbch chan<- probeResult
	var stopDB func(context.Context) error = func(context.Context) error { return nil }
	if runServer {
		db, dbch, stopDB, err = startDB(cfg.DBPath)
		if err != nil {
			fatal(err)
		}
	}

	// Targets + monitor
	states := make([]*targetState, 0, len(cfg.Targets))
	var wg sync.WaitGroup
	ctx, cancel := context.WithCancel(context.Background())
	if runServer {
		for _, t := range cfg.Targets {
			states = append(states, &targetState{cfg: t})
		}
		if db != nil {
			preloadFromDB(db, states, &cfg)
		}
		for _, s := range states {
			wg.Add(1)
			go func(s *targetState) { defer wg.Done(); monitor(ctx, s, &cfg, dbch) }(s)
		}
	}

	// HTTP servers
	servers := []*http.Server{}
	if runServer {
		srv := serveHTTP(&cfg, states)
		servers = append(servers, srv)
		go func() {
			if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
				fatal(err)
			}
		}()
	}
	if runAgent {
		ag := serveAgent(&cfg)
		servers = append(servers, ag)
		go func() {
			var err error
			cert := strings.TrimSpace(cfg.SSLCert)
			key := strings.TrimSpace(cfg.SSLKey)
			if cert != "" && key != "" {
				err = ag.ListenAndServeTLS(cert, key)
			} else {
				err = ag.ListenAndServe()
			}
			if err != nil && !errors.Is(err, http.ErrServerClosed) {
				fatal(err)
			}
		}()
	}

	// Signals
	done := make(chan os.Signal, 1)
	signal.Notify(done, syscall.SIGINT, syscall.SIGTERM)
	<-done
	// Graceful stop
	cancel()
	ctxTO, cancelTO := context.WithTimeout(context.Background(), 5*time.Second)
	for _, s := range servers {
		_ = s.Shutdown(ctxTO)
	}
	cancelTO()
	wg.Wait()
	_ = stopDB(context.Background())
}

func loadConfig(path string) (Config, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		return Config{}, err
	}
	dec := yaml.NewDecoder(bytes.NewReader(b))
	dec.KnownFields(true)
	var c Config
	if err := dec.Decode(&c); err != nil {
		return Config{}, err
	}
	return c, nil
}

func fatal(err error) { fmt.Fprintln(os.Stderr, err.Error()); os.Exit(1) }
func logSetSilent()   { log = &silentLogger{} }

// tiny logger shim so we can be quiet without touching stdlib log package globally
var log logger = &stdLogger{}

type logger interface{ Printf(string, ...any) }

type stdLogger struct{}

func (*stdLogger) Printf(f string, a ...any) {
	fmt.Fprintf(io.Discard, f, a...) /* discard by default */
}

type silentLogger struct{}

func (*silentLogger) Printf(string, ...any) {}

// ===== Embedded UI (polling JSON + RTT sparklines) =====

var indexHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Breath IPv6 Status</title>
<style>
:root{--bg:#0b0d10;--card:#111418;--text:#e7edf5;--muted:#94a3b8;--border:rgba(148,163,184,.16);--good:#16a34a;--bad:#dc2626;--brand:#26c6da}
body{font:16px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,Arial;margin:0;background:var(--bg);color:var(--text)}
main{max-width:1300px;margin:24px auto;padding:0 16px}
h1{font-size:1.35rem;margin:0 0 6px}
small{color:var(--muted)} .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace} 
.grid{margin-top:16px;display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(300px,1fr))}
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:10px 12px;display:grid;grid-template-rows:auto auto auto 1fr auto auto; grid-auto-rows:min-content;row-gap:8px;box-shadow:0 10px 24px rgba(0,0,0,.18);overflow:hidden}
.card.down{background:linear-gradient(180deg, rgba(220,38,38,.15), transparent 38%), var(--card);} 
.head{display:flex;align-items:baseline;gap:8px;justify-content:space-between}
.name a{color:var(--text);text-decoration:none;font-weight:700}
.name a:hover{text-decoration:underline}
.site{color:var(--muted);font-size:.9rem}
.addr{color:var(--muted);font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.stats{display:flex;gap:10px;align-items:center;font-size:.95rem}
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-weight:700}
.good{color:var(--good)}.bad{color:var(--bad)}
.spark{height:44px}
canvas{display:block;width:100%;height:44px}
.ports{display:flex;flex-wrap:wrap;gap:6px}
.port{border:1px solid var(--border);border-radius:999px;padding:2px 8px;font-size:.9rem}
.port.ok{background:rgba(22,163,74,.22);border-color:rgba(22,163,74,.5)}
.port.bad{background:rgba(220,38,38,.22);border-color:rgba(220,38,38,.5)}
.port.warn{background:rgba(245,158,11,.22);border-color:rgba(245,158,11,.5)}
.port.muted{background:rgba(148,163,184,.16);border-color:var(--border);opacity:.8}
.actions{display:flex;justify-content:flex-end;margin-top:6px}
button{background:transparent;border:1px solid var(--border);color:var(--text);border-radius:8px;padding:4px 8px;cursor:pointer}
button:hover{border-color:var(--brand)}
/* Modal */
.modal{position:fixed;inset:0;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.55);z-index:10}
.modal.show{display:flex}
.dialog{max-width:720px;width:92vw;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px}
.dialog h2{margin:0 0 8px}
.row{display:flex;gap:12px;flex-wrap:wrap}
.kv{min-width:180px}
hr{border:none;border-top:1px solid var(--border);margin:12px 0}
</style>
</head>
<body>
<main>
  <h1>Breath IPv6 Status</h1>
  <small>Service first. Support before self. IPv6-only status page.</small>
  <div class="grid" id="grid"></div>
  <!--BREATH_CUSTOM-->
</main>
<div id="modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="md-title">
  <div class="dialog">
    <div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
      <h2 id="md-title">Host</h2>
      <button id="md-close" aria-label="Close">✕</button>
    </div>
    <div id="md-body"></div>
  </div>
</div>
<script>
const VISIBLE_MS=2000, HIDDEN_MS=15000; let pollTimer=null, controller=null; let lastJSON='';
const grid = document.getElementById('grid');
function renderIfChanged(d){
  try{ const j = JSON.stringify(d); if(j===lastJSON) return; lastJSON = j; render(d); }
  catch(_){ render(d); }
}
const modal = document.getElementById('modal');
const mdBody = document.getElementById('md-body');
const mdTitle = document.getElementById('md-title');
const mdClose = document.getElementById('md-close');
mdClose.onclick=()=>modal.classList.remove('show'); modal.addEventListener('click',e=>{ if(e.target===modal) modal.classList.remove('show'); });

function esc(s){return (s??'').toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;').replace(/'/g,'&#39;');}
function fmtMs(v){return (typeof v==='number')? v.toFixed(2)+' ms':'—'}
function fmtPct(v){return (typeof v==='number')? v.toFixed(2)+'%':'—'}
function fmtUptimeSec(s){ if(typeof s!=='number'||!isFinite(s)) return '—'; let d=Math.floor(s/86400); s%=86400; let h=Math.floor(s/3600); s%=3600; let m=Math.floor(s/60); const parts=[]; if(d) parts.push(d+'d'); if(h) parts.push(h+'h'); if(m) parts.push(m+'m'); return parts.join(' ')||'0m'; }
function addrURL(addr){ if(!addr) return '#'; return 'http://['+addr+']'; }
function splitHostDomain(n){ if(!n) return {host:'',domain:''}; const i=n.indexOf('.'); if(i<0) return {host:n,domain:''}; return {host:n.slice(0,i), domain:n.slice(i+1)} }
function chipPorts(a){ if(!Array.isArray(a)||!a.length) return '<span class="port warn">—</span>'; return a.map(function(p){ var st=(p.state||'').toLowerCase(); var cls= st==='open'?'ok':(st==='closed'?'bad':'warn'); var title=p.port+':'+st; return '<span class="port '+cls+'" title="'+title+'">'+p.port+'</span>'; }).join(''); }

async function poll() {
  if (controller) return;
  controller = new AbortController();
  try {
    const r = await fetch('/api/summary', { cache: 'no-store', signal: controller.signal });
    if (!r.ok) throw new Error('HTTP ' + r.status);
    const ct = (r.headers.get('content-type') || '').toLowerCase();
    if (!ct.includes('application/json')) throw new Error('non-json response');
    const d = await r.json();
    renderIfChanged(d);
  } catch (e) {
    // silent; page keeps last good render
  } finally {
    controller = null;
  }
}

function drawSpark(c,data) {
  const rect = c.getBoundingClientRect();
  const w = Math.max(120, Math.floor(rect.width));
  const h = 44;
  if (c.width !== w || c.height !== h) { c.width = w; c.height = h; }
  const ctx = c.getContext('2d');
  ctx.clearRect(0,0,w,h);
  if (!Array.isArray(data) || data.length === 0) return;
  const vals = data.filter(v => typeof v === 'number' && v >= 0);
  if (vals.length === 0) return;
  let min = Math.min(...vals), max = Math.max(...vals);
  if (min === max) { min -= 1; max += 1; }
  const N = data.length;
  const step = (w - 2) / (N - 1);
  const yScale = v => h - ((v - min) / (max - min)) * (h - 2) - 1; // 1px inset
  ctx.beginPath();
  let started = false;
  for (let i = 0; i < N; i++) {
    const v = data[i];
    if (typeof v !== 'number' || v < 0) continue; // gaps in series
    const x = 1 + i * step;
    const y = yScale(v);
    if (!started) { ctx.moveTo(x, y); started = true; }
    else { ctx.lineTo(x, y); }
  }
  ctx.lineWidth = 1;
  ctx.strokeStyle = '#26c6da';
  ctx.stroke();
}

function openModal(t){ mdTitle.textContent = t.name||t.addr; const a = esc(t.addr||'');
  const addrLink = a? ('<a class="mono" href="'+addrURL(a)+'" target="_blank" rel="noopener">['+a+']</a>') : '—';
  let html = '<div class="row"><div class="kv"><strong>Site</strong><br/>'+esc(t.site||'')+'</div>'+
    '<div class="kv"><strong>Address</strong><br/>'+addrLink+'</div>'+
    '<div class="kv"><strong>Status</strong><br/>'+'<span class="'+(t.up?'good':'bad')+'">'+(t.up?'UP':'DOWN')+'</span> · '+fmtMs(t.ping_ms)+' · '+fmtPct(t.uptime_pct)+'</div></div>'+
    '<hr/><div><strong>Ports</strong><div class="ports">'+chipPorts(t.ports)+'</div></div>';
  if(t.agent){ html += '<hr/><div><strong>Agent</strong> <small class="mono">'+esc(t.agent)+'</small><div id="agentbox">Loading…</div></div>'; }
  mdBody.innerHTML = html; modal.classList.add('show');
  if(t.agent){ fetch(t.agent,{cache:'no-store'}).then(r=>r.json()).then(j=>{
    const upHost = fmtUptimeSec(j.host_uptime_sec);
    const upProc = fmtUptimeSec(j.uptime_sec);
    const ips = Array.isArray(j.ipv6)? j.ipv6.map(esc).join(', '):'—';
    const MEM = (j.mem_total_bytes!=null)? (Math.round(j.mem_total_bytes/1024/1024)+' MiB total'):'—';
    const LOAD = (j.load1!=null)? (j.load1.toFixed(2)+', '+j.load5.toFixed(2)+', '+j.load15.toFixed(2)):'—';
    let svcs='';
    if(Array.isArray(j.services)&&j.services.length){
      svcs = '<div style="margin-top:8px"><strong>Services</strong><div class="ports">'+
        j.services.map(s=>'<span class="port '+(s.active?'ok':'bad')+'" title="'+esc(s.state||'')+'">'+esc(s.name)+'</span>').join('')+
        '</div></div>';
    }
    document.getElementById('agentbox').innerHTML = '<div class="row">'+
      '<div class="kv"><strong>Host</strong><br/>'+esc(j.host||'')+'</div>'+
      '<div class="kv"><strong>OS/Arch</strong><br/>'+esc(j.os||'')+'/'+esc(j.arch||'')+'</div>'+
      '<div class="kv"><strong>Go</strong><br/>'+esc(j.go||'')+'</div>'+
      '<div class="kv"><strong>Uptime</strong><br/>host '+upHost+' · agent '+upProc+'</div>'+
    '</div>'+
    '<div class="row">'+
      '<div class="kv"><strong>Load</strong><br/>'+LOAD+'</div>'+
      '<div class="kv"><strong>Memory</strong><br/>'+MEM+'</div>'+
    '</div>'+
    '<div style="margin-top:8px"><strong>IPv6</strong><br/><span class="mono">'+ips+'</span></div>'+
    svcs;
  }).catch(()=>{ document.getElementById('agentbox').textContent='Agent unreachable'; }); }
}

const cardMap = new Map();
function render(d){ const rows = Array.isArray(d?.rows)? d.rows:[]; const seen=new Set();
  for(const t of rows){ const key=t.name||t.addr; seen.add(key); let card = cardMap.get(key); if(!card){
      card = document.createElement('div'); card.className='card';
      card.innerHTML = '<div class="head"><div class="name"><a target="_blank" rel="noopener"></a></div><div class="site"></div></div>'+
        '<div class="addr mono"></div>'+
        '<div class="stats"></div>'+
        '<div class="spark"><canvas></canvas></div>'+
        '<div class="ports"></div>'+
        '<div class="actions"><button class="details">Details</button></div>';
      grid.appendChild(card); cardMap.set(key, card);
      card.querySelector('.details').addEventListener('click', ()=>openModal(t));
    }
    const up = !!t.up; card.classList.toggle('down', !up);
    const split = splitHostDomain(t.name||'');
    const a = card.querySelector('.name a'); a.textContent = split.host || t.addr || ''; a.href = addrURL(t.addr);
    const siteLine = (t.site? t.site : '') + (split.domain? (t.site? ' — ' : '') + split.domain : '');
    card.querySelector('.site').textContent = siteLine;
    card.querySelector('.addr').textContent = t.addr? ('['+t.addr+']'):'—';
    card.querySelector('.stats').innerHTML = '<span class="badge '+(up?'good':'bad')+'">'+(up?'UP':'DOWN')+'</span> <span>'+fmtMs(t.ping_ms)+'</span> <span>'+fmtPct(t.uptime_pct)+'</span>';
    drawSpark(card.querySelector('canvas'), t.rtt_spark);
    card.querySelector('.ports').innerHTML = chipPorts(t.ports);
    // update modal binding each render so it uses latest row snapshot
    card.querySelector('.details').onclick = ()=>openModal(t);
  }
  for(const [k,el] of cardMap){ if(!seen.has(k)){ el.remove(); cardMap.delete(k); } }
}

function schedule(ms){ if(pollTimer) clearInterval(pollTimer); pollTimer = setInterval(poll, ms); }
  document.addEventListener('visibilitychange', ()=> schedule(document.hidden?HIDDEN_MS:VISIBLE_MS));
  window.addEventListener('focus', ()=> schedule(VISIBLE_MS));
  window.addEventListener('blur', ()=> schedule(HIDDEN_MS));
  window.addEventListener('load',()=>{ schedule(document.hidden?HIDDEN_MS:VISIBLE_MS); poll(); });
</script>
</body></html>`

Issues and PRs are welcome. Keep the code small, dependencies tight, and defaults secure.


License (MIT)

Copyright (c) 2025 Breath Technology

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.