@cat-factory/app 0.160.1 → 0.162.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.
Files changed (33) hide show
  1. package/app/components/environments/EnvironmentStatusPanel.vue +2 -0
  2. package/app/components/panels/ReportsPanel.logic.spec.ts +76 -0
  3. package/app/components/panels/ReportsPanel.logic.ts +71 -0
  4. package/app/components/panels/ReportsPanel.vue +471 -0
  5. package/app/components/panels/ReportsSpendBreakdown.vue +70 -0
  6. package/app/components/panels/inspector/ServiceTestConfig.vue +9 -0
  7. package/app/components/settings/CloudflareHandlerSection.vue +319 -0
  8. package/app/components/settings/InfraHandlersConfigurator.vue +7 -0
  9. package/app/composables/api/reports.ts +19 -0
  10. package/app/composables/useApi.ts +2 -0
  11. package/app/composables/useNavContributions.ts +1 -0
  12. package/app/composables/useWorkspaceStream.ts +5 -2
  13. package/app/modular/nav-contributions.spec.ts +1 -0
  14. package/app/modular/nav-contributions.ts +11 -0
  15. package/app/pages/index.vue +2 -0
  16. package/app/stores/providerConnections.ts +1 -0
  17. package/app/stores/reports.spec.ts +113 -0
  18. package/app/stores/reports.ts +91 -0
  19. package/app/stores/ui/modals.ts +14 -0
  20. package/app/types/execution.ts +8 -0
  21. package/app/utils/apiOrigin.spec.ts +37 -0
  22. package/app/utils/apiOrigin.ts +30 -0
  23. package/i18n/locales/de.json +89 -1
  24. package/i18n/locales/en.json +89 -1
  25. package/i18n/locales/es.json +89 -1
  26. package/i18n/locales/fr.json +89 -1
  27. package/i18n/locales/he.json +89 -1
  28. package/i18n/locales/it.json +89 -1
  29. package/i18n/locales/ja.json +89 -1
  30. package/i18n/locales/pl.json +89 -1
  31. package/i18n/locales/tr.json +89 -1
  32. package/i18n/locales/uk.json +89 -1
  33. package/package.json +2 -2
@@ -16,6 +16,7 @@ const { t, d } = useI18n()
16
16
  const PROVISION_TYPE_KEYS: Record<ProvisionType, string> = {
17
17
  kubernetes: 'environments.provisionType.kubernetes',
18
18
  'docker-compose': 'environments.provisionType.docker-compose',
19
+ cloudflare: 'environments.provisionType.cloudflare',
19
20
  custom: 'environments.provisionType.custom',
20
21
  infraless: 'environments.provisionType.infraless',
21
22
  }
