mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 15:51: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
@@ -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