@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
@@ -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,
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { apiOriginFor, wsOriginFor } from '~/utils/apiOrigin'
3
+
4
+ // The split-origin deployment (an absolute apiBase) and the same-origin one (empty apiBase, one
5
+ // reverse proxy in front of both halves — the compose preview stack) must both produce a usable
6
+ // socket origin. An empty apiBase used to yield a RELATIVE WebSocket URL, which is exactly the
7
+ // case a preview stack hits: its host port is assigned at `up` time, so no absolute origin can be
8
+ // baked into the build.
9
+
10
+ describe('apiOriginFor', () => {
11
+ it('keeps an absolute apiBase (split origins)', () => {
12
+ expect(apiOriginFor('https://api.example.com', 'https://app.example.com')).toBe(
13
+ 'https://api.example.com',
14
+ )
15
+ })
16
+
17
+ it('falls back to the page origin when apiBase is empty or blank (same origin)', () => {
18
+ expect(apiOriginFor('', 'http://localhost:49153')).toBe('http://localhost:49153')
19
+ expect(apiOriginFor(' ', 'http://localhost:49153')).toBe('http://localhost:49153')
20
+ })
21
+
22
+ it('is empty when neither is known (SSR — no socket is opened there)', () => {
23
+ expect(apiOriginFor('', '')).toBe('')
24
+ })
25
+ })
26
+
27
+ describe('wsOriginFor', () => {
28
+ it('maps http→ws and https→wss', () => {
29
+ expect(wsOriginFor('http://localhost:8787', '')).toBe('ws://localhost:8787')
30
+ expect(wsOriginFor('https://api.example.com', '')).toBe('wss://api.example.com')
31
+ })
32
+
33
+ it('derives the socket origin from the page for a same-origin deployment', () => {
34
+ expect(wsOriginFor('', 'https://preview.example.com')).toBe('wss://preview.example.com')
35
+ expect(wsOriginFor('', 'http://localhost:49153')).toBe('ws://localhost:49153')
36
+ })
37
+ })
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Where the SPA's API lives, resolved from the build-time `runtimeConfig.public.apiBase`.
3
+ *
4
+ * Two deployment topologies are supported and they differ ONLY here:
5
+ *
6
+ * - **Split origins** (the production Cloudflare/Node deployments): `apiBase` is an absolute
7
+ * origin (`https://api.example.com`), the SPA is served from a different one, and the backend
8
+ * allow-lists the SPA via `CORS_ALLOWED_ORIGINS`.
9
+ * - **Same origin**: `apiBase` is EMPTY and one reverse proxy serves both the static SPA and the
10
+ * API (the compose preview stack in `deploy/preview/compose`). REST calls are then relative and
11
+ * need nothing extra — but the WebSocket URL does, because the socket origin cannot be derived
12
+ * from an empty string. That is what this module exists for: it substitutes the PAGE's origin,
13
+ * so the stream connects to whatever host/port the stack happens to be published on. A preview
14
+ * stack's host port is assigned at `up` time, so it is not knowable when the SPA is built —
15
+ * same-origin is the only topology that works there.
16
+ */
17
+
18
+ /** The API origin the SPA should talk to: `apiBase` when set, else the page's own origin. */
19
+ export function apiOriginFor(apiBase: string, pageOrigin: string): string {
20
+ return apiBase.trim() || pageOrigin
21
+ }
22
+
23
+ /**
24
+ * The WebSocket origin (`http`→`ws`, `https`→`wss`) for the API. Falls back to `pageOrigin` for
25
+ * the same-origin topology; returns `''` only when neither is known (SSR, where no socket is
26
+ * opened anyway).
27
+ */
28
+ export function wsOriginFor(apiBase: string, pageOrigin: string): string {
29
+ return apiOriginFor(apiBase, pageOrigin).replace(/^http/, 'ws')
30
+ }
@@ -173,7 +173,26 @@
173
173
  "loading": "Infrastruktureinstellungen werden geladen…",
174
174
  "kubeNoun": "der Kubernetes-Handler",
175
175
  "kubeOverrideNoun": "der Kubernetes-Override",
