@cat-factory/app 0.78.0 → 0.79.1
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/gates/GateFailingCheckList.vue +62 -0
- package/app/components/gates/GateResultView.vue +43 -52
- package/app/components/panels/AgentStepDetail.vue +9 -0
- package/app/components/panels/AttemptEntryHeader.vue +36 -0
- package/app/components/provisioning/ProvisioningLogsDrawer.vue +55 -7
- package/app/components/testing/TestReportWindow.vue +20 -24
- package/app/stores/provisioningLogs.spec.ts +95 -0
- package/app/stores/provisioningLogs.ts +18 -6
- package/i18n/locales/en.json +2 -0
- package/i18n/locales/es.json +2 -0
- package/i18n/locales/fr.json +2 -0
- package/i18n/locales/he.json +2 -0
- package/i18n/locales/ja.json +2 -0
- package/i18n/locales/pl.json +2 -0
- package/i18n/locales/tr.json +2 -0
- package/i18n/locales/uk.json +2 -0
- package/package.json +2 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Shared renderer for a gate's failing-check list (`gateFailingCheckSchema[]`): each check
|
|
3
|
+
// links to its GitHub run when a URL is known, with its conclusion. Used by BOTH the CI
|
|
4
|
+
// gate's precheck panel and each per-attempt "handed to the fixer" list in GateResultView,
|
|
5
|
+
// so the link + conclusion-fallback logic lives in one place (they had drifted — the
|
|
6
|
+
// per-attempt copy silently dropped the GitHub link).
|
|
7
|
+
import type { GateFailingCheck } from '~/types/execution'
|
|
8
|
+
|
|
9
|
+
defineProps<{
|
|
10
|
+
checks: GateFailingCheck[]
|
|
11
|
+
// Compact layout for the per-attempt timeline; the fuller card layout is the default
|
|
12
|
+
// (the precheck panel).
|
|
13
|
+
dense?: boolean
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const { t } = useI18n()
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<ul :class="dense ? 'space-y-0.5' : 'space-y-1'">
|
|
21
|
+
<li
|
|
22
|
+
v-for="(c, i) in checks"
|
|
23
|
+
:key="`${c.name}-${i}`"
|
|
24
|
+
class="flex items-center"
|
|
25
|
+
:class="
|
|
26
|
+
dense ? 'gap-1.5' : 'gap-2 rounded-md border border-slate-800 bg-slate-950/40 px-3 py-1.5'
|
|
27
|
+
"
|
|
28
|
+
>
|
|
29
|
+
<UIcon
|
|
30
|
+
name="i-lucide-circle-x"
|
|
31
|
+
class="shrink-0 text-rose-400"
|
|
32
|
+
:class="dense ? 'h-3 w-3' : 'h-3.5 w-3.5'"
|
|
33
|
+
/>
|
|
34
|
+
<a
|
|
35
|
+
v-if="c.url"
|
|
36
|
+
:href="c.url"
|
|
37
|
+
target="_blank"
|
|
38
|
+
rel="noopener"
|
|
39
|
+
class="group min-w-0 flex-1 truncate text-sky-300 hover:text-sky-200 hover:underline"
|
|
40
|
+
:class="dense ? 'text-[12px]' : 'text-[13px]'"
|
|
41
|
+
:title="t('gates.ci.openOnGithub', { name: c.name })"
|
|
42
|
+
>
|
|
43
|
+
{{ c.name }}
|
|
44
|
+
<UIcon
|
|
45
|
+
name="i-lucide-external-link"
|
|
46
|
+
class="ms-0.5 inline h-3 w-3 opacity-60 group-hover:opacity-100"
|
|
47
|
+
/>
|
|
48
|
+
</a>
|
|
49
|
+
<span
|
|
50
|
+
v-else
|
|
51
|
+
class="min-w-0 flex-1 truncate"
|
|
52
|
+
:class="dense ? 'text-[12px] text-slate-300' : 'text-[13px] text-slate-200'"
|
|
53
|
+
>{{ c.name }}</span
|
|
54
|
+
>
|
|
55
|
+
<span
|
|
56
|
+
class="shrink-0 uppercase text-rose-300"
|
|
57
|
+
:class="dense ? 'text-[10px]' : 'text-[11px]'"
|
|
58
|
+
>{{ c.conclusion ?? t('gates.ci.conclusionFallback') }}</span
|
|
59
|
+
>
|
|
60
|
+
</li>
|
|
61
|
+
</ul>
|
|
62
|
+
</template>
|
|
@@ -10,10 +10,12 @@ import { agentKindMeta } from '~/utils/catalog'
|
|
|
10
10
|
import type { GateAttempt, GateStepState } from '~/types/execution'
|
|
11
11
|
import StepRestartControl from '~/components/panels/StepRestartControl.vue'
|
|
12
12
|
import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
13
|
+
import AttemptEntryHeader from '~/components/panels/AttemptEntryHeader.vue'
|
|
14
|
+
import GateFailingCheckList from '~/components/gates/GateFailingCheckList.vue'
|
|
13
15
|
|
|
14
16
|
const board = useBoardStore()
|
|
15
17
|
const execution = useExecutionStore()
|
|
16
|
-
const { t
|
|
18
|
+
const { t } = useI18n()
|
|
17
19
|
|
|
18
20
|
// Synchronous window: it reads its state straight off the execution step, so there's
|
|
19
21
|
// nothing to fetch on open (no `onOpen` loader).
|
|
@@ -75,10 +77,6 @@ const OUTCOME_LABELS = computed<Record<GateAttempt['outcome'], string>>(() => ({
|
|
|
75
77
|
failed: t('gates.outcome.failed'),
|
|
76
78
|
}))
|
|
77
79
|
|
|
78
|
-
function formatClock(ms?: number | null): string | null {
|
|
79
|
-
return ms ? d(new Date(ms), 'long') : null
|
|
80
|
-
}
|
|
81
|
-
|
|
82
80
|
/**
|
|
83
81
|
* The display status — a roll-up of the persisted gate state + the run's status, so the
|
|
84
82
|
* window reads as a conclusion rather than raw fields:
|
|
@@ -308,35 +306,7 @@ const conflictVerdict = computed(() => {
|
|
|
308
306
|
<h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
309
307
|
{{ t('gates.ci.failingChecks') }}
|
|
310
308
|
</h3>
|
|
311
|
-
<
|
|
312
|
-
<li
|
|
313
|
-
v-for="(c, i) in failingChecks"
|
|
314
|
-
:key="`${c.name}-${i}`"
|
|
315
|
-
class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-950/40 px-3 py-1.5"
|
|
316
|
-
>
|
|
317
|
-
<UIcon name="i-lucide-circle-x" class="h-3.5 w-3.5 shrink-0 text-rose-400" />
|
|
318
|
-
<a
|
|
319
|
-
v-if="c.url"
|
|
320
|
-
:href="c.url"
|
|
321
|
-
target="_blank"
|
|
322
|
-
rel="noopener"
|
|
323
|
-
class="group min-w-0 flex-1 truncate text-[13px] text-sky-300 hover:text-sky-200 hover:underline"
|
|
324
|
-
:title="t('gates.ci.openOnGithub', { name: c.name })"
|
|
325
|
-
>
|
|
326
|
-
{{ c.name }}
|
|
327
|
-
<UIcon
|
|
328
|
-
name="i-lucide-external-link"
|
|
329
|
-
class="ms-0.5 inline h-3 w-3 opacity-60 group-hover:opacity-100"
|
|
330
|
-
/>
|
|
331
|
-
</a>
|
|
332
|
-
<span v-else class="min-w-0 flex-1 truncate text-[13px] text-slate-200">{{
|
|
333
|
-
c.name
|
|
334
|
-
}}</span>
|
|
335
|
-
<span class="shrink-0 text-[11px] uppercase text-rose-300">
|
|
336
|
-
{{ c.conclusion ?? t('gates.ci.conclusionFallback') }}
|
|
337
|
-
</span>
|
|
338
|
-
</li>
|
|
339
|
-
</ul>
|
|
309
|
+
<GateFailingCheckList v-if="failingChecks.length" :checks="failingChecks" />
|
|
340
310
|
<p v-else class="text-[13px] leading-relaxed text-slate-300">
|
|
341
311
|
{{ gate.lastFailureSummary || t('gates.ci.failureFallback') }}
|
|
342
312
|
</p>
|
|
@@ -389,26 +359,47 @@ const conflictVerdict = computed(() => {
|
|
|
389
359
|
:key="a.attempt"
|
|
390
360
|
class="rounded-md border border-slate-800 bg-slate-950/40 px-3 py-2"
|
|
391
361
|
>
|
|
392
|
-
<
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
362
|
+
<AttemptEntryHeader
|
|
363
|
+
:label="t('gates.attempt', { number: a.attempt })"
|
|
364
|
+
:outcome="a.outcome"
|
|
365
|
+
:outcome-label="OUTCOME_LABELS[a.outcome]"
|
|
366
|
+
:at="a.at"
|
|
367
|
+
date-format="long"
|
|
368
|
+
/>
|
|
369
|
+
<!-- What this round was asked to fix: the instructions the gate handed the
|
|
370
|
+
helper (the failing-check summary / conflict reason / review comments),
|
|
371
|
+
plus the structured red checks for the CI gate. -->
|
|
372
|
+
<div
|
|
373
|
+
v-if="a.instructions || (a.failingChecks && a.failingChecks.length)"
|
|
374
|
+
class="mt-1.5"
|
|
375
|
+
>
|
|
376
|
+
<p class="text-[11px] text-slate-500">
|
|
377
|
+
{{ t('gates.attemptInstructions', { helper: helperMeta.label }) }}
|
|
378
|
+
</p>
|
|
379
|
+
<GateFailingCheckList
|
|
380
|
+
v-if="a.failingChecks && a.failingChecks.length"
|
|
381
|
+
class="mt-1"
|
|
382
|
+
:checks="a.failingChecks"
|
|
383
|
+
dense
|
|
384
|
+
/>
|
|
385
|
+
<p
|
|
386
|
+
v-else-if="a.instructions"
|
|
387
|
+
class="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-slate-300"
|
|
401
388
|
>
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}}</span>
|
|
389
|
+
{{ a.instructions }}
|
|
390
|
+
</p>
|
|
405
391
|
</div>
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
class="mt-1
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
392
|
+
<!-- The helper's own report of what it did / what remains. -->
|
|
393
|
+
<template v-if="a.summary">
|
|
394
|
+
<p class="mt-1.5 text-[11px] text-slate-500">
|
|
395
|
+
{{ t('gates.attemptReport', { helper: helperMeta.label }) }}
|
|
396
|
+
</p>
|
|
397
|
+
<p
|
|
398
|
+
class="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-slate-400"
|
|
399
|
+
>
|
|
400
|
+
{{ a.summary }}
|
|
401
|
+
</p>
|
|
402
|
+
</template>
|
|
412
403
|
</li>
|
|
413
404
|
</ol>
|
|
414
405
|
</section>
|
|
@@ -89,6 +89,14 @@ const executionId = computed(() => instance.value?.id ?? null)
|
|
|
89
89
|
// "spinning up" phase, no spinner.
|
|
90
90
|
const runFailed = computed(() => instance.value?.status === 'failed')
|
|
91
91
|
|
|
92
|
+
// Whether the run is still doing something (can still spin infra up/down). A terminal
|
|
93
|
+
// run (`done`/`failed`) has nothing left to provision, so the infra-attempts drawer
|
|
94
|
+
// stops its background live-polling (manual refresh stays available).
|
|
95
|
+
const runLive = computed(() => {
|
|
96
|
+
const status = instance.value?.status
|
|
97
|
+
return status != null && status !== 'done' && status !== 'failed'
|
|
98
|
+
})
|
|
99
|
+
|
|
92
100
|
// Live elapsed-time clock for the open step.
|
|
93
101
|
const { isRunning, durationLabel } = useStepTimer({
|
|
94
102
|
step: () => step.value,
|
|
@@ -406,6 +414,7 @@ async function copyOutput() {
|
|
|
406
414
|
v-if="showProvisioning"
|
|
407
415
|
class="mt-2"
|
|
408
416
|
:execution-id="executionId"
|
|
417
|
+
:live="runLive"
|
|
409
418
|
/>
|
|
410
419
|
</div>
|
|
411
420
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Shared header row for an attempt-timeline entry. The polling-gate helper attempts
|
|
3
|
+
// (GateResultView) and the Tester's fixer rounds (TestReportWindow) render the same chrome —
|
|
4
|
+
// a leading label, an outcome badge, and a timestamp — differing only in the optional icon,
|
|
5
|
+
// the resolved label strings, and the date format. The per-attempt body stays the caller's.
|
|
6
|
+
defineProps<{
|
|
7
|
+
label: string
|
|
8
|
+
outcome: 'completed' | 'failed'
|
|
9
|
+
outcomeLabel: string
|
|
10
|
+
at?: number | null
|
|
11
|
+
dateFormat?: 'short' | 'long'
|
|
12
|
+
icon?: string
|
|
13
|
+
iconClass?: string
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const { d } = useI18n()
|
|
17
|
+
|
|
18
|
+
function formatClock(ms: number | null | undefined, fmt: 'short' | 'long'): string | null {
|
|
19
|
+
return ms ? d(new Date(ms), fmt) : null
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<div class="flex items-center gap-2">
|
|
25
|
+
<UIcon v-if="icon" :name="icon" class="h-3.5 w-3.5 shrink-0" :class="iconClass" />
|
|
26
|
+
<span class="text-[13px] font-medium text-slate-200">{{ label }}</span>
|
|
27
|
+
<UBadge :color="outcome === 'failed' ? 'error' : 'neutral'" variant="subtle" size="sm">{{
|
|
28
|
+
outcomeLabel
|
|
29
|
+
}}</UBadge>
|
|
30
|
+
<span
|
|
31
|
+
v-if="formatClock(at, dateFormat ?? 'short')"
|
|
32
|
+
class="ms-auto text-[11px] text-slate-500"
|
|
33
|
+
>{{ formatClock(at, dateFormat ?? 'short') }}</span
|
|
34
|
+
>
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
@@ -4,15 +4,28 @@
|
|
|
4
4
|
// container), with its outcome and — for failures — the verbatim provider/runtime
|
|
5
5
|
// error. Two modes, mutually exclusive: pass `subsystem` for the provider config
|
|
6
6
|
// panels' drawer, or `executionId` for a run's "Infrastructure attempts" drawer (which
|
|
7
|
-
// surfaces that run's container/runner/env attempts).
|
|
8
|
-
|
|
7
|
+
// surfaces that run's container/runner/env attempts).
|
|
8
|
+
//
|
|
9
|
+
// In `executionId` mode the drawer LIVE-tracks: while the run is active (`live`) it
|
|
10
|
+
// silently re-polls so each container spin-up / tear-down appears with its timestamp as
|
|
11
|
+
// it happens, and it does one final poll when the run goes terminal to catch the last
|
|
12
|
+
// tear-down row (written just before the terminal event), after which the auto-poll
|
|
13
|
+
// stops. Background polls never spin the refresh button (they're silent), but the manual
|
|
14
|
+
// refresh control stays available even once the run is terminal — so a tear-down row that
|
|
15
|
+
// was missed or not yet persisted at the terminal instant can always be refetched.
|
|
16
|
+
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
|
9
17
|
import type {
|
|
10
18
|
ProvisioningOperation,
|
|
11
19
|
ProvisioningOutcome,
|
|
12
20
|
ProvisioningSubsystem,
|
|
13
21
|
} from '~/types/provisioningLogs'
|
|
14
22
|
|
|
15
|
-
const props = defineProps<{
|
|
23
|
+
const props = defineProps<{
|
|
24
|
+
subsystem?: ProvisioningSubsystem
|
|
25
|
+
executionId?: string
|
|
26
|
+
/** Run-details mode only: whether the run is still active (drives live polling). */
|
|
27
|
+
live?: boolean
|
|
28
|
+
}>()
|
|
16
29
|
|
|
17
30
|
const { t, d } = useI18n()
|
|
18
31
|
|
|
@@ -23,12 +36,47 @@ const state = computed(() =>
|
|
|
23
36
|
: store.bySubsystem[props.subsystem ?? 'environment'],
|
|
24
37
|
)
|
|
25
38
|
|
|
26
|
-
function reload() {
|
|
27
|
-
if (props.executionId) void store.loadForExecution(props.executionId)
|
|
39
|
+
function reload(silent = false) {
|
|
40
|
+
if (props.executionId) void store.loadForExecution(props.executionId, { silent })
|
|
28
41
|
else if (props.subsystem) void store.load(props.subsystem)
|
|
29
42
|
}
|
|
30
43
|
|
|
31
|
-
|
|
44
|
+
// --- live polling (executionId mode only) --------------------------------
|
|
45
|
+
const POLL_MS = 4000
|
|
46
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
47
|
+
|
|
48
|
+
function stopPolling() {
|
|
49
|
+
if (timer) {
|
|
50
|
+
clearInterval(timer)
|
|
51
|
+
timer = undefined
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function startPolling() {
|
|
56
|
+
stopPolling()
|
|
57
|
+
timer = setInterval(() => reload(true), POLL_MS)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
watch(
|
|
61
|
+
() => props.live,
|
|
62
|
+
(live, wasLive) => {
|
|
63
|
+
if (live && props.executionId != null) {
|
|
64
|
+
startPolling()
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
// Cleanup must NOT depend on `executionId` still being set: when the run's instance
|
|
68
|
+
// clears, `live` and `executionId` fall away in the same tick, so stop the interval
|
|
69
|
+
// unconditionally or it leaks (firing no-op reloads) for the component's lifetime.
|
|
70
|
+
stopPolling()
|
|
71
|
+
// On the active→terminal transition, poll once more (silently) to pick up the
|
|
72
|
+
// tear-down row the engine writes just before it emits the terminal state.
|
|
73
|
+
if (wasLive && props.executionId != null) reload(true)
|
|
74
|
+
},
|
|
75
|
+
{ immediate: true },
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
onMounted(() => reload())
|
|
79
|
+
onBeforeUnmount(stopPolling)
|
|
32
80
|
|
|
33
81
|
// Exhaustive enum→label maps of literal `t(...)` keys (keeps the typed-key drift guard
|
|
34
82
|
// live for these runtime-indexed lookups).
|
|
@@ -62,7 +110,7 @@ function when(epochMs: number): string {
|
|
|
62
110
|
variant="ghost"
|
|
63
111
|
size="xs"
|
|
64
112
|
:loading="state.loading"
|
|
65
|
-
@click="reload"
|
|
113
|
+
@click="reload()"
|
|
66
114
|
>
|
|
67
115
|
{{ t('provisioning.refresh') }}
|
|
68
116
|
</UButton>
|
|
@@ -18,6 +18,7 @@ import ArtifactLightbox from '~/components/media/ArtifactLightbox.vue'
|
|
|
18
18
|
import StepRestartControl from '~/components/panels/StepRestartControl.vue'
|
|
19
19
|
import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
20
20
|
import StepContainerStatus from '~/components/panels/StepContainerStatus.vue'
|
|
21
|
+
import AttemptEntryHeader from '~/components/panels/AttemptEntryHeader.vue'
|
|
21
22
|
import EnvironmentStatusPanel from '~/components/environments/EnvironmentStatusPanel.vue'
|
|
22
23
|
import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
|
|
23
24
|
|
|
@@ -59,6 +60,12 @@ const qualityVerdicts = computed(() => [...(quality.value?.verdicts ?? [])].reve
|
|
|
59
60
|
// run's infrastructure attempts + logs (container/runner/env spin-up), not just the
|
|
60
61
|
// report. The container/subtask signals already flow onto the step via the generic poll.
|
|
61
62
|
const runFailed = computed(() => instance.value?.status === 'failed')
|
|
63
|
+
// A terminal run (done/failed) can't spin more infra: the attempts drawer stops its
|
|
64
|
+
// background live-polling (manual refresh stays available).
|
|
65
|
+
const runLive = computed(() => {
|
|
66
|
+
const status = instance.value?.status
|
|
67
|
+
return status != null && status !== 'done' && status !== 'failed'
|
|
68
|
+
})
|
|
62
69
|
const stepEnvironment = computed(() => step.value?.environment ?? null)
|
|
63
70
|
const executionId = computed(() => instance.value?.id ?? null)
|
|
64
71
|
// The infra-attempts log drawer is opened on demand (it fetches the per-run log rows).
|
|
@@ -485,6 +492,7 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
|
|
|
485
492
|
v-if="showProvisioning"
|
|
486
493
|
class="mt-2"
|
|
487
494
|
:execution-id="executionId"
|
|
495
|
+
:live="runLive"
|
|
488
496
|
/>
|
|
489
497
|
</div>
|
|
490
498
|
</section>
|
|
@@ -507,30 +515,18 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
|
|
|
507
515
|
data-testid="tester-fixer-attempt"
|
|
508
516
|
class="rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2"
|
|
509
517
|
>
|
|
510
|
-
<
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
size="sm"
|
|
523
|
-
>
|
|
524
|
-
{{
|
|
525
|
-
a.outcome === 'completed'
|
|
526
|
-
? t('testing.fixerTimeline.completed')
|
|
527
|
-
: t('testing.fixerTimeline.failed')
|
|
528
|
-
}}
|
|
529
|
-
</UBadge>
|
|
530
|
-
<span class="ms-auto text-[11px] text-slate-500">{{
|
|
531
|
-
d(new Date(a.at), 'short')
|
|
532
|
-
}}</span>
|
|
533
|
-
</div>
|
|
518
|
+
<AttemptEntryHeader
|
|
519
|
+
:label="t('testing.fixerTimeline.attempt', { n: a.attempt })"
|
|
520
|
+
:outcome="a.outcome"
|
|
521
|
+
:outcome-label="
|
|
522
|
+
a.outcome === 'completed'
|
|
523
|
+
? t('testing.fixerTimeline.completed')
|
|
524
|
+
: t('testing.fixerTimeline.failed')
|
|
525
|
+
"
|
|
526
|
+
:at="a.at"
|
|
527
|
+
:icon="a.outcome === 'completed' ? 'i-lucide-wrench' : 'i-lucide-circle-x'"
|
|
528
|
+
:icon-class="a.outcome === 'completed' ? 'text-amber-300' : 'text-rose-400'"
|
|
529
|
+
/>
|
|
534
530
|
<p v-if="a.summary" class="mt-1 text-[12px] leading-snug text-slate-400">
|
|
535
531
|
{{ a.summary }}
|
|
536
532
|
</p>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { useProvisioningLogsStore } from '~/stores/provisioningLogs'
|
|
3
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
import type { ProvisioningLogEntry } from '~/types/provisioningLogs'
|
|
5
|
+
|
|
6
|
+
/** Minimal attempt-row factory — only the fields the store passes through. */
|
|
7
|
+
function entry(over: Partial<ProvisioningLogEntry> = {}): ProvisioningLogEntry {
|
|
8
|
+
return {
|
|
9
|
+
id: 'p1',
|
|
10
|
+
workspaceId: 'ws1',
|
|
11
|
+
subsystem: 'container',
|
|
12
|
+
operation: 'dispatch',
|
|
13
|
+
outcome: 'success',
|
|
14
|
+
targetId: 'job1',
|
|
15
|
+
providerId: null,
|
|
16
|
+
blockId: null,
|
|
17
|
+
executionId: 'exec1',
|
|
18
|
+
error: null,
|
|
19
|
+
detail: null,
|
|
20
|
+
createdAt: 1,
|
|
21
|
+
...over,
|
|
22
|
+
} as ProvisioningLogEntry
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('provisioningLogs store — loadForExecution', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('a visible load flips the loading spinner and stores the entries', async () => {
|
|
31
|
+
let resolveFetch!: (r: { entries: ProvisioningLogEntry[] }) => void
|
|
32
|
+
const pending = new Promise<{ entries: ProvisioningLogEntry[] }>((res) => {
|
|
33
|
+
resolveFetch = res
|
|
34
|
+
})
|
|
35
|
+
vi.stubGlobal('useApi', () => ({ listProvisioningLogs: () => pending }))
|
|
36
|
+
|
|
37
|
+
const store = useProvisioningLogsStore()
|
|
38
|
+
const load = store.loadForExecution('exec1')
|
|
39
|
+
// In flight: the button spinner is on.
|
|
40
|
+
expect(store.byExecution.exec1!.loading).toBe(true)
|
|
41
|
+
|
|
42
|
+
resolveFetch({ entries: [entry()] })
|
|
43
|
+
await load
|
|
44
|
+
|
|
45
|
+
expect(store.byExecution.exec1!.loading).toBe(false)
|
|
46
|
+
expect(store.byExecution.exec1!.entries).toHaveLength(1)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('a silent poll never flips the loading spinner', async () => {
|
|
50
|
+
vi.stubGlobal('useApi', () => ({
|
|
51
|
+
listProvisioningLogs: () => Promise.resolve({ entries: [entry({ operation: 'release' })] }),
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
const store = useProvisioningLogsStore()
|
|
55
|
+
await store.loadForExecution('exec1', { silent: true })
|
|
56
|
+
|
|
57
|
+
// Never went truthy — a background poll must not show a "refreshing" spinner.
|
|
58
|
+
expect(store.byExecution.exec1!.loading).toBe(false)
|
|
59
|
+
// But it still updates the timeline (the tear-down row now shows).
|
|
60
|
+
expect(store.byExecution.exec1!.entries[0]!.operation).toBe('release')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('a silent poll failure keeps the last-good entries and surfaces no error', async () => {
|
|
64
|
+
const store = useProvisioningLogsStore()
|
|
65
|
+
|
|
66
|
+
// Seed a good snapshot via a visible load.
|
|
67
|
+
vi.stubGlobal('useApi', () => ({
|
|
68
|
+
listProvisioningLogs: () => Promise.resolve({ entries: [entry()] }),
|
|
69
|
+
}))
|
|
70
|
+
await store.loadForExecution('exec1')
|
|
71
|
+
expect(store.byExecution.exec1!.entries).toHaveLength(1)
|
|
72
|
+
|
|
73
|
+
// A background poll then blips — the drawer must keep showing what it had.
|
|
74
|
+
vi.stubGlobal('useApi', () => ({
|
|
75
|
+
listProvisioningLogs: () => Promise.reject(new Error('network')),
|
|
76
|
+
}))
|
|
77
|
+
await store.loadForExecution('exec1', { silent: true })
|
|
78
|
+
|
|
79
|
+
expect(store.byExecution.exec1!.entries).toHaveLength(1)
|
|
80
|
+
expect(store.byExecution.exec1!.error).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('a visible load failure clears entries and reports the error', async () => {
|
|
84
|
+
vi.stubGlobal('useApi', () => ({
|
|
85
|
+
listProvisioningLogs: () => Promise.reject(new Error('503')),
|
|
86
|
+
}))
|
|
87
|
+
|
|
88
|
+
const store = useProvisioningLogsStore()
|
|
89
|
+
await store.loadForExecution('exec1')
|
|
90
|
+
|
|
91
|
+
expect(store.byExecution.exec1!.loading).toBe(false)
|
|
92
|
+
expect(store.byExecution.exec1!.entries).toHaveLength(0)
|
|
93
|
+
expect(store.byExecution.exec1!.error).toBe('503')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -47,22 +47,34 @@ export const useProvisioningLogsStore = defineStore('provisioningLogs', () => {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Load a run's provisioning attempts. `silent` is for the drawer's background poll
|
|
52
|
+
* while the run is live: it must NOT flip the `loading` spinner (it would flicker
|
|
53
|
+
* every poll) and a transient failure must NOT clear the last-good entries or surface
|
|
54
|
+
* an error banner — the visible refresh path (initial open / manual refresh) owns those.
|
|
55
|
+
*/
|
|
56
|
+
async function loadForExecution(executionId: string, opts?: { silent?: boolean }) {
|
|
51
57
|
const ws = useWorkspaceStore()
|
|
52
58
|
const s = (byExecution[executionId] ??= emptyState())
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
if (!opts?.silent) {
|
|
60
|
+
s.loading = true
|
|
61
|
+
s.error = null
|
|
62
|
+
}
|
|
55
63
|
try {
|
|
56
64
|
const { entries } = await api.listProvisioningLogs(ws.requireId(), {
|
|
57
65
|
executionId,
|
|
58
66
|
limit: 200,
|
|
59
67
|
})
|
|
60
68
|
s.entries = entries
|
|
69
|
+
s.error = null
|
|
61
70
|
} catch (err) {
|
|
62
|
-
|
|
63
|
-
|
|
71
|
+
// A background poll keeps the last snapshot on a blip; only a visible load reports.
|
|
72
|
+
if (!opts?.silent) {
|
|
73
|
+
s.error = err instanceof Error ? err.message : 'Failed to load logs'
|
|
74
|
+
s.entries = []
|
|
75
|
+
}
|
|
64
76
|
} finally {
|
|
65
|
-
s.loading = false
|
|
77
|
+
if (!opts?.silent) s.loading = false
|
|
66
78
|
}
|
|
67
79
|
}
|
|
68
80
|
|
package/i18n/locales/en.json
CHANGED
|
@@ -2981,6 +2981,8 @@
|
|
|
2981
2981
|
},
|
|
2982
2982
|
"attemptsHeading": "{helper} attempts",
|
|
2983
2983
|
"attempt": "Attempt {number}",
|
|
2984
|
+
"attemptInstructions": "Handed to {helper}",
|
|
2985
|
+
"attemptReport": "{helper} report",
|
|
2984
2986
|
"outcome": {
|
|
2985
2987
|
"completed": "completed",
|
|
2986
2988
|
"failed": "failed"
|
package/i18n/locales/es.json
CHANGED
|
@@ -2885,6 +2885,8 @@
|
|
|
2885
2885
|
},
|
|
2886
2886
|
"attemptsHeading": "Intentos de {helper}",
|
|
2887
2887
|
"attempt": "Intento {number}",
|
|
2888
|
+
"attemptInstructions": "Entregado a {helper}",
|
|
2889
|
+
"attemptReport": "Informe de {helper}",
|
|
2888
2890
|
"outcome": {
|
|
2889
2891
|
"completed": "completado",
|
|
2890
2892
|
"failed": "fallido"
|
package/i18n/locales/fr.json
CHANGED
|
@@ -2885,6 +2885,8 @@
|
|
|
2885
2885
|
},
|
|
2886
2886
|
"attemptsHeading": "Tentatives du {helper}",
|
|
2887
2887
|
"attempt": "Tentative {number}",
|
|
2888
|
+
"attemptInstructions": "Transmis à {helper}",
|
|
2889
|
+
"attemptReport": "Rapport de {helper}",
|
|
2888
2890
|
"outcome": {
|
|
2889
2891
|
"completed": "terminée",
|
|
2890
2892
|
"failed": "échouée"
|
package/i18n/locales/he.json
CHANGED
package/i18n/locales/ja.json
CHANGED
package/i18n/locales/pl.json
CHANGED
|
@@ -2885,6 +2885,8 @@
|
|
|
2885
2885
|
},
|
|
2886
2886
|
"attemptsHeading": "Próby: {helper}",
|
|
2887
2887
|
"attempt": "Próba {number}",
|
|
2888
|
+
"attemptInstructions": "Przekazano do: {helper}",
|
|
2889
|
+
"attemptReport": "Raport: {helper}",
|
|
2888
2890
|
"outcome": {
|
|
2889
2891
|
"completed": "ukończono",
|
|
2890
2892
|
"failed": "niepowodzenie"
|
package/i18n/locales/tr.json
CHANGED
|
@@ -2898,6 +2898,8 @@
|
|
|
2898
2898
|
},
|
|
2899
2899
|
"attemptsHeading": "{helper} denemeleri",
|
|
2900
2900
|
"attempt": "Deneme {number}",
|
|
2901
|
+
"attemptInstructions": "{helper}'a iletildi",
|
|
2902
|
+
"attemptReport": "{helper} raporu",
|
|
2901
2903
|
"outcome": {
|
|
2902
2904
|
"completed": "tamamlandı",
|
|
2903
2905
|
"failed": "başarısız oldu"
|
package/i18n/locales/uk.json
CHANGED
|
@@ -2885,6 +2885,8 @@
|
|
|
2885
2885
|
},
|
|
2886
2886
|
"attemptsHeading": "Спроби: {helper}",
|
|
2887
2887
|
"attempt": "Спроба {number}",
|
|
2888
|
+
"attemptInstructions": "Передано: {helper}",
|
|
2889
|
+
"attemptReport": "Звіт: {helper}",
|
|
2888
2890
|
"outcome": {
|
|
2889
2891
|
"completed": "завершено",
|
|
2890
2892
|
"failed": "помилка"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.79.1",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"valibot": "^1.4.2",
|
|
35
35
|
"vue": "^3.5.39",
|
|
36
36
|
"wretch": "^3.0.9",
|
|
37
|
-
"@cat-factory/contracts": "0.
|
|
37
|
+
"@cat-factory/contracts": "0.85.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@toad-contracts/testing": "0.3.2",
|