@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.
- package/app/components/environments/EnvironmentStatusPanel.vue +2 -0
- 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/ServiceTestConfig.vue +9 -0
- package/app/components/settings/CloudflareHandlerSection.vue +319 -0
- package/app/components/settings/InfraHandlersConfigurator.vue +7 -0
- 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/useWorkspaceStream.ts +5 -2
- 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/providerConnections.ts +1 -0
- 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/types/execution.ts +8 -0
- package/app/utils/apiOrigin.spec.ts +37 -0
- package/app/utils/apiOrigin.ts +30 -0
- package/i18n/locales/de.json +89 -1
- package/i18n/locales/en.json +89 -1
- package/i18n/locales/es.json +89 -1
- package/i18n/locales/fr.json +89 -1
- package/i18n/locales/he.json +89 -1
- package/i18n/locales/it.json +89 -1
- package/i18n/locales/ja.json +89 -1
- package/i18n/locales/pl.json +89 -1
- package/i18n/locales/tr.json +89 -1
- package/i18n/locales/uk.json +89 -1
- 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>
|