@flame0510/project-aether 1.10.0 → 1.11.1
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.
- package/README.md +6 -2
- package/app/agents/CostSection.tsx +12 -10
- package/app/agents/PageClient.tsx +7 -0
- package/app/agents/PanelRow.tsx +9 -0
- package/app/agents/ResourceSection.tsx +105 -0
- package/app/api/assistant/route.ts +2 -2
- package/app/api/containers/route.ts +18 -2
- package/app/api/costs/agent/route.ts +20 -3
- package/app/api/gateway/provider/route.ts +20 -2
- package/app/api/metrics/alerts/route.ts +47 -0
- package/app/api/metrics/containers/route.ts +54 -0
- package/app/api/metrics/route.ts +4 -6
- package/app/api/update-check/route.ts +20 -11
- package/app/components/ui/Accordion.tsx +45 -0
- package/app/components/ui/TimeSeriesChart.tsx +20 -2
- package/app/components/ui/index.ts +1 -0
- package/app/containers/ContainersClient.tsx +48 -6
- package/app/gateway/ModelDetailsModal.tsx +4 -2
- package/app/gateway/PageClient.tsx +86 -36
- package/app/globals.css +36 -0
- package/app/system/AgentCharts.tsx +138 -0
- package/app/system/AgentsSection.tsx +281 -0
- package/app/system/PageClient.tsx +11 -9
- package/app/system/RecentAlerts.tsx +72 -0
- package/app/system/SystemSkeleton.tsx +53 -1
- package/app/system/loading.tsx +5 -1
- package/bin/postinstall.js +7 -4
- package/bin/rev4a.js +6 -7
- package/daemon.js +195 -4
- package/docs/ARCHITECTURE.md +36 -5
- package/docs/FRONTEND-ARCHITECTURE.md +13 -4
- package/docs/REV4A.md +9 -6
- package/docs/dev/API-REFERENCE.md +116 -30
- package/docs/dev/DATABASE.md +58 -2
- package/docs/dev/GATEWAY.md +13 -4
- package/docs/rag/DATA-FRESHNESS.md +16 -1
- package/docs/rag/GLOSSARY.md +3 -3
- package/docs/rag/REV4A-OVERVIEW.md +9 -5
- package/docs/rag/WHAT-I-CAN-ANSWER.md +4 -1
- package/lib/container-metrics.ts +340 -0
- package/lib/docker-socket-path.d.ts +9 -0
- package/lib/docker-socket-path.js +133 -0
- package/lib/docker-socket.ts +10 -99
- package/lib/docker-stats.d.ts +88 -0
- package/lib/docker-stats.js +284 -0
- package/lib/metrics-db.ts +15 -6
- package/lib/model-details.ts +3 -3
- package/lib/model-pricing.ts +6 -4
- package/lib/utils/format.ts +29 -3
- package/model-details.json +2799 -2274
- package/model-pricing.json +63 -48
- package/models.config.json +41 -10
- package/npm-shrinkwrap.json +1979 -0
- package/package.json +11 -9
- package/scripts/check-language.mjs +1 -1
- package/scripts/check-package-types.mjs +74 -0
- package/scripts/test-docker-stats.mjs +270 -0
|
@@ -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';
|
|
@@ -51,13 +54,8 @@ interface MetricsPayload {
|
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
const ROLE_LABEL: Record<string, string> = { root: 'system', docker: 'Docker data', data: 'Rev4a data' };
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
}
|
|
57
|
+
/** A filesystem's display name: the bare "/" is the root volume — clearer as a word. Its mount stays in the aria-label and, for other filesystems, is the name. */
|
|
58
|
+
const diskLabel = (mount: string) => (mount === '/' ? 'Root' : mount);
|
|
61
59
|
|
|
62
60
|
function formatUptime(s: number): string {
|
|
63
61
|
const d = Math.floor(s / 86_400);
|
|
@@ -198,7 +196,7 @@ export default function SystemPageClient() {
|
|
|
198
196
|
{latest?.disks.map((d) => (
|
|
199
197
|
<div key={d.mount} style={{ marginTop: 14 }}>
|
|
200
198
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
|
|
201
|
-
<span style={{ fontSize: 13 }}>{d.mount}</span>
|
|
199
|
+
<span style={{ fontSize: 13 }}>{diskLabel(d.mount)}</span>
|
|
202
200
|
<span style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
203
201
|
{d.roles.map((r) => <Pill key={r}>{ROLE_LABEL[r] ?? r}</Pill>)}
|
|
204
202
|
</span>
|
|
@@ -244,7 +242,7 @@ export default function SystemPageClient() {
|
|
|
244
242
|
</Surface>
|
|
245
243
|
{(data?.disk_history ?? []).map((d) => (
|
|
246
244
|
<Surface as="section" key={d.mount}>
|
|
247
|
-
<div className="ui-kicker">Storage · {d.mount}</div>
|
|
245
|
+
<div className="ui-kicker">Storage · {diskLabel(d.mount)}</div>
|
|
248
246
|
<TimeSeriesChart
|
|
249
247
|
label={`Storage in use on ${d.mount}`}
|
|
250
248
|
points={d.points.map((p) => ({ ts: p.ts, value: p.percent }))}
|
|
@@ -258,6 +256,10 @@ export default function SystemPageClient() {
|
|
|
258
256
|
))}
|
|
259
257
|
</div>
|
|
260
258
|
)}
|
|
259
|
+
|
|
260
|
+
<AgentsSection range={range} span={span} formatTime={formatTime} />
|
|
261
|
+
|
|
262
|
+
<RecentAlerts timeZone={timeZone} />
|
|
261
263
|
</Page>
|
|
262
264
|
);
|
|
263
265
|
}
|
|
@@ -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
|
+
}
|
package/app/system/loading.tsx
CHANGED
|
@@ -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
|
}
|
package/bin/postinstall.js
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Post-install hook: build Next.js app if
|
|
3
|
+
* Post-install hook: build Next.js app if a complete production build is absent.
|
|
4
4
|
*
|
|
5
5
|
* Skipped when REV4A_SKIP_POSTINSTALL_BUILD is set: CI and the release workflow run
|
|
6
6
|
* `npm run build` as an explicit step, and a second build here would run before every
|
|
7
|
-
* other check
|
|
7
|
+
* other check. A failed build must fail the install so an update cannot restart into
|
|
8
|
+
* an incomplete .next directory.
|
|
8
9
|
*/
|
|
9
10
|
const { existsSync } = require('fs');
|
|
10
11
|
const { join } = require('path');
|
|
11
12
|
|
|
12
13
|
const ROOT = join(__dirname, '..');
|
|
13
|
-
const
|
|
14
|
+
const BUILD_ID = join(ROOT, '.next', 'BUILD_ID');
|
|
14
15
|
|
|
15
|
-
if (!process.env.REV4A_SKIP_POSTINSTALL_BUILD && !existsSync(
|
|
16
|
+
if (!process.env.REV4A_SKIP_POSTINSTALL_BUILD && !existsSync(BUILD_ID)) {
|
|
16
17
|
try {
|
|
17
18
|
const { execSync } = require('child_process');
|
|
18
19
|
console.log('[rev4a] Building dashboard… (one-time, may take a minute)');
|
|
19
20
|
execSync('npx next build', { cwd: ROOT, stdio: 'inherit' });
|
|
21
|
+
if (!existsSync(BUILD_ID)) throw new Error('next build returned without a BUILD_ID');
|
|
20
22
|
console.log('[rev4a] Build complete.');
|
|
21
23
|
} catch (e) {
|
|
22
24
|
console.error('[rev4a] Build failed. Run "rev4a build" to retry.');
|
|
25
|
+
process.exitCode = 1;
|
|
23
26
|
}
|
|
24
27
|
}
|
package/bin/rev4a.js
CHANGED
|
@@ -431,6 +431,7 @@ function build() {
|
|
|
431
431
|
// Generous on purpose: a build is the slowest step of an install, and killing it
|
|
432
432
|
// because it passed two minutes takes the server down over slowness alone.
|
|
433
433
|
execSync('npx next build', { stdio: 'inherit', cwd: ROOT, timeout: 30 * 60_000 });
|
|
434
|
+
if (!existsSync(join(NEXT_DIR, 'BUILD_ID'))) throw new Error('next build returned without a BUILD_ID');
|
|
434
435
|
log('BUILD', 'Build complete');
|
|
435
436
|
} catch (err) {
|
|
436
437
|
log('BUILD', `Build failed: ${err.message}`);
|
|
@@ -487,8 +488,8 @@ function preflight() {
|
|
|
487
488
|
}
|
|
488
489
|
ensureRev4aRules();
|
|
489
490
|
|
|
490
|
-
if (!existsSync(NEXT_DIR)) {
|
|
491
|
-
log('PREFLIGHT', 'No build found — running build first…');
|
|
491
|
+
if (!existsSync(join(NEXT_DIR, 'BUILD_ID'))) {
|
|
492
|
+
log('PREFLIGHT', 'No complete build found — running build first…');
|
|
492
493
|
build();
|
|
493
494
|
}
|
|
494
495
|
}
|
|
@@ -769,11 +770,9 @@ if (cmd === 'update') {
|
|
|
769
770
|
stdio: 'inherit',
|
|
770
771
|
timeout: 20 * 60_000,
|
|
771
772
|
});
|
|
772
|
-
//
|
|
773
|
-
|
|
774
|
-
if (existsSync(
|
|
775
|
-
try { execSync(`rm -rf "${nextDir}"`, { stdio: 'inherit' }); } catch {}
|
|
776
|
-
}
|
|
773
|
+
// postinstall builds the freshly installed package and fails the install if it
|
|
774
|
+
// cannot; keep that build for the restart. The fallback covers a skipped build.
|
|
775
|
+
if (!existsSync(join(ROOT, '.next', 'BUILD_ID'))) build();
|
|
777
776
|
}
|
|
778
777
|
console.log('Update complete. Restarting…');
|
|
779
778
|
try {
|