@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.
@@ -3,12 +3,39 @@
3
3
  // the live URL, the TTL, and — when it failed/expired — the verbatim provider error.
4
4
  // Used in a run's details (AgentStepDetail) so the Tester (and any env-consuming step)
5
5
  // shows whether the env is spinning up / running / shut down / errored, with the error.
6
+ import type { InfraEngine, ProvisionType } from '@cat-factory/contracts'
6
7
  import type { RunEnvironment, HumanTestEnvironmentStatus } from '~/types/execution'
7
8
 
8
- defineProps<{ environment: RunEnvironment | null; degradedReason?: string | null }>()
9
+ const props = defineProps<{ environment: RunEnvironment | null; degradedReason?: string | null }>()
9
10
 
10
11
  const { t, d } = useI18n()
11
12
 
13
+ // Exhaustive enum→key maps (keep the typed-key drift guard live) for the resolved
14
+ // provision type + engine recorded on the handle, so run details state exactly what was
15
+ // stood up and how. `infraless`/`none` are filtered out of the display below.
16
+ const PROVISION_TYPE_KEYS: Record<ProvisionType, string> = {
17
+ kubernetes: 'environments.provisionType.kubernetes',
18
+ 'docker-compose': 'environments.provisionType.docker-compose',
19
+ custom: 'environments.provisionType.custom',
20
+ infraless: 'environments.provisionType.infraless',
21
+ }
22
+ const ENGINE_KEYS: Record<InfraEngine, string> = {
23
+ 'local-docker': 'environments.engine.local-docker',
24
+ 'local-k3s': 'environments.engine.local-k3s',
25
+ 'remote-kubernetes': 'environments.engine.remote-kubernetes',
26
+ 'remote-custom': 'environments.engine.remote-custom',
27
+ none: 'environments.engine.none',
28
+ }
29
+
30
+ const provisionTypeLabel = computed(() => {
31
+ const pt = props.environment?.provisionType
32
+ return pt ? t(PROVISION_TYPE_KEYS[pt]) : null
33
+ })
34
+ const engineLabel = computed(() => {
35
+ const e = props.environment?.engine
36
+ return e && e !== 'none' ? t(ENGINE_KEYS[e]) : null
37
+ })
38
+
12
39
  // Exhaustive enum→label map of literal `t(...)` keys (keeps the typed-key drift guard
13
40
  // live); the color/icon stay static, English-neutral styling.
14
41
  const ENV_STATUS_META = computed<
@@ -82,6 +109,18 @@ const ENV_STATUS_META = computed<
82
109
  <p v-if="environment.expiresAt" class="text-[11px] text-slate-500">
83
110
  {{ t('environments.expires', { date: d(new Date(environment.expiresAt), 'long') }) }}
84
111
  </p>
112
+ <!-- The resolved provision type + engine recorded at provision time, so a run states
113
+ exactly what was provisioned and how (the what/where ÷ how split). -->
114
+ <dl v-if="provisionTypeLabel || engineLabel" class="flex flex-wrap gap-x-4 gap-y-0.5">
115
+ <div v-if="provisionTypeLabel" class="flex items-center gap-1 text-[11px]">
116
+ <dt class="text-slate-500">{{ t('environments.provisionTypeLabel') }}</dt>
117
+ <dd class="text-slate-300">{{ provisionTypeLabel }}</dd>
118
+ </div>
119
+ <div v-if="engineLabel" class="flex items-center gap-1 text-[11px]">
120
+ <dt class="text-slate-500">{{ t('environments.engineLabel') }}</dt>
121
+ <dd class="text-slate-300">{{ engineLabel }}</dd>
122
+ </div>
123
+ </dl>
85
124
  <!-- The verbatim provider error when the environment failed/expired. -->
86
125
  <pre
87
126
  v-if="
