@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,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the agents spent, read from costs.db — the figures each agent's own OpenClaw
|
|
3
|
+
* recorded, copied by daemon.js (see docs/dev/DATABASE.md, Costs Database). Nothing here
|
|
4
|
+
* computes a cost. The spend queries of every reader live here — the Costs page, the
|
|
5
|
+
* dashboard (GET /api/costs, the SSE stream, the system-health cost check) and the
|
|
6
|
+
* agent panel; the vendor readings are in lib/cost-reconciliation.ts.
|
|
7
|
+
*
|
|
8
|
+
* Days are UTC dates, as OpenClaw buckets them.
|
|
9
|
+
*/
|
|
10
|
+
import type Database from 'better-sqlite3';
|
|
11
|
+
import { openCostsDb } from './costs-db';
|
|
12
|
+
|
|
13
|
+
/** How often daemon.js reads the agents; a figure older than 3× this is not current. */
|
|
14
|
+
export const COLLECT_INTERVAL_S = 300;
|
|
15
|
+
|
|
16
|
+
export const utcDate = (ms: number) => new Date(ms).toISOString().slice(0, 10);
|
|
17
|
+
|
|
18
|
+
/** The UTC dates of the last `days` days, today included, oldest first. */
|
|
19
|
+
export function lastDays(days: number, now = Date.now()): string[] {
|
|
20
|
+
return Array.from({ length: days }, (_, i) => utcDate(now - (days - 1 - i) * 86_400_000));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CostTotals {
|
|
24
|
+
cost: number; tokens: number; input: number; output: number; cacheRead: number; cacheWrite: number;
|
|
25
|
+
inputCost: number; outputCost: number; cacheReadCost: number; cacheWriteCost: number; missing: number;
|
|
26
|
+
}
|
|
27
|
+
export interface AgentCostRow {
|
|
28
|
+
container: string; agentId: string | null; name: string | null; cost: number; tokens: number; missing: number;
|
|
29
|
+
collectedAt: number | null; attemptedAt: number | null; error: string | null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Rows without tokens or cost are left out: OpenClaw also counts internal entries as
|
|
33
|
+
* models (`gateway-injected`, messages the Gateway adds itself) that cost nothing.
|
|
34
|
+
*/
|
|
35
|
+
export interface ModelCostRow { provider: string; model: string; cost: number; tokens: number; calls: number }
|
|
36
|
+
export interface SessionCostRow {
|
|
37
|
+
container: string; agentName: string | null; sessionId: string; sessionKey: string | null; label: string | null;
|
|
38
|
+
agentId: string | null; model: string | null; cost: number; tokens: number; missing: number; lastActivity: number | null;
|
|
39
|
+
}
|
|
40
|
+
export interface CostSummary {
|
|
41
|
+
totals: CostTotals;
|
|
42
|
+
daily: { date: string; cost: number; tokens: number; missing: number }[];
|
|
43
|
+
byAgent: AgentCostRow[];
|
|
44
|
+
byModel: ModelCostRow[];
|
|
45
|
+
sessions: SessionCostRow[];
|
|
46
|
+
/** Calls OpenClaw could not price, by model, over each agent's last 30 days. */
|
|
47
|
+
unpriced: { model: string; calls: number; agents: string[] }[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface SourceRow {
|
|
51
|
+
container: string; agent_id: string | null; name: string | null;
|
|
52
|
+
collected_at: number | null; attempted_at: number | null; error: string | null; missing_by_model: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const EMPTY_TOTALS: CostTotals = {
|
|
56
|
+
cost: 0, tokens: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0,
|
|
57
|
+
inputCost: 0, outputCost: 0, cacheReadCost: 0, cacheWriteCost: 0, missing: 0,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const TOTALS_SQL = `
|
|
61
|
+
SELECT COALESCE(SUM(cost), 0) AS cost, COALESCE(SUM(total_tokens), 0) AS tokens,
|
|
62
|
+
COALESCE(SUM(input), 0) AS input, COALESCE(SUM(output), 0) AS output,
|
|
63
|
+
COALESCE(SUM(cache_read), 0) AS cacheRead, COALESCE(SUM(cache_write), 0) AS cacheWrite,
|
|
64
|
+
COALESCE(SUM(input_cost), 0) AS inputCost, COALESCE(SUM(output_cost), 0) AS outputCost,
|
|
65
|
+
COALESCE(SUM(cache_read_cost), 0) AS cacheReadCost, COALESCE(SUM(cache_write_cost), 0) AS cacheWriteCost,
|
|
66
|
+
COALESCE(SUM(missing), 0) AS missing
|
|
67
|
+
FROM agent_cost_daily`;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Everything the Costs page shows for a range of UTC dates. `container` narrows it to
|
|
71
|
+
* one agent (the agent panel).
|
|
72
|
+
*/
|
|
73
|
+
export function readCostSummary(
|
|
74
|
+
db: Database.Database,
|
|
75
|
+
dates: string[],
|
|
76
|
+
opts: { container?: string; topSessions?: number } = {},
|
|
77
|
+
): CostSummary {
|
|
78
|
+
const startDate = dates[0];
|
|
79
|
+
const endDate = dates[dates.length - 1];
|
|
80
|
+
const where = `date BETWEEN @startDate AND @endDate${opts.container ? ' AND container = @container' : ''}`;
|
|
81
|
+
const params = { startDate, endDate, container: opts.container ?? null };
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
|
|
84
|
+
const totals = db.prepare(`${TOTALS_SQL} WHERE ${where}`).get(params) as CostTotals;
|
|
85
|
+
|
|
86
|
+
const dailyRows = db.prepare(`
|
|
87
|
+
SELECT date, SUM(cost) AS cost, SUM(total_tokens) AS tokens, SUM(missing) AS missing
|
|
88
|
+
FROM agent_cost_daily WHERE ${where} GROUP BY date`).all(params) as CostSummary['daily'];
|
|
89
|
+
const byDate = new Map(dailyRows.map((r) => [r.date, r]));
|
|
90
|
+
const daily = dates.map((date) => byDate.get(date) ?? { date, cost: 0, tokens: 0, missing: 0 });
|
|
91
|
+
|
|
92
|
+
const sources = (db.prepare('SELECT * FROM agent_cost_sources').all() as SourceRow[])
|
|
93
|
+
.filter((s) => !opts.container || s.container === opts.container);
|
|
94
|
+
const spend = new Map(
|
|
95
|
+
(db.prepare(`
|
|
96
|
+
SELECT container, SUM(cost) AS cost, SUM(total_tokens) AS tokens, SUM(missing) AS missing
|
|
97
|
+
FROM agent_cost_daily WHERE ${where} GROUP BY container`).all(params) as
|
|
98
|
+
{ container: string; cost: number; tokens: number; missing: number }[]).map((r) => [r.container, r]),
|
|
99
|
+
);
|
|
100
|
+
const byAgent = sources
|
|
101
|
+
.map((s) => ({
|
|
102
|
+
container: s.container,
|
|
103
|
+
agentId: s.agent_id,
|
|
104
|
+
name: s.name,
|
|
105
|
+
cost: spend.get(s.container)?.cost ?? 0,
|
|
106
|
+
tokens: spend.get(s.container)?.tokens ?? 0,
|
|
107
|
+
missing: spend.get(s.container)?.missing ?? 0,
|
|
108
|
+
collectedAt: s.collected_at,
|
|
109
|
+
attemptedAt: s.attempted_at,
|
|
110
|
+
error: s.error,
|
|
111
|
+
}))
|
|
112
|
+
.filter((a) => a.tokens > 0 || a.missing > 0 || a.error || (a.attemptedAt ?? 0) > now - 3 * COLLECT_INTERVAL_S * 1000)
|
|
113
|
+
.sort((a, b) => b.cost - a.cost || b.tokens - a.tokens);
|
|
114
|
+
|
|
115
|
+
const byModel = db.prepare(`
|
|
116
|
+
SELECT provider, model, SUM(cost) AS cost, SUM(tokens) AS tokens, SUM(calls) AS calls
|
|
117
|
+
FROM agent_cost_model_daily WHERE ${where}
|
|
118
|
+
GROUP BY provider, model HAVING SUM(tokens) > 0 OR SUM(cost) > 0 ORDER BY cost DESC, tokens DESC`).all(params) as ModelCostRow[];
|
|
119
|
+
|
|
120
|
+
const names = new Map(sources.map((s) => [s.container, s.name]));
|
|
121
|
+
const since = Date.parse(`${startDate}T00:00:00Z`);
|
|
122
|
+
const sessions = (db.prepare(`
|
|
123
|
+
SELECT container, session_id AS sessionId, session_key AS sessionKey, label, agent_id AS agentId, model,
|
|
124
|
+
cost, tokens, missing, last_activity AS lastActivity
|
|
125
|
+
FROM agent_cost_sessions WHERE last_activity >= @since${opts.container ? ' AND container = @container' : ''}
|
|
126
|
+
ORDER BY cost DESC, tokens DESC LIMIT @limit`).all({ since, container: opts.container ?? null, limit: opts.topSessions ?? 20 }) as
|
|
127
|
+
Omit<SessionCostRow, 'agentName'>[])
|
|
128
|
+
.map((s) => ({ ...s, agentName: names.get(s.container) ?? null }));
|
|
129
|
+
|
|
130
|
+
const unpricedMap = new Map<string, { model: string; calls: number; agents: string[] }>();
|
|
131
|
+
for (const s of sources) {
|
|
132
|
+
let parsed: Record<string, number> = {};
|
|
133
|
+
try { parsed = JSON.parse(s.missing_by_model || '{}'); } catch { /* keep empty */ }
|
|
134
|
+
for (const [model, calls] of Object.entries(parsed)) {
|
|
135
|
+
if (!calls) continue;
|
|
136
|
+
const entry = unpricedMap.get(model) ?? { model, calls: 0, agents: [] };
|
|
137
|
+
entry.calls += calls;
|
|
138
|
+
entry.agents.push(s.name ?? s.container);
|
|
139
|
+
unpricedMap.set(model, entry);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const unpriced = [...unpricedMap.values()].sort((a, b) => b.calls - a.calls);
|
|
143
|
+
|
|
144
|
+
return { totals, daily, byAgent, byModel, sessions, unpriced };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Today's spend (UTC day) and all-time spend, by model today — for the dashboard. Null
|
|
149
|
+
* when there is nothing to read yet; never throws, so a broken costs.db cannot fail a
|
|
150
|
+
* page about something else (the reason is in `error`).
|
|
151
|
+
*/
|
|
152
|
+
export function dashboardCosts(): {
|
|
153
|
+
today: number; allTime: number; missingToday: number;
|
|
154
|
+
byModelToday: ModelCostRow[]; error?: string;
|
|
155
|
+
} | null {
|
|
156
|
+
let db: Database.Database | null = null;
|
|
157
|
+
try {
|
|
158
|
+
db = openCostsDb();
|
|
159
|
+
if (!db) return null;
|
|
160
|
+
const today = utcDate(Date.now());
|
|
161
|
+
const t = db.prepare(`${TOTALS_SQL} WHERE date = ?`).get(today) as CostTotals;
|
|
162
|
+
const all = db.prepare(`${TOTALS_SQL}`).get() as CostTotals;
|
|
163
|
+
const byModelToday = db.prepare(`
|
|
164
|
+
SELECT provider, model, SUM(cost) AS cost, SUM(tokens) AS tokens, SUM(calls) AS calls
|
|
165
|
+
FROM agent_cost_model_daily WHERE date = ? GROUP BY provider, model HAVING SUM(tokens) > 0 OR SUM(cost) > 0 ORDER BY cost DESC, tokens DESC`).all(today) as ModelCostRow[];
|
|
166
|
+
return { today: t.cost, allTime: all.cost, missingToday: t.missing, byModelToday };
|
|
167
|
+
} catch (e) {
|
|
168
|
+
return { today: 0, allTime: 0, missingToday: 0, byModelToday: [], error: (e as Error).message };
|
|
169
|
+
} finally {
|
|
170
|
+
db?.close();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each container consumes, read from metrics.db (daemon.js writes it — see
|
|
3
|
+
* docs/dev/DATABASE.md, Container consumption). The queries behind the System page's
|
|
4
|
+
* *Agents* section, the agent panel and the Containers page; the routes stay thin.
|
|
5
|
+
*
|
|
6
|
+
* CPU is stored in cores and shown as a share of Docker's cores (`docker_host.ncpu`): on the
|
|
7
|
+
* VPS that is the machine, on Docker Desktop its VM. Memory is the working set in MB,
|
|
8
|
+
* with its share of Docker's memory next to it.
|
|
9
|
+
*/
|
|
10
|
+
import type Database from 'better-sqlite3';
|
|
11
|
+
|
|
12
|
+
/** How often daemon.js samples containers, and reads Docker's disk (seconds). */
|
|
13
|
+
export const CONTAINER_INTERVAL_S = 60;
|
|
14
|
+
export const STORAGE_INTERVAL_S = 600;
|
|
15
|
+
/** History points per chart. */
|
|
16
|
+
const POINTS = 120;
|
|
17
|
+
/** A container whose newest sample is older than this many intervals is not running. */
|
|
18
|
+
const RUNNING_WITHIN_INTERVALS = 3;
|
|
19
|
+
|
|
20
|
+
const TABLES = ['container_metrics', 'container_sources', 'docker_host', 'docker_storage'];
|
|
21
|
+
|
|
22
|
+
/** False until the daemon has created the tables (first start after an update). */
|
|
23
|
+
export function containerTablesReady(db: Database.Database): boolean {
|
|
24
|
+
const row = db
|
|
25
|
+
.prepare(`SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name IN (${TABLES.map(() => '?').join(', ')})`)
|
|
26
|
+
.get(...TABLES) as { n: number };
|
|
27
|
+
return row.n === TABLES.length;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Seconds per chart point: at least two samples, so a drifting timer leaves no empty bucket. */
|
|
31
|
+
export function bucketSeconds(spanS: number, intervalS: number): number {
|
|
32
|
+
return Math.max(2 * intervalS, Math.round(spanS / POINTS));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DockerHost { ncpu: number; mem_total_mb: number }
|
|
36
|
+
|
|
37
|
+
export function readDockerHost(db: Database.Database): DockerHost | null {
|
|
38
|
+
const row = db.prepare('SELECT ncpu, mem_total_mb FROM docker_host WHERE id = 1').get() as
|
|
39
|
+
{ ncpu: number | null; mem_total_mb: number | null } | undefined;
|
|
40
|
+
return row && row.ncpu && row.mem_total_mb ? { ncpu: row.ncpu, mem_total_mb: row.mem_total_mb } : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `value` as a percent of `whole`, to `digits` decimals — CPU needs two: an idle agent is a few hundredths of a percent of the machine. */
|
|
44
|
+
const pct = (value: number | null, whole: number | null | undefined, digits = 1): number | null =>
|
|
45
|
+
value === null || !whole ? null : Math.round((value / whole) * 100 * 10 ** digits) / 10 ** digits;
|
|
46
|
+
const round2 = (n: number | null): number | null => (n === null ? null : Math.round(n * 100) / 100);
|
|
47
|
+
|
|
48
|
+
/** The named volumes a container uses, as daemon.js recorded them. */
|
|
49
|
+
function volumesOf(list: string | null): string[] {
|
|
50
|
+
return list ? list.split(',').filter(Boolean) : [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ContainerNow {
|
|
54
|
+
cpu_cores: number | null;
|
|
55
|
+
cpu_percent: number | null;
|
|
56
|
+
mem_mb: number | null;
|
|
57
|
+
mem_percent: number | null;
|
|
58
|
+
pids: number | null;
|
|
59
|
+
net_rx_bps: number | null;
|
|
60
|
+
net_tx_bps: number | null;
|
|
61
|
+
blk_read_bps: number | null;
|
|
62
|
+
blk_write_bps: number | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ContainerRow {
|
|
66
|
+
container: string;
|
|
67
|
+
name: string;
|
|
68
|
+
agent_id: string | null;
|
|
69
|
+
is_agent: boolean;
|
|
70
|
+
running: boolean;
|
|
71
|
+
last_seen: number;
|
|
72
|
+
now: ContainerNow | null;
|
|
73
|
+
/** Average and peak over the range, CPU as a share of Docker's cores. */
|
|
74
|
+
range: { cpu_avg_percent: number | null; cpu_max_percent: number | null; mem_avg_mb: number | null; mem_max_mb: number | null };
|
|
75
|
+
/** Named volumes plus the writable layer, from the last reading of Docker's disk. */
|
|
76
|
+
storage: { volume_mb: number | null; layer_mb: number | null; total_mb: number | null; ts: number | null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface DockerStorage {
|
|
80
|
+
ts: number;
|
|
81
|
+
images_mb: number | null;
|
|
82
|
+
images_reclaimable_mb: number | null;
|
|
83
|
+
build_cache_mb: number | null;
|
|
84
|
+
build_cache_reclaimable_mb: number | null;
|
|
85
|
+
volumes_mb: number;
|
|
86
|
+
/** What no container uses, all of it — `unused_volumes` lists only the largest five. */
|
|
87
|
+
unused_volumes_mb: number;
|
|
88
|
+
layers_mb: number;
|
|
89
|
+
/** Volumes no container uses (cold backups, leftovers), largest first. */
|
|
90
|
+
unused_volumes: { name: string; size_mb: number }[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ContainerOverview {
|
|
94
|
+
host: DockerHost | null;
|
|
95
|
+
/** Unix seconds of the newest sample, and how old it is. */
|
|
96
|
+
sampled_at: number | null;
|
|
97
|
+
age_s: number | null;
|
|
98
|
+
containers: ContainerRow[];
|
|
99
|
+
/** The machine minus the containers: what is not one of them (the host, Rev4a, other software). */
|
|
100
|
+
rest: { cpu_percent: number | null; mem_mb: number | null } | null;
|
|
101
|
+
docker_storage: DockerStorage | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface NowRow {
|
|
105
|
+
ts: number; cpu_cores: number | null; mem_mb: number | null; pids: number | null;
|
|
106
|
+
net_rx_bps: number | null; net_tx_bps: number | null; blk_read_bps: number | null; blk_write_bps: number | null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The newest reading of each volume, layer and total, or null before the first. */
|
|
110
|
+
export function readDockerStorage(db: Database.Database): DockerStorage | null {
|
|
111
|
+
const ts = (db.prepare('SELECT MAX(ts) AS ts FROM docker_storage').get() as { ts: number | null }).ts;
|
|
112
|
+
if (ts === null) return null;
|
|
113
|
+
const rows = db.prepare('SELECT kind, name, size_mb, reclaimable_mb FROM docker_storage WHERE ts = ?').all(ts) as
|
|
114
|
+
{ kind: string; name: string; size_mb: number | null; reclaimable_mb: number | null }[];
|
|
115
|
+
const one = (kind: string) => rows.find((r) => r.kind === kind);
|
|
116
|
+
const sum = (kind: string) => rows.filter((r) => r.kind === kind).reduce((acc, r) => acc + (r.size_mb ?? 0), 0);
|
|
117
|
+
const unused = rows
|
|
118
|
+
.filter((r) => r.kind === 'volume' && (r.reclaimable_mb ?? 0) > 0)
|
|
119
|
+
.map((r) => ({ name: r.name, size_mb: r.reclaimable_mb as number }))
|
|
120
|
+
.sort((a, b) => b.size_mb - a.size_mb);
|
|
121
|
+
return {
|
|
122
|
+
ts,
|
|
123
|
+
images_mb: one('images')?.size_mb ?? null,
|
|
124
|
+
images_reclaimable_mb: one('images')?.reclaimable_mb ?? null,
|
|
125
|
+
build_cache_mb: one('build_cache')?.size_mb ?? null,
|
|
126
|
+
build_cache_reclaimable_mb: one('build_cache')?.reclaimable_mb ?? null,
|
|
127
|
+
volumes_mb: sum('volume'),
|
|
128
|
+
unused_volumes_mb: unused.reduce((a, v) => a + v.size_mb, 0),
|
|
129
|
+
layers_mb: sum('layer'),
|
|
130
|
+
unused_volumes: unused.slice(0, 5),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface AggregateRow { container: string; cpu_avg: number | null; cpu_max: number | null; mem_avg: number | null; mem_max: number | null }
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* From 24 hours up, the average and peak scan every sample of the range: the page's heaviest
|
|
138
|
+
* query — synchronous, so it holds the server's event loop (about 100 ms for ten containers
|
|
139
|
+
* over 24 h, measured) on every 30 s poll — and its answer hardly moves in two minutes. So
|
|
140
|
+
* those ranges are kept that long; shorter ones are cheap and read fresh.
|
|
141
|
+
*/
|
|
142
|
+
const LONG_RANGE_S = 24 * 3_600;
|
|
143
|
+
const AGGREGATE_TTL_MS = 120_000;
|
|
144
|
+
const aggregateCache = new Map<string, { at: number; rows: Map<string, AggregateRow> }>();
|
|
145
|
+
|
|
146
|
+
function rangeAggregates(db: Database.Database, spanS: number, since: number): Map<string, AggregateRow> {
|
|
147
|
+
const cacheable = spanS >= LONG_RANGE_S;
|
|
148
|
+
const key = `${db.name}:${spanS}`;
|
|
149
|
+
if (cacheable) {
|
|
150
|
+
const hit = aggregateCache.get(key);
|
|
151
|
+
if (hit && Date.now() - hit.at < AGGREGATE_TTL_MS) return hit.rows;
|
|
152
|
+
}
|
|
153
|
+
const rows = new Map(
|
|
154
|
+
(db
|
|
155
|
+
.prepare(
|
|
156
|
+
`SELECT container, AVG(cpu_cores) AS cpu_avg, MAX(cpu_cores) AS cpu_max, AVG(mem_mb) AS mem_avg, MAX(mem_mb) AS mem_max
|
|
157
|
+
FROM container_metrics WHERE ts > ? GROUP BY container`,
|
|
158
|
+
)
|
|
159
|
+
.all(since) as AggregateRow[])
|
|
160
|
+
.map((r) => [r.container, r]),
|
|
161
|
+
);
|
|
162
|
+
if (cacheable) aggregateCache.set(key, { at: Date.now(), rows });
|
|
163
|
+
return rows;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Every container seen in the range: who it is, what it uses now, its average and peak over
|
|
168
|
+
* the range, and its disk — running ones first, by CPU.
|
|
169
|
+
*/
|
|
170
|
+
export function readContainerOverview(
|
|
171
|
+
db: Database.Database,
|
|
172
|
+
spanS: number,
|
|
173
|
+
nowS = Math.floor(Date.now() / 1000),
|
|
174
|
+
/** Cores of the machine the API runs on, to put the containers' CPU on the machine's scale in `rest`. */
|
|
175
|
+
hostCores?: number,
|
|
176
|
+
): ContainerOverview {
|
|
177
|
+
const host = readDockerHost(db);
|
|
178
|
+
const sampledAt = (db.prepare('SELECT MAX(ts) AS ts FROM container_metrics').get() as { ts: number | null }).ts;
|
|
179
|
+
const since = nowS - spanS;
|
|
180
|
+
|
|
181
|
+
const sources = db
|
|
182
|
+
.prepare('SELECT container, agent_id, name, is_agent, volumes, last_seen FROM container_sources WHERE last_seen > ?')
|
|
183
|
+
.all(since) as { container: string; agent_id: string | null; name: string | null; is_agent: number; volumes: string | null; last_seen: number }[];
|
|
184
|
+
|
|
185
|
+
const aggregates = rangeAggregates(db, spanS, since);
|
|
186
|
+
// Running is judged against the newest sample, not the wall clock: a daemon that stopped must
|
|
187
|
+
// not make every container look stopped (the page says "Not collecting" instead) — but never
|
|
188
|
+
// against a sample from the future, which a clock set back would leave behind.
|
|
189
|
+
const reference = sampledAt === null ? null : Math.min(sampledAt, nowS);
|
|
190
|
+
|
|
191
|
+
const storage = readDockerStorage(db);
|
|
192
|
+
const storageRows = storage
|
|
193
|
+
? (db.prepare('SELECT kind, name, size_mb FROM docker_storage WHERE ts = ?').all(storage.ts) as { kind: string; name: string; size_mb: number | null }[])
|
|
194
|
+
: [];
|
|
195
|
+
const sizeOf = (kind: string, name: string) => storageRows.find((r) => r.kind === kind && r.name === name)?.size_mb ?? null;
|
|
196
|
+
|
|
197
|
+
const newest = db.prepare(
|
|
198
|
+
`SELECT ts, cpu_cores, mem_mb, pids, net_rx_bps, net_tx_bps, blk_read_bps, blk_write_bps
|
|
199
|
+
FROM container_metrics WHERE container = ? ORDER BY ts DESC LIMIT 3`,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const containers: ContainerRow[] = sources.map((s) => {
|
|
203
|
+
const recent = newest.all(s.container) as NowRow[];
|
|
204
|
+
const latest = recent[0];
|
|
205
|
+
const running = Boolean(latest && reference !== null && latest.ts >= reference - RUNNING_WITHIN_INTERVALS * CONTAINER_INTERVAL_S);
|
|
206
|
+
// A rate is null on the first sample after a start; the previous reading stands in for it.
|
|
207
|
+
const rate = (key: keyof NowRow) => recent.find((r) => r[key] !== null)?.[key] ?? null;
|
|
208
|
+
const cores = running ? (rate('cpu_cores') as number | null) : null;
|
|
209
|
+
const now: ContainerNow | null = running
|
|
210
|
+
? {
|
|
211
|
+
cpu_cores: cores,
|
|
212
|
+
cpu_percent: pct(cores, host?.ncpu, 2),
|
|
213
|
+
mem_mb: latest.mem_mb,
|
|
214
|
+
mem_percent: pct(latest.mem_mb, host?.mem_total_mb),
|
|
215
|
+
pids: latest.pids,
|
|
216
|
+
net_rx_bps: rate('net_rx_bps') as number | null,
|
|
217
|
+
net_tx_bps: rate('net_tx_bps') as number | null,
|
|
218
|
+
blk_read_bps: rate('blk_read_bps') as number | null,
|
|
219
|
+
blk_write_bps: rate('blk_write_bps') as number | null,
|
|
220
|
+
}
|
|
221
|
+
: null;
|
|
222
|
+
const agg = aggregates.get(s.container);
|
|
223
|
+
const volumeSizes = volumesOf(s.volumes).map((v) => sizeOf('volume', v));
|
|
224
|
+
const volume = volumeSizes.length && volumeSizes.some((v) => v !== null) ? volumeSizes.reduce<number>((a, v) => a + (v ?? 0), 0) : null;
|
|
225
|
+
const layer = sizeOf('layer', s.container);
|
|
226
|
+
return {
|
|
227
|
+
container: s.container,
|
|
228
|
+
name: s.name || s.container,
|
|
229
|
+
agent_id: s.agent_id,
|
|
230
|
+
is_agent: s.is_agent === 1,
|
|
231
|
+
running,
|
|
232
|
+
last_seen: s.last_seen,
|
|
233
|
+
now,
|
|
234
|
+
range: {
|
|
235
|
+
cpu_avg_percent: pct(agg?.cpu_avg ?? null, host?.ncpu, 2),
|
|
236
|
+
cpu_max_percent: pct(agg?.cpu_max ?? null, host?.ncpu, 2),
|
|
237
|
+
mem_avg_mb: agg?.mem_avg == null ? null : Math.round(agg.mem_avg),
|
|
238
|
+
mem_max_mb: agg?.mem_max ?? null,
|
|
239
|
+
},
|
|
240
|
+
storage: {
|
|
241
|
+
volume_mb: volume,
|
|
242
|
+
layer_mb: layer,
|
|
243
|
+
total_mb: volume === null && layer === null ? null : (volume ?? 0) + (layer ?? 0),
|
|
244
|
+
ts: storage?.ts ?? null,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
containers.sort((a, b) =>
|
|
250
|
+
Number(b.running) - Number(a.running)
|
|
251
|
+
|| (b.now?.cpu_percent ?? -1) - (a.now?.cpu_percent ?? -1)
|
|
252
|
+
|| a.name.localeCompare(b.name));
|
|
253
|
+
|
|
254
|
+
// Machine minus the containers, from the machine's newest sample.
|
|
255
|
+
const machine = db
|
|
256
|
+
.prepare('SELECT cpu_percent, ram_used_mb FROM system_metrics ORDER BY ts DESC LIMIT 1')
|
|
257
|
+
.get() as { cpu_percent: number | null; ram_used_mb: number | null } | undefined;
|
|
258
|
+
const running = containers.filter((c) => c.running && c.now);
|
|
259
|
+
// The machine's CPU is a share of the host's cores; a container's is of Docker's — the same on a
|
|
260
|
+
// server, not on Docker Desktop, whose VM may have fewer. Compare in cores over the host's.
|
|
261
|
+
const containersShare = hostCores && hostCores > 0
|
|
262
|
+
? (running.reduce((a, c) => a + (c.now?.cpu_cores ?? 0), 0) / hostCores) * 100
|
|
263
|
+
: running.reduce((a, c) => a + (c.now?.cpu_percent ?? 0), 0);
|
|
264
|
+
const rest = machine && host
|
|
265
|
+
? {
|
|
266
|
+
cpu_percent: machine.cpu_percent === null ? null : round2(Math.max(0, machine.cpu_percent - containersShare)),
|
|
267
|
+
mem_mb: machine.ram_used_mb === null ? null : Math.max(0, machine.ram_used_mb - running.reduce((a, c) => a + (c.now?.mem_mb ?? 0), 0)),
|
|
268
|
+
}
|
|
269
|
+
: null;
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
host,
|
|
273
|
+
sampled_at: sampledAt,
|
|
274
|
+
age_s: sampledAt === null ? null : Math.max(0, nowS - sampledAt),
|
|
275
|
+
containers,
|
|
276
|
+
rest,
|
|
277
|
+
docker_storage: storage,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface ContainerHistory {
|
|
282
|
+
host: DockerHost | null;
|
|
283
|
+
container: string;
|
|
284
|
+
name: string | null;
|
|
285
|
+
bucket_s: number;
|
|
286
|
+
/** CPU as a share of Docker's cores (average and peak), memory in MB (average and peak). */
|
|
287
|
+
history: { ts: number; cpu_avg: number | null; cpu_max: number | null; mem_avg: number | null; mem_max: number | null }[];
|
|
288
|
+
storage_bucket_s: number;
|
|
289
|
+
/** Named volumes plus the writable layer, MB. */
|
|
290
|
+
storage_history: { ts: number; total_mb: number | null }[];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** One container's history, bucketed like the machine's (`GET /api/metrics`). */
|
|
294
|
+
export function readContainerHistory(db: Database.Database, container: string, spanS: number, nowS = Math.floor(Date.now() / 1000)): ContainerHistory {
|
|
295
|
+
const host = readDockerHost(db);
|
|
296
|
+
const since = nowS - spanS;
|
|
297
|
+
const bucket = bucketSeconds(spanS, CONTAINER_INTERVAL_S);
|
|
298
|
+
const storageBucket = bucketSeconds(spanS, STORAGE_INTERVAL_S);
|
|
299
|
+
|
|
300
|
+
const source = db.prepare('SELECT name, volumes FROM container_sources WHERE container = ?').get(container) as
|
|
301
|
+
{ name: string | null; volumes: string | null } | undefined;
|
|
302
|
+
|
|
303
|
+
const rows = db
|
|
304
|
+
.prepare(
|
|
305
|
+
`SELECT (ts / CAST(@bucket AS INTEGER)) * CAST(@bucket AS INTEGER) AS ts,
|
|
306
|
+
AVG(cpu_cores) AS cpu_avg, MAX(cpu_cores) AS cpu_max, AVG(mem_mb) AS mem_avg, MAX(mem_mb) AS mem_max
|
|
307
|
+
FROM container_metrics WHERE container = @container AND ts > @since
|
|
308
|
+
GROUP BY ts / CAST(@bucket AS INTEGER) ORDER BY ts`,
|
|
309
|
+
)
|
|
310
|
+
.all({ bucket, container, since }) as { ts: number; cpu_avg: number | null; cpu_max: number | null; mem_avg: number | null; mem_max: number | null }[];
|
|
311
|
+
|
|
312
|
+
const volumes = volumesOf(source?.volumes ?? null);
|
|
313
|
+
const placeholders = volumes.map(() => '?').join(', ');
|
|
314
|
+
// Volumes and the writable layer, summed per reading, then averaged per bucket.
|
|
315
|
+
const storageRows = db
|
|
316
|
+
.prepare(
|
|
317
|
+
`SELECT (ts / CAST(? AS INTEGER)) * CAST(? AS INTEGER) AS ts, AVG(total) AS total_mb FROM (
|
|
318
|
+
SELECT ts, SUM(size_mb) AS total FROM docker_storage
|
|
319
|
+
WHERE ts > ? AND ((kind = 'layer' AND name = ?)${volumes.length ? ` OR (kind = 'volume' AND name IN (${placeholders}))` : ''})
|
|
320
|
+
GROUP BY ts
|
|
321
|
+
) GROUP BY ts / CAST(? AS INTEGER) ORDER BY ts`,
|
|
322
|
+
)
|
|
323
|
+
.all(storageBucket, storageBucket, since, container, ...volumes, storageBucket) as { ts: number; total_mb: number | null }[];
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
host,
|
|
327
|
+
container,
|
|
328
|
+
name: source?.name ?? null,
|
|
329
|
+
bucket_s: bucket,
|
|
330
|
+
history: rows.map((r) => ({
|
|
331
|
+
ts: r.ts,
|
|
332
|
+
cpu_avg: pct(r.cpu_avg, host?.ncpu, 2),
|
|
333
|
+
cpu_max: pct(r.cpu_max, host?.ncpu, 2),
|
|
334
|
+
mem_avg: r.mem_avg === null ? null : Math.round(r.mem_avg),
|
|
335
|
+
mem_max: r.mem_max,
|
|
336
|
+
})),
|
|
337
|
+
storage_bucket_s: storageBucket,
|
|
338
|
+
storage_history: storageRows.map((r) => ({ ts: r.ts, total_mb: r.total_mb === null ? null : Math.round(r.total_mb) })),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The check against the bill: what each vendor charged, next to what the agents' OpenClaw
|
|
3
|
+
* priced, over the same interval. daemon.js writes `vendor_balance` rows in costs.db, each
|
|
4
|
+
* holding the vendor's figure and the agents' cumulative priced total for that vendor's
|
|
5
|
+
* models, read at the same moment. Between two rows:
|
|
6
|
+
*
|
|
7
|
+
* - billed — DeepSeek: the balance drops (a rise is a top-up, counted apart);
|
|
8
|
+
* OpenRouter: the growth of its lifetime usage.
|
|
9
|
+
* - priced — the growth of the agents' total.
|
|
10
|
+
*
|
|
11
|
+
* A difference is expected when the same key serves more than the agents (the assistant,
|
|
12
|
+
* clients outside Rev4a), when a vendor bills late, and from DeepSeek's balance being
|
|
13
|
+
* given to the cent. Nothing here corrects either side.
|
|
14
|
+
*/
|
|
15
|
+
import type Database from 'better-sqlite3';
|
|
16
|
+
|
|
17
|
+
export interface Reconciliation {
|
|
18
|
+
vendor: string;
|
|
19
|
+
currency: string | null;
|
|
20
|
+
/** Unix ms of the two readings compared; null when there are not two yet. */
|
|
21
|
+
from: number | null;
|
|
22
|
+
to: number | null;
|
|
23
|
+
billed: number | null;
|
|
24
|
+
priced: number | null;
|
|
25
|
+
/** Balance rises between the readings (DeepSeek): money added, not spent. */
|
|
26
|
+
topUps: number;
|
|
27
|
+
readings: number;
|
|
28
|
+
/** False when the vendor's figure is in another currency than the agents' dollars. */
|
|
29
|
+
comparable: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Row { ts: number; vendor: string; currency: string | null; balance: number | null; usage: number | null; agents_cost: number }
|
|
33
|
+
|
|
34
|
+
const round = (n: number) => Math.round(n * 1e6) / 1e6;
|
|
35
|
+
|
|
36
|
+
export function readReconciliation(db: Database.Database, startDate: string, endDate: string): Reconciliation[] {
|
|
37
|
+
const hasTable = db
|
|
38
|
+
.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = 'vendor_balance'")
|
|
39
|
+
.get() as { n: number };
|
|
40
|
+
if (!hasTable.n) return [];
|
|
41
|
+
const start = Date.parse(`${startDate}T00:00:00Z`);
|
|
42
|
+
const end = Date.parse(`${endDate}T00:00:00Z`) + 86_400_000;
|
|
43
|
+
const vendors = (db.prepare('SELECT DISTINCT vendor FROM vendor_balance ORDER BY vendor').all() as { vendor: string }[]).map((v) => v.vendor);
|
|
44
|
+
|
|
45
|
+
return vendors.map((vendor) => {
|
|
46
|
+
// The reading at or just before the start of the range, else the first inside it.
|
|
47
|
+
const before = db.prepare('SELECT * FROM vendor_balance WHERE vendor = ? AND ts <= ? ORDER BY ts DESC LIMIT 1').get(vendor, start) as Row | undefined;
|
|
48
|
+
const inside = db.prepare('SELECT * FROM vendor_balance WHERE vendor = ? AND ts > ? AND ts < ? ORDER BY ts').all(vendor, start, end) as Row[];
|
|
49
|
+
const rows = before ? [before, ...inside] : inside;
|
|
50
|
+
const currency = rows[rows.length - 1]?.currency ?? null;
|
|
51
|
+
const empty: Reconciliation = {
|
|
52
|
+
vendor, currency, from: rows[0]?.ts ?? null, to: null, billed: null, priced: null, topUps: 0,
|
|
53
|
+
readings: rows.length, comparable: currency === 'USD',
|
|
54
|
+
};
|
|
55
|
+
if (rows.length < 2) return empty;
|
|
56
|
+
|
|
57
|
+
const a = rows[0];
|
|
58
|
+
const b = rows[rows.length - 1];
|
|
59
|
+
let billed = 0;
|
|
60
|
+
let topUps = 0;
|
|
61
|
+
if (vendor === 'openrouter') {
|
|
62
|
+
billed = (b.usage ?? 0) - (a.usage ?? 0);
|
|
63
|
+
} else {
|
|
64
|
+
for (let i = 1; i < rows.length; i++) {
|
|
65
|
+
const delta = (rows[i - 1].balance ?? 0) - (rows[i].balance ?? 0);
|
|
66
|
+
if (delta >= 0) billed += delta;
|
|
67
|
+
else topUps += -delta;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
...empty,
|
|
72
|
+
to: b.ts,
|
|
73
|
+
billed: round(billed),
|
|
74
|
+
priced: round(b.agents_cost - a.agents_cost),
|
|
75
|
+
topUps: round(topUps),
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
}
|
package/lib/costs-db.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Read access to the agent costs database (daemon.js writes it).
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import Database from 'better-sqlite3';
|
|
5
|
+
import { DB_PATH } from './db';
|
|
6
|
+
|
|
7
|
+
/** Next to events.db, as daemon.js places it; its own file, like metrics.db. */
|
|
8
|
+
export const COSTS_DB_PATH = path.join(path.dirname(DB_PATH), 'costs.db');
|
|
9
|
+
|
|
10
|
+
const TABLES = ['agent_cost_daily', 'agent_cost_model_daily', 'agent_cost_sessions', 'agent_cost_sources'];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Open the costs database read-only, or null when there is nothing to read yet: no file
|
|
14
|
+
* (first start, or a daemon that never ran) or one without the daemon's tables. The
|
|
15
|
+
* daemon owns the schema.
|
|
16
|
+
*/
|
|
17
|
+
export function openCostsDb(): Database.Database | null {
|
|
18
|
+
if (!fs.existsSync(COSTS_DB_PATH)) return null;
|
|
19
|
+
const db = new Database(COSTS_DB_PATH, { readonly: true, fileMustExist: true });
|
|
20
|
+
try {
|
|
21
|
+
const row = db
|
|
22
|
+
.prepare(`SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name IN (${TABLES.map(() => '?').join(', ')})`)
|
|
23
|
+
.get(...TABLES) as { n: number };
|
|
24
|
+
if (row.n === TABLES.length) return db;
|
|
25
|
+
} catch (e) {
|
|
26
|
+
db.close();
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
db.close();
|
|
30
|
+
return null;
|
|
31
|
+
}
|