feat: system monitoring + push agent (merge plan M3)

systems + system_stats tables; agents push JSON every N seconds
to POST /api/systems/ingest with a per-system bearer token. The
server stores each sample, keeps a `latest` snapshot for cheap
list/widget reads, prunes stats after 7d, and marks systems down
when silent > max(3x interval, 30s) — firing system.up/down
webhook events on transitions.

cmd/dash-agent is a standalone Linux collector: /proc for
cpu/mem/net/load/uptime, /sys hwmon temps, statfs disk, and
container liveness via /var/run/docker.sock when present.
No imports from the server module — the sample shape is
duplicated deliberately so the agent stays a single static
binary.

Frontend: /systems card grid with usage bars, /systems/:id with
recharts cpu/mem/net/load, temps + containers tables, and the
agent command; `system` board widget; nav + palette + WidgetDialog
picker wired.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-21 19:02:58 +02:00
1 parent 2f3e8a1879
commit 74c987c18a
16 files changed
+1541 -10

No files matched your search

+7 -6
View File
@@ -172,12 +172,13 @@ Domain-locker-grade domain intelligence.
Beszel-style server monitoring, push-based. Beszel-style server monitoring, push-based.
- [ ] Schema: `systems` (name, token, host, os/arch, last_seen, status), `system_stats` (system_id, ts, cpu, mem, disk, net, load, temps JSON, containers JSON — retention prune) - [x] Schema: `systems` (name, token, host, os/arch, last_seen, status, `latest` JSON snapshot), `system_stats` (system_id, ts, payload JSON — one column, no sub-field queries; 7d retention prune)
- [ ] Ingest: `POST /api/systems/ingest` with per-system bearer token; offline when silent > 3× interval - [x] Ingest: `POST /api/systems/ingest` with per-system bearer token; offline when silent > max(3×interval, 30s); `system.up`/`system.down` webhook events
- [ ] `cmd/dash-agent`: Linux-first collector — `/proc` (cpu/mem/net/load/uptime), `/sys` hwmon temps, statfs disk; container stats when `/var/run/docker.sock` present; POST every 10s; systemd unit + Dockerfile - [x] `cmd/dash-agent`: Linux collector — `/proc` (cpu/mem/net/load/uptime), `/sys` hwmon temps, statfs disk, docker.sock container list; POST every `-interval` (default 10s); `-once` debug mode
- [ ] Systems page (gauge cards, uptime, last seen) + detail (recharts: cpu%, mem, disk io, net, temps) - [ ] Agent packaging: systemd unit + Dockerfile + release binaries (Phase 4)
- [ ] Board widget `system` (cpu/mem bars) - [x] Systems page (status cards, cpu/mem/disk bars, uptime) + detail (recharts: cpu%, mem%, net rx/tx, load; temps + containers tables)
- [ ] Not ported: the beszel agent protocol (SSH/WS into PocketBase) — our agent is push-JSON. SMART/ZFS/GPU metrics deferred to demand. - [x] Board widget `system` (cpu/mem/disk mini bars)
- [x] Not ported: the beszel agent protocol (SSH/WS into PocketBase) — our agent is push-JSON. SMART/ZFS/GPU metrics + per-container cpu/mem deferred to demand.
### Phase M4 — Ops layer → v0.7.0 ### Phase M4 — Ops layer → v0.7.0
+359
View File
@@ -0,0 +1,359 @@
// Command dash-agent collects host metrics and pushes them to a Dash server.
// Linux-first: /proc for cpu/mem/net/load/uptime, /sys hwmon for temps,
// statfs for disk, and the Docker socket for container liveness when present.
//
// env DASH_URL=http://dash:8080 DASH_TOKEN=agent_xxx dash-agent
//
// Flags override env: -url, -token, -interval (seconds), -once.
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"runtime"
"strconv"
"strings"
"syscall"
"time"
)
func main() {
url := flag.String("url", os.Getenv("DASH_URL"), "dash server base URL")
token := flag.String("token", os.Getenv("DASH_TOKEN"), "system token")
interval := flag.Int("interval", envInt("DASH_INTERVAL", 10), "push interval seconds")
once := flag.Bool("once", false, "collect and print one sample, no push")
flag.Parse()
c := &collector{}
c.host, _ = os.Hostname()
c.os = runtime.GOOS
c.arch = runtime.GOARCH
c.cpuModel, c.cores = cpuInfo()
c.prevCPU = readCPUTicks()
c.prevNet = readNetBytes()
c.prevAt = time.Now()
if *once {
// Rates need a delta window — idle briefly so cpu/net are real.
time.Sleep(500 * time.Millisecond)
s := c.sample(*interval)
out, _ := json.MarshalIndent(s, "", " ")
fmt.Println(string(out))
return
}
if *url == "" || *token == "" {
log.Fatal("dash-agent: -url and -token (or DASH_URL/DASH_TOKEN) required")
}
log.Printf("dash-agent: pushing to %s every %ds", *url, *interval)
for {
s := c.sample(*interval)
if err := push(*url, *token, s); err != nil {
log.Printf("dash-agent: push: %v", err)
}
time.Sleep(time.Duration(*interval) * time.Second)
}
}
func envInt(k string, def int) int {
if v, err := strconv.Atoi(os.Getenv(k)); err == nil && v > 0 {
return v
}
return def
}
// sample mirrors api.StatSample — duplicated here so the agent stays a
// standalone binary with zero imports from the server module.
type sample struct {
Host string `json:"host"`
OS string `json:"os"`
Arch string `json:"arch"`
CPUModel string `json:"cpuModel"`
Cores int `json:"cores"`
IntervalS int `json:"intervalS"`
UptimeS float64 `json:"uptimeS"`
CPU float64 `json:"cpu"`
MemTotal float64 `json:"memTotal"`
MemUsed float64 `json:"memUsed"`
SwapTotal float64 `json:"swapTotal"`
SwapUsed float64 `json:"swapUsed"`
DiskTotal float64 `json:"diskTotal"`
DiskUsed float64 `json:"diskUsed"`
NetRx float64 `json:"netRx"`
NetTx float64 `json:"netTx"`
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
Temps map[string]float64 `json:"temps,omitempty"`
Containers []containerStat `json:"containers,omitempty"`
}
type containerStat struct {
Name string `json:"name"`
State string `json:"state"`
}
type collector struct {
host, os, arch, cpuModel string
cores int
prevCPU cpuTicks
prevNet [2]uint64 // rx, tx bytes
prevAt time.Time
}
type cpuTicks struct{ idle, total uint64 }
func (c *collector) sample(interval int) sample {
s := sample{
Host: c.host, OS: c.os, Arch: c.arch,
CPUModel: c.cpuModel, Cores: c.cores, IntervalS: interval,
}
now := time.Now()
dt := now.Sub(c.prevAt).Seconds()
if dt <= 0 {
dt = float64(interval)
}
if cur := readCPUTicks(); cur.total > c.prevCPU.total {
busy := float64(cur.total-cur.idle) - float64(c.prevCPU.total-c.prevCPU.idle)
s.CPU = busy / float64(cur.total-c.prevCPU.total) * 100
c.prevCPU = cur
}
s.MemTotal, s.MemUsed, s.SwapTotal, s.SwapUsed = readMem()
s.DiskTotal, s.DiskUsed = diskUsage("/")
if cur := readNetBytes(); cur[0] >= c.prevNet[0] {
s.NetRx = float64(cur[0]-c.prevNet[0]) / dt
s.NetTx = float64(cur[1]-c.prevNet[1]) / dt
c.prevNet = cur
}
c.prevAt = now
s.Load1, s.Load5, s.Load15 = readLoad()
s.UptimeS = readUptime()
s.Temps = readTemps()
s.Containers = readContainers()
return s
}
func push(url, token string, s sample) error {
body, _ := json.Marshal(s)
req, _ := http.NewRequest(http.MethodPost,
strings.TrimRight(url, "/")+"/api/systems/ingest", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
res, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return err
}
defer res.Body.Close()
io.Copy(io.Discard, res.Body)
if res.StatusCode >= 300 {
return fmt.Errorf("server returned %d", res.StatusCode)
}
return nil
}
// ---------- /proc + /sys readers (Linux; all no-op-safe elsewhere) ----------
// readCPUTicks parses the aggregate cpu line: fields are user nice system
// idle iowait irq softirq steal — everything but idle+iowait counts busy.
func readCPUTicks() cpuTicks {
b, err := os.ReadFile("/proc/stat")
if err != nil {
return cpuTicks{}
}
var t cpuTicks
for _, line := range strings.Split(string(b), "\n") {
if !strings.HasPrefix(line, "cpu ") {
continue
}
f := strings.Fields(line)[1:]
var vals []uint64
for _, v := range f {
n, _ := strconv.ParseUint(v, 10, 64)
vals = append(vals, n)
t.total += n
}
if len(vals) >= 5 {
t.idle = vals[3] + vals[4]
}
break
}
return t
}
func cpuInfo() (model string, cores int) {
b, err := os.ReadFile("/proc/cpuinfo")
if err != nil {
return "", runtime.NumCPU()
}
for _, line := range strings.Split(string(b), "\n") {
if strings.HasPrefix(line, "processor") {
cores++
}
if model == "" && strings.HasPrefix(line, "model name") {
model = strings.TrimSpace(strings.SplitN(line, ":", 2)[1])
}
}
if cores == 0 {
cores = runtime.NumCPU()
}
return model, cores
}
// readMem returns total/used bytes for RAM and swap from /proc/meminfo.
func readMem() (memTotal, memUsed, swapTotal, swapUsed float64) {
b, err := os.ReadFile("/proc/meminfo")
if err != nil {
return
}
kb := map[string]float64{}
for _, line := range strings.Split(string(b), "\n") {
f := strings.Fields(line)
if len(f) >= 2 {
kb[strings.TrimSuffix(f[0], ":")], _ = strconv.ParseFloat(f[1], 64)
}
}
memTotal = kb["MemTotal"] * 1024
memUsed = (kb["MemTotal"] - kb["MemAvailable"]) * 1024
swapTotal = kb["SwapTotal"] * 1024
swapUsed = (kb["SwapTotal"] - kb["SwapFree"]) * 1024
return
}
func diskUsage(path string) (total, used float64) {
var st syscall.Statfs_t
if err := syscall.Statfs(path, &st); err != nil {
return 0, 0
}
total = float64(st.Blocks) * float64(st.Bsize)
used = float64(st.Blocks-st.Bfree) * float64(st.Bsize)
return total, used
}
// readNetBytes sums rx/tx across interfaces, skipping loopback.
func readNetBytes() [2]uint64 {
b, err := os.ReadFile("/proc/net/dev")
if err != nil {
return [2]uint64{}
}
var rx, tx uint64
for _, line := range strings.Split(string(b), "\n") {
i := strings.Index(line, ":")
if i < 0 || strings.Contains(line[:i], "lo") {
continue
}
f := strings.Fields(line[i+1:])
if len(f) >= 9 {
r, _ := strconv.ParseUint(f[0], 10, 64)
t, _ := strconv.ParseUint(f[8], 10, 64)
rx += r
tx += t
}
}
return [2]uint64{rx, tx}
}
func readLoad() (l1, l5, l15 float64) {
b, err := os.ReadFile("/proc/loadavg")
if err != nil {
return
}
f := strings.Fields(string(b))
if len(f) >= 3 {
l1, _ = strconv.ParseFloat(f[0], 64)
l5, _ = strconv.ParseFloat(f[1], 64)
l15, _ = strconv.ParseFloat(f[2], 64)
}
return
}
func readUptime() float64 {
b, err := os.ReadFile("/proc/uptime")
if err != nil {
return 0
}
f := strings.Fields(string(b))
if len(f) > 0 {
v, _ := strconv.ParseFloat(f[0], 64)
return v
}
return 0
}
// readTemps walks /sys/class/hwmon for temp*_input sensors.
func readTemps() map[string]float64 {
out := map[string]float64{}
hwmons, _ := os.ReadDir("/sys/class/hwmon")
for _, hw := range hwmons {
dir := "/sys/class/hwmon/" + hw.Name()
chip, _ := os.ReadFile(dir + "/name")
entries, _ := os.ReadDir(dir)
for _, e := range entries {
name := e.Name()
if !strings.HasPrefix(name, "temp") || !strings.HasSuffix(name, "_input") {
continue
}
b, err := os.ReadFile(dir + "/" + name)
if err != nil {
continue
}
millideg, err := strconv.ParseFloat(strings.TrimSpace(string(b)), 64)
if err != nil || millideg <= 0 || millideg > 150000 {
continue
}
label := strings.TrimSpace(string(chip))
if lb, err := os.ReadFile(dir + "/" + strings.TrimSuffix(name, "_input") + "_label"); err == nil {
if l := strings.TrimSpace(string(lb)); l != "" {
label = l
}
}
if _, dup := out[label]; !dup {
out[label] = millideg / 1000
}
}
}
return out
}
// readContainers lists Docker containers via the unix socket when present.
func readContainers() []containerStat {
if _, err := os.Stat("/var/run/docker.sock"); err != nil {
return nil
}
client := &http.Client{
Timeout: 3 * time.Second,
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock")
},
},
}
res, err := client.Get("http://d/containers/json?all=1")
if err != nil {
return nil
}
defer res.Body.Close()
var raw []struct {
Names []string `json:"Names"`
State string `json:"State"`
}
if json.NewDecoder(res.Body).Decode(&raw) != nil {
return nil
}
out := make([]containerStat, 0, len(raw))
for _, r := range raw {
name := ""
if len(r.Names) > 0 {
name = strings.TrimPrefix(r.Names[0], "/")
}
out = append(out, containerStat{Name: name, State: r.State})
}
return out
}
+9
View File
@@ -41,6 +41,7 @@ func newServer(logger *zap.Logger, db *sql.DB, iconsDir string) *Server {
s.lookup = domain.NewLookup().Run s.lookup = domain.NewLookup().Run
widget.Register(&monitorWidget{db: db}) widget.Register(&monitorWidget{db: db})
widget.Register(&domainWidget{db: db}) widget.Register(&domainWidget{db: db})
widget.Register(&systemWidget{db: db})
return s return s
} }
@@ -92,6 +93,14 @@ func (s *Server) routes() *gin.Engine {
v1.GET("/domains/:id/checks", s.domainChecks) v1.GET("/domains/:id/checks", s.domainChecks)
v1.POST("/notify/test", s.notifyTest) v1.POST("/notify/test", s.notifyTest)
v1.GET("/systems", s.listSystemsH)
v1.POST("/systems", s.createSystem)
v1.GET("/systems/:id", s.getSystem)
v1.PATCH("/systems/:id", s.patchSystem)
v1.DELETE("/systems/:id", s.deleteSystem)
v1.GET("/systems/:id/stats", s.systemStats)
v1.POST("/systems/ingest", s.ingestSystem)
v1.GET("/status", s.getStatus) v1.GET("/status", s.getStatus)
v1.GET("/widgets/types", s.widgetTypes) v1.GET("/widgets/types", s.widgetTypes)
v1.GET("/widgets/:id/data", s.widgetData) v1.GET("/widgets/:id/data", s.widgetData)
+96
View File
@@ -539,6 +539,102 @@ func TestNotifyWebhook(t *testing.T) {
} }
} }
func TestSystemIngest(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// No token -> 401.
res, _ := do(t, "POST", srv.URL+"/api/systems/ingest", `{"cpu":1}`)
if res.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", res.StatusCode)
}
res, sys := do(t, "POST", srv.URL+"/api/systems", `{"name":"nas"}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create: %v", sys)
}
id, token := sys["id"].(string), sys["token"].(string)
if token == "" {
t.Fatal("no agent token issued")
}
// Bad token -> 401.
req, _ := http.NewRequest("POST", srv.URL+"/api/systems/ingest",
strings.NewReader(`{"cpu":50}`))
req.Header.Set("Authorization", "Bearer wrong")
if r, err := http.DefaultClient.Do(req); err != nil || r.StatusCode != http.StatusUnauthorized {
t.Fatalf("bad token: %v %v", r.StatusCode, err)
}
// Real push -> system goes up, latest + stats stored.
push := func() (*http.Response, map[string]any) {
t.Helper()
req, _ := http.NewRequest("POST", srv.URL+"/api/systems/ingest",
strings.NewReader(`{"host":"nas.local","os":"linux","arch":"amd64",
"cpuModel":"Test CPU","cores":4,"cpu":42.5,"memTotal":8000,"memUsed":4000,
"diskTotal":100000,"diskUsed":50000,"netRx":100,"netTx":50,"load1":1.5}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("push: %v", err)
}
defer res.Body.Close()
var out map[string]any
_ = json.NewDecoder(res.Body).Decode(&out)
return res, out
}
if res, out := push(); res.StatusCode != http.StatusOK {
t.Fatalf("ingest: %v", out)
}
res, sys = do(t, "GET", srv.URL+"/api/systems/"+id, "")
if res.StatusCode != http.StatusOK || sys["status"] != "up" {
t.Fatalf("get: %v", sys)
}
if sys["host"] != "nas.local" || sys["cores"].(float64) != 4 {
t.Fatalf("host fields not stored: %v", sys)
}
latest := sys["latest"].(map[string]any)
if latest["cpu"].(float64) != 42.5 {
t.Fatalf("latest not stored: %v", latest)
}
res, list := do(t, "GET", srv.URL+"/api/systems/"+id+"/stats?hours=1", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("stats: %v", list)
}
// stats returns an array — decode separately.
req2, _ := http.NewRequest("GET", srv.URL+"/api/systems/"+id+"/stats?hours=1", nil)
r2, _ := http.DefaultClient.Do(req2)
var arr []map[string]any
_ = json.NewDecoder(r2.Body).Decode(&arr)
r2.Body.Close()
if len(arr) != 1 || arr[0]["cpu"].(float64) != 42.5 {
t.Fatalf("stats rows: %v", arr)
}
// Widget fetcher serves the latest sample.
_, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
_, w := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","kind":"widget","name":"S","config":{"type":"system","systemId":"`+id+`"}}`)
res, data := do(t, "GET", srv.URL+"/api/widgets/"+w["id"].(string)+"/data", "")
inner, _ := data["data"].(map[string]any)
if res.StatusCode != http.StatusOK || inner["name"] != "nas" || inner["cpu"].(float64) != 42.5 {
t.Fatalf("system widget: %d %v", res.StatusCode, data)
}
// Rename + token rotation.
res, sys = do(t, "PATCH", srv.URL+"/api/systems/"+id, `{"name":"nas2","rotateToken":true}`)
if sys["name"] != "nas2" || sys["token"] == token {
t.Fatalf("patch: %v", sys)
}
res, _ = do(t, "DELETE", srv.URL+"/api/systems/"+id, "")
if res.StatusCode != http.StatusNoContent {
t.Fatalf("delete: %d", res.StatusCode)
}
}
func TestMidpoint(t *testing.T) { func TestMidpoint(t *testing.T) {
f := func(v float64) *float64 { return &v } f := func(v float64) *float64 { return &v }
if got := midpoint(nil, nil); got != 1024 { if got := midpoint(nil, nil); got != 1024 {
+5
View File
@@ -411,6 +411,10 @@ func (s *Server) runScheduler(done <-chan struct{}) {
if _, err := s.db.Exec(`DELETE FROM heartbeats WHERE checked_at < datetime('now','-30 days')`); err != nil { if _, err := s.db.Exec(`DELETE FROM heartbeats WHERE checked_at < datetime('now','-30 days')`); err != nil {
s.log.Error("heartbeat prune", zap.Error(err)) s.log.Error("heartbeat prune", zap.Error(err))
} }
// System samples are high-rate; a week of raw ticks is enough.
if _, err := s.db.Exec(`DELETE FROM system_stats WHERE ts < datetime('now','-7 days')`); err != nil {
s.log.Error("system stats prune", zap.Error(err))
}
} }
} }
} }
@@ -453,6 +457,7 @@ func (s *Server) sweep() {
} }
s.sweepDomains() s.sweepDomains()
s.sweepSystems()
} }
// runCheck executes one monitor check (with retries) and persists the result. // runCheck executes one monitor check (with retries) and persists the result.
+343
View File
@@ -0,0 +1,343 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/tdvorak/dash/internal/widget"
"go.uber.org/zap"
)
// ---------- model ----------
// StatSample is one agent payload. Numeric fields are optional-ish: the
// agent may omit what a platform cannot provide.
type StatSample struct {
Host string `json:"host"`
OS string `json:"os"`
Arch string `json:"arch"`
CPUModel string `json:"cpuModel"`
Cores int `json:"cores"`
IntervalS int `json:"intervalS"`
UptimeS float64 `json:"uptimeS"`
CPU float64 `json:"cpu"` // percent
MemTotal float64 `json:"memTotal"` // bytes
MemUsed float64 `json:"memUsed"`
SwapTotal float64 `json:"swapTotal"`
SwapUsed float64 `json:"swapUsed"`
DiskTotal float64 `json:"diskTotal"` // bytes, root fs
DiskUsed float64 `json:"diskUsed"`
NetRx float64 `json:"netRx"` // bytes/sec
NetTx float64 `json:"netTx"`
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
Temps map[string]float64 `json:"temps"`
Containers []ContainerStat `json:"containers"`
}
// ContainerStat is one Docker container's liveness; per-container CPU/mem
// needs cgroup sampling — deferred (v1 ships name+state only).
type ContainerStat struct {
Name string `json:"name"`
State string `json:"state"`
}
// System is one monitored host; `latest` is the newest StatSample.
type System struct {
ID string `json:"id"`
Name string `json:"name"`
Token string `json:"token"`
Host string `json:"host"`
OS string `json:"os"`
Arch string `json:"arch"`
CPUModel string `json:"cpuModel"`
Cores int `json:"cores"`
IntervalS int `json:"intervalS"`
Status string `json:"status"` // pending | up | down
LastSeenAt *string `json:"lastSeenAt"`
Latest *StatSample `json:"latest"`
CreatedAt string `json:"createdAt"`
}
const systemCols = `id, name, token, host, os, arch, cpu_model, cores,
interval_s, status, last_seen_at, latest, created_at`
func scanSystem(row interface{ Scan(...any) error }) (*System, error) {
var sys System
var latest string
err := row.Scan(&sys.ID, &sys.Name, &sys.Token, &sys.Host, &sys.OS,
&sys.Arch, &sys.CPUModel, &sys.Cores, &sys.IntervalS, &sys.Status,
&sys.LastSeenAt, &latest, &sys.CreatedAt)
if err != nil {
return nil, err
}
if latest != "" && latest != "{}" {
var s StatSample
if json.Unmarshal([]byte(latest), &s) == nil {
sys.Latest = &s
}
}
return &sys, nil
}
func (s *Server) loadSystem(id string) (*System, error) {
return scanSystem(s.db.QueryRow(`SELECT `+systemCols+` FROM systems WHERE id = ?`, id))
}
func (s *Server) loadSystemByToken(token string) (*System, error) {
return scanSystem(s.db.QueryRow(
`SELECT `+systemCols+` FROM systems WHERE token = ?`, token))
}
// ---------- handlers ----------
func (s *Server) listSystemsH(c *gin.Context) {
rows, err := s.db.Query(`SELECT ` + systemCols + ` FROM systems ORDER BY name`)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
out := []System{}
for rows.Next() {
sys, err := scanSystem(rows)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
out = append(out, *sys)
}
c.JSON(http.StatusOK, out)
}
func (s *Server) createSystem(c *gin.Context) {
var in struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&in); err != nil || in.Name == "" {
fail(c, http.StatusBadRequest, "name required")
return
}
id := newID("sys")
if _, err := s.db.Exec(`INSERT INTO systems (id, name, token) VALUES (?,?,?)`,
id, in.Name, newID("agent")); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
sys, err := s.loadSystem(id)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusCreated, sys)
}
func (s *Server) getSystem(c *gin.Context) {
sys, err := s.loadSystem(c.Param("id"))
if notFound(err) {
fail(c, http.StatusNotFound, "system not found")
return
}
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, sys)
}
func (s *Server) patchSystem(c *gin.Context) {
id := c.Param("id")
if _, err := s.loadSystem(id); notFound(err) {
fail(c, http.StatusNotFound, "system not found")
return
}
var in struct {
Name *string `json:"name"`
Rotate *bool `json:"rotateToken"`
}
if err := c.ShouldBindJSON(&in); err != nil {
fail(c, http.StatusBadRequest, "invalid body")
return
}
if in.Name != nil && *in.Name != "" {
_, _ = s.db.Exec(`UPDATE systems SET name = ? WHERE id = ?`, *in.Name, id)
}
if in.Rotate != nil && *in.Rotate {
_, _ = s.db.Exec(`UPDATE systems SET token = ? WHERE id = ?`, newID("agent"), id)
}
sys, err := s.loadSystem(id)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, sys)
}
func (s *Server) deleteSystem(c *gin.Context) {
res, err := s.db.Exec(`DELETE FROM systems WHERE id = ?`, c.Param("id"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(c, http.StatusNotFound, "system not found")
return
}
c.Status(http.StatusNoContent)
}
// systemStats returns stored samples newest-last for charting.
func (s *Server) systemStats(c *gin.Context) {
if _, err := s.loadSystem(c.Param("id")); notFound(err) {
fail(c, http.StatusNotFound, "system not found")
return
}
hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24"))
if hours <= 0 || hours > 24*7 {
hours = 24
}
rows, err := s.db.Query(`SELECT ts, payload FROM system_stats
WHERE system_id = ? AND ts >= datetime('now', ?) ORDER BY ts`,
c.Param("id"), "-"+strconv.Itoa(hours)+" hours")
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
type sample struct {
TS string `json:"ts"`
StatSample
}
out := []sample{}
for rows.Next() {
var ts, payload string
if err := rows.Scan(&ts, &payload); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
var sm sample
sm.TS = ts
if json.Unmarshal([]byte(payload), &sm.StatSample) == nil {
out = append(out, sm)
}
}
c.JSON(http.StatusOK, out)
}
// ingestSystem receives one agent push. Auth: Authorization: Bearer <token>.
func (s *Server) ingestSystem(c *gin.Context) {
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
if token == "" {
token = c.GetHeader("X-Dash-Token")
}
if token == "" {
fail(c, http.StatusUnauthorized, "token required")
return
}
sys, err := s.loadSystemByToken(token)
if err != nil {
fail(c, http.StatusUnauthorized, "unknown token")
return
}
var sample StatSample
if err := c.ShouldBindJSON(&sample); err != nil {
fail(c, http.StatusBadRequest, "invalid body")
return
}
if sample.IntervalS <= 0 {
sample.IntervalS = 10
}
payload, _ := json.Marshal(&sample)
prev := sys.Status
_, err = s.db.Exec(`UPDATE systems SET
host = ?, os = ?, arch = ?, cpu_model = ?, cores = ?, interval_s = ?,
status = 'up', last_seen_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
latest = ?
WHERE id = ?`,
sample.Host, sample.OS, sample.Arch, sample.CPUModel, sample.Cores,
sample.IntervalS, string(payload), sys.ID)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if _, err := s.db.Exec(`INSERT INTO system_stats (system_id, payload) VALUES (?,?)`,
sys.ID, string(payload)); err != nil {
s.log.Error("system stats insert", zap.Error(err))
}
if prev != "up" {
s.notify("system.up", sys.Name,
fmt.Sprintf("System %q is back online (%s)", sys.Name, sample.Host))
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// sweepSystems marks agents down when silent for > 3 intervals (min 30s).
func (s *Server) sweepSystems() {
rows, err := s.db.Query(`UPDATE systems SET status = 'down'
WHERE status = 'up' AND last_seen_at IS NOT NULL
AND datetime(last_seen_at, '+' || MAX(3 * interval_s, 30) || ' seconds') <= datetime('now')
RETURNING id, name, host`)
if err != nil {
s.log.Error("system sweep", zap.Error(err))
return
}
defer rows.Close()
for rows.Next() {
var id, name, host string
if rows.Scan(&id, &name, &host) == nil {
s.notify("system.down", name,
fmt.Sprintf("System %q stopped reporting (%s)", name, host))
}
}
}
// ---------- widget fetcher ----------
// systemWidget serves board tiles bound to a system via config.systemId.
type systemWidget struct{ db *sql.DB }
func (w *systemWidget) Meta() widget.Type {
return widget.Type{
Type: "system", Name: "System",
Description: "Live CPU/memory/disk of a monitored host",
Fields: []widget.Field{
{Key: "systemId", Label: "System", Required: true},
},
}
}
func (w *systemWidget) Fetch(_ context.Context, cfg json.RawMessage) (any, error) {
var c struct {
SystemID string `json:"systemId"`
}
if err := json.Unmarshal(cfg, &c); err != nil || c.SystemID == "" {
return nil, errors.New("config.systemId required")
}
var name, status, latest string
var lastSeen *string
err := w.db.QueryRow(`SELECT name, status, last_seen_at, latest
FROM systems WHERE id = ?`, c.SystemID).Scan(&name, &status, &lastSeen, &latest)
if err != nil {
return nil, errors.New("system not found")
}
var sm StatSample
_ = json.Unmarshal([]byte(latest), &sm)
return map[string]any{
"name": name,
"status": status,
"lastSeen": lastSeen,
"cpu": sm.CPU,
"memTotal": sm.MemTotal,
"memUsed": sm.MemUsed,
"diskTotal": sm.DiskTotal,
"diskUsed": sm.DiskUsed,
}, nil
}
@@ -0,0 +1,31 @@
-- +goose Up
CREATE TABLE systems (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
host TEXT NOT NULL DEFAULT '',
os TEXT NOT NULL DEFAULT '',
arch TEXT NOT NULL DEFAULT '',
cpu_model TEXT NOT NULL DEFAULT '',
cores INTEGER NOT NULL DEFAULT 0,
interval_s INTEGER NOT NULL DEFAULT 10,
status TEXT NOT NULL DEFAULT 'pending',
last_seen_at TEXT,
latest TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- Whole agent payload per tick; no sub-field queries, so one JSON column
-- instead of a wide row that drifts with every agent version.
CREATE TABLE system_stats (
system_id TEXT NOT NULL REFERENCES systems(id) ON DELETE CASCADE,
ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
payload TEXT NOT NULL,
PRIMARY KEY (system_id, ts)
);
CREATE INDEX idx_system_stats_ts ON system_stats(ts);
-- +goose Down
DROP TABLE IF EXISTS system_stats;
DROP TABLE IF EXISTS systems;
+10 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, Route, useLocation } from "wouter"; import { Link, Route, useLocation } from "wouter";
import { Activity, Globe, LayoutGrid, Moon, Plus, Search, Settings, Sun } from "lucide-react"; import { Activity, Globe, LayoutGrid, Moon, Plus, Search, Server, Settings, Sun } from "lucide-react";
import { BoardProvider, useBoard } from "@/board/store"; import { BoardProvider, useBoard } from "@/board/store";
import { Board } from "@/board/Board"; import { Board } from "@/board/Board";
import { ServiceDialog } from "@/components/ServiceDialog"; import { ServiceDialog } from "@/components/ServiceDialog";
@@ -11,6 +11,8 @@ import { MonitorsPage } from "@/pages/MonitorsPage";
import { MonitorDetailPage } from "@/pages/MonitorDetailPage"; import { MonitorDetailPage } from "@/pages/MonitorDetailPage";
import { DomainsPage } from "@/pages/DomainsPage"; import { DomainsPage } from "@/pages/DomainsPage";
import { DomainDetailPage } from "@/pages/DomainDetailPage"; import { DomainDetailPage } from "@/pages/DomainDetailPage";
import { SystemsPage } from "@/pages/SystemsPage";
import { SystemDetailPage } from "@/pages/SystemDetailPage";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Item } from "@/types"; import type { Item } from "@/types";
@@ -27,6 +29,7 @@ const NAV = [
{ href: "/", label: "Board", icon: LayoutGrid }, { href: "/", label: "Board", icon: LayoutGrid },
{ href: "/monitors", label: "Monitors", icon: Activity }, { href: "/monitors", label: "Monitors", icon: Activity },
{ href: "/domains", label: "Domains", icon: Globe }, { href: "/domains", label: "Domains", icon: Globe },
{ href: "/systems", label: "Systems", icon: Server },
]; ];
function Shell() { function Shell() {
@@ -141,6 +144,12 @@ function Shell() {
<Route path="/domains/:id"> <Route path="/domains/:id">
{(p) => <DomainDetailPage id={p.id} />} {(p) => <DomainDetailPage id={p.id} />}
</Route> </Route>
<Route path="/systems">
<SystemsPage />
</Route>
<Route path="/systems/:id">
{(p) => <SystemDetailPage id={p.id} />}
</Route>
<ServiceDialog open={svcOpen} onOpenChange={setSvcOpen} item={editing?.kind === "service" ? editing : undefined} /> <ServiceDialog open={svcOpen} onOpenChange={setSvcOpen} item={editing?.kind === "service" ? editing : undefined} />
<WidgetDialog open={wdgOpen} onOpenChange={setWdgOpen} item={editing?.kind === "widget" ? editing : undefined} /> <WidgetDialog open={wdgOpen} onOpenChange={setWdgOpen} item={editing?.kind === "widget" ? editing : undefined} />
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { Activity, ArrowRight, Download, Globe, LayoutGrid, Moon, Plus, Settings, Sun } from "lucide-react"; import { Activity, ArrowRight, Download, Globe, LayoutGrid, Moon, Plus, Server, Settings, Sun } from "lucide-react";
import { useBoard } from "@/board/store"; import { useBoard } from "@/board/store";
import { hostOf, IconImg } from "@/board/primitives"; import { hostOf, IconImg } from "@/board/primitives";
import { rendererLabels, rendererList } from "@/renderers"; import { rendererLabels, rendererList } from "@/renderers";
@@ -45,6 +45,7 @@ export function CommandPalette({
{ id: "nav-board", group: "Actions", glyph: LayoutGrid, label: "Go to Board", run: () => navigate("/") }, { id: "nav-board", group: "Actions", glyph: LayoutGrid, label: "Go to Board", run: () => navigate("/") },
{ id: "nav-monitors", group: "Actions", glyph: Activity, label: "Go to Monitors", run: () => navigate("/monitors") }, { id: "nav-monitors", group: "Actions", glyph: Activity, label: "Go to Monitors", run: () => navigate("/monitors") },
{ id: "nav-domains", group: "Actions", glyph: Globe, label: "Go to Domains", run: () => navigate("/domains") }, { id: "nav-domains", group: "Actions", glyph: Globe, label: "Go to Domains", run: () => navigate("/domains") },
{ id: "nav-systems", group: "Actions", glyph: Server, label: "Go to Systems", run: () => navigate("/systems") },
{ id: "add-service", group: "Actions", glyph: Plus, label: "Add service", hint: "new", run: onAddService }, { id: "add-service", group: "Actions", glyph: Plus, label: "Add service", hint: "new", run: onAddService },
{ id: "add-widget", group: "Actions", glyph: Plus, label: "Add widget", hint: "new", run: onAddWidget }, { id: "add-widget", group: "Actions", glyph: Plus, label: "Add widget", hint: "new", run: onAddWidget },
{ id: "settings", group: "Actions", glyph: Settings, label: "Open settings", run: onSettings }, { id: "settings", group: "Actions", glyph: Settings, label: "Open settings", run: onSettings },
@@ -34,6 +34,7 @@ export function WidgetDialog({
const [values, setValues] = useState<Record<string, string>>({}); const [values, setValues] = useState<Record<string, string>>({});
const [monitors, setMonitors] = useState<{ id?: string; name?: string }[]>([]); const [monitors, setMonitors] = useState<{ id?: string; name?: string }[]>([]);
const [domains, setDomains] = useState<{ id?: string; name?: string }[]>([]); const [domains, setDomains] = useState<{ id?: string; name?: string }[]>([]);
const [systems, setSystems] = useState<{ id?: string; name?: string }[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [err, setErr] = useState(""); const [err, setErr] = useState("");
@@ -47,6 +48,7 @@ export function WidgetDialog({
.catch(() => setTypes([])); .catch(() => setTypes([]));
void api.GET("/api/monitors").then(({ data }) => setMonitors(data ?? [])); void api.GET("/api/monitors").then(({ data }) => setMonitors(data ?? []));
void api.GET("/api/domains").then(({ data }) => setDomains(data ?? [])); void api.GET("/api/domains").then(({ data }) => setDomains(data ?? []));
void api.GET("/api/systems").then(({ data }) => setSystems(data ?? []));
const cfg = item?.config ?? null; const cfg = item?.config ?? null;
setType(cfg && typeof cfg.type === "string" ? cfg.type : ""); setType(cfg && typeof cfg.type === "string" ? cfg.type : "");
@@ -146,14 +148,14 @@ export function WidgetDialog({
<Label htmlFor={`wdg-${f.key}`}> <Label htmlFor={`wdg-${f.key}`}>
{f.label}{f.required ? "" : " (optional)"} {f.label}{f.required ? "" : " (optional)"}
</Label> </Label>
{f.key === "monitorId" || f.key === "domainId" ? ( {f.key === "monitorId" || f.key === "domainId" || f.key === "systemId" ? (
<Select <Select
value={values[f.key] ?? ""} value={values[f.key] ?? ""}
onValueChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))} onValueChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
> >
<SelectTrigger><SelectValue placeholder="Pick one…" /></SelectTrigger> <SelectTrigger><SelectValue placeholder="Pick one…" /></SelectTrigger>
<SelectContent> <SelectContent>
{(f.key === "monitorId" ? monitors : domains).map((m) => ( {(f.key === "monitorId" ? monitors : f.key === "domainId" ? domains : systems).map((m) => (
<SelectItem key={m.id} value={m.id ?? ""}>{m.name}</SelectItem> <SelectItem key={m.id} value={m.id ?? ""}>{m.name}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -0,0 +1,220 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "wouter";
import { ArrowLeft, Copy, Trash2 } from "lucide-react";
import { Area, AreaChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { api } from "@/api";
import type { System, SystemStat } from "@/types";
import { agentCmd, fmtBytes, fmtUptime } from "@/pages/SystemsPage";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function SystemDetailPage({ id }: { id: string }) {
const [sys, setSys] = useState<System | null>(null);
const [stats, setStats] = useState<SystemStat[]>([]);
const [hours, setHours] = useState(24);
const [notFound, setNotFound] = useState(false);
const load = useCallback(async () => {
const [{ data: s, response }, { data: st }] = await Promise.all([
api.GET("/api/systems/{id}", { params: { path: { id } } }),
api.GET("/api/systems/{id}/stats", { params: { path: { id }, query: { hours } } }),
]);
if (response.status === 404) setNotFound(true);
if (s) setSys(s);
if (st) setStats(st);
}, [id, hours]);
useEffect(() => {
void load();
const t = setInterval(() => void load(), 15_000);
return () => clearInterval(t);
}, [load]);
if (notFound) return (
<main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">
<Link href="/systems" className="inline-flex items-center gap-1.5 hover:text-text"><ArrowLeft size={12} /> Systems</Link>
<p className="mt-4">System not found.</p>
</main>
);
if (!sys) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
const l = sys.latest;
const chart = stats.map((s) => ({
t: new Date(s.ts ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
cpu: s.cpu,
memPct: s.memTotal ? ((s.memUsed ?? 0) / s.memTotal) * 100 : undefined,
rx: (s.netRx ?? 0) / 1024,
tx: (s.netTx ?? 0) / 1024,
load: s.load1,
}));
const axisTick = { fontSize: 10, fill: "var(--text-faint)" };
const tipStyle = { background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 11 };
return (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<Link href="/systems" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
<ArrowLeft size={12} /> Systems
</Link>
<div className="mb-6 flex items-start justify-between">
<div>
<div className="flex items-center gap-2.5">
<span className={cn("size-2 rounded-full",
sys.status === "up" ? "bg-up" : sys.status === "down" ? "bg-down" : "bg-text-faint/40")} />
<h1 className="text-[17px] font-semibold tracking-tight">{sys.name}</h1>
<span className="font-mono text-[10.5px] uppercase tracking-[0.08em] text-text-faint">{sys.status}</span>
</div>
<p className="mt-1 font-mono text-[11.5px] text-text-faint">
{sys.host ? `${sys.host} · ` : ""}{sys.os}/{sys.arch}
{sys.cpuModel ? ` · ${sys.cpuModel}` : ""}{sys.cores ? ` · ${sys.cores} cores` : ""}
{sys.lastSeenAt ? ` · seen ${new Date(sys.lastSeenAt).toLocaleTimeString()}` : " · never seen"}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" aria-label="Delete"
onClick={() => {
if (confirm(`Delete system "${sys.name}"?`)) {
void api.DELETE("/api/systems/{id}", { params: { path: { id } } }).then(() => { location.href = "/systems"; });
}
}}>
<Trash2 size={12} />
</Button>
</div>
</div>
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
{[
["CPU", l?.cpu !== undefined ? `${l.cpu.toFixed(1)}%` : "—"],
["Memory", l?.memTotal ? `${fmtBytes(l.memUsed ?? 0)} / ${fmtBytes(l.memTotal)}` : "—"],
["Disk /", l?.diskTotal ? `${fmtBytes(l.diskUsed ?? 0)} / ${fmtBytes(l.diskTotal)}` : "—"],
["Uptime", l?.uptimeS ? fmtUptime(l.uptimeS) : "—"],
].map(([k, v]) => (
<div key={k} className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{k}</div>
<div className="mt-1 font-mono text-[15px] font-medium">{v}</div>
</div>
))}
</div>
<div className="mb-2 flex items-center justify-between">
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Usage</h2>
<div className="flex gap-1">
{[1, 6, 24, 168].map((h) => (
<button key={h} type="button" onClick={() => setHours(h)}
className={cn("rounded-[6px] px-2 py-1 font-mono text-[10.5px]",
hours === h ? "bg-surface-hover text-text" : "text-text-faint hover:text-text")}>
{h === 1 ? "1h" : h === 6 ? "6h" : h === 24 ? "24h" : "7d"}
</button>
))}
</div>
</div>
<div className="mb-6 grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">CPU %</div>
<ResponsiveContainer width="100%" height="85%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} domain={[0, 100]} unit="%" width={48} />
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
<Area type="monotone" dataKey="cpu" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Memory %</div>
<ResponsiveContainer width="100%" height="85%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} domain={[0, 100]} unit="%" width={48} />
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
<Area type="monotone" dataKey="memPct" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Network KB/s</div>
<ResponsiveContainer width="100%" height="85%">
<LineChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={58} />
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
<Line type="monotone" dataKey="rx" name="rx" stroke="var(--up)" strokeWidth={1.5} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="tx" name="tx" stroke="var(--text-faint)" strokeWidth={1.5} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Load 1m</div>
<ResponsiveContainer width="100%" height="85%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="t" tick={axisTick} tickLine={false} axisLine={false} minTickGap={40} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={48} />
<Tooltip contentStyle={tipStyle} labelStyle={{ color: "var(--text-faint)" }} />
<Area type="monotone" dataKey="load" stroke="var(--up)" fill="var(--up)" fillOpacity={0.12} strokeWidth={1.5} dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
{l?.temps && Object.keys(l.temps).length > 0 && (
<div className="rounded-[10px] border border-border">
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
Temperatures
</h2>
<table className="w-full text-[12px]">
<tbody>
{Object.entries(l.temps).map(([k, v]) => (
<tr key={k} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 text-text-dim">{k}</td>
<td className="px-4 py-2 text-right font-mono text-[11px]">{v.toFixed(1)}°C</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{l?.containers && l.containers.length > 0 && (
<div className="rounded-[10px] border border-border">
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
Containers
</h2>
<table className="w-full text-[12px]">
<tbody>
{l.containers.map((ctr) => (
<tr key={ctr.name} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{ctr.name}</td>
<td className="px-4 py-2 text-right">
<span className={cn("font-mono text-[10.5px] uppercase",
ctr.state === "running" ? "text-up" : "text-text-faint")}>
{ctr.state}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="mt-6 rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="mb-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Agent command</div>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-text-dim">
{agentCmd(sys)}
</code>
<Button variant="outline" size="icon" aria-label="Copy"
onClick={() => void navigator.clipboard.writeText(agentCmd(sys))}>
<Copy size={12} />
</Button>
</div>
</div>
</main>
);
}
+197
View File
@@ -0,0 +1,197 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "wouter";
import { Copy, Plus, Trash2 } from "lucide-react";
import { api } from "@/api";
import type { System } from "@/types";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
export function fmtBytes(b: number) {
if (b >= 1 << 40) return `${(b / (1 << 40)).toFixed(1)} TB`;
if (b >= 1 << 30) return `${(b / (1 << 30)).toFixed(1)} GB`;
return `${(b / (1 << 20)).toFixed(0)} MB`;
}
export function fmtUptime(s: number) {
const d = Math.floor(s / 86400);
if (d > 0) return `${d}d ${Math.floor((s % 86400) / 3600)}h`;
const h = Math.floor(s / 3600);
if (h > 0) return `${h}h ${Math.floor((s % 3600) / 60)}m`;
return `${Math.floor(s / 60)}m`;
}
export function Bar({ pct, warnAt = 90 }: { pct: number | undefined; warnAt?: number }) {
return (
<span className="h-1.5 w-full overflow-hidden rounded-full bg-surface-hover">
<span
className={cn("block h-full rounded-full", pct !== undefined && pct > warnAt ? "bg-down" : "bg-up")}
style={{ width: `${Math.min(pct ?? 0, 100)}%` }}
/>
</span>
);
}
/** The one-liner agents run; shown on create and on the detail page. */
export function agentCmd(sys: System) {
const base = location.origin;
return `DASH_URL=${base} DASH_TOKEN=${sys.token} dash-agent`;
}
export function SystemsPage() {
const [systems, setSystems] = useState<System[]>([]);
const [loaded, setLoaded] = useState(false);
const [dlgOpen, setDlgOpen] = useState(false);
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [created, setCreated] = useState<System | null>(null);
const load = useCallback(async () => {
const { data } = await api.GET("/api/systems");
if (data) setSystems(data);
setLoaded(true);
}, []);
useEffect(() => {
void load();
const t = setInterval(() => void load(), 15_000);
return () => clearInterval(t);
}, [load]);
async function create() {
if (!name.trim()) return;
setBusy(true);
const { data } = await api.POST("/api/systems", { body: { name: name.trim() } });
setBusy(false);
if (data) {
setCreated(data);
setName("");
void load();
}
}
async function remove(id: string | undefined, n: string | undefined) {
if (!id || !confirm(`Delete system "${n}"?`)) return;
await api.DELETE("/api/systems/{id}", { params: { path: { id } } });
await load();
}
return (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-[15px] font-semibold tracking-tight">Systems</h1>
<p className="text-[12px] text-text-faint">Servers and PCs reporting via dash-agent.</p>
</div>
<Button onClick={() => { setCreated(null); setDlgOpen(true); }}>
<Plus size={13} strokeWidth={2.2} /> New system
</Button>
</div>
{loaded && systems.length === 0 ? (
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
<p className="text-[13px] text-text-dim">No systems yet.</p>
<p className="mt-1 text-[11.5px] text-text-faint">
Add one, then run <code className="font-mono">dash-agent</code> on the host with the issued token.
</p>
</div>
) : (
<div className="grid grid-cols-3 gap-3 max-[900px]:grid-cols-2 max-[560px]:grid-cols-1">
{systems.map((sys) => {
const l = sys.latest;
const memPct = l?.memTotal ? ((l.memUsed ?? 0) / l.memTotal) * 100 : undefined;
const diskPct = l?.diskTotal ? ((l.diskUsed ?? 0) / l.diskTotal) * 100 : undefined;
const rows: [string, number | undefined, string][] = [
["cpu", l?.cpu, l?.cpu !== undefined ? `${l.cpu.toFixed(0)}%` : "—"],
["mem", memPct, l?.memTotal ? `${fmtBytes(l.memUsed ?? 0)} / ${fmtBytes(l.memTotal)}` : "—"],
["disk", diskPct, l?.diskTotal ? `${fmtBytes(l.diskUsed ?? 0)} / ${fmtBytes(l.diskTotal)}` : "—"],
];
return (
<div key={sys.id} className="rounded-[10px] border border-border bg-surface px-4 py-3.5">
<div className="mb-2.5 flex items-center gap-2">
<span className={cn("size-1.5 shrink-0 rounded-full",
sys.status === "up" ? "bg-up" : sys.status === "down" ? "bg-down" : "bg-text-faint/40")} />
<Link href={`/systems/${sys.id}`} className="min-w-0 flex-1 truncate text-[13px] font-medium hover:underline">
{sys.name}
</Link>
<Button variant="outline" size="icon" aria-label="Delete"
onClick={() => void remove(sys.id, sys.name)}>
<Trash2 size={11} />
</Button>
</div>
{sys.host ? (
<p className="mb-2.5 truncate font-mono text-[10.5px] text-text-faint">
{sys.host} · {sys.cpuModel || `${sys.cores} cores`}
{l?.uptimeS ? ` · up ${fmtUptime(l.uptimeS)}` : ""}
</p>
) : (
<p className="mb-2.5 font-mono text-[10.5px] text-text-faint">waiting for agent…</p>
)}
<div className="flex flex-col gap-1.5">
{rows.map(([label, pct, txt]) => (
<div key={label} className="flex items-center gap-2">
<span className="w-7 font-mono text-[9.5px] uppercase text-text-faint">{label}</span>
<span className="flex-1"><Bar pct={pct} /></span>
<span className="w-24 text-right font-mono text-[10px] text-text-dim">{txt}</span>
</div>
))}
</div>
</div>
);
})}
</div>
)}
<Dialog open={dlgOpen} onOpenChange={setDlgOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New system</DialogTitle>
<DialogDescription>Name the host, then run dash-agent on it with the issued token.</DialogDescription>
</DialogHeader>
{created ? (
<div className="flex flex-col gap-3">
<p className="text-[12px] text-text-dim">
Agent command for <span className="font-medium text-text">{created.name}</span>:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto whitespace-nowrap rounded-[7px] border border-border bg-bg px-3 py-2 font-mono text-[11px]">
{agentCmd(created)}
</code>
<Button variant="outline" size="icon" aria-label="Copy"
onClick={() => void navigator.clipboard.writeText(agentCmd(created))}>
<Copy size={12} />
</Button>
</div>
<p className="text-[11px] text-text-faint">
The token identifies this system — keep it out of public repos.
</p>
</div>
) : (
<div className="flex flex-col gap-1.5">
<Label htmlFor="sys-name">Name</Label>
<Input id="sys-name" value={name} onChange={(e) => setName(e.target.value)}
placeholder="nas" autoFocus
onKeyDown={(e) => { if (e.key === "Enter") void create(); }} />
</div>
)}
<DialogFooter>
{created ? (
<Button onClick={() => setDlgOpen(false)}>Done</Button>
) : (
<>
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>Cancel</Button>
<Button onClick={() => void create()} disabled={busy || !name.trim()}>
{busy ? "Creating…" : "Create"}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</main>
);
}
+4
View File
@@ -20,6 +20,10 @@ export type Domain = components["schemas"]["Domain"];
export type DomainInput = components["schemas"]["DomainInput"]; export type DomainInput = components["schemas"]["DomainInput"];
export type DomainView = components["schemas"]["DomainView"]; export type DomainView = components["schemas"]["DomainView"];
export type DomainCheck = components["schemas"]["DomainCheck"]; export type DomainCheck = components["schemas"]["DomainCheck"];
export type System = components["schemas"]["System"];
export type StatSample = components["schemas"]["StatSample"];
/** Stats rows are StatSample plus the server-side timestamp. */
export type SystemStat = StatSample & { ts?: string };
export type Theme = "dark" | "light"; export type Theme = "dark" | "light";
export type RendererName = "bento" | "cards" | "index" | "console"; export type RendererName = "bento" | "cards" | "index" | "console";
+2
View File
@@ -5,6 +5,7 @@ import { PiholeWidget, AdguardWidget } from "@/widgets/blocker";
import { ImmichWidget } from "@/widgets/immich"; import { ImmichWidget } from "@/widgets/immich";
import { MonitorWidget } from "@/widgets/monitor"; import { MonitorWidget } from "@/widgets/monitor";
import { DomainWidget } from "@/widgets/domain"; import { DomainWidget } from "@/widgets/domain";
import { SystemWidget } from "@/widgets/system";
// WidgetHost seam: renderers own the tile chrome; this maps config.type to // WidgetHost seam: renderers own the tile chrome; this maps config.type to
// the component that fills it. Adding a widget = one fetcher in Go + one // the component that fills it. Adding a widget = one fetcher in Go + one
@@ -32,6 +33,7 @@ const registry: WidgetRegistry = {
immich: { view: ImmichWidget }, immich: { view: ImmichWidget },
monitor: { view: MonitorWidget }, monitor: { view: MonitorWidget },
domain: { view: DomainWidget }, domain: { view: DomainWidget },
system: { view: SystemWidget },
}; };
export function cfgStr(item: Item, key: string): string { export function cfgStr(item: Item, key: string): string {
+60
View File
@@ -0,0 +1,60 @@
import type { WidgetData } from "@/widgets";
import type { Item } from "@/types";
import { cn } from "@/lib/utils";
// Board tile for a monitored system: status dot + cpu/mem/disk mini bars.
// config.systemId binds it; data comes from the system fetcher.
export function SystemWidget({ item, data, error }: {
item: Item;
data: WidgetData | undefined;
error: string | undefined;
}) {
const status = typeof data?.status === "string" ? data.status : undefined;
const sysId = typeof item.config?.systemId === "string" ? item.config.systemId : "";
const cpu = typeof data?.cpu === "number" ? data.cpu : undefined;
const memTotal = typeof data?.memTotal === "number" ? data.memTotal : 0;
const memUsed = typeof data?.memUsed === "number" ? data.memUsed : 0;
const diskTotal = typeof data?.diskTotal === "number" ? data.diskTotal : 0;
const diskUsed = typeof data?.diskUsed === "number" ? data.diskUsed : 0;
const memPct = memTotal > 0 ? (memUsed / memTotal) * 100 : undefined;
const diskPct = diskTotal > 0 ? (diskUsed / diskTotal) * 100 : undefined;
const bar = (pct: number | undefined, label: string) => (
<span className="flex items-center gap-1" title={`${label}: ${pct !== undefined ? pct.toFixed(0) + "%" : "—"}`}>
<span className="font-mono text-[9px] uppercase text-text-faint">{label}</span>
<span className="h-1 w-8 overflow-hidden rounded-full bg-surface-hover">
<span
className={cn("block h-full rounded-full", pct !== undefined && pct > 90 ? "bg-down" : "bg-up")}
style={{ width: `${Math.min(pct ?? 0, 100)}%` }}
/>
</span>
</span>
);
return (
<a
href={sysId ? `/systems/${sysId}` : "/systems"}
className="flex w-full items-center gap-2.5 text-left"
onClick={(e) => e.stopPropagation()}
>
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
error ? "bg-down" : status === "up" ? "bg-up" : status === "down" ? "bg-down" : "bg-text-faint/40",
)}
/>
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium">
{typeof data?.name === "string" ? data.name : item.name}
</span>
{error ? (
<span className="shrink-0 font-mono text-[10.5px] text-text-faint">err</span>
) : (
<span className="flex shrink-0 items-center gap-2">
{bar(cpu, "cpu")}
{bar(memPct, "mem")}
{bar(diskPct, "dsk")}
</span>
)}
</a>
);
}
+192
View File
@@ -551,6 +551,129 @@ paths:
"400": "400":
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
/api/systems:
get:
operationId: listSystems
responses:
"200":
description: All monitored systems with their latest sample
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/System"
post:
operationId: createSystem
summary: Register a system; the returned token authenticates agent pushes
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string }
responses:
"201":
description: Created system
content:
application/json:
schema:
$ref: "#/components/schemas/System"
"400":
$ref: "#/components/responses/BadRequest"
/api/systems/{id}:
parameters:
- $ref: "#/components/parameters/id"
get:
operationId: getSystem
responses:
"200":
description: System detail
content:
application/json:
schema:
$ref: "#/components/schemas/System"
"404":
$ref: "#/components/responses/NotFound"
patch:
operationId: patchSystem
summary: Rename or rotate the agent token
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string }
rotateToken: { type: boolean }
responses:
"200":
description: Updated system
content:
application/json:
schema:
$ref: "#/components/schemas/System"
"404":
$ref: "#/components/responses/NotFound"
delete:
operationId: deleteSystem
responses:
"204":
description: Deleted
"404":
$ref: "#/components/responses/NotFound"
/api/systems/{id}/stats:
parameters:
- $ref: "#/components/parameters/id"
get:
operationId: systemStats
summary: Stored agent samples for charting
parameters:
- name: hours
in: query
schema: { type: integer, default: 24, maximum: 168 }
responses:
"200":
description: Samples oldest-to-newest
content:
application/json:
schema:
type: array
items:
allOf:
- $ref: "#/components/schemas/StatSample"
- type: object
properties:
ts: { type: string, format: date-time }
"404":
$ref: "#/components/responses/NotFound"
/api/systems/ingest:
post:
operationId: ingestSystem
summary: "Agent push endpoint; Authorization: Bearer <system token>"
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/StatSample"
responses:
"200":
description: Sample stored
"401":
description: Missing or unknown token
"400":
$ref: "#/components/responses/BadRequest"
/api/settings: /api/settings:
get: get:
operationId: getSettings operationId: getSettings
@@ -650,6 +773,10 @@ paths:
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
components: components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
parameters: parameters:
id: id:
name: id name: id
@@ -1188,6 +1315,71 @@ components:
checkedAt: checkedAt:
type: string type: string
format: date-time format: date-time
StatSample:
type: object
description: One agent push; missing fields mean the platform can't provide them
properties:
host: { type: string }
os: { type: string }
arch: { type: string }
cpuModel: { type: string }
cores: { type: integer }
intervalS: { type: integer }
uptimeS: { type: number }
cpu:
type: number
description: CPU usage percent 0-100
memTotal: { type: number, description: bytes }
memUsed: { type: number, description: bytes }
swapTotal: { type: number, description: bytes }
swapUsed: { type: number, description: bytes }
diskTotal: { type: number, description: root filesystem bytes }
diskUsed: { type: number, description: bytes }
netRx: { type: number, description: bytes per second }
netTx: { type: number, description: bytes per second }
load1: { type: number }
load5: { type: number }
load15: { type: number }
temps:
type: object
additionalProperties: { type: number }
description: Sensor label -> celsius
containers:
type: array
items:
$ref: "#/components/schemas/ContainerStat"
ContainerStat:
type: object
properties:
name: { type: string }
state: { type: string }
System:
type: object
required: [id, name, token, status]
properties:
id: { type: string }
name: { type: string }
token:
type: string
description: Agent bearer token
host: { type: string }
os: { type: string }
arch: { type: string }
cpuModel: { type: string }
cores: { type: integer }
intervalS: { type: integer }
status:
type: string
enum: [pending, up, down]
lastSeenAt:
type: string
format: date-time
nullable: true
latest:
$ref: "#/components/schemas/StatSample"
createdAt:
type: string
format: date-time
WidgetField: WidgetField:
type: object type: object
required: [key, label] required: [key, label]