Files
Dash/apps/backend/internal/api/systems.go
T
Tomas DvorakandDevin f3143af351 feat(api): phase 5 — alerts, agent depth, subdomains, auth, boards
P5b: notifier transport registry (webhook/slack/discord/telegram/
gotify/ntfy/smtp via net/smtp), per-monitor+domain alert rules
(failsBeforeDown, latencyWarn, certDays, mute).

P5c: per-container cpu/mem, optional SMART/ZFS/GPU exec-or-skip
collectors, crt.sh subdomain discovery with daily sweep.

P5d: opt-in auth (bcrypt, sqlite sessions, 7d rotating cookie, login
rate limit, public-route bypass) and multiple boards (boards table,
sections.board_id, /api/boards CRUD, slug routing).

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 12:49:44 +02:00

377 lines
11 KiB
Go

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"`
// Optional collectors — present only when the host has the tools.
Smart []SmartDisk `json:"smart,omitempty"`
ZFS []ZFSPool `json:"zfs,omitempty"`
GPU []GPUStat `json:"gpu,omitempty"`
}
// ContainerStat is one Docker container's liveness plus resource use.
// CPU is percent of one core (may exceed 100); Mem* are bytes. Zero when the
// agent couldn't reach the stats endpoint.
type ContainerStat struct {
Name string `json:"name"`
State string `json:"state"`
CPU float64 `json:"cpu,omitempty"`
MemUsed float64 `json:"memUsed,omitempty"`
MemLimit float64 `json:"memLimit,omitempty"`
}
// SmartDisk is one drive's SMART summary (smartctl).
type SmartDisk struct {
Device string `json:"device"`
Model string `json:"model"`
Passed *bool `json:"passed,omitempty"`
TempC float64 `json:"tempC,omitempty"`
}
// ZFSPool is one pool's health and space (zpool).
type ZFSPool struct {
Name string `json:"name"`
Health string `json:"health"`
Size float64 `json:"size"`
Free float64 `json:"free"`
}
// GPUStat is one GPU's basic metrics (sysfs or nvidia-smi).
type GPUStat struct {
Name string `json:"name"`
TempC float64 `json:"tempC,omitempty"`
UtilPct float64 `json:"utilPct,omitempty"`
MemUsed float64 `json:"memUsed,omitempty"`
MemTotal float64 `json:"memTotal,omitempty"`
}
// 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
}