@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
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readStatusNote, showsProviderFailure } from './EnvironmentStatusPanel.logic'
3
+ import type { RunEnvironment } from '~/types/execution'
4
+
5
+ /**
6
+ * The panel is where a person watches an environment come up, so it is also where the two prose
7
+ * channels can be shown to contradict each other. The failure this pins is a fault going unshown:
8
+ * a note is only ever context, and rendering it while `lastError` is suppressed reports a healthy
9
+ * spin-up on a row that recorded a real problem.
10
+ */
11
+ const env = (over: Partial<RunEnvironment>): RunEnvironment =>
12
+ ({ id: 'env-1', url: null, status: 'provisioning', ...over }) as RunEnvironment
13
+
14
+ describe('readStatusNote', () => {
15
+ it('shows what a still-provisioning environment is waiting on', () => {
16
+ expect(readStatusNote(env({ statusNote: ' the deploy job is queued ' }))).toBe(
17
+ 'the deploy job is queued',
18
+ )
19
+ })
20
+
21
+ it('withholds the note whenever a fault is recorded, whatever the status', () => {
22
+ // A teardown carries the row's `lastError` forward, so a failed-then-torn-down environment is
23
+ // a real shape with both fields set and a status the error block does not cover. Keyed off
24
+ // that block's own render condition, the panel showed the note and NO fault at all.
25
+ expect(
26
+ readStatusNote(
27
+ env({ status: 'torn_down', lastError: 'quota exceeded', statusNote: 'still deploying' }),
28
+ ),
29
+ ).toBeNull()
30
+ expect(
31
+ readStatusNote(
32
+ env({ status: 'failed', lastError: 'quota exceeded', statusNote: 'still deploying' }),
33
+ ),
34
+ ).toBeNull()
35
+ })
36
+
37
+ it('says nothing beside an environment that reached the state the note explains', () => {
38
+ // "Provider note: the workload is not routed yet" beside a green READY badge is two claims,
39
+ // and the badge is the true one.
40
+ expect(readStatusNote(env({ status: 'ready', statusNote: 'not routed yet' }))).toBeNull()
41
+ expect(readStatusNote(env({ status: 'torn_down', statusNote: 'still deploying' }))).toBeNull()
42
+ expect(
43
+ readStatusNote(env({ status: 'tearing_down', statusNote: 'still deploying' })),
44
+ ).toBeNull()
45
+ })
46
+
47
+ it('keeps the note on a terminal status that recorded no fault', () => {
48
+ // The same disposition kernel's readiness verdict takes: with no error to show, the last
49
+ // thing the provider said is all there is.
50
+ expect(
51
+ readStatusNote(env({ status: 'failed', statusNote: 'the deploy job never started' })),
52
+ ).toBe('the deploy job never started')
53
+ })
54
+
55
+ it('reads a blank note, an absent one and no environment alike', () => {
56
+ expect(readStatusNote(env({ statusNote: ' ' }))).toBeNull()
57
+ expect(readStatusNote(env({}))).toBeNull()
58
+ expect(readStatusNote(null)).toBeNull()
59
+ })
60
+ })
61
+
62
+ describe('showsProviderFailure', () => {
63
+ it('is the fault block, on the statuses that stopped at one', () => {
64
+ expect(showsProviderFailure(env({ status: 'failed', lastError: 'quota' }))).toBe(true)
65
+ expect(showsProviderFailure(env({ status: 'expired', lastError: 'quota' }))).toBe(true)
66
+ expect(showsProviderFailure(env({ status: 'provisioning', lastError: 'quota' }))).toBe(false)
67
+ expect(showsProviderFailure(env({ status: 'failed' }))).toBe(false)
68
+ expect(showsProviderFailure(null)).toBe(false)
69
+ })
70
+ })
@@ -0,0 +1,48 @@
1
+ // Which of an environment's two prose channels the panel shows, extracted from
2
+ // `EnvironmentStatusPanel.vue` so the precedence can be asserted without mounting the panel (see
3
+ // `EnvironmentStatusPanel.logic.spec.ts`).
4
+ //
5
+ // The environment record carries two accounts of itself and they answer different questions.
6
+ // `lastError` is a recorded FAULT: the provider's verbatim cause, written on a status the
7
+ // environment will not leave. `statusNote` is the provider's account of a state it has NOT left
8
+ // yet: why this environment is not ready. A row can carry both, and which one a reader is shown
9
+ // decides which layer they go looking at.
10
+
11
+ import type { RunEnvironment } from '~/types/execution'
12
+ import type { EnvironmentStatus } from '@cat-factory/contracts'
13
+
14
+ /**
15
+ * The statuses whose failure block the panel renders: a fault is shown as the headline account
16
+ * only where the environment actually stopped at one.
17
+ */
18
+ const FAILURE_STATUSES = new Set<EnvironmentStatus>(['failed', 'expired'])
19
+
20
+ /**
21
+ * The statuses a note still says something about. `ready` has REACHED the state the note explains
22
+ * not being in, and the two teardown statuses describe a spin-up nobody is waiting on any more (a
23
+ * teardown carries the row's note forward, so this is a live shape rather than a hypothetical
24
+ * one). On `failed` / `expired` the note is what a provider that recorded no fault last said,
25
+ * which is the disposition kernel's readiness verdict takes for the same pair.
26
+ */
27
+ const NOTE_STATUSES = new Set<EnvironmentStatus>(['provisioning', 'failed', 'expired'])
28
+
29
+ /** Whether the verbatim provider error is the panel's account of this environment. */
30
+ export function showsProviderFailure(env: RunEnvironment | null | undefined): boolean {
31
+ return !!env?.lastError && FAILURE_STATUSES.has(env.status)
32
+ }
33
+
34
+ /**
35
+ * The note to render, or null.
36
+ *
37
+ * Two rules, and it is the FAULT's presence that decides the first rather than whether the error
38
+ * block happens to be on screen. A recorded `lastError` outranks a note wherever they collide,
39
+ * whatever the status: they are two claims about one environment and the fault is the more
40
+ * specific one, so keying this off the error block's own render condition hid a real fault on
41
+ * every status that block does not cover. And a status that has left the state the note describes
42
+ * has nothing left to add with it.
43
+ */
44
+ export function readStatusNote(env: RunEnvironment | null | undefined): string | null {
45
+ if (!env || env.lastError) return null
46
+ if (!NOTE_STATUSES.has(env.status)) return null
47
+ return env.statusNote?.trim() || null
48
+ }
@@ -5,6 +5,10 @@
5
5
  // shows whether the env is spinning up / running / shut down / errored, with the error.
