@cat-factory/app 0.59.2 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,393 @@
1
+ <script setup lang="ts">
2
+ // The per-provision-type infra configurator (the workspace + per-user "how"). One section per
3
+ // provision type a service can declare:
4
+ // - kubernetes → an engine picker (local-k3s / remote-kubernetes) revealing the kube
5
+ // ENGINE connect form (apiserver + URL derivation; the manifest SOURCE is
6
+ // service-owned, configured on the service).
7
+ // - docker-compose → handled by the runtime's local Docker capability — informational, no
8
+ // connection (a DinD-capable runner stands the service's compose stack up).
9
+ // - custom → the custom-manifest-type catalog editor + a `remote-custom` HTTP handler
10
+ // per custom type (matched to a service's pinned `manifestId`).
11
+ // In LOCAL mode each handler additionally offers a per-USER override (this-machine only),
12
+ // written to the `/me/environment-handlers` endpoints. Drives the infraConfig store.
13
+ import { computed, ref, watch } from 'vue'
14
+ import type {
15
+ CustomManifestType,
16
+ EnvironmentHandlerView,
17
+ InfraEngine,
18
+ InfraHandlerConfig,
19
+ } from '@cat-factory/contracts'
20
+
21
+ // The discriminated config branches the two handler forms produce, so the save handlers stay
22
+ // typed end-to-end (no `as never`): a wrong shape fails the typecheck here, not server-side.
23
+ type KubeHandlerConfig = Extract<InfraHandlerConfig, { engine: 'local-k3s' | 'remote-kubernetes' }>
24
+ type RemoteCustomConfig = Extract<InfraHandlerConfig, { engine: 'remote-custom' }>
25
+ import KubernetesEngineForm from '~/components/settings/KubernetesEngineForm.vue'
26
+ import ProviderManifestEditor from '~/components/settings/ProviderManifestEditor.vue'
27
+ import CustomManifestTypeEditor from '~/components/settings/CustomManifestTypeEditor.vue'
28
+
29
+ const { t } = useI18n()
30
+ const infra = useInfraConfigStore()
31
+ const auth = useAuthStore()
32
+ const toast = useToast()
33
+
34
+ const isLocal = computed(() => auth.localMode?.enabled === true)
35
+
36
+ onMounted(() => {
37
+ void infra.ensureLoaded()
38
+ if (isLocal.value) void infra.loadUserHandlers()
39
+ })
40
+ watch(isLocal, (local) => {
41
+ if (local) void infra.loadUserHandlers()
42
+ })
43
+
44
+ // The engines valid for the `kubernetes` provision type, mirroring the contract's discriminated
45
+ // `infraHandlerConfigSchema` (the engine list isn't served over HTTP). `local-k3s` is local-mode
46
+ // only; `remote-kubernetes` is always offered.
47
+ const kubeEngines = computed<Extract<InfraEngine, 'local-k3s' | 'remote-kubernetes'>[]>(() =>
48
+ isLocal.value ? ['local-k3s', 'remote-kubernetes'] : ['remote-kubernetes'],
49
+ )
50
+ const KUBE_ENGINE_KEYS: Record<'local-k3s' | 'remote-kubernetes', string> = {
51
+ 'local-k3s': 'settings.infrastructure.engine.local-k3s',
52
+ 'remote-kubernetes': 'settings.infrastructure.engine.remote-kubernetes',
53
+ }
54
+
55
+ const kubeHandler = computed(() => infra.handlerFor('kubernetes') ?? null)
56
+ // The registered handler's engine, shown verbatim in the "active engine" line so it reflects
57
+ // what is SAVED (not the unsaved picker selection below).
58
+ const kubeHandlerEngineLabel = computed(() => {
59
+ const e = kubeHandler.value?.engine
60
+ return e === 'local-k3s' || e === 'remote-kubernetes' ? t(KUBE_ENGINE_KEYS[e]) : ''
61
+ })
62
+ // The engine to configure: the registered handler's engine when it's valid for this mode, else
63
+ // the first valid one. Only adopt the handler's engine if the picker actually offers it, so a
64
+ // `local-k3s` handler viewed in a non-local deployment can't select an unlisted/unsupported
65
+ // engine (which would re-register a handler the runtime can't run).
66
+ const selectedKubeEngine = ref<'local-k3s' | 'remote-kubernetes'>('remote-kubernetes')
67
+ watch(
68
+ [kubeHandler, kubeEngines],
69
+ ([h, engines]) => {
70
+ const e = h?.engine
71
+ if ((e === 'local-k3s' || e === 'remote-kubernetes') && engines.includes(e)) {
72
+ selectedKubeEngine.value = e
73
+ } else if (!engines.includes(selectedKubeEngine.value)) {
74
+ selectedKubeEngine.value = engines[0]!
75
+ }
76
+ },
77
+ { immediate: true },
78
+ )
79
+
80
+ const busy = ref(false)
81
+
82
+ async function saveKube(payload: { config: KubeHandlerConfig; secrets: Record<string, string> }) {
83
+ busy.value = true
84
+ try {
85
+ await infra.registerHandler({
86
+ provisionType: 'kubernetes',
87
+ config: payload.config,
88
+ secrets: payload.secrets,
89
+ })
90
+ toastSaved()
91
+ } catch (e) {
92
+ notifyError(e)
93
+ } finally {
94
+ busy.value = false
95
+ }
96
+ }
97
+
98
+ async function removeKube() {
99
+ busy.value = true
100
+ try {
101
+ await infra.unregisterHandler('kubernetes')
102
+ toastRemoved()
103
+ } catch (e) {
104
+ notifyError(e)
105
+ } finally {
106
+ busy.value = false
107
+ }
108
+ }
109
+
110
+ // ---- per-user override (local mode only): a personal kube handler layered over the
111
+ // workspace one for THIS machine, written to the `/me/environment-handlers` endpoints. ----
112
+ const showKubeOverride = ref(false)
113
+ const kubeUserHandler = computed(() => infra.userHandlerFor('kubernetes') ?? null)
114
+ const userOverridesOn = computed(() => isLocal.value && infra.userOverridesAvailable === true)
115
+
116
+ async function saveKubeOverride(payload: {
117
+ config: KubeHandlerConfig
118
+ secrets: Record<string, string>
119
+ }) {
120
+ busy.value = true
121
+ try {
122
+ await infra.upsertUserHandler('kubernetes', {
123
+ config: payload.config,
124
+ secrets: payload.secrets,
125
+ })
126
+ toastSaved()
127
+ } catch (e) {
128
+ notifyError(e)
129
+ } finally {
130
+ busy.value = false
131
+ }
132
+ }
133
+
134
+ async function removeKubeOverride() {
135
+ busy.value = true
136
+ try {
137
+ await infra.removeUserHandler('kubernetes')
138
+ toastRemoved()
139
+ } catch (e) {
140
+ notifyError(e)
141
+ } finally {
142
+ busy.value = false
143
+ }
144
+ }
145
+
146
+ // ---- custom (remote-custom HTTP handler per custom-manifest-type) -----------
147
+ const selectedCustomId = ref<string>('')
148
+ const customTypeItems = computed(() =>
149
+ infra.customTypes.map((c: CustomManifestType) => ({
150
+ label: `${c.label} (${c.manifestId})`,
151
+ value: c.manifestId,
152
+ })),
153
+ )
154
+ watch(
155
+ customTypeItems,
156
+ (items) => {
157
+ if (!items.some((i) => i.value === selectedCustomId.value)) {
158
+ selectedCustomId.value = items[0]?.value ?? ''
159
+ }
160
+ },
161
+ { immediate: true },
162
+ )
163
+ const customHandler = computed<EnvironmentHandlerView | null>(() =>
164
+ selectedCustomId.value ? (infra.handlerFor('custom', selectedCustomId.value) ?? null) : null,
165
+ )
166
+ const customSavedManifest = computed<Record<string, unknown> | undefined>(() => {
167
+ const cfg = customHandler.value?.config
168
+ return cfg && cfg.engine === 'remote-custom'
169
+ ? (cfg.manifest as Record<string, unknown>)
170
+ : undefined
171
+ })
172
+
173
+ async function saveCustom(payload: {
174
+ manifest: Record<string, unknown>
175
+ secrets: Record<string, string>
176
+ }) {
177
+ if (!selectedCustomId.value) return
178
+ busy.value = true
179
+ try {
180
+ const config: RemoteCustomConfig = {
181
+ engine: 'remote-custom',
182
+ manifest: payload.manifest as RemoteCustomConfig['manifest'],
183
+ acceptsManifestId: selectedCustomId.value,
184
+ }
185
+ await infra.registerHandler({
186
+ provisionType: 'custom',
187
+ manifestId: selectedCustomId.value,
188
+ config,
189
+ secrets: payload.secrets,
190
+ })
191
+ toastSaved()
192
+ } catch (e) {
193
+ notifyError(e)
194
+ } finally {
195
+ busy.value = false
196
+ }
197
+ }
198
+
199
+ async function removeCustom() {
200
+ if (!selectedCustomId.value) return
201
+ busy.value = true
202
+ try {
203
+ await infra.unregisterHandler('custom', selectedCustomId.value)
204
+ toastRemoved()
205
+ } catch (e) {
206
+ notifyError(e)
207
+ } finally {
208
+ busy.value = false
209
+ }
210
+ }
211
+
212
+ function toastSaved() {
213
+ toast.add({
214
+ title: t('settings.infrastructure.handler.saved'),
215
+ icon: 'i-lucide-check',
216
+ color: 'success',
217
+ })
218
+ }
219
+ function toastRemoved() {
220
+ toast.add({ title: t('settings.infrastructure.handler.removed'), icon: 'i-lucide-check' })
221
+ }
222
+ function notifyError(e: unknown) {
223
+ toast.add({
224
+ title: t('settings.infrastructure.handler.saveFailed'),
225
+ description: e instanceof Error ? e.message : String(e),
226
+ icon: 'i-lucide-triangle-alert',
227
+ color: 'error',
228
+ })
229
+ }
230
+ </script>
231
+
232
+ <template>
233
+ <!-- Only render the configurator once the handler bundle has actually resolved (available
234
+ === true). While it's still being probed (null) show a loading line instead of flashing
235
+ the full form, and render nothing when the integration is off (false). -->
236
+ <div v-if="infra.available === true" class="space-y-5">
237
+ <p class="text-xs text-slate-400">{{ t('settings.infrastructure.handler.intro') }}</p>
238
+
239
+ <!-- kubernetes -->
240
+ <section class="space-y-2 rounded-lg border border-slate-700 bg-slate-900/40 p-3">
241
+ <h3 class="text-sm font-semibold text-slate-200">
242
+ {{ t('inspector.testConfig.provisionTypes.kubernetes') }}
243
+ </h3>
244
+ <p
245
+ v-if="kubeHandler"
246
+ class="flex items-center justify-between gap-2 text-[12px] text-slate-300"
247
+ >
248
+ <span>
249
+ {{ t('settings.infrastructure.handler.activeEngine') }}
250
+ <span class="text-slate-200">{{ kubeHandlerEngineLabel }}</span>
251
+ </span>
252
+ <UButton
253
+ icon="i-lucide-trash-2"
254
+ color="error"
255
+ variant="ghost"
256
+ size="xs"
257
+ :disabled="busy"
258
+ @click="removeKube"
259
+ />
260
+ </p>
261
+
262
+ <div class="space-y-1">
263
+ <span class="text-[11px] text-slate-400">{{
264
+ t('settings.infrastructure.handler.engineLabel')
265
+ }}</span>
266
+ <div class="flex flex-wrap gap-1">
267
+ <UButton
268
+ v-for="e in kubeEngines"
269
+ :key="e"
270
+ :color="selectedKubeEngine === e ? 'primary' : 'neutral'"
271
+ :variant="selectedKubeEngine === e ? 'soft' : 'ghost'"
272
+ size="xs"
273
+ @click="selectedKubeEngine = e"
274
+ >
275
+ {{ t(KUBE_ENGINE_KEYS[e]) }}
276
+ </UButton>
277
+ </div>
278
+ </div>
279
+
280
+ <KubernetesEngineForm
281
+ :engine="selectedKubeEngine"
282
+ :handler="kubeHandler"
283
+ :supports-test="false"
284
+ :testing="false"
285
+ :busy="busy"
286
+ :test-result="null"
287
+ @save="saveKube"
288
+ />
289
+
290
+ <!-- Local mode: a personal override for THIS machine, layered over the workspace handler. -->
291
+ <div v-if="userOverridesOn" class="border-t border-slate-800 pt-2">
292
+ <button
293
+ type="button"
294
+ class="flex w-full items-center gap-1.5 text-start text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:text-slate-300"
295
+ @click="showKubeOverride = !showKubeOverride"
296
+ >
297
+ <UIcon
298
+ :name="showKubeOverride ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
299
+ class="h-3.5 w-3.5"
300
+ />
301
+ {{ t('settings.infrastructure.handler.personalOverride') }}
302
+ <UBadge v-if="kubeUserHandler" color="primary" variant="subtle" size="sm">
303
+ {{ t('settings.infrastructure.handler.overrideActive') }}
304
+ </UBadge>
305
+ </button>
306
+ <div v-if="showKubeOverride" class="mt-2 space-y-2">
307
+ <p class="text-[11px] text-slate-500">
308
+ {{ t('settings.infrastructure.handler.personalOverrideHint') }}
309
+ </p>
310
+ <p v-if="kubeUserHandler" class="flex justify-end">
311
+ <UButton
312
+ icon="i-lucide-trash-2"
313
+ color="error"
314
+ variant="ghost"
315
+ size="xs"
316
+ :disabled="busy"
317
+ @click="removeKubeOverride"
318
+ >
319
+ {{ t('settings.infrastructure.handler.removeOverride') }}
320
+ </UButton>
321
+ </p>
322
+ <KubernetesEngineForm
323
+ :engine="selectedKubeEngine"
324
+ :handler="kubeUserHandler"
325
+ :supports-test="false"
326
+ :testing="false"
327
+ :busy="busy"
328
+ :test-result="null"
329
+ @save="saveKubeOverride"
330
+ />
331
+ </div>
332
+ </div>
333
+ </section>
334
+
335
+ <!-- docker-compose: handled by the runtime's local Docker capability, no connection. -->
336
+ <section class="space-y-1 rounded-lg border border-slate-700 bg-slate-900/40 p-3">
337
+ <h3 class="text-sm font-semibold text-slate-200">
338
+ {{ t('inspector.testConfig.provisionTypes.docker-compose') }}
339
+ </h3>
340
+ <p class="text-[12px] text-slate-400">{{ t('settings.infrastructure.dockerComposeInfo') }}</p>
341
+ </section>
342
+
343
+ <!-- custom: the catalog editor + a remote-custom HTTP handler per custom type. -->
344
+ <section class="space-y-3 rounded-lg border border-slate-700 bg-slate-900/40 p-3">
345
+ <h3 class="text-sm font-semibold text-slate-200">
346
+ {{ t('inspector.testConfig.provisionTypes.custom') }}
347
+ </h3>
348
+
349
+ <CustomManifestTypeEditor />
350
+
351
+ <div v-if="infra.customTypes.length" class="space-y-2 border-t border-slate-800 pt-3">
352
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
353
+ {{ t('settings.infrastructure.handler.customHandlerTitle') }}
354
+ </p>
355
+ <UFormField :label="t('settings.infrastructure.handler.customTypeLabel')">
356
+ <USelect v-model="selectedCustomId" :items="customTypeItems" />
357
+ </UFormField>
358
+ <p
359
+ v-if="customHandler"
360
+ class="flex items-center justify-between gap-2 text-[12px] text-slate-300"
361
+ >
362
+ <span class="text-emerald-400">{{
363
+ t('settings.infrastructure.handler.customConnected')
364
+ }}</span>
365
+ <UButton
366
+ icon="i-lucide-trash-2"
367
+ color="error"
368
+ variant="ghost"
369
+ size="xs"
370
+ :disabled="busy"
371
+ @click="removeCustom"
372
+ />
373
+ </p>
374
+ <ProviderManifestEditor
375
+ v-if="selectedCustomId"
376
+ :key="selectedCustomId"
377
+ kind="environment"
378
+ :saved-manifest="customSavedManifest"
379
+ :connected="!!customHandler"
380
+ :stored-secret-keys="customHandler?.secretKeys ?? []"
381
+ :supports-test="false"
382
+ :testing="false"
383
+ :busy="busy"
384
+ :test-result="null"
385
+ @save="saveCustom"
386
+ />
387
+ </div>
388
+ </section>
389
+ </div>
390
+ <p v-else-if="infra.available === null" class="text-xs text-slate-500">
391
+ {{ t('settings.infrastructure.handler.loading') }}
392
+ </p>
393
+ </template>
@@ -12,6 +12,7 @@
12
12
  import { computed, ref, watch } from 'vue'
13
13
  import type { ProviderConnectionKind } from '~/types/providerConnections'
14
14
  import InfrastructureBackendPicker from '~/components/settings/InfrastructureBackendPicker.vue'
15
+ import InfraHandlersConfigurator from '~/components/settings/InfraHandlersConfigurator.vue'
15
16
  import LocalContainerPoolSettings from '~/components/settings/LocalContainerPoolSettings.vue'
16
17
 
17
18
  const { t } = useI18n()
@@ -117,8 +118,10 @@ watch([tabs, () => store.loaded], () => {
117
118
  </template>
118
119
  <template #environment>
119
120
  <div class="space-y-4">
120
- <!-- One unified list of where the Tester's ephemeral environments run. -->
121
- <InfrastructureBackendPicker axis="testEnv" />
121
+ <!-- The Tester's environment is driven by each SERVICE's declared provision type
122
+ (the "what/where"); the workspace configures HOW each type is handled here —
123
+ the engine + connection per provision type, plus the custom-type catalog. -->
124
+ <InfraHandlersConfigurator />
122
125
  </div>
123
126
  </template>
124
127
  </UTabs>