@cat-factory/app 0.161.0 → 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.
@@ -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>
@@ -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),
@@ -45,6 +45,7 @@ export function useNavContributions() {
45
45
  localModels: () => ui.openLocalModels(),
46
46
  accountSettings: () => ui.openAccountSettings(),
47
47
  operatorDashboard: () => ui.openOperatorDashboard(),
48
+ reports: () => ui.openReports(),
48
49
  shortcuts: () => ui.openShortcutsHelp(),
49
50
  }
50
51
 
@@ -166,6 +166,7 @@ describe('nav grouping helpers', () => {
166
166
  'model-config',
167
167
  'account-settings',
168
168
  'operator-dashboard',
169
+ 'reports',
169
170
  ])
170
171
  })
171
172
 
@@ -96,6 +96,7 @@ export const NAV_ACTIONS = [
96
96
  'localModels',
97
97
  'accountSettings',
98
98
  'operatorDashboard',
99
+ 'reports',
99
100
  'shortcuts',
100
101
  ] as const
101
102
 
@@ -354,6 +355,16 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
354
355
  testId: 'nav-operator-dashboard',
355
356
  sidebar: { group: 'configuration', order: 40 },
356
357
  },
358
+ {
359
+ id: 'reports',
360
+ labelKey: 'nav.reports',
361
+ icon: 'i-lucide-chart-column',
362
+ surfaces: S('sidebar'),
363
+ gate: (g) => g.accountsEnabled && g.isAccountAdmin,
364
+ action: 'reports',
365
+ testId: 'nav-reports',
366
+ sidebar: { group: 'configuration', order: 45 },
367
+ },
357
368
  {
358
369
  id: 'keyboard-shortcuts',
359
370
  labelKey: 'layout.commandBar.cmd.shortcuts',
@@ -35,6 +35,7 @@ const ObservabilityPanel = defineAsyncComponent(
35
35
  const OperatorDashboardPanel = defineAsyncComponent(
36
36
  () => import('~/components/panels/OperatorDashboardPanel.vue'),
37
37
  )
38
+ const ReportsPanel = defineAsyncComponent(() => import('~/components/panels/ReportsPanel.vue'))
38
39
  const KaizenPanel = defineAsyncComponent(() => import('~/components/kaizen/KaizenPanel.vue'))
39
40
  // Occasional, externally store-gated surfaces — deferred to their own chunks like the
40
41
  // sibling document modals above. Each mounts only while its ui open-flag is set, so it
@@ -400,6 +401,7 @@ watch(
400
401
  <RecurringPipelineModal v-if="ui.addRecurringFrameId" />
401
402
  <ObservabilityPanel v-if="ui.observabilityInstanceId" />
402
403
  <OperatorDashboardPanel v-if="ui.operatorDashboardOpen" />
404
+ <ReportsPanel v-if="ui.reportsOpen" />
403
405
  <KaizenPanel v-if="ui.kaizenScreenOpen" />
404
406
  <DocumentSourceConnectModal v-if="ui.documentConnect" />
405
407
  <DocumentImportModal v-if="ui.documentImport" />
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useAccountsStore } from '~/stores/accounts'
3
+ import { useReportsStore } from '~/stores/reports'
4
+ import type { ReportsView } from '~/types/execution'
5
+
6
+ // The reports view is loaded three ways that can each be triggered while another is still in
7
+ // flight — the window buttons, the board filter and the refresh button — so the store's
8
+ // monotonicity guard is the thing that keeps the panel from showing numbers for a window the
9
+ // user already left. These drive that race directly.
10
+
11
+ /** Minimal projection — only what the store stores and these assertions read. */
12
+ function view(over: Partial<ReportsView> = {}): ReportsView {
13
+ return {
14
+ window: '7d',
15
+ generatedAt: 1_000,
16
+ since: 0,
17
+ workspaceId: null,
18
+ currency: 'EUR',
19
+ totals: { inputTokens: 0, outputTokens: 0, calls: 0, meteredCost: 0, subscriptionCost: 0 },
20
+ spend: { byModel: [], byAgentKind: [], byWorkspace: [], byService: [], byTaskType: [] },
21
+ activity: { byWorkspace: [], byService: [], byTaskType: [] },
22
+ trend: { bucketMs: 1_000, points: [] },
23
+ ...over,
24
+ } as ReportsView
25
+ }
26
+
27
+ /** Seed an active account, since every load is scoped to one. */
28
+ function seedAccount() {
29
+ const accounts = useAccountsStore()
30
+ accounts.accounts = [
31
+ {
32
+ id: 'acc1',
33
+ type: 'org',
34
+ name: 'Acme',
35
+ githubAccountLogin: null,
36
+ createdAt: 0,
37
+ roles: null,
38
+ },
39
+ ] as never
40
+ accounts.activeAccountId = 'acc1'
41
+ }
42
+
43
+ describe('reports store — concurrent loads', () => {
44
+ beforeEach(() => {
45
+ seedAccount()
46
+ })
47
+
48
+ it('a stale load never overwrites a fresher one that already resolved', async () => {
49
+ // The user picks 24h, then immediately 90d. The 24h request is slower and lands LAST;
50
+ // committing it would leave the panel labelled 90d while showing 24h numbers.
51
+ let resolveSlow!: (v: ReportsView) => void
52
+ const slow = new Promise<ReportsView>((res) => {
53
+ resolveSlow = res
54
+ })
55
+ const responses: Record<string, Promise<ReportsView>> = {
56
+ '24h': slow,
57
+ '90d': Promise.resolve(view({ window: '90d', totals: { ...view().totals, calls: 42 } })),
58
+ }
59
+ vi.stubGlobal('useApi', () => ({
60
+ getReports: (_id: string, window: string) => responses[window]!,
61
+ }))
62
+ const store = useReportsStore()
63
+
64
+ const first = store.setWindow('24h')
65
+ const second = store.setWindow('90d')
66
+ await second
67
+ resolveSlow(view({ window: '24h', totals: { ...view().totals, calls: 7 } }))
68
+ await first
69
+
70
+ expect(store.view?.window).toBe('90d')
71
+ expect(store.view?.totals.calls).toBe(42)
72
+ // The superseded load must not resurrect the spinner it started either.
73
+ expect(store.loading).toBe(false)
74
+ })
75
+
76
+ it('a superseded FAILURE never replaces the newer view with an error', async () => {
77
+ // The same race the other way round: an in-flight load rejects after a later
78
+ // one succeeded. Committing it would blank a perfectly good panel.
79
+ let rejectSlow!: (e: Error) => void
80
+ const slow = new Promise<ReportsView>((_res, rej) => {
81
+ rejectSlow = rej
82
+ })
83
+ const responses: Record<string, Promise<ReportsView>> = {
84
+ '24h': slow,
85
+ '90d': Promise.resolve(view({ window: '90d' })),
86
+ }
87
+ vi.stubGlobal('useApi', () => ({
88
+ getReports: (_id: string, window: string) => responses[window]!,
89
+ }))
90
+ const store = useReportsStore()
91
+
92
+ const first = store.setWindow('24h')
93
+ await store.setWindow('90d')
94
+ rejectSlow(new Error('gateway timeout'))
95
+ await first
96
+
97
+ expect(store.failed).toBe(false)
98
+ expect(store.error).toBeNull()
99
+ expect(store.view?.window).toBe('90d')
100
+ })
101
+
102
+ it('records a failure from the newest load, with the raw message as detail', async () => {
103
+ vi.stubGlobal('useApi', () => ({
104
+ getReports: () => Promise.reject(new Error('reports are not available')),
105
+ }))
106
+ const store = useReportsStore()
107
+ await store.load()
108
+ expect(store.failed).toBe(true)
109
+ // Raw backend prose only — the localized heading is the panel's job.
110
+ expect(store.error).toBe('reports are not available')
111
+ expect(store.loading).toBe(false)
112
+ })
113
+ })
@@ -0,0 +1,91 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { ReportWindow, ReportsView } from '~/types/execution'
4
+ import { useAccountsStore } from '~/stores/accounts'
5
+
6
+ /**
7
+ * Reports: cross-cutting usage analytics for the active account — spend per model and
8
+ * agent kind, spend + run activity per workspace / service / task type, and a spend trend,
9
+ * over a selectable window and optionally narrowed to one board.
10
+ *
11
+ * The sibling of the `platformObservability` store: same account scope, same admin gate,
12
+ * same on-demand load. Nothing is pushed live (these are periodic rollups); changing the
13
+ * window or the board filter re-fetches, and a manual refresh re-fetches unconditionally.
14
+ */
15
+ export const useReportsStore = defineStore('reports', () => {
16
+ const api = useApi()
17
+ const accounts = useAccountsStore()
18
+
19
+ const window = ref<ReportWindow>('7d')
20
+ /** The single board every breakdown is narrowed to, or null for the whole account. */
21
+ const workspaceFilter = ref<string | null>(null)
22
+ const view = ref<ReportsView | null>(null)
23
+ const loading = ref(false)
24
+ /** Whether the last load failed. The panel owns the localized wording. */
25
+ const failed = ref(false)
26
+ /**
27
+ * The backend's raw (untranslated) message for a failed load, shown beneath the localized
28
+ * heading as a last-resort detail — null when the failure carried no message at all.
29
+ */
30
+ const error = ref<string | null>(null)
31
+
32
+ const accountId = computed(() => accounts.activeAccount?.id ?? null)
33
+
34
+ /**
35
+ * Monotonicity guard. The window buttons, the board filter and the refresh button can each
36
+ * start a load, so two are easily in flight at once — and without this a staler response
37
+ * resolving later would overwrite the newer view (and its `loading`/error state) with
38
+ * numbers for a window the user already moved off. Only the newest load may commit.
39
+ */
40
+ let latest = 0
41
+
42
+ async function load() {
43
+ const id = accountId.value
44
+ if (!id) {
45
+ view.value = null
46
+ return
47
+ }
48
+ const seq = ++latest
49
+ loading.value = true
50
+ failed.value = false
51
+ error.value = null
52
+ try {
53
+ const next = await api.getReports(id, window.value, workspaceFilter.value)
54
+ if (seq !== latest) return
55
+ view.value = next
56
+ } catch (err) {
57
+ if (seq !== latest) return
58
+ failed.value = true
59
+ error.value = err instanceof Error ? err.message : null
60
+ } finally {
61
+ if (seq === latest) loading.value = false
62
+ }
63
+ }
64
+
65
+ /** Switch the window and reload (a no-op when it is already the loaded one). */
66
+ async function setWindow(next: ReportWindow) {
67
+ if (next === window.value && view.value) return
68
+ window.value = next
69
+ await load()
70
+ }
71
+
72
+ /** Narrow to one board (null = the whole account) and reload. */
73
+ async function setWorkspaceFilter(next: string | null) {
74
+ if (next === workspaceFilter.value && view.value) return
75
+ workspaceFilter.value = next
76
+ await load()
77
+ }
78
+
79
+ return {
80
+ window,
81
+ workspaceFilter,
82
+ view,
83
+ loading,
84
+ failed,
85
+ error,
86
+ accountId,
87
+ load,
88
+ setWindow,
89
+ setWorkspaceFilter,
90
+ }
91
+ })
@@ -440,6 +440,10 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
440
440
  // from `observabilityConnectionOpen` (the Datadog connection) AND `observabilityInstanceId`
441
441
  // (the per-run LLM call panel).
442
442
  const operatorDashboardOpen = ref(false)
443
+ // Reports: the cross-cutting usage-analytics panel over the same account scope (spend per
444
+ // model / agent kind, spend + activity per workspace / service / task type). Admin-gated.
445
+ // Distinct from `operatorDashboardOpen`, which answers the deployment-HEALTH question.
446
+ const reportsOpen = ref(false)
443
447
  // Private package registries: the workspace's npm/GitHub-Packages entries agent
444
448
  // containers install with. Opened from the Integrations hub.
445
449
  const packageRegistriesOpen = ref(false)
@@ -486,6 +490,13 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
486
490
  function closeOperatorDashboard() {
487
491
  operatorDashboardOpen.value = false
488
492
  }
493
+ function openReports() {
494
+ resetHubReturn()
495
+ reportsOpen.value = true
496
+ }
497
+ function closeReports() {
498
+ reportsOpen.value = false
499
+ }
489
500
  function openPackageRegistries() {
490
501
  resetHubReturn()
491
502
  packageRegistriesOpen.value = true
@@ -544,6 +555,7 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
544
555
  slackOpen,
545
556
  observabilityConnectionOpen,
546
557
  operatorDashboardOpen,
558
+ reportsOpen,
547
559
  packageRegistriesOpen,
548
560
  apiTokensOpen,
549
561
  modelConfigOpen,
@@ -559,6 +571,8 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
559
571
  openObservabilityConnection,
560
572
  closeObservabilityConnection,
561
573
  openOperatorDashboard,
574
+ openReports,
575
+ closeReports,
562
576
  closeOperatorDashboard,
563
577
  openPackageRegistries,
564
578
  closePackageRegistries,
@@ -30,6 +30,14 @@ export type {
30
30
  PlatformOutcomeTotals,
31
31
  PlatformTrendPoint,
32
32
  PlatformFailureSlice,
33
+ ReportWindow,
34
+ ReportSpendDimension,
35
+ ReportActivityDimension,
36
+ ReportSpendRow,
37
+ ReportActivityRow,
38
+ ReportTrendPoint,
39
+ ReportTotals,
40
+ ReportsView,
33
41
  AgentSearchQuery,
34
42
  WebSearchAvailability,
35
43
  WebSearchProvider,
@@ -3000,6 +3000,70 @@
3000
3000
  "title": "Jetzt aktiv"
3001
3001
  }
3002
3002
  },
3003
+ "reports": {
3004
+ "title": "Berichte",
3005
+ "refresh": "Aktualisieren",
3006
+ "loading": "Berichte werden geladen…",
3007
+ "retry": "Erneut versuchen",
3008
+ "error": "Berichte konnten nicht geladen werden.",
3009
+ "period": "{from} bis {to}",
3010
+ "unattributed": "Nicht zugeordnet",
3011
+ "window": {
3012
+ "oneDay": "Letzte 24 Stunden",
3013
+ "sevenDays": "Letzte 7 Tage",
3014
+ "thirtyDays": "Letzte 30 Tage",
3015
+ "ninetyDays": "Letzte 90 Tage"
3016
+ },
3017
+ "filter": {
3018
+ "board": "Board",
3019
+ "allBoards": "Alle Boards"
3020
+ },
3021
+ "dimension": {
3022
+ "workspace": "Board",
3023
+ "service": "Service",
3024
+ "taskType": "Aufgabentyp"
3025
+ },
3026
+ "breakdown": {
3027
+ "title": "Aufschlüsselung nach"
3028
+ },
3029
+ "totals": {
3030
+ "title": "In diesem Zeitraum",
3031
+ "metered": "Abgerechnete Kosten",
3032
+ "subscription": "Abo",
3033
+ "calls": "LLM-Aufrufe",
3034
+ "tokens": "Tokens",
3035
+ "illustrative": "Abokosten sind nur kalkulatorisch – diese Tarife sind pauschal, werden nicht pro Token abgerechnet und zählen nie als Ausgaben."
3036
+ },
3037
+ "trend": {
3038
+ "title": "Kosten im Zeitverlauf",
3039
+ "empty": "In diesem Zeitraum wurde keine Nutzung erfasst."
3040
+ },
3041
+ "legend": {
3042
+ "metered": "Abgerechnet",
3043
+ "subscription": "Abo"
3044
+ },
3045
+ "spend": {
3046
+ "byModel": "Kosten nach Modell",
3047
+ "byAgentKind": "Kosten nach Agententyp",
3048
+ "heading": "Kosten",
3049
+ "empty": "In diesem Zeitraum wurde keine Nutzung erfasst.",
3050
+ "calls": "{count} Aufruf | {count} Aufrufe",
3051
+ "tokens": "{input} rein / {output} raus",
3052
+ "subscriptionAside": "+{value} Abo"
3053
+ },
3054
+ "activity": {
3055
+ "heading": "Läufe",
3056
+ "empty": "In diesem Zeitraum gab es keine Läufe.",
3057
+ "runs": "{count} Lauf | {count} Läufe",
3058
+ "avg": "Ø {value}"
3059
+ },
3060
+ "status": {
3061
+ "done": "Abgeschlossen",
3062
+ "failed": "Fehlgeschlagen",
3063
+ "running": "Läuft",
3064
+ "other": "Sonstige"
3065
+ }
3066
+ },
3003
3067
  "auth": {
3004
3068
  "gate": {
3005
3069
  "loading": "Wird geladen…"
@@ -4159,6 +4223,7 @@
4159
4223
  "modelConfiguration": "Modellkonfiguration",
4160
4224
  "accountSettings": "Kontoeinstellungen",
4161
4225
  "operatorDashboard": "Plattform-Observability",
4226
+ "reports": "Berichte",
4162
4227
  "environmentSetup": "Umgebungseinrichtung"
4163
4228
  },
4164
4229
  "errors": {
@@ -130,6 +130,7 @@
130
130
  "modelConfiguration": "Model configuration",
131
131
  "accountSettings": "Account settings",
132
132
  "operatorDashboard": "Platform observability",
133
+ "reports": "Reports",
133
134
  "environmentSetup": "Environment setup"
134
135
  },
135
136
  "board": {
@@ -1524,6 +1525,70 @@
1524
1525
  "title": "Live now"
1525
1526
  }
1526
1527
  },
1528
+ "reports": {
1529
+ "title": "Reports",
1530
+ "refresh": "Refresh",
1531
+ "loading": "Loading reports…",
1532
+ "retry": "Retry",
1533
+ "error": "Reports could not be loaded.",
1534
+ "period": "{from} to {to}",
1535
+ "unattributed": "Unattributed",
1536
+ "window": {
1537
+ "oneDay": "Last 24 hours",
1538
+ "sevenDays": "Last 7 days",
1539
+ "thirtyDays": "Last 30 days",
1540
+ "ninetyDays": "Last 90 days"
1541
+ },
1542
+ "filter": {
1543
+ "board": "Board",
1544
+ "allBoards": "All boards"
1545
+ },
1546
+ "dimension": {
1547
+ "workspace": "Board",
1548
+ "service": "Service",
1549
+ "taskType": "Task type"
1550
+ },
1551
+ "breakdown": {
1552
+ "title": "Breakdown by"
1553
+ },
1554
+ "totals": {
1555
+ "title": "This window",
1556
+ "metered": "Metered spend",
1557
+ "subscription": "Subscription",
1558
+ "calls": "LLM calls",
1559
+ "tokens": "Tokens",
1560
+ "illustrative": "Subscription costs are illustrative — those plans are flat-rate, not billed per token, and are never counted as spend."
1561
+ },
1562
+ "trend": {
1563
+ "title": "Spend over time",
1564
+ "empty": "No recorded usage in this window."
1565
+ },
1566
+ "legend": {
1567
+ "metered": "Metered",
1568
+ "subscription": "Subscription"
1569
+ },
1570
+ "spend": {
1571
+ "byModel": "Spend by model",
1572
+ "byAgentKind": "Spend by agent kind",
1573
+ "heading": "Spend",
1574
+ "empty": "No recorded usage in this window.",
1575
+ "calls": "{count} call | {count} calls",
1576
+ "tokens": "{input} in / {output} out",
1577
+ "subscriptionAside": "+{value} subscription"
1578
+ },
1579
+ "activity": {
1580
+ "heading": "Runs",
1581
+ "empty": "No runs in this window.",
1582
+ "runs": "{count} run | {count} runs",
1583
+ "avg": "avg {value}"
1584
+ },
1585
+ "status": {
1586
+ "done": "Completed",
1587
+ "failed": "Failed",
1588
+ "running": "Running",
1589
+ "other": "Other"
1590
+ }
1591
+ },
1527
1592
  "auth": {
1528
1593
  "gate": {
1529
1594
  "loading": "Loading…"
@@ -111,6 +111,7 @@
111
111
  "modelConfiguration": "Configuración del modelo",
112
112
  "accountSettings": "Ajustes de la cuenta",
113
113
  "operatorDashboard": "Observabilidad de la plataforma",
114
+ "reports": "Informes",
114
115
  "infrastructure": "Infraestructura",
115
116
  "environmentSetup": "Configuración de entorno"
116
117
  },
@@ -1461,6 +1462,70 @@
1461
1462
  "title": "Activas ahora"
1462
1463
  }
1463
1464
  },
1465
+ "reports": {
1466
+ "title": "Informes",
1467
+ "refresh": "Actualizar",
1468
+ "loading": "Cargando informes…",
1469
+ "retry": "Reintentar",
1470
+ "error": "No se pudieron cargar los informes.",
1471
+ "period": "Del {from} al {to}",
1472
+ "unattributed": "Sin atribuir",
1473
+ "window": {
1474
+ "oneDay": "Últimas 24 horas",
1475
+ "sevenDays": "Últimos 7 días",
1476
+ "thirtyDays": "Últimos 30 días",
1477
+ "ninetyDays": "Últimos 90 días"
1478
+ },
1479
+ "filter": {
1480
+ "board": "Tablero",
1481
+ "allBoards": "Todos los tableros"
1482
+ },
1483
+ "dimension": {
1484
+ "workspace": "Tablero",
1485
+ "service": "Servicio",
1486
+ "taskType": "Tipo de tarea"
1487
+ },
1488
+ "breakdown": {
1489
+ "title": "Desglose por"
1490
+ },
1491
+ "totals": {
1492
+ "title": "En este periodo",
1493
+ "metered": "Gasto medido",
1494
+ "subscription": "Suscripción",
1495
+ "calls": "Llamadas al LLM",
1496
+ "tokens": "Tokens",
1497
+ "illustrative": "Los costes de suscripción son orientativos: esos planes son de tarifa plana, no se facturan por token y nunca cuentan como gasto."
1498
+ },
1499
+ "trend": {
1500
+ "title": "Gasto a lo largo del tiempo",
1501
+ "empty": "No se registró uso en este periodo."
1502
+ },
1503
+ "legend": {
1504
+ "metered": "Medido",
1505
+ "subscription": "Suscripción"
1506
+ },
1507
+ "spend": {
1508
+ "byModel": "Gasto por modelo",
1509
+ "byAgentKind": "Gasto por tipo de agente",
1510
+ "heading": "Gasto",
1511
+ "empty": "No se registró uso en este periodo.",
1512
+ "calls": "{count} llamada | {count} llamadas",
1513
+ "tokens": "{input} de entrada / {output} de salida",
1514
+ "subscriptionAside": "+{value} suscripción"
1515
+ },
1516
+ "activity": {
1517
+ "heading": "Ejecuciones",
1518
+ "empty": "No hubo ejecuciones en este periodo.",
1519
+ "runs": "{count} ejecución | {count} ejecuciones",
1520
+ "avg": "media {value}"
1521
+ },
1522
+ "status": {
1523
+ "done": "Completadas",
1524
+ "failed": "Fallidas",
1525
+ "running": "En curso",
1526
+ "other": "Otras"
1527
+ }
1528
+ },
1464
1529
  "auth": {
1465
1530
  "gate": {
1466
1531
  "loading": "Cargando…"