aimeat 3.3.0 → 3.3.2

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 (141) hide show
  1. package/README.md +1 -1
  2. package/dist/.env.example +10 -1
  3. package/dist/build-stamp.json +4 -4
  4. package/dist/locales/en.json +9263 -9225
  5. package/dist/locales/es.json +9255 -9217
  6. package/dist/locales/fi.json +9263 -9225
  7. package/dist/public/css/views/admin.css +935 -860
  8. package/dist/public/css/views/chat.css +393 -388
  9. package/dist/public/js/services/admin.js +12 -0
  10. package/dist/public/robots.node.txt +4 -0
  11. package/dist/public/views/admin/config-tab.js +333 -185
  12. package/dist/public/views/admin/usage-tab.js +373 -307
  13. package/dist/public/views/chat/parts.js +25 -6
  14. package/dist/public/views/profile/landing-page.cards.js +732 -700
  15. package/dist/public/views/profile/landing-page.js +6 -2
  16. package/dist/scripts/audit-mcp-schemas.js +22 -6
  17. package/dist/scripts/audit-mcp-schemas.js.map +1 -1
  18. package/dist/scripts/check-ai-disclosure.d.ts +1 -1
  19. package/dist/scripts/check-ai-disclosure.d.ts.map +1 -1
  20. package/dist/scripts/check-ai-disclosure.js +19 -3
  21. package/dist/scripts/check-ai-disclosure.js.map +1 -1
  22. package/dist/scripts/check-config-coverage.d.ts +2 -0
  23. package/dist/scripts/check-config-coverage.d.ts.map +1 -0
  24. package/dist/scripts/check-config-coverage.js +129 -0
  25. package/dist/scripts/check-config-coverage.js.map +1 -0
  26. package/dist/src/auth/ai-gate.d.ts +25 -0
  27. package/dist/src/auth/ai-gate.d.ts.map +1 -0
  28. package/dist/src/auth/ai-gate.js +21 -0
  29. package/dist/src/auth/ai-gate.js.map +1 -0
  30. package/dist/src/cli/connect/mcp/tools/apps.d.ts.map +1 -1
  31. package/dist/src/cli/connect/mcp/tools/apps.js +102 -20
  32. package/dist/src/cli/connect/mcp/tools/apps.js.map +1 -1
  33. package/dist/src/cli/connect/tool-call-defs-agent.d.ts.map +1 -1
  34. package/dist/src/cli/connect/tool-call-defs-agent.js +38 -9
  35. package/dist/src/cli/connect/tool-call-defs-agent.js.map +1 -1
  36. package/dist/src/cli/connect/tool-call-defs-apps.d.ts.map +1 -1
  37. package/dist/src/cli/connect/tool-call-defs-apps.js +199 -103
  38. package/dist/src/cli/connect/tool-call-defs-apps.js.map +1 -1
  39. package/dist/src/cli/connect/tool-call-defs-core.d.ts.map +1 -1
  40. package/dist/src/cli/connect/tool-call-defs-core.js +149 -26
  41. package/dist/src/cli/connect/tool-call-defs-core.js.map +1 -1
  42. package/dist/src/cli/connect/tool-call-defs-organism.d.ts.map +1 -1
  43. package/dist/src/cli/connect/tool-call-defs-organism.js +20 -3
  44. package/dist/src/cli/connect/tool-call-defs-organism.js.map +1 -1
  45. package/dist/src/cli/connect/tool-call-defs-packages.d.ts +23 -0
  46. package/dist/src/cli/connect/tool-call-defs-packages.d.ts.map +1 -0
  47. package/dist/src/cli/connect/tool-call-defs-packages.js +45 -0
  48. package/dist/src/cli/connect/tool-call-defs-packages.js.map +1 -0
  49. package/dist/src/cli/connect/tool-call-defs-workflows.d.ts +14 -0
  50. package/dist/src/cli/connect/tool-call-defs-workflows.d.ts.map +1 -0
  51. package/dist/src/cli/connect/tool-call-defs-workflows.js +75 -0
  52. package/dist/src/cli/connect/tool-call-defs-workflows.js.map +1 -0
  53. package/dist/src/cli/connect/tool-call.d.ts.map +1 -1
  54. package/dist/src/cli/connect/tool-call.js +54 -1
  55. package/dist/src/cli/connect/tool-call.js.map +1 -1
  56. package/dist/src/config-types-ai.d.ts +13 -0
  57. package/dist/src/config-types-ai.d.ts.map +1 -1
  58. package/dist/src/config.d.ts.map +1 -1
  59. package/dist/src/config.js +2 -0
  60. package/dist/src/config.js.map +1 -1
  61. package/dist/src/generated/api-types.d.ts +196 -0
  62. package/dist/src/generated/api-types.d.ts.map +1 -1
  63. package/dist/src/mcp/annotations.d.ts.map +1 -1
  64. package/dist/src/mcp/annotations.js +8 -0
  65. package/dist/src/mcp/annotations.js.map +1 -1
  66. package/dist/src/mcp/catalog/definitions/organisms-workspaces-apps.d.ts.map +1 -1
  67. package/dist/src/mcp/catalog/definitions/organisms-workspaces-apps.js +25 -8
  68. package/dist/src/mcp/catalog/definitions/organisms-workspaces-apps.js.map +1 -1
  69. package/dist/src/mcp/catalog/definitions/packages.d.ts +17 -0
  70. package/dist/src/mcp/catalog/definitions/packages.d.ts.map +1 -0
  71. package/dist/src/mcp/catalog/definitions/packages.js +46 -0
  72. package/dist/src/mcp/catalog/definitions/packages.js.map +1 -0
  73. package/dist/src/mcp/catalog/definitions.d.ts.map +1 -1
  74. package/dist/src/mcp/catalog/definitions.js +2 -0
  75. package/dist/src/mcp/catalog/definitions.js.map +1 -1
  76. package/dist/src/mcp/catalog/scopes.d.ts.map +1 -1
  77. package/dist/src/mcp/catalog/scopes.js +2 -0
  78. package/dist/src/mcp/catalog/scopes.js.map +1 -1
  79. package/dist/src/mcp/catalog/surfaces.d.ts.map +1 -1
  80. package/dist/src/mcp/catalog/surfaces.js +2 -0
  81. package/dist/src/mcp/catalog/surfaces.js.map +1 -1
  82. package/dist/src/routes/admin-usage.d.ts.map +1 -1
  83. package/dist/src/routes/admin-usage.js +57 -0
  84. package/dist/src/routes/admin-usage.js.map +1 -1
  85. package/dist/src/routes/ai.d.ts.map +1 -1
  86. package/dist/src/routes/ai.js +4 -15
  87. package/dist/src/routes/ai.js.map +1 -1
  88. package/dist/src/routes/bootstrap.d.ts +2 -1
  89. package/dist/src/routes/bootstrap.d.ts.map +1 -1
  90. package/dist/src/routes/bootstrap.js +7 -18
  91. package/dist/src/routes/bootstrap.js.map +1 -1
  92. package/dist/src/routes/chat.d.ts +3 -0
  93. package/dist/src/routes/chat.d.ts.map +1 -1
  94. package/dist/src/routes/chat.js +20 -0
  95. package/dist/src/routes/chat.js.map +1 -1
  96. package/dist/src/routes/llm-proxy.d.ts +34 -0
  97. package/dist/src/routes/llm-proxy.d.ts.map +1 -0
  98. package/dist/src/routes/llm-proxy.js +258 -0
  99. package/dist/src/routes/llm-proxy.js.map +1 -0
  100. package/dist/src/routes/sitemaps.d.ts +22 -0
  101. package/dist/src/routes/sitemaps.d.ts.map +1 -0
  102. package/dist/src/routes/sitemaps.js +67 -0
  103. package/dist/src/routes/sitemaps.js.map +1 -0
  104. package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
  105. package/dist/src/server-bootstrap/routes-loader.js +2 -0
  106. package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
  107. package/dist/src/services/ai-completion.d.ts +76 -0
  108. package/dist/src/services/ai-completion.d.ts.map +1 -1
  109. package/dist/src/services/ai-completion.js +104 -68
  110. package/dist/src/services/ai-completion.js.map +1 -1
  111. package/dist/src/services/chat-session.d.ts +3 -0
  112. package/dist/src/services/chat-session.d.ts.map +1 -1
  113. package/dist/src/services/chat-session.js +4 -0
  114. package/dist/src/services/chat-session.js.map +1 -1
  115. package/dist/src/services/config-schema.d.ts +4 -0
  116. package/dist/src/services/config-schema.d.ts.map +1 -1
  117. package/dist/src/services/config-schema.js +33 -0
  118. package/dist/src/services/config-schema.js.map +1 -1
  119. package/dist/src/services/goose-acp.d.ts.map +1 -1
  120. package/dist/src/services/goose-acp.js +11 -0
  121. package/dist/src/services/goose-acp.js.map +1 -1
  122. package/dist/src/services/handbooks/appdev.d.ts +1 -1
  123. package/dist/src/services/handbooks/appdev.d.ts.map +1 -1
  124. package/dist/src/services/handbooks/appdev.js +7 -1
  125. package/dist/src/services/handbooks/appdev.js.map +1 -1
  126. package/dist/src/services/openrouter.d.ts +23 -0
  127. package/dist/src/services/openrouter.d.ts.map +1 -1
  128. package/dist/src/services/openrouter.js +32 -1
  129. package/dist/src/services/openrouter.js.map +1 -1
  130. package/dist/src/services/usage-summary.d.ts +15 -0
  131. package/dist/src/services/usage-summary.d.ts.map +1 -1
  132. package/dist/src/services/usage-summary.js +15 -0
  133. package/dist/src/services/usage-summary.js.map +1 -1
  134. package/dist/src/storage/providers/postgres-kysely/methods/wallet.d.ts.map +1 -1
  135. package/dist/src/storage/providers/postgres-kysely/methods/wallet.js +23 -4
  136. package/dist/src/storage/providers/postgres-kysely/methods/wallet.js.map +1 -1
  137. package/dist/src/storage/providers/sqlite/methods/work.d.ts +5 -0
  138. package/dist/src/storage/providers/sqlite/methods/work.d.ts.map +1 -1
  139. package/dist/src/storage/providers/sqlite/methods/work.js +34 -22
  140. package/dist/src/storage/providers/sqlite/methods/work.js.map +1 -1
  141. package/package.json +4 -1
