@cat-factory/app 0.288.2 → 0.289.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/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/panels/ObservabilityPanel.vue +19 -0
- package/app/components/providers/ApiKeysSection.vue +24 -14
- 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/serviceCatalog.spec.ts +51 -0
- package/app/utils/serviceCatalog.ts +68 -0
- package/i18n/locales/de.json +88 -3
- package/i18n/locales/en.json +88 -3
- package/i18n/locales/es.json +88 -3
- package/i18n/locales/fr.json +88 -3
- package/i18n/locales/he.json +88 -3
- package/i18n/locales/it.json +88 -3
- package/i18n/locales/ja.json +88 -3
- package/i18n/locales/pl.json +88 -3
- package/i18n/locales/tr.json +88 -3
- package/i18n/locales/uk.json +88 -3
- package/package.json +2 -2
|
@@ -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>
|
|
@@ -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>
|
|
@@ -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
|
/**
|