@cat-factory/app 0.134.0 → 0.135.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 +70 -632
- package/app/components/environments/steps/EnvPickStep.vue +40 -0
- package/app/components/environments/steps/EnvPreflightStep.vue +115 -0
- package/app/components/environments/steps/EnvReviewStep.vue +381 -0
- package/app/components/environments/steps/EnvSaveStep.vue +109 -0
- package/app/components/environments/steps/JourneyStepNav.vue +30 -0
- package/app/modular/journeys/environmentSetup.frame.ts +31 -0
- package/app/modular/journeys/environmentSetup.logic.spec.ts +34 -0
- package/app/modular/journeys/environmentSetup.logic.ts +86 -0
- package/app/modular/journeys/environmentSetup.ts +143 -0
- package/app/modular/journeys/persistence.spec.ts +68 -0
- package/app/modular/journeys/persistence.ts +58 -0
- package/app/modular/registry.ts +9 -3
- package/app/plugins/modular.client.ts +37 -4
- package/app/stores/environmentWizard.ts +23 -27
- package/package.json +11 -10
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The environment-setup journey's PICK step (slice 3 of the modular-vue adoption).
|
|
3
|
+
// Lists the workspace's service frames; choosing one fires the journey's `next`
|
|
4
|
+
// exit carrying the frame id, which the transition folds into journey state and
|
|
5
|
+
// advances to the review step. Rendered by `JourneyOutlet`, so it receives the
|
|
6
|
+
// `ModuleEntryProps` (`input` / `exit` / `goBack`) as attributes.
|
|
7
|
+
import type { EnvSelectOutput } from '~/modular/journeys/environmentSetup.logic'
|
|
8
|
+
|
|
9
|
+
defineProps<{
|
|
10
|
+
input: { frameId: string | null }
|
|
11
|
+
exit: (name: 'select', output: EnvSelectOutput) => void
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
const store = useEnvironmentWizardStore()
|
|
15
|
+
const { t } = useI18n()
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<section class="space-y-3" data-testid="env-setup-step-pick">
|
|
20
|
+
<p class="text-sm text-slate-400">{{ t('environmentWizard.pick.intro') }}</p>
|
|
21
|
+
<p v-if="!store.serviceFrames.length" class="text-sm text-slate-500">
|
|
22
|
+
{{ t('environmentWizard.pick.empty') }}
|
|
23
|
+
</p>
|
|
24
|
+
<div v-else class="space-y-1.5">
|
|
25
|
+
<UButton
|
|
26
|
+
v-for="frame in store.serviceFrames"
|
|
27
|
+
:key="frame.id"
|
|
28
|
+
block
|
|
29
|
+
color="neutral"
|
|
30
|
+
variant="soft"
|
|
31
|
+
class="justify-start"
|
|
32
|
+
icon="i-lucide-box"
|
|
33
|
+
:data-testid="`env-setup-frame-${frame.id}`"
|
|
34
|
+
@click="exit('select', { frameId: frame.id })"
|
|
35
|
+
>
|
|
36
|
+
{{ frame.title }}
|
|
37
|
+
</UButton>
|
|
38
|
+
</div>
|
|
39
|
+
</section>
|
|
40
|
+
</template>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The environment-setup journey's PREFLIGHT step (slice 3 of the modular-vue
|
|
3
|
+
// adoption). Runs the working recipe's declared host preflight checks (degrades
|
|
4
|
+
// on a non-local facade) and lets the operator advance to save. `next` is always
|
|
5
|
+
// allowed — preflights are advisory, not a hard gate.
|
|
6
|
+
import { computed } from 'vue'
|
|
7
|
+
import JourneyStepNav from '~/components/environments/steps/JourneyStepNav.vue'
|
|
8
|
+
import { useEnvironmentWizardTarget } from '~/modular/journeys/environmentSetup.frame'
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
input: { frameId: string | null }
|
|
12
|
+
exit: (name: 'advance') => void
|
|
13
|
+
goBack?: () => void
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const store = useEnvironmentWizardStore()
|
|
17
|
+
const preflights = usePreflightsStore()
|
|
18
|
+
const { t } = useI18n()
|
|
19
|
+
|
|
20
|
+
// Keep the data store pointed at THIS step's frame — a resume that lands here
|
|
21
|
+
// (or a prior open of a different frame) must not leave preflight reading another
|
|
22
|
+
// frame's recipe. Idempotent per frame; see the composable.
|
|
23
|
+
useEnvironmentWizardTarget(() => props.input.frameId)
|
|
24
|
+
|
|
25
|
+
// The host-probe runtime isn't wired (a non-local facade 503'd) — the checklist degrades to a note.
|
|
26
|
+
const preflightsUnavailable = computed(() => preflights.available === false)
|
|
27
|
+
|
|
28
|
+
const PREFLIGHT_COLOR: Record<'pass' | 'warn' | 'fail', 'success' | 'warning' | 'error'> = {
|
|
29
|
+
pass: 'success',
|
|
30
|
+
warn: 'warning',
|
|
31
|
+
fail: 'error',
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<section class="space-y-3" data-testid="env-setup-step-preflight">
|
|
37
|
+
<div class="flex items-center justify-between gap-2">
|
|
38
|
+
<p class="text-sm text-slate-400">{{ t('environmentWizard.preflight.intro') }}</p>
|
|
39
|
+
<UButton
|
|
40
|
+
size="xs"
|
|
41
|
+
variant="soft"
|
|
42
|
+
color="primary"
|
|
43
|
+
icon="i-lucide-list-checks"
|
|
44
|
+
:loading="store.preflightRunning"
|
|
45
|
+
data-testid="env-setup-preflight-run"
|
|
46
|
+
@click="store.runPreflight()"
|
|
47
|
+
>
|
|
48
|
+
{{ t('environmentWizard.preflight.run') }}
|
|
49
|
+
</UButton>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<p
|
|
53
|
+
v-if="!store.recipe.prerequisites?.length"
|
|
54
|
+
class="text-[12px] text-slate-500"
|
|
55
|
+
data-testid="env-setup-preflight-none"
|
|
56
|
+
>
|
|
57
|
+
{{ t('environmentWizard.preflight.none') }}
|
|
58
|
+
</p>
|
|
59
|
+
<p
|
|
60
|
+
v-else-if="preflightsUnavailable"
|
|
61
|
+
class="text-[12px] text-amber-300/80"
|
|
62
|
+
data-testid="env-setup-preflight-unavailable"
|
|
63
|
+
>
|
|
64
|
+
{{ t('environmentWizard.preflight.unavailable') }}
|
|
65
|
+
</p>
|
|
66
|
+
<p
|
|
67
|
+
v-if="store.preflightError"
|
|
68
|
+
class="text-[12px] text-rose-300/80"
|
|
69
|
+
data-testid="env-setup-preflight-error"
|
|
70
|
+
>
|
|
71
|
+
{{ store.preflightError }}
|
|
72
|
+
</p>
|
|
73
|
+
|
|
74
|
+
<ul
|
|
75
|
+
v-if="store.preflightResults?.length"
|
|
76
|
+
class="space-y-2"
|
|
77
|
+
data-testid="env-setup-preflight-results"
|
|
78
|
+
>
|
|
79
|
+
<li
|
|
80
|
+
v-for="r in store.preflightResults"
|
|
81
|
+
:key="r.title"
|
|
82
|
+
class="rounded border border-slate-800 bg-slate-900/40 p-2"
|
|
83
|
+
>
|
|
84
|
+
<div class="flex items-center gap-2">
|
|
85
|
+
<UBadge :color="PREFLIGHT_COLOR[r.status]" variant="subtle" size="sm">
|
|
86
|
+
{{ r.status }}
|
|
87
|
+
</UBadge>
|
|
88
|
+
<span class="text-[12px] text-slate-200">{{ r.title }}</span>
|
|
89
|
+
<span v-if="!r.required" class="ms-auto text-[10px] text-slate-500">
|
|
90
|
+
{{ t('environmentWizard.preflight.optional') }}
|
|
91
|
+
</span>
|
|
92
|
+
</div>
|
|
93
|
+
<p v-if="r.detail" class="mt-1 text-[11px] text-slate-400">{{ r.detail }}</p>
|
|
94
|
+
<pre
|
|
95
|
+
v-if="r.status !== 'pass' && r.remediation"
|
|
96
|
+
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"
|
|
97
|
+
>{{ r.remediation }}</pre
|
|
98
|
+
>
|
|
99
|
+
</li>
|
|
100
|
+
</ul>
|
|
101
|
+
|
|
102
|
+
<JourneyStepNav :go-back="goBack">
|
|
103
|
+
<template #primary>
|
|
104
|
+
<UButton
|
|
105
|
+
color="primary"
|
|
106
|
+
trailing-icon="i-lucide-arrow-right"
|
|
107
|
+
data-testid="env-setup-next"
|
|
108
|
+
@click="exit('advance')"
|
|
109
|
+
>
|
|
110
|
+
{{ t('common.next') }}
|
|
111
|
+
</UButton>
|
|
112
|
+
</template>
|
|
113
|
+
</JourneyStepNav>
|
|
114
|
+
</section>
|
|
115
|
+
</template>
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The environment-setup journey's REVIEW step (slice 3 of the modular-vue adoption).
|
|
3
|
+
// Seeds the data layer for the journey's target frame (idempotent per frame, so
|
|
4
|
+
// back-navigation doesn't clobber edits), then presents the detected + optionally
|
|
5
|
+
// analyst-augmented `docker-compose` recipe for the operator to confirm/edit.
|
|
6
|
+
// Firing the journey's `advance` exit (gated until a recommendation + exposed
|
|
7
|
+
// service exist) advances to the preflight step.
|
|
8
|
+
import { computed, ref } from 'vue'
|
|
9
|
+
import type {
|
|
10
|
+
MergeableRecipeField,
|
|
11
|
+
ProvisioningComposeFileCandidate,
|
|
12
|
+
ProvisioningProfileCandidate,
|
|
13
|
+
ProvisioningSeedDumpCandidate,
|
|
14
|
+
} from '@cat-factory/contracts'
|
|
15
|
+
import JourneyStepNav from '~/components/environments/steps/JourneyStepNav.vue'
|
|
16
|
+
import { useEnvironmentWizardTarget } from '~/modular/journeys/environmentSetup.frame'
|
|
17
|
+
|
|
18
|
+
const props = defineProps<{
|
|
19
|
+
input: { frameId: string | null }
|
|
20
|
+
exit: (name: 'advance') => void
|
|
21
|
+
goBack?: () => void
|
|
22
|
+
}>()
|
|
23
|
+
|
|
24
|
+
const store = useEnvironmentWizardStore()
|
|
25
|
+
const { t } = useI18n()
|
|
26
|
+
|
|
27
|
+
// Bridge the journey's target frame into the data store (idempotent per frame,
|
|
28
|
+
// so re-entry / back-nav / resume keeps in-progress edits). See the composable.
|
|
29
|
+
useEnvironmentWizardTarget(() => props.input.frameId)
|
|
30
|
+
|
|
31
|
+
// ---- Provenance -------------------------------------------------------------
|
|
32
|
+
const FIELD_LABEL = computed<Record<MergeableRecipeField, string>>(() => ({
|
|
33
|
+
composeFiles: t('environmentWizard.field.composeFiles'),
|
|
34
|
+
composeProfiles: t('environmentWizard.field.composeProfiles'),
|
|
35
|
+
envFiles: t('environmentWizard.field.envFiles'),
|
|
36
|
+
externalNetworks: t('environmentWizard.field.externalNetworks'),
|
|
37
|
+
sharedStackRefs: t('environmentWizard.field.sharedStackRefs'),
|
|
38
|
+
prerequisites: t('environmentWizard.field.prerequisites'),
|
|
39
|
+
setupSteps: t('environmentWizard.field.setupSteps'),
|
|
40
|
+
healthGate: t('environmentWizard.field.healthGate'),
|
|
41
|
+
teardownSteps: t('environmentWizard.field.teardownSteps'),
|
|
42
|
+
}))
|
|
43
|
+
const ORIGIN_COLOR: Record<'detector' | 'analyst' | 'both', 'success' | 'info' | 'warning'> = {
|
|
44
|
+
detector: 'success',
|
|
45
|
+
analyst: 'info',
|
|
46
|
+
both: 'warning',
|
|
47
|
+
}
|
|
48
|
+
const ORIGIN_LABEL = computed<Record<'detector' | 'analyst' | 'both', string>>(() => ({
|
|
49
|
+
detector: t('environmentWizard.origin.detector'),
|
|
50
|
+
analyst: t('environmentWizard.origin.analyst'),
|
|
51
|
+
both: t('environmentWizard.origin.both'),
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
// ---- Candidate helpers ------------------------------------------------------
|
|
55
|
+
const composeFileCandidates = computed<ProvisioningComposeFileCandidate[]>(
|
|
56
|
+
() => store.recommendation?.composeFileCandidates ?? [],
|
|
57
|
+
)
|
|
58
|
+
const profileCandidates = computed<ProvisioningProfileCandidate[]>(
|
|
59
|
+
() => store.recommendation?.profileCandidates ?? [],
|
|
60
|
+
)
|
|
61
|
+
const seedDumpCandidates = computed<ProvisioningSeedDumpCandidate[]>(
|
|
62
|
+
() => store.recommendation?.seedDumpCandidates ?? [],
|
|
63
|
+
)
|
|
64
|
+
const composeServiceOptions = computed(() =>
|
|
65
|
+
(store.recommendation?.composeServiceCandidates ?? []).map((c) => ({
|
|
66
|
+
label: c.recommended ? `${c.service} · ${t('environmentWizard.recommended')}` : c.service,
|
|
67
|
+
value: c.service,
|
|
68
|
+
})),
|
|
69
|
+
)
|
|
70
|
+
// The repo ships its own imperative bring-up (a `bin/*console*` / Makefile / justfile the
|
|
71
|
+
// deterministic scan can't read) — the strongest signal to run the analyst, so when present we
|
|
72
|
+
// elevate the deep-analysis affordance from a quiet opt-in to a prominent nudge naming the CLI.
|
|
73
|
+
const repoCliHint = computed(() => store.recommendation?.repoCliHint)
|
|
74
|
+
|
|
75
|
+
function fileEnabled(path: string): boolean {
|
|
76
|
+
return store.recipe.composeFiles?.includes(path) ?? false
|
|
77
|
+
}
|
|
78
|
+
function profileEnabled(profile: string): boolean {
|
|
79
|
+
return store.recipe.composeProfiles?.includes(profile) ?? false
|
|
80
|
+
}
|
|
81
|
+
function seedAdded(path: string): boolean {
|
|
82
|
+
return (store.recipe.setupSteps ?? []).some(
|
|
83
|
+
(s) => s.kind === 'compose-exec' && s.stdinFile === path,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- Raw recipe editor ------------------------------------------------------
|
|
88
|
+
const rawOpen = ref(false)
|
|
89
|
+
const rawText = ref('')
|
|
90
|
+
const rawError = ref<string | null>(null)
|
|
91
|
+
function openRaw() {
|
|
92
|
+
rawText.value = JSON.stringify(store.recipe, null, 2)
|
|
93
|
+
rawError.value = null
|
|
94
|
+
rawOpen.value = true
|
|
95
|
+
}
|
|
96
|
+
function toggleRaw() {
|
|
97
|
+
if (rawOpen.value) rawOpen.value = false
|
|
98
|
+
else openRaw()
|
|
99
|
+
}
|
|
100
|
+
function applyRaw() {
|
|
101
|
+
rawError.value = store.setRecipeFromJson(rawText.value)
|
|
102
|
+
if (!rawError.value) rawOpen.value = false
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- Step gating ------------------------------------------------------------
|
|
106
|
+
const canLeaveReview = computed(
|
|
107
|
+
() => !!store.recommendation && store.composeService.trim().length > 0,
|
|
108
|
+
)
|
|
109
|
+
</script>
|
|
110
|
+
|
|
111
|
+
<template>
|
|
112
|
+
<section class="space-y-4" data-testid="env-setup-step-review">
|
|
113
|
+
<!-- detection status -->
|
|
114
|
+
<div class="flex items-center justify-between gap-2">
|
|
115
|
+
<div class="min-w-0">
|
|
116
|
+
<p class="text-sm font-medium text-slate-200">{{ store.targetFrame?.title }}</p>
|
|
117
|
+
<p class="text-[11px] text-slate-500">{{ t('environmentWizard.review.detectHint') }}</p>
|
|
118
|
+
</div>
|
|
119
|
+
<UButton
|
|
120
|
+
size="xs"
|
|
121
|
+
variant="soft"
|
|
122
|
+
color="primary"
|
|
123
|
+
icon="i-lucide-wand-sparkles"
|
|
124
|
+
:loading="store.detecting"
|
|
125
|
+
:disabled="!store.hasRepo"
|
|
126
|
+
data-testid="env-setup-detect"
|
|
127
|
+
@click="store.detect()"
|
|
128
|
+
>
|
|
129
|
+
{{ t('environmentWizard.review.detect') }}
|
|
130
|
+
</UButton>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<p v-if="!store.hasRepo" class="text-[12px] text-amber-300/80">
|
|
134
|
+
{{ t('environmentWizard.review.noRepo') }}
|
|
135
|
+
</p>
|
|
136
|
+
<p
|
|
137
|
+
v-else-if="store.detectError"
|
|
138
|
+
class="text-[12px] text-rose-300/80"
|
|
139
|
+
data-testid="env-setup-detect-error"
|
|
140
|
+
>
|
|
141
|
+
{{ t('environmentWizard.review.detectError') }}
|
|
142
|
+
</p>
|
|
143
|
+
|
|
144
|
+
<template v-if="store.recommendation && !store.detecting">
|
|
145
|
+
<!-- deep analysis (opt-in; elevated to a prominent nudge when the repo ships its own CLI) -->
|
|
146
|
+
<div
|
|
147
|
+
class="rounded-md border p-3"
|
|
148
|
+
:class="
|
|
149
|
+
repoCliHint
|
|
150
|
+
? 'border-primary-700/60 bg-primary-950/30'
|
|
151
|
+
: 'border-slate-800 bg-slate-900/40'
|
|
152
|
+
"
|
|
153
|
+
>
|
|
154
|
+
<p
|
|
155
|
+
v-if="repoCliHint"
|
|
156
|
+
class="mb-2 flex items-start gap-1.5 text-[11px] text-primary-300"
|
|
157
|
+
data-testid="env-setup-cli-nudge"
|
|
158
|
+
>
|
|
159
|
+
<UIcon name="i-lucide-lightbulb" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
160
|
+
<span>{{ t('environmentWizard.analysis.cliNudge', { path: repoCliHint.path }) }}</span>
|
|
161
|
+
</p>
|
|
162
|
+
<div class="flex items-center justify-between gap-2">
|
|
163
|
+
<div class="min-w-0">
|
|
164
|
+
<p class="text-[12px] font-medium text-slate-300">
|
|
165
|
+
{{ t('environmentWizard.analysis.title') }}
|
|
166
|
+
</p>
|
|
167
|
+
<p class="text-[11px] text-slate-500">{{ t('environmentWizard.analysis.hint') }}</p>
|
|
168
|
+
</div>
|
|
169
|
+
<UButton
|
|
170
|
+
size="xs"
|
|
171
|
+
variant="soft"
|
|
172
|
+
:color="repoCliHint ? 'primary' : 'neutral'"
|
|
173
|
+
icon="i-lucide-sparkles"
|
|
174
|
+
:loading="store.analysisStatus === 'running'"
|
|
175
|
+
:disabled="!store.canAnalyze || store.analysisStatus === 'running'"
|
|
176
|
+
data-testid="env-setup-run-analysis"
|
|
177
|
+
@click="store.startAnalysis()"
|
|
178
|
+
>
|
|
179
|
+
{{ t('environmentWizard.analysis.run') }}
|
|
180
|
+
</UButton>
|
|
181
|
+
</div>
|
|
182
|
+
<p
|
|
183
|
+
v-if="!store.canAnalyze"
|
|
184
|
+
class="mt-2 text-[11px] text-slate-500"
|
|
185
|
+
data-testid="env-setup-analysis-unavailable"
|
|
186
|
+
>
|
|
187
|
+
{{ t('environmentWizard.analysis.unavailable') }}
|
|
188
|
+
</p>
|
|
189
|
+
<p v-else-if="store.analysisStatus === 'failed'" class="mt-2 text-[11px] text-rose-300/80">
|
|
190
|
+
{{ t('environmentWizard.analysis.failed') }}
|
|
191
|
+
</p>
|
|
192
|
+
<div
|
|
193
|
+
v-else-if="store.analysisStatus === 'ready'"
|
|
194
|
+
class="mt-2 space-y-2"
|
|
195
|
+
data-testid="env-setup-analysis-ready"
|
|
196
|
+
>
|
|
197
|
+
<p v-if="store.merged?.summary" class="text-[11px] leading-snug text-slate-400">
|
|
198
|
+
{{ store.merged.summary }}
|
|
199
|
+
</p>
|
|
200
|
+
<UButton
|
|
201
|
+
size="xs"
|
|
202
|
+
variant="soft"
|
|
203
|
+
color="primary"
|
|
204
|
+
icon="i-lucide-git-merge"
|
|
205
|
+
data-testid="env-setup-apply-analysis"
|
|
206
|
+
@click="store.applyAnalystDraft()"
|
|
207
|
+
>
|
|
208
|
+
{{ t('environmentWizard.analysis.apply') }}
|
|
209
|
+
</UButton>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<!-- per-field provenance -->
|
|
214
|
+
<div v-if="store.merged?.fields.length" class="space-y-1.5">
|
|
215
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
216
|
+
{{ t('environmentWizard.review.provenanceTitle') }}
|
|
217
|
+
</p>
|
|
218
|
+
<div class="flex flex-wrap gap-1.5">
|
|
219
|
+
<UBadge
|
|
220
|
+
v-for="f in store.merged.fields"
|
|
221
|
+
:key="f.field"
|
|
222
|
+
:color="ORIGIN_COLOR[f.origin]"
|
|
223
|
+
variant="subtle"
|
|
224
|
+
size="sm"
|
|
225
|
+
:title="f.detectorMessage ?? f.analystRationale ?? ''"
|
|
226
|
+
:data-testid="`env-setup-field-${f.field}`"
|
|
227
|
+
>
|
|
228
|
+
{{ FIELD_LABEL[f.field] }} · {{ ORIGIN_LABEL[f.origin] }}
|
|
229
|
+
</UBadge>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<!-- exposed compose service + port -->
|
|
234
|
+
<div class="grid grid-cols-2 gap-3">
|
|
235
|
+
<UFormField :label="t('environmentWizard.review.service')" required>
|
|
236
|
+
<USelect
|
|
237
|
+
v-if="composeServiceOptions.length"
|
|
238
|
+
v-model="store.composeService"
|
|
239
|
+
:items="composeServiceOptions"
|
|
240
|
+
value-key="value"
|
|
241
|
+
:placeholder="t('environmentWizard.review.servicePlaceholder')"
|
|
242
|
+
class="w-full"
|
|
243
|
+
data-testid="env-setup-service-select"
|
|
244
|
+
/>
|
|
245
|
+
<UInput
|
|
246
|
+
v-else
|
|
247
|
+
v-model="store.composeService"
|
|
248
|
+
:placeholder="t('environmentWizard.review.servicePlaceholder')"
|
|
249
|
+
class="w-full"
|
|
250
|
+
data-testid="env-setup-service-input"
|
|
251
|
+
/>
|
|
252
|
+
</UFormField>
|
|
253
|
+
<UFormField :label="t('environmentWizard.review.port')">
|
|
254
|
+
<UInput
|
|
255
|
+
v-model.number="store.exposedPort"
|
|
256
|
+
type="number"
|
|
257
|
+
class="w-full"
|
|
258
|
+
data-testid="env-setup-port"
|
|
259
|
+
/>
|
|
260
|
+
</UFormField>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<!-- compose file layering -->
|
|
264
|
+
<div v-if="composeFileCandidates.length" class="space-y-1.5">
|
|
265
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
266
|
+
{{ t('environmentWizard.review.composeFiles') }}
|
|
267
|
+
</p>
|
|
268
|
+
<div class="flex flex-wrap gap-1.5">
|
|
269
|
+
<UButton
|
|
270
|
+
v-for="c in composeFileCandidates"
|
|
271
|
+
:key="c.path"
|
|
272
|
+
size="xs"
|
|
273
|
+
:color="fileEnabled(c.path) ? 'primary' : 'neutral'"
|
|
274
|
+
:variant="fileEnabled(c.path) ? 'soft' : 'ghost'"
|
|
275
|
+
:icon="fileEnabled(c.path) ? 'i-lucide-check' : 'i-lucide-plus'"
|
|
276
|
+
:data-testid="`env-setup-file-${c.name}`"
|
|
277
|
+
@click="store.toggleComposeFile(c.path)"
|
|
278
|
+
>
|
|
279
|
+
{{ c.name }}<span v-if="c.os" class="ms-1 text-[9px] opacity-70">{{ c.os }}</span>
|
|
280
|
+
</UButton>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<!-- compose profiles -->
|
|
285
|
+
<div v-if="profileCandidates.length" class="space-y-1.5">
|
|
286
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
287
|
+
{{ t('environmentWizard.review.profiles') }}
|
|
288
|
+
</p>
|
|
289
|
+
<div class="flex flex-wrap gap-1.5">
|
|
290
|
+
<UButton
|
|
291
|
+
v-for="c in profileCandidates"
|
|
292
|
+
:key="c.profile"
|
|
293
|
+
size="xs"
|
|
294
|
+
:color="profileEnabled(c.profile) ? 'primary' : 'neutral'"
|
|
295
|
+
:variant="profileEnabled(c.profile) ? 'soft' : 'ghost'"
|
|
296
|
+
:icon="profileEnabled(c.profile) ? 'i-lucide-check' : 'i-lucide-plus'"
|
|
297
|
+
:data-testid="`env-setup-profile-${c.profile}`"
|
|
298
|
+
@click="store.toggleProfile(c.profile)"
|
|
299
|
+
>
|
|
300
|
+
{{ c.profile }}
|
|
301
|
+
</UButton>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<!-- seed dumps -->
|
|
306
|
+
<div v-if="seedDumpCandidates.length" class="space-y-1.5">
|
|
307
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
308
|
+
{{ t('environmentWizard.review.seedDumps') }}
|
|
309
|
+
</p>
|
|
310
|
+
<div class="space-y-1">
|
|
311
|
+
<div
|
|
312
|
+
v-for="c in seedDumpCandidates"
|
|
313
|
+
:key="c.path"
|
|
314
|
+
class="flex items-center justify-between gap-2 rounded border border-slate-800 bg-slate-900/40 px-2 py-1"
|
|
315
|
+
>
|
|
316
|
+
<span class="truncate text-[11px] text-slate-300">{{ c.path }}</span>
|
|
317
|
+
<UButton
|
|
318
|
+
size="xs"
|
|
319
|
+
:color="seedAdded(c.path) ? 'success' : 'neutral'"
|
|
320
|
+
:variant="seedAdded(c.path) ? 'soft' : 'ghost'"
|
|
321
|
+
:icon="seedAdded(c.path) ? 'i-lucide-check' : 'i-lucide-plus'"
|
|
322
|
+
:disabled="seedAdded(c.path)"
|
|
323
|
+
:data-testid="`env-setup-seed-${c.name}`"
|
|
324
|
+
@click="store.addSeedStep(c)"
|
|
325
|
+
>
|
|
326
|
+
{{
|
|
327
|
+
seedAdded(c.path)
|
|
328
|
+
? t('environmentWizard.review.seedAdded')
|
|
329
|
+
: t('environmentWizard.review.seedAdd')
|
|
330
|
+
}}
|
|
331
|
+
</UButton>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
<!-- raw recipe editor -->
|
|
337
|
+
<div>
|
|
338
|
+
<UButton
|
|
339
|
+
size="xs"
|
|
340
|
+
variant="ghost"
|
|
341
|
+
color="neutral"
|
|
342
|
+
:icon="rawOpen ? 'i-lucide-chevron-down' : 'i-lucide-code'"
|
|
343
|
+
data-testid="env-setup-raw-toggle"
|
|
344
|
+
@click="toggleRaw"
|
|
345
|
+
>
|
|
346
|
+
{{ t('environmentWizard.review.rawEditor') }}
|
|
347
|
+
</UButton>
|
|
348
|
+
<div v-if="rawOpen" class="mt-2 space-y-2">
|
|
349
|
+
<UTextarea
|
|
350
|
+
v-model="rawText"
|
|
351
|
+
:rows="10"
|
|
352
|
+
class="w-full font-mono text-[11px]"
|
|
353
|
+
data-testid="env-setup-raw-text"
|
|
354
|
+
/>
|
|
355
|
+
<p v-if="rawError" class="text-[11px] text-rose-300/80" data-testid="env-setup-raw-error">
|
|
356
|
+
{{ rawError }}
|
|
357
|
+
</p>
|
|
358
|
+
<div class="flex justify-end">
|
|
359
|
+
<UButton size="xs" color="primary" data-testid="env-setup-raw-apply" @click="applyRaw">
|
|
360
|
+
{{ t('environmentWizard.review.rawApply') }}
|
|
361
|
+
</UButton>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
</template>
|
|
366
|
+
|
|
367
|
+
<JourneyStepNav :go-back="goBack">
|
|
368
|
+
<template #primary>
|
|
369
|
+
<UButton
|
|
370
|
+
color="primary"
|
|
371
|
+
trailing-icon="i-lucide-arrow-right"
|
|
372
|
+
:disabled="!canLeaveReview"
|
|
373
|
+
data-testid="env-setup-next"
|
|
374
|
+
@click="exit('advance')"
|
|
375
|
+
>
|
|
376
|
+
{{ t('common.next') }}
|
|
377
|
+
</UButton>
|
|
378
|
+
</template>
|
|
379
|
+
</JourneyStepNav>
|
|
380
|
+
</section>
|
|
381
|
+
</template>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The environment-setup journey's SAVE step (slice 3 of the modular-vue adoption).
|
|
3
|
+
// Persists the confirmed recipe onto the frame + registers the workspace's
|
|
4
|
+
// docker-compose handler, then optionally trial-provisions with live logs.
|
|
5
|
+
// "Done" fires the journey's `advance` exit, which the transition maps to `complete`
|
|
6
|
+
// — the host closes the modal on finish.
|
|
7
|
+
import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
|
|
8
|
+
import JourneyStepNav from '~/components/environments/steps/JourneyStepNav.vue'
|
|
9
|
+
import { useEnvironmentWizardTarget } from '~/modular/journeys/environmentSetup.frame'
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
input: { frameId: string | null }
|
|
13
|
+
exit: (name: 'advance') => void
|
|
14
|
+
goBack?: () => void
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const store = useEnvironmentWizardStore()
|
|
18
|
+
const { t } = useI18n()
|
|
19
|
+
|
|
20
|
+
// Keep the data store pointed at THIS step's frame, so `save()` persists the
|
|
21
|
+
// frame the journey is actually on (not a stale one). Idempotent; see composable.
|
|
22
|
+
useEnvironmentWizardTarget(() => props.input.frameId)
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<section class="space-y-3" data-testid="env-setup-step-save">
|
|
27
|
+
<UFormField
|
|
28
|
+
:label="t('environmentWizard.save.handlerLabel')"
|
|
29
|
+
:description="t('environmentWizard.save.handlerHint')"
|
|
30
|
+
>
|
|
31
|
+
<UInput v-model="store.handlerLabel" class="w-full" data-testid="env-setup-handler-label" />
|
|
32
|
+
</UFormField>
|
|
33
|
+
|
|
34
|
+
<div class="rounded-md border border-slate-800 bg-slate-900/40 p-3 text-[12px] text-slate-300">
|
|
35
|
+
<p>
|
|
36
|
+
{{
|
|
37
|
+
t('environmentWizard.save.summary', {
|
|
38
|
+
frame: store.targetFrame?.title ?? '',
|
|
39
|
+
service: store.composeService,
|
|
40
|
+
})
|
|
41
|
+
}}
|
|
42
|
+
</p>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<p
|
|
46
|
+
v-if="store.saveError"
|
|
47
|
+
class="text-[12px] text-rose-300/80"
|
|
48
|
+
data-testid="env-setup-save-error"
|
|
49
|
+
>
|
|
50
|
+
{{ store.saveError }}
|
|
51
|
+
</p>
|
|
52
|
+
|
|
53
|
+
<div v-if="!store.saved" class="flex justify-end">
|
|
54
|
+
<UButton
|
|
55
|
+
color="primary"
|
|
56
|
+
icon="i-lucide-save"
|
|
57
|
+
:loading="store.saving"
|
|
58
|
+
:disabled="!store.composeService.trim()"
|
|
59
|
+
data-testid="env-setup-save"
|
|
60
|
+
@click="store.save()"
|
|
61
|
+
>
|
|
62
|
+
{{ t('environmentWizard.save.save') }}
|
|
63
|
+
</UButton>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<!-- saved: confirmation + optional trial provision -->
|
|
67
|
+
<template v-else>
|
|
68
|
+
<div
|
|
69
|
+
class="flex items-center gap-2 rounded-md border border-emerald-800/50 bg-emerald-950/30 p-2 text-[12px] text-emerald-200"
|
|
70
|
+
data-testid="env-setup-saved"
|
|
71
|
+
>
|
|
72
|
+
<UIcon name="i-lucide-check-circle" class="h-4 w-4" />
|
|
73
|
+
{{ t('environmentWizard.save.saved') }}
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div class="flex items-center justify-between gap-2">
|
|
77
|
+
<p class="text-[11px] text-slate-500">{{ t('environmentWizard.trial.hint') }}</p>
|
|
78
|
+
<UButton
|
|
79
|
+
size="xs"
|
|
80
|
+
variant="soft"
|
|
81
|
+
color="neutral"
|
|
82
|
+
icon="i-lucide-play"
|
|
83
|
+
:loading="store.trialing"
|
|
84
|
+
:disabled="store.trialStarted"
|
|
85
|
+
data-testid="env-setup-trial"
|
|
86
|
+
@click="store.trialProvision()"
|
|
87
|
+
>
|
|
88
|
+
{{ t('environmentWizard.trial.run') }}
|
|
89
|
+
</UButton>
|
|
90
|
+
</div>
|
|
91
|
+
<p v-if="store.trialError" class="text-[11px] text-rose-300/80">{{ store.trialError }}</p>
|
|
92
|
+
<ProvisioningLogsDrawer v-if="store.trialStarted" subsystem="environment" />
|
|
93
|
+
</template>
|
|
94
|
+
|
|
95
|
+
<JourneyStepNav :go-back="goBack">
|
|
96
|
+
<template #primary>
|
|
97
|
+
<UButton
|
|
98
|
+
color="neutral"
|
|
99
|
+
variant="soft"
|
|
100
|
+
icon="i-lucide-check"
|
|
101
|
+
data-testid="env-setup-done"
|
|
102
|
+
@click="exit('advance')"
|
|
103
|
+
>
|
|
104
|
+
{{ t('common.done') }}
|
|
105
|
+
</UButton>
|
|
106
|
+
</template>
|
|
107
|
+
</JourneyStepNav>
|
|
108
|
+
</section>
|
|
109
|
+
</template>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Shared footer chrome for a modular-vue journey step (slice 3 of the modular-vue
|
|
3
|
+
// adoption — docs/initiatives/modular-vue-adoption.md). Renders the Back control
|
|
4
|
+
// (wired to the host-provided `goBack`, present only when the current entry
|
|
5
|
+
// declared `allowBack` and there's a prior step) plus a `primary` slot for the
|
|
6
|
+
// step's own advance affordance, so gating stays local to each step.
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
/** The host-provided rewind callback (`ModuleEntryProps.goBack`), or undefined
|
|
9
|
+
* on the first step / when back isn't allowed. */
|
|
10
|
+
goBack?: () => void
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const { t } = useI18n()
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<div class="flex items-center justify-between border-t border-slate-800 pt-3">
|
|
18
|
+
<UButton
|
|
19
|
+
color="neutral"
|
|
20
|
+
variant="ghost"
|
|
21
|
+
:disabled="!props.goBack"
|
|
22
|
+
icon="i-lucide-arrow-left"
|
|
23
|
+
data-testid="env-setup-back"
|
|
24
|
+
@click="props.goBack?.()"
|
|
25
|
+
>
|
|
26
|
+
{{ t('common.back') }}
|
|
27
|
+
</UButton>
|
|
28
|
+
<slot name="primary" />
|
|
29
|
+
</div>
|
|
30
|
+
</template>
|