@flame0510/project-aether 1.9.1 → 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.
- package/README.md +3 -3
- package/app/agents/CostSection.tsx +94 -0
- package/app/agents/PageClient.tsx +14 -0
- package/app/agents/PanelRow.tsx +9 -0
- package/app/agents/ResourceSection.tsx +105 -0
- package/app/api/assistant/route.ts +10 -3
- package/app/api/containers/route.ts +18 -2
- package/app/api/costs/agent/route.ts +55 -0
- package/app/api/costs/route.ts +19 -77
- package/app/api/costs/usage/route.ts +65 -0
- package/app/api/gateway/sync.ts +18 -4
- 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/stats/route.ts +2 -3
- package/app/api/stats-since/route.ts +1 -1
- package/app/api/stream/route.ts +3 -5
- package/app/api/system-health/route.ts +33 -32
- package/app/components/CostBreakdown.tsx +1 -1
- package/app/components/Sidebar.tsx +10 -0
- package/app/components/SystemCockpit.tsx +1 -1
- package/app/components/ui/Accordion.tsx +45 -0
- package/app/components/ui/TimeSeriesChart.tsx +47 -11
- package/app/components/ui/index.ts +1 -0
- package/app/containers/ContainersClient.tsx +48 -6
- package/app/costs/CostsSkeleton.tsx +88 -0
- package/app/costs/PageClient.tsx +366 -0
- package/app/costs/loading.tsx +13 -0
- package/app/costs/page.tsx +5 -0
- package/app/globals.css +42 -0
- package/app/system/AgentCharts.tsx +138 -0
- package/app/system/AgentsSection.tsx +281 -0
- package/app/system/PageClient.tsx +7 -7
- package/app/system/RecentAlerts.tsx +72 -0
- package/app/system/SystemSkeleton.tsx +53 -1
- package/app/system/loading.tsx +5 -1
- package/daemon.js +646 -41
- package/docs/ARCHITECTURE.md +72 -31
- package/docs/DESIGN-SYSTEM.md +2 -2
- package/docs/FRONTEND-ARCHITECTURE.md +14 -3
- package/docs/REV4A.md +6 -5
- package/docs/dev/API-REFERENCE.md +208 -38
- package/docs/dev/DATABASE.md +176 -20
- package/docs/dev/GATEWAY.md +53 -9
- package/docs/rag/DATA-FRESHNESS.md +34 -7
- package/docs/rag/GLOSSARY.md +8 -5
- package/docs/rag/REV4A-OVERVIEW.md +10 -3
- package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -3
- package/instrumentation.ts +11 -0
- package/lib/agent-costs.ts +172 -0
- package/lib/container-metrics.ts +340 -0
- package/lib/cost-reconciliation.ts +78 -0
- package/lib/costs-db.ts +31 -0
- package/lib/docker-socket-path.js +133 -0
- package/lib/docker-socket.ts +10 -99
- package/lib/docker-stats.js +284 -0
- package/lib/metrics-db.ts +15 -6
- package/lib/model-pricing.ts +123 -3
- package/lib/price-schedule-sync.ts +133 -0
- package/lib/utils/format.ts +38 -0
- package/model-pricing.json +269 -121
- package/package.json +1 -1
- package/scripts/refresh-model-pricing.mjs +16 -4
- package/scripts/test-docker-stats.mjs +270 -0
- package/lib/billing.ts +0 -100
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Skeleton } from '../components/Skeleton';
|
|
2
|
+
import { Surface } from '../components/ui';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Placeholders for the Costs page, used by `loading.tsx` while the route loads and by the
|
|
6
|
+
* page before its first answer. Each shimmer sits in the class of the text it stands in
|
|
7
|
+
* for (`ui-kicker`, `ui-metric-value`, `ui-key-value`, `ui-meter`), so the boxes have the
|
|
8
|
+
* real heights and nothing jumps when the data lands.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function KeyValueRow({ label, value }: { label: number; value: number }) {
|
|
12
|
+
return (
|
|
13
|
+
<div className="ui-key-value">
|
|
14
|
+
<span><Skeleton as="span" inline width={label} height={11} /></span>
|
|
15
|
+
<span><Skeleton as="span" inline width={value} height={11} /></span>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function FigureCard({ title, rows }: { title: number; rows: number }) {
|
|
21
|
+
return (
|
|
22
|
+
<Surface as="article">
|
|
23
|
+
<div className="ui-kicker"><Skeleton as="span" inline width={title} height={10} /></div>
|
|
24
|
+
<div className="ui-metric-value"><Skeleton as="span" inline width={88} height={26} accent /></div>
|
|
25
|
+
<div className="ui-key-values" style={{ marginTop: 12 }}>
|
|
26
|
+
{Array.from({ length: rows }).map((_, i) => <KeyValueRow key={i} label={70 + (i % 2) * 30} value={48} />)}
|
|
27
|
+
</div>
|
|
28
|
+
</Surface>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function CostsCardsSkeleton() {
|
|
33
|
+
return (
|
|
34
|
+
<div className="costs-cards" aria-busy="true" aria-label="Loading costs">
|
|
35
|
+
<FigureCard title={40} rows={2} />
|
|
36
|
+
<FigureCard title={64} rows={4} />
|
|
37
|
+
<FigureCard title={48} rows={4} />
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One breakdown list: a title, then rows of name and figure over a share meter. */
|
|
43
|
+
function ListSkeleton({ rows }: { rows: number }) {
|
|
44
|
+
return (
|
|
45
|
+
<Surface as="section">
|
|
46
|
+
<div className="ui-kicker"><Skeleton as="span" inline width={90} height={10} /></div>
|
|
47
|
+
{Array.from({ length: rows }).map((_, i) => (
|
|
48
|
+
<div key={i} style={{ marginTop: 14 }}>
|
|
49
|
+
<KeyValueRow label={140 - (i % 3) * 20} value={56} />
|
|
50
|
+
<div className="ui-meter"><Skeleton height={6} style={{ flex: 1 }} /></div>
|
|
51
|
+
</div>
|
|
52
|
+
))}
|
|
53
|
+
</Surface>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function CostsBodySkeleton() {
|
|
58
|
+
return (
|
|
59
|
+
<div aria-hidden="true" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
60
|
+
<Surface as="section">
|
|
61
|
+
<div className="ui-kicker"><Skeleton as="span" inline width={120} height={10} /></div>
|
|
62
|
+
<Skeleton height={160} />
|
|
63
|
+
</Surface>
|
|
64
|
+
<div className="costs-lists">
|
|
65
|
+
<ListSkeleton rows={3} />
|
|
66
|
+
<ListSkeleton rows={3} />
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The range switch (Today · 7 days · 30 days) for the route's loading state. */
|
|
73
|
+
export function CostsRangeSkeleton() {
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
aria-hidden="true"
|
|
77
|
+
style={{ display: 'flex', borderBottom: '1px solid var(--border)', marginBottom: 20, height: 44, alignItems: 'center' }}
|
|
78
|
+
>
|
|
79
|
+
{[36, 44, 50].map((w, i) => (
|
|
80
|
+
<div key={i} style={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
|
|
81
|
+
<Skeleton width={w} height={11} />
|
|
82
|
+
</div>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const COSTS_DESCRIPTION = 'What each agent spent, as its own OpenClaw priced it with the rates Rev4a syncs. Days are UTC.';
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { apiFetch } from '@/lib/apiFetch';
|
|
5
|
+
import { formatCost } from '@/lib/utils/format';
|
|
6
|
+
import { useRev4aTimezone } from '@/lib/hooks/useRev4aTimezone';
|
|
7
|
+
import { Meter, Page, PageHeader, Pill, Surface, Tabs, TimeSeriesChart } from '../components/ui';
|
|
8
|
+
import { COSTS_DESCRIPTION, CostsBodySkeleton, CostsCardsSkeleton } from './CostsSkeleton';
|
|
9
|
+
|
|
10
|
+
type Range = 'today' | '7d' | '30d';
|
|
11
|
+
const RANGES: { id: Range; label: string }[] = [
|
|
12
|
+
{ id: 'today', label: 'Today' },
|
|
13
|
+
{ id: '7d', label: '7 days' },
|
|
14
|
+
{ id: '30d', label: '30 days' },
|
|
15
|
+
];
|
|
16
|
+
/** The daemon reads the agents every 5 min; a minute keeps the page close behind it. */
|
|
17
|
+
const POLL_MS = 60_000;
|
|
18
|
+
const DAY_S = 86_400;
|
|
19
|
+
|
|
20
|
+
interface Totals {
|
|
21
|
+
cost: number; tokens: number; input: number; output: number; cacheRead: number; cacheWrite: number;
|
|
22
|
+
inputCost: number; outputCost: number; cacheReadCost: number; cacheWriteCost: number; missing: number;
|
|
23
|
+
}
|
|
24
|
+
interface AgentRow {
|
|
25
|
+
container: string; agentId: string | null; name: string | null; cost: number; tokens: number; missing: number;
|
|
26
|
+
collectedAt: number | null; attemptedAt: number | null; error: string | null;
|
|
27
|
+
}
|
|
28
|
+
interface ModelRow { provider: string; model: string; cost: number; tokens: number; calls: number }
|
|
29
|
+
interface SessionRow {
|
|
30
|
+
container: string; sessionId: string; sessionKey: string | null; label: string | null; agentName: string | null;
|
|
31
|
+
model: string | null; cost: number; tokens: number; missing: number; lastActivity: number | null;
|
|
32
|
+
}
|
|
33
|
+
interface CostsPayload {
|
|
34
|
+
available: boolean;
|
|
35
|
+
range: Range;
|
|
36
|
+
startDate: string;
|
|
37
|
+
endDate: string;
|
|
38
|
+
intervalS: number;
|
|
39
|
+
pricing: { vendor: string; band: 'peak' | 'off-peak' | null; nextChange: number | null; source: string } | null;
|
|
40
|
+
totals: Totals;
|
|
41
|
+
daily: { date: string; cost: number; tokens: number; missing: number }[];
|
|
42
|
+
byAgent: AgentRow[];
|
|
43
|
+
byModel: ModelRow[];
|
|
44
|
+
sessions: SessionRow[];
|
|
45
|
+
unpriced: { model: string; calls: number; agents: string[] }[];
|
|
46
|
+
reconciliation: Reconciliation[];
|
|
47
|
+
}
|
|
48
|
+
interface Reconciliation {
|
|
49
|
+
vendor: string; currency: string | null; from: number | null; to: number | null;
|
|
50
|
+
billed: number | null; priced: number | null; topUps: number; readings: number; comparable: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const VENDOR_LABEL: Record<string, string> = { deepseek: 'DeepSeek', openrouter: 'OpenRouter' };
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
function formatTokens(n: number): string {
|
|
57
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
|
58
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
59
|
+
return String(n);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatAge(ms: number): string {
|
|
63
|
+
const min = Math.round(ms / 60_000);
|
|
64
|
+
if (min < 1) return 'just now';
|
|
65
|
+
if (min < 60) return `${min} min ago`;
|
|
66
|
+
const h = Math.round(min / 60);
|
|
67
|
+
return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} d ago`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const dayStart = (date: string) => Date.parse(`${date}T00:00:00Z`) / 1000;
|
|
71
|
+
const utcDay = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' });
|
|
72
|
+
const utcTime = new Intl.DateTimeFormat('en-GB', { weekday: 'short', hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'UTC' });
|
|
73
|
+
|
|
74
|
+
function share(part: number, whole: number): number {
|
|
75
|
+
return whole > 0 ? (part / whole) * 100 : 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A breakdown row: name and figure, a share meter, and a muted detail line. */
|
|
79
|
+
function BreakdownRow({ name, figure, detail, percent, label }: { name: string; figure: string; detail: string; percent: number; label: string }) {
|
|
80
|
+
return (
|
|
81
|
+
<div style={{ marginTop: 14 }}>
|
|
82
|
+
<div className="ui-key-value" style={{ fontSize: 12 }}>
|
|
83
|
+
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</span>
|
|
84
|
+
<span>{figure}</span>
|
|
85
|
+
</div>
|
|
86
|
+
<Meter percent={percent} level="ok" label={label} />
|
|
87
|
+
<div className="ui-muted" style={{ fontSize: 11, marginTop: 4 }}>{detail}</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default function CostsPageClient() {
|
|
93
|
+
const [range, setRange] = useState<Range>('7d');
|
|
94
|
+
const [data, setData] = useState<CostsPayload | null>(null);
|
|
95
|
+
const [loading, setLoading] = useState(true);
|
|
96
|
+
const [error, setError] = useState('');
|
|
97
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
98
|
+
const timeZone = useRev4aTimezone();
|
|
99
|
+
|
|
100
|
+
const load = useCallback(async (r: Range) => {
|
|
101
|
+
abortRef.current?.abort();
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
abortRef.current = controller;
|
|
104
|
+
setLoading(true);
|
|
105
|
+
try {
|
|
106
|
+
const res = await apiFetch(`/api/costs/usage?range=${r}`, { signal: controller.signal });
|
|
107
|
+
const body = await res.json();
|
|
108
|
+
if (!res.ok) throw new Error(body?.error || `Error ${res.status}`);
|
|
109
|
+
setData(body as CostsPayload);
|
|
110
|
+
setError('');
|
|
111
|
+
} catch (e: any) {
|
|
112
|
+
if (e?.name === 'AbortError') return;
|
|
113
|
+
setError(e?.message || 'Could not load the costs');
|
|
114
|
+
} finally {
|
|
115
|
+
if (abortRef.current === controller) setLoading(false);
|
|
116
|
+
}
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
load(range);
|
|
121
|
+
const timer = setInterval(() => load(range), POLL_MS);
|
|
122
|
+
return () => {
|
|
123
|
+
clearInterval(timer);
|
|
124
|
+
abortRef.current?.abort();
|
|
125
|
+
};
|
|
126
|
+
}, [range, load]);
|
|
127
|
+
|
|
128
|
+
const firstLoad = data === null && !error;
|
|
129
|
+
const stale = loading && data !== null && data.range !== range;
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
const t = data?.totals;
|
|
132
|
+
const sessionTime = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: false, timeZone });
|
|
133
|
+
|
|
134
|
+
// Agents the daemon is still trying to read (a recent attempt) whose figures are not
|
|
135
|
+
// current. A stopped or deleted agent is no longer attempted and keeps its last figures
|
|
136
|
+
// on purpose: it is not flagged.
|
|
137
|
+
const interval = (data?.intervalS ?? 300) * 1000;
|
|
138
|
+
const lagging = (data?.byAgent ?? []).filter(
|
|
139
|
+
(a) => a.attemptedAt !== null && now - a.attemptedAt <= interval * 3
|
|
140
|
+
&& (a.error !== null || !a.collectedAt || now - a.collectedAt > interval * 3),
|
|
141
|
+
);
|
|
142
|
+
const maxDaily = Math.max(0, ...(data?.daily ?? []).map((d) => d.cost));
|
|
143
|
+
// OpenClaw names an unpriced model `provider/model`, like the rows below.
|
|
144
|
+
const unpricedModels = new Set((data?.unpriced ?? []).map((u) => u.model));
|
|
145
|
+
|
|
146
|
+
const band = data?.pricing;
|
|
147
|
+
const bandPill = band?.band ? (
|
|
148
|
+
<Pill tone={band.band === 'off-peak' ? 'success' : 'neutral'}>
|
|
149
|
+
{band.vendor} {band.band}{band.nextChange ? ` · until ${utcTime.format(new Date(band.nextChange))} UTC` : ''}
|
|
150
|
+
</Pill>
|
|
151
|
+
) : undefined;
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<Page>
|
|
155
|
+
<PageHeader eyebrow="Spend" title="Costs" description={COSTS_DESCRIPTION} action={bandPill} />
|
|
156
|
+
|
|
157
|
+
<Tabs items={RANGES.map((r) => ({ label: r.label, active: range === r.id, onClick: () => setRange(r.id) }))} />
|
|
158
|
+
|
|
159
|
+
{error && <Surface tone="danger"><span style={{ color: 'var(--red)', fontSize: 12 }}>{error}</span></Surface>}
|
|
160
|
+
|
|
161
|
+
{data && !data.available && (
|
|
162
|
+
<Surface tone="warning">
|
|
163
|
+
<div className="ui-kicker">No figures yet</div>
|
|
164
|
+
<p className="ui-muted" style={{ fontSize: 12, marginTop: 8 }}>
|
|
165
|
+
The Rev4a daemon reads every running agent each {Math.round(data.intervalS / 60)} min. If this stays empty, check that rev4a serve is running.
|
|
166
|
+
</p>
|
|
167
|
+
</Surface>
|
|
168
|
+
)}
|
|
169
|
+
|
|
170
|
+
{lagging.length > 0 && (
|
|
171
|
+
<Surface tone="warning">
|
|
172
|
+
<div className="ui-kicker">Not current</div>
|
|
173
|
+
<div className="ui-key-values" style={{ marginTop: 8 }}>
|
|
174
|
+
{lagging.map((a) => (
|
|
175
|
+
<div className="ui-key-value" key={a.container}>
|
|
176
|
+
<span>{a.name ?? a.container}</span>
|
|
177
|
+
<span className="ui-muted">
|
|
178
|
+
{a.error ?? 'not read recently'}
|
|
179
|
+
{a.collectedAt ? ` · last read ${formatAge(now - a.collectedAt)}` : ' · never read'}
|
|
180
|
+
</span>
|
|
181
|
+
</div>
|
|
182
|
+
))}
|
|
183
|
+
</div>
|
|
184
|
+
</Surface>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{data && data.unpriced.length > 0 && (
|
|
188
|
+
<Surface tone="warning">
|
|
189
|
+
<div className="ui-kicker">Calls without a price · last 30 days</div>
|
|
190
|
+
<p className="ui-muted" style={{ fontSize: 12, marginTop: 8 }}>
|
|
191
|
+
OpenClaw counted these calls but could not price them, so they are not in the figures: the model is no longer in the catalogue, or has no price in model-pricing.json.
|
|
192
|
+
</p>
|
|
193
|
+
<div className="ui-key-values" style={{ marginTop: 8 }}>
|
|
194
|
+
{data.unpriced.map((u) => (
|
|
195
|
+
<div className="ui-key-value" key={u.model}>
|
|
196
|
+
<span>{u.model}</span>
|
|
197
|
+
<span>{u.calls} call{u.calls === 1 ? '' : 's'} · {u.agents.join(', ')}</span>
|
|
198
|
+
</div>
|
|
199
|
+
))}
|
|
200
|
+
</div>
|
|
201
|
+
</Surface>
|
|
202
|
+
)}
|
|
203
|
+
|
|
204
|
+
{firstLoad ? <CostsCardsSkeleton /> : (
|
|
205
|
+
<div className="costs-cards" style={{ opacity: stale ? 0.5 : 1 }}>
|
|
206
|
+
<Surface as="article">
|
|
207
|
+
<div className="ui-kicker">Spent</div>
|
|
208
|
+
<div className="ui-metric-value">{formatCost(t?.cost ?? 0)}</div>
|
|
209
|
+
<div className="ui-key-values" style={{ marginTop: 12 }}>
|
|
210
|
+
<div className="ui-key-value"><span className="ui-muted">Tokens</span><span>{formatTokens(t?.tokens ?? 0)}</span></div>
|
|
211
|
+
<div className="ui-key-value">
|
|
212
|
+
<span className="ui-muted">Days</span>
|
|
213
|
+
<span>{data ? (data.startDate === data.endDate ? utcDay.format(new Date(data.endDate)) : `${utcDay.format(new Date(data.startDate))} – ${utcDay.format(new Date(data.endDate))}`) : '—'}</span>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</Surface>
|
|
217
|
+
|
|
218
|
+
<Surface as="article">
|
|
219
|
+
<div className="ui-kicker">Where the money went</div>
|
|
220
|
+
<div className="ui-key-values" style={{ marginTop: 12 }}>
|
|
221
|
+
<div className="ui-key-value"><span className="ui-muted">Input</span><span>{formatCost(t?.inputCost ?? 0)}</span></div>
|
|
222
|
+
<div className="ui-key-value"><span className="ui-muted">Output</span><span>{formatCost(t?.outputCost ?? 0)}</span></div>
|
|
223
|
+
<div className="ui-key-value"><span className="ui-muted">Cache read</span><span>{formatCost(t?.cacheReadCost ?? 0)}</span></div>
|
|
224
|
+
<div className="ui-key-value"><span className="ui-muted">Cache write</span><span>{formatCost(t?.cacheWriteCost ?? 0)}</span></div>
|
|
225
|
+
</div>
|
|
226
|
+
</Surface>
|
|
227
|
+
|
|
228
|
+
<Surface as="article">
|
|
229
|
+
<div className="ui-kicker">Tokens</div>
|
|
230
|
+
<div className="ui-key-values" style={{ marginTop: 12 }}>
|
|
231
|
+
<div className="ui-key-value"><span className="ui-muted">Input</span><span>{formatTokens(t?.input ?? 0)}</span></div>
|
|
232
|
+
<div className="ui-key-value"><span className="ui-muted">Output</span><span>{formatTokens(t?.output ?? 0)}</span></div>
|
|
233
|
+
<div className="ui-key-value"><span className="ui-muted">Cache read</span><span>{formatTokens(t?.cacheRead ?? 0)}</span></div>
|
|
234
|
+
<div className="ui-key-value">
|
|
235
|
+
<span className="ui-muted">Input served from cache</span>
|
|
236
|
+
<span>{Math.round(share(t?.cacheRead ?? 0, (t?.input ?? 0) + (t?.cacheRead ?? 0) + (t?.cacheWrite ?? 0)))}%</span>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
</Surface>
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{firstLoad ? <CostsBodySkeleton /> : data && (
|
|
244
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, opacity: stale ? 0.5 : 1 }}>
|
|
245
|
+
{data.range !== 'today' && (
|
|
246
|
+
<Surface as="section">
|
|
247
|
+
<div className="ui-kicker">Spent per day · UTC</div>
|
|
248
|
+
<TimeSeriesChart
|
|
249
|
+
label="Spent per day"
|
|
250
|
+
points={data.daily.map((d) => ({ ts: dayStart(d.date), value: d.cost }))}
|
|
251
|
+
from={dayStart(data.startDate)}
|
|
252
|
+
to={dayStart(data.endDate)}
|
|
253
|
+
bucket={DAY_S}
|
|
254
|
+
yMax={maxDaily > 0 ? maxDaily * 1.2 : 1}
|
|
255
|
+
formatValue={formatCost}
|
|
256
|
+
formatTime={(ts) => utcDay.format(new Date(ts * 1000))}
|
|
257
|
+
/>
|
|
258
|
+
</Surface>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
<div className="costs-lists">
|
|
262
|
+
<Surface as="section">
|
|
263
|
+
<div className="ui-kicker">By agent</div>
|
|
264
|
+
{data.byAgent.length === 0 && <p className="ui-muted" style={{ fontSize: 12, marginTop: 12 }}>No agent has spent anything in this range.</p>}
|
|
265
|
+
{data.byAgent.map((a) => (
|
|
266
|
+
<BreakdownRow
|
|
267
|
+
key={a.container}
|
|
268
|
+
name={a.name ?? a.container}
|
|
269
|
+
figure={formatCost(a.cost)}
|
|
270
|
+
percent={share(a.cost, t?.cost ?? 0)}
|
|
271
|
+
label={`Share of the spend for ${a.name ?? a.container}`}
|
|
272
|
+
detail={`${a.container} · ${formatTokens(a.tokens)} tokens${a.missing ? ` · ${a.missing} unpriced` : ''}`}
|
|
273
|
+
/>
|
|
274
|
+
))}
|
|
275
|
+
</Surface>
|
|
276
|
+
|
|
277
|
+
<Surface as="section">
|
|
278
|
+
<div className="ui-kicker">By model</div>
|
|
279
|
+
{data.byModel.length === 0 && <p className="ui-muted" style={{ fontSize: 12, marginTop: 12 }}>No model calls in this range.</p>}
|
|
280
|
+
{data.byModel.map((m) => (
|
|
281
|
+
<BreakdownRow
|
|
282
|
+
key={`${m.provider}/${m.model}`}
|
|
283
|
+
name={m.model}
|
|
284
|
+
figure={formatCost(m.cost)}
|
|
285
|
+
percent={share(m.cost, t?.cost ?? 0)}
|
|
286
|
+
label={`Share of the spend for ${m.model}`}
|
|
287
|
+
detail={`${m.provider} · ${m.calls} call${m.calls === 1 ? '' : 's'} · ${formatTokens(m.tokens)} tokens${unpricedModels.has(`${m.provider}/${m.model}`) ? ' · no price, not counted' : ''}`}
|
|
288
|
+
/>
|
|
289
|
+
))}
|
|
290
|
+
</Surface>
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
{data.reconciliation.length > 0 && (
|
|
294
|
+
<Surface as="section">
|
|
295
|
+
<div className="ui-kicker">Against the bill</div>
|
|
296
|
+
<p className="ui-muted" style={{ fontSize: 12, marginTop: 8 }}>
|
|
297
|
+
What each vendor charged (its balance or usage, read every hour) next to what the agents priced, over the same readings.
|
|
298
|
+
The same key also pays for the assistant and any client outside Rev4a, and DeepSeek gives its balance to the cent: a small gap is normal.
|
|
299
|
+
</p>
|
|
300
|
+
<div className="ui-key-values" style={{ marginTop: 8 }}>
|
|
301
|
+
{data.reconciliation.map((r) => {
|
|
302
|
+
const name = VENDOR_LABEL[r.vendor] ?? r.vendor;
|
|
303
|
+
if (r.billed === null || r.priced === null || !r.from || !r.to) {
|
|
304
|
+
return (
|
|
305
|
+
<div className="ui-key-value" key={r.vendor}>
|
|
306
|
+
<span>{name}</span>
|
|
307
|
+
<span className="ui-muted">{r.readings ? 'one reading so far — the comparison starts with the next, within the hour' : 'no reading in this range'}</span>
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
if (!r.comparable) {
|
|
312
|
+
return (
|
|
313
|
+
<div className="ui-key-value" key={r.vendor}>
|
|
314
|
+
<span>{name}</span>
|
|
315
|
+
<span className="ui-muted">billed in {r.currency}, not comparable with dollars</span>
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const gap = r.billed - r.priced;
|
|
320
|
+
return (
|
|
321
|
+
<div key={r.vendor} style={{ marginTop: 6 }}>
|
|
322
|
+
<div className="ui-key-value" style={{ fontSize: 12 }}>
|
|
323
|
+
<span>{name}</span>
|
|
324
|
+
<span>billed {formatCost(r.billed)} · priced {formatCost(r.priced)} · gap {gap < 0 ? '−' : '+'}{formatCost(Math.abs(gap))}</span>
|
|
325
|
+
</div>
|
|
326
|
+
<div className="ui-muted" style={{ fontSize: 11, marginTop: 4 }}>
|
|
327
|
+
{sessionTime.format(new Date(r.from))} → {sessionTime.format(new Date(r.to))} · {r.readings} readings
|
|
328
|
+
{r.topUps > 0 ? ` · ${formatCost(r.topUps)} added to the balance (not counted as spend)` : ''}
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
);
|
|
332
|
+
})}
|
|
333
|
+
</div>
|
|
334
|
+
</Surface>
|
|
335
|
+
)}
|
|
336
|
+
|
|
337
|
+
<Surface as="section">
|
|
338
|
+
<div className="ui-kicker">Most expensive sessions</div>
|
|
339
|
+
{data.sessions.length === 0 && <p className="ui-muted" style={{ fontSize: 12, marginTop: 12 }}>No session was active in this range.</p>}
|
|
340
|
+
{data.sessions.map((s) => (
|
|
341
|
+
<BreakdownRow
|
|
342
|
+
key={`${s.container}/${s.sessionId}`}
|
|
343
|
+
name={s.label || s.sessionKey || s.sessionId}
|
|
344
|
+
figure={formatCost(s.cost)}
|
|
345
|
+
percent={share(s.cost, data.sessions[0]?.cost ?? 0)}
|
|
346
|
+
label={`Cost of ${s.label || s.sessionId} relative to the most expensive session`}
|
|
347
|
+
detail={[
|
|
348
|
+
s.agentName ?? s.container,
|
|
349
|
+
s.model,
|
|
350
|
+
`${formatTokens(s.tokens)} tokens`,
|
|
351
|
+
s.missing ? `${s.missing} unpriced` : null,
|
|
352
|
+
s.lastActivity ? `last active ${sessionTime.format(new Date(s.lastActivity))}` : null,
|
|
353
|
+
].filter(Boolean).join(' · ')}
|
|
354
|
+
/>
|
|
355
|
+
))}
|
|
356
|
+
{data.sessions.length > 0 && (
|
|
357
|
+
<p className="ui-muted" style={{ fontSize: 11, marginTop: 14 }}>
|
|
358
|
+
A session's figure is its total over the last 30 days, not only this range.
|
|
359
|
+
</p>
|
|
360
|
+
)}
|
|
361
|
+
</Surface>
|
|
362
|
+
</div>
|
|
363
|
+
)}
|
|
364
|
+
</Page>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Page, PageHeader } from '../components/ui';
|
|
2
|
+
import { COSTS_DESCRIPTION, CostsBodySkeleton, CostsCardsSkeleton, CostsRangeSkeleton } from './CostsSkeleton';
|
|
3
|
+
|
|
4
|
+
export default function CostsLoading() {
|
|
5
|
+
return (
|
|
6
|
+
<Page>
|
|
7
|
+
<PageHeader eyebrow="Spend" title="Costs" description={COSTS_DESCRIPTION} />
|
|
8
|
+
<CostsRangeSkeleton />
|
|
9
|
+
<CostsCardsSkeleton />
|
|
10
|
+
<CostsBodySkeleton />
|
|
11
|
+
</Page>
|
|
12
|
+
);
|
|
13
|
+
}
|
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; }
|
|
@@ -684,6 +720,12 @@ html, body { height: 100%; height: 100dvh; background: var(--bg); color: var(--t
|
|
|
684
720
|
.system-cards, .system-charts { grid-template-columns: minmax(0, 1fr); }
|
|
685
721
|
}
|
|
686
722
|
|
|
723
|
+
.costs-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
|
724
|
+
.costs-lists { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
|
725
|
+
@media (max-width: 991px) {
|
|
726
|
+
.costs-cards, .costs-lists { grid-template-columns: minmax(0, 1fr); }
|
|
727
|
+
}
|
|
728
|
+
|
|
687
729
|
.cockpit { overflow: auto; height: 100%; padding: 20px; }
|
|
688
730
|
.cockpit__metrics { display: grid; grid-template-columns: minmax(260px, 1.2fr) repeat(3, minmax(160px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
|
689
731
|
.cockpit__machine-item { white-space: nowrap; color: var(--text); }
|
|
@@ -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
|
+
}
|