@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
|
@@ -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>
|
|
@@ -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
|
-
{{
|
|
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>
|