@@ -1,307 +1,373 @@
1
- /**
2
- * @file usage-tab.js
3
- * @description Admin dashboard "Usage" tab — the unified operator-wide view of node LLM usage. It
4
- * renders TWO clearly-labeled, never-summed sections, each a different system counting different
5
- * call paths:
6
- * 1. "Agent LLM ledger" (GET /v1/admin/ledger) — priced agent LLM calls: totals, per-day cost
7
- * chart, a per-user "top spenders" table (click a user to drill the per-agent table), a
8
- * per-model table, and a per-agent table.
9
- * 2. "AI apps spend" (GET /v1/admin/ai-usage) — AI-apps token/cost: node totals, a per-app
10
- * stacked daily bar (cost⇄tokens toggle), a by-app table, and a per-user top-spenders table.
11
- * Both share one time-range control. The two figures are NEVER combined into a grand total.
12
- * @structure
13
- * - getDateRange(period) — preset key → { from, to } date strings
14
- * - UsageTab (default) — fetches both aggregates and renders the two sections
15
- * @usage Registered in views/admin.js NAV_GROUPS (replaces the old ai-usage entry); rendered with
16
- * the shared admin tab props.
17
- * @version-history
18
- * v1.1.0 — 2026-08-14 — Third section: the CALL stream (surfaces, tools, apps, refusals) from
19
- * GET /v1/admin/usage/summary. It counts invocations, never spend, so it is never summed with
20
- * the two above. Body in usage-tab.calls.js.
21
- * v1.0.0 — 2026-07-11 — Initial: unified operator usage tab (agent LLM ledger + AI apps spend),
22
- * with per-user drill-down on the ledger's top-spenders table.
23
- */
24
- import { h } from 'preact';
25
- import { useState, useEffect, useCallback } from 'preact/hooks';
26
- import htm from 'htm';
27
- const html = htm.bind(h);
28
- import { t } from '/js/i18n.js';
29
- import { num, StatsGrid, DataTable, Spinner, Empty } from './shared.js';
30
- import { UsageChart, colorForIndex } from '/components/UsageChart.js';
31
- import * as api from '/js/services/admin.js';
32
- import { swallowed } from '/js/swallowed.js';
33
- import { CallsSection } from './usage-tab.calls.js';
34
-
35
- const usd = (n) => { const v = Number(n) || 0; return '$' + (v < 1 ? v.toFixed(4) : v.toFixed(2)); };
36
- function fmtCompact(n) {
37
- const v = Number(n) || 0;
38
- if (v < 1000) return String(Math.round(v));
39
- if (v < 1_000_000) return (v / 1000).toFixed(v < 10_000 ? 1 : 0) + 'k';
40
- return (v / 1_000_000).toFixed(1) + 'M';
41
- }
42
-
43
- /** Preset key → inclusive { from, to } date strings (UTC). 'all' reaches back far enough for everything. */
44
- function getDateRange(period) {
45
- const today = new Date();
46
- const fmt = (d) => d.toISOString().split('T')[0];
47
- const to = fmt(today);
48
- const back = (n) => { const d = new Date(today); d.setDate(d.getDate() - n); return fmt(d); };
49
- switch (period) {
50
- case '7d': return { from: back(6), to };
51
- case '30d': return { from: back(29), to };
52
- case '90d': return { from: back(89), to };
53
- case 'all': return { from: '2000-01-01', to };
54
- default: return { from: back(29), to };
55
- }
56
- }
57
-
58
- export default function UsageTab() {
59
- const [period, setPeriod] = useState('30d');
60
- const [metric, setMetric] = useState('cost');
61
- const [ledger, setLedger] = useState(null);
62
- const [aiUsage, setAiUsage] = useState(null);
63
- const [loading, setLoading] = useState(true);
64
- const [selectedUser, setSelectedUser] = useState(null);
65
- const [calls, setCalls] = useState(null);
66
-
67
- const load = useCallback(async (p) => {
68
- setLoading(true);
69
- try {
70
- const range = getDateRange(p);
71
- const quiet = (err) => { swallowed('usage-tab: UsageTab', err); return null; };
72
- // Five reads in parallel rather than in sequence: they are independent, and the tab is only as
73
- // fast as its slowest one either way.
74
- const [lr, ar, cs, ct, ca] = await Promise.all([
75
- api.getLedger(range.from, range.to).catch(quiet),
76
- api.getAiUsage(range.from, range.to).catch(quiet),
77
- api.getUsageSummary('surface', range.from, range.to).catch(quiet),
78
- api.getUsageSummary('tool', range.from, range.to).catch(quiet),
79
- api.getUsageSummary('apps-used', range.from, range.to).catch(quiet),
80
- ]);
81
- if (lr && lr.data) setLedger(lr.data);
82
- if (ar && ar.data) setAiUsage(ar.data);
83
- setCalls({ surface: cs?.data ?? null, tool: ct?.data ?? null, apps: ca?.data ?? null });
84
- } catch (err) { swallowed('usage-tab: UsageTab', err); }
85
- setLoading(false);
86
- }, []);
87
-
88
- useEffect(() => { load(period); }, [period, load]);
89
-
90
- // Silent background refresh on live updates (no spinner flash).
91
- useEffect(() => {
92
- const handler = () => { load(period); };
93
- window.addEventListener('aimeat-live-update', handler);
94
- return () => window.removeEventListener('aimeat-live-update', handler);
95
- }, [period, load]);
96
-
97
- const presets = [
98
- { key: '7d', label: t('dashboard.period7Days') || '7 days' },
99
- { key: '30d', label: t('dashboard.period30Days') || '30 days' },
100
- { key: '90d', label: t('dashboard.period90Days') || '90 days' },
101
- { key: 'all', label: t('dashboard.periodAll') || 'All' },
102
- ];
103
-
104
- const timeRange = html`
105
- <div class="adm-time-range">
106
- <span class="adm-time-range-label">${t('dashboard.periodLabel') || 'Period'}:</span>
107
- ${presets.map((p) => html`
108
- <button class="adm-time-btn ${period === p.key ? 'active' : ''}" onClick=${() => setPeriod(p.key)}>${p.label}</button>
109
- `)}
110
- <span class="adm-time-custom">
111
- <button class="adm-time-btn ${metric === 'cost' ? 'active' : ''}" onClick=${() => setMetric('cost')}>${t('dashboard.aiMetricCost') || 'Cost'}</button>
112
- <button class="adm-time-btn ${metric === 'tokens' ? 'active' : ''}" onClick=${() => setMetric('tokens')}>${t('dashboard.aiMetricTokens') || 'Tokens'}</button>
113
- </span>
114
- </div>`;
115
-
116
- if (!ledger && !aiUsage && !calls) {
117
- return html`<div>${timeRange}${loading ? html`<${Spinner} />` : html`<${Empty} text=${t('dashboard.aiUsageEmpty') || 'No AI usage yet.'} />`}</div>`;
118
- }
119
-
120
- return html`
121
- <div>
122
- ${timeRange}
123
- ${renderLedgerSection(ledger, metric, selectedUser, setSelectedUser)}
124
- <hr class="adm-mt-lg" />
125
- ${renderAiAppsSection(aiUsage, metric)}
126
- <hr class="adm-mt-lg" />
127
- <${CallsSection} data=${calls} />
128
- </div>`;
129
- }
130
-
131
- /* ── Section 1: Agent LLM ledger (priced agent LLM calls) ── */
132
- function renderLedgerSection(data, metric, selectedUser, setSelectedUser) {
133
- const heading = html`<h2 class="adm-section-cyan">${t('dashboard.ledgerSection') || 'Agent LLM ledger'}</h2>`;
134
- if (!data || !data.totals || (data.totals.calls || 0) === 0) {
135
- return html`<div>${heading}<${Empty} text=${t('dashboard.ledgerEmpty') || 'No agent LLM usage yet.'} /></div>`;
136
- }
137
-
138
- const totals = data.totals || {};
139
- const days = Array.isArray(data.days) ? data.days : [];
140
- const perUser = Array.isArray(data.per_user) ? data.per_user : [];
141
- const perModel = Array.isArray(data.per_model) ? data.per_model : [];
142
- const perAgent = Array.isArray(data.per_agent) ? data.per_agent : [];
143
- const isTokens = metric === 'tokens';
144
-
145
- const statItems = [
146
- { label: t('dashboard.ledgerTotalCost') || 'Total cost', value: usd(totals.cost_usd), tone: 'green' },
147
- { label: t('dashboard.ledgerTotalTokens') || 'Tokens', value: totals.total_tokens || 0, tone: 'cyan' },
148
- { label: t('dashboard.ledgerTotalCalls') || 'LLM calls', value: totals.calls || 0, tone: 'blue' },
149
- { label: t('dashboard.ledgerUnpriced') || 'Unpriced calls', value: totals.unpriced_calls || 0, tone: 'amber' },
150
- ];
151
-
152
- // Stacked per-day series by model (like the AI-apps per-app chart), ordered by the top-level
153
- // per_model (cost desc) so the legend/series order is stable.
154
- const labels = days.map((d) => d.date.slice(5));
155
- const models = perModel.map((m) => m.model);
156
- const datasets = models.map((model, i) => ({
157
- label: model,
158
- data: days.map((d) => {
159
- const dm = (d.per_model && d.per_model[model]) || {};
160
- return (isTokens ? dm.total_tokens : dm.cost_usd) || 0;
161
- }),
162
- backgroundColor: colorForIndex(i),
163
- }));
164
- const yFormat = isTokens ? (v) => fmtCompact(v) : usd;
165
-
166
- // Per-user "top spenders" — the owner cell is a clickable toggle that drills the per-agent table.
167
- const userRows = perUser.map((u) => {
168
- const on = selectedUser === u.owner_ghii;
169
- return [
170
- html`<button class=${'adm-time-btn ' + (on ? 'active' : '')}
171
- onClick=${() => setSelectedUser(on ? null : u.owner_ghii)}>${u.owner_ghii}</button>`,
172
- { text: num(u.agents || 0), mono: true },
173
- { text: usd(u.cost_usd), mono: true },
174
- { text: num(u.total_tokens || 0), mono: true },
175
- { text: num(u.calls || 0), mono: true },
176
- { text: num(u.unpriced_calls || 0), mono: true },
177
- ];
178
- });
179
-
180
- const modelRows = perModel.map((m) => [
181
- { text: m.model, mono: true },
182
- { text: (m.providers && m.providers.length) ? m.providers.join(', ') : '—', mono: true },
183
- { text: usd(m.cost_usd), mono: true },
184
- { text: num(m.total_tokens || 0), mono: true },
185
- { text: num(m.calls || 0), mono: true },
186
- { text: num(m.unpriced_calls || 0), mono: true },
187
- ]);
188
-
189
- const shownAgents = selectedUser ? perAgent.filter((a) => a.owner_ghii === selectedUser) : perAgent;
190
- const agentRows = shownAgents.map((a) => [
191
- { text: a.agent_gaii, mono: true },
192
- { text: a.owner_ghii, mono: true },
193
- { text: usd(a.cost_usd), mono: true },
194
- { text: num(a.total_tokens || 0), mono: true },
195
- { text: num(a.calls || 0), mono: true },
196
- { text: num(a.unpriced_calls || 0), mono: true },
197
- ]);
198
-
199
- return html`
200
- <div>
201
- ${heading}
202
- <${StatsGrid} items=${statItems} />
203
- ${datasets.some((ds) => ds.data.some((v) => v > 0))
204
- ? html`<div class="adm-card"><h2>${t('dashboard.ledgerPerDay') || 'Cost over time'}</h2>
205
- <${UsageChart} stacked labels=${labels} datasets=${datasets} height=${240} yFormat=${yFormat} /></div>`
206
- : null}
207
- <h3 class="adm-mt-lg adm-text-sm adm-section-purple">${t('dashboard.ledgerTopSpenders') || 'Top spenders'}</h3>
208
- <${DataTable}
209
- headers=${[
210
- t('dashboard.ledgerColUser') || 'Owner',
211
- t('dashboard.ledgerColAgents') || 'Agents',
212
- t('dashboard.ledgerColCost') || 'Cost',
213
- t('dashboard.ledgerColTokens') || 'Tokens',
214
- t('dashboard.ledgerColCalls') || 'Calls',
215
- t('dashboard.ledgerColUnpriced') || 'Unpriced',
216
- ]}
217
- rows=${userRows} scroll=${true} />
218
- <h3 class="adm-mt-lg adm-text-sm adm-section-cyan">${t('dashboard.ledgerByModel') || 'By model'}</h3>
219
- <${DataTable}
220
- headers=${[
221
- t('dashboard.ledgerColModel') || 'Model',
222
- t('dashboard.ledgerColProvider') || 'Provider',
223
- t('dashboard.ledgerColCost') || 'Cost',
224
- t('dashboard.ledgerColTokens') || 'Tokens',
225
- t('dashboard.ledgerColCalls') || 'Calls',
226
- t('dashboard.ledgerColUnpriced') || 'Unpriced',
227
- ]}
228
- rows=${modelRows} scroll=${true} />
229
- <h3 class="adm-mt-lg adm-text-sm adm-section-purple">
230
- ${t('dashboard.ledgerByAgent') || 'By agent'}
231
- ${selectedUser ? html` — <span class="adm-text-sm">${t('dashboard.ledgerFilteredNote') || 'Showing agents for'} ${selectedUser}</span>
232
- <button class="adm-time-btn" onClick=${() => setSelectedUser(null)}>${t('dashboard.ledgerAllUsers') || 'All users'}</button>` : null}
233
- </h3>
234
- <${DataTable}
235
- headers=${[
236
- t('dashboard.ledgerColAgent') || 'Agent',
237
- t('dashboard.ledgerColUser') || 'Owner',
238
- t('dashboard.ledgerColCost') || 'Cost',
239
- t('dashboard.ledgerColTokens') || 'Tokens',
240
- t('dashboard.ledgerColCalls') || 'Calls',
241
- t('dashboard.ledgerColUnpriced') || 'Unpriced',
242
- ]}
243
- rows=${agentRows} scroll=${true} />
244
- </div>`;
245
- }
246
-
247
- /* ── Section 2: AI apps spend (per-app token/cost; distinct system, never summed with the ledger) ── */
248
- function renderAiAppsSection(data, metric) {
249
- const heading = html`<h2 class="adm-section-purple">${t('dashboard.ledgerAiAppsSection') || 'AI apps spend'}</h2>`;
250
- if (!data) {
251
- return html`<div>${heading}<${Empty} text=${t('dashboard.aiUsageEmpty') || 'No AI usage yet.'} /></div>`;
252
- }
253
-
254
- const days = Array.isArray(data.days) ? data.days : [];
255
- const apps = data.apps || [];
256
- const totals = data.totals || { cost_usd: 0, tokens: 0, calls: 0 };
257
- const isTokens = metric === 'tokens';
258
-
259
- const labels = days.map((d) => d.date.slice(5));
260
- const datasets = apps.map((app, i) => ({
261
- label: app,
262
- data: days.map((d) => {
263
- const m = (d.per_app && d.per_app[app]) || {};
264
- return (isTokens ? m.tokens : m.cost_usd) || 0;
265
- }),
266
- backgroundColor: colorForIndex(i),
267
- }));
268
- const yFormat = isTokens ? (v) => fmtCompact(v) : usd;
269
-
270
- const totalCost = totals.cost_usd || 0;
271
- const appRows = apps.map((app) => {
272
- const m = (data.per_app && data.per_app[app]) || { cost_usd: 0, tokens: 0, calls: 0 };
273
- const share = totalCost > 0 ? Math.round((m.cost_usd / totalCost) * 100) : 0;
274
- return [app, { text: usd(m.cost_usd), mono: true }, { text: num(m.tokens || 0), mono: true }, { text: num(m.calls || 0), mono: true }, { text: share + '%', mono: true }];
275
- });
276
- const userRows = (data.per_user || []).map((u) => [
277
- { text: u.owner_gaii, mono: true },
278
- { text: usd(u.cost_usd), mono: true },
279
- { text: num(u.tokens || 0), mono: true },
280
- { text: num(u.calls || 0), mono: true },
281
- ]);
282
-
283
- const statItems = [
284
- { label: t('dashboard.aiTotalSpend') || 'Total spend', value: usd(totals.cost_usd), tone: 'green' },
285
- { label: t('dashboard.aiTotalTokens') || 'Tokens', value: totals.tokens || 0, tone: 'cyan' },
286
- { label: t('dashboard.aiTotalCalls') || 'Calls', value: totals.calls || 0, tone: 'blue' },
287
- { label: t('dashboard.aiActiveApps') || 'Apps', value: apps.length, tone: 'purple' },
288
- ];
289
-
290
- return html`
291
- <div>
292
- ${heading}
293
- <${StatsGrid} items=${statItems} />
294
- ${datasets.length > 0
295
- ? html`<div class="adm-card"><h2>${t('dashboard.aiPerAppOverTime') || 'Per-app spend over time'}</h2>
296
- <${UsageChart} stacked labels=${labels} datasets=${datasets} height=${260} yFormat=${yFormat} /></div>`
297
- : html`<${Empty} text=${t('dashboard.aiUsageEmpty') || 'No AI usage yet.'} />`}
298
- <h3 class="adm-mt-lg adm-text-sm adm-section-cyan">${t('dashboard.aiByApp') || 'By app'}</h3>
299
- <${DataTable}
300
- headers=${[t('dashboard.aiApp') || 'App', t('dashboard.aiTotalSpend') || 'Spend', t('dashboard.aiTotalTokens') || 'Tokens', t('dashboard.aiTotalCalls') || 'Calls', t('dashboard.aiShare') || 'Share']}
301
- rows=${appRows} scroll=${true} />
302
- <h3 class="adm-mt-lg adm-text-sm adm-section-purple">${t('dashboard.aiByUser') || 'Top spenders'}</h3>
303
- <${DataTable}
304
- headers=${[t('dashboard.aiUser') || 'User', t('dashboard.aiTotalSpend') || 'Spend', t('dashboard.aiTotalTokens') || 'Tokens', t('dashboard.aiTotalCalls') || 'Calls']}
305
- rows=${userRows} scroll=${true} />
306
- </div>`;
307
- }
1
+ /**
2
+ * @file usage-tab.js
3
+ * @description Admin dashboard "Usage" tab — the unified operator-wide view of node LLM usage. It
4
+ * renders TWO clearly-labeled, never-summed sections, each a different system counting different
5
+ * call paths:
6
+ * 1. "Agent LLM ledger" (GET /v1/admin/ledger) — priced agent LLM calls: totals, per-day cost
7
+ * chart, a per-user "top spenders" table (click a user to drill the per-agent table), a
8
+ * per-model table, and a per-agent table.
9
+ * 2. "AI apps spend" (GET /v1/admin/ai-usage) — AI-apps token/cost: node totals, a per-app
10
+ * stacked daily bar (cost⇄tokens toggle), a by-app table, and a per-user top-spenders table.
11
+ * Both share one time-range control. The two figures are NEVER combined into a grand total.
12
+ * @structure
13
+ * - getDateRange(period) — preset key → { from, to } date strings
14
+ * - UsageTab (default) — fetches both aggregates and renders the two sections
15
+ * @usage Registered in views/admin.js NAV_GROUPS (replaces the old ai-usage entry); rendered with
16
+ * the shared admin tab props.
17
+ * @version-history
18
+ * v1.1.0 — 2026-08-14 — Third section: the CALL stream (surfaces, tools, apps, refusals) from
19
+ * GET /v1/admin/usage/summary. It counts invocations, never spend, so it is never summed with
20
+ * the two above. Body in usage-tab.calls.js.
21
+ * v1.0.0 — 2026-07-11 — Initial: unified operator usage tab (agent LLM ledger + AI apps spend),
22
+ * with per-user drill-down on the ledger's top-spenders table.
23
+ */
24
+ import { h } from 'preact';
25
+ import { useState, useEffect, useCallback } from 'preact/hooks';
26
+ import htm from 'htm';
27
+ const html = htm.bind(h);
28
+ import { t } from '/js/i18n.js';
29
+ import { num, StatsGrid, DataTable, Spinner, Empty, Badge } from './shared.js';
30
+ import { UsageChart, colorForIndex } from '/components/UsageChart.js';
31
+ import * as api from '/js/services/admin.js';
32
+ import { swallowed } from '/js/swallowed.js';
33
+ import { CallsSection } from './usage-tab.calls.js';
34
+
35
+ const usd = (n) => { const v = Number(n) || 0; return '$' + (v < 1 ? v.toFixed(4) : v.toFixed(2)); };
36
+ function fmtCompact(n) {
37
+ const v = Number(n) || 0;
38
+ if (v < 1000) return String(Math.round(v));
39
+ if (v < 1_000_000) return (v / 1000).toFixed(v < 10_000 ? 1 : 0) + 'k';
40
+ return (v / 1_000_000).toFixed(1) + 'M';
41
+ }
42
+
43
+ /** Preset key → inclusive { from, to } date strings (UTC). 'all' reaches back far enough for everything. */
44
+ function getDateRange(period) {
45
+ const today = new Date();
46
+ const fmt = (d) => d.toISOString().split('T')[0];
47
+ const to = fmt(today);
48
+ const back = (n) => { const d = new Date(today); d.setDate(d.getDate() - n); return fmt(d); };
49
+ switch (period) {
50
+ case '7d': return { from: back(6), to };
51
+ case '30d': return { from: back(29), to };
52
+ case '90d': return { from: back(89), to };
53
+ case 'all': return { from: '2000-01-01', to };
54
+ default: return { from: back(29), to };
55
+ }
56
+ }
57
+
58
+ export default function UsageTab() {
59
+ const [period, setPeriod] = useState('30d');
60
+ const [metric, setMetric] = useState('cost');
61
+ const [ledger, setLedger] = useState(null);
62
+ const [aiUsage, setAiUsage] = useState(null);
63
+ const [house, setHouse] = useState(null);
64
+ const [loading, setLoading] = useState(true);
65
+ const [selectedUser, setSelectedUser] = useState(null);
66
+ const [calls, setCalls] = useState(null);
67
+
68
+ const load = useCallback(async (p) => {
69
+ setLoading(true);
70
+ try {
71
+ const range = getDateRange(p);
72
+ const quiet = (err) => { swallowed('usage-tab: UsageTab', err); return null; };
73
+ // Five reads in parallel rather than in sequence: they are independent, and the tab is only as
74
+ // fast as its slowest one either way.
75
+ const [lr, ar, cs, ct, ca, hs] = await Promise.all([
76
+ api.getLedger(range.from, range.to).catch(quiet),
77
+ api.getAiUsage(range.from, range.to).catch(quiet),
78
+ api.getUsageSummary('surface', range.from, range.to).catch(quiet),
79
+ api.getUsageSummary('tool', range.from, range.to).catch(quiet),
80
+ api.getUsageSummary('apps-used', range.from, range.to).catch(quiet),
81
+ api.getUsageHouse(range.from, range.to).catch(quiet),
82
+ ]);
83
+ if (lr && lr.data) setLedger(lr.data);
84
+ if (ar && ar.data) setAiUsage(ar.data);
85
+ if (hs && hs.data) setHouse(hs.data);
86
+ setCalls({ surface: cs?.data ?? null, tool: ct?.data ?? null, apps: ca?.data ?? null });
87
+ } catch (err) { swallowed('usage-tab: UsageTab', err); }
88
+ setLoading(false);
89
+ }, []);
90
+
91
+ useEffect(() => { load(period); }, [period, load]);
92
+
93
+ // Silent background refresh on live updates (no spinner flash).
94
+ useEffect(() => {
95
+ const handler = () => { load(period); };
96
+ window.addEventListener('aimeat-live-update', handler);
97
+ return () => window.removeEventListener('aimeat-live-update', handler);
98
+ }, [period, load]);
99
+
100
+ const presets = [
101
+ { key: '7d', label: t('dashboard.period7Days') || '7 days' },
102
+ { key: '30d', label: t('dashboard.period30Days') || '30 days' },
103
+ { key: '90d', label: t('dashboard.period90Days') || '90 days' },
104
+ { key: 'all', label: t('dashboard.periodAll') || 'All' },
105
+ ];
106
+
107
+ const timeRange = html`
108
+ <div class="adm-time-range">
109
+ <span class="adm-time-range-label">${t('dashboard.periodLabel') || 'Period'}:</span>
110
+ ${presets.map((p) => html`
111
+ <button class="adm-time-btn ${period === p.key ? 'active' : ''}" onClick=${() => setPeriod(p.key)}>${p.label}</button>
112
+ `)}
113
+ <span class="adm-time-custom">
114
+ <button class="adm-time-btn ${metric === 'cost' ? 'active' : ''}" onClick=${() => setMetric('cost')}>${t('dashboard.aiMetricCost') || 'Cost'}</button>
115
+ <button class="adm-time-btn ${metric === 'tokens' ? 'active' : ''}" onClick=${() => setMetric('tokens')}>${t('dashboard.aiMetricTokens') || 'Tokens'}</button>
116
+ </span>
117
+ </div>`;
118
+
119
+ if (!ledger && !aiUsage && !calls && !house) {
120
+ return html`<div>${timeRange}${loading ? html`<${Spinner} />` : html`<${Empty} text=${t('dashboard.aiUsageEmpty') || 'No AI usage yet.'} />`}</div>`;
121
+ }
122
+
123
+ return html`
124
+ <div>
125
+ ${timeRange}
126
+ ${renderHouseSection(house)}
127
+ <hr class="adm-mt-lg" />
128
+ ${renderLedgerSection(ledger, metric, selectedUser, setSelectedUser)}
129
+ <hr class="adm-mt-lg" />
130
+ ${renderAiAppsSection(aiUsage, metric)}
131
+ <hr class="adm-mt-lg" />
132
+ <${CallsSection} data=${calls} />
133
+ </div>`;
134
+ }
135
+
136
+ /* ── Section 0: the house's money ──
137
+ *
138
+ * Whose key paid, what the house grants, and what is NOT counted anywhere on this page. The last
139
+ * one is the reason this section exists: the chat agent spends a process-wide key outside the
140
+ * metered path, so every figure below it would otherwise read as the whole bill.
141
+ */
142
+ function renderHouseSection(data) {
143
+ const heading = html`<h2 class="adm-section-cyan">${t('dashboard.houseSection')}</h2>`;
144
+ if (!data) return html`<div>${heading}<${Empty} text=${t('dashboard.houseUnavailable')} /></div>`;
145
+
146
+ const node = (data.by_key_scope && data.by_key_scope.node) || {};
147
+ const own = (data.by_key_scope && data.by_key_scope.own) || {};
148
+ const chat = data.chat_agent || {};
149
+
150
+ const statItems = [
151
+ { label: t('dashboard.houseSpend'), value: usd(node.cost_usd), tone: 'amber' },
152
+ { label: t('dashboard.housePeople'), value: num(node.people || 0), tone: 'blue' },
153
+ { label: t('dashboard.houseOwnKeySpend'), value: usd(own.cost_usd), tone: 'green' },
154
+ { label: t('dashboard.houseGrant'), value: usd(data.free_allowance_usd), tone: 'cyan' },
155
+ ];
156
+
157
+ const spenderRows = (data.top_house_spenders || []).map((u) => [
158
+ u.owner_ghii,
159
+ { text: usd(u.cost_usd), mono: true },
160
+ { text: num(u.calls || 0), mono: true },
161
+ ]);
162
+
163
+ return html`
164
+ <div>
165
+ ${heading}
166
+ <p class="adm-text-dim adm-text-xs adm-mb-sm">${t('dashboard.houseExplain')}</p>
167
+ <${StatsGrid} items=${statItems} />
168
+
169
+ <div class="adm-hrow">
170
+ <span class="adm-hmetric">${t('dashboard.houseKey')}</span>
171
+ <span>${data.house_key_configured
172
+ ? html`<${Badge} type="healthy" /> ${t('dashboard.houseKeySet')}`
173
+ : html`<${Badge} type="idle" /> ${t('dashboard.houseKeyUnset')}`}</span>
174
+ </div>
175
+ <div class="adm-hrow">
176
+ <span class="adm-hmetric">${t('dashboard.houseFallback')}</span>
177
+ <span><code>${data.free_fallback_model || t('dashboard.houseFallbackNone')}</code></span>
178
+ </div>
179
+ <div class="adm-hrow">
180
+ <span class="adm-hmetric">${t('dashboard.houseChat')}</span>
181
+ <span>
182
+ ${chat.enabled
183
+ ? html`<${Badge} type="healthy" /> ${chat.model ? html`<code>${chat.model}</code>` : t('dashboard.houseChatNoModel')}`
184
+ : html`<${Badge} type="idle" /> ${t('dashboard.houseChatOff')}`}
185
+ ${chat.enabled && html`<span class="adm-text-dim adm-text-xs"> ${t('dashboard.houseChatUnmetered')}</span>`}
186
+ </span>
187
+ </div>
188
+
189
+ ${spenderRows.length > 0 && html`
190
+ <h3 class="adm-mt-md adm-text-base">${t('dashboard.houseTopSpenders')}</h3>
191
+ <${DataTable}
192
+ columns=${[t('dashboard.ledgerUser') || 'Owner', t('dashboard.ledgerTotalCost') || 'Cost', t('dashboard.ledgerTotalCalls') || 'Calls']}
193
+ rows=${spenderRows} />`}
194
+ </div>`;
195
+ }
196
+
197
+ /* ── Section 1: Agent LLM ledger (priced agent LLM calls) ── */
198
+ function renderLedgerSection(data, metric, selectedUser, setSelectedUser) {
199
+ const heading = html`<h2 class="adm-section-cyan">${t('dashboard.ledgerSection') || 'Agent LLM ledger'}</h2>`;
200
+ if (!data || !data.totals || (data.totals.calls || 0) === 0) {
201
+ return html`<div>${heading}<${Empty} text=${t('dashboard.ledgerEmpty') || 'No agent LLM usage yet.'} /></div>`;
202
+ }
203
+
204
+ const totals = data.totals || {};
205
+ const days = Array.isArray(data.days) ? data.days : [];
206
+ const perUser = Array.isArray(data.per_user) ? data.per_user : [];
207
+ const perModel = Array.isArray(data.per_model) ? data.per_model : [];
208
+ const perAgent = Array.isArray(data.per_agent) ? data.per_agent : [];
209
+ const isTokens = metric === 'tokens';
210
+
211
+ const statItems = [
212
+ { label: t('dashboard.ledgerTotalCost') || 'Total cost', value: usd(totals.cost_usd), tone: 'green' },
213
+ { label: t('dashboard.ledgerTotalTokens') || 'Tokens', value: totals.total_tokens || 0, tone: 'cyan' },
214
+ { label: t('dashboard.ledgerTotalCalls') || 'LLM calls', value: totals.calls || 0, tone: 'blue' },
215
+ { label: t('dashboard.ledgerUnpriced') || 'Unpriced calls', value: totals.unpriced_calls || 0, tone: 'amber' },
216
+ ];
217
+
218
+ // Stacked per-day series by model (like the AI-apps per-app chart), ordered by the top-level
219
+ // per_model (cost desc) so the legend/series order is stable.
220
+ const labels = days.map((d) => d.date.slice(5));
221
+ const models = perModel.map((m) => m.model);
222
+ const datasets = models.map((model, i) => ({
223
+ label: model,
224
+ data: days.map((d) => {
225
+ const dm = (d.per_model && d.per_model[model]) || {};
226
+ return (isTokens ? dm.total_tokens : dm.cost_usd) || 0;
227
+ }),
228
+ backgroundColor: colorForIndex(i),
229
+ }));
230
+ const yFormat = isTokens ? (v) => fmtCompact(v) : usd;
231
+
232
+ // Per-user "top spenders" the owner cell is a clickable toggle that drills the per-agent table.
233
+ const userRows = perUser.map((u) => {
234
+ const on = selectedUser === u.owner_ghii;
235
+ return [
236
+ html`<button class=${'adm-time-btn ' + (on ? 'active' : '')}
237
+ onClick=${() => setSelectedUser(on ? null : u.owner_ghii)}>${u.owner_ghii}</button>`,
238
+ { text: num(u.agents || 0), mono: true },
239
+ { text: usd(u.cost_usd), mono: true },
240
+ { text: num(u.total_tokens || 0), mono: true },
241
+ { text: num(u.calls || 0), mono: true },
242
+ { text: num(u.unpriced_calls || 0), mono: true },
243
+ ];
244
+ });
245
+
246
+ const modelRows = perModel.map((m) => [
247
+ { text: m.model, mono: true },
248
+ { text: (m.providers && m.providers.length) ? m.providers.join(', ') : '—', mono: true },
249
+ { text: usd(m.cost_usd), mono: true },
250
+ { text: num(m.total_tokens || 0), mono: true },
251
+ { text: num(m.calls || 0), mono: true },
252
+ { text: num(m.unpriced_calls || 0), mono: true },
253
+ ]);
254
+
255
+ const shownAgents = selectedUser ? perAgent.filter((a) => a.owner_ghii === selectedUser) : perAgent;
256
+ const agentRows = shownAgents.map((a) => [
257
+ { text: a.agent_gaii, mono: true },
258
+ { text: a.owner_ghii, mono: true },
259
+ { text: usd(a.cost_usd), mono: true },
260
+ { text: num(a.total_tokens || 0), mono: true },
261
+ { text: num(a.calls || 0), mono: true },
262
+ { text: num(a.unpriced_calls || 0), mono: true },
263
+ ]);
264
+
265
+ return html`
266
+ <div>
267
+ ${heading}
268
+ <${StatsGrid} items=${statItems} />
269
+ ${datasets.some((ds) => ds.data.some((v) => v > 0))
270
+ ? html`<div class="adm-card"><h2>${t('dashboard.ledgerPerDay') || 'Cost over time'}</h2>
271
+ <${UsageChart} stacked labels=${labels} datasets=${datasets} height=${240} yFormat=${yFormat} /></div>`
272
+ : null}
273
+ <h3 class="adm-mt-lg adm-text-sm adm-section-purple">${t('dashboard.ledgerTopSpenders') || 'Top spenders'}</h3>
274
+ <${DataTable}
275
+ headers=${[
276
+ t('dashboard.ledgerColUser') || 'Owner',
277
+ t('dashboard.ledgerColAgents') || 'Agents',
278
+ t('dashboard.ledgerColCost') || 'Cost',
279
+ t('dashboard.ledgerColTokens') || 'Tokens',
280
+ t('dashboard.ledgerColCalls') || 'Calls',
281
+ t('dashboard.ledgerColUnpriced') || 'Unpriced',
282
+ ]}
283
+ rows=${userRows} scroll=${true} />
284
+ <h3 class="adm-mt-lg adm-text-sm adm-section-cyan">${t('dashboard.ledgerByModel') || 'By model'}</h3>
285
+ <${DataTable}
286
+ headers=${[
287
+ t('dashboard.ledgerColModel') || 'Model',
288
+ t('dashboard.ledgerColProvider') || 'Provider',
289
+ t('dashboard.ledgerColCost') || 'Cost',
290
+ t('dashboard.ledgerColTokens') || 'Tokens',
291
+ t('dashboard.ledgerColCalls') || 'Calls',
292
+ t('dashboard.ledgerColUnpriced') || 'Unpriced',
293
+ ]}
294
+ rows=${modelRows} scroll=${true} />
295
+ <h3 class="adm-mt-lg adm-text-sm adm-section-purple">
296
+ ${t('dashboard.ledgerByAgent') || 'By agent'}
297
+ ${selectedUser ? html` — <span class="adm-text-sm">${t('dashboard.ledgerFilteredNote') || 'Showing agents for'} ${selectedUser}</span>
298
+ <button class="adm-time-btn" onClick=${() => setSelectedUser(null)}>${t('dashboard.ledgerAllUsers') || 'All users'}</button>` : null}
299
+ </h3>
300
+ <${DataTable}
301
+ headers=${[
302
+ t('dashboard.ledgerColAgent') || 'Agent',
303
+ t('dashboard.ledgerColUser') || 'Owner',
304
+ t('dashboard.ledgerColCost') || 'Cost',
305
+ t('dashboard.ledgerColTokens') || 'Tokens',
306
+ t('dashboard.ledgerColCalls') || 'Calls',
307
+ t('dashboard.ledgerColUnpriced') || 'Unpriced',
308
+ ]}
309
+ rows=${agentRows} scroll=${true} />
310
+ </div>`;
311
+ }
312
+
313
+ /* ── Section 2: AI apps spend (per-app token/cost; distinct system, never summed with the ledger) ── */
314
+ function renderAiAppsSection(data, metric) {
315
+ const heading = html`<h2 class="adm-section-purple">${t('dashboard.ledgerAiAppsSection') || 'AI apps spend'}</h2>`;
316
+ if (!data) {
317
+ return html`<div>${heading}<${Empty} text=${t('dashboard.aiUsageEmpty') || 'No AI usage yet.'} /></div>`;
318
+ }
319
+
320
+ const days = Array.isArray(data.days) ? data.days : [];
321
+ const apps = data.apps || [];
322
+ const totals = data.totals || { cost_usd: 0, tokens: 0, calls: 0 };
323
+ const isTokens = metric === 'tokens';
324
+
325
+ const labels = days.map((d) => d.date.slice(5));
326
+ const datasets = apps.map((app, i) => ({
327
+ label: app,
328
+ data: days.map((d) => {
329
+ const m = (d.per_app && d.per_app[app]) || {};
330
+ return (isTokens ? m.tokens : m.cost_usd) || 0;
331
+ }),
332
+ backgroundColor: colorForIndex(i),
333
+ }));
334
+ const yFormat = isTokens ? (v) => fmtCompact(v) : usd;
335
+
336
+ const totalCost = totals.cost_usd || 0;
337
+ const appRows = apps.map((app) => {
338
+ const m = (data.per_app && data.per_app[app]) || { cost_usd: 0, tokens: 0, calls: 0 };
339
+ const share = totalCost > 0 ? Math.round((m.cost_usd / totalCost) * 100) : 0;
340
+ return [app, { text: usd(m.cost_usd), mono: true }, { text: num(m.tokens || 0), mono: true }, { text: num(m.calls || 0), mono: true }, { text: share + '%', mono: true }];
341
+ });
342
+ const userRows = (data.per_user || []).map((u) => [
343
+ { text: u.owner_gaii, mono: true },
344
+ { text: usd(u.cost_usd), mono: true },
345
+ { text: num(u.tokens || 0), mono: true },
346
+ { text: num(u.calls || 0), mono: true },
347
+ ]);
348
+
349
+ const statItems = [
350
+ { label: t('dashboard.aiTotalSpend') || 'Total spend', value: usd(totals.cost_usd), tone: 'green' },
351
+ { label: t('dashboard.aiTotalTokens') || 'Tokens', value: totals.tokens || 0, tone: 'cyan' },
352
+ { label: t('dashboard.aiTotalCalls') || 'Calls', value: totals.calls || 0, tone: 'blue' },
353
+ { label: t('dashboard.aiActiveApps') || 'Apps', value: apps.length, tone: 'purple' },
354
+ ];
355
+
356
+ return html`
357
+ <div>
358
+ ${heading}
359
+ <${StatsGrid} items=${statItems} />
360
+ ${datasets.length > 0
361
+ ? html`<div class="adm-card"><h2>${t('dashboard.aiPerAppOverTime') || 'Per-app spend over time'}</h2>
362
+ <${UsageChart} stacked labels=${labels} datasets=${datasets} height=${260} yFormat=${yFormat} /></div>`
363
+ : html`<${Empty} text=${t('dashboard.aiUsageEmpty') || 'No AI usage yet.'} />`}
364
+ <h3 class="adm-mt-lg adm-text-sm adm-section-cyan">${t('dashboard.aiByApp') || 'By app'}</h3>
365
+ <${DataTable}
366
+ headers=${[t('dashboard.aiApp') || 'App', t('dashboard.aiTotalSpend') || 'Spend', t('dashboard.aiTotalTokens') || 'Tokens', t('dashboard.aiTotalCalls') || 'Calls', t('dashboard.aiShare') || 'Share']}
367
+ rows=${appRows} scroll=${true} />
368
+ <h3 class="adm-mt-lg adm-text-sm adm-section-purple">${t('dashboard.aiByUser') || 'Top spenders'}</h3>
369
+ <${DataTable}
370
+ headers=${[t('dashboard.aiUser') || 'User', t('dashboard.aiTotalSpend') || 'Spend', t('dashboard.aiTotalTokens') || 'Tokens', t('dashboard.aiTotalCalls') || 'Calls']}
371
+ rows=${userRows} scroll=${true} />
372
+ </div>`;
373
+ }