Skip to content

Commit 1491d00

Browse files
authored
Add SystemHealth card (#2)
* SystemHealthCard initial * Tweaked * improve import * Make CardGrid fixed two columns * Animate CpuCard grow * No scrollbar * Simplify CPUCard * Make network card more small screen friendly * improve cards code * Make more reusable components * Update docs
1 parent 790b099 commit 1491d00

28 files changed

+795
-241
lines changed

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"editor.formatOnSave": true
3+
}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Key principles:
3333
temperature, fan, etc.)
3434
- Auto-refresh UI built with Next.js and SWR
3535
- Host-scope monitoring via Docker (`network_mode: host`)
36-
- Clean modular dashboard: General, Memory, CPU, Storage, Network cards
36+
- Clean modular dashboard: General, System Health, Memory, CPU, Storage, Network cards
3737
- Mock mode for local development
3838
- Standalone Docker image build
3939

docs/screenshot.png

19 KB
Loading

src/app/api/cards/health/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { NextResponse } from "next/server";
2+
import { services } from "@/services";
3+
4+
export async function GET() {
5+
const svc = await services.health();
6+
const data = await svc.get();
7+
return NextResponse.json(data, { headers: { "Cache-Control": "no-store" } });
8+
}

src/app/globals.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
@import "tailwindcss";
22
@import "tw-animate-css";
33

4+
html, body {
5+
-ms-overflow-style: none;
6+
scrollbar-width: none;
7+
}
8+
html::-webkit-scrollbar,
9+
body::-webkit-scrollbar {
10+
width: 0px;
11+
height: 0px;
12+
display: none;
13+
}
14+
415
@custom-variant dark (&:is(.dark *));
516

617
@theme inline {

src/app/page.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
import { CardGrid } from "@/components/layout/CardGrid";
2-
import GeneralCard from "@/components/cards/GeneralCard";
3-
import MemoryCard from "@/components/cards/MemoryCard";
4-
import StorageCard from "@/components/cards/StorageCard";
5-
import NetworkCard from "@/components/cards/NetworkCard";
6-
import CpuCard from "@/components/cards/CpuCard";
2+
import { GeneralCard, SystemHealthCard, MemoryCard, CpuCard, StorageCard, NetworkCard } from "@/components/cards";
73

84
export default function Dashboard() {
95
return (
106
<main className="p-4">
117
<h1 className="text-3xl font-bold mb-6 text-foreground">Raspberry Pi</h1>
128
<CardGrid>
139
<GeneralCard />
14-
<MemoryCard />
10+
<SystemHealthCard />
1511
<CpuCard />
12+
<MemoryCard />
1613
<StorageCard />
1714
<NetworkCard />
1815
</CardGrid>
1916
</main>
2017
);
21-
}
18+
}

src/components/cards/CpuCard.tsx

Lines changed: 51 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,80 @@
11
"use client";
22

3+
import { useMemo, useState } from "react";
34
import { CardShell } from "@/components/ui/CardShell";
45
import { StatList } from "@/components/ui/StatList";
56
import { StatRow } from "@/components/ui/StatRow";
67
import { LoadTriple } from "@/components/ui/LoadTriple";
7-
import { Progress } from "@/components/ui/progress";
88
import { CardLoading, CardError } from "@/components/ui/CardState";
9-
import { StatusDot } from "@/components/ui/StatusDot";
10-
import { toneForTempC } from "@/utils/health";
119
import { ShowMoreButton } from "@/components/ui/ShowMoreButton";
12-
import { useState } from "react";
10+
import { Collapsible } from "@/components/ui/Collapsible";
1311
import { useCpu } from "@/hooks/useCardData";
1412
import type { CpuInfo } from "@/services/cpu/contract";
13+
import { Section } from "@/components/ui/Section";
14+
import { PerCoreList } from "@/components/rows/PerCoreList";
15+
import { ProcessList } from "@/components/rows/ProcessList";
16+
import { SummaryTitle } from "@/components/ui/SummaryTitle";
17+
18+
function fmtFan(info: Pick<CpuInfo, "fanRpm" | "fanDutyPct">) {
19+
const rpm = Number(info.fanRpm);
20+
const duty = Number(info.fanDutyPct);
21+
if (Number.isFinite(rpm)) return `${Math.round(rpm)} RPM`;
22+
if (Number.isFinite(duty)) return `${Math.round(duty)}% duty`;
23+
return "n/a";
24+
}
1525