176
- "customNoun": "dieser Handler"
176
+ "customNoun": "dieser Handler",
177
+ "cloudflareNoun": "den Cloudflare-Handler"
178
+ },
179
+ "cloudflare": {
180
+ "intro": "Startet pro Pull Request einen Cloudflare Worker, indem der Preview-Workflow des Ziel-Repositorys angestoßen wird. cat-factory spricht nie direkt mit Cloudflare: Es legt ein Deployment an, liest dessen Status und setzt zum Abbau einen inactive-Status.",
181
+ "label": "Anzeigename",
182
+ "subdomain": "workers.dev-Subdomain",
183
+ "subdomainHint": "Die Preview-URL lautet https://WORKER.SUBDOMAIN.workers.dev — gib nur das Subdomain-Label ein, ohne \".workers.dev\".",
184
+ "token": "VCS-API-Token",
185
+ "tokenPlaceholder": "Fein granuliertes Token einfügen",
186
+ "tokenKeep": "Leer lassen, um das gespeicherte Token zu behalten",
187
+ "tokenHint": "Benötigt \"Deployments: read & write\" für das Repository und sonst nichts.",
188
+ "repo": "Workflow-Repository (optional)",
189
+ "repoHint": "Leer lassen, um das jeweilige Repository des Service-Frames zu verwenden. Nur festlegen, wenn ein Repository den Preview-Workflow für mehrere Services enthält.",
190
+ "advanced": "Erweitert",
191
+ "workerTemplate": "Vorlage für den Worker-Namen",
192
+ "environmentTemplate": "Vorlage für den Deployment-Umgebungsnamen",
193
+ "templateHint": "Diese Werte sind der Vertrag mit dem Preview-Workflow: cat-factory leitet daraus die URL ab, und der Workflow benennt seine Ressourcen genauso. Leer lassen, um die Benennung des Referenz-Workflows zu verwenden. Verfügbare Platzhalter: pullNumber, branch, blockId.",
194
+ "apiBaseUrl": "Basis-URL der VCS-API",
195
+ "connectedAs": "Previews werden auf {subdomain}.workers.dev bereitgestellt."
177
196
  }
178
197
  },
179
198
  "providerConnection": {
@@ -1100,6 +1119,7 @@
1100
1119
  "infraless": "Keine Infrastruktur",
1101
1120
  "docker-compose": "Docker Compose",
1102
1121
  "kubernetes": "Kubernetes",
1122
+ "cloudflare": "Cloudflare Workers-Preview",
1103
1123
  "custom": "Benutzerdefiniert"
1104
1124
  },
1105
1125
  "composePath": "docker-compose-Pfad",
@@ -1142,6 +1162,7 @@
1142
1162
  "rendererHint": "Raw wendet die Manifeste unverändert an. Kustomize baut zuerst ein Overlay (benötigt den Container-Deploy-Adapter).",
1143
1163
  "customManifestId": "Benutzerdefinierter Manifesttyp",
1144
1164
  "customManifestIdPlaceholder": "Wähle einen Manifesttyp",
1165
+ "cloudflareHint": "Hier gibt es nichts zu konfigurieren: Das Rezept pro Pull Request liegt im Preview-Workflow dieses Repositorys. Verbinde unter Infrastruktur einen Cloudflare Workers-Handler, um Konto und Benennung festzulegen.",
1145
1166
  "customNoTypes": "Es sind noch keine benutzerdefinierten Manifesttypen definiert. Füge einen im Infrastruktur-Fenster hinzu.",
1146
1167
  "customManifestIdHint": "Der benutzerdefinierte Typ, den dieser Service erzeugt, zugeordnet zu einem Remote-Custom-Handler, den der Workspace konfiguriert.",
1147
1168
  "customManifestPath": "Manifest-Pfad (optional)",
@@ -2979,6 +3000,70 @@
2979
3000
  "title": "Jetzt aktiv"
2980
3001
  }
2981
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
+ },
2982
3067
  "auth": {
2983
3068
  "gate": {
2984
3069
  "loading": "Wird geladen…"
@@ -4138,6 +4223,7 @@
4138
4223
  "modelConfiguration": "Modellkonfiguration",
4139
4224
  "accountSettings": "Kontoeinstellungen",
4140
4225
  "operatorDashboard": "Plattform-Observability",
4226
+ "reports": "Berichte",
4141
4227
  "environmentSetup": "Umgebungseinrichtung"
4142
4228
  },
4143
4229
  "errors": {
@@ -4756,6 +4842,7 @@
4756
4842
  "provisionType": {
4757
4843
  "kubernetes": "Kubernetes",
4758
4844
  "docker-compose": "Docker Compose",
4845
+ "cloudflare": "Cloudflare Workers",
4759
4846
  "custom": "Benutzerdefiniert",
4760
4847
  "infraless": "Keine Infrastruktur"
4761
4848
  },
@@ -4763,6 +4850,7 @@
4763
4850
  "local-docker": "Lokales Docker",
4764
4851
  "local-k3s": "Lokales k3s",
4765
4852
  "remote-kubernetes": "Remote-Kubernetes",
4853
+ "cloudflare": "Cloudflare-Preview",
4766
4854
  "remote-custom": "Remote benutzerdefiniert (HTTP)",
4767
4855
  "none": "Keine"
4768
4856
  }
@@ -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": {
@@ -860,6 +861,7 @@
860
861
  "infraless": "No infrastructure",
861
862
  "docker-compose": "Docker Compose",
862
863
  "kubernetes": "Kubernetes",
864
+ "cloudflare": "Cloudflare Workers preview",
863
865
  "custom": "Custom"
864
866
  },
