@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
|
@@ -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
|
-
<!--
|
|
121
|
-
|
|
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>
|