@cat-factory/app 0.288.2 → 0.290.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 (34) hide show
  1. package/app/components/environments/EnvironmentStatusPanel.logic.spec.ts +70 -0
  2. package/app/components/environments/EnvironmentStatusPanel.logic.ts +48 -0
  3. package/app/components/environments/EnvironmentStatusPanel.vue +21 -4
  4. package/app/components/foundational/FoundationalServiceCatalogList.vue +3 -0
  5. package/app/components/foundational/FoundationalServiceManager.vue +9 -1
  6. package/app/components/foundational/FoundationalServiceRegistry.vue +3 -0
  7. package/app/components/foundational/ServiceCatalogConnection.vue +392 -0
  8. package/app/components/outcome/OutcomeSummaryWindow.logic.spec.ts +1 -0
  9. package/app/components/outcome/OutcomeSummaryWindow.vue +10 -1
  10. package/app/components/panels/ObservabilityPanel.vue +19 -0
  11. package/app/components/providers/ApiKeysSection.vue +24 -14
  12. package/app/components/provisioning/ProvisioningLogsDrawer.vue +1 -0
  13. package/app/components/settings/OpenRouterCatalogPanel.vue +8 -0
  14. package/app/composables/api/foundationalServices.ts +24 -0
  15. package/app/composables/usePipelineErrorToast.ts +5 -0
  16. package/app/stores/foundationalServices.ts +22 -0
  17. package/app/stores/observability.ts +6 -0
  18. package/app/stores/serviceCatalogConnection.spec.ts +116 -0
  19. package/app/stores/serviceCatalogConnection.ts +131 -0
  20. package/app/types/foundationalServices.ts +14 -0
  21. package/app/utils/runOutcome.ts +1 -0
  22. package/app/utils/serviceCatalog.spec.ts +51 -0
  23. package/app/utils/serviceCatalog.ts +68 -0
  24. package/i18n/locales/de.json +90 -3
  25. package/i18n/locales/en.json +90 -3
  26. package/i18n/locales/es.json +90 -3
  27. package/i18n/locales/fr.json +90 -3
  28. package/i18n/locales/he.json +90 -3
  29. package/i18n/locales/it.json +90 -3
  30. package/i18n/locales/ja.json +90 -3
  31. package/i18n/locales/pl.json +90 -3
  32. package/i18n/locales/tr.json +90 -3
  33. package/i18n/locales/uk.json +90 -3
  34. package/package.json +2 -2
