@cat-factory/app 0.47.1 → 0.47.3

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.
@@ -0,0 +1,358 @@
1
+ <script setup lang="ts">
2
+ // One tab of the Infrastructure window — the connect surface for a single provider kind
3
+ // (container agents → runner pool, or test environments → environment provider). Both
4
+ // self-describe via a ProviderDescriptor, so this renders either without hard-coding them:
5
+ // - a NATIVE provider (ships a `manifestTemplate`) → the friendly flat field form, whose
6
+ // values are overlaid back onto the manifest before saving (the single storage path).
7
+ // - a MANIFEST-driven provider (no template) → the full JSON manifest editor
8
+ // (ProviderManifestEditor), which replaces the old "use the API" disclaimer.
9
+ import { computed, ref, toRaw, watch } from 'vue'
10
+ import type { ProviderConnectionKind } from '~/types/providerConnections'
11
+ import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
12
+ import ProviderManifestEditor from '~/components/settings/ProviderManifestEditor.vue'
13
+
14
+ const props = defineProps<{ kind: ProviderConnectionKind }>()
15
+
16
+ const { t } = useI18n()
17
+ const store = useProviderConnectionsStore()
18
+ const toast = useToast()
19
+
20
+ const descriptor = computed(() => store.descriptorFor(props.kind))
21
+ const connection = computed(() => store.connectionFor(props.kind))
22
+
23
+ const BLURB_KEYS: Record<ProviderConnectionKind, string> = {
24
+ environment: 'settings.providerConnection.kind.environment.blurb',
25
+ 'runner-pool': 'settings.providerConnection.kind.runner-pool.blurb',
26
+ }
27
+ const blurb = computed(() => t(BLURB_KEYS[props.kind]))
28
+ const title = computed(() => t(`settings.providerConnection.kind.${props.kind}.title`))
29
+
30
+ watch(
31
+ () => props.kind,
32
+ (k) => void store.loadKind(k).then(resetDraft),
33
+ { immediate: true },
34
+ )
35
+
36
+ // "View logs": the provisioning event history for this provider's subsystem.
37
+ const showLogs = ref(false)
38
+
39
+ // --- Shared state -------------------------------------------------------------------
40
+ const values = ref<Record<string, string>>({})
41
+ const testResult = ref<{ ok: boolean; message?: string } | null>(null)
42
+ const testing = ref(false)
43
+ const busy = ref(false)
44
+
45
+ /** A native provider ships a manifest scaffold ⇒ render the friendly flat field form. */
46
+ const isNative = computed(() => !!descriptor.value?.manifestTemplate)
47
+ const secretFieldCount = computed(
48
+ () => (descriptor.value?.configFields ?? []).filter((f) => f.secret).length,
49
+ )
50
+ const hasSecretFields = computed(() => secretFieldCount.value > 0)
51
+
52
+ // Seed the flat-form draft from the saved manifest so an edit starts from the CURRENT
53
+ // non-secret config (baseUrl + providerConfig). Secret fields are never prefilled.
54
+ function resetDraft() {
55
+ testResult.value = null
56
+ const saved = descriptor.value?.savedManifest
57
+ const cfg = (saved?.providerConfig as Record<string, unknown> | undefined) ?? {}
58
+ const next: Record<string, string> = {}
59
+ for (const f of descriptor.value?.configFields ?? []) {
60
+ if (f.secret) continue
61
+ if (f.key === 'baseUrl') {
62
+ const b = saved?.baseUrl ?? connection.value?.baseUrl
63
+ if (typeof b === 'string') next[f.key] = b
64
+ } else if (typeof cfg[f.key] === 'string') {
65
+ next[f.key] = cfg[f.key] as string
66
+ }
67
+ }
68
+ values.value = next
69
+ }
70
+
71
+ /** A flat-form field is satisfied when filled now, or already stored, or it has a default. */
72
+ function satisfied(key: string): boolean {
73
+ const f = descriptor.value?.configFields.find((cf) => cf.key === key)
74
+ if (!f) return true
75
+ if ((values.value[key] ?? '').trim()) return true
76
+ if (f.default !== undefined) return true
77
+ return !(descriptor.value?.missingRequired ?? []).includes(key)
78
+ }
79
+
80
+ const canSave = computed(() => {
81
+ if (!descriptor.value || !isNative.value) return false
82
+ return (descriptor.value.missingRequired ?? []).every(satisfied)
83
+ })
84
+
85
+ /** Overlay the flat field values onto the native provider's manifest. */
86
+ function buildManifestPayload(): {
87
+ manifest: Record<string, unknown>
88
+ secrets: Record<string, string>
89
+ } | null {
90
+ const template = descriptor.value?.manifestTemplate
91
+ if (!template) return null
92
+ const base = descriptor.value?.savedManifest ?? template
93
+ // `base` is a Vue reactive proxy, which structuredClone refuses; `toRaw` unwraps it.
94
+ const manifest: Record<string, unknown> = structuredClone(toRaw(base))
95
+ const providerConfig: Record<string, unknown> = {
96
+ ...(manifest.providerConfig as Record<string, unknown> | undefined),
97
+ }
98
+ const secrets: Record<string, string> = {}
99
+ for (const f of descriptor.value?.configFields ?? []) {
100
+ const val = (values.value[f.key] ?? '').trim()
101
+ if (!val) continue
102
+ if (f.secret) secrets[f.key] = val
103
+ else if (f.key === 'baseUrl') manifest.baseUrl = val
104
+ else providerConfig[f.key] = val
105
+ }
106
+ if (Object.keys(providerConfig).length) manifest.providerConfig = providerConfig
107
+ return { manifest, secrets }
108
+ }
109
+
110
+ function notifyError(title: string, e: unknown) {
111
+ toast.add({
112
+ title,
113
+ description: e instanceof Error ? e.message : String(e),
114
+ icon: 'i-lucide-triangle-alert',
115
+ color: 'error',
116
+ })
117
+ }
118
+
119
+ function toastSaved() {
120
+ toast.add({
121
+ title: t('settings.providerConnection.toast.saved', { title: title.value }),
122
+ icon: 'i-lucide-check',
123
+ color: 'success',
124
+ })
125
+ }
126
+
127
+ // --- Native flat-form actions -------------------------------------------------------
128
+ async function testNative() {
129
+ const payload = buildManifestPayload()
130
+ if (!payload) return
131
+ testing.value = true
132
+ testResult.value = null
133
+ try {
134
+ testResult.value = await store.test(props.kind, payload)
135
+ } catch (e) {
136
+ testResult.value = { ok: false, message: e instanceof Error ? e.message : String(e) }
137
+ } finally {
138
+ testing.value = false
139
+ }
140
+ }
141
+
142
+ async function saveNative() {
143
+ busy.value = true
144
+ try {
145
+ const payload = buildManifestPayload()
146
+ if (payload) await store.register(props.kind, payload)
147
+ resetDraft()
148
+ toastSaved()
149
+ } catch (e) {
150
+ notifyError(t('settings.providerConnection.toast.saveFailed'), e)
151
+ } finally {
152
+ busy.value = false
153
+ }
154
+ }
155
+
156
+ // --- Manifest-editor actions (emitted from ProviderManifestEditor) ------------------
157
+ async function testManifest(payload: {
158
+ manifest: Record<string, unknown>
159
+ secrets: Record<string, string>
160
+ }) {
161
+ testing.value = true
162
+ testResult.value = null
163
+ try {
164
+ testResult.value = await store.test(props.kind, payload)
165
+ } catch (e) {
166
+ testResult.value = { ok: false, message: e instanceof Error ? e.message : String(e) }
167
+ } finally {
168
+ testing.value = false
169
+ }
170
+ }
171
+
172
+ async function saveManifest(payload: {
173
+ manifest: Record<string, unknown>
174
+ secrets: Record<string, string>
175
+ }) {
176
+ busy.value = true
177
+ try {
178
+ await store.register(props.kind, payload)
179
+ toastSaved()
180
+ } catch (e) {
181
+ notifyError(t('settings.providerConnection.toast.saveFailed'), e)
182
+ } finally {
183
+ busy.value = false
184
+ }
185
+ }
186
+
187
+ async function remove() {
188
+ busy.value = true
189
+ try {
190
+ await store.remove(props.kind)
191
+ resetDraft()
192
+ toast.add({ title: t('settings.providerConnection.toast.removed'), icon: 'i-lucide-check' })
193
+ } catch (e) {
194
+ notifyError(t('settings.providerConnection.toast.removeFailed'), e)
195
+ } finally {
196
+ busy.value = false
197
+ }
198
+ }
199
+
200
+ /** The helper line under a flat field — its own help, plus the "defaulted to …" hint. */
201
+ function fieldHelp(key: string): string | undefined {
202
+ const f = descriptor.value?.configFields.find((cf) => cf.key === key)
203
+ if (!f) return undefined
204
+ const filled = (values.value[key] ?? '').trim()
205
+ if (f.default !== undefined && !filled) {
206
+ const defaulted = t('settings.providerConnection.field.defaultsTo', { value: f.default })
207
+ return f.help ? `${f.help} · ${defaulted}` : defaulted
208
+ }
209
+ return f.help
210
+ }
211
+ </script>
212
+
213
+ <template>
214
+ <div v-if="descriptor" class="space-y-4">
215
+ <div class="flex items-start justify-between gap-3">
216
+ <p class="text-xs text-slate-400">{{ blurb }}</p>
217
+ <UButton
218
+ :icon="showLogs ? 'i-lucide-chevron-up' : 'i-lucide-scroll-text'"
219
+ variant="ghost"
220
+ size="xs"
221
+ class="shrink-0"
222
+ @click="showLogs = !showLogs"
223
+ >
224
+ {{
225
+ showLogs
226
+ ? t('settings.providerConnection.hideLogs')
227
+ : t('settings.providerConnection.viewLogs')
228
+ }}
229
+ </UButton>
230
+ </div>
231
+
232
+ <!-- Provisioning attempt history for this provider's subsystem. -->
233
+ <ProvisioningLogsDrawer v-if="showLogs" :subsystem="kind" />
234
+
235
+ <!-- Saved connection summary -->
236
+ <div
237
+ v-if="connection"
238
+ class="flex items-center justify-between rounded-md border border-slate-700 bg-slate-900/50 px-3 py-2 text-sm"
239
+ >
240
+ <div>
241
+ <span class="font-medium text-slate-200">{{ connection.label }}</span>
242
+ <div class="text-[11px] text-emerald-400">
243
+ {{ t('settings.providerConnection.connectedAt', { baseUrl: connection.baseUrl }) }}
244
+ </div>
245
+ </div>
246
+ <UButton
247
+ icon="i-lucide-trash-2"
248
+ color="error"
249
+ variant="ghost"
250
+ size="xs"
251
+ :disabled="busy"
252
+ @click="remove()"
253
+ />
254
+ </div>
255
+
256
+ <!-- Mandatory-fields warning (mirrors the banner) -->
257
+ <div
258
+ v-if="descriptor.missingRequired.length"
259
+ class="rounded-md border border-amber-500/40 bg-amber-950/40 px-3 py-2 text-xs text-amber-200"
260
+ >
261
+ {{
262
+ t('settings.providerConnection.missingConfig', {
263
+ fields: descriptor.missingRequired.join(', '),
264
+ })
265
+ }}
266
+ </div>
267
+
268
+ <!-- NATIVE provider: the friendly, descriptor-driven flat field form. -->
269
+ <div v-if="isNative" class="rounded-lg border border-dashed border-slate-700 p-3 space-y-3">
270
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
271
+ {{
272
+ connection
273
+ ? t('settings.providerConnection.form.updateConfiguration')
274
+ : t('settings.providerConnection.form.connect')
275
+ }}
276
+ </p>
277
+ <p v-if="connection && hasSecretFields" class="text-[11px] text-amber-300/80">
278
+ {{
279
+ t(
280
+ 'settings.providerConnection.form.reenterSecrets',
281
+ { count: secretFieldCount },
282
+ secretFieldCount,
283
+ )
284
+ }}
285
+ </p>
286
+
287
+ <UFormField
288
+ v-for="field in descriptor.configFields"
289
+ :key="field.key"
290
+ :label="
291
+ field.required && field.default === undefined
292
+ ? field.label
293
+ : t('settings.providerConnection.form.optionalLabel', { label: field.label })
294
+ "
295
+ :help="fieldHelp(field.key)"
296
+ >
297
+ <USelect
298
+ v-if="field.type === 'select'"
299
+ v-model="values[field.key]"
300
+ :items="(field.options ?? []).map((o) => ({ label: o.label, value: o.value }))"
301
+ :placeholder="field.default ?? field.placeholder"
302
+ />
303
+ <UInput
304
+ v-else
305
+ v-model="values[field.key]"
306
+ :type="field.secret ? 'password' : 'text'"
307
+ class="font-mono"
308
+ :placeholder="field.default ?? field.placeholder"
309
+ />
310
+ </UFormField>
311
+
312
+ <div v-if="descriptor.supportsTest" class="flex items-center gap-2">
313
+ <UButton
314
+ color="neutral"
315
+ variant="soft"
316
+ size="sm"
317
+ icon="i-lucide-plug-zap"
318
+ :loading="testing"
319
+ @click="testNative()"
320
+ >
321
+ {{ t('settings.providerConnection.test.button') }}
322
+ </UButton>
323
+ <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
324
+ {{ testResult.message ?? t('settings.providerConnection.test.ok') }}
325
+ </span>
326
+ <span v-else-if="testResult" class="text-xs text-rose-400">
327
+ {{ testResult.message ?? t('settings.providerConnection.test.failed') }}
328
+ </span>
329
+ </div>
330
+
331
+ <div class="flex justify-end">
332
+ <UButton
333
+ color="primary"
334
+ size="sm"
335
+ :loading="busy"
336
+ :disabled="!canSave"
337
+ @click="saveNative()"
338
+ >
339
+ {{ connection ? t('common.save') : t('settings.providerConnection.form.connect') }}
340
+ </UButton>
341
+ </div>
342
+ </div>
343
+
344
+ <!-- MANIFEST-driven provider: the full in-app manifest editor. -->
345
+ <ProviderManifestEditor
346
+ v-else
347
+ :kind="kind"
348
+ :saved-manifest="descriptor.savedManifest"
349
+ :connected="!!connection"
350
+ :supports-test="descriptor.supportsTest"
351
+ :testing="testing"
352
+ :busy="busy"
353
+ :test-result="testResult"
354
+ @test="testManifest"
355
+ @save="saveManifest"
356
+ />
357
+ </div>
358
+ </template>
@@ -0,0 +1,286 @@
1
+ <script setup lang="ts">
2
+ // The in-app manifest editor for a MANIFEST-DRIVEN infrastructure provider (a runner pool
3
+ // or an environment provider without a native code adapter). It replaces the old
4
+ // "register it via the API" disclaimer: the operator authors the provider's full JSON
5
+ // manifest here, supplies the write-only secret values it references, and tests/saves —
6
+ // entirely in-app.
7
+ //
8
+ // The manifest is validated against the SAME Valibot wire contract the backend enforces
9
+ // (runner pool: RunnerPoolManifest; environment: EnvironmentManifest), imported from
10
+ // @cat-factory/contracts so the client check stays in lockstep with the server. The server
11
+ // remains authoritative — register re-validates — so a client that's behind still can't
12
+ // persist an invalid manifest.
13
+ //
14
+ // Secrets are write-only: never prefilled. Because register replaces the whole manifest +
15
+ // secret bundle, EVERY secret key the manifest references must be (re-)supplied on save —
16
+ // on an existing connection the amber hint says so.
17
+ import { computed, ref, watch } from 'vue'
18
+ import * as v from 'valibot'
19
+ import { environmentManifestSchema, runnerPoolManifestSchema } from '@cat-factory/contracts'
20
+ import type { ProviderConnectionKind } from '~/types/providerConnections'
21
+
22
+ const props = defineProps<{
23
+ kind: ProviderConnectionKind
24
+ /** The provider's current saved manifest (secret-ref keys only, no values). */
25
+ savedManifest?: Record<string, unknown>
26
+ /** Whether a connection already exists (drives the re-enter-secrets hint + button label). */
27
+ connected: boolean
28
+ /** Whether the provider exposes a connection test the UI can call. */
29
+ supportsTest: boolean
30
+ /** Bubbled-up busy state from the tab's store calls (so the editor shows loading). */
31
+ testing: boolean
32
+ busy: boolean
33
+ testResult: { ok: boolean; message?: string } | null
34
+ }>()
35
+
36
+ const emit = defineEmits<{
37
+ test: [payload: { manifest: Record<string, unknown>; secrets: Record<string, string> }]
38
+ save: [payload: { manifest: Record<string, unknown>; secrets: Record<string, string> }]
39
+ }>()
40
+
41
+ const { t } = useI18n()
42
+
43
+ // A minimal, valid starter manifest per kind (O1 option a: a static SPA example — no backend
44
+ // round-trip). Seeds the editor when there's no saved manifest to start from. The operator
45
+ // edits providerId/label/baseUrl and the request templates for their own scheduler/API.
46
+ const STARTERS: Record<ProviderConnectionKind, Record<string, unknown>> = {
47
+ 'runner-pool': {
48
+ providerId: 'my-pool',
49
+ label: 'My runner pool',
50
+ baseUrl: 'https://pool.example.com',
51
+ auth: { type: 'bearer', secretRef: { key: 'API_TOKEN' } },
52
+ dispatch: { method: 'POST', pathTemplate: '/jobs', bodyTemplate: '{{input.job}}' },
53
+ poll: { method: 'GET', pathTemplate: '/jobs/{{input.jobId}}' },
54
+ response: {
55
+ statusPath: 'state',
56
+ statusMap: [
57
+ { from: 'running', to: 'running' },
58
+ { from: 'completed', to: 'done' },
59
+ { from: 'error', to: 'failed' },
60
+ ],
61
+ resultPath: 'result',
62
+ },
63
+ },
64
+ environment: {
65
+ providerId: 'my-envs',
66
+ label: 'My environment provider',
67
+ baseUrl: 'https://envs.example.com',
68
+ auth: { type: 'bearer', secretRef: { key: 'API_TOKEN' } },
69
+ provision: { method: 'POST', pathTemplate: '/environments', bodyTemplate: '{}' },
70
+ status: { method: 'GET', pathTemplate: '/environments/{{provision.id}}' },
71
+ teardown: { method: 'DELETE', pathTemplate: '/environments/{{provision.id}}' },
72
+ response: {
73
+ urlPath: 'url',
74
+ statusPath: 'status',
75
+ statusMap: [
76
+ { from: 'building', to: 'provisioning' },
77
+ { from: 'ready', to: 'ready' },
78
+ ],
79
+ },
80
+ },
81
+ }
82
+
83
+ const schema = computed(() =>
84
+ props.kind === 'runner-pool' ? runnerPoolManifestSchema : environmentManifestSchema,
85
+ )
86
+
87
+ const text = ref('')
88
+ const secrets = ref<Record<string, string>>({})
89
+
90
+ /** Seed the editor from the saved manifest (an edit) or the starter (a first connect). */
91
+ function seed() {
92
+ const base = props.savedManifest ?? STARTERS[props.kind]
93
+ text.value = JSON.stringify(base, null, 2)
94
+ secrets.value = {}
95
+ }
96
+
97
+ // Re-seed on first mount and whenever the saved manifest changes (e.g. after a successful
98
+ // save reloads the descriptor) — the saved manifest is the new canonical text and the
99
+ // just-saved secrets are cleared from the write-only inputs.
100
+ watch(() => props.savedManifest, seed, { immediate: true })
101
+
102
+ /** Parse the textarea; null value on a JSON syntax error. */
103
+ const parsed = computed<{ ok: boolean; value?: Record<string, unknown> }>(() => {
104
+ const raw = text.value.trim()
105
+ if (!raw) return { ok: false }
106
+ try {
107
+ const value = JSON.parse(raw)
108
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return { ok: false }
109
+ return { ok: true, value: value as Record<string, unknown> }
110
+ } catch {
111
+ return { ok: false }
112
+ }
113
+ })
114
+
115
+ const jsonError = computed(() => text.value.trim().length > 0 && !parsed.value.ok)
116
+
117
+ /** Validate the parsed object against the wire contract; surface the first issue. */
118
+ const schemaError = computed<string | null>(() => {
119
+ if (!parsed.value.ok || !parsed.value.value) return null
120
+ const result = v.safeParse(schema.value, parsed.value.value)
121
+ if (result.success) return null
122
+ const issue = result.issues[0]
123
+ if (!issue) return t('settings.providerConnection.manifestEditor.invalidShape')
124
+ const path = (issue.path ?? []).map((p) => String((p as { key?: unknown }).key ?? '')).join('.')
125
+ return path ? `${path}: ${issue.message}` : issue.message
126
+ })
127
+
128
+ const validManifest = computed<Record<string, unknown> | null>(() =>
129
+ parsed.value.ok && parsed.value.value && !schemaError.value ? parsed.value.value : null,
130
+ )
131
+
132
+ /**
133
+ * Every secret key the manifest's auth scheme references, discovered generically by walking
134
+ * the parsed object for any `*SecretRef` (or `secretRef`) with a string `key`. Covers bearer
135
+ * / api_key / basic / oauth2 / custom_headers without hard-coding each auth variant.
136
+ */
137
+ const secretKeys = computed<string[]>(() => {
138
+ const out = new Set<string>()
139
+ const walk = (node: unknown) => {
140
+ if (Array.isArray(node)) {
141
+ for (const item of node) walk(item)
142
+ return
143
+ }
144
+ if (node && typeof node === 'object') {
145
+ for (const [key, val] of Object.entries(node)) {
146
+ if (
147
+ /secretref$/i.test(key) &&
148
+ val &&
149
+ typeof val === 'object' &&
150
+ typeof (val as { key?: unknown }).key === 'string'
151
+ ) {
152
+ out.add((val as { key: string }).key)
153
+ } else {
154
+ walk(val)
155
+ }
156
+ }
157
+ }
158
+ }
159
+ if (parsed.value.value) walk(parsed.value.value)
160
+ return [...out]
161
+ })
162
+
163
+ // register() replaces the whole bundle, so every referenced secret must be supplied to save.
164
+ const allSecretsSupplied = computed(() =>
165
+ secretKeys.value.every((k) => (secrets.value[k] ?? '').trim().length > 0),
166
+ )
167
+ const canSave = computed(() => !!validManifest.value && allSecretsSupplied.value)
168
+ // A test can probe with whatever secrets are filled in (a partial probe is still useful).
169
+ const canTest = computed(() => !!validManifest.value)
170
+
171
+ function filledSecrets(): Record<string, string> {
172
+ const out: Record<string, string> = {}
173
+ for (const k of secretKeys.value) {
174
+ const val = (secrets.value[k] ?? '').trim()
175
+ if (val) out[k] = val
176
+ }
177
+ return out
178
+ }
179
+
180
+ function onTest() {
181
+ if (!validManifest.value) return
182
+ emit('test', { manifest: validManifest.value, secrets: filledSecrets() })
183
+ }
184
+
185
+ function onSave() {
186
+ if (!canSave.value || !validManifest.value) return
187
+ emit('save', { manifest: validManifest.value, secrets: filledSecrets() })
188
+ }
189
+ </script>
190
+
191
+ <template>
192
+ <div class="space-y-3 rounded-lg border border-dashed border-slate-700 p-3">
193
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
194
+ {{ t('settings.providerConnection.manifestEditor.title') }}
195
+ </p>
196
+
197
+ <UFormField
198
+ :label="t('settings.providerConnection.manifestEditor.jsonLabel')"
199
+ :help="t('settings.providerConnection.manifestEditor.jsonHelp')"
200
+ >
201
+ <UTextarea
202
+ v-model="text"
203
+ :rows="16"
204
+ class="w-full font-mono text-xs"
205
+ data-testid="manifest-editor-json"
206
+ spellcheck="false"
207
+ />
208
+ </UFormField>
209
+
210
+ <p v-if="!savedManifest && !jsonError && !schemaError" class="text-[11px] text-slate-500">
211
+ {{ t('settings.providerConnection.manifestEditor.starterHint') }}
212
+ </p>
213
+
214
+ <!-- Parse + shape errors, validated against the same contract the backend enforces. -->
215
+ <p
216
+ v-if="jsonError"
217
+ class="rounded-md border border-rose-500/40 bg-rose-950/40 px-3 py-2 text-xs text-rose-200"
218
+ data-testid="manifest-editor-error"
219
+ >
220
+ {{ t('settings.providerConnection.manifestEditor.invalidJson') }}
221
+ </p>
222
+ <p
223
+ v-else-if="schemaError"
224
+ class="rounded-md border border-amber-500/40 bg-amber-950/40 px-3 py-2 text-xs text-amber-200"
225
+ data-testid="manifest-editor-error"
226
+ >
227
+ {{ t('settings.providerConnection.manifestEditor.schemaError', { message: schemaError }) }}
228
+ </p>
229
+
230
+ <!-- Secret sub-form: one write-only input per secret key the manifest references. -->
231
+ <div class="space-y-2">
232
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
233
+ {{ t('settings.providerConnection.manifestEditor.secretsLabel') }}
234
+ </p>
235
+ <p v-if="!secretKeys.length" class="text-[11px] text-slate-500">
236
+ {{ t('settings.providerConnection.manifestEditor.noSecrets') }}
237
+ </p>
238
+ <p v-else-if="connected" class="text-[11px] text-amber-300/80">
239
+ {{ t('settings.providerConnection.manifestEditor.reenterSecrets') }}
240
+ </p>
241
+ <UFormField v-for="key in secretKeys" :key="key" :label="key">
242
+ <UInput
243
+ v-model="secrets[key]"
244
+ type="password"
245
+ class="w-full font-mono"
246
+ autocomplete="off"
247
+ :data-testid="`manifest-editor-secret-${key}`"
248
+ />
249
+ </UFormField>
250
+ </div>
251
+
252
+ <div v-if="supportsTest" class="flex items-center gap-2">
253
+ <UButton
254
+ color="neutral"
255
+ variant="soft"
256
+ size="sm"
257
+ icon="i-lucide-plug-zap"
258
+ :loading="testing"
259
+ :disabled="!canTest"
260
+ data-testid="manifest-editor-test"
261
+ @click="onTest()"
262
+ >
263
+ {{ t('settings.providerConnection.test.button') }}
264
+ </UButton>
265
+ <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
266
+ {{ testResult.message ?? t('settings.providerConnection.test.ok') }}
267
+ </span>
268
+ <span v-else-if="testResult" class="text-xs text-rose-400">
269
+ {{ testResult.message ?? t('settings.providerConnection.test.failed') }}
270
+ </span>
271
+ </div>
272
+
273
+ <div class="flex justify-end">
274
+ <UButton
275
+ color="primary"
276
+ size="sm"
277
+ :loading="busy"
278
+ :disabled="!canSave"
279
+ data-testid="manifest-editor-save"
280
+ @click="onSave()"
281
+ >
282
+ {{ connected ? t('common.save') : t('settings.providerConnection.form.connect') }}
283
+ </UButton>
284
+ </div>
285
+ </div>
286
+ </template>
@@ -69,8 +69,8 @@ const AccountSettingsPanel = defineAsyncComponent(
69
69
  const ObservabilityConnectionPanel = defineAsyncComponent(
70
70
  () => import('~/components/settings/ObservabilityConnectionPanel.vue'),
71
71
  )
72
- const ProviderConnectionPanel = defineAsyncComponent(
73
- () => import('~/components/settings/ProviderConnectionPanel.vue'),
72
+ const InfrastructureWindow = defineAsyncComponent(
73
+ () => import('~/components/settings/InfrastructureWindow.vue'),
74
74
  )
75
75
  const ModelConfigurationPanel = defineAsyncComponent(
76
76
  () => import('~/components/settings/ModelConfigurationPanel.vue'),
@@ -283,7 +283,7 @@ watch(
283
283
  <WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
284
284
  <AccountSettingsPanel v-if="ui.accountSettingsOpen" />
285
285
  <ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
286
- <ProviderConnectionPanel v-if="ui.providerConnectionKind" />
286
+ <InfrastructureWindow v-if="ui.infrastructureOpen" />
287
287
  <ModelConfigurationPanel v-if="ui.modelConfigOpen" />
288
288
  <LocalModelEndpointsPanel v-if="ui.localModelsOpen" />
289
289
  <LocalModeSettingsPanel v-if="ui.localModeSettingsOpen" />