1626
export default function CpuCard() {
1727
const { data, error } = useCpu(1000);
1828
const [expanded, setExpanded] = useState(false);
1929

20-
if (error) return <CardError title="CPU" />;
21-
if (!data) return <CardLoading title="CPU" />;
22-
23-
const tempTone = data.tempC == null ? "neutral" : toneForTempC(data.tempC);
30+
const snapshot = useMemo(() => {
31+
if (!data) return null;
32+
const pctTriple: [number, number, number] = [
33+
Math.min(100, (data.load.l1 / data.load.cpus) * 100),
34+
Math.min(100, (data.load.l5 / data.load.cpus) * 100),
35+
Math.min(100, (data.load.l15 / data.load.cpus) * 100),
36+
];
37+
return {
38+
totalPct: data.totalPct,
39+
pctTriple,
40+
tempText: data.tempC == null ? "n/a" : `${data.tempC.toFixed(1)}°C`,
41+
fanText: fmtFan(data),
42+
perCore: data.perCorePct,
43+
topCpu: data.topCpu,
44+
};
45+
}, [data]);
2446

25-
const fanText =
26-
data.fanRpm != null && Number.isFinite(Number(data.fanRpm))
27-
? `${Math.round(Number(data.fanRpm))} RPM`
28-
: data.fanDutyPct != null && Number.isFinite(Number(data.fanDutyPct))
29-
? `${Math.round(Number(data.fanDutyPct))}% duty`
30-
: "n/a";
47+
if (error) return <CardError title="CPU" />;
48+
if (!snapshot) return <CardLoading title="CPU" />;
3149

3250
return (
33-
<CardShell
34-
title="CPU"
35-
actions={<ShowMoreButton expanded={expanded} onToggle={() => setExpanded((v) => !v)} />}
36-
>
51+
<CardShell title="CPU" actions={<ShowMoreButton expanded={expanded} onToggle={() => setExpanded(v => !v)} />}>
3752
<StatList>
38-
<StatRow
39-
label="Total"
40-
value={<span className="tabular-nums">{data.totalPct.toFixed(1)}%</span>}
41-
/>
53+
<StatRow label="Total" value={<span className="tabular-nums">{snapshot.totalPct.toFixed(1)}%</span>} />
4254
</StatList>
4355

44-
<div className="mt-3">
45-
<LoadTriple
46-
abs={[data.load.l1, data.load.l5, data.load.l15]}
47-
pct={[
48-
Math.min(100, (data.load.l1 / data.load.cpus) * 100),
49-
Math.min(100, (data.load.l5 / data.load.cpus) * 100),
50-
Math.min(100, (data.load.l15 / data.load.cpus) * 100),
51-
]}
52-
/>
53-
</div>
56+
<Section>
57+
<LoadTriple pct={snapshot.pctTriple} />
58+
</Section>
5459

55-
<div className="mt-3">
60+
<Section>
5661
<StatList>
57-
<StatRow
58-
label="CPU Temp"
59-
value={
60-
<span className="inline-flex items-center gap-2">
61-
<StatusDot tone={tempTone} title={`CPU temperature`} />
62-
<span>{data.tempC == null ? "n/a" : `${data.tempC.toFixed(1)}°C`}</span>
63-
</span>
64-
}
65-
/>
66-
<StatRow label="Fan" value={fanText} />
62+
<StatRow label="CPU Temp" value={<span className="inline-flex items-center gap-2">{snapshot.tempText}</span>} />
63+
<StatRow label="Fan" value={snapshot.fanText} />
6764
</StatList>
68-
</div>
65+
</Section>
6966

70-
<div className="mt-3 space-y-2">
71-
{data.perCorePct.map((v: number, i: number) => (
72-
<div key={i} className="space-y-1">
73-
<div className="flex justify-between text-xs text-muted-foreground">
74-
<span>Core {i}</span>
75-
<span className="tabular-nums">{v.toFixed(1)}%</span>
76-
</div>
77-
<Progress value={v} className="h-2" />
78-
</div>
79-
))}
80-
</div>
67+
<PerCoreList values={snapshot.perCore} />
8168

82-
{expanded && (
83-
<div className="mt-3">
84-
<div className="text-sm font-semibold mb-2">Top processes</div>
85-
<ul className="space-y-1">
86-
{data.topCpu.slice(0, 10).map((p: CpuInfo["topCpu"][number]) => (
87-
<li key={p.pid} className="flex justify-between text-xs text-muted-foreground">
88-
<span className="truncate max-w-[60%]">
89-
{p.cmd} <span className="opacity-70">({p.pid})</span>
90-
</span>
91-
<span className="tabular-nums">{p.cpuPct.toFixed(1)}%</span>
92-
</li>
93-
))}
94-
</ul>
95-
</div>
96-
)}
69+
<Collapsible expanded={expanded} summary={<SummaryTitle text="Top processes" />}>
70+
<ProcessList
71+
items={snapshot.topCpu.map((p: CpuInfo["topCpu"][number]) => ({
72+
pid: p.pid,
73+
cmd: p.cmd,
74+
cpuPct: p.cpuPct,
75+
}))}
76+
/>
77+
</Collapsible>
9778
</CardShell>
9879
);
9980
}