6
6
  import type { InfraEngine, ProvisionType } from '@cat-factory/contracts'
7
7
  import type { RunEnvironment, HumanTestEnvironmentStatus } from '~/types/execution'
8
+ import {
9
+ readStatusNote,
10
+ showsProviderFailure,
11
+ } from '~/components/environments/EnvironmentStatusPanel.logic'
8
12
 
9
13
  const props = defineProps<{
10
14
  environment: RunEnvironment | null
@@ -86,6 +90,12 @@ const ENV_STATUS_META = computed<
86
90
  },
87
91
  }))
88
92
 
93
+ // Which of the environment's two prose channels this panel shows. Both predicates live in
94
+ // `EnvironmentStatusPanel.logic.ts`, where the precedence between a recorded fault and a
95
+ // still-coming-up note is stated once and asserted without mounting the panel.
96
+ const failureShown = computed(() => showsProviderFailure(props.environment))
97
+ const statusNote = computed(() => readStatusNote(props.environment))
98
+
89
99
  // The two statuses that describe a transition IN FLIGHT. Only these ever animate, and only
90
100
  // while the run driving the transition is still being driven itself.
91
101
  const envInTransition = computed(
@@ -140,12 +150,19 @@ const envInTransition = computed(
140
150
  </dl>
141
151
  <!-- The verbatim provider error when the environment failed/expired. -->
142
152
  <pre
143
- v-if="
144
- environment.lastError &&
145
- (environment.status === 'failed' || environment.status === 'expired')
146
- "
153
+ v-if="failureShown"
147
154
  class="mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded border border-rose-900/60 bg-rose-950/40 p-1.5 text-[11px] text-rose-200/90"
148
155
  >{{ environment.lastError }}</pre>
156
+ <!-- What the provider says it is still waiting on. Muted rather than alarming: an
157
+ environment mid-rollout is healthy, and styling this like the error above would report
158
+ a fault every deploy. Bounded like the error block, because the text is provider
159
+ prose. -->
160
+ <p
161
+ v-if="statusNote"
162
+ class="mt-1 max-h-32 overflow-auto whitespace-pre-wrap break-words text-[11px] text-slate-400"
163
+ >
164
+ {{ t('environments.statusNote', { note: statusNote }) }}
165
+ </p>
149
166
  </div>
150
167
  <p v-else class="text-[12px] text-slate-500">
151
168
  {{ degradedReason ?? t('environments.empty') }}
@@ -34,6 +34,9 @@ const formatLabel = computed<Record<ApiContractFormat, string>>(() => ({
34
34
  openapi: t('foundational.format.openapi'),
35
35
  'toad-contract': t('foundational.format.toadContract'),
36
36
  'lokalise-api-contract': t('foundational.format.lokaliseApiContract'),
37
+ asyncapi: t('foundational.format.asyncapi'),
38
+ graphql: t('foundational.format.graphql'),
39
+ grpc: t('foundational.format.grpc'),
37
40
  }))
38
41
  // `as const` keeps the literal colour names assignable to UBadge's `color` union.
39
42
  const tierColor = {
@@ -20,6 +20,7 @@ import FoundationalServiceCatalogList from '~/components/foundational/Foundation
20
20
  import FoundationalServiceRegistry from '~/components/foundational/FoundationalServiceRegistry.vue'
21
21
  import FoundationalServiceSources from '~/components/foundational/FoundationalServiceSources.vue'
22
22
  import FoundationalSuppressions from '~/components/foundational/FoundationalSuppressions.vue'
23
+ import ServiceCatalogConnection from '~/components/foundational/ServiceCatalogConnection.vue'
23
24
 
24
25
  const props = withDefaults(
25
26
  defineProps<{
@@ -50,7 +51,7 @@ watch(
50
51
  { immediate: true },
51
52
  )
52
53
 
53
- type Tab = 'catalog' | 'registry' | 'sources'
54
+ type Tab = 'catalog' | 'registry' | 'sources' | 'portal'
54
55
  const tab = ref<Tab>(props.showCatalog ? 'catalog' : 'registry')
55
56
 
56
57
  const ownerLabel = computed(() =>
@@ -62,10 +63,14 @@ const tabs = computed(() => {
62
63
  { value: 'registry' as const, label: ownerLabel.value, slot: 'registry' },
63
64
  { value: 'sources' as const, label: t('foundational.tab.sources'), slot: 'sources' },
64
65
  ]
66
+ // The developer-portal import is WORKSPACE-only, so it rides the same flag the merged catalog
67
+ // does rather than a second one: the credential is workspace-keyed, and an account tab would
68
+ // offer a connection the backend serves at no scope.
65
69
  if (!props.showCatalog) return items
66
70
  return [
67
71
  { value: 'catalog' as const, label: t('foundational.tab.catalog'), slot: 'catalog' },
68
72
  ...items,
73
+ { value: 'portal' as const, label: t('foundational.tab.portal'), slot: 'portal' },
69
74
  ]
70
75
  })
71
76
 
@@ -116,6 +121,9 @@ const activeTab = computed({
116
121
  <template #sources>
117
122
  <FoundationalServiceSources :kind="props.kind" :owner-id="props.ownerId" />
118
123
  </template>
124
+ <template #portal>
125
+ <ServiceCatalogConnection />
126
+ </template>
119
127
  </UTabs>
120
128
  </div>
121
129
  </template>
@@ -37,6 +37,9 @@ const formatLabel = computed<Record<ApiContractFormat, string>>(() => ({
37
37
  openapi: t('foundational.format.openapi'),
38
38
  'toad-contract': t('foundational.format.toadContract'),
39
39
  'lokalise-api-contract': t('foundational.format.lokaliseApiContract'),
40
+ asyncapi: t('foundational.format.asyncapi'),
41
+ graphql: t('foundational.format.graphql'),
42
+ grpc: t('foundational.format.grpc'),
40
43
  }))
41
44
  const formatItems = computed(() =>
42
45
  (Object.keys(formatLabel.value) as ApiContractFormat[]).map((value) => ({
@@ -0,0 +1,392 @@
1
+ <script setup lang="ts">
2
+ // The workspace's SERVICE CATALOG connection: the developer portal (Backstage) whose services are
3
+ // imported into the foundational-services catalog as `workspace`-tier entries
4
+ // (backend/docs/service-catalog-import.md).
5
+ //
6
+ // A THIRD supply route beside the registry tab (upload) and the sources tab (a linked repo), so it
7
+ // lives beside them rather than in a settings page of its own: what it produces is the same
8
+ // catalog, and an operator deciding where a service came from should not have to look in two
9
+ // places.
10
+ //
11
+ // The form's shape follows the auth vocabulary, which is closed for a reason: these are the ways
12
+ // organisations actually run a self-hosted Backstage, and each needs a different request built. The
13
+ // fields shown switch on the selected mode, so a static token is one box and nothing else.
14
+ import { computed, reactive, ref } from 'vue'
15
+ import type { ConnectServiceCatalogInput, ServiceCatalogAuthMode } from '~/types/domain'
16
+ import { useFoundationalServicesStore } from '~/stores/foundationalServices'
17
+ import {
18
+ SERVICE_CATALOG_AUTH_KEYS,
19
+ SERVICE_CATALOG_AUTH_ORDER,
20
+ SERVICE_CATALOG_STATUS_COLORS,
21
+ serviceCatalogStatusKey,
22
+ } from '~/utils/serviceCatalog'
23
+
24
+ const catalog = useFoundationalServicesStore()
25
+ const toast = useToast()
26
+ const { present } = usePipelineErrorToast()
27
+ const { t, d } = useI18n()
28
+ const { confirm } = useConfirm()
29
+
30
+ const authMode = ref<ServiceCatalogAuthMode>('static-token')
31
+ const form = reactive({
32
+ baseUrl: '',
33
+ token: '',
34
+ sharedSecret: '',
35
+ tokenUrl: '',
36
+ clientId: '',
37
+ clientSecret: '',
38
+ scope: '',
39
+ audience: '',
40
+ username: '',
41
+ password: '',
42
+ headerName: '',
43
+ headerValue: '',
44
+ secondHeaderName: '',
45
+ secondHeaderValue: '',
46
+ entityFilter: '',
47
+ includeApis: true,
48
+ maxServices: 200,
49
+ })
50
+ const busy = ref<'connect' | 'probe' | 'import' | 'disconnect' | null>(null)
51
+
52
+ const connection = computed(() => catalog.serviceCatalog)
53
+
54
+ // Both vocabularies map to their keys in `~/utils/serviceCatalog`, whose spec asserts every entry
55
+ // against the base catalog: these are reached through a lookup rather than a literal key written
56
+ // out at the call site, so the typed-message-key guard cannot see them.
57
+ const authModeItems = computed(() =>
58
+ SERVICE_CATALOG_AUTH_ORDER.map((value) => ({
59
+ value,
60
+ label: t(SERVICE_CATALOG_AUTH_KEYS[value]),
61
+ })),
62
+ )
63
+
64
+ const authModeLabel = (mode: ServiceCatalogAuthMode) => t(SERVICE_CATALOG_AUTH_KEYS[mode])
65
+ const statusLabel = computed(() =>
66
+ t(serviceCatalogStatusKey(connection.value?.lastSyncStatus ?? null)),
67
+ )
68
+ const syncStatusColor = computed(() => {
69
+ const status = connection.value?.lastSyncStatus
70
+ return status ? SERVICE_CATALOG_STATUS_COLORS[status] : 'neutral'
71
+ })
72
+
73
+ /**
74
+ * The body both `connect` and `probe` send.
75
+ *
76
+ * ONE builder for both, deliberately: the probe exists to test what the operator has just typed,
77
+ * and a second builder is how a probe ends up testing a slightly different credential from the one
78
+ * that gets stored.
79
+ */
80
+ function buildInput(): ConnectServiceCatalogInput {
81
+ const terms = form.entityFilter
82
+ .split(/[\n,]/)
83
+ .map((term) => term.trim())
84
+ .filter(Boolean)
85
+ return {
86
+ baseUrl: form.baseUrl.trim(),
87
+ auth: buildAuth(),
88
+ ...(terms.length > 0 ? { entityFilter: terms } : {}),
89
+ includeApis: form.includeApis,
90
+ maxServices: form.maxServices,
91
+ }
92
+ }
93
+
94
+ function buildAuth(): ConnectServiceCatalogInput['auth'] {
95
+ switch (authMode.value) {
96
+ case 'none':
97
+ return { mode: 'none' }
98
+ case 'static-token':
99
+ return { mode: 'static-token', token: form.token.trim() }
100
+ case 'legacy-shared-secret':
101
+ return { mode: 'legacy-shared-secret', sharedSecret: form.sharedSecret.trim() }
102
+ case 'oauth2-client-credentials':
103
+ return {
104
+ mode: 'oauth2-client-credentials',
105
+ tokenUrl: form.tokenUrl.trim(),
106
+ clientId: form.clientId.trim(),
107
+ clientSecret: form.clientSecret.trim(),
108
+ ...(form.scope.trim() ? { scope: form.scope.trim() } : {}),
109
+ ...(form.audience.trim() ? { audience: form.audience.trim() } : {}),
110
+ }
111
+ case 'basic':
112
+ return { mode: 'basic', username: form.username.trim(), password: form.password }
113
+ case 'headers':
114
+ return {
115
+ mode: 'headers',
116
+ headers: [
117
+ { name: form.headerName.trim(), value: form.headerValue },
118
+ ...(form.secondHeaderName.trim()
119
+ ? [{ name: form.secondHeaderName.trim(), value: form.secondHeaderValue }]
120
+ : []),
121
+ ],
122
+ }
123
+ }
124
+ }
125
+
126
+ async function withBusy(kind: NonNullable<typeof busy.value>, fn: () => Promise<void>) {
127
+ if (busy.value) return
128
+ busy.value = kind
129
+ try {
130
+ await fn()
131
+ } finally {
132
+ busy.value = null
133
+ }
134
+ }
135
+
136
+ async function connect() {
137
+ await withBusy('connect', async () => {
138
+ try {
139
+ await catalog.connectServiceCatalog(buildInput())
140
+ toast.add({ title: t('serviceCatalog.toast.connected'), color: 'success' })
141
+ } catch (error) {
142
+ present(error, 'serviceCatalog.toast.connectFailed')
143
+ return
144
+ }
145
+ // The first import follows the connect, and is REPORTED as its own outcome. The connection is
146
+ // stored by the time it runs, so a revoked token surfacing here is an import failure with an
147
+ // import remedy; presenting it under "could not connect" would deny what the panel is already
148
+ // showing and bury the remedy under a title that says the opposite.
149
+ await runImport()
150
+ })
151
+ }
152
+
153
+ async function probe() {
154
+ await withBusy('probe', async () => {
155
+ try {
156
+ const result = await catalog.probeServiceCatalog(buildInput())
157
+ toast.add({
158
+ title: result.ok
159
+ ? t('serviceCatalog.toast.probeOk')
160
+ : t('serviceCatalog.toast.probeFailed'),
161
+ description: result.message,
162
+ color: result.ok ? 'success' : 'error',
163
+ })
164
+ } catch (error) {
165
+ present(error, 'serviceCatalog.toast.probeFailed')
166
+ }
167
+ })
168
+ }
169
+
170
+ async function importNow() {
171
+ await withBusy('import', runImport)
172
+ }
173
+
174
+ /** One import and its toast, shared by the Import button and the connect flow's first pass. */
175
+ async function runImport(): Promise<void> {
176
+ try {
177
+ const result = await catalog.importServiceCatalog()
178
+ toast.add({
179
+ title: t('serviceCatalog.toast.imported'),
180
+ description: t('serviceCatalog.toast.importedDetail', {
181
+ upserted: result.upserted,
182
+ unchanged: result.unchanged,
183
+ tombstoned: result.tombstoned,
184
+ }),
185
+ color: result.status === 'ok' ? 'success' : 'warning',
186
+ })
187
+ } catch (error) {
188
+ present(error, 'serviceCatalog.toast.importFailed')
189
+ }
190
+ }
191
+
192
+ async function disconnect() {
193
+ // The imported services are TOMBSTONED with the connection, which is destructive enough to
194
+ // confirm: an operator who expected the rows to stay would otherwise lose a board's whole
195
+ // imported estate on one click.
196
+ if (
197
+ !(await confirm({
198
+ title: t('serviceCatalog.disconnect.title'),
199
+ description: t('serviceCatalog.disconnect.body'),
200
+ confirmLabel: t('serviceCatalog.disconnect.confirm'),
201
+ variant: 'destructive',
202
+ }))
203
+ ) {
204
+ return
205
+ }
206
+ await withBusy('disconnect', async () => {
207
+ try {
208
+ await catalog.disconnectServiceCatalog()
209
+ toast.add({ title: t('serviceCatalog.toast.disconnected'), color: 'success' })
210
+ } catch (error) {
211
+ present(error, 'serviceCatalog.toast.disconnectFailed')
212
+ }
213
+ })
214
+ }
215
+ </script>
216
+
217
+ <template>
218
+ <div class="flex flex-col gap-4" data-testid="service-catalog-connection">
219
+ <!-- Unwired is stated, never offered as a form that would fail with a raw 503. -->
220
+ <div
221
+ v-if="catalog.serviceCatalogAvailable === false"
222
+ class="rounded-md border border-slate-800 bg-slate-900/40 p-3 text-sm text-slate-400"
223
+ >
224
+ {{ t('serviceCatalog.unavailable') }}
225
+ </div>
226
+
227
+ <template v-else>
228
+ <p class="text-sm text-slate-400">{{ t('serviceCatalog.intro') }}</p>
229
+
230
+ <!-- The CONNECTED state, with what the last import concluded. `lastSyncMessage` is the
231
+ load-bearing line: it is what says a catalog is a PREFIX of the portal's estate. -->
232
+ <div
233
+ v-if="connection"
234
+ class="flex flex-col gap-2 rounded-md border border-slate-800 bg-slate-900/40 p-3"
235
+ data-testid="service-catalog-connected"
236
+ >
237
+ <div class="flex flex-wrap items-center gap-2">
238
+ <UBadge :color="syncStatusColor" variant="subtle">{{ statusLabel }}</UBadge>
239
+ <span class="font-mono text-xs text-slate-300">{{ connection.baseUrl }}</span>
240
+ <span class="text-xs text-slate-500">{{ authModeLabel(connection.authMode) }}</span>
241
+ <span v-if="connection.lastSyncedAt" class="text-xs text-slate-500">
242
+ {{ d(new Date(connection.lastSyncedAt), 'short') }}
243
+ </span>
244
+ </div>
245
+ <p class="text-xs text-slate-400">
246
+ {{
247
+ t('serviceCatalog.summary', {
248
+ filter: connection.entityFilter.join(', '),
249
+ max: connection.maxServices,
250
+ })
251
+ }}
252
+ </p>
253
+ <p v-if="connection.lastSyncMessage" class="text-xs text-amber-400">
254
+ {{ connection.lastSyncMessage }}
255
+ </p>
256
+ <div class="flex gap-2">
257
+ <UButton size="xs" :loading="busy === 'import'" :disabled="!!busy" @click="importNow">
258
+ {{ t('serviceCatalog.action.import') }}
259
+ </UButton>
260
+ <UButton
261
+ size="xs"
262
+ color="error"
263
+ variant="soft"
264
+ :loading="busy === 'disconnect'"
265
+ :disabled="!!busy"
266
+ @click="disconnect"
267
+ >
268
+ {{ t('serviceCatalog.action.disconnect') }}
269
+ </UButton>
270
+ </div>
271
+ </div>
272
+
273
+ <!-- The connect / re-connect form. Shown alongside a live connection too, because rotating
274
+ a token is the routine reason to come here. -->
275
+ <div class="flex flex-col gap-3 rounded-md border border-slate-800 p-3">
276
+ <h4 class="text-sm font-medium text-slate-200">
277
+ {{ connection ? t('serviceCatalog.form.replace') : t('serviceCatalog.form.connect') }}
278
+ </h4>
279
+
280
+ <UFormField
281
+ :label="t('serviceCatalog.field.baseUrl')"
282
+ :help="t('serviceCatalog.help.baseUrl')"
283
+ >
284
+ <UInput v-model="form.baseUrl" placeholder="https://backstage.example.com" />
285
+ </UFormField>
286
+
287
+ <UFormField :label="t('serviceCatalog.field.authMode')">
288
+ <USelect v-model="authMode" :items="authModeItems" value-key="value" />
289
+ </UFormField>
290
+
291
+ <UFormField
292
+ v-if="authMode === 'static-token'"
293
+ :label="t('serviceCatalog.field.token')"
294
+ :help="t('serviceCatalog.help.token')"
295
+ >
296
+ <UInput v-model="form.token" type="password" />
297
+ </UFormField>
298
+
299
+ <UFormField
300
+ v-if="authMode === 'legacy-shared-secret'"
301
+ :label="t('serviceCatalog.field.sharedSecret')"
302
+ :help="t('serviceCatalog.help.sharedSecret')"
303
+ >
304
+ <UInput v-model="form.sharedSecret" type="password" />
305
+ </UFormField>
306
+
307
+ <template v-if="authMode === 'oauth2-client-credentials'">
308
+ <UFormField :label="t('serviceCatalog.field.tokenUrl')">
309
+ <UInput v-model="form.tokenUrl" placeholder="https://idp.example.com/oauth2/token" />
310
+ </UFormField>
311
+ <UFormField :label="t('serviceCatalog.field.clientId')">
312
+ <UInput v-model="form.clientId" />
313
+ </UFormField>
314
+ <UFormField :label="t('serviceCatalog.field.clientSecret')">
315
+ <UInput v-model="form.clientSecret" type="password" />
316
+ </UFormField>
317
+ <UFormField :label="t('serviceCatalog.field.scope')">
318
+ <UInput v-model="form.scope" />
319
+ </UFormField>
320
+ <UFormField :label="t('serviceCatalog.field.audience')">
321
+ <UInput v-model="form.audience" />
322
+ </UFormField>
323
+ </template>
324
+
325
+ <template v-if="authMode === 'basic'">
326
+ <UFormField :label="t('serviceCatalog.field.username')">
327
+ <UInput v-model="form.username" />
328
+ </UFormField>
329
+ <UFormField :label="t('serviceCatalog.field.password')">
330
+ <UInput v-model="form.password" type="password" />
331
+ </UFormField>
332
+ </template>
333
+
334
+ <template v-if="authMode === 'headers'">
335
+ <UFormField
336
+ :label="t('serviceCatalog.field.headerName')"
337
+ :help="t('serviceCatalog.help.headers')"
338
+ >
339
+ <UInput v-model="form.headerName" placeholder="CF-Access-Client-Id" />
340
+ </UFormField>
341
+ <UFormField :label="t('serviceCatalog.field.headerValue')">
342
+ <UInput v-model="form.headerValue" type="password" />
343
+ </UFormField>
344
+ <UFormField :label="t('serviceCatalog.field.secondHeaderName')">
345
+ <UInput v-model="form.secondHeaderName" placeholder="CF-Access-Client-Secret" />
346
+ </UFormField>
347
+ <UFormField :label="t('serviceCatalog.field.secondHeaderValue')">
348
+ <UInput v-model="form.secondHeaderValue" type="password" />
349
+ </UFormField>
350
+ </template>
351
+
352
+ <UFormField
353
+ :label="t('serviceCatalog.field.entityFilter')"
354
+ :help="t('serviceCatalog.help.entityFilter')"
355
+ >
356
+ <UTextarea v-model="form.entityFilter" :rows="2" placeholder="kind=component" />
357
+ </UFormField>
358
+
359
+ <UFormField
360
+ :label="t('serviceCatalog.field.maxServices')"
361
+ :help="t('serviceCatalog.help.maxServices')"
362
+ >
363
+ <UInput v-model.number="form.maxServices" type="number" :min="1" :max="1000" />
364
+ </UFormField>
365
+
366
+ <UCheckbox v-model="form.includeApis" :label="t('serviceCatalog.field.includeApis')" />
367
+
368
+ <div class="flex gap-2">
369
+ <UButton
370
+ size="xs"
371
+ :loading="busy === 'connect'"
372
+ :disabled="!!busy || !form.baseUrl.trim()"
373
+ @click="connect"
374
+ >
375
+ {{
376
+ connection ? t('serviceCatalog.action.replace') : t('serviceCatalog.action.connect')
377
+ }}
378
+ </UButton>
379
+ <UButton
380
+ size="xs"
381
+ variant="soft"
382
+ :loading="busy === 'probe'"
383
+ :disabled="!!busy || !form.baseUrl.trim()"
384
+ @click="probe"
385
+ >
386
+ {{ t('serviceCatalog.action.probe') }}
387
+ </UButton>
388
+ </div>
389
+ </div>
390
+ </template>
391
+ </div>
392
+ </template>
@@ -20,6 +20,7 @@ const env = (overrides: Partial<OutcomeEnvironment> = {}): OutcomeEnvironment =>
20
20
  frameId: 'frm_own',
21
21
  environmentId: 'env_1',
22
22
  detail: null,
23
+ detailKind: null,
23
24
  ...overrides,
24
25
  })
25
26
 
@@ -803,12 +803,21 @@ function openTestReport() {
803
803
  : t('outcome.environments.expires', { date: d(new Date(row.expiresAt), 'long') })
804
804
  }}
805
805
  </p>
806
+ <!-- One slot, two kinds of claim. A provider's note about a spin-up in progress is
807
+ labelled as one; a recorded fault is the row's own prose. Unlabelled they read
808
+ identically, and "the deploy job is queued behind 3 others" in the slot that
809
+ otherwise holds "quota exceeded" reports a fault the environment does not have. -->
806
810
  <p
807
811
  v-if="row.detail"
808
812
  class="mt-1 break-words text-[12px] leading-relaxed text-slate-500"
809
813
  data-testid="outcome-environment-detail"
814
+ :data-detail-kind="row.detailKind"
810
815
  >
811
- {{ row.detail }}
816
+ {{
817
+ row.detailKind === 'note'
818
+ ? t('environments.statusNote', { note: row.detail })
819
+ : row.detail
820
+ }}
812
821
  </p>
813
822
  </div>
814
823
  </template>