@cat-factory/app 0.53.0 → 0.54.1

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,304 @@
1
+ <script setup lang="ts">
2
+ // ONE flat radio list per infrastructure axis — the single picker that replaces the old
3
+ // two-step "where it runs" radio + "runner/environment backend" dropdown. Each item is a
4
+ // concrete destination with a one-line description, so there is no more hidden second list:
5
+ // - execution axis → where agent containers run: the facade built-in (local Docker /
6
+ // Cloudflare Containers), the registered backend kinds (Kubernetes, a custom HTTP runner
7
+ // pool, plus any deployment-registered CUSTOM kind), and — in local mode — a low-config
8
+ // "Local Kubernetes (k3s)" preset.
9
+ // - testEnv axis → where the Tester's ephemeral environments run: the built-in
10
+ // (in-container docker-compose) + the registered backend kinds.
11
+ //
12
+ // The backend kinds come from the workspace snapshot (`providerConnections.backendKindsFor`),
13
+ // so a custom kind a deployment registered shows up as a first-class radio item. Selecting a
14
+ // pool/cluster item reveals its connect form inline (driven through ProviderConnectionTab). In
15
+ // LOCAL MODE the choice is a real per-workspace toggle (the `delegate*` settings) — but we
16
+ // DEFER writing `delegate=true` until a connection is actually registered, so a half-configured
17
+ // pick never routes runs to a non-existent pool. Off-local (Worker/Node) the active backend is
18
+ // deployment/registration-driven, so the radio doesn't write the toggle — it only reveals the
19
+ // connect forms — and a read-only "Active: …" line states what's effectively routing.
20
+ import { computed, ref, watch } from 'vue'
21
+ import type { ExecutionBackendKind, TestEnvBackendKind } from '@cat-factory/contracts'
22
+ import ProviderConnectionTab from '~/components/settings/ProviderConnectionTab.vue'
23
+
24
+ const props = defineProps<{ axis: 'execution' | 'testEnv' }>()
25
+
26
+ const { t } = useI18n()
27
+ const auth = useAuthStore()
28
+ const settings = useWorkspaceSettingsStore()
29
+ const providerConnections = useProviderConnectionsStore()
30
+ const toast = useToast()
31
+
32
+ type BackendKind = ExecutionBackendKind | TestEnvBackendKind
33
+ // A radio item: the built-in facade runtime, one per registered backend kind (built-in +
34
+ // custom), or the synthetic local-k3s preset. `backendKind` is the slug passed to the connect
35
+ // tab (absent on the built-in, which means "don't delegate"); `preset` prefills the k8s form.
36
+ interface PickerItem {
37
+ id: string
38
+ backendKind?: string
39
+ preset?: 'k3s'
40
+ label: string
41
+ desc: string
42
+ }
43
+
44
+ // The built-in facade runtimes carry localized labels + descriptions. i18n leaf keys are
45
+ // spelled as literals so the typed-message-keys check stays live.
46
+ const BUILTIN_KEYS: Record<BackendKind, { label: string; desc: string }> = {
47
+ 'local-docker': {
48
+ label: 'settings.infrastructure.executionBackend.local-docker',
49
+ desc: 'settings.infrastructure.executionBackend.local-dockerDesc',
50
+ },
51
+ 'cloudflare-containers': {
52
+ label: 'settings.infrastructure.executionBackend.cloudflare-containers',
53
+ desc: 'settings.infrastructure.executionBackend.cloudflare-containersDesc',
54
+ },
55
+ 'runner-pool': {
56
+ label: 'settings.infrastructure.executionBackend.runner-pool',
57
+ desc: 'settings.infrastructure.executionBackend.runner-poolDesc',
58
+ },
59
+ 'local-compose': {
60
+ label: 'settings.infrastructure.testEnvBackend.local-compose',
61
+ desc: 'settings.infrastructure.testEnvBackend.local-composeDesc',
62
+ },
63
+ 'environment-provider': {
64
+ label: 'settings.infrastructure.testEnvBackend.environment-provider',
65
+ desc: 'settings.infrastructure.testEnvBackend.environment-providerDesc',
66
+ },
67
+ }
68
+ // The built-in `kubernetes` backend kind's localized label/desc, per axis.
69
+ const KUBERNETES_KEYS: Record<'execution' | 'testEnv', { label: string; desc: string }> = {
70
+ execution: {
71
+ label: 'settings.infrastructure.executionBackend.kubernetes',
72
+ desc: 'settings.infrastructure.executionBackend.kubernetesDesc',
73
+ },
74
+ testEnv: {
75
+ label: 'settings.infrastructure.testEnvBackend.kubernetes',
76
+ desc: 'settings.infrastructure.testEnvBackend.kubernetesDesc',
77
+ },
78
+ }
79
+ // k3s is an execution-only preset (the env k8s config can't be reduced to low-config), so a
80
+ // single catalog key serves it — no dead test-env keys.
81
+ const K3S_KEYS = {
82
+ label: 'settings.infrastructure.executionBackend.k3s',
83
+ desc: 'settings.infrastructure.executionBackend.k3sDesc',
84
+ }
85
+
86
+ const cap = computed<{ available: BackendKind[]; active: BackendKind } | null>(() => {
87
+ const c = auth.infrastructure?.[props.axis]
88
+ return c ? { available: c.available as BackendKind[], active: c.active as BackendKind } : null
89
+ })
90
+ const isLocal = computed(() => auth.localMode?.enabled === true)
91
+ const suggestedImage = computed(() => auth.infrastructure?.execution.suggestedExecutorImage)
92
+
93
+ // The kind reached by "delegating" away from the on-machine default + its connection kind.
94
+ const delegatedKind = computed<BackendKind>(() =>
95
+ props.axis === 'execution' ? 'runner-pool' : 'environment-provider',
96
+ )
97
+ const connectionKind = computed<'runner-pool' | 'environment'>(() =>
98
+ props.axis === 'execution' ? 'runner-pool' : 'environment',
99
+ )
100
+ // The built-in (the available option that isn't the delegated one), if this facade has one.
101
+ const builtinKind = computed<BackendKind | null>(
102
+ () => cap.value?.available.find((k) => k !== delegatedKind.value) ?? null,
103
+ )
104
+ const connection = computed(() => providerConnections.connectionFor(connectionKind.value))
105
+ const connectionRegistered = computed(() => !!connection.value)
106
+ // Pool/cluster items can be configured only when the deployment supports delegation AND the
107
+ // connect integration is enabled (not 503).
108
+ const poolConfigurable = computed(
109
+ () =>
110
+ (cap.value?.available.includes(delegatedKind.value) ?? false) &&
111
+ providerConnections.isAvailable(connectionKind.value),
112
+ )
113
+
114
+ // The delegation flag is a genuine per-workspace toggle ONLY in local mode.
115
+ const writable = computed(() => isLocal.value && (cap.value?.available.length ?? 0) > 1)
116
+ const delegated = computed(() =>
117
+ props.axis === 'execution'
118
+ ? settings.settings.delegateAgentsToRunnerPool
119
+ : settings.settings.delegateTestEnvToProvider,
120
+ )
121
+
122
+ // The effective active backend (matches the prior ExecutionBackendSelector logic): local
123
+ // mode follows the toggle; off-local the delegated backend is active when its pool is
124
+ // registered, else the deployment default.
125
+ const effectiveActive = computed<BackendKind | null>(() => {
126
+ if (!cap.value) return builtinKind.value
127
+ if (writable.value) return delegated.value ? delegatedKind.value : builtinKind.value
128
+ if (cap.value.available.includes(delegatedKind.value) && connectionRegistered.value) {
129
+ return delegatedKind.value
130
+ }
131
+ return cap.value.active
132
+ })
133
+
134
+ // Localized label/desc for a built-in backend kind; null for a CUSTOM kind (which uses its
135
+ // snapshot label and has no description).
136
+ function backendKindKeys(kind: string): { label: string; desc: string } | null {
137
+ if (kind === 'kubernetes') return KUBERNETES_KEYS[props.axis]
138
+ if (kind === 'manifest') return BUILTIN_KEYS[delegatedKind.value]
139
+ return null
140
+ }
141
+
142
+ // The radio item the current state maps to (an item id). A stored connection reads back as its
143
+ // own backend-kind slug; k3s is a one-shot prefill, never re-derived.
144
+ const derivedItem = computed<string>(() => {
145
+ if (effectiveActive.value === delegatedKind.value) return connection.value?.kind ?? 'kubernetes'
146
+ return 'builtin'
147
+ })
148
+
149
+ const selected = ref<string>(derivedItem.value)
150
+ // Re-sync when the derived state changes (e.g. after a save/remove or a settings flip
151
+ // elsewhere). A pending pool/k3s pick before save doesn't move `derivedItem`, so the user's
152
+ // selection survives until a connection is registered.
153
+ watch(derivedItem, (v) => {
154
+ selected.value = v
155
+ })
156
+
157
+ const items = computed<PickerItem[]>(() => {
158
+ const out: PickerItem[] = []
159
+ if (builtinKind.value) {
160
+ const k = BUILTIN_KEYS[builtinKind.value]
161
+ out.push({ id: 'builtin', label: t(k.label), desc: t(k.desc) })
162
+ }
163
+ if (poolConfigurable.value) {
164
+ for (const opt of providerConnections.backendKindsFor(connectionKind.value)) {
165
+ const keys = backendKindKeys(opt.kind)
166
+ out.push({
167
+ id: opt.kind,
168
+ backendKind: opt.kind,
169
+ label: keys ? t(keys.label) : opt.label,
170
+ desc: keys ? t(keys.desc) : '',
171
+ })
172
+ }
173
+ // The k3s low-config preset prefills the RUNNER k8s form; the env k8s config (manifest
174
+ // source + URL derivation) can't be reduced to low-config, so it's execution-only.
175
+ if (isLocal.value && props.axis === 'execution') {
176
+ out.push({
177
+ id: 'k3s',
178
+ backendKind: 'kubernetes',
179
+ preset: 'k3s',
180
+ label: t(K3S_KEYS.label),
181
+ desc: t(K3S_KEYS.desc),
182
+ })
183
+ }
184
+ }
185
+ return out
186
+ })
187
+
188
+ // The active-line label: prefer the registered connection's concrete kind over the generic
189
+ // "delegated" label so "Active: Kubernetes cluster" reads truthfully.
190
+ const activeLabel = computed(() => {
191
+ const c = connection.value
192
+ if (effectiveActive.value === delegatedKind.value && c?.kind) {
193
+ const keys = backendKindKeys(c.kind)
194
+ if (keys) return t(keys.label)
195
+ const opt = providerConnections
196
+ .backendKindsFor(connectionKind.value)
197
+ .find((o) => o.kind === c.kind)
198
+ return opt?.label ?? c.kind
199
+ }
200
+ const kind = effectiveActive.value
201
+ return kind ? t(BUILTIN_KEYS[kind].label) : ''
202
+ })
203
+
204
+ const saving = ref(false)
205
+
206
+ async function setDelegate(value: boolean) {
207
+ saving.value = true
208
+ try {
209
+ await settings.update(
210
+ props.axis === 'execution'
211
+ ? { delegateAgentsToRunnerPool: value }
212
+ : { delegateTestEnvToProvider: value },
213
+ )
214
+ } catch (e) {
215
+ toast.add({
216
+ title: t('settings.infrastructure.updateFailed'),
217
+ description: e instanceof Error ? e.message : String(e),
218
+ icon: 'i-lucide-triangle-alert',
219
+ color: 'error',
220
+ })
221
+ } finally {
222
+ saving.value = false
223
+ }
224
+ }
225
+
226
+ async function select(id: string) {
227
+ selected.value = id
228
+ if (!writable.value) return // off-local: reveal the form, but don't flip a toggle.
229
+ if (id === 'builtin') {
230
+ await setDelegate(false)
231
+ return
232
+ }
233
+ // Pool/cluster pick: only commit delegation now if a pool is already registered; otherwise
234
+ // defer to onConnected so we never route to a non-existent pool (the amber hint nags).
235
+ if (connectionRegistered.value) await setDelegate(true)
236
+ }
237
+
238
+ // Emitted by ProviderConnectionTab after a connection is successfully registered: now it's
239
+ // safe to activate delegation for the pending pick.
240
+ async function onConnected() {
241
+ if (writable.value && selected.value !== 'builtin' && !delegated.value) await setDelegate(true)
242
+ }
243
+
244
+ // Which connect form ProviderConnectionTab should show, and the k3s prefill signal.
245
+ const selectedItem = computed<PickerItem | undefined>(() =>
246
+ items.value.find((i) => i.id === selected.value),
247
+ )
248
+ const showConnectForm = computed(() => !!selectedItem.value?.backendKind && poolConfigurable.value)
249
+ const selectedBackendKind = computed(() => selectedItem.value?.backendKind ?? 'manifest')
250
+ const selectedPreset = computed(() => selectedItem.value?.preset)
251
+ // Nag when a pool item is picked in local mode but no pool is registered to back it.
252
+ const showRegisterHint = computed(
253
+ () => writable.value && selected.value !== 'builtin' && !connectionRegistered.value,
254
+ )
255
+
256
+ const labelKey = computed(() =>
257
+ props.axis === 'execution'
258
+ ? 'settings.infrastructure.executionBackend.label'
259
+ : 'settings.infrastructure.testEnvBackend.label',
260
+ )
261
+ </script>
262
+
263
+ <template>
264
+ <section v-if="cap" class="space-y-2 rounded-lg border border-slate-700 bg-slate-900/40 p-3">
265
+ <h3 class="text-sm font-semibold text-slate-200">{{ t(labelKey) }}</h3>
266
+
267
+ <!-- Off-local: the active backend is deployment/registration-driven; state it plainly. -->
268
+ <p v-if="!writable" class="text-sm text-slate-300" :data-testid="`${axis}-backend-active`">
269
+ {{ t('settings.infrastructure.active', { backend: activeLabel }) }}
270
+ </p>
271
+
272
+ <div class="space-y-1.5" :data-testid="`${axis}-backend-options`">
273
+ <label v-for="item in items" :key="item.id" class="flex cursor-pointer items-start gap-2">
274
+ <input
275
+ type="radio"
276
+ class="mt-1"
277
+ :value="item.id"
278
+ :checked="item.id === selected"
279
+ :disabled="saving"
280
+ :data-testid="`${axis}-backend-${item.id}`"
281
+ @change="select(item.id)"
282
+ />
283
+ <span class="min-w-0">
284
+ <span class="text-sm text-slate-200">{{ item.label }}</span>
285
+ <span v-if="item.desc" class="block text-[11px] text-slate-400">{{ item.desc }}</span>
286
+ </span>
287
+ </label>
288
+ </div>
289
+
290
+ <p v-if="showRegisterHint" class="text-[11px] text-amber-300/80">
291
+ {{ t('settings.infrastructure.registerHint') }}
292
+ </p>
293
+
294
+ <!-- The connect form for the selected pool/cluster, driven through the shared tab. -->
295
+ <ProviderConnectionTab
296
+ v-if="showConnectForm"
297
+ :kind="connectionKind"
298
+ :backend-kind="selectedBackendKind"
299
+ :preset="selectedPreset"
300
+ :suggested-image="suggestedImage"
301
+ @connected="onConnected"
302
+ />
303
+ </section>
304
+ </template>
@@ -11,8 +11,7 @@
11
11
  // backend integration is disabled (503) simply doesn't render.