@@ -23,6 +24,7 @@ const ENGINE_KEYS: Record<InfraEngine, string> = {
23
24
  'local-docker': 'environments.engine.local-docker',
24
25
  'local-k3s': 'environments.engine.local-k3s',
25
26
  'remote-kubernetes': 'environments.engine.remote-kubernetes',
27
+ cloudflare: 'environments.engine.cloudflare',
26
28
  'remote-custom': 'environments.engine.remote-custom',
27
29
  none: 'environments.engine.none',
28
30
  }
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { ReportActivityRow, ReportSpendRow } from '~/types/execution'
3
+ import {
4
+ activitySegments,
5
+ columnPct,
6
+ isUnattributed,
7
+ maxOf,
8
+ segmentPct,
9
+ spendMagnitude,
10
+ } from './ReportsPanel.logic'
11
+
12
+ const spend = (over: Partial<ReportSpendRow>): ReportSpendRow => ({
13
+ key: 'k',
14
+ label: null,
15
+ inputTokens: 0,
16
+ outputTokens: 0,
17
+ calls: 0,
18
+ meteredCost: 0,
19
+ subscriptionCost: 0,
20
+ ...over,
21
+ })
22
+
23
+ describe('ReportsPanel logic', () => {
24
+ it('ranks a spend slice by its combined footprint', () => {
25
+ // Ranking/scaling only: the sum mixes real money with the illustrative cost of
26
+ // flat-rate quota usage, so no caller may render it as an amount. `ReportsSpendBreakdown`
27
+ // shows `meteredCost` and `subscriptionCost` as separate figures for exactly that reason.
28
+ expect(spendMagnitude(spend({ meteredCost: 2, subscriptionCost: 3 }))).toBe(5)
29
+ })
30
+
31
+ it('scales every segment against the widest row, not its own row', () => {
32
+ // A per-row denominator would draw both bars full-width and erase the ranking.
33
+ const rows = [spend({ meteredCost: 10 }), spend({ meteredCost: 2 })]
34
+ const max = maxOf(rows, spendMagnitude)
35
+ expect(segmentPct(rows[0]!.meteredCost, max)).toBe(100)
36
+ expect(segmentPct(rows[1]!.meteredCost, max)).toBe(20)
37
+ })
38
+
39
+ it('returns a zero max for an empty list and draws nothing from it', () => {
40
+ expect(maxOf([], spendMagnitude)).toBe(0)
41
+ expect(segmentPct(5, 0)).toBe(0)
42
+ expect(columnPct(5, 0)).toBe(0)
43
+ })
44
+
45
+ it('floors a non-zero column so a small bucket stays visible', () => {
46
+ // Without the floor a single cheap call rounds to an empty column and reads as a
47
+ // quiet period, which is the opposite of what happened.
48
+ expect(columnPct(0.001, 1000)).toBe(2)
49
+ expect(columnPct(0, 1000)).toBe(0)
50
+ expect(columnPct(500, 1000)).toBe(50)
51
+ })
52
+
53
+ it('treats the empty key as the unattributed bucket', () => {
54
+ expect(isUnattributed('')).toBe(true)
55
+ expect(isUnattributed('feature')).toBe(false)
56
+ })
57
+
58
+ it('lists activity status splits in the legend order', () => {
59
+ const row: ReportActivityRow = {
60
+ key: 'ws',
61
+ label: 'Board',
62
+ runs: 6,
63
+ done: 3,
64
+ failed: 2,
65
+ running: 1,
66
+ other: 0,
67
+ avgDurationMs: 100,
68
+ }
69
+ expect(activitySegments(row)).toEqual([
70
+ { status: 'done', count: 3 },
71
+ { status: 'failed', count: 2 },
72
+ { status: 'running', count: 1 },
73
+ { status: 'other', count: 0 },
74
+ ])
75
+ })
76
+ })
@@ -0,0 +1,71 @@
1
+ import type { ReportActivityRow, ReportSpendRow, ReportTrendPoint } from '~/types/execution'
2
+
3
+ // Pure sizing/derivation behind `ReportsPanel.vue`: everything the template needs that is
4
+ // arithmetic rather than markup, so it is unit-tested directly instead of through the DOM.
5
+
6
+ // The two `*Magnitude` helpers add the metered and subscription costs together, which is a
7
+ // number NOBODY MAY RENDER AS MONEY: only `meteredCost` is real spend, and `subscriptionCost`
8
+ // is the illustrative equivalent-API cost of flat-rate quota usage, so their sum denominates
9
+ // nothing. They exist purely as the RANKING and BAR-SCALING measure — the one job the sum is
10
+ // valid for, because it orders slices by total footprint exactly as the SQL `ORDER BY` does.
11
+ // Anything shown to a reader with a currency symbol must come off one of the two fields.
12
+
13
+ /** A spend slice's combined footprint. Ranking/scaling only — never a displayed amount. */
14
+ export function spendMagnitude(row: ReportSpendRow): number {
15
+ return row.meteredCost + row.subscriptionCost
16
+ }
17
+
18
+ /** A trend bucket's combined footprint. Ranking/scaling only — never a displayed amount. */
19
+ export function trendMagnitude(point: ReportTrendPoint): number {
20
+ return point.meteredCost + point.subscriptionCost
21
+ }
22
+
23
+ /**
24
+ * The heaviest value in a list, or 0 when empty. Bars are drawn RELATIVE to this, so a
25
+ * full bar means "the biggest slice here", never "all of some absolute quota".
26
+ */
27
+ export function maxOf<T>(rows: T[], measure: (row: T) => number): number {
28
+ return rows.reduce((max, row) => Math.max(max, measure(row)), 0)
29
+ }
30
+
31
+ /**
32
+ * A segment's share of the widest row, as a percentage. Scaling every segment against the
33
+ * SAME denominator is what makes a stacked bar's total length comparable across rows; a
34
+ * per-row denominator would draw every bar full-width and destroy the ranking.
35
+ */
36
+ export function segmentPct(value: number, max: number): number {
37
+ if (max <= 0 || value <= 0) return 0
38
+ return (value / max) * 100
39
+ }
40
+
41
+ /**
42
+ * A trend column's height as a percentage of the tallest column. A non-zero value floors at
43
+ * 2% so a single small call is still a visible mark rather than an apparent gap — the one
44
+ * thing a reader would otherwise misread as "nothing happened here".
45
+ */
46
+ export function columnPct(value: number, max: number): number {
47
+ if (max <= 0 || value <= 0) return 0
48
+ return Math.max(2, (value / max) * 100)
49
+ }
50
+
51
+ /**
52
+ * Whether a breakdown key is the unattributed bucket. It is a REAL slice (a call whose run,
53
+ * service or task type could not be resolved), so it is rendered with an explicit label
54
+ * rather than silently dropped — omitting it would under-report the window while looking
55
+ * complete.
56
+ */
57
+ export function isUnattributed(key: string): boolean {
58
+ return key === ''
59
+ }
60
+
61
+ /** Every status split of an activity row, in the fixed order the legend declares. */
62
+ export function activitySegments(
63
+ row: ReportActivityRow,
64
+ ): Array<{ status: 'done' | 'failed' | 'running' | 'other'; count: number }> {
65
+ return [
66
+ { status: 'done', count: row.done },
67
+ { status: 'failed', count: row.failed },
68
+ { status: 'running', count: row.running },
69
+ { status: 'other', count: row.other },
70
+ ]
71
+ }
@@ -0,0 +1,471 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { onKeyStroke } from '@vueuse/core'
4
+ import type {
5
+ ReportActivityDimension,
6
+ ReportActivityRow,
7
+ ReportSpendRow,
8
+ ReportWindow,
9
+ } from '~/types/execution'
10
+ import { formatMs, formatTokens } from '~/utils/observability'
11
+ import {
12
+ activitySegments,
13
+ columnPct,
14
+ isUnattributed,
15
+ maxOf,
16
+ segmentPct,
17
+ trendMagnitude,
18
+ } from './ReportsPanel.logic'
19
+
20
+ // Reports: cross-cutting usage analytics for the active account — where the spend and the
21
+ // work actually go. Spend per model and agent kind, spend + run activity per workspace /
22
+ // service / task type, and a spend trend, over a selectable window and optionally narrowed
23
+ // to one board. Admin-gated; opened via `ui.openReports()` from the sidebar. The sibling of
24
+ // `OperatorDashboardPanel`, which answers the health question over the same account scope.
25
+ //
26
+ // Charting follows the panel-native idiom (Tailwind marks, no charting dependency), with
27
+ // two deliberate encodings:
28
+ // - SPEND is two series — `violet-500` metered (real money) and `amber-600` subscription
29
+ // (the illustrative equivalent-API cost of flat-rate quota usage). The pair is validated
30
+ // colorblind-safe against the dark surface, and a legend is always present.
31
+ // - ACTIVITY uses the app's RESERVED status colors (emerald done / rose failed / sky
32
+ // running / slate other), which are red-green adjacent by design across the product.
33
+ // Every status is therefore ALSO carried as a number under its bar and named in the
34
+ // legend, so identity is never colour alone.
35
+ const ui = useUiStore()
36
+ const accounts = useAccountsStore()
37
+ const workspace = useWorkspaceStore()
38
+ const reports = useReportsStore()
39
+ const { t, te, d, n } = useI18n()
40
+
41
+ const open = computed(() => ui.reportsOpen)
42
+ const view = computed(() => reports.view)
43
+ const loading = computed(() => reports.loading)
44
+ const failed = computed(() => reports.failed)
45
+ /** The backend's untranslated message, shown as detail under the localized heading. */
46
+ const error = computed(() => reports.error)
47
+ const accountName = computed(() => accounts.activeAccount?.name ?? '')
48
+
49
+ // Window options as static literal keys (keeps the typed-message-key check live).
50
+ const WINDOWS: { value: ReportWindow; label: string }[] = [
51
+ { value: '24h', label: t('reports.window.oneDay') },
52
+ { value: '7d', label: t('reports.window.sevenDays') },
53
+ { value: '30d', label: t('reports.window.thirtyDays') },
54
+ { value: '90d', label: t('reports.window.ninetyDays') },
55
+ ]
56
+
57
+ // The dimension the paired spend + activity breakdowns are grouped by. Model and agent
58
+ // kind have no activity counterpart (a run carries no single kind), so they render
59
+ // unconditionally above rather than joining this switch.
60
+ const DIMENSIONS: { value: ReportActivityDimension; label: string }[] = [
61
+ { value: 'workspace', label: t('reports.dimension.workspace') },
62
+ { value: 'service', label: t('reports.dimension.service') },
63
+ { value: 'taskType', label: t('reports.dimension.taskType') },
64
+ ]
65
+ const dimension = ref<ReportActivityDimension>('workspace')
66
+
67
+ const currency = computed(() => view.value?.currency ?? 'EUR')
68
+ const money = (value: number) => n(value, { key: 'currency', currency: currency.value })
69
+
70
+ /** A slice's display name: the resolved label, the raw key, or the unattributed notice. */
71
+ function sliceLabel(row: { key: string; label: string | null }): string {
72
+ if (isUnattributed(row.key)) return t('reports.unattributed')
73
+ return row.label ?? row.key
74
+ }
75
+
76
+ const spendByDimension = computed<ReportSpendRow[]>(() => {
77
+ const spend = view.value?.spend
78
+ if (!spend) return []
79
+ if (dimension.value === 'workspace') return spend.byWorkspace
80
+ if (dimension.value === 'service') return spend.byService
81
+ return spend.byTaskType
82
+ })
83
+ const activityByDimension = computed<ReportActivityRow[]>(() => {
84
+ const activity = view.value?.activity
85
+ if (!activity) return []
86
+ if (dimension.value === 'workspace') return activity.byWorkspace
87
+ if (dimension.value === 'service') return activity.byService
88
+ return activity.byTaskType
89
+ })
90
+
91
+ const maxTrend = computed(() => maxOf(view.value?.trend.points ?? [], trendMagnitude))
92
+ const hasSpend = computed(() => (view.value?.totals.calls ?? 0) > 0)
93
+ // Hoisted: every activity bar is scaled against the busiest slice in the SAME list, so this
94
+ // is invariant across the row loop. Computing it inline would re-scan the list once per
95
+ // status segment of every row.
96
+ const maxRuns = computed(() => maxOf(activityByDimension.value, (row) => row.runs))
97
+
98
+ /** Boards the filter offers — the active account's, since the report is account-scoped. */
99
+ const boards = computed(() => workspace.accountWorkspaces)
100
+
101
+ function trendTooltip(point: { start: number; meteredCost: number; subscriptionCost: number }) {
102
+ return `${d(new Date(point.start), 'short')} · ${t('reports.legend.metered')} ${money(point.meteredCost)} · ${t('reports.legend.subscription')} ${money(point.subscriptionCost)}`
103
+ }
104
+
105
+ // Exhaustive enum→key map (the tier-2 dynamic-key guard): a new activity status fails the
106
+ // typecheck here rather than silently rendering a raw code.
107
+ const STATUS_KEYS: Record<'done' | 'failed' | 'running' | 'other', string> = {
108
+ done: 'reports.status.done',
109
+ failed: 'reports.status.failed',
110
+ running: 'reports.status.running',
111
+ other: 'reports.status.other',
112
+ }
113
+ const STATUS_CLASSES: Record<'done' | 'failed' | 'running' | 'other', string> = {
114
+ done: 'bg-emerald-500',
115
+ failed: 'bg-rose-500',
116
+ running: 'bg-sky-500',
117
+ other: 'bg-slate-500',
118
+ }
119
+ /** `te`-guarded so a locale missing the key shows the raw status, never a raw message key. */
120
+ function statusLabel(status: 'done' | 'failed' | 'running' | 'other'): string {
121
+ const key = STATUS_KEYS[status]
122
+ return te(key) ? t(key) : status
123
+ }
124
+
125
+ function refresh() {
126
+ void reports.load()
127
+ }
128
+ function close() {
129
+ ui.closeReports()
130
+ }
131
+ onKeyStroke('Escape', () => {
132
+ if (open.value) close()
133
+ })
134
+
135
+ // Load (and refresh) whenever the panel opens.
136
+ watch(
137
+ open,
138
+ (isOpen) => {
139
+ if (isOpen) void reports.load()
140
+ },
141
+ { immediate: true },
142
+ )
143
+ </script>
144
+
145
+ <template>
146
+ <Teleport to="body">
147
+ <Transition name="reports-fade">
148
+ <div
149
+ v-if="open"
150
+ class="fixed inset-0 z-[60] flex flex-col bg-slate-950/96 backdrop-blur-sm"
151
+ role="dialog"
152
+ aria-modal="true"
153
+ data-testid="reports-panel"
154
+ >
155
+ <header class="flex flex-wrap items-center gap-3 border-b border-slate-800 px-6 py-4">
156
+ <div
157
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-violet-500/15"
158
+ >
159
+ <UIcon name="i-lucide-chart-column" class="h-5 w-5 text-violet-400" />
160
+ </div>
161
+ <div class="min-w-0">
162
+ <h1 class="truncate text-base font-semibold text-white">{{ t('reports.title') }}</h1>
163
+ <p v-if="accountName" class="truncate text-xs text-slate-500">{{ accountName }}</p>
164
+ </div>
165
+ <!-- Filters in ONE row above the charts: window, then board scope. -->
166
+ <div class="ms-auto flex flex-wrap items-center gap-1.5">
167
+ <select
168
+ class="rounded-lg border border-slate-800 bg-slate-900 px-2.5 py-1.5 text-[12px] text-slate-200"
169
+ :value="reports.workspaceFilter ?? ''"
170
+ :aria-label="t('reports.filter.board')"
171
+ data-testid="reports-board-filter"
172
+ @change="
173
+ reports.setWorkspaceFilter(($event.target as HTMLSelectElement).value || null)
174
+ "
175
+ >
176
+ <option value="">{{ t('reports.filter.allBoards') }}</option>
177
+ <option v-for="board in boards" :key="board.id" :value="board.id">
178
+ {{ board.name }}
179
+ </option>
180
+ </select>
181
+ <div class="me-1 flex rounded-lg border border-slate-800 p-0.5 text-[12px]">
182
+ <button
183
+ v-for="opt in WINDOWS"
184
+ :key="opt.value"
185
+ class="rounded-md px-2.5 py-1 transition"
186
+ :class="
187
+ reports.window === opt.value
188
+ ? 'bg-slate-800 text-slate-100'
189
+ : 'text-slate-400 hover:text-slate-200'
190
+ "
191
+ :data-testid="`reports-window-${opt.value}`"
192
+ @click="reports.setWindow(opt.value)"
193
+ >
194
+ {{ opt.label }}
195
+ </button>
196
+ </div>
197
+ <button
198
+ class="rounded-lg border border-slate-800 p-1.5 text-slate-400 transition hover:text-slate-200"
199
+ :aria-label="t('reports.refresh')"
200
+ :title="t('reports.refresh')"
201
+ @click="refresh"
202
+ >
203
+ <UIcon
204
+ name="i-lucide-refresh-cw"
205
+ class="h-4 w-4"
206
+ :class="{ 'animate-spin': loading }"
207
+ />
208
+ </button>
209
+ <button
210
+ class="rounded-lg border border-slate-800 p-1.5 text-slate-400 transition hover:text-slate-200"
211
+ :aria-label="t('common.close')"
212
+ @click="close"
213
+ >
214
+ <UIcon name="i-lucide-x" class="h-4 w-4" />
215
+ </button>
216
+ </div>
217
+ </header>
218
+
219
+ <div class="flex-1 overflow-y-auto px-6 py-5">
220
+ <div
221
+ v-if="failed"
222
+ class="mx-auto max-w-2xl rounded-lg border border-rose-800/60 bg-rose-950/40 p-4 text-sm text-rose-200"
223
+ >
224
+ <p>{{ t('reports.error') }}</p>
225
+ <p v-if="error" class="mt-1 text-xs text-rose-300/80">{{ error }}</p>
226
+ <button
227
+ class="mt-2 rounded-md border border-rose-700 px-3 py-1 text-xs hover:bg-rose-900/40"
228
+ @click="refresh"
229
+ >
230
+ {{ t('reports.retry') }}
231
+ </button>
232
+ </div>
233
+
234
+ <div v-else-if="loading && !view" class="py-16 text-center text-sm text-slate-400">
235
+ {{ t('reports.loading') }}
236
+ </div>
237
+
238
+ <div v-else-if="view" class="mx-auto flex max-w-5xl flex-col gap-6">
239
+ <p class="text-[11px] text-slate-500">
240
+ {{
241
+ t('reports.period', {
242
+ from: d(new Date(view.since), 'short'),
243
+ to: d(new Date(view.generatedAt), 'short'),
244
+ })
245
+ }}
246
+ </p>
247
+
248
+ <!-- Headline totals. A stat tile, not a chart: these are single numbers. -->
249
+ <section>
250
+ <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
251
+ {{ t('reports.totals.title') }}
252
+ </h2>
253
+ <div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
254
+ <div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
255
+ <p class="text-2xl font-semibold text-violet-300" data-testid="reports-metered">
256
+ {{ money(view.totals.meteredCost) }}
257
+ </p>
258
+ <p class="text-xs text-slate-500">{{ t('reports.totals.metered') }}</p>
259
+ </div>
260
+ <div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
261
+ <p class="text-2xl font-semibold text-amber-300">
262
+ {{ money(view.totals.subscriptionCost) }}
263
+ </p>
264
+ <p class="text-xs text-slate-500">{{ t('reports.totals.subscription') }}</p>
265
+ </div>
266
+ <div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
267
+ <p class="text-2xl font-semibold text-white">
268
+ {{ n(view.totals.calls, 'decimal') }}
269
+ </p>
270
+ <p class="text-xs text-slate-500">{{ t('reports.totals.calls') }}</p>
271
+ </div>
272
+ <div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
273
+ <p class="text-2xl font-semibold text-white">
274
+ {{ formatTokens(view.totals.inputTokens + view.totals.outputTokens) }}
275
+ </p>
276
+ <p class="text-xs text-slate-500">{{ t('reports.totals.tokens') }}</p>
277
+ </div>
278
+ </div>
279
+ <p class="mt-1.5 text-[10px] text-slate-500">
280
+ {{ t('reports.totals.illustrative') }}
281
+ </p>
282
+ </section>
283
+
284
+ <!-- Spend over time. One axis, two stacked series, legend always present. -->
285
+ <section>
286
+ <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
287
+ {{ t('reports.trend.title') }}
288
+ </h2>
289
+ <div class="rounded-lg border border-slate-800 bg-slate-900/40 p-4">
290
+ <div v-if="!hasSpend" class="py-6 text-center text-xs text-slate-500">
291
+ {{ t('reports.trend.empty') }}
292
+ </div>
293
+ <div v-else class="flex h-28 items-end gap-0.5" data-testid="reports-trend">
294
+ <div
295
+ v-for="point in view.trend.points"
296
+ :key="point.start"
297
+ class="flex flex-1 flex-col justify-end gap-[2px]"
298
+ :title="trendTooltip(point)"
299
+ >
300
+ <div
301
+ class="w-full rounded-t-sm bg-amber-600"
302
+ :style="{ height: `${columnPct(point.subscriptionCost, maxTrend)}%` }"
303
+ />
304
+ <div
305
+ class="w-full rounded-sm bg-violet-500"
306
+ :style="{ height: `${columnPct(point.meteredCost, maxTrend)}%` }"
307
+ />
308
+ </div>
309
+ </div>
310
+ <div class="mt-2 flex items-center gap-4 text-[11px] text-slate-500">
311
+ <span class="flex items-center gap-1">
312
+ <span class="h-2 w-2 rounded-sm bg-violet-500" />{{
313
+ t('reports.legend.metered')
314
+ }}
315
+ </span>
316
+ <span class="flex items-center gap-1">
317
+ <span class="h-2 w-2 rounded-sm bg-amber-600" />{{
318
+ t('reports.legend.subscription')
319
+ }}
320
+ </span>
321
+ </div>
322
+ </div>
323
+ </section>
324
+
325
+ <!-- Spend by model + by agent kind: the two axes a run has no single value for. -->
326
+ <div class="grid gap-6 md:grid-cols-2">
327
+ <section>
328
+ <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
329
+ {{ t('reports.spend.byModel') }}
330
+ </h2>
331
+ <ReportsSpendBreakdown
332
+ :rows="view.spend.byModel"
333
+ :currency="currency"
334
+ test-id="reports-spend-model"
335
+ :label-of="sliceLabel"
336
+ />
337
+ </section>
338
+ <section>
339
+ <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
340
+ {{ t('reports.spend.byAgentKind') }}
341
+ </h2>
342
+ <ReportsSpendBreakdown
343
+ :rows="view.spend.byAgentKind"
344
+ :currency="currency"
345
+ test-id="reports-spend-agent-kind"
346
+ :label-of="sliceLabel"
347
+ />
348
+ </section>
349
+ </div>
350
+
351
+ <!-- The shared axis: spend AND activity for the same grouping, side by side. -->
352
+ <section class="flex flex-col gap-3">
353
+ <div class="flex flex-wrap items-center gap-2">
354
+ <h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
355
+ {{ t('reports.breakdown.title') }}
356
+ </h2>
357
+ <div class="flex rounded-lg border border-slate-800 p-0.5 text-[12px]">
358
+ <button
359
+ v-for="opt in DIMENSIONS"
360
+ :key="opt.value"
361
+ class="rounded-md px-2.5 py-1 transition"
362
+ :class="
363
+ dimension === opt.value
364
+ ? 'bg-slate-800 text-slate-100'
365
+ : 'text-slate-400 hover:text-slate-200'
366
+ "
367
+ :data-testid="`reports-dimension-${opt.value}`"
368
+ @click="dimension = opt.value"
369
+ >
370
+ {{ opt.label }}
371
+ </button>
372
+ </div>
373
+ </div>
374
+ <div class="grid gap-6 md:grid-cols-2">
375
+ <div>
376
+ <h3 class="mb-2 text-[11px] text-slate-500">{{ t('reports.spend.heading') }}</h3>
377
+ <ReportsSpendBreakdown
378
+ :rows="spendByDimension"
379
+ :currency="currency"
380
+ test-id="reports-spend-dimension"
381
+ :label-of="sliceLabel"
382
+ />
383
+ </div>
384
+ <div>
385
+ <h3 class="mb-2 text-[11px] text-slate-500">
386
+ {{ t('reports.activity.heading') }}
387
+ </h3>
388
+ <div class="rounded-lg border border-slate-800 bg-slate-900/40 p-4">
389
+ <div
390
+ v-if="!activityByDimension.length"
391
+ class="py-4 text-center text-xs text-slate-500"
392
+ >
393
+ {{ t('reports.activity.empty') }}
394
+ </div>
395
+ <template v-else>
396
+ <ul class="flex flex-col gap-3" data-testid="reports-activity">
397
+ <li
398
+ v-for="row in activityByDimension"
399
+ :key="row.key"
400
+ class="text-xs"
401
+ data-testid="reports-activity-row"
402
+ >
403
+ <div class="mb-1 flex items-baseline justify-between gap-2">
404
+ <span class="min-w-0 truncate text-slate-300">{{
405
+ sliceLabel(row)
406
+ }}</span>
407
+ <span class="shrink-0 tabular-nums text-slate-400">
408
+ {{ t('reports.activity.runs', { count: row.runs }, row.runs) }}
409
+ </span>
410
+ </div>
411
+ <div class="flex h-1.5 gap-[2px]">
412
+ <div
413
+ v-for="segment in activitySegments(row)"
414
+ :key="segment.status"
415
+ class="h-1.5 rounded-full"
416
+ :class="STATUS_CLASSES[segment.status]"
417
+ :style="{
418
+ width: `${segmentPct(segment.count, maxRuns)}%`,
419
+ }"
420
+ />
421
+ </div>
422
+ <!-- The status counts in text: the reserved status hues are
423
+ red-green adjacent, so identity is never colour alone. -->
424
+ <p class="mt-1 flex flex-wrap gap-x-2 text-[10px] text-slate-500">
425
+ <span v-for="segment in activitySegments(row)" :key="segment.status">
426
+ {{ n(segment.count, 'decimal') }} {{ statusLabel(segment.status) }}
427
+ </span>
428
+ <span v-if="row.avgDurationMs != null">
429
+ ·
430
+ {{
431
+ t('reports.activity.avg', { value: formatMs(row.avgDurationMs) })
432
+ }}
433
+ </span>
434
+ </p>
435
+ </li>
436
+ </ul>
437
+ <div
438
+ class="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-slate-500"
439
+ >
440
+ <span
441
+ v-for="status in ['done', 'failed', 'running', 'other'] as const"
442
+ :key="status"
443
+ class="flex items-center gap-1"
444
+ >
445
+ <span class="h-2 w-2 rounded-sm" :class="STATUS_CLASSES[status]" />{{
446
+ statusLabel(status)
447
+ }}
448
+ </span>
449
+ </div>
450
+ </template>
451
+ </div>
452
+ </div>
453
+ </div>
454
+ </section>
455
+ </div>
456
+ </div>
457
+ </div>
458
+ </Transition>
459
+ </Teleport>
460
+ </template>
461
+
462
+ <style scoped>
463
+ .reports-fade-enter-active,
464
+ .reports-fade-leave-active {
465
+ transition: opacity 0.15s ease;
466
+ }
467
+ .reports-fade-enter-from,
468
+ .reports-fade-leave-to {
469
+ opacity: 0;
470
+ }
471
+ </style>