@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.
- package/app/components/environments/EnvironmentStatusPanel.logic.spec.ts +70 -0
- package/app/components/environments/EnvironmentStatusPanel.logic.ts +48 -0
- package/app/components/environments/EnvironmentStatusPanel.vue +21 -4
- package/app/components/foundational/FoundationalServiceCatalogList.vue +3 -0
- package/app/components/foundational/FoundationalServiceManager.vue +9 -1
- package/app/components/foundational/FoundationalServiceRegistry.vue +3 -0
- package/app/components/foundational/ServiceCatalogConnection.vue +392 -0
- package/app/components/outcome/OutcomeSummaryWindow.logic.spec.ts +1 -0
- package/app/components/outcome/OutcomeSummaryWindow.vue +10 -1
- package/app/components/panels/ObservabilityPanel.vue +19 -0
- package/app/components/providers/ApiKeysSection.vue +24 -14
- package/app/components/provisioning/ProvisioningLogsDrawer.vue +1 -0
- package/app/components/settings/OpenRouterCatalogPanel.vue +8 -0
- package/app/composables/api/foundationalServices.ts +24 -0
- package/app/composables/usePipelineErrorToast.ts +5 -0
- package/app/stores/foundationalServices.ts +22 -0
- package/app/stores/observability.ts +6 -0
- package/app/stores/serviceCatalogConnection.spec.ts +116 -0
- package/app/stores/serviceCatalogConnection.ts +131 -0
- package/app/types/foundationalServices.ts +14 -0
- package/app/utils/runOutcome.ts +1 -0
- package/app/utils/serviceCatalog.spec.ts +51 -0
- package/app/utils/serviceCatalog.ts +68 -0
- package/i18n/locales/de.json +90 -3
- package/i18n/locales/en.json +90 -3
- package/i18n/locales/es.json +90 -3
- package/i18n/locales/fr.json +90 -3
- package/i18n/locales/he.json +90 -3
- package/i18n/locales/it.json +90 -3
- package/i18n/locales/ja.json +90 -3
- package/i18n/locales/pl.json +90 -3
- package/i18n/locales/tr.json +90 -3
- package/i18n/locales/uk.json +90 -3
- 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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
|
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'
|
package/app/utils/runOutcome.ts
CHANGED
|
@@ -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
|
+
})
|