@@ -1,14 +1,22 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref } from 'vue'
3
- import type { Block, CloudProvider, InstanceSize, ProvisionType } from '~/types/domain'
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
+ import type {
4
+ Block,
5
+ CloudProvider,
6
+ InstanceSize,
7
+ ProvisionType,
8
+ ServiceProvisioning,
9
+ } from '~/types/domain'
10
+ import type { KubernetesManifestSource, KubernetesRenderer } from '@cat-factory/contracts'
4
11
  import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
5
12
 
6
13
  // Service-level (frame) configuration: the service-owned PROVISIONING — the provision
7
14
  // TYPE this service produces (`infraless` / `docker-compose` / `kubernetes` / `custom`)
8
- // plus, for docker-compose, the in-repo compose path the Tester stands up and the
9
- // cloud provider + instance size the service's container jobs run on. The WORKSPACE
10
- // configures HOW each type is handled (the engine + connection), so this view only owns
11
- // the "what + where". Autodiscovery suggests a compose path when the service is added.
15
+ // plus the in-repo specifics it owns (the "what + where"): where its kubernetes manifests
16
+ // live (colocated path or a separate repo) + the renderer, its compose path, or the custom
17
+ // manifest id it pins. The WORKSPACE configures HOW each type is handled (the engine +
18
+ // connection); this view only owns the "what + where". Autodiscovery suggests a compose path
19
+ // when the service is added.
12
20
  const props = defineProps<{
13
21
  block: Block
14
22
  // Repo backing this service, supplied by the add-service modal when the block is
@@ -20,13 +28,44 @@ const board = useBoardStore()
20
28
  const accounts = useAccountsStore()
21
29
  const github = useGitHubStore()
22
30
  const services = useServicesStore()
31
+ const infra = useInfraConfigStore()
23
32
  const { t } = useI18n()
24
33
 
34
+ // The custom-manifest-type catalog feeds the `custom` picker. Cheap + shared (coalesced).
35
+ onMounted(() => void infra.ensureLoaded())
36
+
25
37
  // The service's declared provision type (absent ⇒ treated as `infraless`: no environment
26
- // is stood up for the Tester). Switching type preserves the compose path so toggling away
27
- // and back doesn't lose it.
38
+ // is stood up for the Tester). Switching type MERGES onto the existing provisioning so each
39
+ // type's in-repo specifics survive toggling away and back (only the branch matching the type
40
+ // is meaningful — the others are ignored at provision time).
28
41
  const provisionType = computed<ProvisionType>(() => props.block.provisioning?.type ?? 'infraless')
29
42
  const composePath = computed(() => props.block.provisioning?.composePath ?? '')
43
+ const localDevOnly = computed(() => props.block.provisioning?.localDevOnly === true)
44
+ // Local kube manifest-source edit state, seeded once per block from the persisted (and
45
+ // already-validated) source. Driving the inputs from local refs rather than the
46
+ // discriminated persisted object keeps the repo/ref across a colocated<->separate toggle
47
+ // and lets a half-entered source live in the form WITHOUT writing a value the server would
48
+ // reject (the schema requires a non-empty repo for `separate` and a non-empty path for
49
+ // both). We only persist the source once it's valid (see commitManifestSource).
50
+ const kubeSourceType = ref<'colocated' | 'separate'>('colocated')
51
+ const kubeRepo = ref('')
52
+ const kubeRef = ref('')
53
+ const kubePath = ref('')
54
+ const kubeRenderer = ref<KubernetesRenderer>('raw')
55
+ watch(
56
+ () => props.block.id,
57
+ () => {
58
+ const src = props.block.provisioning?.manifestSource
59
+ kubeSourceType.value = src?.type ?? 'colocated'
60
+ kubePath.value = src?.path ?? ''
61
+ kubeRenderer.value = src?.renderer ?? 'raw'
62
+ kubeRepo.value = src?.type === 'separate' ? src.repo : ''
63
+ kubeRef.value = src?.type === 'separate' ? (src.ref ?? '') : ''
64
+ },
65
+ { immediate: true },
66
+ )
67
+ const customManifestId = computed(() => props.block.provisioning?.manifestId ?? '')
68
+ const customManifestPath = computed(() => props.block.provisioning?.manifestPath ?? '')
30
69
 
31
70
  const PROVISION_TYPES = computed<{ value: ProvisionType; label: string }[]>(() => [
32
71
  { value: 'infraless', label: t('inspector.testConfig.provisionTypes.infraless') },
@@ -35,20 +74,79 @@ const PROVISION_TYPES = computed<{ value: ProvisionType; label: string }[]>(() =
35
74
  { value: 'custom', label: t('inspector.testConfig.provisionTypes.custom') },
36
75
  ])
37
76
 
77
+ const RENDERERS = computed<{ value: KubernetesRenderer; label: string }[]>(() => [
78
+ { value: 'raw', label: t('inspector.testConfig.renderers.raw') },
79
+ { value: 'kustomize', label: t('inspector.testConfig.renderers.kustomize') },
80
+ ])
81
+
82
+ const customTypeItems = computed(() =>
83
+ infra.customTypes.map((c) => ({ label: `${c.label} (${c.manifestId})`, value: c.manifestId })),
84
+ )
85
+
86
+ // Merge a partial onto the current provisioning, preserving the other branches' fields.
87
+ function patchProvisioning(patch: Partial<ServiceProvisioning>) {
88
+ const current: ServiceProvisioning = props.block.provisioning ?? { type: 'infraless' }
89
+ board.updateBlock(props.block.id, { provisioning: { ...current, ...patch } })
90
+ }
91
+
38
92
  function setProvisionType(type: ProvisionType) {
39
- // Carry the compose path across a switch so it isn't lost when toggling type.
40
- board.updateBlock(props.block.id, {
41
- provisioning: {
42
- type,
43
- ...(type === 'docker-compose' && composePath.value ? { composePath: composePath.value } : {}),
44
- },
45
- })
93
+ patchProvisioning({ type })
46
94
  }
47
95
 
48
96
  function setComposePath(value: string) {
49
- board.updateBlock(props.block.id, {
50
- provisioning: { type: 'docker-compose', composePath: value.trim() },
51
- })
97
+ patchProvisioning({ type: 'docker-compose', composePath: value.trim() })
98
+ }
99
+
100
+ function setLocalDevOnly(value: boolean) {
101
+ patchProvisioning({ localDevOnly: value })
102
+ }
103
+
104
+ // Build the discriminated manifest source from the local edit state and persist it ONLY
105
+ // when it satisfies the schema (a non-empty repo for `separate`, a non-empty path for both);
106
+ // an incomplete edit sets the type but omits the source, so we never PATCH a value the
107
+ // server would 422 on.
108
+ function commitManifestSource() {
109
+ const path = kubePath.value.trim()
110
+ const rendererPart = kubeRenderer.value === 'kustomize' ? { renderer: kubeRenderer.value } : {}
111
+ let next: KubernetesManifestSource | undefined
112
+ if (kubeSourceType.value === 'separate') {
113
+ const repo = kubeRepo.value.trim()
114
+ const ref = kubeRef.value.trim()
115
+ if (repo && path)
116
+ next = { type: 'separate', repo, path, ...(ref ? { ref } : {}), ...rendererPart }
117
+ } else if (path) {
118
+ next = { type: 'colocated', path, ...rendererPart }
119
+ }
120
+ patchProvisioning(next ? { type: 'kubernetes', manifestSource: next } : { type: 'kubernetes' })
121
+ }
122
+
123
+ function setKubeSourceType(type: 'colocated' | 'separate') {
124
+ kubeSourceType.value = type
125
+ commitManifestSource()
126
+ }
127
+ function setKubeRepo(value: string) {
128
+ kubeRepo.value = value
129
+ commitManifestSource()
130
+ }
131
+ function setKubeRef(value: string) {
132
+ kubeRef.value = value
133
+ commitManifestSource()
134
+ }
135
+ function setKubePath(value: string) {
136
+ kubePath.value = value
137
+ commitManifestSource()
138
+ }
139
+ function setKubeRenderer(value: KubernetesRenderer) {
140
+ kubeRenderer.value = value
141
+ commitManifestSource()
142
+ }
143
+
144
+ function setCustomManifestId(value: string) {
145
+ patchProvisioning({ type: 'custom', manifestId: value || undefined })
146
+ }
147
+
148
+ function setCustomManifestPath(value: string) {
149
+ patchProvisioning({ type: 'custom', manifestPath: value.trim() || undefined })
52
150
  }
53
151
 
54
152
  // The provisioning hints (cloud provider + instance size) are advisory inputs to the
@@ -66,17 +164,22 @@ const repoContext = computed<{ githubId: number; directory?: string | null } | u
66
164
  return r ? { githubId: r.githubId } : undefined
67
165
  })
