@cat-factory/app 0.47.2 → 0.47.4
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/github/AddServiceFromRepoModal.vue +14 -12
- package/app/components/layout/IntegrationsHub.vue +44 -23
- package/app/components/settings/InfrastructureWindow.vue +221 -0
- package/app/components/settings/ProviderConnectionTab.vue +358 -0
- package/app/components/settings/ProviderManifestEditor.vue +286 -0
- package/app/pages/index.vue +3 -3
- package/app/stores/ui.ts +12 -6
- package/i18n/locales/en.json +23 -10
- package/i18n/locales/es.json +23 -10
- package/i18n/locales/fr.json +23 -10
- package/i18n/locales/pl.json +23 -10
- package/i18n/locales/uk.json +23 -10
- package/package.json +2 -1
- package/app/components/settings/ProviderConnectionPanel.vue +0 -524
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// One tab of the Infrastructure window — the connect surface for a single provider kind
|
|
3
|
+
// (container agents → runner pool, or test environments → environment provider). Both
|
|
4
|
+
// self-describe via a ProviderDescriptor, so this renders either without hard-coding them:
|
|
5
|
+
// - a NATIVE provider (ships a `manifestTemplate`) → the friendly flat field form, whose
|
|
6
|
+
// values are overlaid back onto the manifest before saving (the single storage path).
|
|
7
|
+
// - a MANIFEST-driven provider (no template) → the full JSON manifest editor
|
|
8
|
+
// (ProviderManifestEditor), which replaces the old "use the API" disclaimer.
|
|
9
|
+
import { computed, ref, toRaw, watch } from 'vue'
|
|
10
|
+
import type { ProviderConnectionKind } from '~/types/providerConnections'
|
|
11
|
+
import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
|
|
12
|
+
import ProviderManifestEditor from '~/components/settings/ProviderManifestEditor.vue'
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{ kind: ProviderConnectionKind }>()
|
|
15
|
+
|
|
16
|
+
const { t } = useI18n()
|
|
17
|
+
const store = useProviderConnectionsStore()
|
|
18
|
+
const toast = useToast()
|
|
19
|
+
|
|
20
|
+
const descriptor = computed(() => store.descriptorFor(props.kind))
|
|
21
|
+
const connection = computed(() => store.connectionFor(props.kind))
|
|
22
|
+
|
|
23
|
+
const BLURB_KEYS: Record<ProviderConnectionKind, string> = {
|
|
24
|
+
environment: 'settings.providerConnection.kind.environment.blurb',
|
|
25
|
+
'runner-pool': 'settings.providerConnection.kind.runner-pool.blurb',
|
|
26
|
+
}
|
|
27
|
+
const blurb = computed(() => t(BLURB_KEYS[props.kind]))
|
|
28
|
+
const title = computed(() => t(`settings.providerConnection.kind.${props.kind}.title`))
|
|
29
|
+
|
|
30
|
+
watch(
|
|
31
|
+
() => props.kind,
|
|
32
|
+
(k) => void store.loadKind(k).then(resetDraft),
|
|
33
|
+
{ immediate: true },
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// "View logs": the provisioning event history for this provider's subsystem.
|
|
37
|
+
const showLogs = ref(false)
|
|
38
|
+
|
|
39
|
+
// --- Shared state -------------------------------------------------------------------
|
|
40
|
+
const values = ref<Record<string, string>>({})
|
|
41
|
+
const testResult = ref<{ ok: boolean; message?: string } | null>(null)
|
|
42
|
+
const testing = ref(false)
|
|
43
|
+
const busy = ref(false)
|
|
44
|
+
|
|
45
|
+
/** A native provider ships a manifest scaffold ⇒ render the friendly flat field form. */
|
|
46
|
+
const isNative = computed(() => !!descriptor.value?.manifestTemplate)
|
|
47
|
+
const secretFieldCount = computed(
|
|
48
|
+
() => (descriptor.value?.configFields ?? []).filter((f) => f.secret).length,
|
|
49
|
+
)
|
|
50
|
+
const hasSecretFields = computed(() => secretFieldCount.value > 0)
|
|
51
|
+
|
|
52
|
+
// Seed the flat-form draft from the saved manifest so an edit starts from the CURRENT
|
|
53
|
+
// non-secret config (baseUrl + providerConfig). Secret fields are never prefilled.
|
|
54
|
+
function resetDraft() {
|
|
55
|
+
testResult.value = null
|
|
56
|
+
const saved = descriptor.value?.savedManifest
|
|
57
|
+
const cfg = (saved?.providerConfig as Record<string, unknown> | undefined) ?? {}
|
|
58
|
+
const next: Record<string, string> = {}
|
|
59
|
+
for (const f of descriptor.value?.configFields ?? []) {
|
|
60
|
+
if (f.secret) continue
|
|
61
|
+
if (f.key === 'baseUrl') {
|
|
62
|
+
const b = saved?.baseUrl ?? connection.value?.baseUrl
|
|
63
|
+
if (typeof b === 'string') next[f.key] = b
|
|
64
|
+
} else if (typeof cfg[f.key] === 'string') {
|
|
65
|
+
next[f.key] = cfg[f.key] as string
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
values.value = next
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A flat-form field is satisfied when filled now, or already stored, or it has a default. */
|
|
72
|
+
function satisfied(key: string): boolean {
|
|
73
|
+
const f = descriptor.value?.configFields.find((cf) => cf.key === key)
|
|
74
|
+
if (!f) return true
|
|
75
|
+
if ((values.value[key] ?? '').trim()) return true
|
|
76
|
+
if (f.default !== undefined) return true
|
|
77
|
+
return !(descriptor.value?.missingRequired ?? []).includes(key)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const canSave = computed(() => {
|
|
81
|
+
if (!descriptor.value || !isNative.value) return false
|
|
82
|
+
return (descriptor.value.missingRequired ?? []).every(satisfied)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
/** Overlay the flat field values onto the native provider's manifest. */
|
|
86
|
+
function buildManifestPayload(): {
|
|
87
|
+
manifest: Record<string, unknown>
|
|
88
|
+
secrets: Record<string, string>
|
|
89
|
+
} | null {
|
|
90
|
+
const template = descriptor.value?.manifestTemplate
|
|
91
|
+
if (!template) return null
|
|
92
|
+
const base = descriptor.value?.savedManifest ?? template
|
|
93
|
+
// `base` is a Vue reactive proxy, which structuredClone refuses; `toRaw` unwraps it.
|
|
94
|
+
const manifest: Record<string, unknown> = structuredClone(toRaw(base))
|
|
95
|
+
const providerConfig: Record<string, unknown> = {
|
|
96
|
+
...(manifest.providerConfig as Record<string, unknown> | undefined),
|
|
97
|
+
}
|
|
98
|
+
const secrets: Record<string, string> = {}
|
|
99
|
+
for (const f of descriptor.value?.configFields ?? []) {
|
|
100
|
+
const val = (values.value[f.key] ?? '').trim()
|
|
101
|
+
if (!val) continue
|
|
102
|
+
if (f.secret) secrets[f.key] = val
|
|
103
|
+
else if (f.key === 'baseUrl') manifest.baseUrl = val
|
|
104
|
+
else providerConfig[f.key] = val
|
|
105
|
+
}
|
|
106
|
+
if (Object.keys(providerConfig).length) manifest.providerConfig = providerConfig
|
|
107
|
+
return { manifest, secrets }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function notifyError(title: string, e: unknown) {
|
|
111
|
+
toast.add({
|
|
112
|
+
title,
|
|
113
|
+
description: e instanceof Error ? e.message : String(e),
|
|
114
|
+
icon: 'i-lucide-triangle-alert',
|
|
115
|
+
color: 'error',
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function toastSaved() {
|
|
120
|
+
toast.add({
|
|
121
|
+
title: t('settings.providerConnection.toast.saved', { title: title.value }),
|
|
122
|
+
icon: 'i-lucide-check',
|
|
123
|
+
color: 'success',
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- Native flat-form actions -------------------------------------------------------
|
|
128
|
+
async function testNative() {
|
|
129
|
+
const payload = buildManifestPayload()
|
|
130
|
+
if (!payload) return
|
|
131
|
+
testing.value = true
|
|
132
|
+
testResult.value = null
|
|
133
|
+
try {
|
|
134
|
+
testResult.value = await store.test(props.kind, payload)
|
|
135
|
+
} catch (e) {
|
|
136
|
+
testResult.value = { ok: false, message: e instanceof Error ? e.message : String(e) }
|
|
137
|
+
} finally {
|
|
138
|
+
testing.value = false
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function saveNative() {
|
|
143
|
+
busy.value = true
|
|
144
|
+
try {
|
|
145
|
+
const payload = buildManifestPayload()
|
|
146
|
+
if (payload) await store.register(props.kind, payload)
|
|
147
|
+
resetDraft()
|
|
148
|
+
toastSaved()
|
|
149
|
+
} catch (e) {
|
|
150
|
+
notifyError(t('settings.providerConnection.toast.saveFailed'), e)
|
|
151
|
+
} finally {
|
|
152
|
+
busy.value = false
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- Manifest-editor actions (emitted from ProviderManifestEditor) ------------------
|
|
157
|
+
async function testManifest(payload: {
|
|
158
|
+
manifest: Record<string, unknown>
|
|
159
|
+
secrets: Record<string, string>
|
|
160
|
+
}) {
|
|
161
|
+
testing.value = true
|
|
162
|
+
testResult.value = null
|
|
163
|
+
try {
|
|
164
|
+
testResult.value = await store.test(props.kind, payload)
|
|
165
|
+
} catch (e) {
|
|
166
|
+
testResult.value = { ok: false, message: e instanceof Error ? e.message : String(e) }
|
|
167
|
+
} finally {
|
|
168
|
+
testing.value = false
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function saveManifest(payload: {
|
|
173
|
+
manifest: Record<string, unknown>
|
|
174
|
+
secrets: Record<string, string>
|
|
175
|
+
}) {
|
|
176
|
+
busy.value = true
|
|
177
|
+
try {
|
|
178
|
+
await store.register(props.kind, payload)
|
|
179
|
+
toastSaved()
|
|
180
|
+
} catch (e) {
|
|
181
|
+
notifyError(t('settings.providerConnection.toast.saveFailed'), e)
|
|
182
|
+
} finally {
|
|
183
|
+
busy.value = false
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function remove() {
|
|
188
|
+
busy.value = true
|
|
189
|
+
try {
|
|
190
|
+
await store.remove(props.kind)
|
|
191
|
+
resetDraft()
|
|
192
|
+
toast.add({ title: t('settings.providerConnection.toast.removed'), icon: 'i-lucide-check' })
|
|
193
|
+
} catch (e) {
|
|
194
|
+
notifyError(t('settings.providerConnection.toast.removeFailed'), e)
|
|
195
|
+
} finally {
|
|
196
|
+
busy.value = false
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The helper line under a flat field — its own help, plus the "defaulted to …" hint. */
|
|
201
|
+
function fieldHelp(key: string): string | undefined {
|
|
202
|
+
const f = descriptor.value?.configFields.find((cf) => cf.key === key)
|
|
203
|
+
if (!f) return undefined
|
|
204
|
+
const filled = (values.value[key] ?? '').trim()
|
|
205
|
+
if (f.default !== undefined && !filled) {
|
|
206
|
+
const defaulted = t('settings.providerConnection.field.defaultsTo', { value: f.default })
|
|
207
|
+
return f.help ? `${f.help} · ${defaulted}` : defaulted
|
|
208
|
+
}
|
|
209
|
+
return f.help
|
|
210
|
+
}
|
|
211
|
+
</script>
|
|
212
|
+
|
|
213
|
+
<template>
|
|
214
|
+
<div v-if="descriptor" class="space-y-4">
|
|
215
|
+
<div class="flex items-start justify-between gap-3">
|
|
216
|
+
<p class="text-xs text-slate-400">{{ blurb }}</p>
|
|
217
|
+
<UButton
|
|
218
|
+
:icon="showLogs ? 'i-lucide-chevron-up' : 'i-lucide-scroll-text'"
|
|
219
|
+
variant="ghost"
|
|
220
|
+
size="xs"
|
|
221
|
+
class="shrink-0"
|
|
222
|
+
@click="showLogs = !showLogs"
|
|
223
|
+
>
|
|
224
|
+
{{
|
|
225
|
+
showLogs
|
|
226
|
+
? t('settings.providerConnection.hideLogs')
|
|
227
|
+
: t('settings.providerConnection.viewLogs')
|
|
228
|
+
}}
|
|
229
|
+
</UButton>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<!-- Provisioning attempt history for this provider's subsystem. -->
|
|
233
|
+
<ProvisioningLogsDrawer v-if="showLogs" :subsystem="kind" />
|
|
234
|
+
|
|
235
|
+
<!-- Saved connection summary -->
|
|
236
|
+
<div
|
|
237
|
+
v-if="connection"
|
|
238
|
+
class="flex items-center justify-between rounded-md border border-slate-700 bg-slate-900/50 px-3 py-2 text-sm"
|
|
239
|
+
>
|
|
240
|
+
<div>
|
|
241
|
+
<span class="font-medium text-slate-200">{{ connection.label }}</span>
|
|
242
|
+
<div class="text-[11px] text-emerald-400">
|
|
243
|
+
{{ t('settings.providerConnection.connectedAt', { baseUrl: connection.baseUrl }) }}
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
<UButton
|
|
247
|
+
icon="i-lucide-trash-2"
|
|
248
|
+
color="error"
|
|
249
|
+
variant="ghost"
|
|
250
|
+
size="xs"
|
|
251
|
+
:disabled="busy"
|
|
252
|
+
@click="remove()"
|
|
253
|
+
/>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<!-- Mandatory-fields warning (mirrors the banner) -->
|
|
257
|
+
<div
|
|
258
|
+
v-if="descriptor.missingRequired.length"
|
|
259
|
+
class="rounded-md border border-amber-500/40 bg-amber-950/40 px-3 py-2 text-xs text-amber-200"
|
|
260
|
+
>
|
|
261
|
+
{{
|
|
262
|
+
t('settings.providerConnection.missingConfig', {
|
|
263
|
+
fields: descriptor.missingRequired.join(', '),
|
|
264
|
+
})
|
|
265
|
+
}}
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<!-- NATIVE provider: the friendly, descriptor-driven flat field form. -->
|
|
269
|
+
<div v-if="isNative" class="rounded-lg border border-dashed border-slate-700 p-3 space-y-3">
|
|
270
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
271
|
+
{{
|
|
272
|
+
connection
|
|
273
|
+
? t('settings.providerConnection.form.updateConfiguration')
|
|
274
|
+
: t('settings.providerConnection.form.connect')
|
|
275
|
+
}}
|
|
276
|
+
</p>
|
|
277
|
+
<p v-if="connection && hasSecretFields" class="text-[11px] text-amber-300/80">
|
|
278
|
+
{{
|
|
279
|
+
t(
|
|
280
|
+
'settings.providerConnection.form.reenterSecrets',
|
|
281
|
+
{ count: secretFieldCount },
|
|
282
|
+
secretFieldCount,
|
|
283
|
+
)
|
|
284
|
+
}}
|
|
285
|
+
</p>
|
|
286
|
+
|
|
287
|
+
<UFormField
|
|
288
|
+
v-for="field in descriptor.configFields"
|
|
289
|
+
:key="field.key"
|
|
290
|
+
:label="
|
|
291
|
+
field.required && field.default === undefined
|
|
292
|
+
? field.label
|
|
293
|
+
: t('settings.providerConnection.form.optionalLabel', { label: field.label })
|
|
294
|
+
"
|
|
295
|
+
:help="fieldHelp(field.key)"
|
|
296
|
+
>
|
|
297
|
+
<USelect
|
|
298
|
+
v-if="field.type === 'select'"
|
|
299
|
+
v-model="values[field.key]"
|
|
300
|
+
:items="(field.options ?? []).map((o) => ({ label: o.label, value: o.value }))"
|
|
301
|
+
:placeholder="field.default ?? field.placeholder"
|
|
302
|
+
/>
|
|
303
|
+
<UInput
|
|
304
|
+
v-else
|
|
305
|
+
v-model="values[field.key]"
|
|
306
|
+
:type="field.secret ? 'password' : 'text'"
|
|
307
|
+
class="font-mono"
|
|
308
|
+
:placeholder="field.default ?? field.placeholder"
|
|
309
|
+
/>
|
|
310
|
+
</UFormField>
|
|
311
|
+
|
|
312
|
+
<div v-if="descriptor.supportsTest" class="flex items-center gap-2">
|
|
313
|
+
<UButton
|
|
314
|
+
color="neutral"
|
|
315
|
+
variant="soft"
|
|
316
|
+
size="sm"
|
|
317
|
+
icon="i-lucide-plug-zap"
|
|
318
|
+
:loading="testing"
|
|
319
|
+
@click="testNative()"
|
|
320
|
+
>
|
|
321
|
+
{{ t('settings.providerConnection.test.button') }}
|
|
322
|
+
</UButton>
|
|
323
|
+
<span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
|
|
324
|
+
{{ testResult.message ?? t('settings.providerConnection.test.ok') }}
|
|
325
|
+
</span>
|
|
326
|
+
<span v-else-if="testResult" class="text-xs text-rose-400">
|
|
327
|
+
{{ testResult.message ?? t('settings.providerConnection.test.failed') }}
|
|
328
|
+
</span>
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
<div class="flex justify-end">
|
|
332
|
+
<UButton
|
|
333
|
+
color="primary"
|
|
334
|
+
size="sm"
|
|
335
|
+
:loading="busy"
|
|
336
|
+
:disabled="!canSave"
|
|
337
|
+
@click="saveNative()"
|
|
338
|
+
>
|
|
339
|
+
{{ connection ? t('common.save') : t('settings.providerConnection.form.connect') }}
|
|
340
|
+
</UButton>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
|
|
344
|
+
<!-- MANIFEST-driven provider: the full in-app manifest editor. -->
|
|
345
|
+
<ProviderManifestEditor
|
|
346
|
+
v-else
|
|
347
|
+
:kind="kind"
|
|
348
|
+
:saved-manifest="descriptor.savedManifest"
|
|
349
|
+
:connected="!!connection"
|
|
350
|
+
:supports-test="descriptor.supportsTest"
|
|
351
|
+
:testing="testing"
|
|
352
|
+
:busy="busy"
|
|
353
|
+
:test-result="testResult"
|
|
354
|
+
@test="testManifest"
|
|
355
|
+
@save="saveManifest"
|
|
356
|
+
/>
|
|
357
|
+
</div>
|
|
358
|
+
</template>
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The in-app manifest editor for a MANIFEST-DRIVEN infrastructure provider (a runner pool
|
|
3
|
+
// or an environment provider without a native code adapter). It replaces the old
|
|
4
|
+
// "register it via the API" disclaimer: the operator authors the provider's full JSON
|
|
5
|
+
// manifest here, supplies the write-only secret values it references, and tests/saves —
|
|
6
|
+
// entirely in-app.
|
|
7
|
+
//
|
|
8
|
+
// The manifest is validated against the SAME Valibot wire contract the backend enforces
|
|
9
|
+
// (runner pool: RunnerPoolManifest; environment: EnvironmentManifest), imported from
|
|
10
|
+
// @cat-factory/contracts so the client check stays in lockstep with the server. The server
|
|
11
|
+
// remains authoritative — register re-validates — so a client that's behind still can't
|
|
12
|
+
// persist an invalid manifest.
|
|
13
|
+
//
|
|
14
|
+
// Secrets are write-only: never prefilled. Because register replaces the whole manifest +
|
|
15
|
+
// secret bundle, EVERY secret key the manifest references must be (re-)supplied on save —
|
|
16
|
+
// on an existing connection the amber hint says so.
|
|
17
|
+
import { computed, ref, watch } from 'vue'
|
|
18
|
+
import * as v from 'valibot'
|
|
19
|
+
import { environmentManifestSchema, runnerPoolManifestSchema } from '@cat-factory/contracts'
|
|
20
|
+
import type { ProviderConnectionKind } from '~/types/providerConnections'
|
|
21
|
+
|
|
22
|
+
const props = defineProps<{
|
|
23
|
+
kind: ProviderConnectionKind
|
|
24
|
+
/** The provider's current saved manifest (secret-ref keys only, no values). */
|
|
25
|
+
savedManifest?: Record<string, unknown>
|
|
26
|
+
/** Whether a connection already exists (drives the re-enter-secrets hint + button label). */
|
|
27
|
+
connected: boolean
|
|
28
|
+
/** Whether the provider exposes a connection test the UI can call. */
|
|
29
|
+
supportsTest: boolean
|
|
30
|
+
/** Bubbled-up busy state from the tab's store calls (so the editor shows loading). */
|
|
31
|
+
testing: boolean
|
|
32
|
+
busy: boolean
|
|
33
|
+
testResult: { ok: boolean; message?: string } | null
|
|
34
|
+
}>()
|
|
35
|
+
|
|
36
|
+
const emit = defineEmits<{
|
|
37
|
+
test: [payload: { manifest: Record<string, unknown>; secrets: Record<string, string> }]
|
|
38
|
+
save: [payload: { manifest: Record<string, unknown>; secrets: Record<string, string> }]
|
|
39
|
+
}>()
|
|
40
|
+
|
|
41
|
+
const { t } = useI18n()
|
|
42
|
+
|
|
43
|
+
// A minimal, valid starter manifest per kind (O1 option a: a static SPA example — no backend
|
|
44
|
+
// round-trip). Seeds the editor when there's no saved manifest to start from. The operator
|
|
45
|
+
// edits providerId/label/baseUrl and the request templates for their own scheduler/API.
|
|
46
|
+
const STARTERS: Record<ProviderConnectionKind, Record<string, unknown>> = {
|
|
47
|
+
'runner-pool': {
|
|
48
|
+
providerId: 'my-pool',
|
|
49
|
+
label: 'My runner pool',
|
|
50
|
+
baseUrl: 'https://pool.example.com',
|
|
51
|
+
auth: { type: 'bearer', secretRef: { key: 'API_TOKEN' } },
|
|
52
|
+
dispatch: { method: 'POST', pathTemplate: '/jobs', bodyTemplate: '{{input.job}}' },
|
|
53
|
+
poll: { method: 'GET', pathTemplate: '/jobs/{{input.jobId}}' },
|
|
54
|
+
response: {
|
|
55
|
+
statusPath: 'state',
|
|
56
|
+
statusMap: [
|
|
57
|
+
{ from: 'running', to: 'running' },
|
|
58
|
+
{ from: 'completed', to: 'done' },
|
|
59
|
+
{ from: 'error', to: 'failed' },
|
|
60
|
+
],
|
|
61
|
+
resultPath: 'result',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
environment: {
|
|
65
|
+
providerId: 'my-envs',
|
|
66
|
+
label: 'My environment provider',
|
|
67
|
+
baseUrl: 'https://envs.example.com',
|
|
68
|
+
auth: { type: 'bearer', secretRef: { key: 'API_TOKEN' } },
|
|
69
|
+
provision: { method: 'POST', pathTemplate: '/environments', bodyTemplate: '{}' },
|
|
70
|
+
status: { method: 'GET', pathTemplate: '/environments/{{provision.id}}' },
|
|
71
|
+
teardown: { method: 'DELETE', pathTemplate: '/environments/{{provision.id}}' },
|
|
72
|
+
response: {
|
|
73
|
+
urlPath: 'url',
|
|
74
|
+
statusPath: 'status',
|
|
75
|
+
statusMap: [
|
|
76
|
+
{ from: 'building', to: 'provisioning' },
|
|
77
|
+
{ from: 'ready', to: 'ready' },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const schema = computed(() =>
|
|
84
|
+
props.kind === 'runner-pool' ? runnerPoolManifestSchema : environmentManifestSchema,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const text = ref('')
|
|
88
|
+
const secrets = ref<Record<string, string>>({})
|
|
89
|
+
|
|
90
|
+
/** Seed the editor from the saved manifest (an edit) or the starter (a first connect). */
|
|
91
|
+
function seed() {
|
|
92
|
+
const base = props.savedManifest ?? STARTERS[props.kind]
|
|
93
|
+
text.value = JSON.stringify(base, null, 2)
|
|
94
|
+
secrets.value = {}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Re-seed on first mount and whenever the saved manifest changes (e.g. after a successful
|
|
98
|
+
// save reloads the descriptor) — the saved manifest is the new canonical text and the
|
|
99
|
+
// just-saved secrets are cleared from the write-only inputs.
|
|
100
|
+
watch(() => props.savedManifest, seed, { immediate: true })
|
|
101
|
+
|
|
102
|
+
/** Parse the textarea; null value on a JSON syntax error. */
|
|
103
|
+
const parsed = computed<{ ok: boolean; value?: Record<string, unknown> }>(() => {
|
|
104
|
+
const raw = text.value.trim()
|
|
105
|
+
if (!raw) return { ok: false }
|
|
106
|
+
try {
|
|
107
|
+
const value = JSON.parse(raw)
|
|
108
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return { ok: false }
|
|
109
|
+
return { ok: true, value: value as Record<string, unknown> }
|
|
110
|
+
} catch {
|
|
111
|
+
return { ok: false }
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const jsonError = computed(() => text.value.trim().length > 0 && !parsed.value.ok)
|
|
116
|
+
|
|
117
|
+
/** Validate the parsed object against the wire contract; surface the first issue. */
|
|
118
|
+
const schemaError = computed<string | null>(() => {
|
|
119
|
+
if (!parsed.value.ok || !parsed.value.value) return null
|
|
120
|
+
const result = v.safeParse(schema.value, parsed.value.value)
|
|
121
|
+
if (result.success) return null
|
|
122
|
+
const issue = result.issues[0]
|
|
123
|
+
if (!issue) return t('settings.providerConnection.manifestEditor.invalidShape')
|
|
124
|
+
const path = (issue.path ?? []).map((p) => String((p as { key?: unknown }).key ?? '')).join('.')
|
|
125
|
+
return path ? `${path}: ${issue.message}` : issue.message
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const validManifest = computed<Record<string, unknown> | null>(() =>
|
|
129
|
+
parsed.value.ok && parsed.value.value && !schemaError.value ? parsed.value.value : null,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Every secret key the manifest's auth scheme references, discovered generically by walking
|
|
134
|
+
* the parsed object for any `*SecretRef` (or `secretRef`) with a string `key`. Covers bearer
|
|
135
|
+
* / api_key / basic / oauth2 / custom_headers without hard-coding each auth variant.
|
|
136
|
+
*/
|
|
137
|
+
const secretKeys = computed<string[]>(() => {
|
|
138
|
+
const out = new Set<string>()
|
|
139
|
+
const walk = (node: unknown) => {
|
|
140
|
+
if (Array.isArray(node)) {
|
|
141
|
+
for (const item of node) walk(item)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
if (node && typeof node === 'object') {
|
|
145
|
+
for (const [key, val] of Object.entries(node)) {
|
|
146
|
+
if (
|
|
147
|
+
/secretref$/i.test(key) &&
|
|
148
|
+
val &&
|
|
149
|
+
typeof val === 'object' &&
|
|
150
|
+
typeof (val as { key?: unknown }).key === 'string'
|
|
151
|
+
) {
|
|
152
|
+
out.add((val as { key: string }).key)
|
|
153
|
+
} else {
|
|
154
|
+
walk(val)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (parsed.value.value) walk(parsed.value.value)
|
|
160
|
+
return [...out]
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// register() replaces the whole bundle, so every referenced secret must be supplied to save.
|
|
164
|
+
const allSecretsSupplied = computed(() =>
|
|
165
|
+
secretKeys.value.every((k) => (secrets.value[k] ?? '').trim().length > 0),
|
|
166
|
+
)
|
|
167
|
+
const canSave = computed(() => !!validManifest.value && allSecretsSupplied.value)
|
|
168
|
+
// A test can probe with whatever secrets are filled in (a partial probe is still useful).
|
|
169
|
+
const canTest = computed(() => !!validManifest.value)
|
|
170
|
+
|
|
171
|
+
function filledSecrets(): Record<string, string> {
|
|
172
|
+
const out: Record<string, string> = {}
|
|
173
|
+
for (const k of secretKeys.value) {
|
|
174
|
+
const val = (secrets.value[k] ?? '').trim()
|
|
175
|
+
if (val) out[k] = val
|
|
176
|
+
}
|
|
177
|
+
return out
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function onTest() {
|
|
181
|
+
if (!validManifest.value) return
|
|
182
|
+
emit('test', { manifest: validManifest.value, secrets: filledSecrets() })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function onSave() {
|
|
186
|
+
if (!canSave.value || !validManifest.value) return
|
|
187
|
+
emit('save', { manifest: validManifest.value, secrets: filledSecrets() })
|
|
188
|
+
}
|
|
189
|
+
</script>
|
|
190
|
+
|
|
191
|
+
<template>
|
|
192
|
+
<div class="space-y-3 rounded-lg border border-dashed border-slate-700 p-3">
|
|
193
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
194
|
+
{{ t('settings.providerConnection.manifestEditor.title') }}
|
|
195
|
+
</p>
|
|
196
|
+
|
|
197
|
+
<UFormField
|
|
198
|
+
:label="t('settings.providerConnection.manifestEditor.jsonLabel')"
|
|
199
|
+
:help="t('settings.providerConnection.manifestEditor.jsonHelp')"
|
|
200
|
+
>
|
|
201
|
+
<UTextarea
|
|
202
|
+
v-model="text"
|
|
203
|
+
:rows="16"
|
|
204
|
+
class="w-full font-mono text-xs"
|
|
205
|
+
data-testid="manifest-editor-json"
|
|
206
|
+
spellcheck="false"
|
|
207
|
+
/>
|
|
208
|
+
</UFormField>
|
|
209
|
+
|
|
210
|
+
<p v-if="!savedManifest && !jsonError && !schemaError" class="text-[11px] text-slate-500">
|
|
211
|
+
{{ t('settings.providerConnection.manifestEditor.starterHint') }}
|
|
212
|
+
</p>
|
|
213
|
+
|
|
214
|
+
<!-- Parse + shape errors, validated against the same contract the backend enforces. -->
|
|
215
|
+
<p
|
|
216
|
+
v-if="jsonError"
|
|
217
|
+
class="rounded-md border border-rose-500/40 bg-rose-950/40 px-3 py-2 text-xs text-rose-200"
|
|
218
|
+
data-testid="manifest-editor-error"
|
|
219
|
+
>
|
|
220
|
+
{{ t('settings.providerConnection.manifestEditor.invalidJson') }}
|
|
221
|
+
</p>
|
|
222
|
+
<p
|
|
223
|
+
v-else-if="schemaError"
|
|
224
|
+
class="rounded-md border border-amber-500/40 bg-amber-950/40 px-3 py-2 text-xs text-amber-200"
|
|
225
|
+
data-testid="manifest-editor-error"
|
|
226
|
+
>
|
|
227
|
+
{{ t('settings.providerConnection.manifestEditor.schemaError', { message: schemaError }) }}
|
|
228
|
+
</p>
|
|
229
|
+
|
|
230
|
+
<!-- Secret sub-form: one write-only input per secret key the manifest references. -->
|
|
231
|
+
<div class="space-y-2">
|
|
232
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
233
|
+
{{ t('settings.providerConnection.manifestEditor.secretsLabel') }}
|
|
234
|
+
</p>
|
|
235
|
+
<p v-if="!secretKeys.length" class="text-[11px] text-slate-500">
|
|
236
|
+
{{ t('settings.providerConnection.manifestEditor.noSecrets') }}
|
|
237
|
+
</p>
|
|
238
|
+
<p v-else-if="connected" class="text-[11px] text-amber-300/80">
|
|
239
|
+
{{ t('settings.providerConnection.manifestEditor.reenterSecrets') }}
|
|
240
|
+
</p>
|
|
241
|
+
<UFormField v-for="key in secretKeys" :key="key" :label="key">
|
|
242
|
+
<UInput
|
|
243
|
+
v-model="secrets[key]"
|
|
244
|
+
type="password"
|
|
245
|
+
class="w-full font-mono"
|
|
246
|
+
autocomplete="off"
|
|
247
|
+
:data-testid="`manifest-editor-secret-${key}`"
|
|
248
|
+
/>
|
|
249
|
+
</UFormField>
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
<div v-if="supportsTest" class="flex items-center gap-2">
|
|
253
|
+
<UButton
|
|
254
|
+
color="neutral"
|
|
255
|
+
variant="soft"
|
|
256
|
+
size="sm"
|
|
257
|
+
icon="i-lucide-plug-zap"
|
|
258
|
+
:loading="testing"
|
|
259
|
+
:disabled="!canTest"
|
|
260
|
+
data-testid="manifest-editor-test"
|
|
261
|
+
@click="onTest()"
|
|
262
|
+
>
|
|
263
|
+
{{ t('settings.providerConnection.test.button') }}
|
|
264
|
+
</UButton>
|
|
265
|
+
<span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
|
|
266
|
+
{{ testResult.message ?? t('settings.providerConnection.test.ok') }}
|
|
267
|
+
</span>
|
|
268
|
+
<span v-else-if="testResult" class="text-xs text-rose-400">
|
|
269
|
+
{{ testResult.message ?? t('settings.providerConnection.test.failed') }}
|
|
270
|
+
</span>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<div class="flex justify-end">
|
|
274
|
+
<UButton
|
|
275
|
+
color="primary"
|
|
276
|
+
size="sm"
|
|
277
|
+
:loading="busy"
|
|
278
|
+
:disabled="!canSave"
|
|
279
|
+
data-testid="manifest-editor-save"
|
|
280
|
+
@click="onSave()"
|
|
281
|
+
>
|
|
282
|
+
{{ connected ? t('common.save') : t('settings.providerConnection.form.connect') }}
|
|
283
|
+
</UButton>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
</template>
|
package/app/pages/index.vue
CHANGED
|
@@ -69,8 +69,8 @@ const AccountSettingsPanel = defineAsyncComponent(
|
|
|
69
69
|
const ObservabilityConnectionPanel = defineAsyncComponent(
|
|
70
70
|
() => import('~/components/settings/ObservabilityConnectionPanel.vue'),
|
|
71
71
|
)
|
|
72
|
-
const
|
|
73
|
-
() => import('~/components/settings/
|
|
72
|
+
const InfrastructureWindow = defineAsyncComponent(
|
|
73
|
+
() => import('~/components/settings/InfrastructureWindow.vue'),
|
|
74
74
|
)
|
|
75
75
|
const ModelConfigurationPanel = defineAsyncComponent(
|
|
76
76
|
() => import('~/components/settings/ModelConfigurationPanel.vue'),
|
|
@@ -283,7 +283,7 @@ watch(
|
|
|
283
283
|
<WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
|
|
284
284
|
<AccountSettingsPanel v-if="ui.accountSettingsOpen" />
|
|
285
285
|
<ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
|
|
286
|
-
<
|
|
286
|
+
<InfrastructureWindow v-if="ui.infrastructureOpen" />
|
|
287
287
|
<ModelConfigurationPanel v-if="ui.modelConfigOpen" />
|
|
288
288
|
<LocalModelEndpointsPanel v-if="ui.localModelsOpen" />
|
|
289
289
|
<LocalModeSettingsPanel v-if="ui.localModeSettingsOpen" />
|