@flame0510/project-aether 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/app/agents/CostSection.tsx +2 -8
  3. package/app/agents/PageClient.tsx +7 -0
  4. package/app/agents/PanelRow.tsx +9 -0
  5. package/app/agents/ResourceSection.tsx +105 -0
  6. package/app/api/assistant/route.ts +2 -2
  7. package/app/api/containers/route.ts +18 -2
  8. package/app/api/costs/agent/route.ts +2 -3
  9. package/app/api/metrics/alerts/route.ts +47 -0
  10. package/app/api/metrics/containers/route.ts +54 -0
  11. package/app/api/metrics/route.ts +4 -6
  12. package/app/components/ui/Accordion.tsx +45 -0
  13. package/app/components/ui/TimeSeriesChart.tsx +20 -2
  14. package/app/components/ui/index.ts +1 -0
  15. package/app/containers/ContainersClient.tsx +48 -6
  16. package/app/globals.css +36 -0
  17. package/app/system/AgentCharts.tsx +138 -0
  18. package/app/system/AgentsSection.tsx +281 -0
  19. package/app/system/PageClient.tsx +7 -7
  20. package/app/system/RecentAlerts.tsx +72 -0
  21. package/app/system/SystemSkeleton.tsx +53 -1
  22. package/app/system/loading.tsx +5 -1
  23. package/daemon.js +195 -4
  24. package/docs/ARCHITECTURE.md +36 -5
  25. package/docs/FRONTEND-ARCHITECTURE.md +12 -3
  26. package/docs/REV4A.md +4 -4
  27. package/docs/dev/API-REFERENCE.md +91 -17
  28. package/docs/dev/DATABASE.md +58 -2
  29. package/docs/rag/DATA-FRESHNESS.md +16 -1
  30. package/docs/rag/GLOSSARY.md +2 -2
  31. package/docs/rag/REV4A-OVERVIEW.md +7 -3
  32. package/docs/rag/WHAT-I-CAN-ANSWER.md +4 -1
  33. package/lib/container-metrics.ts +340 -0
  34. package/lib/docker-socket-path.js +133 -0
  35. package/lib/docker-socket.ts +10 -99
  36. package/lib/docker-stats.js +284 -0
  37. package/lib/metrics-db.ts +15 -6
  38. package/lib/utils/format.ts +29 -3
  39. package/package.json +1 -1
  40. package/scripts/test-docker-stats.mjs +270 -0
