@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.
- package/app/components/environments/EnvironmentStatusPanel.vue +40 -1
- package/app/components/panels/inspector/ServiceTestConfig.vue +312 -46
- package/app/components/settings/CustomManifestTypeEditor.vue +171 -0
- package/app/components/settings/InfraHandlersConfigurator.vue +393 -0
- package/app/components/settings/InfrastructureWindow.vue +5 -2
- package/app/components/settings/KubernetesEngineForm.vue +339 -0
- package/app/composables/api/infraHandlers.ts +89 -0
- package/app/composables/useApi.ts +2 -0
- package/app/stores/infraConfig.ts +172 -0
- package/app/types/domain.ts +1 -0
- package/i18n/locales/en.json +110 -2
- package/i18n/locales/es.json +110 -2
- package/i18n/locales/fr.json +110 -2
- package/i18n/locales/he.json +110 -2
- package/i18n/locales/ja.json +110 -2
- package/i18n/locales/pl.json +110 -2
- package/i18n/locales/tr.json +110 -2
- package/i18n/locales/uk.json +110 -2
- package/package.json +2 -2
|
@@ -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 {
|
|
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
|
|
9
|
-
//
|
|
10
|
-
// configures HOW each type is handled (the engine +
|
|
11
|
-
// the "what + where". Autodiscovery suggests a compose path
|
|
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
|
|
27
|
-
// and back
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
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
|
-
|
|
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)
|
|
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-
|
|
138
|
-
<
|
|
139
|
-
|
|
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="
|
|
409
|
+
:model-value="customManifestPath"
|
|
142
410
|
size="xs"
|
|
143
|
-
class="
|
|
144
|
-
|
|
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) =>
|
|
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
|
|
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
|
-
{{
|
|
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>
|