@cat-factory/app 0.49.1 → 0.50.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/layout/IntegrationsHub.vue +3 -66
- package/app/components/layout/SideBar.vue +39 -0
- package/app/components/settings/ExecutionBackendSelector.vue +171 -0
- package/app/components/settings/InfrastructureWindow.vue +36 -126
- package/app/components/settings/KubernetesEnvironmentForm.vue +383 -0
- package/app/components/settings/LocalContainerPoolSettings.vue +181 -0
- package/app/components/settings/ProviderConnectionTab.vue +67 -30
- package/app/composables/api/providerConnections.ts +15 -10
- package/app/pages/index.vue +0 -4
- package/app/stores/auth.ts +10 -1
- package/app/stores/ui.ts +14 -18
- package/i18n/locales/en.json +61 -23
- package/i18n/locales/es.json +64 -26
- package/i18n/locales/fr.json +64 -26
- package/i18n/locales/pl.json +64 -26
- package/i18n/locales/uk.json +64 -26
- package/package.json +2 -2
- package/app/components/settings/LocalModeSettingsPanel.vue +0 -195
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The Kubernetes "ephemeral environment backend" connect form — one option of the
|
|
3
|
+
// environment tab's backend-type selector (the other being the BYO HTTP manifest editor).
|
|
4
|
+
// It builds the discriminated `{ kind: 'kubernetes', kubernetes }` config plus the
|
|
5
|
+
// `apiToken` secret bundle and emits test/save to the parent tab (which calls the shared
|
|
6
|
+
// provider-connections store). The K8s env config differs from the runner K8s config
|
|
7
|
+
// (manifest source + URL derivation vs an executor image + namespace), so it has its own
|
|
8
|
+
// form rather than reusing KubernetesRunnerForm.
|
|
9
|
+
import { computed, reactive, ref, watch } from 'vue'
|
|
10
|
+
import { KUBERNETES_ENV_TOKEN_SECRET_KEY } from '@cat-factory/contracts'
|
|
11
|
+
import type { ProviderConnection } from '~/types/providerConnections'
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
connection: ProviderConnection | null
|
|
15
|
+
supportsTest: boolean
|
|
16
|
+
testing: boolean
|
|
17
|
+
busy: boolean
|
|
18
|
+
testResult: { ok: boolean; message?: string } | null
|
|
19
|
+
}>()
|
|
20
|
+
|
|
21
|
+
const emit = defineEmits<{
|
|
22
|
+
test: [payload: { config: Record<string, unknown>; secrets: Record<string, string> }]
|
|
23
|
+
save: [payload: { config: Record<string, unknown>; secrets: Record<string, string> }]
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const { t } = useI18n()
|
|
27
|
+
|
|
28
|
+
const form = reactive({
|
|
29
|
+
label: '',
|
|
30
|
+
apiServerUrl: '',
|
|
31
|
+
caCertPem: '',
|
|
32
|
+
insecureSkipTlsVerify: false,
|
|
33
|
+
namespaceTemplate: '',
|
|
34
|
+
imageTemplate: '',
|
|
35
|
+
// manifest source
|
|
36
|
+
manifestSourceType: 'colocated' as 'colocated' | 'separate',
|
|
37
|
+
manifestPath: '',
|
|
38
|
+
manifestRepo: '',
|
|
39
|
+
manifestRef: '',
|
|
40
|
+
// url derivation
|
|
41
|
+
urlSource: 'ingressTemplate' as 'ingressTemplate' | 'ingressStatus' | 'serviceStatus',
|
|
42
|
+
hostTemplate: '',
|
|
43
|
+
ingressName: '',
|
|
44
|
+
serviceName: '',
|
|
45
|
+
servicePort: '',
|
|
46
|
+
urlScheme: '' as '' | 'http' | 'https',
|
|
47
|
+
})
|
|
48
|
+
const apiToken = ref('')
|
|
49
|
+
|
|
50
|
+
const manifestSourceItems = computed(() => [
|
|
51
|
+
{ label: t('settings.providerConnection.kubernetesEnv.sourceColocated'), value: 'colocated' },
|
|
52
|
+
{ label: t('settings.providerConnection.kubernetesEnv.sourceSeparate'), value: 'separate' },
|
|
53
|
+
])
|
|
54
|
+
const urlSourceItems = computed(() => [
|
|
55
|
+
{
|
|
56
|
+
label: t('settings.providerConnection.kubernetesEnv.urlIngressTemplate'),
|
|
57
|
+
value: 'ingressTemplate',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
label: t('settings.providerConnection.kubernetesEnv.urlIngressStatus'),
|
|
61
|
+
value: 'ingressStatus',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
label: t('settings.providerConnection.kubernetesEnv.urlServiceStatus'),
|
|
65
|
+
value: 'serviceStatus',
|
|
66
|
+
},
|
|
67
|
+
])
|
|
68
|
+
const schemeItems = computed(() => [
|
|
69
|
+
{ label: t('settings.providerConnection.kubernetesEnv.schemeDefault'), value: '' },
|
|
70
|
+
{ label: 'https', value: 'https' },
|
|
71
|
+
{ label: 'http', value: 'http' },
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
// A registered k8s env connection exposes its non-secret config, so prefill every
|
|
75
|
+
// non-secret field from it (never the token — secrets are write-only and re-entered on
|
|
76
|
+
// update). This lets an edit change one field without re-typing the whole form.
|
|
77
|
+
watch(
|
|
78
|
+
() => props.connection,
|
|
79
|
+
(c) => {
|
|
80
|
+
if (c?.kind !== 'kubernetes') return
|
|
81
|
+
const k =
|
|
82
|
+
c.config && (c.config as { kind?: string }).kind === 'kubernetes'
|
|
83
|
+
? (c.config as { kubernetes: Record<string, unknown> }).kubernetes
|
|
84
|
+
: undefined
|
|
85
|
+
if (!k) return
|
|
86
|
+
form.label = typeof k.label === 'string' ? k.label : ''
|
|
87
|
+
form.apiServerUrl = typeof k.apiServerUrl === 'string' ? k.apiServerUrl : ''
|
|
88
|
+
form.caCertPem = typeof k.caCertPem === 'string' ? k.caCertPem : ''
|
|
89
|
+
form.insecureSkipTlsVerify = k.insecureSkipTlsVerify === true
|
|
90
|
+
form.namespaceTemplate = typeof k.namespaceTemplate === 'string' ? k.namespaceTemplate : ''
|
|
91
|
+
form.imageTemplate = typeof k.imageTemplate === 'string' ? k.imageTemplate : ''
|
|
92
|
+
const src = k.manifestSource as Record<string, unknown> | undefined
|
|
93
|
+
if (src?.type === 'separate') {
|
|
94
|
+
form.manifestSourceType = 'separate'
|
|
95
|
+
form.manifestRepo = typeof src.repo === 'string' ? src.repo : ''
|
|
96
|
+
form.manifestRef = typeof src.ref === 'string' ? src.ref : ''
|
|
97
|
+
form.manifestPath = typeof src.path === 'string' ? src.path : ''
|
|
98
|
+
} else if (src?.type === 'colocated') {
|
|
99
|
+
form.manifestSourceType = 'colocated'
|
|
100
|
+
form.manifestPath = typeof src.path === 'string' ? src.path : ''
|
|
101
|
+
}
|
|
102
|
+
const url = k.url as Record<string, unknown> | undefined
|
|
103
|
+
if (url?.source === 'ingressTemplate') {
|
|
104
|
+
form.urlSource = 'ingressTemplate'
|
|
105
|
+
form.hostTemplate = typeof url.hostTemplate === 'string' ? url.hostTemplate : ''
|
|
106
|
+
} else if (url?.source === 'ingressStatus') {
|
|
107
|
+
form.urlSource = 'ingressStatus'
|
|
108
|
+
form.ingressName = typeof url.ingressName === 'string' ? url.ingressName : ''
|
|
109
|
+
} else if (url?.source === 'serviceStatus') {
|
|
110
|
+
form.urlSource = 'serviceStatus'
|
|
111
|
+
form.serviceName = typeof url.serviceName === 'string' ? url.serviceName : ''
|
|
112
|
+
form.servicePort = typeof url.port === 'number' ? String(url.port) : ''
|
|
113
|
+
}
|
|
114
|
+
if (url && (url.scheme === 'http' || url.scheme === 'https')) form.urlScheme = url.scheme
|
|
115
|
+
},
|
|
116
|
+
{ immediate: true },
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// Mirror kubernetesManifestSourceSchema's `owner/repo` regex so a slashless value is
|
|
120
|
+
// caught here with a field hint instead of a generic 422 from the backend.
|
|
121
|
+
const repoShapeValid = computed(() => /^[^/\s]+\/[^/\s]+$/.test(form.manifestRepo.trim()))
|
|
122
|
+
const manifestSourceValid = computed(() =>
|
|
123
|
+
form.manifestSourceType === 'separate'
|
|
124
|
+
? repoShapeValid.value && !!form.manifestPath.trim()
|
|
125
|
+
: !!form.manifestPath.trim(),
|
|
126
|
+
)
|
|
127
|
+
// serviceStatus.port is an optional integer 1..65535 (kubernetesUrlSourceSchema). Validate
|
|
128
|
+
// it here so a decimal isn't silently dropped and an out-of-range value isn't sent then 422'd.
|
|
129
|
+
const servicePortValid = computed(() => {
|
|
130
|
+
const raw = form.servicePort.trim()
|
|
131
|
+
if (!raw) return true
|
|
132
|
+
const port = Number(raw)
|
|
133
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535
|
|
134
|
+
})
|
|
135
|
+
const urlValid = computed(() => {
|
|
136
|
+
if (form.urlSource === 'ingressTemplate') return !!form.hostTemplate.trim()
|
|
137
|
+
if (form.urlSource === 'serviceStatus') return !!form.serviceName.trim() && servicePortValid.value
|
|
138
|
+
return true // ingressStatus has no required field
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const canSave = computed(
|
|
142
|
+
() =>
|
|
143
|
+
!!form.label.trim() &&
|
|
144
|
+
!!form.apiServerUrl.trim() &&
|
|
145
|
+
!!apiToken.value.trim() &&
|
|
146
|
+
manifestSourceValid.value &&
|
|
147
|
+
urlValid.value,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
function buildManifestSource(): Record<string, unknown> {
|
|
151
|
+
if (form.manifestSourceType === 'separate') {
|
|
152
|
+
const src: Record<string, unknown> = {
|
|
153
|
+
type: 'separate',
|
|
154
|
+
repo: form.manifestRepo.trim(),
|
|
155
|
+
path: form.manifestPath.trim(),
|
|
156
|
+
}
|
|
157
|
+
if (form.manifestRef.trim()) src.ref = form.manifestRef.trim()
|
|
158
|
+
return src
|
|
159
|
+
}
|
|
160
|
+
return { type: 'colocated', path: form.manifestPath.trim() }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function buildUrl(): Record<string, unknown> {
|
|
164
|
+
const url: Record<string, unknown> = { source: form.urlSource }
|
|
165
|
+
if (form.urlSource === 'ingressTemplate') {
|
|
166
|
+
url.hostTemplate = form.hostTemplate.trim()
|
|
167
|
+
} else if (form.urlSource === 'ingressStatus') {
|
|
168
|
+
if (form.ingressName.trim()) url.ingressName = form.ingressName.trim()
|
|
169
|
+
} else {
|
|
170
|
+
url.serviceName = form.serviceName.trim()
|
|
171
|
+
const port = Number(form.servicePort)
|
|
172
|
+
if (form.servicePort.trim() && Number.isInteger(port)) url.port = port
|
|
173
|
+
}
|
|
174
|
+
if (form.urlScheme) url.scheme = form.urlScheme
|
|
175
|
+
return url
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function buildPayload(): { config: Record<string, unknown>; secrets: Record<string, string> } {
|
|
179
|
+
const kubernetes: Record<string, unknown> = {
|
|
180
|
+
label: form.label.trim(),
|
|
181
|
+
apiServerUrl: form.apiServerUrl.trim(),
|
|
182
|
+
manifestSource: buildManifestSource(),
|
|
183
|
+
url: buildUrl(),
|
|
184
|
+
}
|
|
185
|
+
if (form.caCertPem.trim()) kubernetes.caCertPem = form.caCertPem.trim()
|
|
186
|
+
if (form.insecureSkipTlsVerify) kubernetes.insecureSkipTlsVerify = true
|
|
187
|
+
if (form.namespaceTemplate.trim()) kubernetes.namespaceTemplate = form.namespaceTemplate.trim()
|
|
188
|
+
if (form.imageTemplate.trim()) kubernetes.imageTemplate = form.imageTemplate.trim()
|
|
189
|
+
return {
|
|
190
|
+
config: { kind: 'kubernetes', kubernetes },
|
|
191
|
+
secrets: { [KUBERNETES_ENV_TOKEN_SECRET_KEY]: apiToken.value.trim() },
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function optional(label: string): string {
|
|
196
|
+
return t('settings.providerConnection.form.optionalLabel', { label })
|
|
197
|
+
}
|
|
198
|
+
</script>
|
|
199
|
+
|
|
200
|
+
<template>
|
|
201
|
+
<div class="rounded-lg border border-dashed border-slate-700 p-3 space-y-3">
|
|
202
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
203
|
+
{{
|
|
204
|
+
connection?.kind === 'kubernetes'
|
|
205
|
+
? t('settings.providerConnection.form.updateConfiguration')
|
|
206
|
+
: t('settings.providerConnection.form.connect')
|
|
207
|
+
}}
|
|
208
|
+
</p>
|
|
209
|
+
|
|
210
|
+
<UFormField :label="t('settings.providerConnection.kubernetesEnv.label')">
|
|
211
|
+
<UInput
|
|
212
|
+
v-model="form.label"
|
|
213
|
+
:placeholder="t('settings.providerConnection.kubernetesEnv.labelPlaceholder')"
|
|
214
|
+
/>
|
|
215
|
+
</UFormField>
|
|
216
|
+
|
|
217
|
+
<UFormField
|
|
218
|
+
:label="t('settings.providerConnection.kubernetesEnv.apiServerUrl')"
|
|
219
|
+
:help="t('settings.providerConnection.kubernetesEnv.apiServerUrlHelp')"
|
|
220
|
+
>
|
|
221
|
+
<UInput v-model="form.apiServerUrl" class="font-mono" placeholder="https://10.0.0.1:6443" />
|
|
222
|
+
</UFormField>
|
|
223
|
+
|
|
224
|
+
<UFormField
|
|
225
|
+
:label="t('settings.providerConnection.kubernetesEnv.apiToken')"
|
|
226
|
+
:help="t('settings.providerConnection.kubernetesEnv.apiTokenHelp')"
|
|
227
|
+
>
|
|
228
|
+
<UInput v-model="apiToken" type="password" class="font-mono" />
|
|
229
|
+
</UFormField>
|
|
230
|
+
|
|
231
|
+
<!-- Manifest source: where the per-PR resources are read from. -->
|
|
232
|
+
<UFormField :label="t('settings.providerConnection.kubernetesEnv.manifestSourceLabel')">
|
|
233
|
+
<USelect v-model="form.manifestSourceType" :items="manifestSourceItems" />
|
|
234
|
+
</UFormField>
|
|
235
|
+
|
|
236
|
+
<UFormField
|
|
237
|
+
v-if="form.manifestSourceType === 'separate'"
|
|
238
|
+
:label="t('settings.providerConnection.kubernetesEnv.repo')"
|
|
239
|
+
:help="t('settings.providerConnection.kubernetesEnv.repoHelp')"
|
|
240
|
+
>
|
|
241
|
+
<UInput v-model="form.manifestRepo" class="font-mono" placeholder="acme/preview-manifests" />
|
|
242
|
+
</UFormField>
|
|
243
|
+
|
|
244
|
+
<UFormField
|
|
245
|
+
v-if="form.manifestSourceType === 'separate'"
|
|
246
|
+
:label="optional(t('settings.providerConnection.kubernetesEnv.ref'))"
|
|
247
|
+
:help="t('settings.providerConnection.kubernetesEnv.refHelp')"
|
|
248
|
+
>
|
|
249
|
+
<UInput v-model="form.manifestRef" class="font-mono" placeholder="main" />
|
|
250
|
+
</UFormField>
|
|
251
|
+
|
|
252
|
+
<UFormField
|
|
253
|
+
:label="t('settings.providerConnection.kubernetesEnv.path')"
|
|
254
|
+
:help="t('settings.providerConnection.kubernetesEnv.pathHelp')"
|
|
255
|
+
>
|
|
256
|
+
<UInput v-model="form.manifestPath" class="font-mono" placeholder="k8s/preview" />
|
|
257
|
+
</UFormField>
|
|
258
|
+
|
|
259
|
+
<!-- URL derivation: how the live environment URL is resolved once applied. -->
|
|
260
|
+
<UFormField :label="t('settings.providerConnection.kubernetesEnv.urlSourceLabel')">
|
|
261
|
+
<USelect v-model="form.urlSource" :items="urlSourceItems" />
|
|
262
|
+
</UFormField>
|
|
263
|
+
|
|
264
|
+
<UFormField
|
|
265
|
+
v-if="form.urlSource === 'ingressTemplate'"
|
|
266
|
+
:label="t('settings.providerConnection.kubernetesEnv.hostTemplate')"
|
|
267
|
+
:help="t('settings.providerConnection.kubernetesEnv.hostTemplateHelp')"
|
|
268
|
+
>
|
|
269
|
+
<UInput
|
|
270
|
+
v-model="form.hostTemplate"
|
|
271
|
+
class="font-mono"
|
|
272
|
+
placeholder="{{branch}}.preview.example.com"
|
|
273
|
+
/>
|
|
274
|
+
</UFormField>
|
|
275
|
+
|
|
276
|
+
<UFormField
|
|
277
|
+
v-if="form.urlSource === 'ingressStatus'"
|
|
278
|
+
:label="optional(t('settings.providerConnection.kubernetesEnv.ingressName'))"
|
|
279
|
+
:help="t('settings.providerConnection.kubernetesEnv.ingressNameHelp')"
|
|
280
|
+
>
|
|
281
|
+
<UInput v-model="form.ingressName" class="font-mono" />
|
|
282
|
+
</UFormField>
|
|
283
|
+
|
|
284
|
+
<UFormField
|
|
285
|
+
v-if="form.urlSource === 'serviceStatus'"
|
|
286
|
+
:label="t('settings.providerConnection.kubernetesEnv.serviceName')"
|
|
287
|
+
>
|
|
288
|
+
<UInput v-model="form.serviceName" class="font-mono" />
|
|
289
|
+
</UFormField>
|
|
290
|
+
|
|
291
|
+
<UFormField
|
|
292
|
+
v-if="form.urlSource === 'serviceStatus'"
|
|
293
|
+
:label="optional(t('settings.providerConnection.kubernetesEnv.port'))"
|
|
294
|
+
>
|
|
295
|
+
<UInput
|
|
296
|
+
v-model="form.servicePort"
|
|
297
|
+
type="number"
|
|
298
|
+
:min="1"
|
|
299
|
+
:max="65535"
|
|
300
|
+
class="font-mono"
|
|
301
|
+
placeholder="80"
|
|
302
|
+
/>
|
|
303
|
+
</UFormField>
|
|
304
|
+
|
|
305
|
+
<UFormField :label="optional(t('settings.providerConnection.kubernetesEnv.scheme'))">
|
|
306
|
+
<USelect v-model="form.urlScheme" :items="schemeItems" />
|
|
307
|
+
</UFormField>
|
|
308
|
+
|
|
309
|
+
<!-- Optional refinements. -->
|
|
310
|
+
<UFormField
|
|
311
|
+
:label="optional(t('settings.providerConnection.kubernetesEnv.namespaceTemplate'))"
|
|
312
|
+
:help="t('settings.providerConnection.kubernetesEnv.namespaceTemplateHelp')"
|
|
313
|
+
>
|
|
314
|
+
<UInput
|
|
315
|
+
v-model="form.namespaceTemplate"
|
|
316
|
+
class="font-mono"
|
|
317
|
+
placeholder="cf-env-{{pullNumber}}"
|
|
318
|
+
/>
|
|
319
|
+
</UFormField>
|
|
320
|
+
|
|
321
|
+
<UFormField
|
|
322
|
+
:label="optional(t('settings.providerConnection.kubernetesEnv.imageTemplate'))"
|
|
323
|
+
:help="t('settings.providerConnection.kubernetesEnv.imageTemplateHelp')"
|
|
324
|
+
>
|
|
325
|
+
<UInput v-model="form.imageTemplate" class="font-mono" />
|
|
326
|
+
</UFormField>
|
|
327
|
+
|
|
328
|
+
<UFormField
|
|
329
|
+
:label="optional(t('settings.providerConnection.kubernetesEnv.caCertPem'))"
|
|
330
|
+
:help="t('settings.providerConnection.kubernetesEnv.caCertPemHelp')"
|
|
331
|
+
>
|
|
332
|
+
<UTextarea
|
|
333
|
+
v-model="form.caCertPem"
|
|
334
|
+
:rows="3"
|
|
335
|
+
class="font-mono"
|
|
336
|
+
placeholder="-----BEGIN CERTIFICATE-----"
|
|
337
|
+
/>
|
|
338
|
+
</UFormField>
|
|
339
|
+
|
|
340
|
+
<UFormField :help="t('settings.providerConnection.kubernetesEnv.insecureSkipTlsVerifyHelp')">
|
|
341
|
+
<UCheckbox
|
|
342
|
+
v-model="form.insecureSkipTlsVerify"
|
|
343
|
+
:label="t('settings.providerConnection.kubernetesEnv.insecureSkipTlsVerify')"
|
|
344
|
+
/>
|
|
345
|
+
</UFormField>
|
|
346
|
+
|
|
347
|
+
<div v-if="supportsTest" class="flex items-center gap-2">
|
|
348
|
+
<UButton
|
|
349
|
+
color="neutral"
|
|
350
|
+
variant="soft"
|
|
351
|
+
size="sm"
|
|
352
|
+
icon="i-lucide-plug-zap"
|
|
353
|
+
:loading="testing"
|
|
354
|
+
:disabled="!canSave"
|
|
355
|
+
@click="emit('test', buildPayload())"
|
|
356
|
+
>
|
|
357
|
+
{{ t('settings.providerConnection.test.button') }}
|
|
358
|
+
</UButton>
|
|
359
|
+
<span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
|
|
360
|
+
{{ testResult.message ?? t('settings.providerConnection.test.ok') }}
|
|
361
|
+
</span>
|
|
362
|
+
<span v-else-if="testResult" class="text-xs text-rose-400">
|
|
363
|
+
{{ testResult.message ?? t('settings.providerConnection.test.failed') }}
|
|
364
|
+
</span>
|
|
365
|
+
</div>
|
|
366
|
+
|
|
367
|
+
<div class="flex justify-end">
|
|
368
|
+
<UButton
|
|
369
|
+
color="primary"
|
|
370
|
+
size="sm"
|
|
371
|
+
:loading="busy"
|
|
372
|
+
:disabled="!canSave"
|
|
373
|
+
@click="emit('save', buildPayload())"
|
|
374
|
+
>
|
|
375
|
+
{{
|
|
376
|
+
connection?.kind === 'kubernetes'
|
|
377
|
+
? t('common.save')
|
|
378
|
+
: t('settings.providerConnection.form.connect')
|
|
379
|
+
}}
|
|
380
|
+
</UButton>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
</template>
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Local-mode-only: the warm-container pool + per-repo checkout reuse. These are a
|
|
3
|
+
// per-DEPLOYMENT singleton stored in the DB (they replaced the LOCAL_POOL_* / HARNESS_* env
|
|
4
|
+
// vars), so a developer tunes them here instead of editing .env. The warm pool keeps idle
|
|
5
|
+
// harness containers ready and re-leases one (preferring repo affinity) to each run — far
|
|
6
|
+
// faster startup than a cold container per run. Saving applies the new sizing to the running
|
|
7
|
+
// service immediately (live resize, no restart); in-flight runs keep the container they hold,
|
|
8
|
+
// and the checkout config applies to containers started after the save.
|
|
9
|
+
//
|
|
10
|
+
// Previously a standalone modal (LocalModeSettingsPanel); now folded into the Agent-containers
|
|
11
|
+
// tab of the Infrastructure window, since the warm pool IS the local agent-container runtime.
|
|
12
|
+
import { reactive, ref, watch } from 'vue'
|
|
13
|
+
|
|
14
|
+
const { t } = useI18n()
|
|
15
|
+
const store = useLocalSettingsStore()
|
|
16
|
+
const toast = useToast()
|
|
17
|
+
|
|
18
|
+
const saving = ref(false)
|
|
19
|
+
|
|
20
|
+
// Editable draft. `idleMinutes` and `cleanKeep` are friendlier renderings of the stored
|
|
21
|
+
// `pool.idleTtlMs` (ms) and `checkout.cleanKeep` (string[]).
|
|
22
|
+
const draft = reactive({
|
|
23
|
+
size: 0,
|
|
24
|
+
minWarm: 0,
|
|
25
|
+
max: null as number | null,
|
|
26
|
+
idleMinutes: 10,
|
|
27
|
+
workspaceRoot: '/workspace',
|
|
28
|
+
cleanKeep: 'node_modules,.venv,target,.gradle,.pnpm-store',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
function syncDraft() {
|
|
32
|
+
const s = store.settings
|
|
33
|
+
if (!s) return
|
|
34
|
+
draft.size = s.pool.size
|
|
35
|
+
draft.minWarm = s.pool.minWarm
|
|
36
|
+
draft.max = s.pool.max
|
|
37
|
+
draft.idleMinutes = Math.round(s.pool.idleTtlMs / 60_000)
|
|
38
|
+
draft.workspaceRoot = s.checkout.workspaceRoot
|
|
39
|
+
draft.cleanKeep = s.checkout.cleanKeep.join(',')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Load + hydrate the draft on mount (the tab only mounts in local mode).
|
|
43
|
+
void store.load().then(syncDraft)
|
|
44
|
+
watch(() => store.settings, syncDraft)
|
|
45
|
+
|
|
46
|
+
async function save() {
|
|
47
|
+
const cleanKeep = draft.cleanKeep
|
|
48
|
+
.split(',')
|
|
49
|
+
.map((s) => s.trim())
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
saving.value = true
|
|
52
|
+
try {
|
|
53
|
+
await store.save({
|
|
54
|
+
pool: {
|
|
55
|
+
size: Math.max(0, Math.floor(draft.size)),
|
|
56
|
+
minWarm: Math.max(0, Math.floor(draft.minWarm)),
|
|
57
|
+
max: draft.max == null ? null : Math.max(0, Math.floor(draft.max)),
|
|
58
|
+
idleTtlMs: Math.max(0, Math.floor(draft.idleMinutes * 60_000)),
|
|
59
|
+
},
|
|
60
|
+
checkout: { workspaceRoot: draft.workspaceRoot.trim() || '/workspace', cleanKeep },
|
|
61
|
+
})
|
|
62
|
+
toast.add({
|
|
63
|
+
title: t('settings.localMode.toast.saved'),
|
|
64
|
+
icon: 'i-lucide-check',
|
|
65
|
+
color: 'success',
|
|
66
|
+
})
|
|
67
|
+
} catch (e) {
|
|
68
|
+
toast.add({
|
|
69
|
+
title: t('settings.localMode.toast.saveFailed'),
|
|
70
|
+
description: e instanceof Error ? e.message : String(e),
|
|
71
|
+
icon: 'i-lucide-triangle-alert',
|
|
72
|
+
color: 'error',
|
|
73
|
+
})
|
|
74
|
+
} finally {
|
|
75
|
+
saving.value = false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<template>
|
|
81
|
+
<div class="space-y-6" data-testid="local-container-pool-settings">
|
|
82
|
+
<i18n-t
|
|
83
|
+
keypath="settings.localMode.intro"
|
|
84
|
+
tag="p"
|
|
85
|
+
class="text-xs text-slate-400"
|
|
86
|
+
scope="global"
|
|
87
|
+
>
|
|
88
|
+
<template #poolVars>
|
|
89
|
+
<code>LOCAL_POOL_*</code>
|
|
90
|
+
</template>
|
|
91
|
+
<template #harnessVars>
|
|
92
|
+
<code>HARNESS_*</code>
|
|
93
|
+
</template>
|
|
94
|
+
</i18n-t>
|
|
95
|
+
|
|
96
|
+
<!-- Warm container pool -->
|
|
97
|
+
<section class="space-y-3">
|
|
98
|
+
<div>
|
|
99
|
+
<h4 class="text-sm font-semibold text-slate-200">
|
|
100
|
+
{{ t('settings.localMode.pool.heading') }}
|
|
101
|
+
</h4>
|
|
102
|
+
<i18n-t
|
|
103
|
+
keypath="settings.localMode.pool.description"
|
|
104
|
+
tag="p"
|
|
105
|
+
class="text-[11px] text-slate-400"
|
|
106
|
+
scope="global"
|
|
107
|
+
>
|
|
108
|
+
<template #appleContainer>
|
|
109
|
+
<code>container</code>
|
|
110
|
+
</template>
|
|
111
|
+
</i18n-t>
|
|
112
|
+
</div>
|
|
113
|
+
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
114
|
+
<UFormField
|
|
115
|
+
:label="t('settings.localMode.pool.size.label')"
|
|
116
|
+
:help="t('settings.localMode.pool.size.help')"
|
|
117
|
+
>
|
|
118
|
+
<UInput v-model.number="draft.size" type="number" :min="0" size="sm" />
|
|
119
|
+
</UFormField>
|
|
120
|
+
<UFormField
|
|
121
|
+
:label="t('settings.localMode.pool.minWarm.label')"
|
|
122
|
+
:help="t('settings.localMode.pool.minWarm.help')"
|
|
123
|
+
>
|
|
124
|
+
<UInput v-model.number="draft.minWarm" type="number" :min="0" size="sm" />
|
|
125
|
+
</UFormField>
|
|
126
|
+
<UFormField
|
|
127
|
+
:label="t('settings.localMode.pool.max.label')"
|
|
128
|
+
:help="t('settings.localMode.pool.max.help')"
|
|
129
|
+
>
|
|
130
|
+
<UInput
|
|
131
|
+
v-model.number="draft.max"
|
|
132
|
+
type="number"
|
|
133
|
+
:min="0"
|
|
134
|
+
size="sm"
|
|
135
|
+
:placeholder="t('settings.localMode.pool.max.placeholder')"
|
|
136
|
+
/>
|
|
137
|
+
</UFormField>
|
|
138
|
+
<UFormField
|
|
139
|
+
:label="t('settings.localMode.pool.idleTimeout.label')"
|
|
140
|
+
:help="t('settings.localMode.pool.idleTimeout.help')"
|
|
141
|
+
>
|
|
142
|
+
<UInput v-model.number="draft.idleMinutes" type="number" :min="0" size="sm" />
|
|
143
|
+
</UFormField>
|
|
144
|
+
</div>
|
|
145
|
+
</section>
|
|
146
|
+
|
|
147
|
+
<!-- Checkout reuse -->
|
|
148
|
+
<section class="space-y-3 border-t border-slate-800 pt-6">
|
|
149
|
+
<div>
|
|
150
|
+
<h4 class="text-sm font-semibold text-slate-200">
|
|
151
|
+
{{ t('settings.localMode.checkout.heading') }}
|
|
152
|
+
</h4>
|
|
153
|
+
<p class="text-[11px] text-slate-400">
|
|
154
|
+
{{ t('settings.localMode.checkout.description') }}
|
|
155
|
+
</p>
|
|
156
|
+
</div>
|
|
157
|
+
<UFormField
|
|
158
|
+
:label="t('settings.localMode.checkout.workspaceRoot.label')"
|
|
159
|
+
:help="t('settings.localMode.checkout.workspaceRoot.help')"
|
|
160
|
+
>
|
|
161
|
+
<UInput v-model="draft.workspaceRoot" size="sm" placeholder="/workspace" />
|
|
162
|
+
</UFormField>
|
|
163
|
+
<UFormField
|
|
164
|
+
:label="t('settings.localMode.checkout.cleanKeep.label')"
|
|
165
|
+
:help="t('settings.localMode.checkout.cleanKeep.help')"
|
|
166
|
+
>
|
|
167
|
+
<UInput
|
|
168
|
+
v-model="draft.cleanKeep"
|
|
169
|
+
size="sm"
|
|
170
|
+
placeholder="node_modules,.venv,target,.gradle,.pnpm-store"
|
|
171
|
+
/>
|
|
172
|
+
</UFormField>
|
|
173
|
+
</section>
|
|
174
|
+
|
|
175
|
+
<div class="flex justify-end">
|
|
176
|
+
<UButton color="primary" icon="i-lucide-save" :loading="saving" @click="save">
|
|
177
|
+
{{ t('common.save') }}
|
|
178
|
+
</UButton>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</template>
|