@cat-factory/app 0.59.2 → 0.60.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,339 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The kube ENGINE connection form for a `kubernetes` provision-type handler — the "how"
|
|
3
|
+
// (apiserver + TLS + namespace + URL derivation), split from the service-owned manifest
|
|
4
|
+
// source (the "what/where", configured on the service in ServiceTestConfig). Serves both the
|
|
5
|
+
// `local-k3s` and `remote-kubernetes` engines (they share `kubernetesEngineConfigSchema`); the
|
|
6
|
+
// parent passes the selected engine and gets back the discriminated `{ engine, kubernetes }`
|
|
7
|
+
// config + the `apiToken` secret bundle. Distinct from KubernetesEnvironmentForm (the legacy
|
|
8
|
+
// single-connection backend, which still carries the manifest source inline).
|
|
9
|
+
import { computed, reactive, ref, watch } from 'vue'
|
|
10
|
+
import { KUBERNETES_ENV_TOKEN_SECRET_KEY } from '@cat-factory/contracts'
|
|
11
|
+
import type {
|
|
12
|
+
EnvironmentHandlerView,
|
|
13
|
+
InfraEngine,
|
|
14
|
+
InfraHandlerConfig,
|
|
15
|
+
} from '@cat-factory/contracts'
|
|
16
|
+
|
|
17
|
+
// The kube branch of the discriminated handler config this form produces (the `local-k3s` /
|
|
18
|
+
// `remote-kubernetes` engines share `kubernetesEngineConfigSchema`). Emitting this typed
|
|
19
|
+
// (rather than a bare `Record`) lets the parent pass it straight to registerHandler with no
|
|
20
|
+
// `as never` cast, so a wrong config shape is caught at the call site instead of server-side.
|
|
21
|
+
type KubeHandlerConfig = Extract<InfraHandlerConfig, { engine: 'local-k3s' | 'remote-kubernetes' }>
|
|
22
|
+
type KubeHandlerPayload = { config: KubeHandlerConfig; secrets: Record<string, string> }
|
|
23
|
+
|
|
24
|
+
const props = defineProps<{
|
|
25
|
+
/** `local-k3s` or `remote-kubernetes` — the engine this handler is registered under. */
|
|
26
|
+
engine: Extract<InfraEngine, 'local-k3s' | 'remote-kubernetes'>
|
|
27
|
+
/** The registered handler (if any) — prefills every non-secret field on edit. */
|
|
28
|
+
handler: EnvironmentHandlerView | null
|
|
29
|
+
supportsTest: boolean
|
|
30
|
+
testing: boolean
|
|
31
|
+
busy: boolean
|
|
32
|
+
testResult: { ok: boolean; message?: string } | null
|
|
33
|
+
}>()
|
|
34
|
+
|
|
35
|
+
const emit = defineEmits<{
|
|
36
|
+
test: [payload: KubeHandlerPayload]
|
|
37
|
+
save: [payload: KubeHandlerPayload]
|
|
38
|
+
}>()
|
|
39
|
+
|
|
40
|
+
const { t } = useI18n()
|
|
41
|
+
|
|
42
|
+
type UrlSource =
|
|
43
|
+
| 'ingressTemplate'
|
|
44
|
+
| 'ingressStatus'
|
|
45
|
+
| 'serviceStatus'
|
|
46
|
+
| 'gatewayStatus'
|
|
47
|
+
| 'httpRouteStatus'
|
|
48
|
+
|
|
49
|
+
const form = reactive({
|
|
50
|
+
label: '',
|
|
51
|
+
apiServerUrl: '',
|
|
52
|
+
caCertPem: '',
|
|
53
|
+
insecureSkipTlsVerify: false,
|
|
54
|
+
namespaceTemplate: '',
|
|
55
|
+
imageTemplate: '',
|
|
56
|
+
urlSource: 'ingressTemplate' as UrlSource,
|
|
57
|
+
hostTemplate: '',
|
|
58
|
+
ingressName: '',
|
|
59
|
+
serviceName: '',
|
|
60
|
+
servicePort: '',
|
|
61
|
+
gatewayName: '',
|
|
62
|
+
httpRouteName: '',
|
|
63
|
+
urlScheme: '' as '' | 'http' | 'https',
|
|
64
|
+
})
|
|
65
|
+
const apiToken = ref('')
|
|
66
|
+
|
|
67
|
+
const urlSourceItems = computed(() => [
|
|
68
|
+
{
|
|
69
|
+
label: t('settings.infrastructure.kubernetesEngine.urlIngressTemplate'),
|
|
70
|
+
value: 'ingressTemplate',
|
|
71
|
+
},
|
|
72
|
+
{ label: t('settings.infrastructure.kubernetesEngine.urlIngressStatus'), value: 'ingressStatus' },
|
|
73
|
+
{ label: t('settings.infrastructure.kubernetesEngine.urlServiceStatus'), value: 'serviceStatus' },
|
|
74
|
+
{ label: t('settings.infrastructure.kubernetesEngine.urlGatewayStatus'), value: 'gatewayStatus' },
|
|
75
|
+
{
|
|
76
|
+
label: t('settings.infrastructure.kubernetesEngine.urlHttpRouteStatus'),
|
|
77
|
+
value: 'httpRouteStatus',
|
|
78
|
+
},
|
|
79
|
+
])
|
|
80
|
+
const schemeItems = computed(() => [
|
|
81
|
+
{ label: t('settings.infrastructure.kubernetesEngine.schemeDefault'), value: '' },
|
|
82
|
+
{ label: 'https', value: 'https' },
|
|
83
|
+
{ label: 'http', value: 'http' },
|
|
84
|
+
])
|
|
85
|
+
|
|
86
|
+
// Prefill every non-secret field from a registered handler's stored config (never the token —
|
|
87
|
+
// secrets are write-only and re-entered on update), so an edit changes one field without
|
|
88
|
+
// re-typing the form.
|
|
89
|
+
watch(
|
|
90
|
+
() => props.handler,
|
|
91
|
+
(h) => {
|
|
92
|
+
const cfg = h?.config
|
|
93
|
+
if (!cfg || (cfg.engine !== 'local-k3s' && cfg.engine !== 'remote-kubernetes')) return
|
|
94
|
+
const k = cfg.kubernetes as Record<string, unknown>
|
|
95
|
+
form.label = typeof k.label === 'string' ? k.label : ''
|
|
96
|
+
form.apiServerUrl = typeof k.apiServerUrl === 'string' ? k.apiServerUrl : ''
|
|
97
|
+
form.caCertPem = typeof k.caCertPem === 'string' ? k.caCertPem : ''
|
|
98
|
+
form.insecureSkipTlsVerify = k.insecureSkipTlsVerify === true
|
|
99
|
+
form.namespaceTemplate = typeof k.namespaceTemplate === 'string' ? k.namespaceTemplate : ''
|
|
100
|
+
form.imageTemplate = typeof k.imageTemplate === 'string' ? k.imageTemplate : ''
|
|
101
|
+
const url = k.url as Record<string, unknown> | undefined
|
|
102
|
+
const src = typeof url?.source === 'string' ? (url.source as UrlSource) : 'ingressTemplate'
|
|
103
|
+
form.urlSource = src
|
|
104
|
+
form.hostTemplate = typeof url?.hostTemplate === 'string' ? url.hostTemplate : ''
|
|
105
|
+
form.ingressName = typeof url?.ingressName === 'string' ? url.ingressName : ''
|
|
106
|
+
form.serviceName = typeof url?.serviceName === 'string' ? url.serviceName : ''
|
|
107
|
+
form.servicePort = typeof url?.port === 'number' ? String(url.port) : ''
|
|
108
|
+
form.gatewayName = typeof url?.gatewayName === 'string' ? url.gatewayName : ''
|
|
109
|
+
form.httpRouteName = typeof url?.httpRouteName === 'string' ? url.httpRouteName : ''
|
|
110
|
+
if (url?.scheme === 'http' || url?.scheme === 'https') form.urlScheme = url.scheme
|
|
111
|
+
},
|
|
112
|
+
{ immediate: true },
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const servicePortValid = computed(() => {
|
|
116
|
+
const raw = form.servicePort.trim()
|
|
117
|
+
if (!raw) return true
|
|
118
|
+
const port = Number(raw)
|
|
119
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535
|
|
120
|
+
})
|
|
121
|
+
const urlValid = computed(() => {
|
|
122
|
+
if (form.urlSource === 'ingressTemplate') return !!form.hostTemplate.trim()
|
|
123
|
+
if (form.urlSource === 'serviceStatus') return !!form.serviceName.trim() && servicePortValid.value
|
|
124
|
+
return true // ingressStatus / gatewayStatus / httpRouteStatus have no required field
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const connected = computed(() => !!props.handler)
|
|
128
|
+
const canSave = computed(
|
|
129
|
+
() =>
|
|
130
|
+
!!form.label.trim() && !!form.apiServerUrl.trim() && !!apiToken.value.trim() && urlValid.value,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
function buildUrl(): Record<string, unknown> {
|
|
134
|
+
const url: Record<string, unknown> = { source: form.urlSource }
|
|
135
|
+
if (form.urlSource === 'ingressTemplate') {
|
|
136
|
+
url.hostTemplate = form.hostTemplate.trim()
|
|
137
|
+
} else if (form.urlSource === 'ingressStatus') {
|
|
138
|
+
if (form.ingressName.trim()) url.ingressName = form.ingressName.trim()
|
|
139
|
+
} else if (form.urlSource === 'serviceStatus') {
|
|
140
|
+
url.serviceName = form.serviceName.trim()
|
|
141
|
+
const port = Number(form.servicePort)
|
|
142
|
+
if (form.servicePort.trim() && Number.isInteger(port)) url.port = port
|
|
143
|
+
} else if (form.urlSource === 'gatewayStatus') {
|
|
144
|
+
if (form.gatewayName.trim()) url.gatewayName = form.gatewayName.trim()
|
|
145
|
+
} else {
|
|
146
|
+
if (form.httpRouteName.trim()) url.httpRouteName = form.httpRouteName.trim()
|
|
147
|
+
}
|
|
148
|
+
if (form.urlScheme) url.scheme = form.urlScheme
|
|
149
|
+
return url
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function buildPayload(): KubeHandlerPayload {
|
|
153
|
+
const kubernetes: Record<string, unknown> = {
|
|
154
|
+
label: form.label.trim(),
|
|
155
|
+
apiServerUrl: form.apiServerUrl.trim(),
|
|
156
|
+
url: buildUrl(),
|
|
157
|
+
}
|
|
158
|
+
if (form.caCertPem.trim()) kubernetes.caCertPem = form.caCertPem.trim()
|
|
159
|
+
if (form.insecureSkipTlsVerify) kubernetes.insecureSkipTlsVerify = true
|
|
160
|
+
if (form.namespaceTemplate.trim()) kubernetes.namespaceTemplate = form.namespaceTemplate.trim()
|
|
161
|
+
if (form.imageTemplate.trim()) kubernetes.imageTemplate = form.imageTemplate.trim()
|
|
162
|
+
// One honest assertion at the boundary that actually builds the shape (the reactive form is
|
|
163
|
+
// dynamically assembled, then validated server-side); the emitted config flows typed onward.
|
|
164
|
+
return {
|
|
165
|
+
config: { engine: props.engine, kubernetes } as unknown as KubeHandlerConfig,
|
|
166
|
+
secrets: { [KUBERNETES_ENV_TOKEN_SECRET_KEY]: apiToken.value.trim() },
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function optional(label: string): string {
|
|
171
|
+
return t('settings.providerConnection.form.optionalLabel', { label })
|
|
172
|
+
}
|
|
173
|
+
</script>
|
|
174
|
+
|
|
175
|
+
<template>
|
|
176
|
+
<div class="rounded-lg border border-dashed border-slate-700 p-3 space-y-3">
|
|
177
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
178
|
+
{{
|
|
179
|
+
connected
|
|
180
|
+
? t('settings.providerConnection.form.updateConfiguration')
|
|
181
|
+
: t('settings.providerConnection.form.connect')
|
|
182
|
+
}}
|
|
183
|
+
</p>
|
|
184
|
+
|
|
185
|
+
<UFormField :label="t('settings.infrastructure.kubernetesEngine.label')">
|
|
186
|
+
<UInput
|
|
187
|
+
v-model="form.label"
|
|
188
|
+
:placeholder="t('settings.infrastructure.kubernetesEngine.labelPlaceholder')"
|
|
189
|
+
/>
|
|
190
|
+
</UFormField>
|
|
191
|
+
|
|
192
|
+
<UFormField
|
|
193
|
+
:label="t('settings.infrastructure.kubernetesEngine.apiServerUrl')"
|
|
194
|
+
:help="t('settings.infrastructure.kubernetesEngine.apiServerUrlHelp')"
|
|
195
|
+
>
|
|
196
|
+
<UInput v-model="form.apiServerUrl" class="font-mono" placeholder="https://10.0.0.1:6443" />
|
|
197
|
+
</UFormField>
|
|
198
|
+
|
|
199
|
+
<UFormField
|
|
200
|
+
:label="t('settings.infrastructure.kubernetesEngine.apiToken')"
|
|
201
|
+
:help="t('settings.infrastructure.kubernetesEngine.apiTokenHelp')"
|
|
202
|
+
>
|
|
203
|
+
<UInput v-model="apiToken" type="password" class="font-mono" autocomplete="off" />
|
|
204
|
+
</UFormField>
|
|
205
|
+
|
|
206
|
+
<!-- URL derivation: how the live environment URL is resolved once the service's
|
|
207
|
+
manifests are applied. -->
|
|
208
|
+
<UFormField :label="t('settings.infrastructure.kubernetesEngine.urlSourceLabel')">
|
|
209
|
+
<USelect v-model="form.urlSource" :items="urlSourceItems" />
|
|
210
|
+
</UFormField>
|
|
211
|
+
|
|
212
|
+
<UFormField
|
|
213
|
+
v-if="form.urlSource === 'ingressTemplate'"
|
|
214
|
+
:label="t('settings.infrastructure.kubernetesEngine.hostTemplate')"
|
|
215
|
+
:help="t('settings.infrastructure.kubernetesEngine.hostTemplateHelp')"
|
|
216
|
+
>
|
|
217
|
+
<UInput
|
|
218
|
+
v-model="form.hostTemplate"
|
|
219
|
+
class="font-mono"
|
|
220
|
+
placeholder="{{branch}}.preview.example.com"
|
|
221
|
+
/>
|
|
222
|
+
</UFormField>
|
|
223
|
+
|
|
224
|
+
<UFormField
|
|
225
|
+
v-if="form.urlSource === 'ingressStatus'"
|
|
226
|
+
:label="optional(t('settings.infrastructure.kubernetesEngine.ingressName'))"
|
|
227
|
+
>
|
|
228
|
+
<UInput v-model="form.ingressName" class="font-mono" />
|
|
229
|
+
</UFormField>
|
|
230
|
+
|
|
231
|
+
<UFormField
|
|
232
|
+
v-if="form.urlSource === 'serviceStatus'"
|
|
233
|
+
:label="t('settings.infrastructure.kubernetesEngine.serviceName')"
|
|
234
|
+
>
|
|
235
|
+
<UInput v-model="form.serviceName" class="font-mono" />
|
|
236
|
+
</UFormField>
|
|
237
|
+
<UFormField
|
|
238
|
+
v-if="form.urlSource === 'serviceStatus'"
|
|
239
|
+
:label="optional(t('settings.infrastructure.kubernetesEngine.port'))"
|
|
240
|
+
>
|
|
241
|
+
<UInput
|
|
242
|
+
v-model="form.servicePort"
|
|
243
|
+
type="number"
|
|
244
|
+
:min="1"
|
|
245
|
+
:max="65535"
|
|
246
|
+
class="font-mono"
|
|
247
|
+
placeholder="80"
|
|
248
|
+
/>
|
|
249
|
+
</UFormField>
|
|
250
|
+
|
|
251
|
+
<UFormField
|
|
252
|
+
v-if="form.urlSource === 'gatewayStatus'"
|
|
253
|
+
:label="optional(t('settings.infrastructure.kubernetesEngine.gatewayName'))"
|
|
254
|
+
>
|
|
255
|
+
<UInput v-model="form.gatewayName" class="font-mono" />
|
|
256
|
+
</UFormField>
|
|
257
|
+
|
|
258
|
+
<UFormField
|
|
259
|
+
v-if="form.urlSource === 'httpRouteStatus'"
|
|
260
|
+
:label="optional(t('settings.infrastructure.kubernetesEngine.httpRouteName'))"
|
|
261
|
+
>
|
|
262
|
+
<UInput v-model="form.httpRouteName" class="font-mono" />
|
|
263
|
+
</UFormField>
|
|
264
|
+
|
|
265
|
+
<UFormField :label="optional(t('settings.infrastructure.kubernetesEngine.scheme'))">
|
|
266
|
+
<USelect v-model="form.urlScheme" :items="schemeItems" />
|
|
267
|
+
</UFormField>
|
|
268
|
+
|
|
269
|
+
<!-- Optional refinements. -->
|
|
270
|
+
<UFormField
|
|
271
|
+
:label="optional(t('settings.infrastructure.kubernetesEngine.namespaceTemplate'))"
|
|
272
|
+
:help="t('settings.infrastructure.kubernetesEngine.namespaceTemplateHelp')"
|
|
273
|
+
>
|
|
274
|
+
<UInput
|
|
275
|
+
v-model="form.namespaceTemplate"
|
|
276
|
+
class="font-mono"
|
|
277
|
+
placeholder="cf-env-{{pullNumber}}"
|
|
278
|
+
/>
|
|
279
|
+
</UFormField>
|
|
280
|
+
|
|
281
|
+
<UFormField
|
|
282
|
+
:label="optional(t('settings.infrastructure.kubernetesEngine.imageTemplate'))"
|
|
283
|
+
:help="t('settings.infrastructure.kubernetesEngine.imageTemplateHelp')"
|
|
284
|
+
>
|
|
285
|
+
<UInput v-model="form.imageTemplate" class="font-mono" />
|
|
286
|
+
</UFormField>
|
|
287
|
+
|
|
288
|
+
<UFormField
|
|
289
|
+
:label="optional(t('settings.infrastructure.kubernetesEngine.caCertPem'))"
|
|
290
|
+
:help="t('settings.infrastructure.kubernetesEngine.caCertPemHelp')"
|
|
291
|
+
>
|
|
292
|
+
<UTextarea
|
|
293
|
+
v-model="form.caCertPem"
|
|
294
|
+
:rows="3"
|
|
295
|
+
class="font-mono"
|
|
296
|
+
placeholder="-----BEGIN CERTIFICATE-----"
|
|
297
|
+
/>
|
|
298
|
+
</UFormField>
|
|
299
|
+
|
|
300
|
+
<UFormField :help="t('settings.infrastructure.kubernetesEngine.insecureSkipTlsVerifyHelp')">
|
|
301
|
+
<UCheckbox
|
|
302
|
+
v-model="form.insecureSkipTlsVerify"
|
|
303
|
+
:label="t('settings.infrastructure.kubernetesEngine.insecureSkipTlsVerify')"
|
|
304
|
+
/>
|
|
305
|
+
</UFormField>
|
|
306
|
+
|
|
307
|
+
<div v-if="supportsTest" class="flex items-center gap-2">
|
|
308
|
+
<UButton
|
|
309
|
+
color="neutral"
|
|
310
|
+
variant="soft"
|
|
311
|
+
size="sm"
|
|
312
|
+
icon="i-lucide-plug-zap"
|
|
313
|
+
:loading="testing"
|
|
314
|
+
:disabled="!canSave"
|
|
315
|
+
@click="emit('test', buildPayload())"
|
|
316
|
+
>
|
|
317
|
+
{{ t('settings.providerConnection.test.button') }}
|
|
318
|
+
</UButton>
|
|
319
|
+
<span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
|
|
320
|
+
{{ testResult.message ?? t('settings.providerConnection.test.ok') }}
|
|
321
|
+
</span>
|
|
322
|
+
<span v-else-if="testResult" class="text-xs text-rose-400">
|
|
323
|
+
{{ testResult.message ?? t('settings.providerConnection.test.failed') }}
|
|
324
|
+
</span>
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
<div class="flex justify-end">
|
|
328
|
+
<UButton
|
|
329
|
+
color="primary"
|
|
330
|
+
size="sm"
|
|
331
|
+
:loading="busy"
|
|
332
|
+
:disabled="!canSave"
|
|
333
|
+
@click="emit('save', buildPayload())"
|
|
334
|
+
>
|
|
335
|
+
{{ connected ? t('common.save') : t('settings.providerConnection.form.connect') }}
|
|
336
|
+
</UButton>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
</template>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {
|
|
2
|
+
listEnvironmentHandlersContract,
|
|
3
|
+
listEnvironmentUserHandlersContract,
|
|
4
|
+
registerEnvironmentHandlerContract,
|
|
5
|
+
removeCustomManifestTypeContract,
|
|
6
|
+
removeEnvironmentUserHandlerContract,
|
|
7
|
+
unregisterEnvironmentHandlerContract,
|
|
8
|
+
upsertCustomManifestTypeContract,
|
|
9
|
+
upsertEnvironmentUserHandlerContract,
|
|
10
|
+
} from '@cat-factory/contracts'
|
|
11
|
+
import type {
|
|
12
|
+
ProvisionType,
|
|
13
|
+
RegisterEnvironmentHandlerInput,
|
|
14
|
+
UpsertCustomManifestTypeInput,
|
|
15
|
+
UpsertEnvironmentUserHandlerBody,
|
|
16
|
+
} from '@cat-factory/contracts'
|
|
17
|
+
import type { ApiContext } from './context'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Per-provision-type infra HANDLER config (the workspace + per-user "how"): the batched
|
|
21
|
+
* handler bundle (every workspace handler + the custom-manifest-type catalog),
|
|
22
|
+
* register/remove for a workspace handler, custom-type CRUD, and — local mode only —
|
|
23
|
+
* the per-user override handlers (mounted at `/me/...`, so user-scoped, no `/workspaces`
|
|
24
|
+
* prefix; these 503 off the local facade). See EnvironmentController +
|
|
25
|
+
* EnvironmentUserHandlerController in @cat-factory/server.
|
|
26
|
+
*/
|
|
27
|
+
export function infraHandlersApi({ send, ws }: ApiContext) {
|
|
28
|
+
return {
|
|
29
|
+
// ---- Workspace per-type handlers + custom-type catalog ------------------
|
|
30
|
+
listEnvironmentHandlers: (workspaceId: string) =>
|
|
31
|
+
send(listEnvironmentHandlersContract, { pathPrefix: ws(workspaceId) }),
|
|
32
|
+
|
|
33
|
+
registerEnvironmentHandler: (workspaceId: string, body: RegisterEnvironmentHandlerInput) =>
|
|
34
|
+
send(registerEnvironmentHandlerContract, { pathPrefix: ws(workspaceId), body }),
|
|
35
|
+
|
|
36
|
+
// `manifestId` (for a `custom` handler) rides as a query param; absent ⇒ the bare handler.
|
|
37
|
+
unregisterEnvironmentHandler: (
|
|
38
|
+
workspaceId: string,
|
|
39
|
+
provisionType: ProvisionType,
|
|
40
|
+
manifestId?: string,
|
|
41
|
+
) =>
|
|
42
|
+
send(unregisterEnvironmentHandlerContract, {
|
|
43
|
+
pathPrefix: ws(workspaceId),
|
|
44
|
+
pathParams: { provisionType },
|
|
45
|
+
queryParams: { manifestId },
|
|
46
|
+
}),
|
|
47
|
+
|
|
48
|
+
upsertCustomManifestType: (
|
|
49
|
+
workspaceId: string,
|
|
50
|
+
manifestId: string,
|
|
51
|
+
body: UpsertCustomManifestTypeInput,
|
|
52
|
+
) =>
|
|
53
|
+
send(upsertCustomManifestTypeContract, {
|
|
54
|
+
pathPrefix: ws(workspaceId),
|
|
55
|
+
pathParams: { manifestId },
|
|
56
|
+
body,
|
|
57
|
+
}),
|
|
58
|
+
|
|
59
|
+
removeCustomManifestType: (workspaceId: string, manifestId: string) =>
|
|
60
|
+
send(removeCustomManifestTypeContract, {
|
|
61
|
+
pathPrefix: ws(workspaceId),
|
|
62
|
+
pathParams: { manifestId },
|
|
63
|
+
}),
|
|
64
|
+
|
|
65
|
+
// ---- Per-USER override handlers (local mode; `/me/...`, no ws prefix) ----
|
|
66
|
+
listEnvironmentUserHandlers: (workspaceId: string) =>
|
|
67
|
+
send(listEnvironmentUserHandlersContract, { pathParams: { workspaceId } }),
|
|
68
|
+
|
|
69
|
+
upsertEnvironmentUserHandler: (
|
|
70
|
+
workspaceId: string,
|
|
71
|
+
provisionType: ProvisionType,
|
|
72
|
+
body: UpsertEnvironmentUserHandlerBody,
|
|
73
|
+
) =>
|
|
74
|
+
send(upsertEnvironmentUserHandlerContract, {
|
|
75
|
+
pathParams: { workspaceId, provisionType },
|
|
76
|
+
body,
|
|
77
|
+
}),
|
|
78
|
+
|
|
79
|
+
removeEnvironmentUserHandler: (
|
|
80
|
+
workspaceId: string,
|
|
81
|
+
provisionType: ProvisionType,
|
|
82
|
+
manifestId?: string,
|
|
83
|
+
) =>
|
|
84
|
+
send(removeEnvironmentUserHandlerContract, {
|
|
85
|
+
pathParams: { workspaceId, provisionType },
|
|
86
|
+
queryParams: { manifestId },
|
|
87
|
+
}),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -12,6 +12,7 @@ import { fragmentsApi } from './api/fragments'
|
|
|
12
12
|
import { githubApi } from './api/github'
|
|
13
13
|
import { humanReviewApi } from './api/humanReview'
|
|
14
14
|
import { humanTestApi } from './api/humanTest'
|
|
15
|
+
import { infraHandlersApi } from './api/infraHandlers'
|
|
15
16
|
import { visualConfirmApi } from './api/visualConfirm'
|
|
16
17
|
import { kaizenApi } from './api/kaizen'
|
|
17
18
|
import { localSettingsApi } from './api/localSettings'
|
|
@@ -107,6 +108,7 @@ export function useApi() {
|
|
|
107
108
|
...notificationsApi(ctx),
|
|
108
109
|
...presetsApi(ctx),
|
|
109
110
|
...providerConnectionsApi(ctx),
|
|
111
|
+
...infraHandlersApi(ctx),
|
|
110
112
|
...provisioningLogsApi(ctx),
|
|
111
113
|
...releaseHealthApi(ctx),
|
|
112
114
|
...recurringApi(ctx),
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref, type Ref } from 'vue'
|
|
3
|
+
import type {
|
|
4
|
+
CustomManifestType,
|
|
5
|
+
EnvironmentHandlerView,
|
|
6
|
+
ProvisionType,
|
|
7
|
+
RegisterEnvironmentHandlerInput,
|
|
8
|
+
UpsertCustomManifestTypeInput,
|
|
9
|
+
UpsertEnvironmentUserHandlerBody,
|
|
10
|
+
} from '@cat-factory/contracts'
|
|
11
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
12
|
+
|
|
13
|
+
// One predicate for "this handler is the (type, manifestId) one" — custom handlers are keyed by
|
|
14
|
+
// manifestId, the rest by type alone (manifestId null). Defined once and reused across both the
|
|
15
|
+
// workspace and per-user handler sets so the match key lives in a single place.
|
|
16
|
+
const sameHandler = (h: EnvironmentHandlerView, type: ProvisionType, manifestId?: string | null) =>
|
|
17
|
+
h.provisionType === type && (h.manifestId ?? null) === (manifestId ?? null)
|
|
18
|
+
|
|
19
|
+
// Replace the matching entry in a handler-list ref, or append it.
|
|
20
|
+
function upsertInto(list: Ref<EnvironmentHandlerView[]>, saved: EnvironmentHandlerView) {
|
|
21
|
+
const idx = list.value.findIndex((h) => sameHandler(h, saved.provisionType, saved.manifestId))
|
|
22
|
+
if (idx >= 0) list.value[idx] = saved
|
|
23
|
+
else list.value.push(saved)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The per-provision-type infra handlers (the workspace + per-user "how"): for each provision
|
|
28
|
+
* type a service can declare, which engine + connection the workspace stands its environment
|
|
29
|
+
* up with, plus the open custom-manifest-type catalog. Loaded on demand (the Infrastructure
|
|
30
|
+
* window's per-type configurator + the service inspector's custom-type picker), not from the
|
|
31
|
+
* snapshot, since the secret bundles never leave the server.
|
|
32
|
+
*
|
|
33
|
+
* The per-USER override handlers (`userHandlers`) are local mode only — the backend service is
|
|
34
|
+
* wired solely by the local facade, so the endpoints 503 elsewhere and `userOverridesAvailable`
|
|
35
|
+
* stays false (the override affordance hides).
|
|
36
|
+
*/
|
|
37
|
+
export const useInfraConfigStore = defineStore('infraConfig', () => {
|
|
38
|
+
const api = useApi()
|
|
39
|
+
|
|
40
|
+
const handlers = ref<EnvironmentHandlerView[]>([])
|
|
41
|
+
const customTypes = ref<CustomManifestType[]>([])
|
|
42
|
+
const userHandlers = ref<EnvironmentHandlerView[]>([])
|
|
43
|
+
const loading = ref(false)
|
|
44
|
+
// `null` until first probed; `false` ⇒ the test-env handler integration is off (503),
|
|
45
|
+
// so the configurator hides. Mirrors the other infra stores' availability gate.
|
|
46
|
+
const available = ref<boolean | null>(null)
|
|
47
|
+
// Per-user overrides probe independently (local mode only).
|
|
48
|
+
const userOverridesAvailable = ref<boolean | null>(null)
|
|
49
|
+
let inFlight: Promise<void> | null = null
|
|
50
|
+
|
|
51
|
+
/** Force a refresh of the workspace handler bundle (used after a save/remove). */
|
|
52
|
+
async function load() {
|
|
53
|
+
const ws = useWorkspaceStore()
|
|
54
|
+
loading.value = true
|
|
55
|
+
try {
|
|
56
|
+
const bundle = await api.listEnvironmentHandlers(ws.requireId())
|
|
57
|
+
handlers.value = bundle.handlers
|
|
58
|
+
customTypes.value = bundle.customTypes
|
|
59
|
+
available.value = true
|
|
60
|
+
} catch {
|
|
61
|
+
// 503 (environments integration off) or any error → hide the configurator.
|
|
62
|
+
available.value = false
|
|
63
|
+
handlers.value = []
|
|
64
|
+
customTypes.value = []
|
|
65
|
+
} finally {
|
|
66
|
+
loading.value = false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Load once and share the result: repeated window opens / inspector mounts reuse the
|
|
72
|
+
* resolved state (and coalesce a concurrent in-flight request). Use `load()` to force a
|
|
73
|
+
* refresh. The custom-type catalog feeds the service inspector's `custom` picker, so this
|
|
74
|
+
* is also called there (cheaply) to populate it.
|
|
75
|
+
*/
|
|
76
|
+
async function ensureLoaded() {
|
|
77
|
+
if (available.value !== null) return
|
|
78
|
+
if (!inFlight) inFlight = load().finally(() => (inFlight = null))
|
|
79
|
+
return inFlight
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The workspace handler matching a provision type (+ manifest id, for `custom`), if registered. */
|
|
83
|
+
function handlerFor(
|
|
84
|
+
type: ProvisionType,
|
|
85
|
+
manifestId?: string | null,
|
|
86
|
+
): EnvironmentHandlerView | undefined {
|
|
87
|
+
return handlers.value.find((h) => sameHandler(h, type, manifestId))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function registerHandler(input: RegisterEnvironmentHandlerInput) {
|
|
91
|
+
const ws = useWorkspaceStore()
|
|
92
|
+
const saved = await api.registerEnvironmentHandler(ws.requireId(), input)
|
|
93
|
+
upsertInto(handlers, saved)
|
|
94
|
+
return saved
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function unregisterHandler(type: ProvisionType, manifestId?: string | null) {
|
|
98
|
+
const ws = useWorkspaceStore()
|
|
99
|
+
await api.unregisterEnvironmentHandler(ws.requireId(), type, manifestId ?? undefined)
|
|
100
|
+
handlers.value = handlers.value.filter((h) => !sameHandler(h, type, manifestId))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---- Custom-manifest-type catalog CRUD (workspace-defined entries only) ----
|
|
104
|
+
async function upsertCustomType(manifestId: string, input: UpsertCustomManifestTypeInput) {
|
|
105
|
+
const ws = useWorkspaceStore()
|
|
106
|
+
const saved = await api.upsertCustomManifestType(ws.requireId(), manifestId, input)
|
|
107
|
+
const idx = customTypes.value.findIndex((t) => t.manifestId === saved.manifestId)
|
|
108
|
+
if (idx >= 0) customTypes.value[idx] = saved
|
|
109
|
+
else customTypes.value.push(saved)
|
|
110
|
+
return saved
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function removeCustomType(manifestId: string) {
|
|
114
|
+
const ws = useWorkspaceStore()
|
|
115
|
+
await api.removeCustomManifestType(ws.requireId(), manifestId)
|
|
116
|
+
customTypes.value = customTypes.value.filter((t) => t.manifestId !== manifestId)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- Per-user override handlers (local mode) ------------------------------
|
|
120
|
+
async function loadUserHandlers() {
|
|
121
|
+
const ws = useWorkspaceStore()
|
|
122
|
+
try {
|
|
123
|
+
const { handlers: list } = await api.listEnvironmentUserHandlers(ws.requireId())
|
|
124
|
+
userHandlers.value = list
|
|
125
|
+
userOverridesAvailable.value = true
|
|
126
|
+
} catch {
|
|
127
|
+
// 503 (not the local facade) / not signed in → no per-user overrides surface.
|
|
128
|
+
userOverridesAvailable.value = false
|
|
129
|
+
userHandlers.value = []
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function userHandlerFor(
|
|
134
|
+
type: ProvisionType,
|
|
135
|
+
manifestId?: string | null,
|
|
136
|
+
): EnvironmentHandlerView | undefined {
|
|
137
|
+
return userHandlers.value.find((h) => sameHandler(h, type, manifestId))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function upsertUserHandler(type: ProvisionType, body: UpsertEnvironmentUserHandlerBody) {
|
|
141
|
+
const ws = useWorkspaceStore()
|
|
142
|
+
const saved = await api.upsertEnvironmentUserHandler(ws.requireId(), type, body)
|
|
143
|
+
upsertInto(userHandlers, saved)
|
|
144
|
+
return saved
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function removeUserHandler(type: ProvisionType, manifestId?: string | null) {
|
|
148
|
+
const ws = useWorkspaceStore()
|
|
149
|
+
await api.removeEnvironmentUserHandler(ws.requireId(), type, manifestId ?? undefined)
|
|
150
|
+
userHandlers.value = userHandlers.value.filter((h) => !sameHandler(h, type, manifestId))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
handlers,
|
|
155
|
+
customTypes,
|
|
156
|
+
userHandlers,
|
|
157
|
+
loading,
|
|
158
|
+
available,
|
|
159
|
+
userOverridesAvailable,
|
|
160
|
+
load,
|
|
161
|
+
ensureLoaded,
|
|
162
|
+
handlerFor,
|
|
163
|
+
registerHandler,
|
|
164
|
+
unregisterHandler,
|
|
165
|
+
upsertCustomType,
|
|
166
|
+
removeCustomType,
|
|
167
|
+
loadUserHandlers,
|
|
168
|
+
userHandlerFor,
|
|
169
|
+
upsertUserHandler,
|
|
170
|
+
removeUserHandler,
|
|
171
|
+
}
|
|
172
|
+
})
|