src/components/cards/GeneralCard.tsx

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,39 @@
11
"use client";
22

3+
import { useMemo } from "react";
34
import { CardShell } from "@/components/ui/CardShell";
45
import { StatList } from "@/components/ui/StatList";
56
import { StatRow } from "@/components/ui/StatRow";
67
import { CardLoading, CardError } from "@/components/ui/CardState";
78
import { useGeneral } from "@/hooks/useCardData";
89

10+
function formatUptime(secs: number) {
11+
const h = Math.floor(secs / 3600);
12+
const m = Math.floor((secs % 3600) / 60);
13+
return `${h}h ${m}m`;
14+
}
15+
916
export default function GeneralCard() {
1017
const { data, error } = useGeneral(30_000);
1118

12-
if (error) return <CardError title="General" />;
13-
if (!data) return <CardLoading title="General" />;
19+
const snapshot = useMemo(() => {
20+
if (!data) return null;
21+
return {
22+
hostname: data.os.hostname,
23+
platform: `${data.os.platform} - ${data.os.arch}`,
24+
uptimeText: formatUptime(Number(data.uptimeSec ?? 0)),
25+
};
26+
}, [data]);
1427

15-
const secs = Number(data.uptimeSec ?? 0);
16-
const h = Math.floor(secs / 3600);
17-
const m = Math.floor((secs % 3600) / 60);
28+
if (error) return <CardError title="General" />;
29+
if (!snapshot) return <CardLoading title="General" />;
1830

1931
return (
2032
<CardShell title="General">
2133
<StatList>
22-
<StatRow label="Hostname" value={data.os.hostname} />
23-
<StatRow label="Platform" value={`${data.os.platform} - ${data.os.arch}`} />
24-
<StatRow label="Uptime" value={`${h}h ${m}m`} />
34+
<StatRow label="Hostname" value={snapshot.hostname} />
35+
<StatRow label="Platform" value={snapshot.platform} />
36+
<StatRow label="Uptime" value={snapshot.uptimeText} />
2537
</StatList>
2638
</CardShell>
2739
);

src/components/cards/MemoryCard.tsx

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,57 @@
11
"use client";
22

3-
import { useState } from "react";
3+
import { useMemo, useState } from "react";
44
import { CardShell } from "@/components/ui/CardShell";
55
import { ProgressStat } from "@/components/ui/ProgressStat";
66
import { Collapsible } from "@/components/ui/Collapsible";
77
import { ShowMoreButton } from "@/components/ui/ShowMoreButton";
8-
import { ProcessRow } from "@/components/rows/ProcessRow";
98
import { CardLoading, CardError } from "@/components/ui/CardState";
109
import { useMemory } from "@/hooks/useCardData";
1110
import type { TopProc } from "@/lib/system/types";
11+
import { Section } from "@/components/ui/Section";
12+
import { ProcessList } from "@/components/rows/ProcessList";
13+
import { SummaryTitle } from "@/components/ui/SummaryTitle";
14+
15+
function clamp(n: number, lo = 0, hi = 100) { return Math.max(lo, Math.min(hi, n)); }
1216

1317
export default function MemoryCard() {
1418
const { data, error } = useMemory(1000);
1519
const [expanded, setExpanded] = useState(false);
1620

17-
if (error) return <CardError title="Memory" />;
18-
if (!data) return <CardLoading title="Memory" />;
21+
const snapshot = useMemo(() => {
22+
if (!data) return null;
23+
const used = Number(data.usedGB);
24+
const total = Number(data.totalGB);
25+
const pct = clamp((used / (total || 1)) * 100);
26+
const top = Array.isArray(data.topProcs) ? (data.topProcs as TopProc[]) : [];
27+
return {
28+
used,
29+
total,
30+
pct,
31+
top,
32+
visible: top.slice(0, expanded ? 20 : 8),
33+
hasTop: top.length > 0,
34+
};
35+
}, [data, expanded]);
1936

20-
const pct = (data.usedGB / data.totalGB) * 100;
21-
const top = Array.isArray(data.topProcs) ? (data.topProcs as TopProc[]) : [];
22-
const visible = top.slice(0, expanded ? 20 : 8);
37+
if (error) return <CardError title="Memory" />;
38+
if (!snapshot) return <CardLoading title="Memory" />;
2339

2440
return (
2541
<CardShell
2642
title="Memory"
27-
actions={
28-
top.length > 0 ? (
29-
<ShowMoreButton expanded={expanded} onToggle={() => setExpanded((v) => !v)} />
30-
) : null
31-
}
43+
actions={snapshot.hasTop ? (
44+
<ShowMoreButton expanded={expanded} onToggle={() => setExpanded(v => !v)} />
45+
) : null}
3246
>
33-
<ProgressStat label="Used" used={data.usedGB} total={data.totalGB} valuePercent={pct} />
47+
<ProgressStat label="Used" used={snapshot.used} total={snapshot.total} valuePercent={snapshot.pct} />
3448

35-
{top.length > 0 && (
36-
<Collapsible
37-
expanded={expanded}
38-
summary={<div className="text-sm font-semibold">Top processes</div>}
39-
>
40-
<ul className="space-y-1">
41-
{visible.map((p: TopProc) => (
42-
<li key={p.pid}>
43-
<ProcessRow
44-
pid={p.pid}
45-
name={p.cmd}
46-
rssMB={Number(p.rssMB)}
47-
memPct={Number(p.memPct)}
48-
/>
49-
</li>
50-
))}
51-
</ul>
52-
</Collapsible>
49+
{snapshot.hasTop && (
50+
<Section>
51+
<Collapsible expanded={expanded} summary={<SummaryTitle text="Top processes" />}>
52+
<ProcessList items={snapshot.visible} />
53+
</Collapsible>
54+
</Section>
5355
)}
5456
</CardShell>
5557
);

0 commit comments

Comments
 (0)