@cat-factory/app 0.296.6 → 0.297.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,176 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type {
4
+ EnvironmentProbeFailure,
5
+ EnvironmentProbeReport,
6
+ EnvironmentProbeVerdict,
7
+ } from '@cat-factory/contracts'
8
+
9
+ // What the AGENT DRY RUN found, as the inspector renders it.
10
+ //
11
+ // The order is deliberate and is the order a developer acts in: the VERDICT (can an agent work
12
+ // here at all), then what the platform failed to tell the agent (the thing to go and fix), then
13
+ // the operations as evidence, then the agent's own prose last. `missingContext` sits above the
14
+ // operation list rather than below it because it is the product of the diagnostic: an operator who
15
+ // reads only the first two lines has read the actionable part.
16
+ const props = defineProps<{ report: EnvironmentProbeReport }>()
17
+
18
+ const { t, te } = useI18n()
19
+
20
+ /**
21
+ * Per-verdict label keys, exhaustive over the contracts union: a new verdict fails THIS
22
+ * typecheck until it has copy. (The lookup is dynamic, so the typed-message-keys check cannot see
23
+ * it; the map's exhaustiveness is the drift guard, as with the stage keys.)
24
+ */
25
+ const VERDICT_KEYS: Record<EnvironmentProbeVerdict, string> = {
26
+ operable: 'inspector.testConfig.envProbe.verdict.operable',
27
+ partially_operable: 'inspector.testConfig.envProbe.verdict.partially_operable',
28
+ inoperable: 'inspector.testConfig.envProbe.verdict.inoperable',
29
+ }
30
+
31
+ const VERDICT_CLASS: Record<EnvironmentProbeVerdict, string> = {
32
+ operable: 'text-emerald-300/90',
33
+ // Amber, not rose: the LIFECYCLE succeeded and the finding is partial. Colouring it as a
34
+ // failure would make it read as the run breaking rather than as the diagnostic working.
35
+ partially_operable: 'text-amber-300/90',
36
+ inoperable: 'text-rose-300/90',
37
+ }
38
+
39
+ /**
40
+ * Per-failure-kind labels, exhaustive over the contracts vocabulary. Rendered through a `te`
41
+ * guard: the kinds are a CLOSED and PERSISTED vocabulary, so a report stored before a member was
42
+ * renamed must still render honestly (the raw value) rather than as an empty span.
43
+ */
44
+ const FAILURE_KEYS: Record<EnvironmentProbeFailure, string> = {
45
+ auth_missing: 'inspector.testConfig.envProbe.failure.auth_missing',
46
+ auth_rejected: 'inspector.testConfig.envProbe.failure.auth_rejected',
47
+ access_unclear: 'inspector.testConfig.envProbe.failure.access_unclear',
48
+ endpoint_unknown: 'inspector.testConfig.envProbe.failure.endpoint_unknown',
49
+ unreachable: 'inspector.testConfig.envProbe.failure.unreachable',
50
+ timeout: 'inspector.testConfig.envProbe.failure.timeout',
51
+ server_error: 'inspector.testConfig.envProbe.failure.server_error',
52
+ bad_request: 'inspector.testConfig.envProbe.failure.bad_request',
53
+ tooling_missing: 'inspector.testConfig.envProbe.failure.tooling_missing',
54
+ other: 'inspector.testConfig.envProbe.failure.other',
55
+ }
56
+
57
+ function failureLabel(kind: EnvironmentProbeFailure): string {
58
+ const key = FAILURE_KEYS[kind]
59
+ return key && te(key) ? t(key) : kind
60
+ }
61
+
62
+ const verdictLabel = computed(() => {
63
+ const key = VERDICT_KEYS[props.report.verdict]
64
+ return key && te(key) ? t(key) : props.report.verdict
65
+ })
66
+
67
+ const verdictClass = computed(() => VERDICT_CLASS[props.report.verdict] ?? 'text-slate-300')
68
+
69
+ /** The icon per operation outcome. `not_attempted` is its own mark, never a failure's. */
70
+ function outcomeIcon(outcome: EnvironmentProbeReport['operations'][number]['outcome']): string {
71
+ if (outcome === 'succeeded') return 'i-lucide-check'
72
+ if (outcome === 'failed') return 'i-lucide-x'
73
+ return 'i-lucide-minus'
74
+ }
75
+
76
+ function outcomeClass(outcome: EnvironmentProbeReport['operations'][number]['outcome']): string {
77
+ if (outcome === 'succeeded') return 'text-emerald-400'
78
+ if (outcome === 'failed') return 'text-rose-400'
79
+ return 'text-slate-500'
80
+ }
81
+ </script>
82
+
83
+ <template>
84
+ <div
85
+ class="space-y-2 rounded border border-white/5 bg-white/[0.02] p-2"
86
+ data-testid="env-probe-report"
87
+ >
88
+ <p class="text-[11px] font-medium" :class="verdictClass" data-testid="env-probe-verdict">
89
+ {{ verdictLabel }}
90
+ </p>
91
+
92
+ <!-- The tallies the PLATFORM computed from the agent's per-operation judgements, including
93
+ how many successes were behind authentication: the count the verdict turns on. -->
94
+ <p class="text-[11px] text-slate-400">
95
+ {{
96
+ t('inspector.testConfig.envProbe.counts', {
97
+ succeeded: report.succeeded,
98
+ attempted: report.attempted,
99
+ authenticated: report.authenticatedSucceeded,
100
+ })
101
+ }}
102
+ </p>
103
+
104
+ <!-- What the platform did not tell the agent: the actionable half of the report, so it comes
105
+ before the evidence. -->
106
+ <div v-if="report.missingContext.length" data-testid="env-probe-missing">
107
+ <p class="text-[11px] font-medium text-amber-300/90">
108
+ {{ t('inspector.testConfig.envProbe.missingContext') }}
109
+ </p>
110
+ <ul class="mt-0.5 list-disc space-y-0.5 pl-4 text-[11px] text-slate-300">
111
+ <li v-for="(line, i) in report.missingContext" :key="i">{{ line }}</li>
112
+ </ul>
113
+ </div>
114
+
115
+ <div v-if="report.blockers.length" data-testid="env-probe-blockers">
116
+ <p class="text-[11px] font-medium text-rose-300/90">
117
+ {{ t('inspector.testConfig.envProbe.blockers') }}
118
+ </p>
119
+ <ul class="mt-0.5 space-y-0.5 text-[11px] text-slate-300">
120
+ <li v-for="(blocker, i) in report.blockers" :key="i">
121
+ <span class="text-slate-400">{{ failureLabel(blocker.kind) }}:</span>
122
+ {{ blocker.detail }}
123
+ </li>
124
+ </ul>
125
+ </div>
126
+
127
+ <!-- The operations, as evidence for the verdict above. This is the "what was attempted" a dry
128
+ run exists to report: without it a verdict is an opinion. -->
129
+ <div v-if="report.operations.length" data-testid="env-probe-operations">
130
+ <p class="text-[11px] font-medium text-slate-300">
131
+ {{ t('inspector.testConfig.envProbe.operations') }}
132
+ </p>
133
+ <ul class="mt-0.5 space-y-1 text-[11px]">
134
+ <li v-for="(op, i) in report.operations" :key="i" class="flex items-start gap-1.5">
135
+ <UIcon
136
+ :name="outcomeIcon(op.outcome)"
137
+ class="mt-0.5 size-3 shrink-0"
138
+ :class="outcomeClass(op.outcome)"
139
+ />
140
+ <span class="min-w-0">
141
+ <span class="text-slate-200">{{ op.name }}</span>
142
+ <span v-if="op.authenticated" class="ml-1 text-slate-500">
143
+ {{ t('inspector.testConfig.envProbe.authenticated') }}
144
+ </span>
145
+ <span v-if="op.target" class="ml-1 font-mono text-slate-500">{{ op.target }}</span>
146
+ <span v-if="op.failure" class="block text-rose-300/70">
147
+ {{ failureLabel(op.failure) }}<template v-if="op.detail">: {{ op.detail }}</template>
148
+ </span>
149
+ <span v-else-if="op.detail" class="block text-slate-400">{{ op.detail }}</span>
150
+ </span>
151
+ </li>
152
+ </ul>
153
+ <!-- Every cap records what it dropped: a reader must never take a truncated list for the
154
+ whole attempt. The two ways an operation fails to reach the list are rendered SEPARATELY
155
+ because they send a reader somewhere different: one says the agent reported more than is
156
+ shown, the other says its reply was malformed. -->
157
+ <p v-if="report.operationsOmitted" class="mt-1 text-[11px] text-slate-500">
158
+ {{
159
+ t('inspector.testConfig.envProbe.operationsOmitted', { count: report.operationsOmitted })
160
+ }}
161
+ </p>
162
+ <p v-if="report.operationsUnreadable" class="mt-1 text-[11px] text-slate-500">
163
+ {{
164
+ t('inspector.testConfig.envProbe.operationsUnreadable', {
165
+ count: report.operationsUnreadable,
166
+ })
167
+ }}
168
+ </p>
169
+ </div>
170
+
171
+ <p v-if="report.summary" class="text-[11px] text-slate-400">{{ report.summary }}</p>
172
+ <p v-if="report.model" class="text-[11px] text-slate-600">
173
+ {{ t('inspector.testConfig.envProbe.model', { model: report.model }) }}
174
+ </p>
175
+ </div>
176
+ </template>
@@ -0,0 +1,371 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import type { Block, EnvironmentTestMode, EnvironmentTestStage } from '~/types/domain'
4
+ import type { ConflictReason } from '@cat-factory/contracts'
5
+ import { environmentProbeSurfaceFor } from '@cat-factory/contracts'
6
+ import EnvProbeReport from '~/components/panels/inspector/EnvProbeReport.vue'
7
+ import { apiErrorEnvelope } from '~/composables/api/errors'
8
+ import { parseConflict } from '~/composables/usePipelineErrorToast'
9
+
10
+ // The two SELF-TESTS a service frame offers, side by side, because they answer consecutive
11
+ // questions about the same environment:
12
+ //
13
+ // 1. "Test environment creation": does the provisioning config stand an environment up and
14
+ // take it down again? (create branch → provision → tear down → delete branch)
15
+ // 2. "Test agent dry run": handed that environment, could an AGENT actually operate the
16
+ // service: work out what to call, authenticate, and get something real done? It adds one
17
+ // `probing` stage in the middle and comes back with a report.
18
+ //
19
+ // One component rather than two because they share every piece of state and every refusal: the
20
+ // same store, the same `infraless` precondition, the same 409 vocabulary, the same
21
+ // jump-to-the-handler remedy. Extracted out of `ServiceTestConfig.vue`, which owns the service's
22
+ // provisioning CONFIG and was the wrong home for a pair of run controls.
23
+ const props = defineProps<{ block: Block }>()
24
+
25
+ const envTest = useEnvironmentTestStore()
26
+ const ui = useUiStore()
27
+ const { t, te } = useI18n()
28
+
29
+ /** Nothing to provision for an `infraless` service, so neither self-test has a subject. */
30
+ const canTest = computed(() => (props.block.provisioning?.type ?? 'infraless') !== 'infraless')
31
+
32
+ /**
33
+ * Which prober the dry run would use, through the SAME rule the backend picks it with. Rendered
34
+ * in the hint so a developer knows what they are about to spend a container on: a browser run on
35
+ * a frontend frame, HTTP calls on everything else.
36
+ */
37
+ const probeSurface = computed(() => environmentProbeSurfaceFor(props.block.type))
38
+
39
+ /**
40
+ * The self-test's start/stop error, per mode. Structured (not a bare string) so the
41
+ * not-provisionable case can render its remedy prose PLUS a one-click jump to the
42
+ * environment-handler config; every other case is plain text.
43
+ */
44
+ interface SelfTestError {
45
+ text: string
46
+ /** Show the "Configure infrastructure" deep-link: only the not-provisionable handler case. */
47
+ configurable?: boolean
48
+ }
49
+ const errors = ref<Partial<Record<EnvironmentTestMode, SelfTestError>>>({})
50
+ const starting = ref<Partial<Record<EnvironmentTestMode, boolean>>>({})
51
+
52
+ /**
53
+ * The newest run for this frame in one mode: re-attaches after a reconnect (running runs are
54
+ * carried in the snapshot), so the live stage keeps showing without a locally-held id. Scoped by
55
+ * mode so each control reports its OWN run rather than whichever ran last.
56
+ */
57
+ const provisionRun = computed(() => envTest.runForBlock(props.block.id, 'provision'))
58
+ const probeRun = computed(() => envTest.runForBlock(props.block.id, 'agent-probe'))
59
+ function runFor(mode: EnvironmentTestMode) {
60
+ return mode === 'agent-probe' ? probeRun.value : provisionRun.value
61
+ }
62
+ function isRunning(mode: EnvironmentTestMode) {
63
+ return runFor(mode)?.status === 'running'
64
+ }
65
+
66
+ /**
67
+ * Whether EITHER self-test is in flight for this frame, which disables both start buttons.
68
+ *
69
+ * Per FRAME, not per mode: each run provisions its own ephemeral environment under a synthetic
70
+ * per-run key that nothing supersedes, so two at once is two live environments for one service,
71
+ * billed twice and racing each other to create on any provider whose namespace is derived per
72
+ * service rather than per branch. The backend refuses the second outright
73
+ * (`env_test_already_running`); this is the affordance that keeps a developer from meeting that
74
+ * refusal by pressing the obvious thing.
75
+ */
76
+ const busy = computed(() => isRunning('provision') || isRunning('agent-probe'))
77
+
78
+ /**
79
+ * The prober's live todo counts, rendered while the dry run is probing.
80
+ *
81
+ * `probing` is the one stage of this run measured in minutes, so it is the one where a bare
82
+ * "probing with an agent" is indistinguishable from a wedged run for as long as the container
83
+ * works.
84
+ */
85
+ const probeProgress = computed(() => {
86
+ const run = probeRun.value
87
+ if (!run || run.status !== 'running' || run.stage !== 'probing') return null
88
+ return run.probeProgress
89
+ })
90
+
91
+ // Per-stage label KEYS, exhaustive over the contracts `EnvironmentTestStage` union: a new
92
+ // backend stage fails THIS typecheck until mapped (the key is resolved at runtime, so the
93
+ // typed-message-keys check can't see the `t()` lookup: the map's exhaustiveness is the
94
+ // drift guard, same pattern as `CONFLICT_TITLE_KEYS`).
95
+ const STAGE_KEYS: Record<EnvironmentTestStage, string> = {
96
+ creating_branch: 'inspector.testConfig.envTest.stage.creating_branch',
97
+ provisioning: 'inspector.testConfig.envTest.stage.provisioning',
98
+ probing: 'inspector.testConfig.envTest.stage.probing',
99
+ tearing_down: 'inspector.testConfig.envTest.stage.tearing_down',
100
+ deleting_branch: 'inspector.testConfig.envTest.stage.deleting_branch',
101
+ done: 'inspector.testConfig.envTest.stage.done',
102
+ }
103
+
104
+ function stageLabel(stage: EnvironmentTestStage): string {
105
+ const key = STAGE_KEYS[stage]
106
+ // `te`-guarded so a locale missing the key shows the raw stage id, never a raw message key.
107
+ return te(key) ? t(key) : stage
108
+ }
109
+
110
+ // The start preflight's machine-readable 409 reasons, mapped to their localized titles,
111
+ // exhaustive over the contracts `env_test_*` conflict reasons (same drift guard as above).
112
+ // The raw backend `message` is only the last-resort fallback for unmapped/non-conflict errors.
113
+ const CONFLICT_KEYS: Record<Extract<ConflictReason, `env_test_${string}`>, string> = {
114
+ env_test_not_a_frame: 'errors.conflict.title.env_test_not_a_frame',
115
+ env_test_infraless: 'errors.conflict.title.env_test_infraless',
116
+ env_test_not_provisionable: 'errors.conflict.title.env_test_not_provisionable',
117
+ env_test_no_vcs: 'errors.conflict.title.env_test_no_vcs',
118
+ env_test_connection_failed: 'errors.conflict.title.env_test_connection_failed',
119
+ env_test_probe_unavailable: 'errors.conflict.title.env_test_probe_unavailable',
120
+ env_test_already_running: 'errors.conflict.title.env_test_already_running',
121
+ env_test_over_budget: 'errors.conflict.title.env_test_over_budget',
122
+ }
123
+
124
+ function buildError(e: unknown): SelfTestError {
125
+ const parsed = parseConflict(e)
126
+ const reason = parsed?.reason
127
+ // No workspace handler resolves for the service's provision type. Word the SPECIFIC case,
128
+ // nothing configured vs. an ambiguous match (carried on `details.handlerIssue`, distinct from the
129
+ // `env_test_not_provisionable` code), and offer the one-click jump to the Infrastructure →
130
+ // Test-environments handler config.
131
+ if (reason === 'env_test_not_provisionable') {
132
+ const ambiguous = parsed?.details.handlerIssue === 'type-mismatch'
133
+ return {
134
+ text: t(
135
+ ambiguous
136
+ ? 'errors.conflict.description.env_test_not_provisionable_type_mismatch'
137
+ : 'errors.conflict.description.env_test_not_provisionable_no_handler',
138
+ ),
139
+ configurable: true,
140
+ }
141
+ }
142
+ // The handler resolved but its live connection probe failed. The provider's OWN message is the
143
+ // actionable part ("project 'X' was not found"), so wrap it in localized prose rather than
144
+ // replacing it with a generic sentence, and offer the same jump, since the fix is in the
145
+ // handler's connection config.
146
+ if (reason === 'env_test_connection_failed' && parsed?.message) {
147
+ return {
148
+ text: t('errors.conflict.description.env_test_connection_failed_detail', {
149
+ detail: parsed.message,
150
+ }),
151
+ configurable: true,
152
+ }
153
+ }
154
+ const key =
155
+ reason && reason in CONFLICT_KEYS
156
+ ? CONFLICT_KEYS[reason as keyof typeof CONFLICT_KEYS]
157
+ : undefined
158
+ if (key && te(key)) return { text: t(key) }
159
+ return { text: apiErrorEnvelope(e)?.message ?? (e instanceof Error ? e.message : String(e)) }
160
+ }
161
+
162
+ async function start(mode: EnvironmentTestMode) {
163
+ if (!canTest.value || starting.value[mode] || busy.value) return
164
+ starting.value = { ...starting.value, [mode]: true }
165
+ errors.value = { ...errors.value, [mode]: undefined }
166
+ try {
167
+ await envTest.start(props.block.id, mode)
168
+ } catch (e) {
169
+ errors.value = { ...errors.value, [mode]: buildError(e) }
170
+ } finally {
171
+ starting.value = { ...starting.value, [mode]: false }
172
+ }
173
+ }
174
+
175
+ async function stop(mode: EnvironmentTestMode) {
176
+ const run = runFor(mode)
177
+ if (!run || run.status !== 'running') return
178
+ try {
179
+ await envTest.stop(run.id)
180
+ } catch (e) {
181
+ errors.value = { ...errors.value, [mode]: buildError(e) }
182
+ }
183
+ }
184
+ </script>
185
+
186
+ <template>
187
+ <div class="mt-3 space-y-3 border-t border-white/5 pt-3" data-testid="env-test-section">
188
+ <!-- The precondition both self-tests share, stated once at the top: with no provision type
189
+ there is no environment to stand up and therefore nothing for an agent to drive either. -->
190
+ <p v-if="!canTest" class="text-[11px] text-slate-500">
191
+ {{ t('inspector.testConfig.envTest.infraless') }}
192
+ </p>
193
+
194
+ <!-- 1. The provisioning self-test: the whole lifecycle against a throwaway branch. -->
195
+ <div class="flex items-center justify-between gap-2">
196
+ <div class="min-w-0">
197
+ <p class="text-[11px] font-medium text-slate-300">
198
+ {{ t('inspector.testConfig.envTest.title') }}
199
+ </p>
200
+ <p class="text-[11px] text-slate-400">{{ t('inspector.testConfig.envTest.hint') }}</p>
201
+ </div>
202
+ <UButton
203
+ v-if="!isRunning('provision')"
204
+ icon="i-lucide-flask-conical"
205
+ size="xs"
206
+ color="primary"
207
+ variant="soft"
208
+ data-testid="env-test-start"
209
+ :loading="starting.provision"
210
+ :disabled="!canTest || busy"
211
+ @click="start('provision')"
212
+ >
213
+ {{ t('inspector.testConfig.envTest.start') }}
214
+ </UButton>
215
+ <UButton
216
+ v-else
217
+ icon="i-lucide-square"
218
+ size="xs"
219
+ color="neutral"
220
+ variant="ghost"
221
+ data-testid="env-test-stop"
222
+ @click="stop('provision')"
223
+ >
224
+ {{ t('inspector.testConfig.envTest.stop') }}
225
+ </UButton>
226
+ </div>
227
+
228
+ <p
229
+ v-if="provisionRun"
230
+ class="text-[11px]"
231
+ :class="{
232
+ 'text-sky-300/80': provisionRun.status === 'running',
233
+ 'text-emerald-300/80': provisionRun.status === 'succeeded',
234
+ 'text-rose-300/80': provisionRun.status === 'failed',
235
+ }"
236
+ data-testid="env-test-status"
237
+ >
238
+ <template v-if="provisionRun.status === 'running'">
239
+ {{ t('inspector.testConfig.envTest.running', { stage: stageLabel(provisionRun.stage) }) }}
240
+ </template>
241
+ <template v-else-if="provisionRun.status === 'succeeded'">
242
+ {{ t('inspector.testConfig.envTest.succeeded') }}
243
+ </template>
244
+ <template v-else>
245
+ {{ t('inspector.testConfig.envTest.failed') }}
246
+ <template v-if="provisionRun.failedStage">
247
+ ({{ stageLabel(provisionRun.failedStage) }})
248
+ </template>
249
+ <span v-if="provisionRun.error" class="block text-rose-300/70">
250
+ {{ provisionRun.error }}
251
+ </span>
252
+ </template>
253
+ </p>
254
+
255
+ <div v-if="errors.provision" class="text-[11px] text-rose-400" data-testid="env-test-error">
256
+ <p>{{ errors.provision.text }}</p>
257
+ <!-- Only the not-provisionable handler case is one-click fixable: jump to Infrastructure →
258
+ Test environments, where the workspace's per-type environment handler is registered. -->
259
+ <UButton
260
+ v-if="errors.provision.configurable"
261
+ class="mt-1.5"
262
+ icon="i-lucide-settings"
263
+ size="xs"
264
+ color="neutral"
265
+ variant="soft"
266
+ data-testid="env-test-configure-handler"
267
+ @click="ui.openProviderConnection('environment')"
268
+ >
269
+ {{ t('errors.conflict.action.configureInfrastructure') }}
270
+ </UButton>
271
+ </div>
272
+
273
+ <!-- 2. The agent dry run: the same lifecycle plus a prober, reporting what an agent could
274
+ and could not do with the environment it was handed. -->
275
+ <div class="flex items-center justify-between gap-2 border-t border-white/5 pt-3">
276
+ <div class="min-w-0">
277
+ <p class="text-[11px] font-medium text-slate-300">
278
+ {{ t('inspector.testConfig.envProbe.title') }}
279
+ </p>
280
+ <p class="text-[11px] text-slate-400">
281
+ {{ t('inspector.testConfig.envProbe.hint') }}
282
+ {{
283
+ probeSurface === 'ui'
284
+ ? t('inspector.testConfig.envProbe.surfaceUi')
285
+ : t('inspector.testConfig.envProbe.surfaceApi')
286
+ }}
287
+ </p>
288
+ </div>
289
+ <UButton
290
+ v-if="!isRunning('agent-probe')"
291
+ icon="i-lucide-bot"
292
+ size="xs"
293
+ color="primary"
294
+ variant="soft"
295
+ data-testid="env-probe-start"
296
+ :loading="starting['agent-probe']"
297
+ :disabled="!canTest || busy"
298
+ @click="start('agent-probe')"
299
+ >
300
+ {{ t('inspector.testConfig.envProbe.start') }}
301
+ </UButton>
302
+ <UButton
303
+ v-else
304
+ icon="i-lucide-square"
305
+ size="xs"
306
+ color="neutral"
307
+ variant="ghost"
308
+ data-testid="env-probe-stop"
309
+ @click="stop('agent-probe')"
310
+ >
311
+ {{ t('inspector.testConfig.envTest.stop') }}
312
+ </UButton>
313
+ </div>
314
+
315
+ <p
316
+ v-if="probeRun"
317
+ class="text-[11px]"
318
+ :class="{
319
+ 'text-sky-300/80': probeRun.status === 'running',
320
+ 'text-emerald-300/80': probeRun.status === 'succeeded',
321
+ 'text-rose-300/80': probeRun.status === 'failed',
322
+ }"
323
+ data-testid="env-probe-status"
324
+ >
325
+ <template v-if="probeRun.status === 'running'">
326
+ {{ t('inspector.testConfig.envTest.running', { stage: stageLabel(probeRun.stage) }) }}
327
+ <template v-if="probeProgress">
328
+ {{
329
+ t('inspector.testConfig.envProbe.progress', {
330
+ completed: probeProgress.completed,
331
+ total: probeProgress.total,
332
+ })
333
+ }}
334
+ </template>
335
+ </template>
336
+ <template v-else-if="probeRun.status === 'succeeded'">
337
+ {{ t('inspector.testConfig.envProbe.completed') }}
338
+ </template>
339
+ <template v-else>
340
+ {{ t('inspector.testConfig.envProbe.failed') }}
341
+ <template v-if="probeRun.failedStage">({{ stageLabel(probeRun.failedStage) }})</template>
342
+ <span v-if="probeRun.error" class="block text-rose-300/70">{{ probeRun.error }}</span>
343
+ </template>
344
+ </p>
345
+
346
+ <!-- The report renders whatever the dry run got as far as, on a succeeded run AND on one that
347
+ failed after the probe reported: the finding is the product, and withholding it because a
348
+ later stage broke would hide the only thing the run was for. -->
349
+ <EnvProbeReport v-if="probeRun?.probe" :report="probeRun.probe" />
350
+
351
+ <div
352
+ v-if="errors['agent-probe']"
353
+ class="text-[11px] text-rose-400"
354
+ data-testid="env-probe-error"
355
+ >
356
+ <p>{{ errors['agent-probe']!.text }}</p>
357
+ <UButton
358
+ v-if="errors['agent-probe']!.configurable"
359
+ class="mt-1.5"
360
+ icon="i-lucide-settings"
361
+ size="xs"
362
+ color="neutral"
363
+ variant="soft"
364
+ data-testid="env-probe-configure-handler"
365
+ @click="ui.openProviderConnection('environment')"
366
+ >
367
+ {{ t('errors.conflict.action.configureInfrastructure') }}
368
+ </UButton>
369
+ </div>
370
+ </div>
371
+ </template>