@cat-factory/app 0.97.0 → 0.98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/environments/EnvironmentSetupWizard.vue +654 -0
- package/app/components/layout/SideBar.vue +14 -0
- package/app/components/panels/inspector/FrontendConfig.vue +17 -7
- package/app/components/panels/inspector/ServiceTestConfig.vue +48 -7
- package/app/composables/api/environments.ts +7 -1
- package/app/composables/api/preflights.ts +16 -0
- package/app/composables/useApi.ts +2 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/environmentWizard.ts +489 -0
- package/app/stores/preflights.ts +48 -0
- package/app/stores/ui.ts +21 -0
- package/i18n/locales/en.json +86 -2
- package/i18n/locales/es.json +86 -2
- package/i18n/locales/fr.json +86 -2
- package/i18n/locales/he.json +86 -2
- package/i18n/locales/ja.json +86 -2
- package/i18n/locales/pl.json +86 -2
- package/i18n/locales/tr.json +86 -2
- package/i18n/locales/uk.json +86 -2
- package/package.json +2 -2
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The environment setup wizard (shared-stacks slice 7): a guided detect → review → preflight →
|
|
3
|
+
// save flow that configures a service frame's `docker-compose` provisioning so the SINGLE Deployer
|
|
4
|
+
// step provisions it (recipe execution + shared stacks + preflights), and the Tester targets the
|
|
5
|
+
// resulting env. All cross-step state + actions live in `useEnvironmentWizardStore`; this component
|
|
6
|
+
// is the presentation shell. On save it registers the workspace's `docker-compose` handler AND
|
|
7
|
+
// writes the recipe onto the frame, then optionally trial-provisions with live logs.
|
|
8
|
+
import { computed, ref, watch } from 'vue'
|
|
9
|
+
import type {
|
|
10
|
+
MergeableRecipeField,
|
|
11
|
+
ProvisioningComposeFileCandidate,
|
|
12
|
+
ProvisioningProfileCandidate,
|
|
13
|
+
ProvisioningSeedDumpCandidate,
|
|
14
|
+
} from '@cat-factory/contracts'
|
|
15
|
+
import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
|
|
16
|
+
|
|
17
|
+
const ui = useUiStore()
|
|
18
|
+
const store = useEnvironmentWizardStore()
|
|
19
|
+
const preflights = usePreflightsStore()
|
|
20
|
+
const { t } = useI18n()
|
|
21
|
+
|
|
22
|
+
// The host-probe runtime isn't wired (a non-local facade 503'd) — the checklist degrades to a note.
|
|
23
|
+
const preflightsUnavailable = computed(() => preflights.available === false)
|
|
24
|
+
|
|
25
|
+
const open = computed({
|
|
26
|
+
get: () => ui.environmentWizardOpen,
|
|
27
|
+
set: (v: boolean) => {
|
|
28
|
+
if (!v) ui.closeEnvironmentSetup()
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Reset + (re)seed the flow whenever the modal opens, honouring the frame the launcher preselected.
|
|
33
|
+
watch(
|
|
34
|
+
open,
|
|
35
|
+
(isOpen) => {
|
|
36
|
+
if (isOpen) store.open(ui.environmentWizardFrameId)
|
|
37
|
+
},
|
|
38
|
+
{ immediate: true },
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
// ---- Stepper header ---------------------------------------------------------
|
|
42
|
+
const STEP_ORDER = ['pick', 'review', 'preflight', 'save'] as const
|
|
43
|
+
type Step = (typeof STEP_ORDER)[number]
|
|
44
|
+
const STEP_LABEL = computed<Record<Step, string>>(() => ({
|
|
45
|
+
pick: t('environmentWizard.steps.pick'),
|
|
46
|
+
review: t('environmentWizard.steps.review'),
|
|
47
|
+
preflight: t('environmentWizard.steps.preflight'),
|
|
48
|
+
save: t('environmentWizard.steps.save'),
|
|
49
|
+
}))
|
|
50
|
+
const currentIndex = computed(() => STEP_ORDER.indexOf(store.step as Step))
|
|
51
|
+
|
|
52
|
+
// ---- Provenance -------------------------------------------------------------
|
|
53
|
+
const FIELD_LABEL = computed<Record<MergeableRecipeField, string>>(() => ({
|
|
54
|
+
composeFiles: t('environmentWizard.field.composeFiles'),
|
|
55
|
+
composeProfiles: t('environmentWizard.field.composeProfiles'),
|
|
56
|
+
envFiles: t('environmentWizard.field.envFiles'),
|
|
57
|
+
externalNetworks: t('environmentWizard.field.externalNetworks'),
|
|
58
|
+
sharedStackRefs: t('environmentWizard.field.sharedStackRefs'),
|
|
59
|
+
prerequisites: t('environmentWizard.field.prerequisites'),
|
|
60
|
+
setupSteps: t('environmentWizard.field.setupSteps'),
|
|
61
|
+
healthGate: t('environmentWizard.field.healthGate'),
|
|
62
|
+
teardownSteps: t('environmentWizard.field.teardownSteps'),
|
|
63
|
+
}))
|
|
64
|
+
const ORIGIN_COLOR: Record<'detector' | 'analyst' | 'both', 'success' | 'info' | 'warning'> = {
|
|
65
|
+
detector: 'success',
|
|
66
|
+
analyst: 'info',
|
|
67
|
+
both: 'warning',
|
|
68
|
+
}
|
|
69
|
+
const ORIGIN_LABEL = computed<Record<'detector' | 'analyst' | 'both', string>>(() => ({
|
|
70
|
+
detector: t('environmentWizard.origin.detector'),
|
|
71
|
+
analyst: t('environmentWizard.origin.analyst'),
|
|
72
|
+
both: t('environmentWizard.origin.both'),
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
// ---- Candidate helpers ------------------------------------------------------
|
|
76
|
+
const composeFileCandidates = computed<ProvisioningComposeFileCandidate[]>(
|
|
77
|
+
() => store.recommendation?.composeFileCandidates ?? [],
|
|
78
|
+
)
|
|
79
|
+
const profileCandidates = computed<ProvisioningProfileCandidate[]>(
|
|
80
|
+
() => store.recommendation?.profileCandidates ?? [],
|
|
81
|
+
)
|
|
82
|
+
const seedDumpCandidates = computed<ProvisioningSeedDumpCandidate[]>(
|
|
83
|
+
() => store.recommendation?.seedDumpCandidates ?? [],
|
|
84
|
+
)
|
|
85
|
+
const composeServiceOptions = computed(() =>
|
|
86
|
+
(store.recommendation?.composeServiceCandidates ?? []).map((c) => ({
|
|
87
|
+
label: c.recommended ? `${c.service} · ${t('environmentWizard.recommended')}` : c.service,
|
|
88
|
+
value: c.service,
|
|
89
|
+
})),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
function fileEnabled(path: string): boolean {
|
|
93
|
+
return store.recipe.composeFiles?.includes(path) ?? false
|
|
94
|
+
}
|
|
95
|
+
function profileEnabled(profile: string): boolean {
|
|
96
|
+
return store.recipe.composeProfiles?.includes(profile) ?? false
|
|
97
|
+
}
|
|
98
|
+
function seedAdded(path: string): boolean {
|
|
99
|
+
return (store.recipe.setupSteps ?? []).some(
|
|
100
|
+
(s) => s.kind === 'compose-exec' && s.stdinFile === path,
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- Raw recipe editor ------------------------------------------------------
|
|
105
|
+
const rawOpen = ref(false)
|
|
106
|
+
const rawText = ref('')
|
|
107
|
+
const rawError = ref<string | null>(null)
|
|
108
|
+
function openRaw() {
|
|
109
|
+
rawText.value = JSON.stringify(store.recipe, null, 2)
|
|
110
|
+
rawError.value = null
|
|
111
|
+
rawOpen.value = true
|
|
112
|
+
}
|
|
113
|
+
function toggleRaw() {
|
|
114
|
+
if (rawOpen.value) rawOpen.value = false
|
|
115
|
+
else openRaw()
|
|
116
|
+
}
|
|
117
|
+
function applyRaw() {
|
|
118
|
+
rawError.value = store.setRecipeFromJson(rawText.value)
|
|
119
|
+
if (!rawError.value) rawOpen.value = false
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- Preflight verdict presentation -----------------------------------------
|
|
123
|
+
const PREFLIGHT_COLOR: Record<'pass' | 'warn' | 'fail', 'success' | 'warning' | 'error'> = {
|
|
124
|
+
pass: 'success',
|
|
125
|
+
warn: 'warning',
|
|
126
|
+
fail: 'error',
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- Step navigation --------------------------------------------------------
|
|
130
|
+
const canLeaveReview = computed(
|
|
131
|
+
() => !!store.recommendation && store.composeService.trim().length > 0,
|
|
132
|
+
)
|
|
133
|
+
function back() {
|
|
134
|
+
const idx = currentIndex.value
|
|
135
|
+
if (idx > 0) store.goToStep(STEP_ORDER[idx - 1]!)
|
|
136
|
+
}
|
|
137
|
+
function next() {
|
|
138
|
+
const idx = currentIndex.value
|
|
139
|
+
if (idx < STEP_ORDER.length - 1) store.goToStep(STEP_ORDER[idx + 1]!)
|
|
140
|
+
}
|
|
141
|
+
</script>
|
|
142
|
+
|
|
143
|
+
<template>
|
|
144
|
+
<UModal
|
|
145
|
+
v-model:open="open"
|
|
146
|
+
:title="t('environmentWizard.title')"
|
|
147
|
+
:description="t('environmentWizard.subtitle')"
|
|
148
|
+
:ui="{ content: 'max-w-3xl' }"
|
|
149
|
+
>
|
|
150
|
+
<template #body>
|
|
151
|
+
<div class="space-y-5" data-testid="env-setup-wizard">
|
|
152
|
+
<!-- stepper header -->
|
|
153
|
+
<ol class="flex items-center gap-2 text-[11px]">
|
|
154
|
+
<li
|
|
155
|
+
v-for="(s, i) in STEP_ORDER"
|
|
156
|
+
:key="s"
|
|
157
|
+
class="flex items-center gap-2"
|
|
158
|
+
:data-testid="`env-setup-crumb-${s}`"
|
|
159
|
+
>
|
|
160
|
+
<span
|
|
161
|
+
class="flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold"
|
|
162
|
+
:class="
|
|
163
|
+
i === currentIndex
|
|
164
|
+
? 'bg-primary-500 text-white'
|
|
165
|
+
: i < currentIndex
|
|
166
|
+
? 'bg-emerald-600/70 text-white'
|
|
167
|
+
: 'bg-slate-700 text-slate-300'
|
|
168
|
+
"
|
|
169
|
+
>{{ i + 1 }}</span
|
|
170
|
+
>
|
|
171
|
+
<span :class="i === currentIndex ? 'text-slate-100' : 'text-slate-500'">
|
|
172
|
+
{{ STEP_LABEL[s] }}
|
|
173
|
+
</span>
|
|
174
|
+
<UIcon
|
|
175
|
+
v-if="i < STEP_ORDER.length - 1"
|
|
176
|
+
name="i-lucide-chevron-right"
|
|
177
|
+
class="h-3 w-3 text-slate-600"
|
|
178
|
+
/>
|
|
179
|
+
</li>
|
|
180
|
+
</ol>
|
|
181
|
+
|
|
182
|
+
<!-- ============================ PICK ============================ -->
|
|
183
|
+
<section v-if="store.step === 'pick'" class="space-y-3" data-testid="env-setup-step-pick">
|
|
184
|
+
<p class="text-sm text-slate-400">{{ t('environmentWizard.pick.intro') }}</p>
|
|
185
|
+
<p v-if="!store.serviceFrames.length" class="text-sm text-slate-500">
|
|
186
|
+
{{ t('environmentWizard.pick.empty') }}
|
|
187
|
+
</p>
|
|
188
|
+
<div v-else class="space-y-1.5">
|
|
189
|
+
<UButton
|
|
190
|
+
v-for="frame in store.serviceFrames"
|
|
191
|
+
:key="frame.id"
|
|
192
|
+
block
|
|
193
|
+
color="neutral"
|
|
194
|
+
variant="soft"
|
|
195
|
+
class="justify-start"
|
|
196
|
+
icon="i-lucide-box"
|
|
197
|
+
:data-testid="`env-setup-frame-${frame.id}`"
|
|
198
|
+
@click="store.selectFrame(frame.id)"
|
|
199
|
+
>
|
|
200
|
+
{{ frame.title }}
|
|
201
|
+
</UButton>
|
|
202
|
+
</div>
|
|
203
|
+
</section>
|
|
204
|
+
|
|
205
|
+
<!-- =========================== REVIEW =========================== -->
|
|
206
|
+
<section
|
|
207
|
+
v-else-if="store.step === 'review'"
|
|
208
|
+
class="space-y-4"
|
|
209
|
+
data-testid="env-setup-step-review"
|
|
210
|
+
>
|
|
211
|
+
<!-- detection status -->
|
|
212
|
+
<div class="flex items-center justify-between gap-2">
|
|
213
|
+
<div class="min-w-0">
|
|
214
|
+
<p class="text-sm font-medium text-slate-200">{{ store.targetFrame?.title }}</p>
|
|
215
|
+
<p class="text-[11px] text-slate-500">
|
|
216
|
+
{{ t('environmentWizard.review.detectHint') }}
|
|
217
|
+
</p>
|
|
218
|
+
</div>
|
|
219
|
+
<UButton
|
|
220
|
+
size="xs"
|
|
221
|
+
variant="soft"
|
|
222
|
+
color="primary"
|
|
223
|
+
icon="i-lucide-wand-sparkles"
|
|
224
|
+
:loading="store.detecting"
|
|
225
|
+
:disabled="!store.hasRepo"
|
|
226
|
+
data-testid="env-setup-detect"
|
|
227
|
+
@click="store.detect()"
|
|
228
|
+
>
|
|
229
|
+
{{ t('environmentWizard.review.detect') }}
|
|
230
|
+
</UButton>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<p v-if="!store.hasRepo" class="text-[12px] text-amber-300/80">
|
|
234
|
+
{{ t('environmentWizard.review.noRepo') }}
|
|
235
|
+
</p>
|
|
236
|
+
<p
|
|
237
|
+
v-else-if="store.detectError"
|
|
238
|
+
class="text-[12px] text-rose-300/80"
|
|
239
|
+
data-testid="env-setup-detect-error"
|
|
240
|
+
>
|
|
241
|
+
{{ t('environmentWizard.review.detectError') }}
|
|
242
|
+
</p>
|
|
243
|
+
|
|
244
|
+
<template v-if="store.recommendation && !store.detecting">
|
|
245
|
+
<!-- deep analysis (opt-in) -->
|
|
246
|
+
<div class="rounded-md border border-slate-800 bg-slate-900/40 p-3">
|
|
247
|
+
<div class="flex items-center justify-between gap-2">
|
|
248
|
+
<div class="min-w-0">
|
|
249
|
+
<p class="text-[12px] font-medium text-slate-300">
|
|
250
|
+
{{ t('environmentWizard.analysis.title') }}
|
|
251
|
+
</p>
|
|
252
|
+
<p class="text-[11px] text-slate-500">
|
|
253
|
+
{{ t('environmentWizard.analysis.hint') }}
|
|
254
|
+
</p>
|
|
255
|
+
</div>
|
|
256
|
+
<UButton
|
|
257
|
+
size="xs"
|
|
258
|
+
variant="soft"
|
|
259
|
+
color="neutral"
|
|
260
|
+
icon="i-lucide-sparkles"
|
|
261
|
+
:loading="store.analysisStatus === 'running'"
|
|
262
|
+
:disabled="!store.canAnalyze || store.analysisStatus === 'running'"
|
|
263
|
+
data-testid="env-setup-run-analysis"
|
|
264
|
+
@click="store.startAnalysis()"
|
|
265
|
+
>
|
|
266
|
+
{{ t('environmentWizard.analysis.run') }}
|
|
267
|
+
</UButton>
|
|
268
|
+
</div>
|
|
269
|
+
<p
|
|
270
|
+
v-if="!store.canAnalyze"
|
|
271
|
+
class="mt-2 text-[11px] text-slate-500"
|
|
272
|
+
data-testid="env-setup-analysis-unavailable"
|
|
273
|
+
>
|
|
274
|
+
{{ t('environmentWizard.analysis.unavailable') }}
|
|
275
|
+
</p>
|
|
276
|
+
<p
|
|
277
|
+
v-else-if="store.analysisStatus === 'failed'"
|
|
278
|
+
class="mt-2 text-[11px] text-rose-300/80"
|
|
279
|
+
>
|
|
280
|
+
{{ t('environmentWizard.analysis.failed') }}
|
|
281
|
+
</p>
|
|
282
|
+
<div
|
|
283
|
+
v-else-if="store.analysisStatus === 'ready'"
|
|
284
|
+
class="mt-2 space-y-2"
|
|
285
|
+
data-testid="env-setup-analysis-ready"
|
|
286
|
+
>
|
|
287
|
+
<p v-if="store.merged?.summary" class="text-[11px] leading-snug text-slate-400">
|
|
288
|
+
{{ store.merged.summary }}
|
|
289
|
+
</p>
|
|
290
|
+
<UButton
|
|
291
|
+
size="xs"
|
|
292
|
+
variant="soft"
|
|
293
|
+
color="primary"
|
|
294
|
+
icon="i-lucide-git-merge"
|
|
295
|
+
data-testid="env-setup-apply-analysis"
|
|
296
|
+
@click="store.applyAnalystDraft()"
|
|
297
|
+
>
|
|
298
|
+
{{ t('environmentWizard.analysis.apply') }}
|
|
299
|
+
</UButton>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
<!-- per-field provenance -->
|
|
304
|
+
<div v-if="store.merged?.fields.length" class="space-y-1.5">
|
|
305
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
306
|
+
{{ t('environmentWizard.review.provenanceTitle') }}
|
|
307
|
+
</p>
|
|
308
|
+
<div class="flex flex-wrap gap-1.5">
|
|
309
|
+
<UBadge
|
|
310
|
+
v-for="f in store.merged.fields"
|
|
311
|
+
:key="f.field"
|
|
312
|
+
:color="ORIGIN_COLOR[f.origin]"
|
|
313
|
+
variant="subtle"
|
|
314
|
+
size="sm"
|
|
315
|
+
:title="f.detectorMessage ?? f.analystRationale ?? ''"
|
|
316
|
+
:data-testid="`env-setup-field-${f.field}`"
|
|
317
|
+
>
|
|
318
|
+
{{ FIELD_LABEL[f.field] }} · {{ ORIGIN_LABEL[f.origin] }}
|
|
319
|
+
</UBadge>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
<!-- exposed compose service + port -->
|
|
324
|
+
<div class="grid grid-cols-2 gap-3">
|
|
325
|
+
<UFormField :label="t('environmentWizard.review.service')" required>
|
|
326
|
+
<USelect
|
|
327
|
+
v-if="composeServiceOptions.length"
|
|
328
|
+
v-model="store.composeService"
|
|
329
|
+
:items="composeServiceOptions"
|
|
330
|
+
value-key="value"
|
|
331
|
+
:placeholder="t('environmentWizard.review.servicePlaceholder')"
|
|
332
|
+
class="w-full"
|
|
333
|
+
data-testid="env-setup-service-select"
|
|
334
|
+
/>
|
|
335
|
+
<UInput
|
|
336
|
+
v-else
|
|
337
|
+
v-model="store.composeService"
|
|
338
|
+
:placeholder="t('environmentWizard.review.servicePlaceholder')"
|
|
339
|
+
class="w-full"
|
|
340
|
+
data-testid="env-setup-service-input"
|
|
341
|
+
/>
|
|
342
|
+
</UFormField>
|
|
343
|
+
<UFormField :label="t('environmentWizard.review.port')">
|
|
344
|
+
<UInput
|
|
345
|
+
v-model.number="store.exposedPort"
|
|
346
|
+
type="number"
|
|
347
|
+
class="w-full"
|
|
348
|
+
data-testid="env-setup-port"
|
|
349
|
+
/>
|
|
350
|
+
</UFormField>
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
<!-- compose file layering -->
|
|
354
|
+
<div v-if="composeFileCandidates.length" class="space-y-1.5">
|
|
355
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
356
|
+
{{ t('environmentWizard.review.composeFiles') }}
|
|
357
|
+
</p>
|
|
358
|
+
<div class="flex flex-wrap gap-1.5">
|
|
359
|
+
<UButton
|
|
360
|
+
v-for="c in composeFileCandidates"
|
|
361
|
+
:key="c.path"
|
|
362
|
+
size="xs"
|
|
363
|
+
:color="fileEnabled(c.path) ? 'primary' : 'neutral'"
|
|
364
|
+
:variant="fileEnabled(c.path) ? 'soft' : 'ghost'"
|
|
365
|
+
:icon="fileEnabled(c.path) ? 'i-lucide-check' : 'i-lucide-plus'"
|
|
366
|
+
:data-testid="`env-setup-file-${c.name}`"
|
|
367
|
+
@click="store.toggleComposeFile(c.path)"
|
|
368
|
+
>
|
|
369
|
+
{{ c.name }}<span v-if="c.os" class="ms-1 text-[9px] opacity-70">{{ c.os }}</span>
|
|
370
|
+
</UButton>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
|
|
374
|
+
<!-- compose profiles -->
|
|
375
|
+
<div v-if="profileCandidates.length" class="space-y-1.5">
|
|
376
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
377
|
+
{{ t('environmentWizard.review.profiles') }}
|
|
378
|
+
</p>
|
|
379
|
+
<div class="flex flex-wrap gap-1.5">
|
|
380
|
+
<UButton
|
|
381
|
+
v-for="c in profileCandidates"
|
|
382
|
+
:key="c.profile"
|
|
383
|
+
size="xs"
|
|
384
|
+
:color="profileEnabled(c.profile) ? 'primary' : 'neutral'"
|
|
385
|
+
:variant="profileEnabled(c.profile) ? 'soft' : 'ghost'"
|
|
386
|
+
:icon="profileEnabled(c.profile) ? 'i-lucide-check' : 'i-lucide-plus'"
|
|
387
|
+
:data-testid="`env-setup-profile-${c.profile}`"
|
|
388
|
+
@click="store.toggleProfile(c.profile)"
|
|
389
|
+
>
|
|
390
|
+
{{ c.profile }}
|
|
391
|
+
</UButton>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
<!-- seed dumps -->
|
|
396
|
+
<div v-if="seedDumpCandidates.length" class="space-y-1.5">
|
|
397
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
398
|
+
{{ t('environmentWizard.review.seedDumps') }}
|
|
399
|
+
</p>
|
|
400
|
+
<div class="space-y-1">
|
|
401
|
+
<div
|
|
402
|
+
v-for="c in seedDumpCandidates"
|
|
403
|
+
:key="c.path"
|
|
404
|
+
class="flex items-center justify-between gap-2 rounded border border-slate-800 bg-slate-900/40 px-2 py-1"
|
|
405
|
+
>
|
|
406
|
+
<span class="truncate text-[11px] text-slate-300">{{ c.path }}</span>
|
|
407
|
+
<UButton
|
|
408
|
+
size="xs"
|
|
409
|
+
:color="seedAdded(c.path) ? 'success' : 'neutral'"
|
|
410
|
+
:variant="seedAdded(c.path) ? 'soft' : 'ghost'"
|
|
411
|
+
:icon="seedAdded(c.path) ? 'i-lucide-check' : 'i-lucide-plus'"
|
|
412
|
+
:disabled="seedAdded(c.path)"
|
|
413
|
+
:data-testid="`env-setup-seed-${c.name}`"
|
|
414
|
+
@click="store.addSeedStep(c)"
|
|
415
|
+
>
|
|
416
|
+
{{
|
|
417
|
+
seedAdded(c.path)
|
|
418
|
+
? t('environmentWizard.review.seedAdded')
|
|
419
|
+
: t('environmentWizard.review.seedAdd')
|
|
420
|
+
}}
|
|
421
|
+
</UButton>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
|
|
426
|
+
<!-- raw recipe editor -->
|
|
427
|
+
<div>
|
|
428
|
+
<UButton
|
|
429
|
+
size="xs"
|
|
430
|
+
variant="ghost"
|
|
431
|
+
color="neutral"
|
|
432
|
+
:icon="rawOpen ? 'i-lucide-chevron-down' : 'i-lucide-code'"
|
|
433
|
+
data-testid="env-setup-raw-toggle"
|
|
434
|
+
@click="toggleRaw"
|
|
435
|
+
>
|
|
436
|
+
{{ t('environmentWizard.review.rawEditor') }}
|
|
437
|
+
</UButton>
|
|
438
|
+
<div v-if="rawOpen" class="mt-2 space-y-2">
|
|
439
|
+
<UTextarea
|
|
440
|
+
v-model="rawText"
|
|
441
|
+
:rows="10"
|
|
442
|
+
class="w-full font-mono text-[11px]"
|
|
443
|
+
data-testid="env-setup-raw-text"
|
|
444
|
+
/>
|
|
445
|
+
<p
|
|
446
|
+
v-if="rawError"
|
|
447
|
+
class="text-[11px] text-rose-300/80"
|
|
448
|
+
data-testid="env-setup-raw-error"
|
|
449
|
+
>
|
|
450
|
+
{{ rawError }}
|
|
451
|
+
</p>
|
|
452
|
+
<div class="flex justify-end">
|
|
453
|
+
<UButton
|
|
454
|
+
size="xs"
|
|
455
|
+
color="primary"
|
|
456
|
+
data-testid="env-setup-raw-apply"
|
|
457
|
+
@click="applyRaw"
|
|
458
|
+
>
|
|
459
|
+
{{ t('environmentWizard.review.rawApply') }}
|
|
460
|
+
</UButton>
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
</template>
|
|
465
|
+
</section>
|
|
466
|
+
|
|
467
|
+
<!-- ========================== PREFLIGHT ========================= -->
|
|
468
|
+
<section
|
|
469
|
+
v-else-if="store.step === 'preflight'"
|
|
470
|
+
class="space-y-3"
|
|
471
|
+
data-testid="env-setup-step-preflight"
|
|
472
|
+
>
|
|
473
|
+
<div class="flex items-center justify-between gap-2">
|
|
474
|
+
<p class="text-sm text-slate-400">{{ t('environmentWizard.preflight.intro') }}</p>
|
|
475
|
+
<UButton
|
|
476
|
+
size="xs"
|
|
477
|
+
variant="soft"
|
|
478
|
+
color="primary"
|
|
479
|
+
icon="i-lucide-list-checks"
|
|
480
|
+
:loading="store.preflightRunning"
|
|
481
|
+
data-testid="env-setup-preflight-run"
|
|
482
|
+
@click="store.runPreflight()"
|
|
483
|
+
>
|
|
484
|
+
{{ t('environmentWizard.preflight.run') }}
|
|
485
|
+
</UButton>
|
|
486
|
+
</div>
|
|
487
|
+
|
|
488
|
+
<p
|
|
489
|
+
v-if="!store.recipe.prerequisites?.length"
|
|
490
|
+
class="text-[12px] text-slate-500"
|
|
491
|
+
data-testid="env-setup-preflight-none"
|
|
492
|
+
>
|
|
493
|
+
{{ t('environmentWizard.preflight.none') }}
|
|
494
|
+
</p>
|
|
495
|
+
<p
|
|
496
|
+
v-else-if="preflightsUnavailable"
|
|
497
|
+
class="text-[12px] text-amber-300/80"
|
|
498
|
+
data-testid="env-setup-preflight-unavailable"
|
|
499
|
+
>
|
|
500
|
+
{{ t('environmentWizard.preflight.unavailable') }}
|
|
501
|
+
</p>
|
|
502
|
+
<p
|
|
503
|
+
v-if="store.preflightError"
|
|
504
|
+
class="text-[12px] text-rose-300/80"
|
|
505
|
+
data-testid="env-setup-preflight-error"
|
|
506
|
+
>
|
|
507
|
+
{{ store.preflightError }}
|
|
508
|
+
</p>
|
|
509
|
+
|
|
510
|
+
<ul
|
|
511
|
+
v-if="store.preflightResults?.length"
|
|
512
|
+
class="space-y-2"
|
|
513
|
+
data-testid="env-setup-preflight-results"
|
|
514
|
+
>
|
|
515
|
+
<li
|
|
516
|
+
v-for="r in store.preflightResults"
|
|
517
|
+
:key="r.title"
|
|
518
|
+
class="rounded border border-slate-800 bg-slate-900/40 p-2"
|
|
519
|
+
>
|
|
520
|
+
<div class="flex items-center gap-2">
|
|
521
|
+
<UBadge :color="PREFLIGHT_COLOR[r.status]" variant="subtle" size="sm">
|
|
522
|
+
{{ r.status }}
|
|
523
|
+
</UBadge>
|
|
524
|
+
<span class="text-[12px] text-slate-200">{{ r.title }}</span>
|
|
525
|
+
<span v-if="!r.required" class="ms-auto text-[10px] text-slate-500">
|
|
526
|
+
{{ t('environmentWizard.preflight.optional') }}
|
|
527
|
+
</span>
|
|
528
|
+
</div>
|
|
529
|
+
<p v-if="r.detail" class="mt-1 text-[11px] text-slate-400">{{ r.detail }}</p>
|
|
530
|
+
<pre
|
|
531
|
+
v-if="r.status !== 'pass' && r.remediation"
|
|
532
|
+
class="mt-1 max-h-40 overflow-auto whitespace-pre-wrap rounded border border-amber-900/40 bg-amber-950/20 p-1.5 text-[11px] text-amber-200/90"
|
|
533
|
+
>{{ r.remediation }}</pre
|
|
534
|
+
>
|
|
535
|
+
</li>
|
|
536
|
+
</ul>
|
|
537
|
+
</section>
|
|
538
|
+
|
|
539
|
+
<!-- ============================ SAVE ============================ -->
|
|
540
|
+
<section v-else class="space-y-3" data-testid="env-setup-step-save">
|
|
541
|
+
<UFormField
|
|
542
|
+
:label="t('environmentWizard.save.handlerLabel')"
|
|
543
|
+
:description="t('environmentWizard.save.handlerHint')"
|
|
544
|
+
>
|
|
545
|
+
<UInput
|
|
546
|
+
v-model="store.handlerLabel"
|
|
547
|
+
class="w-full"
|
|
548
|
+
data-testid="env-setup-handler-label"
|
|
549
|
+
/>
|
|
550
|
+
</UFormField>
|
|
551
|
+
|
|
552
|
+
<div
|
|
553
|
+
class="rounded-md border border-slate-800 bg-slate-900/40 p-3 text-[12px] text-slate-300"
|
|
554
|
+
>
|
|
555
|
+
<p>
|
|
556
|
+
{{
|
|
557
|
+
t('environmentWizard.save.summary', {
|
|
558
|
+
frame: store.targetFrame?.title ?? '',
|
|
559
|
+
service: store.composeService,
|
|
560
|
+
})
|
|
561
|
+
}}
|
|
562
|
+
</p>
|
|
563
|
+
</div>
|
|
564
|
+
|
|
565
|
+
<p
|
|
566
|
+
v-if="store.saveError"
|
|
567
|
+
class="text-[12px] text-rose-300/80"
|
|
568
|
+
data-testid="env-setup-save-error"
|
|
569
|
+
>
|
|
570
|
+
{{ store.saveError }}
|
|
571
|
+
</p>
|
|
572
|
+
|
|
573
|
+
<div v-if="!store.saved" class="flex justify-end">
|
|
574
|
+
<UButton
|
|
575
|
+
color="primary"
|
|
576
|
+
icon="i-lucide-save"
|
|
577
|
+
:loading="store.saving"
|
|
578
|
+
:disabled="!store.composeService.trim()"
|
|
579
|
+
data-testid="env-setup-save"
|
|
580
|
+
@click="store.save()"
|
|
581
|
+
>
|
|
582
|
+
{{ t('environmentWizard.save.save') }}
|
|
583
|
+
</UButton>
|
|
584
|
+
</div>
|
|
585
|
+
|
|
586
|
+
<!-- saved: confirmation + optional trial provision -->
|
|
587
|
+
<template v-else>
|
|
588
|
+
<div
|
|
589
|
+
class="flex items-center gap-2 rounded-md border border-emerald-800/50 bg-emerald-950/30 p-2 text-[12px] text-emerald-200"
|
|
590
|
+
data-testid="env-setup-saved"
|
|
591
|
+
>
|
|
592
|
+
<UIcon name="i-lucide-check-circle" class="h-4 w-4" />
|
|
593
|
+
{{ t('environmentWizard.save.saved') }}
|
|
594
|
+
</div>
|
|
595
|
+
|
|
596
|
+
<div class="flex items-center justify-between gap-2">
|
|
597
|
+
<p class="text-[11px] text-slate-500">{{ t('environmentWizard.trial.hint') }}</p>
|
|
598
|
+
<UButton
|
|
599
|
+
size="xs"
|
|
600
|
+
variant="soft"
|
|
601
|
+
color="neutral"
|
|
602
|
+
icon="i-lucide-play"
|
|
603
|
+
:loading="store.trialing"
|
|
604
|
+
:disabled="store.trialStarted"
|
|
605
|
+
data-testid="env-setup-trial"
|
|
606
|
+
@click="store.trialProvision()"
|
|
607
|
+
>
|
|
608
|
+
{{ t('environmentWizard.trial.run') }}
|
|
609
|
+
</UButton>
|
|
610
|
+
</div>
|
|
611
|
+
<p v-if="store.trialError" class="text-[11px] text-rose-300/80">
|
|
612
|
+
{{ store.trialError }}
|
|
613
|
+
</p>
|
|
614
|
+
<ProvisioningLogsDrawer v-if="store.trialStarted" subsystem="environment" />
|
|
615
|
+
</template>
|
|
616
|
+
</section>
|
|
617
|
+
|
|
618
|
+
<!-- footer nav -->
|
|
619
|
+
<div class="flex items-center justify-between border-t border-slate-800 pt-3">
|
|
620
|
+
<UButton
|
|
621
|
+
color="neutral"
|
|
622
|
+
variant="ghost"
|
|
623
|
+
:disabled="currentIndex === 0"
|
|
624
|
+
icon="i-lucide-arrow-left"
|
|
625
|
+
data-testid="env-setup-back"
|
|
626
|
+
@click="back"
|
|
627
|
+
>
|
|
628
|
+
{{ t('common.back') }}
|
|
629
|
+
</UButton>
|
|
630
|
+
<UButton
|
|
631
|
+
v-if="store.step !== 'save'"
|
|
632
|
+
color="primary"
|
|
633
|
+
trailing-icon="i-lucide-arrow-right"
|
|
634
|
+
:disabled="(store.step === 'review' && !canLeaveReview) || store.step === 'pick'"
|
|
635
|
+
data-testid="env-setup-next"
|
|
636
|
+
@click="next"
|
|
637
|
+
>
|
|
638
|
+
{{ t('common.next') }}
|
|
639
|
+
</UButton>
|
|
640
|
+
<UButton
|
|
641
|
+
v-else
|
|
642
|
+
color="neutral"
|
|
643
|
+
variant="soft"
|
|
644
|
+
icon="i-lucide-check"
|
|
645
|
+
data-testid="env-setup-done"
|
|
646
|
+
@click="ui.closeEnvironmentSetup()"
|
|
647
|
+
>
|
|
648
|
+
{{ t('common.done') }}
|
|
649
|
+
</UButton>
|
|
650
|
+
</div>
|
|
651
|
+
</div>
|
|
652
|
+
</template>
|
|
653
|
+
</UModal>
|
|
654
|
+
</template>
|
|
@@ -282,6 +282,20 @@ watch(
|
|
|
282
282
|
>
|
|
283
283
|
{{ t('nav.infrastructure') }}
|
|
284
284
|
</UButton>
|
|
285
|
+
<!-- Guided detect → review → preflight → save flow for a service frame's
|
|
286
|
+
docker-compose provisioning (so the single Deployer stands its env up). -->
|
|
287
|
+
<UButton
|
|
288
|
+
block
|
|
289
|
+
color="primary"
|
|
290
|
+
variant="soft"
|
|
291
|
+
size="sm"
|
|
292
|
+
icon="i-lucide-flask-conical"
|
|
293
|
+
class="justify-start"
|
|
294
|
+
data-testid="nav-environment-setup"
|
|
295
|
+
@click="ui.openEnvironmentSetup()"
|
|
296
|
+
>
|
|
297
|
+
{{ t('nav.environmentSetup') }}
|
|
298
|
+
</UButton>
|
|
285
299
|
</div>
|
|
286
300
|
</section>
|
|
287
301
|
</template>
|