@cat-factory/app 0.47.2 → 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.
@@ -259,32 +259,53 @@ const groups = computed<IntegrationGroup[]>(() => {
259
259
  }
260
260
 
261
261
  // --- Infrastructure (ephemeral environments + self-hosted runner pool) -----
262
- // Each gates on its own availability probe, so a backend with the integration off
263
- // shows no dead row. The connected badge reflects a saved connection; the
264
- // ProviderConfigBanner handles the louder "missing mandatory fields" warning.
262
+ // The two providers container agents (runner pool) and Tester environments are the
263
+ // same "bring your own infra" idea and the same custom pool typically backs both, so they
264
+ // collapse into ONE row opening the tabbed Infrastructure window. The row shows a combined
265
+ // per-concern summary ("Agents: connected · Envs: not connected"). Each concern still gates
266
+ // on its own availability probe (a tab whose backend is off simply doesn't render), so the
267
+ // row appears whenever EITHER is available. The ProviderConfigBanner handles the louder
268
+ // "missing mandatory fields" warning.
265
269
  const infra: IntegrationItem[] = []
266
- if (providerConnections.isAvailable('environment')) {
267
- const conn = providerConnections.connectionFor('environment')
268
- infra.push({
269
- key: 'environment',
270
- icon: 'i-lucide-cloud',
271
- label: t('layout.integrationsHub.items.environment.label'),
272
- description: t('layout.integrationsHub.items.environment.description'),
273
- status: conn ? t('layout.integrationsHub.status.connected') : undefined,
274
- connected: !!conn,
275
- onClick: () => go(() => ui.openProviderConnection('environment')),
276
- })
277
- }
278
- if (providerConnections.isAvailable('runner-pool')) {
279
- const conn = providerConnections.connectionFor('runner-pool')
270
+ const agentsAvailable = providerConnections.isAvailable('runner-pool')
271
+ const envsAvailable = providerConnections.isAvailable('environment')
272
+ if (agentsAvailable || envsAvailable) {
273
+ const agentsConn = providerConnections.connectionFor('runner-pool')
274
+ const envsConn = providerConnections.connectionFor('environment')
275
+ // Combined summary across the available concerns only.
276
+ const stateWord = (conn: unknown) =>
277
+ conn
278
+ ? t('layout.integrationsHub.status.connected')
279
+ : t('layout.integrationsHub.status.notConnected')
280
+ const parts: string[] = []
281
+ if (agentsAvailable)
282
+ parts.push(
283
+ t('layout.integrationsHub.items.infrastructure.agents', { state: stateWord(agentsConn) }),
284
+ )
285
+ if (envsAvailable)
286
+ parts.push(
287
+ t('layout.integrationsHub.items.infrastructure.envs', { state: stateWord(envsConn) }),
288
+ )
289
+ // Default the window to the agents tab when available (the common case), else envs.
290
+ const defaultKind = agentsAvailable ? 'runner-pool' : 'environment'
291
+ // A concern counts as satisfied when it's unavailable (nothing to configure) or connected.
292
+ // The row is "connected" (green) only when EVERY available concern is connected — a
293
+ // half-connected state shows the amber "attention" badge instead, so the green badge can't
294
+ // claim "done" while one concern is still unconfigured.
295
+ const agentsSatisfied = !agentsAvailable || !!agentsConn
296
+ const envsSatisfied = !envsAvailable || !!envsConn
297
+ const allConnected = agentsSatisfied && envsSatisfied
298
+ const someConnected = !!agentsConn || !!envsConn
280
299
  infra.push({
281
- key: 'runner-pool',
300
+ key: 'infrastructure',
282
301
  icon: 'i-lucide-server-cog',
283
- label: t('layout.integrationsHub.items.runnerPool.label'),
284
- description: t('layout.integrationsHub.items.runnerPool.description'),
285
- status: conn ? t('layout.integrationsHub.status.connected') : undefined,
286
- connected: !!conn,
287
- onClick: () => go(() => ui.openProviderConnection('runner-pool')),
302
+ label: t('layout.integrationsHub.items.infrastructure.label'),
303
+ description: t('layout.integrationsHub.items.infrastructure.description'),
304
+ status: parts.join(' · '),
305
+ connected: allConnected,
306
+ attention: someConnected && !allConnected,
307
+ attentionLabel: parts.join(' · '),
308
+ onClick: () => go(() => ui.openProviderConnection(defaultKind)),
288
309
  })
289
310
  }
290
311
  // Local-mode-only: the warm-container pool + checkout reuse for the local runner. Shown
@@ -0,0 +1,221 @@
1
+ <script setup lang="ts">
2
+ // The single tabbed "Infrastructure" window. It merges what used to be two separate
3
+ // Integrations-Hub entries — the self-hosted runner pool (container agents) and the
4
+ // ephemeral-environment provider (Tester environments) — into one surface, because the
5
+ // same custom pool typically backs both jobs, so configuring them together reflects
6
+ // reality. Each provider gets its own tab (ProviderConnectionTab); a tab whose backend
7
+ // integration is disabled (503) simply doesn't render.
8
+ //
9
+ // The local-mode delegation toggles are cross-cutting (one per concern), so they live at
10
+ // the TOP of the window rather than buried in one tab — removing the old awkward
11
+ // cross-link hint that pointed from the runner-pool screen back to the env screen.
12
+ import { computed, ref, watch } from 'vue'
13
+ import type { ProviderConnectionKind } from '~/types/providerConnections'
14
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
15
+ import ProviderConnectionTab from '~/components/settings/ProviderConnectionTab.vue'
16
+
17
+ const { t } = useI18n()
18
+ const ui = useUiStore()
19
+ const store = useProviderConnectionsStore()
20
+ const auth = useAuthStore()
21
+ const settings = useWorkspaceSettingsStore()
22
+ const toast = useToast()
23
+
24
+ const open = computed({
25
+ get: () => ui.infrastructureOpen,
26
+ set: (v: boolean) => {
27
+ if (!v) ui.closeProviderConnection()
28
+ },
29
+ })
30
+ const back = useIntegrationBack(open)
31
+
32
+ // Each concern gates on its own availability probe; an unavailable tab isn't offered.
33
+ const agentsAvailable = computed(() => store.isAvailable('runner-pool'))
34
+ const envsAvailable = computed(() => store.isAvailable('environment'))
35
+
36
+ const tabs = computed(() => {
37
+ const out: { value: ProviderConnectionKind; label: string; icon: string; slot: string }[] = []
38
+ if (agentsAvailable.value)
39
+ out.push({
40
+ value: 'runner-pool',
41
+ label: t('settings.providerConnection.tabs.containerAgents'),
42
+ icon: 'i-lucide-server-cog',
43
+ slot: 'runner-pool',
44
+ })
45
+ if (envsAvailable.value)
46
+ out.push({
47
+ value: 'environment',
48
+ label: t('settings.providerConnection.tabs.testEnvironments'),
49
+ icon: 'i-lucide-cloud',
50
+ slot: 'environment',
51
+ })
52
+ return out
53
+ })
54
+
55
+ const activeTab = ref<ProviderConnectionKind>(ui.infrastructureTab)
56
+
57
+ // Honour the deep-linked tab each time the window opens (e.g. the banner's per-kind
58
+ // "Configure…" button), falling back to the first available tab if the requested one is off.
59
+ watch(
60
+ open,
61
+ (isOpen) => {
62
+ if (!isOpen) return
63
+ void store.ensureLoaded().catch(() => {})
64
+ const requested = ui.infrastructureTab
65
+ const available = tabs.value.map((x) => x.value)
66
+ activeTab.value = available.includes(requested) ? requested : (available[0] ?? requested)
67
+ },
68
+ { immediate: true },
69
+ )
70
+ // When availability resolves after open, re-pin onto a valid tab — but keep honouring the
71
+ // deep-linked request. The two availability probes resolve independently, so `tabs` can pass
72
+ // through a transient single-tab list (e.g. the runner-pool probe lands a tick before the
73
+ // environment one). We must NOT let that transient list steal focus from a still-loading
74
+ // requested tab, so only fall back to the first tab once loading has fully settled.
75
+ watch([tabs, () => store.loaded], () => {
76
+ const list = tabs.value
77
+ if (list.some((x) => x.value === activeTab.value)) return
78
+ const requested = ui.infrastructureTab
79
+ if (list.some((x) => x.value === requested)) {
80
+ activeTab.value = requested
81
+ } else if (store.loaded && list.length) {
82
+ activeTab.value = list[0]!.value
83
+ }
84
+ })
85
+
86
+ // --- Local-mode infrastructure delegation (cross-cutting; shown only in local mode) ---
87
+ // In local mode this is where a developer chooses, per workspace, whether to run on this
88
+ // machine (host Docker for agents, in-container docker-compose for the Tester) or delegate
89
+ // to an external service. Each toggle is enabled only once its provider is registered.
90
+ const isLocal = computed(() => auth.localMode?.enabled === true)
91
+ const runnerPoolRegistered = computed(() => !!store.connectionFor('runner-pool'))
92
+ const envRegistered = computed(() => !!store.connectionFor('environment'))
93
+ const savingDelegation = ref(false)
94
+
95
+ async function setDelegation(patch: {
96
+ delegateAgentsToRunnerPool?: boolean
97
+ delegateTestEnvToProvider?: boolean
98
+ }) {
99
+ savingDelegation.value = true
100
+ try {
101
+ await settings.update(patch)
102
+ } catch (e) {
103
+ toast.add({
104
+ title: t('settings.providerConnection.delegation.updateFailed'),
105
+ description: e instanceof Error ? e.message : String(e),
106
+ icon: 'i-lucide-triangle-alert',
107
+ color: 'error',
108
+ })
109
+ } finally {
110
+ savingDelegation.value = false
111
+ }
112
+ }
113
+
114
+ function selectTab(kind: ProviderConnectionKind) {
115
+ activeTab.value = kind
116
+ }
117
+ </script>
118
+
119
+ <template>
120
+ <UModal
121
+ v-model:open="open"
122
+ :title="t('settings.providerConnection.windowTitle')"
123
+ :ui="{ content: 'max-w-xl' }"
124
+ >
125
+ <template #title>
126
+ <IntegrationBackTitle :title="t('settings.providerConnection.windowTitle')" @back="back" />
127
+ </template>
128
+ <template #body>
129
+ <div class="space-y-4">
130
+ <!-- Local-mode delegation: the local-vs-external choice for BOTH container agents
131
+ AND the Tester's ephemeral environments, made once here at the top. -->
132
+ <section
133
+ v-if="isLocal"
134
+ class="space-y-3 rounded-lg border border-slate-700 bg-slate-900/40 p-3"
135
+ >
136
+ <div>
137
+ <h3 class="text-sm font-semibold text-slate-200">
138
+ {{ t('settings.providerConnection.delegation.title') }}
139
+ </h3>
140
+ <p class="mt-1 text-[11px] text-slate-400">
141
+ {{ t('settings.providerConnection.delegation.intro') }}
142
+ </p>
143
+ </div>
144
+
145
+ <!-- Container agents → self-hosted runner pool -->
146
+ <div class="space-y-1">
147
+ <label class="flex items-center gap-2">
148
+ <USwitch
149
+ size="sm"
150
+ :model-value="settings.settings.delegateAgentsToRunnerPool"
151
+ :disabled="savingDelegation || !runnerPoolRegistered"
152
+ @update:model-value="(v) => setDelegation({ delegateAgentsToRunnerPool: v })"
153
+ />
154
+ <span class="text-sm text-slate-200">
155
+ {{ t('settings.providerConnection.delegation.agentsToggle') }}
156
+ </span>
157
+ </label>
158
+ <p class="pl-9 text-[11px] text-slate-400">
159
+ {{ t('settings.providerConnection.delegation.agentsHint') }}
160
+ <template v-if="!runnerPoolRegistered">
161
+ <i18n-t
162
+ keypath="settings.providerConnection.delegation.registerPoolPrompt"
163
+ tag="span"
164
+ scope="global"
165
+ >
166
+ <template #link>
167
+ <button
168
+ type="button"
169
+ class="text-sky-400 underline underline-offset-2 hover:text-sky-300"
170
+ @click="selectTab('runner-pool')"
171
+ >
172
+ {{ t('settings.providerConnection.delegation.registerPoolLink') }}
173
+ </button>
174
+ </template>
175
+ </i18n-t>
176
+ </template>
177
+ </p>
178
+ </div>
179
+
180
+ <!-- Tester environments → environment provider -->
181
+ <div class="space-y-1">
182
+ <label class="flex items-center gap-2">
183
+ <USwitch
184
+ size="sm"
185
+ :model-value="settings.settings.delegateTestEnvToProvider"
186
+ :disabled="savingDelegation || !envRegistered"
187
+ @update:model-value="(v) => setDelegation({ delegateTestEnvToProvider: v })"
188
+ />
189
+ <span class="text-sm text-slate-200">
190
+ {{ t('settings.providerConnection.delegation.envToggle') }}
191
+ </span>
192
+ </label>
193
+ <p class="pl-9 text-[11px] text-slate-400">
194
+ {{ t('settings.providerConnection.delegation.envHint') }}
195
+ </p>
196
+ </div>
197
+ </section>
198
+
199
+ <UTabs
200
+ v-if="tabs.length"
201
+ v-model="activeTab"
202
+ :items="tabs"
203
+ variant="link"
204
+ :ui="{ root: 'gap-4' }"
205
+ data-testid="infrastructure-tabs"
206
+ >
207
+ <template #runner-pool>
208
+ <ProviderConnectionTab kind="runner-pool" />
209
+ </template>
210
+ <template #environment>
211
+ <ProviderConnectionTab kind="environment" />
212
+ </template>
213
+ </UTabs>
214
+
215
+ <p v-else class="px-1 py-6 text-center text-sm text-slate-500">
216
+ {{ t('settings.providerConnection.noneAvailable') }}
217
+ </p>
218
+ </div>
219
+ </template>
220
+ </UModal>
221
+ </template>
@@ -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>