mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
2f3e8a1879
commit
74c987c18a
16 files changed
+1541
-10
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -41,6 +41,7 @@ func newServer(logger *zap.Logger, db *sql.DB, iconsDir string) *Server {
|
||||
s.lookup = domain.NewLookup().Run
|
||||
widget.Register(&monitorWidget{db: db})
|
||||
widget.Register(&domainWidget{db: db})
|
||||
widget.Register(&systemWidget{db: db})
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -92,6 +93,14 @@ func (s *Server) routes() *gin.Engine {
|
||||
v1.GET("/domains/:id/checks", s.domainChecks)
|
||||
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("/widgets/types", s.widgetTypes)
|
||||
v1.GET("/widgets/:id/data", s.widgetData)
|
||||
|
||||
@@ -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) {
|
||||
f := func(v float64) *float64 { return &v }
|
||||
if got := midpoint(nil, nil); got != 1024 {
|
||||
|
||||
@@ -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 {
|
||||
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.sweepSystems()
|
||||
}
|
||||
|
||||
// runCheck executes one monitor check (with retries) and persists the result.
|
||||
|
||||
@@ -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;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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 { Board } from "@/board/Board";
|
||||
import { ServiceDialog } from "@/components/ServiceDialog";
|
||||
@@ -11,6 +11,8 @@ import { MonitorsPage } from "@/pages/MonitorsPage";
|
||||
import { MonitorDetailPage } from "@/pages/MonitorDetailPage";
|
||||
import { DomainsPage } from "@/pages/DomainsPage";
|
||||
import { DomainDetailPage } from "@/pages/DomainDetailPage";
|
||||
import { SystemsPage } from "@/pages/SystemsPage";
|
||||
import { SystemDetailPage } from "@/pages/SystemDetailPage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Item } from "@/types";
|
||||
@@ -27,6 +29,7 @@ const NAV = [
|
||||
{ href: "/", label: "Board", icon: LayoutGrid },
|
||||
{ href: "/monitors", label: "Monitors", icon: Activity },
|
||||
{ href: "/domains", label: "Domains", icon: Globe },
|
||||
{ href: "/systems", label: "Systems", icon: Server },
|
||||
];
|
||||
|
||||
function Shell() {
|
||||
@@ -141,6 +144,12 @@ function Shell() {
|
||||
<Route path="/domains/:id">
|
||||
{(p) => <DomainDetailPage id={p.id} />}
|
||||
</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} />
|
||||
<WidgetDialog open={wdgOpen} onOpenChange={setWdgOpen} item={editing?.kind === "widget" ? editing : undefined} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
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 { hostOf, IconImg } from "@/board/primitives";
|
||||
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-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-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-widget", group: "Actions", glyph: Plus, label: "Add widget", hint: "new", run: onAddWidget },
|
||||
{ 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 [monitors, setMonitors] = 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 [err, setErr] = useState("");
|
||||
|
||||
@@ -47,6 +48,7 @@ export function WidgetDialog({
|
||||
.catch(() => setTypes([]));
|
||||
void api.GET("/api/monitors").then(({ data }) => setMonitors(data ?? []));
|
||||
void api.GET("/api/domains").then(({ data }) => setDomains(data ?? []));
|
||||
void api.GET("/api/systems").then(({ data }) => setSystems(data ?? []));
|
||||
|
||||
const cfg = item?.config ?? null;
|
||||
setType(cfg && typeof cfg.type === "string" ? cfg.type : "");
|
||||
@@ -146,14 +148,14 @@ export function WidgetDialog({
|
||||
<Label htmlFor={`wdg-${f.key}`}>
|
||||
{f.label}{f.required ? "" : " (optional)"}
|
||||
</Label>
|
||||
{f.key === "monitorId" || f.key === "domainId" ? (
|
||||
{f.key === "monitorId" || f.key === "domainId" || f.key === "systemId" ? (
|
||||
<Select
|
||||
value={values[f.key] ?? ""}
|
||||
onValueChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="Pick one…" /></SelectTrigger>
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,10 @@ export type Domain = components["schemas"]["Domain"];
|
||||
export type DomainInput = components["schemas"]["DomainInput"];
|
||||
export type DomainView = components["schemas"]["DomainView"];
|
||||
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 RendererName = "bento" | "cards" | "index" | "console";
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PiholeWidget, AdguardWidget } from "@/widgets/blocker";
|
||||
import { ImmichWidget } from "@/widgets/immich";
|
||||
import { MonitorWidget } from "@/widgets/monitor";
|
||||
import { DomainWidget } from "@/widgets/domain";
|
||||
import { SystemWidget } from "@/widgets/system";
|
||||
|
||||
// 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
|
||||
@@ -32,6 +33,7 @@ const registry: WidgetRegistry = {
|
||||
immich: { view: ImmichWidget },
|
||||
monitor: { view: MonitorWidget },
|
||||
domain: { view: DomainWidget },
|
||||
system: { view: SystemWidget },
|
||||
};
|
||||
|
||||
export function cfgStr(item: Item, key: string): string {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user