@cat-factory/app 0.161.0 → 0.162.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/panels/AgentStepDetail.vue +58 -6
- package/app/components/panels/ReportsPanel.logic.spec.ts +76 -0
- package/app/components/panels/ReportsPanel.logic.ts +71 -0
- package/app/components/panels/ReportsPanel.vue +471 -0
- package/app/components/panels/ReportsSpendBreakdown.vue +70 -0
- package/app/components/panels/inspector/TaskExecution.vue +25 -1
- package/app/components/pipeline/PipelineProgress.vue +13 -3
- package/app/composables/api/reports.ts +19 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/useStepApproval.ts +10 -9
- package/app/modular/nav-contributions.spec.ts +1 -0
- package/app/modular/nav-contributions.ts +11 -0
- package/app/pages/index.vue +2 -0
- package/app/stores/execution/commands.ts +47 -16
- package/app/stores/reports.spec.ts +113 -0
- package/app/stores/reports.ts +91 -0
- package/app/stores/ui/modals.ts +14 -0
- package/app/stores/ui/resultViews.ts +5 -1
- package/app/types/execution.ts +8 -0
- package/app/utils/pipelineRender.spec.ts +72 -0
- package/app/utils/pipelineRender.ts +26 -0
- package/i18n/locales/de.json +74 -1
- package/i18n/locales/en.json +74 -1
- package/i18n/locales/es.json +74 -1
- package/i18n/locales/fr.json +74 -1
- package/i18n/locales/he.json +74 -1
- package/i18n/locales/it.json +74 -1
- package/i18n/locales/ja.json +74 -1
- package/i18n/locales/pl.json +74 -1
- package/i18n/locales/tr.json +74 -1
- package/i18n/locales/uk.json +74 -1
- package/package.json +2 -2
|
@@ -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>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { ReportSpendRow } from '~/types/execution'
|
|
4
|
+
import { maxOf, segmentPct, spendMagnitude } from './ReportsPanel.logic'
|
|
5
|
+
|
|
6
|
+
// One ranked spend breakdown: a horizontal bar per slice, split into the metered
|
|
7
|
+
// (`violet-500`, real money) and subscription (`amber-600`, illustrative equivalent-API
|
|
8
|
+
// cost) segments with a surface gap between them. Extracted from `ReportsPanel.vue` because
|
|
9
|
+
// the panel renders four of these against different dimensions — the shape is identical,
|
|
10
|
+
// only the row list and the heading differ.
|
|
11
|
+
//
|
|
12
|
+
// Every bar is scaled against the HEAVIEST slice in this list, so a full bar means "the
|
|
13
|
+
// biggest consumer here", never an absolute budget. The panel owns the legend (the two
|
|
14
|
+
// series mean the same thing in every breakdown, so repeating it per card would be noise).
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
rows: ReportSpendRow[]
|
|
17
|
+
currency: string
|
|
18
|
+
testId: string
|
|
19
|
+
/** Resolves a slice's display name (the panel owns the unattributed/i18n vocabulary). */
|
|
20
|
+
labelOf: (row: ReportSpendRow) => string
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
const { t, n } = useI18n()
|
|
24
|
+
const money = (value: number) => n(value, { key: 'currency', currency: props.currency })
|
|
25
|
+
const max = computed(() => maxOf(props.rows, spendMagnitude))
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-4">
|
|
30
|
+
<div v-if="!rows.length" class="py-4 text-center text-xs text-slate-500">
|
|
31
|
+
{{ t('reports.spend.empty') }}
|
|
32
|
+
</div>
|
|
33
|
+
<ul v-else class="flex flex-col gap-2.5" :data-testid="testId">
|
|
34
|
+
<li v-for="row in rows" :key="row.key" class="text-xs" data-testid="reports-spend-row">
|
|
35
|
+
<div class="mb-1 flex items-baseline justify-between gap-2">
|
|
36
|
+
<span class="min-w-0 truncate text-slate-300">{{ labelOf(row) }}</span>
|
|
37
|
+
<!-- The metered figure alone is the slice's SPEND. The subscription cost rides
|
|
38
|
+
beside it, in its own series colour and explicitly named, because it is the
|
|
39
|
+
illustrative cost of flat-rate quota usage — adding the two into one currency
|
|
40
|
+
figure would report money that was never billed. -->
|
|
41
|
+
<span class="shrink-0 tabular-nums text-slate-400">
|
|
42
|
+
{{ money(row.meteredCost) }}
|
|
43
|
+
<span v-if="row.subscriptionCost > 0" class="text-amber-400">
|
|
44
|
+
{{ t('reports.spend.subscriptionAside', { value: money(row.subscriptionCost) }) }}
|
|
45
|
+
</span>
|
|
46
|
+
</span>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="flex h-1.5 gap-[2px]">
|
|
49
|
+
<div
|
|
50
|
+
class="h-1.5 rounded-full bg-violet-500"
|
|
51
|
+
:style="{ width: `${segmentPct(row.meteredCost, max)}%` }"
|
|
52
|
+
/>
|
|
53
|
+
<div
|
|
54
|
+
class="h-1.5 rounded-full bg-amber-600"
|
|
55
|
+
:style="{ width: `${segmentPct(row.subscriptionCost, max)}%` }"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
<p class="mt-1 text-[10px] text-slate-500">
|
|
59
|
+
{{ t('reports.spend.calls', { count: row.calls }, row.calls) }} ·
|
|
60
|
+
{{
|
|
61
|
+
t('reports.spend.tokens', {
|
|
62
|
+
input: formatTokens(row.inputTokens),
|
|
63
|
+
output: formatTokens(row.outputTokens),
|
|
64
|
+
})
|
|
65
|
+
}}
|
|
66
|
+
</p>
|
|
67
|
+
</li>
|
|
68
|
+
</ul>
|
|
69
|
+
</div>
|
|
70
|
+
</template>
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
COMPANION_STATE_META,
|
|
7
7
|
isCompanionKind,
|
|
8
8
|
containerPhaseLabel,
|
|
9
|
+
dedicatedParkView,
|
|
9
10
|
} from '~/utils/pipelineRender'
|
|
10
11
|
import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
|
|
11
12
|
import AgentFailureHistory from '~/components/board/AgentFailureHistory.vue'
|
|
@@ -154,6 +155,12 @@ function openForkFor(i: number) {
|
|
|
154
155
|
if (instance.value) ui.openForkDecision(instance.value.id, i)
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
// Open the follow-up triage window for a coder step parked on undecided follow-up items
|
|
159
|
+
// (its dedicated chip — the generic approve resolver refuses this park server-side).
|
|
160
|
+
function openFollowUpsFor(i: number) {
|
|
161
|
+
if (instance.value) ui.openFollowUps(instance.value.id, i)
|
|
162
|
+
}
|
|
163
|
+
|
|
157
164
|
// Open the PR deep-review findings-selection window for a pr-reviewer step parked awaiting
|
|
158
165
|
// a selection (its dedicated chip, mirroring the fork-decision one above).
|
|
159
166
|
function openPrReviewFor(i: number) {
|
|
@@ -425,7 +432,7 @@ async function mergePr() {
|
|
|
425
432
|
v-else-if="
|
|
426
433
|
s.approval &&
|
|
427
434
|
s.approval.status === 'pending' &&
|
|
428
|
-
s
|
|
435
|
+
dedicatedParkView(s) === 'fork-decision'
|
|
429
436
|
"
|
|
430
437
|
color="primary"
|
|
431
438
|
variant="soft"
|
|
@@ -435,6 +442,23 @@ async function mergePr() {
|
|
|
435
442
|
>
|
|
436
443
|
{{ t('inspector.execution.chooseApproach') }}
|
|
437
444
|
</UButton>
|
|
445
|
+
<!-- A coder step parked on undecided follow-up items: triage them (file /
|
|
446
|
+
send back / answer / dismiss) in the dedicated window, not a plain
|
|
447
|
+
approval — the generic approve resolver refuses this park. -->
|
|
448
|
+
<UButton
|
|
449
|
+
v-else-if="
|
|
450
|
+
s.approval &&
|
|
451
|
+
s.approval.status === 'pending' &&
|
|
452
|
+
dedicatedParkView(s) === 'follow-ups'
|
|
453
|
+
"
|
|
454
|
+
color="primary"
|
|
455
|
+
variant="soft"
|
|
456
|
+
size="xs"
|
|
457
|
+
icon="i-lucide-compass"
|
|
458
|
+
@click="openFollowUpsFor(i)"
|
|
459
|
+
>
|
|
460
|
+
{{ t('inspector.execution.triageFollowUps') }}
|
|
461
|
+
</UButton>
|
|
438
462
|
<!-- A pr-reviewer step parked awaiting a finding selection: open the dedicated
|
|
439
463
|
findings-selection window, not the generic approval gate. -->
|
|
440
464
|
<UButton
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
isFailedStep,
|
|
11
11
|
FAILED_STEP_META,
|
|
12
12
|
containerPhaseLabel,
|
|
13
|
+
dedicatedParkView,
|
|
13
14
|
} from '~/utils/pipelineRender'
|
|
14
15
|
import { prReviewPhase } from '~/utils/prReviewProgress'
|
|
15
16
|
import StepMetricsBar from '~/components/observability/StepMetricsBar.vue'
|
|
@@ -74,7 +75,9 @@ function followUpLabel(step: PipelineStep): string {
|
|
|
74
75
|
/** The active fork-decision phase status on a coder step (proposing / awaiting a choice). */
|
|
75
76
|
function forkPhase(step: PipelineStep): 'proposing' | 'awaiting_choice' | null {
|
|
76
77
|
const status = step.forkDecision?.status
|
|
77
|
-
|
|
78
|
+
if (status === 'proposing') return 'proposing'
|
|
79
|
+
// `answering` (a chat reply in flight) still belongs to the fork window's choice phase.
|
|
80
|
+
return status === 'awaiting_choice' || status === 'answering' ? 'awaiting_choice' : null
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
/**
|
|
@@ -670,9 +673,16 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
670
673
|
{{ reviewStageLabel(s.agentKind) }}
|
|
671
674
|
</div>
|
|
672
675
|
|
|
673
|
-
<!-- approval gate: review (and edit) the proposal before continuing
|
|
676
|
+
<!-- approval gate: review (and edit) the proposal before continuing. A park a
|
|
677
|
+
dedicated window owns (fork choice / follow-up triage) renders its own chip
|
|
678
|
+
above instead — the generic approve resolver refuses those server-side. -->
|
|
674
679
|
<div
|
|
675
|
-
v-else-if="
|
|
680
|
+
v-else-if="
|
|
681
|
+
s.approval &&
|
|
682
|
+
s.approval.status === 'pending' &&
|
|
683
|
+
!prReviewAwaiting(s) &&
|
|
684
|
+
!dedicatedParkView(s)
|
|
685
|
+
"
|
|
676
686
|
class="mt-3"
|
|
677
687
|
>
|
|
678
688
|
<UButton
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getReportsContract } from '@cat-factory/contracts'
|
|
2
|
+
import type { ReportWindow } from '~/types/execution'
|
|
3
|
+
import type { ApiContext } from './context'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reports: cross-cutting usage analytics for an account over a time window
|
|
7
|
+
* (admin-gated). The sibling of `platformObservabilityApi` — same account scope,
|
|
8
|
+
* a different question ("where did the spend and the work go" vs "is it healthy").
|
|
9
|
+
* `workspaceId` narrows every breakdown to one board.
|
|
10
|
+
*/
|
|
11
|
+
export function reportsApi({ send }: ApiContext) {
|
|
12
|
+
return {
|
|
13
|
+
getReports: (accountId: string, window: ReportWindow, workspaceId?: string | null) =>
|
|
14
|
+
send(getReportsContract, {
|
|
15
|
+
pathParams: { accountId },
|
|
16
|
+
queryParams: { window, ...(workspaceId ? { workspaceId } : {}) },
|
|
17
|
+
}),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -3,6 +3,7 @@ import { createApiClient, createSend, createSendWith } from './api/client'
|
|
|
3
3
|
import type { ApiContext } from './api/context'
|
|
4
4
|
import { accountsApi } from './api/accounts'
|
|
5
5
|
import { platformObservabilityApi } from './api/platformObservability'
|
|
6
|
+
import { reportsApi } from './api/reports'
|
|
6
7
|
import { authApi } from './api/auth'
|
|
7
8
|
import { bootstrapApi } from './api/bootstrap'
|
|
8
9
|
import { boardApi } from './api/board'
|
|
@@ -112,6 +113,7 @@ export function useApi() {
|
|
|
112
113
|
...modelsApi(ctx),
|
|
113
114
|
...accountsApi(ctx),
|
|
114
115
|
...platformObservabilityApi(ctx),
|
|
116
|
+
...reportsApi(ctx),
|
|
115
117
|
...workspacesApi(ctx),
|
|
116
118
|
...boardApi(ctx),
|
|
117
119
|
...executionApi(ctx),
|