@@ -0,0 +1,138 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { apiFetch } from '@/lib/apiFetch';
5
+ import { formatMb } from '@/lib/utils/format';
6
+ import { Skeleton } from '../components/Skeleton';
7
+ import { Surface, TimeSeriesChart } from '../components/ui';
8
+
9
+ const POLL_MS = 30_000;
10
+
11
+ export interface ContainerHistoryPayload {
12
+ available: boolean;
13
+ range: string;
14
+ bucket_s: number;
15
+ history: { ts: number; cpu_avg: number | null; cpu_max: number | null; mem_avg: number | null; mem_max: number | null }[];
16
+ storage_bucket_s: number;
17
+ storage_history: { ts: number; total_mb: number | null }[];
18
+ }
19
+
20
+ /** A CPU share axis label: an agent sits between 0.01 % and a few percent, so the decimals move with the scale. */
21
+ function cpuLabel(v: number): string {
22
+ if (v >= 10) return `${Math.round(v)}%`;
23
+ if (v >= 1) return `${Math.round(v * 10) / 10}%`;
24
+ return `${Math.round(v * 100) / 100}%`;
25
+ }
26
+
27
+ /**
28
+ * The same three charts as the machine's — CPU, Memory, Storage — for one container, under
29
+ * the page's range tabs. Mounted only while its accordion is open: it fetches on mount, polls
30
+ * every 30 s with an `AbortController` ref, and the fetch is aborted when it closes or the
31
+ * range changes. Each series is a small fraction of Docker's cores, so the y axis follows its
32
+ * own peak (`yMax="auto"`) and memory and storage are absolute sizes, not percents.
33
+ */
34
+ export function AgentCharts({
35
+ container, range, span, formatTime,
36
+ }: { container: string; range: string; span: number; formatTime: (ts: number) => string }) {
37
+ const [data, setData] = useState<ContainerHistoryPayload | null>(null);
38
+ const [error, setError] = useState('');
39
+ const abortRef = useRef<AbortController | null>(null);
40
+
41
+ useEffect(() => {
42
+ const load = async () => {
43
+ abortRef.current?.abort();
44
+ const controller = new AbortController();
45
+ abortRef.current = controller;
46
+ try {
47
+ const res = await apiFetch(
48
+ `/api/metrics/containers?container=${encodeURIComponent(container)}&range=${range}`,
49
+ { signal: controller.signal },
50
+ );
51
+ const body = await res.json();
52
+ if (!res.ok) throw new Error(body?.error || `Error ${res.status}`);
53
+ setData(body as ContainerHistoryPayload);
54
+ setError('');
55
+ } catch (e: any) {
56
+ if (e?.name !== 'AbortError') setError(e?.message || 'Could not load the history');
57
+ }
58
+ };
59
+ load();
60
+ const timer = setInterval(load, POLL_MS);
61
+ return () => {
62
+ clearInterval(timer);
63
+ abortRef.current?.abort();
64
+ };
65
+ }, [container, range]);
66
+
67
+ const to = Math.floor(Date.now() / 1000);
68
+ const from = to - span;
69
+ // While another range loads the previous render stays, dimmed.
70
+ const stale = data !== null && data.range !== range;
71
+
72
+ if (error && !data) return <span style={{ color: 'var(--red)', fontSize: 12 }}>{error}</span>;
73
+ if (!data) {
74
+ return (
75
+ <div className="system-agent-charts" aria-hidden="true">
76
+ {[0, 1, 2].map((i) => (
77
+ <Surface as="section" variant="subtle" key={i}>
78
+ <div className="ui-kicker"><Skeleton as="span" inline width={110} height={10} /></div>
79
+ <Skeleton height={160} />
80
+ <div className="ui-chart__table"><div style={{ fontSize: 10 }}><Skeleton as="span" inline width={48} height={9} /></div></div>
81
+ </Surface>
82
+ ))}
83
+ </div>
84
+ );
85
+ }
86
+
87
+ return (
88
+ <div className="system-agent-charts">
89
+ {error && (
90
+ <p className="ui-muted" style={{ fontSize: 11, margin: 0, gridColumn: '1 / -1' }}>
91
+ Could not refresh ({error}) — showing the last answer; it is tried again every {POLL_MS / 1000} s.
92
+ </p>
93
+ )}
94
+ <Surface as="section" variant="subtle">
95
+ <div className="ui-kicker">CPU · share of Docker's cores</div>
96
+ <TimeSeriesChart
97
+ label={`CPU share of ${container}`}
98
+ points={data.history.map((p) => ({ ts: p.ts, value: p.cpu_avg, peak: p.cpu_max }))}
99
+ from={from}
100
+ to={to}
101
+ bucket={data.bucket_s}
102
+ yMax="auto"
103
+ formatValue={cpuLabel}
104
+ stale={stale}
105
+ formatTime={formatTime}
106
+ />
107
+ </Surface>
108
+ <Surface as="section" variant="subtle">
109
+ <div className="ui-kicker">Memory · average and peak</div>
110
+ <TimeSeriesChart
111
+ label={`Memory of ${container}`}
112
+ points={data.history.map((p) => ({ ts: p.ts, value: p.mem_avg, peak: p.mem_max }))}
113
+ from={from}
114
+ to={to}
115
+ bucket={data.bucket_s}
116
+ yMax="auto"
117
+ formatValue={formatMb}
118
+ stale={stale}
119
+ formatTime={formatTime}
120
+ />
121
+ </Surface>
122
+ <Surface as="section" variant="subtle">
123
+ <div className="ui-kicker">Storage · volumes and writable layer</div>
124
+ <TimeSeriesChart
125
+ label={`Storage of ${container}`}
126
+ points={data.storage_history.map((p) => ({ ts: p.ts, value: p.total_mb }))}
127
+ from={from}
128
+ to={to}
129
+ bucket={data.storage_bucket_s}
130
+ yMax="auto"
131
+ formatValue={formatMb}
132
+ stale={stale}
133
+ formatTime={formatTime}
134
+ />
135
+ </Surface>
136
+ </div>
137
+ );
138
+ }
@@ -0,0 +1,281 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
4
+ import { apiFetch } from '@/lib/apiFetch';
5
+ import { formatMb, formatRate, formatSharePercent } from '@/lib/utils/format';
6
+ import { Accordion, Pill, Surface } from '../components/ui';
7
+ import { AgentCharts } from './AgentCharts';
8
+ import { SystemAgentsHeading, SystemAgentsSkeleton } from './SystemSkeleton';
9
+
10
+ const POLL_MS = 30_000;
11
+ const CONTAINER_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
12
+ /** Three sampling intervals: fresher than this, the daemon has just seen the container. */
13
+ const RECENT_S = 180;
14
+
15
+ interface ContainerRow {
16
+ container: string;
17
+ name: string;
18
+ agent_id: string | null;
19
+ is_agent: boolean;
20
+ running: boolean;
21
+ last_seen: number;
22
+ now: {
23
+ cpu_cores: number | null; cpu_percent: number | null; mem_mb: number | null; mem_percent: number | null; pids: number | null;
24
+ net_rx_bps: number | null; net_tx_bps: number | null; blk_read_bps: number | null; blk_write_bps: number | null;
25
+ } | null;
26
+ range: { cpu_avg_percent: number | null; cpu_max_percent: number | null; mem_avg_mb: number | null; mem_max_mb: number | null };
27
+ storage: { volume_mb: number | null; layer_mb: number | null; total_mb: number | null; ts: number | null };
28
+ }
29
+
30
+ interface DockerStorage {
31
+ ts: number;
32
+ images_mb: number | null; images_reclaimable_mb: number | null;
33
+ build_cache_mb: number | null; build_cache_reclaimable_mb: number | null;
34
+ volumes_mb: number; unused_volumes_mb: number; layers_mb: number;
35
+ unused_volumes: { name: string; size_mb: number }[];
36
+ }
37
+
38
+ interface OverviewPayload {
39
+ available: boolean;
40
+ range: string;
41
+ interval_s: number;
42
+ host?: { ncpu: number; mem_total_mb: number } | null;
43
+ sampled_at?: number | null;
44
+ age_s?: number | null;
45
+ containers?: ContainerRow[];
46
+ rest?: { cpu_percent: number | null; mem_mb: number | null } | null;
47
+ docker_storage?: DockerStorage | null;
48
+ }
49
+
50
+ function ago(ts: number): string {
51
+ const min = Math.round((Date.now() / 1000 - ts) / 60);
52
+ if (min < 1) return 'just now';
53
+ if (min < 60) return `${min} min ago`;
54
+ const h = Math.round(min / 60);
55
+ return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} d ago`;
56
+ }
57
+
58
+ function Figure({ label, value }: { label: string; value: ReactNode }) {
59
+ return <span><span className="ui-muted">{label}</span> {value}</span>;
60
+ }
61
+
62
+ /** One cell of the Docker storage block: what it is, how much, and what of it no container uses. */
63
+ function StorageCell({ label, value, note, title }: { label: string; value: string; note?: string; title?: string }) {
64
+ return (
65
+ <div title={title}>
66
+ <div className="ui-kicker">{label}</div>
67
+ <div style={{ fontSize: 16, marginTop: 6 }}>{value}</div>
68
+ {note && <div className="ui-muted" style={{ fontSize: 11, marginTop: 4 }}>{note}</div>}
69
+ </div>
70
+ );
71
+ }
72
+
73
+ /**
74
+ * The System page's *Agents* section: one accordion per container — closed it carries the
75
+ * numbers (CPU, memory, storage, processes), open it carries the same three charts as the
76
+ * machine, for that container, under the page's range tabs. Above them what Docker holds on
77
+ * disk; below them everything that is not a container, so the figures add up to the cards.
78
+ *
79
+ * `/system?agent=<container>` opens one accordion and scrolls to it.
80
+ */
81
+ export function AgentsSection({
82
+ range, span, formatTime,
83
+ }: { range: string; span: number; formatTime: (ts: number) => string }) {
84
+ const [data, setData] = useState<OverviewPayload | null>(null);
85
+ const [error, setError] = useState('');
86
+ const [open, setOpen] = useState<string[]>([]);
87
+ const [nowS, setNowS] = useState(() => Math.floor(Date.now() / 1000));
88
+ const abortRef = useRef<AbortController | null>(null);
89
+ const scrollTo = useRef<string | null>(null);
90
+
91
+ // A link from the agent panel: open that accordion, scroll to it once the list has loaded.
92
+ useEffect(() => {
93
+ const agent = new URLSearchParams(window.location.search).get('agent');
94
+ if (agent && CONTAINER_RE.test(agent)) {
95
+ setOpen([agent]);
96
+ scrollTo.current = agent;
97
+ }
98
+ }, []);
99
+
100
+ useEffect(() => {
101
+ const load = async () => {
102
+ // Advance the displayed age even when another failed request has the same error text.
103
+ setNowS(Math.floor(Date.now() / 1000));
104
+ abortRef.current?.abort();
105
+ const controller = new AbortController();
106
+ abortRef.current = controller;
107
+ try {
108
+ const res = await apiFetch(`/api/metrics/containers?range=${range}`, { signal: controller.signal });
109
+ const body = await res.json();
110
+ if (!res.ok) throw new Error(body?.error || `Error ${res.status}`);
111
+ setData(body as OverviewPayload);
112
+ setError('');
113
+ } catch (e: any) {
114
+ if (e?.name !== 'AbortError') setError(e?.message || 'Could not load the containers');
115
+ }
116
+ };
117
+ load();
118
+ const timer = setInterval(load, POLL_MS);
119
+ return () => {
120
+ clearInterval(timer);
121
+ abortRef.current?.abort();
122
+ };
123
+ }, [range]);
124
+
125
+ // One attempt, on the first answer: a container that is not in the list then must not pull
126
+ // the page down later, on a poll that happens to include it.
127
+ useEffect(() => {
128
+ const target = scrollTo.current;
129
+ if (!data || !target) return;
130
+ scrollTo.current = null;
131
+ document.getElementById(`agent-${target}`)?.scrollIntoView({ block: 'start' });
132
+ }, [data]);
133
+
134
+ const toggle = (container: string) =>
135
+ setOpen((prev) => (prev.includes(container) ? prev.filter((c) => c !== container) : [...prev, container]));
136
+
137
+ const stale = data !== null && data.range !== range;
138
+ const containers = data?.containers ?? [];
139
+ const storage = data?.docker_storage ?? null;
140
+ // Recompute age from the sample timestamp: a failed refresh must not freeze an old answer's
141
+ // age_s at the value it had when it was fetched.
142
+ const sampleAgeS = data?.sampled_at == null ? null : Math.max(0, nowS - data.sampled_at);
143
+ const notCurrent = data?.available && sampleAgeS !== null && sampleAgeS > data.interval_s * 3;
144
+
145
+ return (
146
+ <section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
147
+ <SystemAgentsHeading />
148
+
149
+ {error && !data && <Surface tone="danger"><span style={{ color: 'var(--red)', fontSize: 12 }}>{error}</span></Surface>}
150
+
151
+ {error && data && (
152
+ <Surface tone="warning">
153
+ <div className="ui-kicker">Could not refresh</div>
154
+ <p className="ui-muted" style={{ fontSize: 12, marginTop: 8 }}>{error}. Showing the last answer; it is tried again every {POLL_MS / 1000} s.</p>
155
+ </Surface>
156
+ )}
157
+
158
+ {!data && !error && <SystemAgentsSkeleton />}
159
+
160
+ {data && !data.available && (
161
+ <Surface tone="warning">
162
+ <div className="ui-kicker">No container samples yet</div>
163
+ <p className="ui-muted" style={{ fontSize: 12, marginTop: 8 }}>
164
+ The Rev4a daemon records them every {data.interval_s} s once Docker answers. If this stays empty, check that rev4a serve is running and that Docker is up.
165
+ </p>
166
+ </Surface>
167
+ )}
168
+
169
+ {notCurrent && sampleAgeS !== null && (
170
+ <Surface tone="warning">
171
+ <div className="ui-kicker">Not collecting</div>
172
+ <p className="ui-muted" style={{ fontSize: 12, marginTop: 8 }}>
173
+ The latest container sample is {Math.round(sampleAgeS / 60)} min old: the values below are not current. Check that the Rev4a daemon is running and that Docker is up.
174
+ </p>
175
+ </Surface>
176
+ )}
177
+
178
+ {data?.available && (
179
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12, opacity: stale ? 0.5 : 1 }}>
180
+ {storage && (
181
+ <Surface as="section">
182
+ <div className="ui-kicker">Docker storage</div>
183
+ <div className="system-docker-storage" style={{ marginTop: 10 }}>
184
+ <StorageCell
185
+ label="Images"
186
+ value={formatMb(storage.images_mb)}
187
+ note={storage.images_reclaimable_mb ? `${formatMb(storage.images_reclaimable_mb)} not used by a container` : undefined}
188
+ title="Images no container is running from — older agent versions you may still roll back to count here"
189
+ />
190
+ <StorageCell
191
+ label="Volumes"
192
+ value={formatMb(storage.volumes_mb)}
193
+ note={storage.unused_volumes_mb ? `${formatMb(storage.unused_volumes_mb)} not attached to a container` : undefined}
194
+ title={storage.unused_volumes.map((v) => `${v.name}: ${formatMb(v.size_mb)}`).join('\n') || undefined}
195
+ />
196
+ <StorageCell label="Writable layers" value={formatMb(storage.layers_mb)} />
197
+ <StorageCell
198
+ label="Build cache"
199
+ value={formatMb(storage.build_cache_mb)}
200
+ note={storage.build_cache_reclaimable_mb ? `${formatMb(storage.build_cache_reclaimable_mb)} not in use` : undefined}
201
+ />
202
+ </div>
203
+ <p className="ui-muted" style={{ fontSize: 11, marginTop: 10 }}>
204
+ Read {ago(storage.ts)}, every 10 min.
205
+ {storage.unused_volumes.length > 0 && ` Not attached: ${storage.unused_volumes.map((v) => `${v.name} (${formatMb(v.size_mb)})`).join(', ')}.`}
206
+ </p>
207
+ </Surface>
208
+ )}
209
+
210
+ {containers.length === 0 && (
211
+ <Surface><p className="ui-muted" style={{ fontSize: 12 }}>No container was seen in this range.</p></Surface>
212
+ )}
213
+
214
+ {containers.map((c) => {
215
+ const r = c.range;
216
+ // Docker's list can stay fresh even when every stats call fails. Decide whether
217
+ // the container was recently seen from its own timestamp, not the newest metrics row.
218
+ const observed = Math.max(0, nowS - c.last_seen) <= RECENT_S;
219
+ const current = !notCurrent && c.running ? c.now : null;
220
+ const status = current ? null : observed ? 'no data' : notCurrent ? 'not current' : 'stopped';
221
+ const parts = current
222
+ ? [
223
+ current.cpu_cores !== null ? `${current.cpu_cores.toFixed(3)} cores` : null,
224
+ r.cpu_avg_percent !== null ? `CPU avg ${formatSharePercent(r.cpu_avg_percent)} · peak ${formatSharePercent(r.cpu_max_percent)}` : null,
225
+ r.mem_avg_mb !== null ? `Memory avg ${formatMb(r.mem_avg_mb)} · peak ${formatMb(r.mem_max_mb)}` : null,
226
+ current.mem_percent !== null ? `${current.mem_percent}% of Docker's memory` : null,
227
+ current.net_rx_bps !== null || current.net_tx_bps !== null ? `↓ ${formatRate(current.net_rx_bps)} ↑ ${formatRate(current.net_tx_bps)}` : null,
228
+ ]
229
+ : [`Last seen ${ago(c.last_seen)}`, r.cpu_avg_percent !== null ? `CPU avg ${formatSharePercent(r.cpu_avg_percent)} · peak ${formatSharePercent(r.cpu_max_percent)} while running` : null];
230
+ return (
231
+ <Accordion
232
+ key={c.container}
233
+ id={`agent-${c.container}`}
234
+ open={open.includes(c.container)}
235
+ onToggle={() => toggle(c.container)}
236
+ title={
237
+ <>
238
+ <span style={{ fontWeight: 600 }}>{c.name}</span>
239
+ <Pill tone={c.is_agent ? 'accent' : 'neutral'}>{c.is_agent ? 'agent' : 'container'}</Pill>
240
+ {status && <Pill tone="warning">{status}</Pill>}
241
+ {c.name !== c.container && (
242
+ <span className="ui-muted" style={{ fontSize: 11, fontFamily: 'var(--font-mono-stack)' }}>{c.container}</span>
243
+ )}
244
+ </>
245
+ }
246
+ summary={
247
+ current ? (
248
+ <>
249
+ <Figure label="CPU" value={formatSharePercent(current.cpu_percent)} />
250
+ <Figure label="Memory" value={formatMb(current.mem_mb)} />
251
+ <Figure label="Storage" value={formatMb(c.storage.total_mb)} />
252
+ <Figure label="PIDs" value={current.pids ?? '—'} />
253
+ </>
254
+ ) : (
255
+ <Figure label="Storage" value={formatMb(c.storage.total_mb)} />
256
+ )
257
+ }
258
+ detail={parts.filter(Boolean).join(' · ')}
259
+ >
260
+ <AgentCharts container={c.container} range={range} span={span} formatTime={formatTime} />
261
+ </Accordion>
262
+ );
263
+ })}
264
+
265
+ {data.rest && containers.length > 0 && (
266
+ <Surface>
267
+ <div className="ui-key-value" style={{ fontSize: 12 }}>
268
+ <span>Everything else <span className="ui-muted">· the host, Rev4a and other software</span></span>
269
+ <span>
270
+ <Figure label="CPU" value={formatSharePercent(data.rest.cpu_percent)} />
271
+ {' '}
272
+ <Figure label="Memory" value={formatMb(data.rest.mem_mb)} />
273
+ </span>
274
+ </div>
275
+ </Surface>
276
+ )}
277
+ </div>
278
+ )}
279
+ </section>
280
+ );
281
+ }
@@ -3,7 +3,10 @@
3
3
  import { useCallback, useEffect, useRef, useState } from 'react';