12
12
  import { computed, ref, watch } from 'vue'
13
13
  import type { ProviderConnectionKind } from '~/types/providerConnections'
14
- import ProviderConnectionTab from '~/components/settings/ProviderConnectionTab.vue'
15
- import ExecutionBackendSelector from '~/components/settings/ExecutionBackendSelector.vue'
14
+ import InfrastructureBackendPicker from '~/components/settings/InfrastructureBackendPicker.vue'
16
15
  import LocalContainerPoolSettings from '~/components/settings/LocalContainerPoolSettings.vue'
17
16
 
18
17
  const { t } = useI18n()
@@ -103,10 +102,9 @@ watch([tabs, () => store.loaded], () => {
103
102
  >
104
103
  <template #runner-pool>
105
104
  <div class="space-y-4">
106
- <!-- Where agent containers run (writable in local mode; read-only elsewhere). -->
107
- <ExecutionBackendSelector axis="execution" />
108
- <!-- The runner-pool connect form only when that integration is enabled. -->
109
- <ProviderConnectionTab v-if="store.isAvailable('runner-pool')" kind="runner-pool" />
105
+ <!-- One unified list of where agent containers run; the selected pool/cluster
106
+ reveals its connect form inline. -->
107
+ <InfrastructureBackendPicker axis="execution" />
110
108
  <!-- Local mode: the warm-pool + checkout reuse ARE the host agent-container
111
109
  runtime, so they live here rather than in a separate menu. -->
112
110
  <section v-if="isLocal" class="border-t border-slate-800 pt-4">
@@ -119,10 +117,8 @@ watch([tabs, () => store.loaded], () => {
119
117
  </template>
120
118
  <template #environment>
121
119
  <div class="space-y-4">
122
- <!-- Where Tester environments run (writable in local mode; read-only elsewhere). -->
123
- <ExecutionBackendSelector axis="testEnv" />
124
- <!-- The environment-provider connect form only when that integration is enabled. -->
125
- <ProviderConnectionTab v-if="store.isAvailable('environment')" kind="environment" />
120
+ <!-- One unified list of where the Tester's ephemeral environments run. -->
121
+ <InfrastructureBackendPicker axis="testEnv" />
126
122
  </div>
127
123
  </template>
128
124
  </UTabs>
@@ -9,6 +9,10 @@ import type { ProviderConnection } from '~/types/providerConnections'
9
9
 
10
10
  const props = defineProps<{
11
11
  connection: ProviderConnection | null
12
+ /** A low-config preset to seed the form with (today: local k3s). */
13
+ preset?: 'k3s'
14
+ /** The deployment's executor image, used to prefill the k3s preset's image field. */
15
+ suggestedImage?: string
12
16
  supportsTest: boolean
13
17
  testing: boolean
14
18
  busy: boolean
@@ -30,6 +34,7 @@ const form = reactive({
30
34
  imageUi: '',
31
35
  caCertPem: '',
32
36
  harnessPort: '',
37
+ insecureSkipTlsVerify: false,
33
38
  })
34
39
  const apiToken = ref('')
35
40
 
@@ -52,11 +57,28 @@ watch(
52
57
  form.imageUi = typeof k.imageUi === 'string' ? k.imageUi : ''
53
58
  form.caCertPem = typeof k.caCertPem === 'string' ? k.caCertPem : ''
54
59
  form.harnessPort = typeof k.harnessPort === 'number' ? String(k.harnessPort) : ''
60
+ form.insecureSkipTlsVerify = k.insecureSkipTlsVerify === true
55
61
  }
56
62
  },
57
63
  { immediate: true },
58
64
  )
59
65
 
66
+ // Low-config k3s preset: seed the local-cluster defaults so the operator only pastes a
67
+ // ServiceAccount token (and an image, unless the deployment surfaced one). Only seeds a
68
+ // fresh form — never clobbers an existing connection's config on edit.
69
+ watch(
70
+ () => props.preset,
71
+ (preset) => {
72
+ if (preset !== 'k3s' || props.connection) return
73
+ form.label = 'Local k3s'
74
+ form.apiServerUrl = 'https://127.0.0.1:6443'
75
+ form.namespace = 'cat-factory'
76
+ form.insecureSkipTlsVerify = true
77
+ if (props.suggestedImage) form.image = props.suggestedImage
78
+ },
79
+ { immediate: true },
80
+ )
81
+
60
82
  const canSave = computed(
61
83
  () =>
62
84
  !!form.label.trim() &&
@@ -75,6 +97,7 @@ function buildPayload(): { config: Record<string, unknown>; secrets: Record<stri
75
97
  }
76
98
  if (form.imageUi.trim()) kubernetes.imageUi = form.imageUi.trim()
77
99
  if (form.caCertPem.trim()) kubernetes.caCertPem = form.caCertPem.trim()
100
+ if (form.insecureSkipTlsVerify) kubernetes.insecureSkipTlsVerify = true
78
101
  const port = Number(form.harnessPort)
79
102
  if (form.harnessPort.trim() && Number.isFinite(port)) kubernetes.harnessPort = port
80
103
  return {
@@ -156,6 +179,13 @@ function buildPayload(): { config: Record<string, unknown>; secrets: Record<stri
156
179
  />
157
180
  </UFormField>
158
181
 
182
+ <UFormField :help="t('settings.providerConnection.kubernetes.insecureSkipTlsVerifyHelp')">
183
+ <UCheckbox
184
+ v-model="form.insecureSkipTlsVerify"
185
+ :label="t('settings.providerConnection.kubernetes.insecureSkipTlsVerify')"
186
+ />
187
+ </UFormField>
188
+
159
189
  <UFormField
160
190
  :label="
161
191
  t('settings.providerConnection.form.optionalLabel', {
@@ -13,7 +13,18 @@ import ProviderManifestEditor from '~/components/settings/ProviderManifestEditor
13
13
  import KubernetesRunnerForm from '~/components/settings/KubernetesRunnerForm.vue'
14
14
  import KubernetesEnvironmentForm from '~/components/settings/KubernetesEnvironmentForm.vue'
15
15
 
16
- const props = defineProps<{ kind: ProviderConnectionKind }>()
16
+ const props = defineProps<{
17
+ kind: ProviderConnectionKind
18
+ /** The selected backend-kind slug — chosen by the parent picker's radio, not a local
19
+ * dropdown. Built-in (`manifest`/`kubernetes`) or a deployment-registered custom kind. */
20
+ backendKind: string
21
+ /** A low-config preset to prefill the Kubernetes form (today: local k3s). */
22
+ preset?: 'k3s'
23
+ /** The deployment's executor image, used to prefill the k3s preset's image field. */
24
+ suggestedImage?: string
25
+ }>()
26
+
27
+ const emit = defineEmits<{ connected: [] }>()
17
28
 
18
29
  const { t } = useI18n()
19
30
  const store = useProviderConnectionsStore()
@@ -31,7 +42,7 @@ const title = computed(() => t(`settings.providerConnection.kind.${props.kind}.t
31
42
 
32
43
  watch(
33
44
  () => props.kind,
34
- (k) => void store.loadKind(k).then(resetDraft),
45
+ (k) => void store.loadKind(k, props.backendKind).then(resetDraft),
35
46
  { immediate: true },
36
47
  )
37
48
 
@@ -109,7 +120,7 @@ function buildManifestPayload(): {
109
120
  if (Object.keys(providerConfig).length) manifest.providerConfig = providerConfig
110
121
  // Carry the selected kind so a CUSTOM backend's flat-form save is tagged with its slug
111
122
  // (not silently wrapped into the built-in `manifest` backend).
112
- return { manifest, secrets, backendKind: backendKind.value }
123
+ return { manifest, secrets, backendKind: props.backendKind }
113
124
  }
114
125
 
115
126
  function notifyError(title: string, e: unknown) {
@@ -149,6 +160,7 @@ async function saveNative() {
149
160
  try {
150
161
  const payload = buildManifestPayload()
151
162
  if (payload) await store.register(props.kind, payload)
163
+ emit('connected')
152
164
  resetDraft()
153
165
  toastSaved()
154
166
  } catch (e) {
@@ -168,7 +180,7 @@ async function testManifest(payload: {
168
180
  testing.value = true
169
181
  testResult.value = null
170
182
  try {
171
- testResult.value = await store.test(props.kind, { ...payload, backendKind: backendKind.value })
183
+ testResult.value = await store.test(props.kind, { ...payload, backendKind: props.backendKind })
172
184
  } catch (e) {
173
185
  testResult.value = { ok: false, message: e instanceof Error ? e.message : String(e) }
174
186
  } finally {
@@ -182,7 +194,8 @@ async function saveManifest(payload: {
182
194
  }) {
183
195
  busy.value = true
184
196
  try {
185
- await store.register(props.kind, { ...payload, backendKind: backendKind.value })
197
+ await store.register(props.kind, { ...payload, backendKind: props.backendKind })
198
+ emit('connected')
186
199
  toastSaved()
187
200
  } catch (e) {
188
201
  notifyError(t('settings.providerConnection.toast.saveFailed'), e)
@@ -191,54 +204,20 @@ async function saveManifest(payload: {
191
204
  }
192
205
  }
193
206
 
194
- // --- Backend selector -----------------------------------------------------------------
195
- // Each infrastructure tab configures one of the backend KINDS registered for its subsystem:
196
- // the built-in BYO `manifest` backend, the native `kubernetes` backend, or any CUSTOM kind a
197
- // deployment registered programmatically. The list is snapshot-driven (built-in fallback in
198
- // the store until it loads); the two K8s backends have bespoke forms, every other kind
199
- // (manifest + custom) uses the descriptor-driven flat form. Defaults to the saved kind.
200
- const backendKind = ref<string>('manifest')
201
- const backendSelectorLabel = computed(() =>
202
- t(
203
- props.kind === 'environment'
204
- ? 'settings.providerConnection.backend.environmentSelectorLabel'
205
- : 'settings.providerConnection.backend.selectorLabel',
206
- ),
207
- )
208
- // Built-in kinds keep their localized labels; a custom kind shows its snapshot displayLabel.
209
- function backendKindLabel(option: { kind: string; label: string }): string {
210
- if (option.kind === 'kubernetes') return t('settings.providerConnection.backend.kubernetes')
211
- if (option.kind === 'manifest') {
212
- return t(
213
- props.kind === 'environment'
214
- ? 'settings.providerConnection.backend.environmentManifest'
215
- : 'settings.providerConnection.backend.manifest',
216
- )
217
- }
218
- return option.label
219
- }
220
- const backendKindItems = computed(() =>
221
- store.backendKindsFor(props.kind).map((o) => ({ label: backendKindLabel(o), value: o.kind })),
222
- )
207
+ // --- Backend connect forms ------------------------------------------------------------
208
+ // Which backend kind to configure (the built-in `manifest`/`kubernetes` backends or any
209
+ // CUSTOM kind a deployment registered) is decided by the parent picker's unified radio and
210
+ // passed in as `backendKind` there is no longer a local dropdown here. The two K8s
211
+ // backends have bespoke forms; every other kind (manifest + custom) uses the descriptor-
212
+ // driven flat form / raw manifest editor. Switching the kind re-probes ONLY that kind's
213
+ // descriptor (so a not-yet-connected custom kind's connect form renders) WITHOUT re-fetching
214
+ // the stored connection — using `loadDescriptor` (not `loadKind`) avoids bouncing the
215
+ // picker's selection back to the stored kind via a connection re-read.
223
216
  watch(
224
- () => connection.value,
225
- (c) => {
226
- if (c?.kind) backendKind.value = c.kind
227
- },
228
- { immediate: true },
217
+ () => props.backendKind,
218
+ (k) => void store.loadDescriptor(props.kind, k).then(resetDraft),
229
219
  )
230
220
 
231
- // Switching the backend kind re-probes ONLY that kind's descriptor (so a not-yet-connected
232
- // custom kind's connect form renders). Always pass the explicit kind — including `manifest`,
233
- // so picking it describes the manifest backend rather than falling back to the stored kind —
234
- // and use `loadDescriptor` (not `loadKind`) so the stored connection isn't re-fetched and the
235
- // selector isn't bounced back to the stored kind by the `connection` watch.
236
- async function onBackendKindChange(k: string) {
237
- backendKind.value = k
238
- await store.loadDescriptor(props.kind, k)
239
- resetDraft()
240
- }
241
-
242
221
  async function testConfig(payload: {
243
222
  config: Record<string, unknown>
244
223
  secrets: Record<string, string>
@@ -261,6 +240,7 @@ async function saveConfig(payload: {
261
240
  busy.value = true
262
241
  try {
263
242
  await store.register(props.kind, payload)
243
+ emit('connected')
264
244
  toastSaved()
265
245
  } catch (e) {
266
246
  notifyError(t('settings.providerConnection.toast.saveFailed'), e)
@@ -350,20 +330,12 @@ function fieldHelp(key: string): string | undefined {
350
330
  }}
351
331
  </div>
352
332
 
353
- <!-- Backend selector: the BYO manifest backend, a native Kubernetes backend, or a
354
- programmatically-registered custom kind. -->
355
- <UFormField :label="backendSelectorLabel">
356
- <USelect
357
- v-model="backendKind"
358
- :items="backendKindItems"
359
- @update:model-value="onBackendKindChange(String($event))"
360
- />
361
- </UFormField>
362
-
363
333
  <!-- Native Kubernetes runner backend (runner-pool). -->
364
334
  <KubernetesRunnerForm
365
335
  v-if="kind === 'runner-pool' && backendKind === 'kubernetes'"
366
336
  :connection="connection"
337
+ :preset="preset"
338
+ :suggested-image="suggestedImage"
367
339
  :supports-test="descriptor.supportsTest"
368
340
  :testing="testing"
369
341
  :busy="busy"
@@ -463,31 +435,20 @@ function fieldHelp(key: string): string | undefined {
463
435
  </div>
464
436
  </div>
465
437
 
466
- <!-- MANIFEST-driven provider: the raw JSON manifest editor. Collapsed by default — it's
467
- the advanced path, needed ONLY to integrate a custom API-based scheduler. The common
468
- backends (local Docker, Cloudflare Containers, Kubernetes) don't need it. -->
469
- <details
438
+ <!-- MANIFEST-driven provider: the raw JSON manifest editor. The radio already selected
439
+ "custom HTTP" so it's shown expanded no extra disclosure. -->
440
+ <ProviderManifestEditor
470
441
  v-else
471
- class="rounded-lg border border-slate-700 bg-slate-900/40 p-3"
472
- :open="!!connection"
473
- >
474
- <summary class="cursor-pointer text-sm font-medium text-slate-200">
475
- {{ t('settings.providerConnection.advancedManifest.summary') }}
476
- </summary>
477
- <p class="mt-2 mb-3 text-[11px] text-slate-400">
478
- {{ t('settings.providerConnection.advancedManifest.intro') }}
479
- </p>
480
- <ProviderManifestEditor
481
- :kind="kind"
482
- :saved-manifest="descriptor.savedManifest"
483
- :connected="!!connection"
484
- :supports-test="descriptor.supportsTest"
485
- :testing="testing"
486
- :busy="busy"
487
- :test-result="testResult"
488
- @test="testManifest"
489
- @save="saveManifest"
490
- />
491
- </details>
442
+ :kind="kind"
443
+ :saved-manifest="descriptor.savedManifest"
444
+ :connected="!!connection"
445
+ :stored-secret-keys="connection?.secretKeys ?? []"
446
+ :supports-test="descriptor.supportsTest"
447
+ :testing="testing"
448
+ :busy="busy"
449
+ :test-result="testResult"
450
+ @test="testManifest"
451
+ @save="saveManifest"
452
+ />
492
453
  </div>
493
454
  </template>
@@ -25,6 +25,9 @@ const props = defineProps<{
25
25
  savedManifest?: Record<string, unknown>
26
26
  /** Whether a connection already exists (drives the re-enter-secrets hint + button label). */
27
27
  connected: boolean
28
+ /** The secret keys already stored for this connection (names only) — shown next to the
29
+ * write-only inputs so it's obvious what exists without scrolling to the summary. */
30
+ storedSecretKeys?: string[]
28
31
  /** Whether the provider exposes a connection test the UI can call. */
29
32
  supportsTest: boolean
30
33
  /** Bubbled-up busy state from the tab's store calls (so the editor shows loading). */
@@ -235,9 +238,22 @@ function onSave() {
235
238
  <p v-if="!secretKeys.length" class="text-[11px] text-slate-500">
236
239
  {{ t('settings.providerConnection.manifestEditor.noSecrets') }}
237
240
  </p>
238
- <p v-else-if="connected" class="text-[11px] text-amber-300/80">
239
- {{ t('settings.providerConnection.manifestEditor.reenterSecrets') }}
240
- </p>
241
+ <template v-else-if="connected">
242
+ <p
243
+ v-if="storedSecretKeys && storedSecretKeys.length"
244
+ class="text-[11px] text-slate-400"
245
+ data-testid="manifest-editor-stored"
246
+ >
247
+ {{
248
+ t('settings.providerConnection.manifestEditor.stored', {
249
+ keys: storedSecretKeys.join(', '),
250
+ })
251
+ }}
252
+ </p>
253
+ <p class="text-[11px] text-amber-300/80">
254
+ {{ t('settings.providerConnection.manifestEditor.reenterSecrets') }}
255
+ </p>
256
+ </template>
241
257
  <UFormField v-for="key in secretKeys" :key="key" :label="key">
242
258
  <UInput
243
259
  v-model="secrets[key]"