@@ -848,6 +848,13 @@ function exportJson() {
848
848
  :title="c.model"
849
849
  >
850
850
  {{ c.provider }}:{{ c.model }}
851
+ <!-- Which upstream a GATEWAY routed to. Without it every `openrouter` row
852
+ reads alike, and an upstream having a bad day cannot be told from the
853
+ gateway having one. Absent for a direct vendor, where `provider`
854
+ already names who served the call. -->
855
+ <span v-if="c.upstreamProvider" class="text-slate-600">
856
+ {{ t('observability.call.viaUpstream', { upstream: c.upstreamProvider }) }}
857
+ </span>
851
858
  </span>
852
859
  <div
853
860
  class="ms-auto flex items-center gap-2.5 text-[11px] tabular-nums text-slate-400"
@@ -927,6 +934,18 @@ function exportJson() {
927
934
  <span>{{
928
935
  t('observability.call.total', { duration: formatMs(c.totalMs) })
929
936
  }}</span>
937
+ <!-- The one MEASURED cost on the row: what the gateway's own ledger says,
938
+ in USD, against the derived estimate every other figure on this panel
939
+ is. `!= null` and not truthiness: a reported 0 is a free route saying
940
+ so, which is a different fact from a producer that reports nothing and
941
+ must not be hidden as if it were. -->
942
+ <span v-if="c.reportedCostUsd != null" class="text-slate-400">
943
+ {{
944
+ t('observability.call.reportedCost', {
945
+ cost: formatCost(c.reportedCostUsd, 'USD'),
946
+ })
947
+ }}
948
+ </span>
930
949
  </div>
931
950
  <div>
932
951
  <div
@@ -16,6 +16,7 @@
16
16
  // - With `accountId`: manage ACCOUNT-wide keys (shared by every workspace in the
17
17
  // account); admin-only, enforced server-side. Surfaced from account/team settings.
18
18
  import { computed, ref, watch } from 'vue'
19
+ import { providerCachesPrompts } from '@cat-factory/contracts'
19
20
  import type { ApiKey, ApiKeyProvider } from '~/types/domain'
20
21
  import SecretInput from '~/components/common/SecretInput.vue'
21
22
 
@@ -48,18 +49,29 @@ interface ProviderMeta {
48
49
  label: string
49
50
  url: string
50
51
  steps: string[]
51
- /**
52
- * Whether this provider caches the re-sent prompt prefix. Connecting a key here
53
- * upgrades its models to the caching `direct` flavour, so a long agentic run stops
54
- * re-billing its whole growing prompt every turn. Mirrors the backend
55
- * `providerCachePolicy`; the gateways are pass-through (no caching we rely on yet).
56
- */
57
- caches?: boolean
52
+ }
53
+
54
+ /**
55
+ * Whether connecting a key here upgrades its models to the caching flavour, so a long agentic
56
+ * run stops re-billing its whole growing prompt every turn.
57
+ *
58
+ * READ from the shared rule rather than kept as a flag beside each entry. A hand-kept copy was
59
+ * already stale: the backend answers per (provider, MODEL) for a gateway, so it now reports
60
+ * caching for several OpenRouter routes while a boolean here still said the gateways cache
61
+ * nothing. A per-provider question is the one this page can ask, and asking the same function
62
+ * the picker and the call paths ask is what keeps the three answers from diverging again.
63
+ *
64
+ * A GATEWAY therefore answers false here, honestly: whether an OpenRouter key caches depends on
65
+ * which model the run picks, which is a per-model badge in the picker rather than a promise this
66
+ * page can make about a key.
67
+ */
68
+ function cachesPrompts(provider: ApiKeyProvider): boolean {
69
+ return providerCachesPrompts(provider)
58
70
  }
59
71
 
60
72
  // Provider metadata. Labels + step instructions resolve through i18n (reactive to the
61
73
  // locale), so each `t(...)` uses a literal key (kept tier-1 typed-key checkable); only the
62
- // `value`/`url`/`caches` differentiators stay inline.
74
+ // `value`/`url` differentiators stay inline.
63
75
  /** Direct vendors: the key reaches that one vendor's own endpoint. */
64
76
  const DIRECT_PROVIDERS = computed<ProviderMeta[]>(() => [
65
77
  {
@@ -70,7 +82,6 @@ const DIRECT_PROVIDERS = computed<ProviderMeta[]>(() => [
70
82
  t('providers.apiKeys.providers.openai.step1'),
71
83
  t('providers.apiKeys.providers.openai.step2'),
72
84
  ],
73
- caches: true,
74
85
  },
75
86
  {
76
87
  value: 'anthropic',
@@ -80,7 +91,6 @@ const DIRECT_PROVIDERS = computed<ProviderMeta[]>(() => [
80
91
  t('providers.apiKeys.providers.anthropic.step1'),
81
92
  t('providers.apiKeys.providers.anthropic.step2'),
82
93
  ],
83
- caches: true,
84
94
  },
85
95
  {
86
96
  value: 'qwen',
@@ -90,7 +100,6 @@ const DIRECT_PROVIDERS = computed<ProviderMeta[]>(() => [
90
100
  t('providers.apiKeys.providers.qwen.step1'),
91
101
  t('providers.apiKeys.providers.qwen.step2'),
92
102
  ],
93
- caches: true,
94
103
  },
95
104
  {
96
105
  value: 'deepseek',
@@ -100,7 +109,6 @@ const DIRECT_PROVIDERS = computed<ProviderMeta[]>(() => [
100
109
  t('providers.apiKeys.providers.deepseek.step1'),
101
110
  t('providers.apiKeys.providers.deepseek.step2'),
102
111
  ],
103
- caches: true,
104
112
  },
105
113
  {
106
114
  value: 'moonshot',
@@ -116,7 +124,6 @@ const DIRECT_PROVIDERS = computed<ProviderMeta[]>(() => [
116
124
  label: t('providers.apiKeys.providers.xai.label'),
117
125
  url: 'https://console.x.ai/',
118
126
  steps: [t('providers.apiKeys.providers.xai.step1'), t('providers.apiKeys.providers.xai.step2')],
119
- caches: true,
120
127
  },
121
128
  ])
122
129
 
@@ -334,7 +341,10 @@ async function remove(k: ApiKey) {
334
341
 
335
342
  <!-- caching capability: connecting a direct key that caches upgrades its models to
336
343
  the caching flavour, so long agentic runs stop re-billing the whole prompt. -->
337
- <p v-if="selected.caches" class="flex items-center gap-1.5 text-[12px] text-emerald-400/90">
344
+ <p
345
+ v-if="cachesPrompts(selected.value)"
346
+ class="flex items-center gap-1.5 text-[12px] text-emerald-400/90"
347
+ >
338
348
  <UIcon name="i-lucide-zap" class="h-3.5 w-3.5 shrink-0" />
339
349
  {{ t('providers.apiKeys.cachingNote', { provider: selected.label }) }}
340
350
  </p>
@@ -96,6 +96,7 @@ const OPERATION_LABEL = computed<Record<ProvisioningOperation, string>>(() => ({
96
96
  teardown: t('provisioning.operation.teardown'),
97
97
  'teardown-verify': t('provisioning.operation.teardown-verify'),
98
98
  status: t('provisioning.operation.status'),
99
+ remediate: t('provisioning.operation.remediate'),
99
100
  dispatch: t('provisioning.operation.dispatch'),
100
101
  release: t('provisioning.operation.release'),
101
102
  'poll-failure': t('provisioning.operation.poll-failure'),
@@ -351,6 +351,14 @@ function manageKeys() {
351
351
  <span class="min-w-0 flex-1">
352
352
  <span class="block truncate text-slate-200">{{ m.name }}</span>
353
353
  <span class="block truncate font-mono text-[11px] text-slate-500">{{ m.id }}</span>
354
+ <!--
355
+ A withdrawal date is the one fact about a model that fails SILENTLY: past it the
356
+ route simply stops answering and the run falls through to whatever the picker
357
+ offers next, so the moment to see it is while choosing.
358
+ -->
359
+ <span v-if="m.expirationDate" class="block truncate text-[11px] text-amber-400">{{
360
+ t('settings.openRouterCatalog.retiresOn', { date: m.expirationDate })
361
+ }}</span>
354
362
  </span>
355
363
  <span class="shrink-0 text-end text-[11px] text-slate-500">
356
364
  <span v-if="m.contextLength" class="block">{{
@@ -1,6 +1,11 @@
1
1
  import {
2
+ connectServiceCatalogContract,
2
3
  createFoundationalServiceContract,
3
4
  deleteFoundationalServiceContract,
5
+ disconnectServiceCatalogContract,
6
+ getServiceCatalogContract,
7
+ probeServiceCatalogContract,
8
+ syncServiceCatalogContract,
4
9
  foundationalServiceSourceStatusContract,
5
10
  getFoundationalServiceContractsContract,
6
11
  linkFoundationalServiceSourceContract,
@@ -15,6 +20,7 @@ import {
15
20
  updateFoundationalServiceContract,
16
21
  } from '@cat-factory/contracts'
17
22
  import type {
23
+ ConnectServiceCatalogInput,
18
24
  CreateFoundationalServiceInput,
19
25
  FoundationalServiceOwnerKind,
20
26
  LinkFoundationalServiceSourceInput,
@@ -136,5 +142,23 @@ export function foundationalServicesApi({ send, ws, scope }: ApiContext) {
136
142
  pathPrefix: scope(kind, id),
137
143
  pathParams: { id: sourceId },
138
144
  }),
145
+
146
+ // ---- the SERVICE CATALOG connection (a developer portal) --------------
147
+ // Workspace-only, and not by omission: the portal credential rides the workspace-keyed secret
148
+ // delegation, so there is no account-scoped shape of this connection to serve.
149
+ getServiceCatalog: (workspaceId: string) =>
150
+ send(getServiceCatalogContract, { pathPrefix: ws(workspaceId) }),
151
+
152
+ connectServiceCatalog: (workspaceId: string, body: ConnectServiceCatalogInput) =>
153
+ send(connectServiceCatalogContract, { pathPrefix: ws(workspaceId), body }),
154
+
155
+ disconnectServiceCatalog: (workspaceId: string) =>
156
+ send(disconnectServiceCatalogContract, { pathPrefix: ws(workspaceId) }),
157
+
158
+ probeServiceCatalog: (workspaceId: string, body: ConnectServiceCatalogInput) =>
159
+ send(probeServiceCatalogContract, { pathPrefix: ws(workspaceId), body }),
160
+
161
+ syncServiceCatalog: (workspaceId: string) =>
162
+ send(syncServiceCatalogContract, { pathPrefix: ws(workspaceId) }),
139
163
  }
140
164
  }
@@ -358,6 +358,11 @@ const UNAVAILABLE_DESCRIPTION_KEYS: Record<UnavailableReason, string> = {
358
358
  connection_credentials_unreadable:
359
359
  'errors.unavailable.description.connection_credentials_unreadable',
360
360
  vcs_capability_unsupported: 'errors.unavailable.description.vcs_capability_unsupported',
361
+ service_catalog_unreachable: 'errors.unavailable.description.service_catalog_unreachable',
362
+ service_catalog_unauthorized: 'errors.unavailable.description.service_catalog_unauthorized',
363
+ service_catalog_filter_missing: 'errors.unavailable.description.service_catalog_filter_missing',
364
+ service_catalog_response_too_large:
365
+ 'errors.unavailable.description.service_catalog_response_too_large',
361
366
  }
362
367
 
363
368
  /**
@@ -13,6 +13,7 @@ import type {
13
13
  UpdateFoundationalServiceInput,
14
14
  } from '~/types/domain'
15
15
  import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
16
+ import { createServiceCatalogState } from '~/stores/serviceCatalogConnection'
16
17
  import { useWorkspaceStore } from '~/stores/workspace'
17
18
 
18
19
  /**
@@ -88,6 +89,17 @@ function foundationalServicesSetup(
88
89
  return requireOwnerId()
89
90
  }
90
91
 
92
+ // The workspace's connected developer portal, in its own factory (`./serviceCatalogConnection`):
93
+ // one connection with one store behind it, where everything else here is the catalog's tiers,
94
+ // suppressions and repo sources. `reloadCatalog` is a THUNK so it can name `reload`, declared
95
+ // further down.
96
+ const portal = createServiceCatalogState({
97
+ api,
98
+ requireWorkspaceId,
99
+ isWorkspaceTier: hasResolved,
100
+ reloadCatalog: () => reload(),
101
+ })
102
+
91
103
  /** Probe the feature + load this owner's tier, sources and (ws) the merged catalog. */
92
104
  async function runProbe() {
93
105
  const id = resolveOwnerId()
@@ -115,6 +127,7 @@ function foundationalServicesSetup(
115
127
  sources.value = []
116
128
  sourceChanges.value = {}
117
129
  sourcesAvailable.value = false
130
+ portal.reset()
118
131
  return
119
132
  }
120
133
  // Repo sources need the GitHub integration; a 503 here hides only the linking UI — the
@@ -126,6 +139,9 @@ function foundationalServicesSetup(
126
139
  sources.value = []
127
140
  sourcesAvailable.value = false
128
141
  }
142
+ // The portal connection is its own gate again (the service-catalog encryption key), and it is
143
+ // workspace-only. See `createServiceCatalogState`, which owns that half.
144
+ await portal.load(id)
129
145
  }
130
146
  // Single-flight the probe keyed on the owner id, so a panel-open fan-out loads once per owner.
131
147
  const { probe, ensureProbed } = useSingleFlightProbe(runProbe, () => resolveOwnerId())
@@ -243,6 +259,8 @@ function foundationalServicesSetup(
243
259
  suppressions,
244
260
  sources,
245
261
  sourceChanges,
262
+ serviceCatalog: portal.serviceCatalog,
263
+ serviceCatalogAvailable: portal.serviceCatalogAvailable,
246
264
  contractBodies,
247
265
  inheritedCount,
248
266
  probe,
@@ -257,6 +275,10 @@ function foundationalServicesSetup(
257
275
  unlinkSource,
258
276
  syncSource,
259
277
  checkSource,
278
+ connectServiceCatalog: portal.connect,
279
+ disconnectServiceCatalog: portal.disconnect,
280
+ probeServiceCatalog: portal.probe,
281
+ importServiceCatalog: portal.importNow,
260
282
  }
261
283
  }
262
284
 
@@ -153,6 +153,12 @@ export const useObservabilityStore = defineStore('observability', () => {
153
153
  // HTTP call at a time and files it. Only a harness CLI's step-level remainder is spend-only,
154
154
  // and that arrives through the stored row, never here.
155
155
  spendOnly: false,
156
+ // The live event is the COMPACT wire shape, which carries no gateway report: the cost a
157
+ // gateway states and the upstream it routed to arrive with the stored row. Null rather than
158
+ // 0, because absent and free are different facts everywhere else on this type and a
159
+ // placeholder zero here would render this run's live rows as free until the panel reloads.
160
+ reportedCostUsd: null,
161
+ upstreamProvider: null,
156
162
  promptText: '',
157
163
  promptPrefixCount: 0,
158
164
  promptHash: '',
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type {
3
+ ConnectServiceCatalogInput,
4
+ ServiceCatalogConnection,
5
+ ServiceCatalogSyncResult,
6
+ } from '~/types/domain'
7
+ import { createServiceCatalogState } from '~/stores/serviceCatalogConnection'
8
+
9
+ // The half of the service-catalog store worth pinning is the SPLIT between connecting and the
10
+ // first import. They fail differently, they have different remedies, and the panel presents them
11
+ // under different titles.
12
+
13
+ const connection: ServiceCatalogConnection = {
14
+ provider: 'backstage',
15
+ baseUrl: 'https://backstage.example.com',
16
+ authMode: 'static-token',
17
+ entityFilter: ['kind=component'],
18
+ includeApis: true,
19
+ maxServices: 200,
20
+ lastSyncedAt: null,
21
+ lastSyncStatus: null,
22
+ lastSyncMessage: null,
23
+ connectedAt: 1,
24
+ }
25
+
26
+ const result: ServiceCatalogSyncResult = {
27
+ upserted: 1,
28
+ tombstoned: 0,
29
+ unchanged: 0,
30
+ contracts: 1,
31
+ coverage: 'complete',
32
+ skippedServices: 0,
33
+ skippedConflicts: 0,
34
+ skippedApis: 0,
35
+ status: 'ok',
36
+ }
37
+
38
+ const input: ConnectServiceCatalogInput = {
39
+ baseUrl: 'https://backstage.example.com',
40
+ auth: { mode: 'static-token', token: 't' },
41
+ }
42
+
43
+ function build(overrides: { syncServiceCatalog?: () => Promise<ServiceCatalogSyncResult> } = {}) {
44
+ const calls: string[] = []
45
+ const state = createServiceCatalogState({
46
+ api: {
47
+ getServiceCatalog: async () => {
48
+ calls.push('get')
49
+ return connection
50
+ },
51
+ connectServiceCatalog: async () => {
52
+ calls.push('connect')
53
+ return connection
54
+ },
55
+ disconnectServiceCatalog: async () => {
56
+ calls.push('disconnect')
57
+ return null
58
+ },
59
+ probeServiceCatalog: async () => ({ ok: true }),
60
+ syncServiceCatalog: async () => {
61
+ calls.push('sync')
62
+ return overrides.syncServiceCatalog ? await overrides.syncServiceCatalog() : result
63
+ },
64
+ },
65
+ requireWorkspaceId: () => 'ws-1',
66
+ isWorkspaceTier: true,
67
+ reloadCatalog: async () => {
68
+ calls.push('reload')
69
+ },
70
+ })
71
+ return { state, calls }
72
+ }
73
+
74
+ describe('createServiceCatalogState', () => {
75
+ it('settles connect() on the CONNECT alone, so an import failure is not a connect failure', async () => {
76
+ // The connection is stored by the time an import runs, so rejecting here would tell the
77
+ // operator the connection failed while the panel renders it connected, and would bury the
78
+ // remedy the real failure names under a title saying the opposite.
79
+ const { state, calls } = build()
80
+
81
+ await expect(state.connect(input)).resolves.toEqual(connection)
82
+
83
+ expect(calls).toEqual(['connect'])
84
+ expect(state.serviceCatalog.value).toEqual(connection)
85
+ })
86
+
87
+ it('re-reads the connection after an import, for the verdict the import stamped there', async () => {
88
+ const { state, calls } = build()
89
+
90
+ await expect(state.importNow()).resolves.toEqual(result)
91
+
92
+ // The stamped verdict is the only thing that tells a human the estate they are looking at is a
93
+ // PREFIX of the portal's.
94
+ expect(calls).toEqual(['sync', 'get', 'reload'])
95
+ })
96
+
97
+ it('propagates an import failure to its own caller', async () => {
98
+ const { state } = build({
99
+ syncServiceCatalog: async () => {
100
+ throw new Error('service catalog unauthorized')
101
+ },
102
+ })
103
+
104
+ await expect(state.importNow()).rejects.toThrow('service catalog unauthorized')
105
+ })
106
+
107
+ it('clears the connection on disconnect and reloads the catalog views it tombstoned', async () => {
108
+ const { state, calls } = build()
109
+ await state.connect(input)
110
+
111
+ await state.disconnect()
112
+
113
+ expect(state.serviceCatalog.value).toBeNull()
114
+ expect(calls).toEqual(['connect', 'disconnect', 'reload'])
115
+ })
116
+ })
@@ -0,0 +1,131 @@
1
+ import { ref } from 'vue'
2
+ import type {
3
+ ConnectServiceCatalogInput,
4
+ ServiceCatalogConnection,
5
+ ServiceCatalogSyncResult,
6
+ } from '~/types/domain'
7
+
8
+ /**
9
+ * The SERVICE CATALOG half of the foundational-services store: the workspace's connected developer
10
+ * portal, and the four actions over it.
11
+ *
12
+ * A separate factory rather than more of `foundationalServicesSetup`, which had reached its
13
+ * per-function line budget. The seam is the feature's own: this is one connection with one store
14
+ * behind it, where everything else in that setup is the catalog's tiers, its suppressions and its
15
+ * repo sources.
16
+ *
17
+ * It is WORKSPACE-only. The portal credential rides the workspace-keyed secret delegation, so the
18
+ * backend serves this connection at no other scope, and the account-tier store gets the same state
19
+ * declared and permanently unavailable rather than a shape that pretends otherwise.
20
+ */
21
+ export interface ServiceCatalogStateDependencies {
22
+ /** The workspace-scoped API calls, narrowed to the five this half makes. */
23
+ api: {
24
+ getServiceCatalog: (workspaceId: string) => Promise<ServiceCatalogConnection | null>
25
+ connectServiceCatalog: (
26
+ workspaceId: string,
27
+ body: ConnectServiceCatalogInput,
28
+ ) => Promise<ServiceCatalogConnection>
29
+ // The contract's 204 sends no body, which the generated client surfaces as `null` rather than
30
+ // `void`; declaring the narrower shape here would refuse the real client.
31
+ disconnectServiceCatalog: (workspaceId: string) => Promise<unknown>
32
+ probeServiceCatalog: (
33
+ workspaceId: string,
34
+ body: ConnectServiceCatalogInput,
35
+ ) => Promise<{ ok: boolean; message?: string }>
36
+ syncServiceCatalog: (workspaceId: string) => Promise<ServiceCatalogSyncResult>
37
+ }
38
+ /** Throws unless this store is the workspace tier; the caller's own guard. */
39
+ requireWorkspaceId: () => string
40
+ /** False on the account tier, where there is no connection to read. */
41
+ isWorkspaceTier: boolean
42
+ /** Reload the catalog views a connect / disconnect / import changed. */
43
+ reloadCatalog: () => Promise<void>
44
+ }
45
+
46
+ export function createServiceCatalogState(deps: ServiceCatalogStateDependencies) {
47
+ const { api, requireWorkspaceId, isWorkspaceTier, reloadCatalog } = deps
48
+
49
+ /** The connection, or null when the workspace has none. */
50
+ const serviceCatalog = ref<ServiceCatalogConnection | null>(null)
51
+ /**
52
+ * false when the deployment configured no service-catalog encryption key: the catalog itself
53
+ * works (contracts can be uploaded), and only the portal-import surface 503s. The finer gate,
54
+ * exactly as `sourcesAvailable` is for the GitHub half.
55
+ */
56
+ const serviceCatalogAvailable = ref(true)
57
+
58
+ /**
59
+ * Read the connection as part of the store's probe.
60
+ *
61
+ * Its own `try` rather than part of the probe's: a 503 here hides only the import panel, and the
62
+ * catalog read that ran before it already succeeded, so folding the two would report a configured
63
+ * catalog as absent because its optional portal half is not wired.
64
+ */
65
+ async function load(workspaceId: string): Promise<void> {
66
+ if (!isWorkspaceTier) {
67
+ serviceCatalogAvailable.value = false
68
+ return
69
+ }
70
+ try {
71
+ serviceCatalog.value = await api.getServiceCatalog(workspaceId)
72
+ serviceCatalogAvailable.value = true
73
+ } catch {
74
+ serviceCatalog.value = null
75
+ serviceCatalogAvailable.value = false
76
+ }
77
+ }
78
+
79
+ /** Clear both views, for a probe whose whole catalog read failed. */
80
+ function reset(): void {
81
+ serviceCatalog.value = null
82
+ serviceCatalogAvailable.value = false
83
+ }
84
+
85
+ /**
86
+ * Store the connection. The first import is the CALLER's next step, not part of this promise.
87
+ *
88
+ * A connection that shows nothing reads as a broken one, so an import does follow immediately
89
+ * (the same reason a linked repo source syncs on link). What it must not do is settle this
90
+ * promise: by the time the import runs the connection is already stored, so folding an import
91
+ * failure in here would reject a call that succeeded, and the panel would show a "could not
92
+ * connect" toast beside the connection it just made, hiding the remedy the real failure names.
93
+ */
94
+ async function connect(input: ConnectServiceCatalogInput) {
95
+ serviceCatalog.value = await api.connectServiceCatalog(requireWorkspaceId(), input)
96
+ return serviceCatalog.value
97
+ }
98
+
99
+ async function disconnect(): Promise<void> {
100
+ await api.disconnectServiceCatalog(requireWorkspaceId())
101
+ serviceCatalog.value = null
102
+ // Disconnecting TOMBSTONES what the portal produced, so both catalog views are now wrong.
103
+ await reloadCatalog()
104
+ }
105
+
106
+ function probe(input: ConnectServiceCatalogInput) {
107
+ return api.probeServiceCatalog(requireWorkspaceId(), input)
108
+ }
109
+
110
+ /** Import now, then refresh both catalog views. */
111
+ async function importNow(): Promise<ServiceCatalogSyncResult> {
112
+ const id = requireWorkspaceId()
113
+ const result = await api.syncServiceCatalog(id)
114
+ // The connection is re-read too: the import stamps its verdict there, and that verdict is the
115
+ // only thing that tells a human the estate they are looking at is a PREFIX of the portal's.
116
+ const [connection] = await Promise.all([api.getServiceCatalog(id), reloadCatalog()])
117
+ serviceCatalog.value = connection
118
+ return result
119
+ }
120
+
121
+ return {
122
+ serviceCatalog,
123
+ serviceCatalogAvailable,
124
+ load,
125
+ reset,
126
+ connect,
127
+ disconnect,
128
+ probe,
129
+ importNow,
130
+ }
131
+ }
@@ -31,3 +31,17 @@ export type {
31
31
  UpdateFoundationalServiceInput,
32
32
  UploadApiContract,
33
33
  } from '@cat-factory/contracts'
34
+
35
+ // The SERVICE CATALOG connection: the developer portal (Backstage) whose services are imported
36
+ // into the catalog above as `workspace`-tier rows. Beside the catalog types rather than in a file
37
+ // of its own, because everything it produces IS that catalog.
38
+ export type {
39
+ ConnectServiceCatalogInput,
40
+ ServiceCatalogAuth,
41
+ ServiceCatalogAuthMode,
42
+ ServiceCatalogConnection,
43
+ ServiceCatalogCoverage,
44
+ ServiceCatalogProvider,
45
+ ServiceCatalogSyncResult,
46
+ ServiceCatalogSyncStatus,
47
+ } from '@cat-factory/contracts'
@@ -26,6 +26,7 @@ export type {
26
26
  OutcomeDisposition,
27
27
  OutcomeEnvironment,
28
28
  OutcomeEnvironments,
29
+ OutcomeEnvironmentDetailKind,
29
30
  OutcomeEnvironmentOrigin,
30
31
  OutcomeEnvironmentState,
31
32
  OutcomePullRequest,
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { missingI18nKeys } from '../../test/i18nKeys'
3
+ import {
4
+ SERVICE_CATALOG_AUTH_KEYS,
5
+ SERVICE_CATALOG_AUTH_ORDER,
6
+ SERVICE_CATALOG_STATUS_COLORS,
7
+ SERVICE_CATALOG_STATUS_KEYS,
8
+ serviceCatalogStatusKey,
9
+ } from './serviceCatalog'
10
+
11
+ describe('service-catalog i18n keys', () => {
12
+ // The gap `test/i18nKeys.ts` exists for: the exhaustive `Record` proves every union member has an
13
+ // entry, and nothing proves the entry still names a key that exists. Deleting one would otherwise
14
+ // read as a clean removal, with the badge rendering its own key path at runtime.
15
+ it('every auth-mode key resolves in the base catalog', () => {
16
+ expect(missingI18nKeys(Object.values(SERVICE_CATALOG_AUTH_KEYS))).toEqual([])
17
+ })
18
+
19
+ it('every status key resolves, the never-imported case included', () => {
20
+ expect(
21
+ missingI18nKeys([
22
+ ...Object.values(SERVICE_CATALOG_STATUS_KEYS),
23
+ serviceCatalogStatusKey(null),
24
+ ]),
25
+ ).toEqual([])
26
+ })
27
+ })
28
+
29
+ describe('serviceCatalogStatusKey', () => {
30
+ it('keeps "never imported" apart from a failure', () => {
31
+ // Collapsing them would tell an operator their portal is broken the moment they connect it.
32
+ expect(serviceCatalogStatusKey(null)).not.toBe(SERVICE_CATALOG_STATUS_KEYS.failed)
33
+ })
34
+ })
35
+
36
+ describe('SERVICE_CATALOG_AUTH_ORDER', () => {
37
+ it('offers exactly the modes the label map knows, once each', () => {
38
+ // A relation over the two, not a pinned count: a new auth mode adds a member to the union, and
39
+ // this fails until the form offers it and the label map names it.
40
+ expect([...SERVICE_CATALOG_AUTH_ORDER].sort()).toEqual(
41
+ Object.keys(SERVICE_CATALOG_AUTH_KEYS).sort(),
42
+ )
43
+ })
44
+ })
45
+
46
+ describe('SERVICE_CATALOG_STATUS_COLORS', () => {
47
+ it('renders a PARTIAL import as a warning rather than a success', () => {
48
+ expect(SERVICE_CATALOG_STATUS_COLORS.partial).toBe('warning')
49
+ expect(SERVICE_CATALOG_STATUS_COLORS.ok).toBe('success')
50
+ })
51
+ })