4
4
  import { apiFetch } from '@/lib/apiFetch';
5
5
  import { useRev4aTimezone } from '@/lib/hooks/useRev4aTimezone';
6
+ import { formatMb } from '@/lib/utils/format';
6
7
  import { Meter, meterLevel, Page, PageHeader, Pill, Surface, Tabs, TimeSeriesChart } from '../components/ui';
8
+ import { AgentsSection } from './AgentsSection';
9
+ import { RecentAlerts } from './RecentAlerts';
7
10
  import { SystemCardsSkeleton, SystemChartsSkeleton, SystemDescription } from './SystemSkeleton';
8
11
 
9
12
  type Range = '1h' | '24h' | '7d' | '30d';
@@ -52,13 +55,6 @@ interface MetricsPayload {
52
55
 
53
56
  const ROLE_LABEL: Record<string, string> = { root: 'system', docker: 'Docker data', data: 'Rev4a data' };
54
57
 
55
- function formatMb(mb: number | null | undefined): string {
56
- if (mb === null || mb === undefined) return '—';
57
- if (mb >= 1024 * 1024) return `${(mb / 1024 / 1024).toFixed(2)} TB`;
58
- if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
59
- return `${mb} MB`;
60
- }
61
-
62
58
  function formatUptime(s: number): string {
63
59
  const d = Math.floor(s / 86_400);
64
60
  const h = Math.floor((s % 86_400) / 3600);
@@ -258,6 +254,10 @@ export default function SystemPageClient() {
258
254
  ))}