865
867
  "composePath": "docker-compose path",
@@ -902,6 +904,7 @@
902
904
  "rendererHint": "Raw applies the manifests as-is. Kustomize builds an overlay first (needs the container deploy adapter).",
903
905
  "customManifestId": "Custom manifest type",
904
906
  "customManifestIdPlaceholder": "Pick a manifest type",
907
+ "cloudflareHint": "Nothing to configure here: the per-pull-request recipe lives in this repository’s own preview workflow. Connect a Cloudflare Workers handler under Infrastructure to choose the account and the naming.",
905
908
  "customNoTypes": "No custom manifest types are defined yet. Add one in the Infrastructure window.",
906
909
  "customManifestIdHint": "The custom type this service produces, matched to a remote-custom handler the workspace configures.",
907
910
  "customManifestPath": "Manifest path (optional)",
@@ -1522,6 +1525,70 @@
1522
1525
  "title": "Live now"
1523
1526
  }
1524
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
+ },
1525
1592
  "auth": {
1526
1593
  "gate": {
1527
1594
  "loading": "Loading…"
@@ -2256,7 +2323,26 @@
2256
2323
  "loading": "Loading infrastructure settings…",
2257
2324
  "kubeNoun": "the Kubernetes handler",
2258
2325
  "kubeOverrideNoun": "the Kubernetes override",
2259
- "customNoun": "this handler"
2326
+ "customNoun": "this handler",
2327
+ "cloudflareNoun": "the Cloudflare handler"
2328
+ },
2329
+ "cloudflare": {
2330
+ "intro": "Stands up a per-pull-request Cloudflare Worker by driving the target repository's own preview workflow. cat-factory never talks to Cloudflare directly: it creates a deployment, reads its status, and posts an inactive status to tear down.",
2331
+ "label": "Display name",
2332
+ "subdomain": "workers.dev subdomain",
2333
+ "subdomainHint": "The preview URL becomes https://WORKER.SUBDOMAIN.workers.dev — enter just the subdomain label, without \".workers.dev\".",
2334
+ "token": "VCS API token",
2335
+ "tokenPlaceholder": "Paste a fine-grained token",
2336
+ "tokenKeep": "Leave blank to keep the stored token",
2337
+ "tokenHint": "Needs Deployments: read & write on the repository, and nothing else.",
2338
+ "repo": "Workflow repository (optional)",
2339
+ "repoHint": "Leave blank to use each service frame's own repository. Pin it only when one repository holds the preview workflow for several services.",
2340
+ "advanced": "Advanced",
2341
+ "workerTemplate": "Worker name template",
2342
+ "environmentTemplate": "Deployment environment template",
2343
+ "templateHint": "These are the contract with the preview workflow: cat-factory derives the URL from them and the workflow names its resources the same way. Leave blank to use the reference workflow's naming. Available placeholders: pullNumber, branch, blockId.",
2344
+ "apiBaseUrl": "VCS API base URL",
2345
+ "connectedAs": "Previews deploy to {subdomain}.workers.dev."
2260
2346
  }
2261
2347
  },
2262
2348
  "providerConnection": {
@@ -4599,6 +4685,7 @@
4599
4685
  "provisionType": {
4600
4686
  "kubernetes": "Kubernetes",
4601
4687
  "docker-compose": "Docker Compose",
4688
+ "cloudflare": "Cloudflare Workers",
4602
4689
  "custom": "Custom",
4603
4690
  "infraless": "No infrastructure"
4604
4691
  },
@@ -4606,6 +4693,7 @@
4606
4693
  "local-docker": "Local Docker",
4607
4694
  "local-k3s": "Local k3s",
4608
4695
  "remote-kubernetes": "Remote Kubernetes",
4696
+ "cloudflare": "Cloudflare preview",
4609
4697
  "remote-custom": "Remote custom (HTTP)",
4610
4698
  "none": "None"
4611
4699
  }