@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.
@@ -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>