@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
|
@@ -20,7 +20,12 @@ interface TimeSeriesChartProps {
|
|
|
20
20
|
to: number;
|
|
21
21
|
/** Seconds per point; two points further apart than 1.5 buckets are not joined. */
|
|
22
22
|
bucket: number;
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The top of the y axis. A number fixes it (100 for a percentage); `'auto'` takes a rounded
|
|
25
|
+
* ceiling just above the highest value or peak plotted — for a series that is a small
|
|
26
|
+
* fraction of its natural scale, like one agent's share of the machine.
|
|
27
|
+
*/
|
|
28
|
+
yMax?: number | 'auto';
|
|
24
29
|
unit?: string;
|
|
25
30
|
/** Formats a value (grid labels, end label, tooltip, table) instead of `<rounded><unit>` — e.g. dollars. */
|
|
26
31
|
formatValue?: (v: number) => string;
|
|
@@ -30,6 +35,17 @@ interface TimeSeriesChartProps {
|
|
|
30
35
|
formatTime: (ts: number) => string;
|
|
31
36
|
}
|
|
32
37
|
|
|
38
|
+
/** A rounded ceiling (1, 2 or 5 times a power of ten) just above the highest value or peak. */
|
|
39
|
+
function autoYMax(points: TimeSeriesPoint[]): number {
|
|
40
|
+
let top = 0;
|
|
41
|
+
for (const p of points) top = Math.max(top, p.value ?? 0, p.peak ?? 0);
|
|
42
|
+
if (!(top > 0)) return 1;
|
|
43
|
+
const target = top * 1.1;
|
|
44
|
+
const base = 10 ** Math.floor(Math.log10(target));
|
|
45
|
+
const m = target / base;
|
|
46
|
+
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * base;
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
const PAD = { top: 12, right: 44, bottom: 22, left: 34 };
|
|
34
50
|
/** Width of one character of the 10 px monospace labels, for sizing the margins. */
|
|
35
51
|
const CHAR_W = 6.2;
|
|
@@ -46,7 +62,7 @@ export function TimeSeriesChart({
|
|
|
46
62
|
from,
|
|
47
63
|
to,
|
|
48
64
|
bucket,
|
|
49
|
-
yMax = 100,
|
|
65
|
+
yMax: yMaxProp = 100,
|
|
50
66
|
unit = '%',
|
|
51
67
|
formatValue,
|
|
52
68
|
height = 160,
|
|
@@ -66,6 +82,8 @@ export function TimeSeriesChart({
|
|
|
66
82
|
return () => ro.disconnect();
|
|
67
83
|
}, []);
|
|
68
84
|
|
|
85
|
+
const yMax = yMaxProp === 'auto' ? autoYMax(points) : yMaxProp;
|
|
86
|
+
|
|
69
87
|
// With a custom format (`$0.0054`) the margins grow to fit the labels instead of cutting
|
|
70
88
|
// them; percentage charts keep the fixed margins.
|
|
71
89
|
const valueText = (v: number) => (formatValue ? formatValue(v) : `${Math.round(v * 10) / 10}${unit}`);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from 'react';
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
|
+
import { formatMb, formatSharePercent } from '@/lib/utils/format';
|
|
5
6
|
import { Button, Pill } from '../components/ui';
|
|
6
7
|
|
|
7
8
|
interface ContainerInfo {
|
|
@@ -16,17 +17,40 @@ interface ContainerInfo {
|
|
|
16
17
|
displayName?: string | null;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/** What a running container consumes now, from GET /api/metrics/containers; absent when the daemon has not sampled it. */
|
|
21
|
+
interface Usage { cpu: number | null; mem: number | null; pids: number | null }
|
|
22
|
+
|
|
19
23
|
export default function ContainersClient() {
|
|
20
24
|
const [containers, setContainers] = useState<ContainerInfo[]>([]);
|
|
25
|
+
const [usage, setUsage] = useState<Record<string, Usage>>({});
|
|
21
26
|
const [loading, setLoading] = useState(true);
|
|
22
27
|
const [error, setError] = useState('');
|
|
28
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
23
29
|
|
|
24
30
|
async function load(signal?: AbortSignal) {
|
|
25
31
|
try {
|
|
26
|
-
const res = await
|
|
32
|
+
const [res, usageRes] = await Promise.all([
|
|
33
|
+
fetch('/api/containers', { signal }),
|
|
34
|
+
// The figures are a bonus: a daemon that has not sampled yet must not break the list.
|
|
35
|
+
fetch('/api/metrics/containers?range=1h', { signal }).catch(() => null),
|
|
36
|
+
]);
|
|
27
37
|
if (!res.ok) throw new Error(`Error ${res.status}`);
|
|
28
38
|
const data = await res.json();
|
|
29
39
|
if (!signal?.aborted) setContainers(Array.isArray(data) ? data : []);
|
|
40
|
+
// The figures are shown only while they are current: a failed request, tables not created
|
|
41
|
+
// yet, or a daemon that stopped sampling (newest sample older than three intervals) clears
|
|
42
|
+
// them — a live container must not keep its last CPU as if it were now.
|
|
43
|
+
const u = usageRes?.ok ? await usageRes.json().catch(() => null) : null;
|
|
44
|
+
if (!signal?.aborted) {
|
|
45
|
+
const current = u?.available && typeof u.age_s === 'number' && u.age_s <= u.interval_s * 3;
|
|
46
|
+
const next: Record<string, Usage> = {};
|
|
47
|
+
if (current) {
|
|
48
|
+
for (const row of u.containers ?? []) {
|
|
49
|
+
if (row.running && row.now) next[row.container] = { cpu: row.now.cpu_percent, mem: row.now.mem_mb, pids: row.now.pids };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
setUsage(next);
|
|
53
|
+
}
|
|
30
54
|
} catch(e: any) {
|
|
31
55
|
if (e?.name === 'AbortError') return;
|
|
32
56
|
if (!signal?.aborted) setError(e.message);
|
|
@@ -35,11 +59,19 @@ export default function ContainersClient() {
|
|
|
35
59
|
}
|
|
36
60
|
}
|
|
37
61
|
|
|
38
|
-
|
|
62
|
+
// One request at a time: a manual refresh or a new poll aborts the request before it, so a
|
|
63
|
+
// slow answer can never overwrite a newer one.
|
|
64
|
+
function refresh() {
|
|
65
|
+
abortRef.current?.abort();
|
|
39
66
|
const controller = new AbortController();
|
|
67
|
+
abortRef.current = controller;
|
|
40
68
|
load(controller.signal);
|
|
41
|
-
|
|
42
|
-
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
refresh();
|
|
73
|
+
const i = setInterval(refresh, 15000);
|
|
74
|
+
return () => { abortRef.current?.abort(); clearInterval(i); };
|
|
43
75
|
}, []);
|
|
44
76
|
|
|
45
77
|
return (
|
|
@@ -51,7 +83,7 @@ export default function ContainersClient() {
|
|
|
51
83
|
<span style={{ fontSize: 12, color: 'var(--text-dim)' }}>
|
|
52
84
|
{containers.length} container · <span style={{ fontFamily: 'var(--font-mono-stack)', fontSize: 11 }}>{new Date().toLocaleTimeString('it-IT')}</span>
|
|
53
85
|
</span>
|
|
54
|
-
<Button variant="ghost" size="sm" onClick={
|
|
86
|
+
<Button variant="ghost" size="sm" onClick={refresh} aria-label="Refresh containers">↻</Button>
|
|
55
87
|
</div>
|
|
56
88
|
</div>
|
|
57
89
|
|
|
@@ -113,6 +145,16 @@ export default function ContainersClient() {
|
|
|
113
145
|
{c.ports && <span style={{ fontFamily: 'var(--font-mono-stack)', color: '#888' }}>{c.ports}</span>}
|
|
114
146
|
</div>
|
|
115
147
|
|
|
148
|
+
{/* What it consumes now — running containers the daemon has sampled */}
|
|
149
|
+
{c.state === 'running' && usage[c.name] && (
|
|
150
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-dim)', fontFamily: 'var(--font-mono-stack)' }}>
|
|
151
|
+
<span>CPU {formatSharePercent(usage[c.name].cpu)}</span>
|
|
152
|
+
<span>Memory {formatMb(usage[c.name].mem)}</span>
|
|
153
|
+
<span>PIDs {usage[c.name].pids ?? '—'}</span>
|
|
154
|
+
<Link href={`/system?agent=${encodeURIComponent(c.name)}`} className="ui-link">Charts →</Link>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
|
|
116
158
|
{/* Agent badge — only for Rev4a-managed agents */}
|
|
117
159
|
{(c.displayName || c.agentId) && (
|
|
118
160
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
|
@@ -24,7 +24,7 @@ interface DetailsView {
|
|
|
24
24
|
modality?: string;
|
|
25
25
|
enabled: boolean;
|
|
26
26
|
deprecated: boolean;
|
|
27
|
-
price: { input: number; output: number; source: 'openrouter' | 'vendor' } | null;
|
|
27
|
+
price: { input: number; output: number; source: 'openrouter' | 'vendor'; scheduled: boolean } | null;
|
|
28
28
|
details: {
|
|
29
29
|
description?: string;
|
|
30
30
|
created?: string;
|
|
@@ -182,7 +182,9 @@ export default function ModelDetailsModal({ modelId, onClose }: { modelId: strin
|
|
|
182
182
|
<div style={{ fontSize: 10, color: 'var(--violet)', textTransform: 'uppercase', letterSpacing: '0.08em', margin: '12px 0 4px' }}>Price</div>
|
|
183
183
|
<PropertyList rows={[
|
|
184
184
|
{ label: 'Input / output', value: formatPrice(view.price) },
|
|
185
|
-
|
|
185
|
+
// The static file holds the list (peak) rate for a time-of-day vendor; the row
|
|
186
|
+
// behind this modal shows the rate in force. Name the difference, not the vendor twice.
|
|
187
|
+
...(view.price ? [{ label: 'Per 1M tokens, from', value: view.price.scheduled ? `${view.providerLabel} · list price` : view.providerLabel, dim: true }] : []),
|
|
186
188
|
]} />
|
|
187
189
|
|
|
188
190
|
{/* Architecture: the factory facts from the model card, open weights only */}
|
|
@@ -23,6 +23,8 @@ interface ModelInfo {
|
|
|
23
23
|
modality?: string;
|
|
24
24
|
/** Total parameters, when the model's card publishes them (open weights only). */
|
|
25
25
|
params?: number;
|
|
26
|
+
/** The vendor band billed right now (DeepSeek peak / off-peak); absent for fixed-rate models. */
|
|
27
|
+
band?: 'peak' | 'off-peak';
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
interface GatewayProvider {
|
|
@@ -37,6 +39,8 @@ interface GatewayProvider {
|
|
|
37
39
|
|
|
38
40
|
interface ProviderApiResponse {
|
|
39
41
|
providers: GatewayProvider[];
|
|
42
|
+
/** When the band flips next, so an open page refreshes the chips just after it. */
|
|
43
|
+
nextPriceChangeMs?: number | null;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
/* ------------------------------------------------------------------ */
|
|
@@ -100,15 +104,25 @@ export default function GatewayPageClient() {
|
|
|
100
104
|
// Accordion state — which provider cards are expanded
|
|
101
105
|
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(new Set());
|
|
102
106
|
const expandedInitRef = useRef(false);
|
|
107
|
+
const loadAbortRef = useRef<AbortController | null>(null);
|
|
108
|
+
const [bandChangeAt, setBandChangeAt] = useState<number | null>(null);
|
|
109
|
+
const [retryAt, setRetryAt] = useState<number | null>(null);
|
|
103
110
|
|
|
104
111
|
/* ---- Data loading ---- */
|
|
105
|
-
const loadAll = useCallback(async () => {
|
|
106
|
-
|
|
112
|
+
const loadAll = useCallback(async (silent = false) => {
|
|
113
|
+
loadAbortRef.current?.abort();
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
loadAbortRef.current = controller;
|
|
116
|
+
if (!silent) setLoading(true);
|
|
107
117
|
try {
|
|
108
|
-
const provRes = await fetch('/api/gateway/provider');
|
|
109
|
-
if (provRes.ok) {
|
|
118
|
+
const provRes = await fetch('/api/gateway/provider', { signal: controller.signal });
|
|
119
|
+
if (!provRes.ok) throw new Error(`Could not load providers (HTTP ${provRes.status})`);
|
|
120
|
+
setError(null);
|
|
121
|
+
setRetryAt(null);
|
|
122
|
+
{
|
|
110
123
|
const provJson: ProviderApiResponse = await provRes.json();
|
|
111
124
|
setProviders(provJson.providers);
|
|
125
|
+
setBandChangeAt(provJson.nextPriceChangeMs ?? null);
|
|
112
126
|
|
|
113
127
|
// Auto-expand configured providers on first load
|
|
114
128
|
if (!expandedInitRef.current) {
|
|
@@ -119,51 +133,79 @@ export default function GatewayPageClient() {
|
|
|
119
133
|
});
|
|
120
134
|
if (configured.size > 0) setExpandedProviders(configured);
|
|
121
135
|
}
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
136
|
+
// A scheduled band refresh only updates model rows. It must not overwrite a key
|
|
137
|
+
// the operator is editing or re-run the slower balance requests.
|
|
138
|
+
if (!silent) {
|
|
139
|
+
// Load keys from provider response (all configured providers now return apiKey)
|
|
140
|
+
const keyEntries: Record<string, string> = {};
|
|
141
|
+
const origEntries: Record<string, string> = {};
|
|
142
|
+
provJson.providers.forEach((p: any) => {
|
|
143
|
+
if (p.configured && p.apiKey) {
|
|
144
|
+
keyEntries[p.provider] = p.apiKey;
|
|
145
|
+
origEntries[p.provider] = p.apiKey;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
setApiKeyInputs((prev) => ({ ...prev, ...keyEntries }));
|
|
149
|
+
setOriginalKeys((prev) => ({ ...prev, ...origEntries }));
|
|
150
|
+
|
|
151
|
+
// Auto-fetch balances for supported providers that have keys configured
|
|
152
|
+
const balanceProvs = provJson.providers
|
|
153
|
+
.filter((p: any) => p.configured && ['deepseek','openrouter','glm'].includes(p.provider))
|
|
154
|
+
.map((p: any) => p.provider);
|
|
155
|
+
for (const bp of balanceProvs) {
|
|
156
|
+
try {
|
|
157
|
+
const bRes = await fetch(`/api/gateway/provider/balance?provider=${bp}`, { signal: controller.signal });
|
|
158
|
+
const bJson = await bRes.json();
|
|
159
|
+
if (bRes.ok && bJson.balance) {
|
|
160
|
+
setBalances((prev) => ({ ...prev, [bp]: { balance: bJson.balance, label: bJson.label } }));
|
|
161
|
+
} else if (bJson.reason === 'no-balance-api') {
|
|
162
|
+
// Permanent: this provider has no balance API for the account — hide the badge.
|
|
163
|
+
setBalances((prev) => ({ ...prev, [bp]: { balance: '', unsupported: true } }));
|
|
164
|
+
} else {
|
|
165
|
+
setBalances((prev) => ({ ...prev, [bp]: { balance: '—', error: bJson.error || 'Failed' } }));
|
|
166
|
+
}
|
|
167
|
+
} catch (e: unknown) {
|
|
168
|
+
if ((e as Error)?.name === 'AbortError') throw e;
|
|
169
|
+
setBalances((prev) => ({ ...prev, [bp]: { balance: '—', error: e instanceof Error ? e.message : String(e) } }));
|
|
149
170
|
}
|
|
150
|
-
} catch (e: unknown) {
|
|
151
|
-
setBalances((prev) => ({ ...prev, [bp]: { balance: '—', error: e instanceof Error ? e.message : String(e) } }));
|
|
152
171
|
}
|
|
153
172
|
}
|
|
154
173
|
}
|
|
155
174
|
} catch (e: unknown) {
|
|
156
|
-
|
|
175
|
+
if ((e as Error)?.name !== 'AbortError') {
|
|
176
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
177
|
+
if (silent) setRetryAt(Date.now() + 60_000);
|
|
178
|
+
}
|
|
157
179
|
} finally {
|
|
158
|
-
|
|
180
|
+
if (loadAbortRef.current === controller) {
|
|
181
|
+
loadAbortRef.current = null;
|
|
182
|
+
setLoading(false);
|
|
183
|
+
}
|
|
159
184
|
}
|
|
160
185
|
}, []);
|
|
161
186
|
|
|
162
|
-
useEffect(() => {
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
void loadAll();
|
|
189
|
+
return () => { loadAbortRef.current?.abort(); };
|
|
190
|
+
}, [loadAll]);
|
|
191
|
+
|
|
192
|
+
// Band chips: when a rate change is due, reload just past it — silently, so an open page
|
|
193
|
+
// flips PEAK / OFF-PEAK with the prices the agents actually get, without flickering skeletons.
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
const next = retryAt ?? bandChangeAt;
|
|
196
|
+
if (!next) return;
|
|
197
|
+
const t = setTimeout(() => { void loadAll(true); }, Math.max(5_000, next - Date.now() + (retryAt ? 0 : 2_000)));
|
|
198
|
+
return () => { clearTimeout(t); };
|
|
199
|
+
}, [bandChangeAt, retryAt, loadAll]);
|
|
163
200
|
|
|
164
201
|
/** Format pricing for display — shared with the details modal (app/lib/model-format.ts). */
|
|
165
202
|
const fmtPrice = formatPrice;
|
|
166
203
|
|
|
204
|
+
/** The band chips' tooltip: when the band in force now ends, in UTC. */
|
|
205
|
+
const bandUntilLabel = bandChangeAt
|
|
206
|
+
? new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'UTC' }).format(new Date(bandChangeAt))
|
|
207
|
+
: null;
|
|
208
|
+
|
|
167
209
|
/**
|
|
168
210
|
* Local model filter. Matches against both the display name and the id,
|
|
169
211
|
* because the upstream vendor often only appears in one of them — e.g.
|
|
@@ -658,6 +700,14 @@ export default function GatewayPageClient() {
|
|
|
658
700
|
<span style={{ marginLeft: 8, color: 'var(--violet)', fontWeight: 500 }}>
|
|
659
701
|
{fmtPrice(pricing)}
|
|
660
702
|
</span>
|
|
703
|
+
{m.band && (
|
|
704
|
+
<span
|
|
705
|
+
title={`Time-of-day rates: ${m.band} until ${bandUntilLabel ? `${bandUntilLabel} UTC` : 'the next change'} — the agents' synced prices follow the band.`}
|
|
706
|
+
style={{ marginLeft: 6, display: 'inline-block', verticalAlign: 'middle' }}
|
|
707
|
+
>
|
|
708
|
+
<Pill tone={m.band === 'peak' ? 'warning' : 'success'}>{m.band === 'peak' ? 'PEAK' : 'OFF-PEAK'}</Pill>
|
|
709
|
+
</span>
|
|
710
|
+
)}
|
|
661
711
|
{typeof m.params === 'number' && (
|
|
662
712
|
<span
|
|
663
713
|
style={{ marginLeft: 8, color: 'var(--text-dim)' }}
|
package/app/globals.css
CHANGED
|
@@ -677,6 +677,42 @@ html, body { height: 100%; height: 100dvh; background: var(--bg); color: var(--t
|
|
|
677
677
|
.terminal-page__screen { flex: 1; min-height: 0; padding: 8px 0 0 12px; overflow: hidden; }
|
|
678
678
|
.terminal-page__screen .xterm { height: 100%; }
|
|
679
679
|
|
|
680
|
+
/* Accordion: a header button carrying title, summary figures and a detail line; the body opens under it. */
|
|
681
|
+
.ui-accordion { border: 1px solid var(--border); background: var(--bg2); border-radius: 0; }
|
|
682
|
+
.ui-accordion__header {
|
|
683
|
+
display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; column-gap: 10px; row-gap: 4px; align-items: center;
|
|
684
|
+
width: 100%; box-sizing: border-box; padding: 12px 16px; background: none; border: 0; border-radius: 0;
|
|
685
|
+
color: var(--text); font: inherit; text-align: left; cursor: pointer;
|
|
686
|
+
}
|
|
687
|
+
.ui-accordion__header:hover { background: rgba(255, 255, 255, 0.02); }
|
|
688
|
+
.ui-accordion__header:focus-visible { outline: 1px solid var(--violet-dim); outline-offset: -1px; }
|
|
689
|
+
.ui-accordion__caret { font-size: 10px; color: var(--text-dim); transition: transform 0.15s; }
|
|
690
|
+
.ui-accordion--open .ui-accordion__caret { transform: rotate(90deg); }
|
|
691
|
+
.ui-accordion__title { min-width: 0; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; }
|
|
692
|
+
.ui-accordion__summary { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 20px; font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
693
|
+
.ui-accordion__detail { grid-column: 2 / -1; font-size: 11px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
|
694
|
+
.ui-accordion__body { border-top: 1px solid var(--border); padding: 16px; }
|
|
695
|
+
@media (max-width: 640px) {
|
|
696
|
+
.ui-accordion__header { grid-template-columns: 14px minmax(0, 1fr); }
|
|
697
|
+
.ui-accordion__summary { grid-column: 2 / -1; justify-content: flex-start; }
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/* The three machine charts (and a storage chart per extra disk) fill the grid: an odd last one spans both columns instead of leaving a hole. */
|
|
701
|
+
.system-charts > :last-child:nth-child(odd) { grid-column: 1 / -1; }
|
|
702
|
+
/* A section of the page under the cards and charts: the accent eyebrow (the page header's own), set apart by a rule and room above, so it does not run into the panel before it. */
|
|
703
|
+
.system-section-heading { margin-top: 14px; padding-top: 20px; border-top: 1px solid var(--border); }
|
|
704
|
+
.system-section-heading .ui-eyebrow { margin-bottom: 6px; }
|
|
705
|
+
.system-agent-charts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
|
706
|
+
@media (max-width: 1199px) {
|
|
707
|
+
.system-agent-charts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
708
|
+
.system-agent-charts > :last-child:nth-child(odd) { grid-column: 1 / -1; }
|
|
709
|
+
}
|
|
710
|
+
@media (max-width: 991px) { .system-agent-charts { grid-template-columns: minmax(0, 1fr); } }
|
|
711
|
+
.system-docker-storage { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
|
|
712
|
+
@media (max-width: 640px) { .system-docker-storage { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
713
|
+
.system-alert { display: grid; grid-template-columns: 110px 64px minmax(0, 1fr); gap: 10px; align-items: baseline; font-size: 12px; padding: 6px 0; border-top: 1px solid var(--border); }
|
|
714
|
+
.system-alert:first-of-type { border-top: 0; }
|
|
715
|
+
@media (max-width: 640px) { .system-alert { grid-template-columns: 1fr; gap: 2px; } }
|
|
680
716
|
/* System page: three cards, then one chart per metric; one column below lg (992px). */
|
|
681
717
|
.system-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
|
682
718
|
.system-charts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
|
@@ -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
|
+
}
|