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

+2
View File
@@ -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 {
+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>
);
}