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
- 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
- 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"
— buildhttp(s)://[addr]:9100/api/agent
automatically."host:port"
or"[v6]:port"
— buildhttp(s)://host:port/api/agent
."http[s]://…"
— used as‑is.
- 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
vssystemd
) 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 setdashboard_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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\"/g,'"').replace(/'/g,''');}
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.