68
166
 
69
- // Compose-file picker: browse the repo and pin the compose file. The Tester runs
70
- // `docker compose -f <path>` from the CLONE ROOT, so the stored path is relative to
71
- // the repo root (the browser starts inside the service's subdirectory for convenience).
167
+ // Repo-path picker, shared by the compose file (`docker compose -f <path>`) and the
168
+ // kubernetes colocated manifests path. The stored path is relative to the repo root (the
169
+ // browser starts inside the service's subdirectory for convenience).
72
170
  const browseOpen = ref(false)
171
+ const browseTarget = ref<'compose' | 'k8s'>('compose')
73
172
  const pickedPath = ref<string | undefined>(undefined)
74
- function openBrowse() {
75
- pickedPath.value = composePath.value || undefined
173
+ function openBrowse(target: 'compose' | 'k8s') {
174
+ browseTarget.value = target
175
+ pickedPath.value = (target === 'compose' ? composePath.value : kubePath.value) || undefined
76
176
  browseOpen.value = true
77
177
  }
78
178
  function applyPicked() {
79
- if (pickedPath.value) setComposePath(pickedPath.value)
179
+ if (pickedPath.value) {
180
+ if (browseTarget.value === 'compose') setComposePath(pickedPath.value)
181
+ else setKubePath(pickedPath.value)
182
+ }
80
183
  browseOpen.value = false
81
184
  }
82
185
 
@@ -134,39 +237,202 @@ function setSize(value: InstanceSize) {
134
237
  </p>
135
238
  </div>
136
239
 
137
- <div v-if="provisionType === 'docker-compose'" class="space-y-1">
138
- <label class="text-[11px] text-slate-400">{{ t('inspector.testConfig.composePath') }}</label>
139
- <div class="flex items-center gap-1">
240
+ <div v-if="provisionType === 'docker-compose'" class="space-y-2">
241
+ <div class="space-y-1">
242
+ <label class="text-[11px] text-slate-400">{{
243
+ t('inspector.testConfig.composePath')
244
+ }}</label>
245
+ <div class="flex items-center gap-1">
246
+ <UInput
247
+ :model-value="composePath"
248
+ size="xs"
249
+ class="flex-1"
250
+ placeholder="docker-compose.yml"
251
+ @blur="(e: FocusEvent) => setComposePath((e.target as HTMLInputElement).value)"
252
+ @keydown.enter="
253
+ (e: KeyboardEvent) => setComposePath((e.target as HTMLInputElement).value)
254
+ "
255
+ />
256
+ <UButton
257
+ v-if="repoContext"
258
+ size="xs"
259
+ variant="soft"
260
+ color="neutral"
261
+ icon="i-lucide-folder-search"
262
+ :title="t('inspector.testConfig.browseRepo')"
263
+ @click="openBrowse('compose')"
264
+ />
265
+ </div>
266
+ <p class="text-[11px] leading-snug text-slate-500">
267
+ {{ t('inspector.testConfig.composeHint') }}
268
+ </p>
269
+ </div>
270
+ <UCheckbox
271
+ :model-value="localDevOnly"
272
+ :label="t('inspector.testConfig.localDevOnly')"
273
+ size="xs"
274
+ @update:model-value="(v: boolean | 'indeterminate') => setLocalDevOnly(v === true)"
275
+ />
276
+ </div>
277
+
278
+ <!-- kubernetes: where the per-PR manifests live (the "what/where"). The engine + cluster
279
+ connection (the "how") is configured per-type in the Infrastructure window. -->
280
+ <div v-if="provisionType === 'kubernetes'" class="space-y-2">
281
+ <div class="space-y-1">
282
+ <span class="text-[11px] text-slate-400">{{
283
+ t('inspector.testConfig.manifestSourceLabel')
284
+ }}</span>
285
+ <div class="flex flex-wrap gap-1">
286
+ <UButton
287
+ :color="kubeSourceType === 'colocated' ? 'primary' : 'neutral'"
288
+ :variant="kubeSourceType === 'colocated' ? 'soft' : 'ghost'"
289
+ size="xs"
290
+ @click="setKubeSourceType('colocated')"
291
+ >
292
+ {{ t('inspector.testConfig.sourceColocated') }}
293
+ </UButton>
294
+ <UButton
295
+ :color="kubeSourceType === 'separate' ? 'primary' : 'neutral'"
296
+ :variant="kubeSourceType === 'separate' ? 'soft' : 'ghost'"
297
+ size="xs"
298
+ @click="setKubeSourceType('separate')"
299
+ >
300
+ {{ t('inspector.testConfig.sourceSeparate') }}
301
+ </UButton>
302
+ </div>
303
+ </div>
304
+
305
+ <div v-if="kubeSourceType === 'separate'" class="space-y-1">
306
+ <label class="text-[11px] text-slate-400">{{
307
+ t('inspector.testConfig.manifestRepo')
308
+ }}</label>
309
+ <UInput
310
+ :model-value="kubeRepo"
311
+ size="xs"
312
+ class="font-mono"
313
+ placeholder="acme/preview-manifests"
314
+ @blur="(e: FocusEvent) => setKubeRepo((e.target as HTMLInputElement).value)"
315
+ @keydown.enter="(e: KeyboardEvent) => setKubeRepo((e.target as HTMLInputElement).value)"
316
+ />
317
+ </div>
318
+ <div v-if="kubeSourceType === 'separate'" class="space-y-1">
319
+ <label class="text-[11px] text-slate-400">{{
320
+ t('inspector.testConfig.manifestRef')
321
+ }}</label>
322
+ <UInput
323
+ :model-value="kubeRef"
324
+ size="xs"
325
+ class="font-mono"
326
+ placeholder="main"
327
+ @blur="(e: FocusEvent) => setKubeRef((e.target as HTMLInputElement).value)"
328
+ @keydown.enter="(e: KeyboardEvent) => setKubeRef((e.target as HTMLInputElement).value)"
329
+ />
330
+ </div>
331
+
332
+ <div class="space-y-1">
333
+ <label class="text-[11px] text-slate-400">{{
334
+ t('inspector.testConfig.manifestPath')
335
+ }}</label>
336
+ <div class="flex items-center gap-1">
337
+ <UInput
338
+ :model-value="kubePath"
339
+ size="xs"
340
+ class="flex-1 font-mono"
341
+ placeholder="k8s/preview"
342
+ @blur="(e: FocusEvent) => setKubePath((e.target as HTMLInputElement).value)"
343
+ @keydown.enter="(e: KeyboardEvent) => setKubePath((e.target as HTMLInputElement).value)"
344
+ />
345
+ <UButton
346
+ v-if="repoContext && kubeSourceType === 'colocated'"
347
+ size="xs"
348
+ variant="soft"
349
+ color="neutral"
350
+ icon="i-lucide-folder-search"
351
+ :title="t('inspector.testConfig.browseRepo')"
352
+ @click="openBrowse('k8s')"
353
+ />
354
+ </div>
355
+ <p class="text-[11px] leading-snug text-slate-500">
356
+ {{ t('inspector.testConfig.manifestPathHint') }}
357
+ </p>
358
+ </div>
359
+
360
+ <div class="space-y-1">
361
+ <span class="text-[11px] text-slate-400">{{
362
+ t('inspector.testConfig.rendererLabel')
363
+ }}</span>
364
+ <div class="flex flex-wrap gap-1">
365
+ <UButton
366
+ v-for="r in RENDERERS"
367
+ :key="r.value"
368
+ :color="kubeRenderer === r.value ? 'primary' : 'neutral'"
369
+ :variant="kubeRenderer === r.value ? 'soft' : 'ghost'"
370
+ size="xs"
371
+ @click="setKubeRenderer(r.value)"
372
+ >
373
+ {{ r.label }}
374
+ </UButton>
375
+ </div>
376
+ <p class="text-[11px] leading-snug text-slate-500">
377
+ {{ t('inspector.testConfig.rendererHint') }}
378
+ </p>
379
+ </div>
380
+ </div>
381
+
382
+ <!-- custom: pin the custom manifest type this service produces (matched to a remote-custom
383
+ handler the workspace configures). -->
384
+ <div v-if="provisionType === 'custom'" class="space-y-2">
385
+ <div class="space-y-1">
386
+ <label class="text-[11px] text-slate-400">{{
387
+ t('inspector.testConfig.customManifestId')
388
+ }}</label>
389
+ <USelect
390
+ v-if="customTypeItems.length"
391
+ :model-value="customManifestId"
392
+ :items="customTypeItems"
393
+ size="xs"
394
+ :placeholder="t('inspector.testConfig.customManifestIdPlaceholder')"
395
+ @update:model-value="(v: string) => setCustomManifestId(v)"
396
+ />
397
+ <p v-else class="text-[11px] leading-snug text-amber-300/80">
398
+ {{ t('inspector.testConfig.customNoTypes') }}
399
+ </p>
400
+ <p class="text-[11px] leading-snug text-slate-500">
401
+ {{ t('inspector.testConfig.customManifestIdHint') }}
402
+ </p>
403
+ </div>
404
+ <div class="space-y-1">
405
+ <label class="text-[11px] text-slate-400">{{
406
+ t('inspector.testConfig.customManifestPath')
407
+ }}</label>
140
408
  <UInput
141
- :model-value="composePath"
409
+ :model-value="customManifestPath"
142
410
  size="xs"
143
- class="flex-1"
144
- placeholder="docker-compose.yml"
145
- @blur="(e: FocusEvent) => setComposePath((e.target as HTMLInputElement).value)"
411
+ class="font-mono"
412
+ @blur="(e: FocusEvent) => setCustomManifestPath((e.target as HTMLInputElement).value)"
146
413
  @keydown.enter="
147
- (e: KeyboardEvent) => setComposePath((e.target as HTMLInputElement).value)
414
+ (e: KeyboardEvent) => setCustomManifestPath((e.target as HTMLInputElement).value)
148
415
  "
149
416
  />
150
- <UButton
151
- v-if="repoContext"
152
- size="xs"
153
- variant="soft"
154
- color="neutral"
155
- icon="i-lucide-folder-search"
156
- :title="t('inspector.testConfig.browseRepo')"
157
- @click="openBrowse"
158
- />
159
417
  </div>
160
- <p class="text-[11px] leading-snug text-slate-500">
161
- {{ t('inspector.testConfig.composeHint') }}
162
- </p>
163
418
  </div>
164
419
 
165
- <UModal v-model:open="browseOpen" :title="t('inspector.testConfig.selectComposeTitle')">
420
+ <UModal
421
+ v-model:open="browseOpen"
422
+ :title="
423
+ browseTarget === 'compose'
424
+ ? t('inspector.testConfig.selectComposeTitle')
425
+ : t('inspector.testConfig.selectManifestTitle')
426
+ "
427
+ >
166
428
  <template #body>
167
429
  <div v-if="repoContext" class="space-y-3">
168
430
  <p class="text-xs text-slate-400">
169
- {{ t('inspector.testConfig.selectComposeHint') }}
431
+ {{
432
+ browseTarget === 'compose'
433
+ ? t('inspector.testConfig.selectComposeHint')
434
+ : t('inspector.testConfig.selectManifestHint')
435
+ }}
170
436
  </p>
171
437
  <RepoTreeBrowser
172
438
  v-model="pickedPath"
@@ -0,0 +1,171 @@
1
+ <script setup lang="ts">
2
+ // The custom-manifest-type catalog editor: lists the open set of `custom` provision types —
3
+ // the read-only programmatically-REGISTERED ones (from code providers) plus the WORKSPACE-
4
+ // defined ones a user can add/edit/remove here. A service pins one of these (its `manifestId`)
5
+ // and a `remote-custom` handler declares which it accepts. Writes the workspace entries via the
6
+ // infraConfig store (`PUT|DELETE /environments/custom-types/:manifestId`).
7
+ import { computed, reactive, ref } from 'vue'
8
+ import type { CustomManifestType } from '@cat-factory/contracts'
9
+
10
+ const { t } = useI18n()
11
+ const infra = useInfraConfigStore()
12
+ const toast = useToast()
13
+
14
+ // A draft for the add/edit form. `manifestId` is locked on edit (it's the PK).
15
+ const draft = reactive({ manifestId: '', label: '', acceptsInputHint: '', description: '' })
16
+ const editing = ref(false)
17
+ const busy = ref(false)
18
+
19
+ const manifestIdValid = computed(() => /^[a-z0-9][a-z0-9-]*$/.test(draft.manifestId.trim()))
20
+ const canSave = computed(
21
+ () => (editing.value || manifestIdValid.value) && !!draft.label.trim() && !busy.value,
22
+ )
23
+
24
+ function startAdd() {
25
+ Object.assign(draft, { manifestId: '', label: '', acceptsInputHint: '', description: '' })
26
+ editing.value = false
27
+ }
28
+
29
+ function startEdit(type: CustomManifestType) {
30
+ Object.assign(draft, {
31
+ manifestId: type.manifestId,
32
+ label: type.label,
33
+ acceptsInputHint: type.acceptsInputHint ?? '',
34
+ description: type.description ?? '',
35
+ })
36
+ editing.value = true
37
+ }
38
+
39
+ async function save() {
40
+ if (!canSave.value) return
41
+ busy.value = true
42
+ try {
43
+ await infra.upsertCustomType(draft.manifestId.trim(), {
44
+ label: draft.label.trim(),
45
+ ...(draft.acceptsInputHint.trim() ? { acceptsInputHint: draft.acceptsInputHint.trim() } : {}),
46
+ ...(draft.description.trim() ? { description: draft.description.trim() } : {}),
47
+ })
48
+ startAdd()
49
+ } catch (e) {
50
+ toast.add({
51
+ title: t('settings.infrastructure.customType.saveFailed'),
52
+ description: e instanceof Error ? e.message : String(e),
53
+ icon: 'i-lucide-triangle-alert',
54
+ color: 'error',
55
+ })
56
+ } finally {
57
+ busy.value = false
58
+ }
59
+ }
60
+
61
+ async function remove(type: CustomManifestType) {
62
+ busy.value = true
63
+ try {
64
+ await infra.removeCustomType(type.manifestId)
65
+ if (editing.value && draft.manifestId === type.manifestId) startAdd()
66
+ } catch (e) {
67
+ toast.add({
68
+ title: t('settings.infrastructure.customType.removeFailed'),
69
+ description: e instanceof Error ? e.message : String(e),
70
+ icon: 'i-lucide-triangle-alert',
71
+ color: 'error',
72
+ })
73
+ } finally {
74
+ busy.value = false
75
+ }
76
+ }
77
+ </script>
78
+
79
+ <template>
80
+ <section class="space-y-3 rounded-lg border border-slate-700 bg-slate-900/40 p-3">
81
+ <div>
82
+ <h3 class="text-sm font-semibold text-slate-200">
83
+ {{ t('settings.infrastructure.customType.title') }}
84
+ </h3>
85
+ <p class="text-[11px] text-slate-500">{{ t('settings.infrastructure.customType.hint') }}</p>
86
+ </div>
87
+
88
+ <!-- The catalog: registered (read-only) + workspace (editable). -->
89
+ <ul v-if="infra.customTypes.length" class="space-y-1.5">
90
+ <li
91
+ v-for="type in infra.customTypes"
92
+ :key="type.manifestId"
93
+ class="flex items-start justify-between gap-2 rounded-md border border-slate-800 bg-slate-900/50 px-2.5 py-1.5"
94
+ >
95
+ <div class="min-w-0">
96
+ <div class="flex items-center gap-1.5">
97
+ <span class="truncate text-[13px] text-slate-200">{{ type.label }}</span>
98
+ <UBadge
99
+ :color="type.source === 'workspace' ? 'primary' : 'neutral'"
100
+ variant="subtle"
101
+ size="sm"
102
+ >
103
+ {{ t(`settings.infrastructure.customType.source.${type.source}`) }}
104
+ </UBadge>
105
+ </div>
106
+ <code class="text-[11px] text-slate-500">{{ type.manifestId }}</code>
107
+ <p v-if="type.description" class="text-[11px] text-slate-400">{{ type.description }}</p>
108
+ </div>
109
+ <div v-if="type.source === 'workspace'" class="flex shrink-0 items-center gap-0.5">
110
+ <UButton
111
+ icon="i-lucide-pencil"
112
+ color="neutral"
113
+ variant="ghost"
114
+ size="xs"
115
+ :disabled="busy"
116
+ @click="startEdit(type)"
117
+ />
118
+ <UButton
119
+ icon="i-lucide-trash-2"
120
+ color="error"
121
+ variant="ghost"
122
+ size="xs"
123
+ :disabled="busy"
124
+ @click="remove(type)"
125
+ />
126
+ </div>
127
+ </li>
128
+ </ul>
129
+ <p v-else class="text-[11px] text-slate-500">
130
+ {{ t('settings.infrastructure.customType.empty') }}
131
+ </p>
132
+
133
+ <!-- Add / edit a workspace-defined type. -->
134
+ <div class="space-y-2 border-t border-slate-800 pt-3">
135
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
136
+ {{
137
+ editing
138
+ ? t('settings.infrastructure.customType.editTitle', { id: draft.manifestId })
139
+ : t('settings.infrastructure.customType.addTitle')
140
+ }}
141
+ </p>
142
+ <UFormField
143
+ v-if="!editing"
144
+ :label="t('settings.infrastructure.customType.manifestId')"
145
+ :help="t('settings.infrastructure.customType.manifestIdHelp')"
146
+ >
147
+ <UInput v-model="draft.manifestId" class="font-mono" placeholder="my-kargo-template" />
148
+ </UFormField>
149
+ <UFormField :label="t('settings.infrastructure.customType.label')">
150
+ <UInput v-model="draft.label" />
151
+ </UFormField>
152
+ <UFormField
153
+ :label="t('settings.infrastructure.customType.acceptsInputHint')"
154
+ :help="t('settings.infrastructure.customType.acceptsInputHintHelp')"
155
+ >
156
+ <UInput v-model="draft.acceptsInputHint" />
157
+ </UFormField>
158
+ <UFormField :label="t('settings.infrastructure.customType.description')">
159
+ <UTextarea v-model="draft.description" :rows="2" />
160
+ </UFormField>
161
+ <div class="flex justify-end gap-2">
162
+ <UButton v-if="editing" color="neutral" variant="ghost" size="sm" @click="startAdd">
163
+ {{ t('common.cancel') }}
164
+ </UButton>
165
+ <UButton color="primary" size="sm" :loading="busy" :disabled="!canSave" @click="save">
166
+ {{ editing ? t('common.save') : t('settings.infrastructure.customType.add') }}
167
+ </UButton>
168
+ </div>
169
+ </div>
170
+ </section>
171
+ </template>