@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.
Files changed (57) hide show
  1. package/README.md +6 -2
  2. package/app/agents/CostSection.tsx +12 -10
  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 +20 -3
  9. package/app/api/gateway/provider/route.ts +20 -2
  10. package/app/api/metrics/alerts/route.ts +47 -0
  11. package/app/api/metrics/containers/route.ts +54 -0
  12. package/app/api/metrics/route.ts +4 -6
  13. package/app/api/update-check/route.ts +20 -11
  14. package/app/components/ui/Accordion.tsx +45 -0
  15. package/app/components/ui/TimeSeriesChart.tsx +20 -2
  16. package/app/components/ui/index.ts +1 -0
  17. package/app/containers/ContainersClient.tsx +48 -6
  18. package/app/gateway/ModelDetailsModal.tsx +4 -2
  19. package/app/gateway/PageClient.tsx +86 -36
  20. package/app/globals.css +36 -0
  21. package/app/system/AgentCharts.tsx +138 -0
  22. package/app/system/AgentsSection.tsx +281 -0
  23. package/app/system/PageClient.tsx +11 -9
  24. package/app/system/RecentAlerts.tsx +72 -0
  25. package/app/system/SystemSkeleton.tsx +53 -1
  26. package/app/system/loading.tsx +5 -1
  27. package/bin/postinstall.js +7 -4
  28. package/bin/rev4a.js +6 -7
  29. package/daemon.js +195 -4
  30. package/docs/ARCHITECTURE.md +36 -5
  31. package/docs/FRONTEND-ARCHITECTURE.md +13 -4
  32. package/docs/REV4A.md +9 -6
  33. package/docs/dev/API-REFERENCE.md +116 -30
  34. package/docs/dev/DATABASE.md +58 -2
  35. package/docs/dev/GATEWAY.md +13 -4
  36. package/docs/rag/DATA-FRESHNESS.md +16 -1
  37. package/docs/rag/GLOSSARY.md +3 -3
  38. package/docs/rag/REV4A-OVERVIEW.md +9 -5
  39. package/docs/rag/WHAT-I-CAN-ANSWER.md +4 -1
  40. package/lib/container-metrics.ts +340 -0
  41. package/lib/docker-socket-path.d.ts +9 -0
  42. package/lib/docker-socket-path.js +133 -0
  43. package/lib/docker-socket.ts +10 -99
  44. package/lib/docker-stats.d.ts +88 -0
  45. package/lib/docker-stats.js +284 -0
  46. package/lib/metrics-db.ts +15 -6
  47. package/lib/model-details.ts +3 -3
  48. package/lib/model-pricing.ts +6 -4
  49. package/lib/utils/format.ts +29 -3
  50. package/model-details.json +2799 -2274
  51. package/model-pricing.json +63 -48
  52. package/models.config.json +41 -10
  53. package/npm-shrinkwrap.json +1979 -0
  54. package/package.json +11 -9
  55. package/scripts/check-language.mjs +1 -1
  56. package/scripts/check-package-types.mjs +74 -0
  57. 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
- yMax?: number;
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,3 +1,4 @@
1
+ export * from './Accordion';
1
2
  export * from './ConfirmModal';
2
3
  export * from './Badge';
3
4
  export * from './Button';
@@ -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 fetch('/api/containers', { signal });
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
- useEffect(() => {
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
- const i = setInterval(() => { if (!controller.signal.aborted) load(controller.signal); }, 15000);
42
- return () => { controller.abort(); clearInterval(i); };
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={() => load()}>↻</Button>
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
- ...(view.price ? [{ label: 'Per 1M tokens, from', value: view.providerLabel, dim: true }] : []),
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
- setLoading(true);
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
- // Load keys from provider response (all configured providers now return apiKey)
123
- const keyEntries: Record<string, string> = {};
124
- const origEntries: Record<string, string> = {};
125
- provJson.providers.forEach((p: any) => {
126
- if (p.configured && p.apiKey) {
127
- keyEntries[p.provider] = p.apiKey;
128
- origEntries[p.provider] = p.apiKey;
129
- }
130
- });
131
- setApiKeyInputs((prev) => ({ ...prev, ...keyEntries }));
132
- setOriginalKeys((prev) => ({ ...prev, ...origEntries }));
133
-
134
- // Auto-fetch balances for supported providers that have keys configured
135
- const balanceProvs = provJson.providers
136
- .filter((p: any) => p.configured && ['deepseek','openrouter','glm'].includes(p.provider))
137
- .map((p: any) => p.provider);
138
- for (const bp of balanceProvs) {
139
- try {
140
- const bRes = await fetch(`/api/gateway/provider/balance?provider=${bp}`);
141
- const bJson = await bRes.json();
142
- if (bRes.ok && bJson.balance) {
143
- setBalances((prev) => ({ ...prev, [bp]: { balance: bJson.balance, label: bJson.label } }));
144
- } else if (bJson.reason === 'no-balance-api') {
145
- // Permanent: this provider has no balance API for the account — hide the badge.
146
- setBalances((prev) => ({ ...prev, [bp]: { balance: '', unsupported: true } }));
147
- } else {
148
- setBalances((prev) => ({ ...prev, [bp]: { balance: '—', error: bJson.error || 'Failed' } }));
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
- setError(e instanceof Error ? e.message : String(e));
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
- setLoading(false);
180
+ if (loadAbortRef.current === controller) {
181
+ loadAbortRef.current = null;
182
+ setLoading(false);
183
+ }
159
184
  }
160
185
  }, []);
161
186
 
162
- useEffect(() => { void loadAll(); }, [loadAll]);
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
+ }