259
255
  </div>
260
256
  )}
257
+
258
+ <AgentsSection range={range} span={span} formatTime={formatTime} />
259
+
260
+ <RecentAlerts timeZone={timeZone} />
261
261
  </Page>
262
262
  );
263
263
  }
@@ -0,0 +1,72 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { apiFetch } from '@/lib/apiFetch';
5
+ import { Pill, Surface } from '../components/ui';
6
+ import type { Tone } from '../components/ui/tokens';
7
+
8
+ const POLL_MS = 60_000;
9
+ const LIMIT = 10;
10
+
11
+ interface Alert { ts: number; metric: string | null; message: string }
12
+
13
+ /** A disk at its limit is the one that does not clear by itself. Only its own keys, never the prototype's. */
14
+ const TONE: Record<string, Tone> = { disk: 'danger', cpu: 'warning', ram: 'warning' };
15
+ const toneOf = (metric: string): Tone => (Object.hasOwn(TONE, metric) ? TONE[metric] : 'neutral');
16
+
17
+ /**
18
+ * The machine's latest alarms — CPU or memory over their threshold on two samples in a row,
19
+ * a disk at 90 % — which the daemon has always recorded and no page showed, so the red bar
20
+ * on a card never said *what* had crossed or *when*. From `GET /api/metrics/alerts`.
21
+ */
22
+ export function RecentAlerts({ timeZone }: { timeZone: string }) {
23
+ const [alerts, setAlerts] = useState<Alert[] | null>(null);
24
+ const [error, setError] = useState('');
25
+ const abortRef = useRef<AbortController | null>(null);
26
+
27
+ useEffect(() => {
28
+ const load = async () => {
29
+ abortRef.current?.abort();
30
+ const controller = new AbortController();
31
+ abortRef.current = controller;
32
+ try {
33
+ const res = await apiFetch(`/api/metrics/alerts?limit=${LIMIT}`, { signal: controller.signal });
34
+ const body = await res.json();
35
+ if (!res.ok) throw new Error(body?.error || `Error ${res.status}`);
36
+ setAlerts(body.alerts as Alert[]);
37
+ setError('');
38
+ } catch (e: any) {
39
+ if (e?.name !== 'AbortError') setError(e?.message || 'Could not load the alerts');
40
+ }
41
+ };
42
+ load();
43
+ const timer = setInterval(load, POLL_MS);
44
+ return () => {
45
+ clearInterval(timer);
46
+ abortRef.current?.abort();
47
+ };
48
+ }, []);
49
+
50
+ const format = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: false, timeZone });
51
+
52
+ return (
53
+ <Surface as="section">
54
+ <div className="ui-kicker">Recent alerts</div>
55
+ <p className="ui-muted" style={{ fontSize: 11, marginTop: 6 }}>
56
+ CPU above 85 % or memory above 90 % on two samples in a row; a disk at 90 %. The latest {LIMIT}.
57
+ </p>
58
+ <div style={{ marginTop: 10 }}>
59
+ {error && <span style={{ color: 'var(--red)', fontSize: 12 }}>{error}</span>}
60
+ {!error && alerts === null && <span className="ui-muted" style={{ fontSize: 12 }}>Loading…</span>}
61
+ {alerts !== null && alerts.length === 0 && <span className="ui-muted" style={{ fontSize: 12 }}>No alert recorded.</span>}
62
+ {alerts?.map((a) => (
63
+ <div className="system-alert" key={`${a.ts}-${a.message}`}>
64
+ <span className="ui-muted">{format.format(new Date(a.ts))}</span>
65
+ <span>{a.metric ? <Pill tone={toneOf(a.metric)}>{a.metric}</Pill> : null}</span>
66
+ <span>{a.message}</span>
67
+ </div>
68
+ ))}
69
+ </div>
70
+ </Surface>
71
+ );
72
+ }
@@ -16,7 +16,7 @@ import { Surface } from '../components/ui';
16
16
  export function SystemDescription({ host }: { host: string | null }) {
17
17
  return (
18
18
  <>
19
- CPU, memory and storage of the machine Rev4a runs on.
19
+ CPU, memory and storage of the machine Rev4a runs on, and of each container.
20
20
  {/* width 0 + min-width 100%: the line takes the header's width without adding its own
21
21
  (unwrapped) width to the header's minimum — else a long hostname widens the page. */}
22
22
  <span style={{ display: 'block', width: 0, minWidth: '100%', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
@@ -113,3 +113,55 @@ export function SystemRangeSkeleton() {
113
113
  </div>
114
114
  );
115
115
  }
116
+
117
+ /** The section heading, shared by the real Agents section and its placeholder, so nothing moves when it loads. */
118
+ export function SystemAgentsHeading() {
119
+ return (
120
+ <div className="system-section-heading">
121
+ <div className="ui-eyebrow">Agents and containers</div>
122
+ <p className="ui-muted" style={{ fontSize: 12 }}>
123
+ Sorted by CPU now. Averages and peaks follow the range above; open one for its charts.
124
+ </p>
125
+ </div>
126
+ );
127
+ }
128
+
129
+ /**
130
+ * The Agents section before its first answer: the Docker storage block and three closed
131
+ * accordions, each shimmer inside the class of the text it stands in for (the real header
132
+ * classes, a `div` where the real one is a button), so the boxes have the real heights.
133
+ */
134
+ export function SystemAgentsSkeleton() {
135
+ return (
136
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }} aria-busy="true" aria-label="Loading containers">
137
+ <Surface as="section">
138
+ <div className="ui-kicker"><Skeleton as="span" inline width={96} height={10} /></div>
139
+ <div className="system-docker-storage" style={{ marginTop: 10 }}>
140
+ {[0, 1, 2, 3].map((i) => (
141
+ <div key={i}>
142
+ <div className="ui-kicker"><Skeleton as="span" inline width={64} height={10} /></div>
143
+ <div style={{ fontSize: 16, marginTop: 6 }}><Skeleton as="span" inline width={72} height={16} /></div>
144
+ <div className="ui-muted" style={{ fontSize: 11, marginTop: 4 }}><Skeleton as="span" inline width={120} height={10} /></div>
145
+ </div>
146
+ ))}
147
+ </div>
148
+ <p className="ui-muted" style={{ fontSize: 11, marginTop: 10 }}><Skeleton as="span" inline width={150} height={10} /></p>
149
+ </Surface>
150
+ {[0, 1, 2].map((i) => (
151
+ <section className="ui-accordion" key={i}>
152
+ <div className="ui-accordion__header" style={{ cursor: 'default' }}>
153
+ <span className="ui-accordion__caret" aria-hidden="true">▶</span>
154
+ <span className="ui-accordion__title"><Skeleton as="span" inline width={110 + i * 20} height={13} /></span>
155
+ <span className="ui-accordion__summary">
156
+ <Skeleton as="span" inline width={62} height={12} />
157
+ <Skeleton as="span" inline width={88} height={12} />
158
+ <Skeleton as="span" inline width={80} height={12} />
159
+ <Skeleton as="span" inline width={52} height={12} />
160
+ </span>
161
+ <span className="ui-accordion__detail"><Skeleton as="span" inline width={300} height={11} /></span>
162
+ </div>
163
+ </section>
164
+ ))}
165
+ </div>
166
+ );
167
+ }
@@ -1,5 +1,5 @@
1
1
  import { Page, PageHeader } from '../components/ui';
2
- import { SystemCardsSkeleton, SystemChartsSkeleton, SystemDescription, SystemRangeSkeleton } from './SystemSkeleton';
2
+ import { SystemAgentsHeading, SystemAgentsSkeleton, SystemCardsSkeleton, SystemChartsSkeleton, SystemDescription, SystemRangeSkeleton } from './SystemSkeleton';
3
3
 
4
4
  export default function SystemLoading() {
5
5
  return (
@@ -8,6 +8,10 @@ export default function SystemLoading() {
8
8
  <SystemCardsSkeleton />
9
9
  <SystemRangeSkeleton />
10
10
  <SystemChartsSkeleton />
11
+ <section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
12
+ <SystemAgentsHeading />
13
+ <SystemAgentsSkeleton />
14
+ </section>
11
15
  </Page>
12
16
  );
13
17
  }