@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,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>
@@ -104,6 +104,7 @@ const PROVISION_TYPES = computed<{ value: ProvisionType; label: string }[]>(() =
104
104
  { value: 'infraless', label: t('inspector.testConfig.provisionTypes.infraless') },
105
105
  { value: 'docker-compose', label: t('inspector.testConfig.provisionTypes.docker-compose') },
106
106
  { value: 'kubernetes', label: t('inspector.testConfig.provisionTypes.kubernetes') },
107
+ { value: 'cloudflare', label: t('inspector.testConfig.provisionTypes.cloudflare') },
107
108
  { value: 'custom', label: t('inspector.testConfig.provisionTypes.custom') },
108
109
  ])
109
110
 
@@ -869,6 +870,14 @@ function setSize(value: InstanceSize) {
869
870
  </div>
870
871
  </div>
871
872
 
873
+ <!-- cloudflare: nothing to collect. Unlike compose (a path) or kubernetes (a manifest
874
+ source), the per-PR recipe lives in the target repository's own preview workflow, so
875
+ declaring the type IS the whole service-side configuration. Say so explicitly rather
876
+ than rendering an empty panel that reads like something failed to load. -->
877
+ <p v-if="provisionType === 'cloudflare'" class="text-[11px] text-slate-500">
878
+ {{ t('inspector.testConfig.cloudflareHint') }}
879
+ </p>
880
+
872
881
  <!-- custom: pin the custom manifest type this service produces (matched to a remote-custom
873
882
  handler the workspace configures). -->
874
883
  <div v-if="provisionType === 'custom'" class="space-y-2">
@@ -0,0 +1,319 @@
1
+ <script setup lang="ts">
2
+ // The `cloudflare` provision type's infra handler (the workspace "how"): the Cloudflare
3
+ // account's workers.dev subdomain, the VCS API token, and the two name templates that are the
4
+ // contract with the target repository's preview workflow.
5
+ //
6
+ // A SELF-CONTAINED section rather than another branch of InfraHandlersConfigurator: that file
7
+ // already carries three provision types at ~600 lines, and a fourth inline would push it well
8
+ // past the point where any single type is findable. It owns its own save/remove/test so the
9
+ // configurator gains exactly one line.
10
+ //
11
+ // There is deliberately no per-user (local-mode) override here, unlike kubernetes: a Cloudflare
12
+ // preview is built by CI and lives in the cloud, so "this machine only" has no meaning for it.
13
+ import { computed, ref, watch } from 'vue'
14
+ import type { InfraHandlerConfig } from '@cat-factory/contracts'
15
+
16
+ type CloudflareHandlerConfig = Extract<InfraHandlerConfig, { engine: 'cloudflare' }>
17
+ type CloudflareConfig = CloudflareHandlerConfig['cloudflare']
18
+
19
+ const { t } = useI18n()
20
+ const infra = useInfraConfigStore()
21
+ const toast = useToast()
22
+ const { confirmAction } = useConfirmAction()
23
+
24
+ const busy = ref(false)
25
+ const testing = ref(false)
26
+ const testResult = ref<{ ok: boolean; message?: string } | null>(null)
27
+ const editing = ref(false)
28
+
29
+ const handler = computed(() => infra.handlerFor('cloudflare') ?? null)
30
+
31
+ // The form state. `label` is prefilled so an operator who fills in only the subdomain still
32
+ // produces a valid config; the two templates are left EMPTY on purpose — blank means "use the
33
+ // reference workflow's naming", and showing the defaults as placeholders rather than values
34
+ // keeps an untouched handler from pinning a shape it never chose.
35
+ const label = ref('Cloudflare Workers preview')
36
+ const workersSubdomain = ref('')
37
+ const repo = ref('')
38
+ const apiBaseUrl = ref('')
39
+ const workerNameTemplate = ref('')
40
+ const environmentNameTemplate = ref('')
41
+ const token = ref('')
42
+
43
+ // Prefill from the saved handler whenever it (re)loads, so re-opening the form edits the
44
+ // stored config instead of silently starting from blank and overwriting it on save. The token
45
+ // is never echoed back by the API, so it stays empty — see `secretsForSave`.
46
+ watch(
47
+ handler,
48
+ (h) => {
49
+ const cfg = (h?.config as CloudflareHandlerConfig | undefined)?.cloudflare
50
+ if (!cfg) return
51
+ label.value = cfg.label
52
+ workersSubdomain.value = cfg.workersSubdomain
53
+ repo.value = cfg.repo ?? ''
54
+ apiBaseUrl.value = cfg.apiBaseUrl ?? ''
55
+ workerNameTemplate.value = cfg.workerNameTemplate ?? ''
56
+ environmentNameTemplate.value = cfg.environmentNameTemplate ?? ''
57
+ },
58
+ { immediate: true },
59
+ )
60
+
61
+ const canSave = computed(() => workersSubdomain.value.trim() !== '' && label.value.trim() !== '')
62
+
63
+ function buildConfig(): CloudflareHandlerConfig {
64
+ const trimmed = (value: string) => value.trim()
65
+ const cloudflare: CloudflareConfig = {
66
+ label: trimmed(label.value),
67
+ workersSubdomain: trimmed(workersSubdomain.value),
68
+ // Every optional field is OMITTED when blank rather than sent as an empty string: the
69
+ // contract's optionals mean "fall back to the default", and an empty string would fail
70
+ // the schema's own minLength/regex checks instead.
71
+ ...(trimmed(repo.value) ? { repo: trimmed(repo.value) } : {}),
72
+ ...(trimmed(apiBaseUrl.value) ? { apiBaseUrl: trimmed(apiBaseUrl.value) } : {}),
73
+ ...(trimmed(workerNameTemplate.value)
74
+ ? { workerNameTemplate: trimmed(workerNameTemplate.value) }
75
+ : {}),
76
+ ...(trimmed(environmentNameTemplate.value)
77
+ ? { environmentNameTemplate: trimmed(environmentNameTemplate.value) }
78
+ : {}),
79
+ }
80
+ return { engine: 'cloudflare', cloudflare }
81
+ }
82
+
83
+ /**
84
+ * An UNTOUCHED token field on an already-connected handler sends no secret, so the stored one
85
+ * is kept. Sending `''` would clear it and leave the handler unable to authenticate.
86
+ */
87
+ function secretsForSave(): Record<string, string> {
88
+ const value = token.value.trim()
89
+ return value ? { githubToken: value } : {}
90
+ }
91
+
92
+ async function save() {
93
+ if (!canSave.value) return
94
+ busy.value = true
95
+ try {
96
+ await infra.registerHandler({
97
+ provisionType: 'cloudflare',
98
+ config: buildConfig(),
99
+ secrets: secretsForSave(),
100
+ })
101
+ token.value = ''
102
+ editing.value = false
103
+ toast.add({
104
+ title: t('settings.infrastructure.handler.saved'),
105
+ icon: 'i-lucide-check',
106
+ color: 'success',
107
+ })
108
+ } catch (e) {
109
+ toast.add({
110
+ title: t('settings.infrastructure.handler.saveFailed'),
111
+ description: e instanceof Error ? e.message : String(e),
112
+ icon: 'i-lucide-triangle-alert',
113
+ color: 'error',
114
+ })
115
+ } finally {
116
+ busy.value = false
117
+ }
118
+ }
119
+
120
+ async function test() {
121
+ testing.value = true
122
+ testResult.value = null
123
+ try {
124
+ testResult.value = await infra.testHandler({
125
+ config: buildConfig(),
126
+ secrets: secretsForSave(),
127
+ })
128
+ } catch (e) {
129
+ testResult.value = { ok: false, message: e instanceof Error ? e.message : String(e) }
130
+ } finally {
131
+ testing.value = false
132
+ }
133
+ }
134
+
135
+ async function remove() {
136
+ if (!(await confirmAction('remove', t('settings.infrastructure.handler.cloudflareNoun')))) return
137
+ busy.value = true
138
+ try {
139
+ await infra.unregisterHandler('cloudflare')
140
+ toast.add({ title: t('settings.infrastructure.handler.removed'), icon: 'i-lucide-check' })
141
+ } catch (e) {
142
+ toast.add({
143
+ title: t('settings.infrastructure.handler.saveFailed'),
144
+ description: e instanceof Error ? e.message : String(e),
145
+ icon: 'i-lucide-triangle-alert',
146
+ color: 'error',
147
+ })
148
+ } finally {
149
+ busy.value = false
150
+ }
151
+ }
152
+ </script>
153
+
154
+ <template>
155
+ <section class="space-y-2 rounded-lg border border-slate-700 bg-slate-900/40 p-3">
156
+ <h3 class="text-sm font-semibold text-slate-200">
157
+ {{ t('inspector.testConfig.provisionTypes.cloudflare') }}
158
+ </h3>
159
+ <p class="text-[11px] text-slate-400">
160
+ {{ t('settings.infrastructure.cloudflare.intro') }}
161
+ </p>
162
+
163
+ <div
164
+ v-if="handler && !editing"
165
+ class="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-2.5"
166
+ data-testid="cloudflare-handler-connected"
167
+ >
168
+ <div class="flex items-start justify-between gap-2">
169
+ <UCheckbox
170
+ :model-value="true"
171
+ disabled
172
+ size="lg"
173
+ :label="t('settings.infrastructure.handler.connectionEstablished')"
174
+ :ui="{ label: 'text-[13px] font-semibold text-emerald-300' }"
175
+ />
176
+ <div class="flex items-center gap-1">
177
+ <UButton
178
+ icon="i-lucide-pencil"
179
+ color="neutral"
180
+ variant="ghost"
181
+ size="xs"
182
+ :disabled="busy"
183
+ :aria-label="t('common.edit')"
184
+ @click="editing = true"
185
+ />
186
+ <UButton
187
+ icon="i-lucide-trash-2"
188
+ color="error"
189
+ variant="ghost"
190
+ size="xs"
191
+ :disabled="busy"
192
+ :aria-label="t('settings.infrastructure.handler.disconnect')"
193
+ @click="remove"
194
+ />
195
+ </div>
196
+ </div>
197
+ <p class="pl-7 text-[11px] text-slate-300">
198
+ {{ t('settings.infrastructure.cloudflare.connectedAs', { subdomain: workersSubdomain }) }}
199
+ </p>
200
+ </div>
201
+
202
+ <div v-else class="space-y-2" data-testid="cloudflare-handler-form">
203
+ <div class="space-y-1">
204
+ <label class="text-[11px] text-slate-400">{{
205
+ t('settings.infrastructure.cloudflare.label')
206
+ }}</label>
207
+ <UInput v-model="label" size="xs" />
208
+ </div>
209
+
210
+ <div class="space-y-1">
211
+ <label class="text-[11px] text-slate-400">{{
212
+ t('settings.infrastructure.cloudflare.subdomain')
213
+ }}</label>
214
+ <UInput v-model="workersSubdomain" size="xs" placeholder="my-account" />
215
+ <p class="text-[11px] text-slate-500">
216
+ {{ t('settings.infrastructure.cloudflare.subdomainHint') }}
217
+ </p>
218
+ </div>
219
+
220
+ <div class="space-y-1">
221
+ <label class="text-[11px] text-slate-400">{{
222
+ t('settings.infrastructure.cloudflare.token')
223
+ }}</label>
224
+ <UInput
225
+ v-model="token"
226
+ type="password"
227
+ size="xs"
228
+ :placeholder="
229
+ handler
230
+ ? t('settings.infrastructure.cloudflare.tokenKeep')
231
+ : t('settings.infrastructure.cloudflare.tokenPlaceholder')
232
+ "
233
+ />
234
+ <p class="text-[11px] text-slate-500">
235
+ {{ t('settings.infrastructure.cloudflare.tokenHint') }}
236
+ </p>
237
+ </div>
238
+
239
+ <div class="space-y-1">
240
+ <label class="text-[11px] text-slate-400">{{
241
+ t('settings.infrastructure.cloudflare.repo')
242
+ }}</label>
243
+ <UInput v-model="repo" size="xs" placeholder="owner/repo" />
244
+ <p class="text-[11px] text-slate-500">
245
+ {{ t('settings.infrastructure.cloudflare.repoHint') }}
246
+ </p>
247
+ </div>
248
+
249
+ <details class="rounded-md border border-slate-700/70 p-2">
250
+ <summary class="cursor-pointer text-[11px] text-slate-400">
251
+ {{ t('settings.infrastructure.cloudflare.advanced') }}
252
+ </summary>
253
+ <div class="mt-2 space-y-2">
254
+ <div class="space-y-1">
255
+ <label class="text-[11px] text-slate-400">{{
256
+ t('settings.infrastructure.cloudflare.workerTemplate')
257
+ }}</label>
258
+ <!-- The placeholder is a FORMAT EXAMPLE containing vue-i18n metacharacters, so it
259
+ stays inline rather than becoming a catalog key. -->
260
+ <UInput
261
+ v-model="workerNameTemplate"
262
+ size="xs"
263
+ placeholder="cat-factory-pr-{{pullNumber}}"
264
+ />
265
+ </div>
266
+ <div class="space-y-1">
267
+ <label class="text-[11px] text-slate-400">{{
268
+ t('settings.infrastructure.cloudflare.environmentTemplate')
269
+ }}</label>
270
+ <UInput v-model="environmentNameTemplate" size="xs" placeholder="pr-{{pullNumber}}" />
271
+ </div>
272
+ <p class="text-[11px] text-slate-500">
273
+ {{ t('settings.infrastructure.cloudflare.templateHint') }}
274
+ </p>
275
+ <div class="space-y-1">
276
+ <label class="text-[11px] text-slate-400">{{
277
+ t('settings.infrastructure.cloudflare.apiBaseUrl')
278
+ }}</label>
279
+ <UInput v-model="apiBaseUrl" size="xs" placeholder="https://api.github.com" />
280
+ </div>
281
+ </div>
282
+ </details>
283
+
284
+ <div class="flex items-center gap-2">
285
+ <UButton size="xs" :disabled="!canSave || busy" @click="save">
286
+ {{ t('common.save') }}
287
+ </UButton>
288
+ <UButton
289
+ size="xs"
290
+ color="neutral"
291
+ variant="subtle"
292
+ :disabled="!canSave || testing"
293
+ :loading="testing"
294
+ @click="test"
295
+ >
296
+ {{ t('settings.providerConnection.test.button') }}
297
+ </UButton>
298
+ <UButton
299
+ v-if="handler"
300
+ size="xs"
301
+ color="neutral"
302
+ variant="ghost"
303
+ :disabled="busy"
304
+ @click="editing = false"
305
+ >
306
+ {{ t('common.cancel') }}
307
+ </UButton>
308
+ </div>
309
+
310
+ <p
311
+ v-if="testResult"
312
+ class="text-[11px]"
313
+ :class="testResult.ok ? 'text-emerald-300' : 'text-rose-300'"
314
+ >
315
+ {{ testResult.message }}
316
+ </p>
317
+ </div>
318
+ </section>
319
+ </template>
@@ -6,6 +6,8 @@
6
6
  // service-owned, configured on the service).
7
7
  // - docker-compose → handled by the runtime's local Docker capability — informational, no
8
8
  // connection (a DinD-capable runner stands the service's compose stack up).
9
+ // - cloudflare → the built-in per-PR Cloudflare Workers preview, driven over the VCS
10
+ // deployments API. Its whole section lives in CloudflareHandlerSection.vue.
9
11
  // - custom → the custom-manifest-type catalog editor + a `remote-custom` HTTP handler
10
12
  // per custom type (matched to a service's pinned `manifestId`).
11
13
  // In LOCAL mode each handler additionally offers a per-USER override (this-machine only),
@@ -25,6 +27,7 @@ type RemoteCustomConfig = Extract<InfraHandlerConfig, { engine: 'remote-custom'
25
27
  import KubernetesEngineForm from '~/components/settings/KubernetesEngineForm.vue'
26
28
  import ProviderManifestEditor from '~/components/settings/ProviderManifestEditor.vue'
27
29
  import CustomManifestTypeEditor from '~/components/settings/CustomManifestTypeEditor.vue'
30
+ import CloudflareHandlerSection from '~/components/settings/CloudflareHandlerSection.vue'
28
31
 
29
32
  const { t } = useI18n()
30
33
  const infra = useInfraConfigStore()
@@ -542,6 +545,10 @@ function notifyError(e: unknown) {
542
545
  <p class="text-[12px] text-slate-400">{{ t('settings.infrastructure.dockerComposeInfo') }}</p>
543
546
  </section>
544
547
 
548
+ <!-- cloudflare: a self-contained section (see the component's own note on why it is not
549
+ another branch here). -->
550
+ <CloudflareHandlerSection />
551
+
545
552
  <!-- custom: the catalog editor + a remote-custom HTTP handler per custom type. -->
546
553
  <section class="space-y-3 rounded-lg border border-slate-700 bg-slate-900/40 p-3">
547
554
  <h3 class="text-sm font-semibold text-slate-200">
@@ -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
 
@@ -1,5 +1,6 @@
1
1
  import { ref, onScopeDispose } from 'vue'
2
2
  import type { WorkspaceEvent } from '~/types/domain'
3
+ import { wsOriginFor } from '~/utils/apiOrigin'
3
4
 
4
5
  /**
5
6
  * Subscribes to the backend's per-workspace WebSocket event stream and keeps the
@@ -50,8 +51,10 @@ export function useWorkspaceStream() {
50
51
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null
51
52
  let boardDebounce: ReturnType<typeof setTimeout> | null = null
52
53
 
53
- // http→ws, https→wss (apiBase is an absolute origin, see nuxt.config.ts).
54
- const wsBase = String(apiBase).replace(/^http/, 'ws')
54
+ // http→ws, https→wss. `apiBase` is an absolute origin on a split-origin deployment (see
55
+ // nuxt.config.ts) and EMPTY on a same-origin one (one proxy in front of the SPA + the API —
56
+ // the compose preview stack), where the socket origin comes from the page instead.
57
+ const wsBase = wsOriginFor(String(apiBase), import.meta.client ? window.location.origin : '')
55
58
 
56
59
  // A coarse board refresh (the resync on reconnect, and the `board` event fan-out) must not be
57
60
  // left silently stale by ONE transient failure: retry a few times with backoff so a blip
@@ -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" />
@@ -20,6 +20,7 @@ const BUILTIN_BACKEND_KINDS: Record<ProviderConnectionKind, BackendKindOption[]>
20
20
  environment: [
21
21
  { kind: 'manifest', label: 'HTTP manifest', engines: ['remote-custom'] },
22
22
  { kind: 'kubernetes', label: 'Kubernetes', engines: ['local-k3s', 'remote-kubernetes'] },
23
+ { kind: 'cloudflare', label: 'Cloudflare Workers', engines: ['cloudflare'] },
23
24
  ],
24
25
  'runner-pool': [
25
26
  { kind: 'manifest', label: 'HTTP manifest pool' },