@cat-factory/app 0.90.0 → 0.91.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.
@@ -86,6 +86,7 @@ const selectedPipelineLabel = computed(
86
86
  const template = computed<ScheduleTemplate>(() => {
87
87
  if (pipelineId.value === 'pl_tech_debt') return 'tech-debt'
88
88
  if (pipelineId.value === 'pl_dep_update') return 'dep-update'
89
+ if (pipelineId.value === 'pl_bug_triage') return 'bug-triage'
89
90
  return 'custom'
90
91
  })
91
92
  const isTechDebt = computed(() => template.value === 'tech-debt')
@@ -3,9 +3,11 @@
3
3
  // environment tab's backend-type selector (the other being the BYO HTTP manifest editor).
4
4
  // It builds the discriminated `{ kind: 'kubernetes', kubernetes }` config plus the
5
5
  // `apiToken` secret bundle and emits test/save to the parent tab (which calls the shared
6
- // provider-connections store). The K8s env config differs from the runner K8s config
7
- // (manifest source + URL derivation vs an executor image + namespace), so it has its own
8
- // form rather than reusing KubernetesRunnerForm.
6
+ // provider-connections store). The K8s env config has manifest-source + URL-derivation
7
+ // (discriminated-union) fields a flat descriptor form can't yet express, so unlike the
8
+ // runner backends, which now self-describe via `configTemplate` and render through the
9
+ // generic form — the environment axis keeps this bespoke form until it, too, is descriptor-
10
+ // driven (see docs/initiatives/descriptor-driven-infra-forms.md).
9
11
  import { computed, reactive, ref, watch } from 'vue'
10
12
  import { KUBERNETES_ENV_TOKEN_SECRET_KEY } from '@cat-factory/contracts'
11
13
  import type { ProviderConnection } from '~/types/providerConnections'
@@ -1,16 +1,20 @@
1
1
  <script setup lang="ts">
2
2
  // One tab of the Infrastructure window — the connect surface for a single provider kind
3
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).
4
+ // self-describe via a ProviderDescriptor, so this renders every backend WITHOUT hard-coding
5
+ // which optional kinds (Kubernetes, EKS, a custom kind) exist:
6
+ // - a NATIVE MANIFEST provider (ships a `manifestTemplate`) the flat field form, whose
7
+ // values are overlaid back onto the manifest before saving.
8
+ // - a NATIVE CONFIG backend (ships a `configTemplate` — the runner backends Kubernetes/EKS
9
+ // and any custom native kind) → the SAME flat field form, whose values are overlaid onto
10
+ // the discriminated `{ kind, <payload> }` config the backend described. The SPA never
11
+ // names a backend; it reads the single payload key off the skeleton.
7
12
  // - a MANIFEST-driven provider (no template) → the full JSON manifest editor
8
13
  // (ProviderManifestEditor), which replaces the old "use the API" disclaimer.
9
14
  import { computed, ref, toRaw, watch } from 'vue'
10
- import type { ProviderConnectionKind } from '~/types/providerConnections'
15
+ import type { ProviderConfigField, ProviderConnectionKind } from '~/types/providerConnections'
11
16
  import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
12
17
  import ProviderManifestEditor from '~/components/settings/ProviderManifestEditor.vue'
13
- import KubernetesRunnerForm from '~/components/settings/KubernetesRunnerForm.vue'
14
18
  import KubernetesEnvironmentForm from '~/components/settings/KubernetesEnvironmentForm.vue'
15
19
 
