@cat-factory/app 0.47.2 → 0.47.3
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 +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
|
@@ -259,32 +259,53 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
259
259
|
}
|
|
260
260
|
|
|
261
261
|
// --- Infrastructure (ephemeral environments + self-hosted runner pool) -----
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
262
|
+
// The two providers — container agents (runner pool) and Tester environments — are the
|
|
263
|
+
// same "bring your own infra" idea and the same custom pool typically backs both, so they
|
|
264
|
+
// collapse into ONE row opening the tabbed Infrastructure window. The row shows a combined
|
|
265
|
+
// per-concern summary ("Agents: connected · Envs: not connected"). Each concern still gates
|
|
266
|
+
// on its own availability probe (a tab whose backend is off simply doesn't render), so the
|
|
267
|
+
// row appears whenever EITHER is available. The ProviderConfigBanner handles the louder
|
|
268
|
+
// "missing mandatory fields" warning.
|
|
265
269
|
const infra: IntegrationItem[] = []
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
270
|
+
const agentsAvailable = providerConnections.isAvailable('runner-pool')
|
|
271
|
+
const envsAvailable = providerConnections.isAvailable('environment')
|
|
272
|
+
if (agentsAvailable || envsAvailable) {
|
|
273
|
+
const agentsConn = providerConnections.connectionFor('runner-pool')
|
|
274
|
+
const envsConn = providerConnections.connectionFor('environment')
|
|
275
|
+
// Combined summary across the available concerns only.
|
|
276
|
+
const stateWord = (conn: unknown) =>
|
|
277
|
+
conn
|
|
278
|
+
? t('layout.integrationsHub.status.connected')
|
|
279
|
+
: t('layout.integrationsHub.status.notConnected')
|
|
280
|
+
const parts: string[] = []
|
|
281
|
+
if (agentsAvailable)
|
|
282
|
+
parts.push(
|
|
283
|
+
t('layout.integrationsHub.items.infrastructure.agents', { state: stateWord(agentsConn) }),
|
|
284
|
+
)
|
|
285
|
+
if (envsAvailable)
|
|
286
|
+
parts.push(
|
|
287
|
+
t('layout.integrationsHub.items.infrastructure.envs', { state: stateWord(envsConn) }),
|
|
288
|
+
)
|
|
289
|
+
// Default the window to the agents tab when available (the common case), else envs.
|
|
290
|
+
const defaultKind = agentsAvailable ? 'runner-pool' : 'environment'
|
|
291
|
+
// A concern counts as satisfied when it's unavailable (nothing to configure) or connected.
|
|
292
|
+
// The row is "connected" (green) only when EVERY available concern is connected — a
|
|
293
|
+
// half-connected state shows the amber "attention" badge instead, so the green badge can't
|
|
294
|
+
// claim "done" while one concern is still unconfigured.
|
|
295
|
+
const agentsSatisfied = !agentsAvailable || !!agentsConn
|
|
296
|
+
const envsSatisfied = !envsAvailable || !!envsConn
|
|
297
|
+
const allConnected = agentsSatisfied && envsSatisfied
|
|
298
|
+
const someConnected = !!agentsConn || !!envsConn
|
|
280
299
|
infra.push({
|
|
281
|
-
key: '
|
|
300
|
+
key: 'infrastructure',
|
|
282
301
|
icon: 'i-lucide-server-cog',
|
|
283
|
-
label: t('layout.integrationsHub.items.
|
|
284
|
-
description: t('layout.integrationsHub.items.
|
|
285
|
-
status:
|
|
286
|
-
connected:
|
|
287
|
-
|
|
302
|
+
label: t('layout.integrationsHub.items.infrastructure.label'),
|
|
303
|
+
description: t('layout.integrationsHub.items.infrastructure.description'),
|
|
304
|
+
status: parts.join(' · '),
|
|
305
|
+
connected: allConnected,
|
|
306
|
+
attention: someConnected && !allConnected,
|
|
307
|
+
attentionLabel: parts.join(' · '),
|
|
308
|
+
onClick: () => go(() => ui.openProviderConnection(defaultKind)),
|
|
288
309
|
})
|
|
289
310
|
}
|
|
290
311
|
// Local-mode-only: the warm-container pool + checkout reuse for the local runner. Shown
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The single tabbed "Infrastructure" window. It merges what used to be two separate
|
|
3
|
+
// Integrations-Hub entries — the self-hosted runner pool (container agents) and the
|
|
4
|
+
// ephemeral-environment provider (Tester environments) — into one surface, because the
|
|
5
|
+
// same custom pool typically backs both jobs, so configuring them together reflects
|
|
6
|
+
// reality. Each provider gets its own tab (ProviderConnectionTab); a tab whose backend
|
|
7
|
+
// integration is disabled (503) simply doesn't render.
|
|
8
|
+
//
|
|
9
|
+
// The local-mode delegation toggles are cross-cutting (one per concern), so they live at
|
|
10
|
+
// the TOP of the window rather than buried in one tab — removing the old awkward
|
|
11
|
+
// cross-link hint that pointed from the runner-pool screen back to the env screen.
|
|
12
|
+
import { computed, ref, watch } from 'vue'
|
|
13
|
+
import type { ProviderConnectionKind } from '~/types/providerConnections'
|
|
14
|
+
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
15
|
+
import ProviderConnectionTab from '~/components/settings/ProviderConnectionTab.vue'
|
|
16
|
+
|
|
17
|
+
const { t } = useI18n()
|
|
18
|
+
const ui = useUiStore()
|
|
19
|
+
const store = useProviderConnectionsStore()
|
|
20
|
+
const auth = useAuthStore()
|
|
21
|
+
const settings = useWorkspaceSettingsStore()
|
|
22
|
+
const toast = useToast()
|
|
23
|
+
|
|
24
|
+
const open = computed({
|
|
25
|
+
get: () => ui.infrastructureOpen,
|
|
26
|
+
set: (v: boolean) => {
|
|
27
|
+
if (!v) ui.closeProviderConnection()
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
const back = useIntegrationBack(open)
|
|
31
|
+
|
|
32
|
+
// Each concern gates on its own availability probe; an unavailable tab isn't offered.
|
|
33
|
+
const agentsAvailable = computed(() => store.isAvailable('runner-pool'))
|
|
34
|
+
const envsAvailable = computed(() => store.isAvailable('environment'))
|
|
35
|
+
|
|
36
|
+
const tabs = computed(() => {
|
|
37
|
+
const out: { value: ProviderConnectionKind; label: string; icon: string; slot: string }[] = []
|
|
38
|
+
if (agentsAvailable.value)
|
|
39
|
+
out.push({
|
|
40
|
+
value: 'runner-pool',
|
|
41
|
+
label: t('settings.providerConnection.tabs.containerAgents'),
|
|
42
|
+
icon: 'i-lucide-server-cog',
|
|
43
|
+
slot: 'runner-pool',
|
|
44
|
+
})
|
|
45
|
+
if (envsAvailable.value)
|
|
46
|
+
out.push({
|
|
47
|
+
value: 'environment',
|
|
48
|
+
label: t('settings.providerConnection.tabs.testEnvironments'),
|
|
49
|
+
icon: 'i-lucide-cloud',
|
|
50
|
+
slot: 'environment',
|
|
51
|
+
})
|
|
52
|
+
return out
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const activeTab = ref<ProviderConnectionKind>(ui.infrastructureTab)
|
|
56
|
+
|
|
57
|
+
// Honour the deep-linked tab each time the window opens (e.g. the banner's per-kind
|
|
58
|
+
// "Configure…" button), falling back to the first available tab if the requested one is off.
|
|
59
|
+
watch(
|
|
60
|
+
open,
|
|
61
|
+
(isOpen) => {
|
|
62
|
+
if (!isOpen) return
|
|
63
|
+
void store.ensureLoaded().catch(() => {})
|
|
64
|
+
const requested = ui.infrastructureTab
|
|
65
|
+
const available = tabs.value.map((x) => x.value)
|
|
66
|
+
activeTab.value = available.includes(requested) ? requested : (available[0] ?? requested)
|
|
67
|
+
},
|
|
68
|
+
{ immediate: true },
|
|
69
|
+
)
|
|
70
|
+
// When availability resolves after open, re-pin onto a valid tab — but keep honouring the
|
|
71
|
+
// deep-linked request. The two availability probes resolve independently, so `tabs` can pass
|
|
72
|
+
// through a transient single-tab list (e.g. the runner-pool probe lands a tick before the
|
|
73
|
+
// environment one). We must NOT let that transient list steal focus from a still-loading
|
|
74
|
+
// requested tab, so only fall back to the first tab once loading has fully settled.
|
|
75
|
+
watch([tabs, () => store.loaded], () => {
|
|
76
|
+
const list = tabs.value
|
|
77
|
+
if (list.some((x) => x.value === activeTab.value)) return
|
|
78
|
+
const requested = ui.infrastructureTab
|
|
79
|
+
if (list.some((x) => x.value === requested)) {
|
|
80
|
+
activeTab.value = requested
|
|
81
|
+
} else if (store.loaded && list.length) {
|
|
82
|
+
activeTab.value = list[0]!.value
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// --- Local-mode infrastructure delegation (cross-cutting; shown only in local mode) ---
|
|
87
|
+
// In local mode this is where a developer chooses, per workspace, whether to run on this
|
|
88
|
+
// machine (host Docker for agents, in-container docker-compose for the Tester) or delegate
|
|
89
|
+
// to an external service. Each toggle is enabled only once its provider is registered.
|
|
90
|
+
const isLocal = computed(() => auth.localMode?.enabled === true)
|
|
91
|
+
const runnerPoolRegistered = computed(() => !!store.connectionFor('runner-pool'))
|
|
92
|
+
const envRegistered = computed(() => !!store.connectionFor('environment'))
|
|
93
|
+
const savingDelegation = ref(false)
|
|
94
|
+
|
|
95
|
+
async function setDelegation(patch: {
|
|
96
|
+
delegateAgentsToRunnerPool?: boolean
|
|
97
|
+
delegateTestEnvToProvider?: boolean
|
|
98
|
+
}) {
|
|
99
|
+
savingDelegation.value = true
|
|
100
|
+
try {
|
|
101
|
+
await settings.update(patch)
|
|
102
|
+
} catch (e) {
|
|
103
|
+
toast.add({
|
|
104
|
+
title: t('settings.providerConnection.delegation.updateFailed'),
|
|
105
|
+
description: e instanceof Error ? e.message : String(e),
|
|
106
|
+
icon: 'i-lucide-triangle-alert',
|
|
107
|
+
color: 'error',
|
|
108
|
+
})
|
|
109
|
+
} finally {
|
|
110
|
+
savingDelegation.value = false
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function selectTab(kind: ProviderConnectionKind) {
|
|
115
|
+
activeTab.value = kind
|
|
116
|
+
}
|
|
117
|
+
</script>
|
|
118
|
+
|
|
119
|
+
<template>
|
|
120
|
+
<UModal
|
|
121
|
+
v-model:open="open"
|
|
122
|
+
:title="t('settings.providerConnection.windowTitle')"
|
|
123
|
+
:ui="{ content: 'max-w-xl' }"
|
|
124
|
+
>
|
|
125
|
+
<template #title>
|
|
126
|
+
<IntegrationBackTitle :title="t('settings.providerConnection.windowTitle')" @back="back" />
|
|
127
|
+
</template>
|
|
128
|
+
<template #body>
|
|
129
|
+
<div class="space-y-4">
|
|
130
|
+
<!-- Local-mode delegation: the local-vs-external choice for BOTH container agents
|
|
131
|
+
AND the Tester's ephemeral environments, made once here at the top. -->
|
|
132
|
+
<section
|
|
133
|
+
v-if="isLocal"
|
|
134
|
+
class="space-y-3 rounded-lg border border-slate-700 bg-slate-900/40 p-3"
|
|
135
|
+
>
|
|
136
|
+
<div>
|
|
137
|
+
<h3 class="text-sm font-semibold text-slate-200">
|
|
138
|
+
{{ t('settings.providerConnection.delegation.title') }}
|
|
139
|
+
</h3>
|
|
140
|
+
<p class="mt-1 text-[11px] text-slate-400">
|
|
141
|
+
{{ t('settings.providerConnection.delegation.intro') }}
|
|
142
|
+
</p>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<!-- Container agents → self-hosted runner pool -->
|
|
146
|
+
<div class="space-y-1">
|
|
147
|
+
<label class="flex items-center gap-2">
|
|
148
|
+
<USwitch
|
|
149
|
+
size="sm"
|
|
150
|
+
:model-value="settings.settings.delegateAgentsToRunnerPool"
|
|
151
|
+
:disabled="savingDelegation || !runnerPoolRegistered"
|
|
152
|
+
@update:model-value="(v) => setDelegation({ delegateAgentsToRunnerPool: v })"
|
|
153
|
+
/>
|
|
154
|
+
<span class="text-sm text-slate-200">
|
|
155
|
+
{{ t('settings.providerConnection.delegation.agentsToggle') }}
|
|
156
|
+
</span>
|
|
157
|
+
</label>
|
|
158
|
+
<p class="pl-9 text-[11px] text-slate-400">
|
|
159
|
+
{{ t('settings.providerConnection.delegation.agentsHint') }}
|
|
160
|
+
<template v-if="!runnerPoolRegistered">
|
|
161
|
+
<i18n-t
|
|
162
|
+
keypath="settings.providerConnection.delegation.registerPoolPrompt"
|
|
163
|
+
tag="span"
|
|
164
|
+
scope="global"
|
|
165
|
+
>
|
|
166
|
+
<template #link>
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
class="text-sky-400 underline underline-offset-2 hover:text-sky-300"
|
|
170
|
+
@click="selectTab('runner-pool')"
|
|
171
|
+
>
|
|
172
|
+
{{ t('settings.providerConnection.delegation.registerPoolLink') }}
|
|
173
|
+
</button>
|
|
174
|
+
</template>
|
|
175
|
+
</i18n-t>
|
|
176
|
+
</template>
|
|
177
|
+
</p>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<!-- Tester environments → environment provider -->
|
|
181
|
+
<div class="space-y-1">
|
|
182
|
+
<label class="flex items-center gap-2">
|
|
183
|
+
<USwitch
|
|
184
|
+
size="sm"
|
|
185
|
+
:model-value="settings.settings.delegateTestEnvToProvider"
|
|
186
|
+
:disabled="savingDelegation || !envRegistered"
|
|
187
|
+
@update:model-value="(v) => setDelegation({ delegateTestEnvToProvider: v })"
|
|
188
|
+
/>
|
|
189
|
+
<span class="text-sm text-slate-200">
|
|
190
|
+
{{ t('settings.providerConnection.delegation.envToggle') }}
|
|
191
|
+
</span>
|
|
192
|
+
</label>
|
|
193
|
+
<p class="pl-9 text-[11px] text-slate-400">
|
|
194
|
+
{{ t('settings.providerConnection.delegation.envHint') }}
|
|
195
|
+
</p>
|
|
196
|
+
</div>
|
|
197
|
+
</section>
|
|
198
|
+
|
|
199
|
+
<UTabs
|
|
200
|
+
v-if="tabs.length"
|
|
201
|
+
v-model="activeTab"
|
|
202
|
+
:items="tabs"
|
|
203
|
+
variant="link"
|
|
204
|
+
:ui="{ root: 'gap-4' }"
|
|
205
|
+
data-testid="infrastructure-tabs"
|
|
206
|
+
>
|
|
207
|
+
<template #runner-pool>
|
|
208
|
+
<ProviderConnectionTab kind="runner-pool" />
|
|
209
|
+
</template>
|
|
210
|
+
<template #environment>
|
|
211
|
+
<ProviderConnectionTab kind="environment" />
|
|
212
|
+
</template>
|
|
213
|
+
</UTabs>
|
|
214
|
+
|
|
215
|
+
<p v-else class="px-1 py-6 text-center text-sm text-slate-500">
|
|
216
|
+
{{ t('settings.providerConnection.noneAvailable') }}
|
|
217
|
+
</p>
|
|
218
|
+
</div>
|
|
219
|
+
</template>
|
|
220
|
+
</UModal>
|
|
221
|
+
</template>
|
|
@@ -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>
|