16
20
  const props = defineProps<{
@@ -56,17 +60,28 @@ const testResult = ref<{ ok: boolean; message?: string } | null>(null)
56
60
  const testing = ref(false)
57
61
  const busy = ref(false)
58
62
 
59
- /** A native provider ships a manifest scaffold ⇒ render the friendly flat field form. */
60
- const isNative = computed(() => !!descriptor.value?.manifestTemplate)
63
+ // A native provider renders the friendly flat field form. Two flavours self-describe it:
64
+ // `manifestTemplate` (overlay onto a manifest) and `configTemplate` (overlay onto a
65
+ // discriminated backend config — the Kubernetes/EKS/custom runner backends).
66
+ const isNativeManifest = computed(() => !!descriptor.value?.manifestTemplate)
67
+ const isNativeConfig = computed(() => !!descriptor.value?.configTemplate)
68
+ const isNative = computed(() => isNativeManifest.value || isNativeConfig.value)
61
69
  const secretFieldCount = computed(
62
70
  () => (descriptor.value?.configFields ?? []).filter((f) => f.secret).length,
63
71
  )
64
72
  const hasSecretFields = computed(() => secretFieldCount.value > 0)
65
73
 
66
- // Seed the flat-form draft from the saved manifest so an edit starts from the CURRENT
67
- // non-secret config (baseUrl + providerConfig). Secret fields are never prefilled.
74
+ // Seed the flat-form draft from the CURRENT non-secret config so an edit starts populated.
75
+ // Secret fields are never prefilled. A `configTemplate` backend gets its flat values straight
76
+ // from `descriptor.values`; a `manifestTemplate` backend reads them off the saved manifest
77
+ // (baseUrl + providerConfig). On a fresh Kubernetes connect a `k3s` preset prefills local defaults.
68
78
  function resetDraft() {
69
79
  testResult.value = null
80
+ if (isNativeConfig.value) {
81
+ values.value = { ...descriptor.value?.values }
82
+ applyPresetDefaults()
83
+ return
84
+ }
70
85
  const saved = descriptor.value?.savedManifest
71
86
  const cfg = (saved?.providerConfig as Record<string, unknown> | undefined) ?? {}
72
87
  const next: Record<string, string> = {}
@@ -82,6 +97,24 @@ function resetDraft() {
82
97
  values.value = next
83
98
  }
84
99
 
100
+ // The low-config `k3s` preset (an execution-axis radio the picker synthesises) prefills the
101
+ // local-cluster defaults into the generic Kubernetes runner form. Only on a FRESH connect —
102
+ // never clobbering a stored connection's values. Kept SPA-local because the preset is a UI
103
+ // affordance of the picker, not a backend concept.
104
+ function applyPresetDefaults() {
105
+ if (props.preset !== 'k3s' || connection.value) return
106
+ const seed: Record<string, string> = {
107
+ label: 'Local k3s',
108
+ apiServerUrl: 'https://127.0.0.1:6443',
109
+ namespace: 'cat-factory',
110
+ insecureSkipTlsVerify: 'true',
111
+ }
112
+ if (props.suggestedImage) seed.image = props.suggestedImage
113
+ for (const [k, val] of Object.entries(seed)) {
114
+ if (!(values.value[k] ?? '').trim()) values.value[k] = val
115
+ }
116
+ }
117
+
85
118
  /** A flat-form field is satisfied when filled now, or already stored, or it has a default. */
86
119
  function satisfied(key: string): boolean {
87
120
  const f = descriptor.value?.configFields.find((cf) => cf.key === key)
@@ -124,6 +157,51 @@ function buildManifestPayload(): {
124
157
  return { manifest, secrets, backendKind: props.backendKind }
125
158
  }
126
159
 
160
+ /** Coerce a flat string form value to the JSON type the backend config expects for its field. */
161
+ function coerceFieldValue(field: ProviderConfigField, raw: string): unknown {
162
+ if (field.type === 'number') return Number(raw)
163
+ if (field.type === 'checkbox') return raw === 'true'
164
+ return raw
165
+ }
166
+
167
+ /**
168
+ * Overlay the flat field values onto a NATIVE backend's discriminated `configTemplate`. The
169
+ * skeleton is `{ kind, <payload> }`, so every non-secret field is written to the single
170
+ * non-`kind` payload key (typed via the field's `type`), each secret to the write-only bundle,
171
+ * and a cleared field is dropped so it reverts to absent. Because the template is the STORED
172
+ * config on an edit, advanced API-only keys the flat form never renders are preserved.
173
+ */
174
+ function buildConfigPayload(): {
175
+ config: Record<string, unknown>
176
+ secrets: Record<string, string>
177
+ } | null {
178
+ const template = descriptor.value?.configTemplate
179
+ if (!template) return null
180
+ const config: Record<string, unknown> = structuredClone(toRaw(template))
181
+ const payloadKey = Object.keys(config).find((k) => k !== 'kind')
182
+ if (!payloadKey) return null
183
+ const payload: Record<string, unknown> = {
184
+ ...(config[payloadKey] as Record<string, unknown> | undefined),
185
+ }
186
+ const secrets: Record<string, string> = {}
187
+ for (const f of descriptor.value?.configFields ?? []) {
188
+ const raw = (values.value[f.key] ?? '').trim()
189
+ if (f.secret) {
190
+ if (raw) secrets[f.key] = raw
191
+ continue
192
+ }
193
+ if (!raw) delete payload[f.key]
194
+ else payload[f.key] = coerceFieldValue(f, raw)
195
+ }
196
+ config[payloadKey] = payload
197
+ return { config, secrets }
198
+ }
199
+
200
+ /** The payload for the active native flavour (discriminated config or manifest overlay). */
201
+ function buildFlatPayload() {
202
+ return isNativeConfig.value ? buildConfigPayload() : buildManifestPayload()
203
+ }
204
+
127
205
  function notifyError(title: string, e: unknown) {
128
206
  toast.add({
129
207
  title,
@@ -141,9 +219,9 @@ function toastSaved() {
141
219
  })
142
220
  }
143
221
 
144
- // --- Native flat-form actions -------------------------------------------------------
222
+ // --- Native flat-form actions (both manifest-overlay and config-overlay flavours) ---
145
223
  async function testNative() {
146
- const payload = buildManifestPayload()
224
+ const payload = buildFlatPayload()
147
225
  if (!payload) return
148
226
  testing.value = true
149
227
  testResult.value = null
@@ -159,7 +237,7 @@ async function testNative() {
159
237
  async function saveNative() {
160
238
  busy.value = true
161
239
  try {
162
- const payload = buildManifestPayload()
240
+ const payload = buildFlatPayload()
163
241
  if (payload) await store.register(props.kind, payload)
164
242
  emit('connected')
165
243
  resetDraft()
@@ -332,23 +410,12 @@ function fieldHelp(key: string): string | undefined {
332
410
  }}
333
411
  </div>
334
412
 
335
- <!-- Native Kubernetes runner backend (runner-pool). -->
336
- <KubernetesRunnerForm
337
- v-if="kind === 'runner-pool' && backendKind === 'kubernetes'"
338
- :connection="connection"
339
- :preset="preset"
340
- :suggested-image="suggestedImage"
341
- :supports-test="descriptor.supportsTest"
342
- :testing="testing"
343
- :busy="busy"
344
- :test-result="testResult"
345
- @test="testConfig"
346
- @save="saveConfig"
347
- />
348
-
349
- <!-- Native Kubernetes ephemeral-environment backend (environment). -->
413
+ <!-- Native Kubernetes ephemeral-environment backend (environment). The runner-pool
414
+ Kubernetes/EKS backends now self-describe via `configTemplate` and render through the
415
+ generic flat form below — no per-kind component. The env axis keeps its bespoke form
416
+ until it, too, is descriptor-driven (see docs/initiatives/descriptor-driven-infra-forms.md). -->
350
417
  <KubernetesEnvironmentForm
351
- v-else-if="kind === 'environment' && backendKind === 'kubernetes'"
418
+ v-if="kind === 'environment' && backendKind === 'kubernetes'"
352
419
  :connection="connection"
353
420
  :supports-test="descriptor.supportsTest"
354
421
  :testing="testing"
@@ -396,6 +463,26 @@ function fieldHelp(key: string): string | undefined {
396
463
  :items="(field.options ?? []).map((o) => ({ label: o.label, value: o.value }))"
397
464
  :placeholder="field.default ?? field.placeholder"
398
465
  />
466
+ <USwitch
467
+ v-else-if="field.type === 'checkbox'"
468
+ :model-value="values[field.key] === 'true'"
469
+ @update:model-value="values[field.key] = $event ? 'true' : 'false'"
470
+ />
471
+ <UTextarea
472
+ v-else-if="field.type === 'textarea'"
473
+ v-model="values[field.key]"
474
+ :rows="4"
475
+ class="w-full font-mono"
476
+ :placeholder="field.default ?? field.placeholder"
477
+ />
478
+ <UInput
479
+ v-else-if="field.type === 'number'"
480
+ :model-value="values[field.key] ?? ''"
481
+ type="number"
482
+ class="font-mono"
483
+ :placeholder="field.default ?? field.placeholder"
484
+ @update:model-value="values[field.key] = String($event ?? '')"
485
+ />
399
486
  <UInput
400
487
  v-else
401
488
  v-model="values[field.key]"
@@ -410,6 +410,19 @@ export const SYSTEM_AGENT_META: Record<string, AgentArchetype> = {
410
410
  description:
411
411
  'Files a tracker ticket (GitHub issue / Jira) from the analysis before work starts.',
412
412
  },
413
+ // The inbound dual of `tracker`: a one-shot engine step that PULLS one matching open issue
414
+ // from the schedule's configured tracker board, marks it in-progress with a "taken by
415
+ // cat-factory" comment, and reseeds the recurring block from it. Runs no model; seeded only
416
+ // into the recurring bug-triage pipeline, so it is a display-metadata system kind (like
417
+ // `tracker`), not a palette archetype.
418
+ 'bug-intake': {
419
+ kind: 'bug-intake',
420
+ label: 'Bug Intake',
421
+ icon: 'i-lucide-inbox',
422
+ color: '#fb923c',
423
+ description:
424
+ 'Pulls one matching open issue from the configured tracker board, marks it in-progress, and seeds the run from it.',
425
+ },
413
426
  conflicts: {
414
427
  kind: 'conflicts',
415
428
  label: 'Conflicts Gate',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.90.0",
3
+ "version": "0.91.1",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,7 +34,7 @@
34
34
  "valibot": "^1.4.2",
35
35
  "vue": "^3.5.39",
36
36
  "wretch": "^3.0.9",
37
- "@cat-factory/contracts": "0.97.0"
37
+ "@cat-factory/contracts": "0.99.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@toad-contracts/testing": "0.3.2",
@@ -1,252 +0,0 @@
1
- <script setup lang="ts">
2
- // The Kubernetes "agent runner backend" connect form — one option of the runner-pool tab's
3
- // backend-type selector (the other being the manifest pool). It builds the discriminated
4
- // `{ kind: 'kubernetes', kubernetes }` config + the `apiToken` secret bundle and emits
5
- // test/save to the parent tab (which calls the shared provider-connections store).
6
- import { computed, reactive, ref, watch } from 'vue'
7
- import { KUBERNETES_RUNNER_TOKEN_SECRET_KEY } from '@cat-factory/contracts'
8
- import type { ProviderConnection } from '~/types/providerConnections'
9
-
10
- const props = defineProps<{
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
16
- supportsTest: boolean
17
- testing: boolean
18
- busy: boolean
19
- testResult: { ok: boolean; message?: string } | null
20
- }>()
21
-
22
- const emit = defineEmits<{
23
- test: [payload: { config: Record<string, unknown>; secrets: Record<string, string> }]
24
- save: [payload: { config: Record<string, unknown>; secrets: Record<string, string> }]
25
- }>()
26
-
27
- const { t } = useI18n()
28
-
29
- const form = reactive({
30
- label: '',
31
- apiServerUrl: '',
32
- namespace: '',
33
- image: '',
34
- imageUi: '',
35
- caCertPem: '',
36
- harnessPort: '',
37
- insecureSkipTlsVerify: false,
38
- })
39
- const apiToken = ref('')
40
-
41
- // A registered k8s connection exposes its non-secret config, so prefill every non-secret
42
- // field from it (never the token — secrets are write-only and re-entered on update). This
43
- // lets an edit change one field without re-typing the whole form.
44
- watch(
45
- () => props.connection,
46
- (c) => {
47
- if (c?.kind !== 'kubernetes') return
48
- form.label = c.label
49
- form.apiServerUrl = c.baseUrl
50
- const k =
51
- c.config && (c.config as { kind?: string }).kind === 'kubernetes'
52
- ? (c.config as { kubernetes: Record<string, unknown> }).kubernetes
53
- : undefined
54
- if (k) {
55
- form.namespace = typeof k.namespace === 'string' ? k.namespace : ''
56
- form.image = typeof k.image === 'string' ? k.image : ''
57
- form.imageUi = typeof k.imageUi === 'string' ? k.imageUi : ''
58
- form.caCertPem = typeof k.caCertPem === 'string' ? k.caCertPem : ''
59
- form.harnessPort = typeof k.harnessPort === 'number' ? String(k.harnessPort) : ''
60
- form.insecureSkipTlsVerify = k.insecureSkipTlsVerify === true
61
- }
62
- },
63
- { immediate: true },
64
- )
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
-
82
- const canSave = computed(
83
- () =>
84
- !!form.label.trim() &&
85
- !!form.apiServerUrl.trim() &&
86
- !!form.namespace.trim() &&
87
- !!form.image.trim() &&
88
- !!apiToken.value.trim(),
89
- )
90
-
91
- // Why the Connect button is disabled, surfaced as a red hint next to it so a mandatory-field gap
92
- // is visible rather than a dead button. Lists the empty required fields by their on-screen label.
93
- const connectBlockedReason = computed(() => {
94
- if (canSave.value) return ''
95
- const missing: string[] = []
96
- if (!form.label.trim()) missing.push(t('settings.providerConnection.kubernetes.label'))
97
- if (!form.apiServerUrl.trim())
98
- missing.push(t('settings.providerConnection.kubernetes.apiServerUrl'))
99
- if (!form.namespace.trim()) missing.push(t('settings.providerConnection.kubernetes.namespace'))
100
- if (!form.image.trim()) missing.push(t('settings.providerConnection.kubernetes.image'))
101
- if (!apiToken.value.trim()) missing.push(t('settings.providerConnection.kubernetes.apiToken'))
102
- return t('settings.providerConnection.form.missingFields', { fields: missing.join(', ') })
103
- })
104
-
105
- function buildPayload(): { config: Record<string, unknown>; secrets: Record<string, string> } {
106
- const kubernetes: Record<string, unknown> = {
107
- label: form.label.trim(),
108
- apiServerUrl: form.apiServerUrl.trim(),
109
- namespace: form.namespace.trim(),
110
- image: form.image.trim(),
111
- }
112
- if (form.imageUi.trim()) kubernetes.imageUi = form.imageUi.trim()
113
- if (form.caCertPem.trim()) kubernetes.caCertPem = form.caCertPem.trim()
114
- if (form.insecureSkipTlsVerify) kubernetes.insecureSkipTlsVerify = true
115
- const port = Number(form.harnessPort)
116
- if (form.harnessPort.trim() && Number.isFinite(port)) kubernetes.harnessPort = port
117
- return {
118
- config: { kind: 'kubernetes', kubernetes },
119
- secrets: { [KUBERNETES_RUNNER_TOKEN_SECRET_KEY]: apiToken.value.trim() },
120
- }
121
- }
122
- </script>
123
-
124
- <template>
125
- <div class="rounded-lg border border-dashed border-slate-700 p-3 space-y-3">
126
- <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
127
- {{
128
- connection?.kind === 'kubernetes'
129
- ? t('settings.providerConnection.form.updateConfiguration')
130
- : t('settings.providerConnection.form.connect')
131
- }}
132
- </p>
133
-
134
- <UFormField :label="t('settings.providerConnection.kubernetes.label')">
135
- <UInput
136
- v-model="form.label"
137
- :placeholder="t('settings.providerConnection.kubernetes.labelPlaceholder')"
138
- />
139
- </UFormField>
140
-
141
- <UFormField
142
- :label="t('settings.providerConnection.kubernetes.apiServerUrl')"
143
- :help="t('settings.providerConnection.kubernetes.apiServerUrlHelp')"
144
- >
145
- <UInput v-model="form.apiServerUrl" class="font-mono" placeholder="https://10.0.0.1:6443" />
146
- </UFormField>
147
-
148
- <UFormField :label="t('settings.providerConnection.kubernetes.namespace')">
149
- <UInput v-model="form.namespace" class="font-mono" placeholder="cat-factory" />
150
- </UFormField>
151
-
152
- <UFormField
153
- :label="t('settings.providerConnection.kubernetes.image')"
154
- :help="t('settings.providerConnection.kubernetes.imageHelp')"
155
- >
156
- <UInput
157
- v-model="form.image"
158
- class="font-mono"
159
- placeholder="ghcr.io/acme/cat-factory-executor:latest"
160
- />
161
- </UFormField>
162
-
163
- <UFormField
164
- :label="
165
- t('settings.providerConnection.form.optionalLabel', {
166
- label: t('settings.providerConnection.kubernetes.imageUi'),
167
- })
168
- "
169
- >
170
- <UInput v-model="form.imageUi" class="font-mono" />
171
- </UFormField>
172
-
173
- <UFormField
174
- :label="t('settings.providerConnection.kubernetes.apiToken')"
175
- :help="t('settings.providerConnection.kubernetes.apiTokenHelp')"
176
- >
177
- <UInput v-model="apiToken" type="password" class="font-mono" />
178
- </UFormField>
179
-
180
- <UFormField
181
- :label="
182
- t('settings.providerConnection.form.optionalLabel', {
183
- label: t('settings.providerConnection.kubernetes.caCertPem'),
184
- })
185
- "
186
- :help="t('settings.providerConnection.kubernetes.caCertPemHelp')"
187
- >
188
- <UTextarea
189
- v-model="form.caCertPem"
190
- :rows="3"
191
- class="font-mono"
192
- placeholder="-----BEGIN CERTIFICATE-----"
193
- />
194
- </UFormField>
195
-
196
- <UFormField :help="t('settings.providerConnection.kubernetes.insecureSkipTlsVerifyHelp')">
197
- <UCheckbox
198
- v-model="form.insecureSkipTlsVerify"
199
- :label="t('settings.providerConnection.kubernetes.insecureSkipTlsVerify')"
200
- />
201
- </UFormField>
202
-
203
- <UFormField
204
- :label="
205
- t('settings.providerConnection.form.optionalLabel', {
206
- label: t('settings.providerConnection.kubernetes.harnessPort'),
207
- })
208
- "
209
- >
210
- <UInput v-model="form.harnessPort" type="number" class="font-mono" placeholder="8080" />
211
- </UFormField>
212
-
213
- <div v-if="supportsTest" class="flex items-center gap-2">
214
- <UButton
215
- color="neutral"
216
- variant="soft"
217
- size="sm"
218
- icon="i-lucide-plug-zap"
219
- :loading="testing"
220
- :disabled="!canSave"
221
- @click="emit('test', buildPayload())"
222
- >
223
- {{ t('settings.providerConnection.test.button') }}
224
- </UButton>
225
- <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
226
- {{ testResult.message ?? t('settings.providerConnection.test.ok') }}
227
- </span>
228
- <span v-else-if="testResult" class="text-xs text-rose-400">
229
- {{ testResult.message ?? t('settings.providerConnection.test.failed') }}
230
- </span>
231
- </div>
232
-
233
- <div class="flex items-center justify-end gap-3">
234
- <p v-if="connectBlockedReason" class="flex-1 text-left text-xs text-rose-400">
235
- {{ connectBlockedReason }}
236
- </p>
237
- <UButton
238
- color="primary"
239
- size="sm"
240
- :loading="busy"
241
- :disabled="!canSave"
242
- @click="emit('save', buildPayload())"
243
- >
244
- {{
245
- connection?.kind === 'kubernetes'
246
- ? t('common.save')
247
- : t('settings.providerConnection.form.connect')
248
- }}
249
- </UButton>
250
- </div>
251
- </div>
252
- </template>