@cat-factory/app 0.211.0 → 0.213.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,329 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
+ import type {
4
+ PlatformAlertSettings,
5
+ PlatformAlertThresholdOverrides,
6
+ PlatformAlertWindow,
7
+ } from '~/types/execution'
8
+
9
+ // Per-account tuning for the platform-health alert sweep (admin only): the ceilings the
10
+ // deployment's aggregate run health is checked against, and the window they are evaluated over.
11
+ // The deployment's env vars set the DEFAULTS; anything left blank here inherits them, so this
12
+ // panel is an override sheet rather than a settings form.
13
+ //
14
+ // That distinction drives the whole component. An empty field means "inherit", NOT zero, and a
15
+ // zero is a live setting in this vocabulary (a `minStalledPriorRuns` of 0 says "page even on an
16
+ // idle window"), so the editor keeps blank and 0 apart end to end and only sends the fields the
17
+ // admin actually filled in.
18
+ const props = defineProps<{ accountId: string }>()
19
+
20
+ const store = useAccountSettingsStore()
21
+ const toast = useToast()
22
+ const { t } = useI18n()
23
+
24
+ const WINDOWS = ['1h', '24h', '7d'] as const satisfies readonly PlatformAlertWindow[]
25
+
26
+ // Exhaustive enum→key map (the tier-2 dynamic-key guard): adding a window without a label
27
+ // fails the typecheck here rather than rendering a raw code.
28
+ const windowLabels = computed<Record<PlatformAlertWindow, string>>(() => ({
29
+ '1h': t('settings.platformAlerts.window.oneHour'),
30
+ '24h': t('settings.platformAlerts.window.oneDay'),
31
+ '7d': t('settings.platformAlerts.window.sevenDays'),
32
+ }))
33
+ const windowItems = computed(() =>
34
+ [
35
+ { label: t('settings.platformAlerts.window.inherit'), value: '' },
36
+ ...WINDOWS.map((w) => ({ label: windowLabels.value[w], value: w })),
37
+ ].map((i) => i),
38
+ )
39
+
40
+ /**
41
+ * The numeric ceilings, each rendered as one row: the contract key plus the input's step.
42
+ * ONE table drives the form, the hydrate and the save, so adding a threshold to the contract
43
+ * is a single entry rather than a form field plus a save branch free to disagree with it.
44
+ */
45
+ const THRESHOLDS = [
46
+ { field: 'minRuns', step: 1 },
47
+ { field: 'maxFailureRate', step: 0.05 },
48
+ { field: 'maxP99DurationMs', step: 1 },
49
+ { field: 'maxBacklog', step: 1 },
50
+ { field: 'stalledBuckets', step: 1 },
51
+ { field: 'minStalledPriorRuns', step: 1 },
52
+ { field: 'maxFailureKindShare', step: 0.05 },
53
+ { field: 'maxSweepFailures', step: 1 },
54
+ ] as const satisfies readonly { field: keyof PlatformAlertThresholdOverrides; step: number }[]
55
+
56
+ type ThresholdField = (typeof THRESHOLDS)[number]['field']
57
+
58
+ // Exhaustive label/hint maps, same drift guard as the windows above.
59
+ const thresholdLabels = computed<Record<ThresholdField, string>>(() => ({
60
+ minRuns: t('settings.platformAlerts.thresholds.minRuns'),
61
+ maxFailureRate: t('settings.platformAlerts.thresholds.maxFailureRate'),
62
+ maxP99DurationMs: t('settings.platformAlerts.thresholds.maxP99DurationMs'),
63
+ maxBacklog: t('settings.platformAlerts.thresholds.maxBacklog'),
64
+ stalledBuckets: t('settings.platformAlerts.thresholds.stalledBuckets'),
65
+ minStalledPriorRuns: t('settings.platformAlerts.thresholds.minStalledPriorRuns'),
66
+ maxFailureKindShare: t('settings.platformAlerts.thresholds.maxFailureKindShare'),
67
+ maxSweepFailures: t('settings.platformAlerts.thresholds.maxSweepFailures'),
68
+ }))
69
+ const thresholdHints = computed<Record<ThresholdField, string>>(() => ({
70
+ minRuns: t('settings.platformAlerts.hints.minRuns'),
71
+ maxFailureRate: t('settings.platformAlerts.hints.maxFailureRate'),
72
+ maxP99DurationMs: t('settings.platformAlerts.hints.maxP99DurationMs'),
73
+ maxBacklog: t('settings.platformAlerts.hints.maxBacklog'),
74
+ stalledBuckets: t('settings.platformAlerts.hints.stalledBuckets'),
75
+ minStalledPriorRuns: t('settings.platformAlerts.hints.minStalledPriorRuns'),
76
+ maxFailureKindShare: t('settings.platformAlerts.hints.maxFailureKindShare'),
77
+ maxSweepFailures: t('settings.platformAlerts.hints.maxSweepFailures'),
78
+ }))
79
+
80
+ // Editable state. Every value is a STRING so an empty field stays distinguishable from a typed
81
+ // `0`: binding a number input to a nullable number collapses those two the moment the field is
82
+ // cleared, and one of them is "leave the deployment default alone".
83
+ const muted = ref(false)
84
+ const alertWindow = ref<PlatformAlertWindow | ''>('')
85
+ const values = ref<Record<ThresholdField, string>>(blankValues())
86
+ const saving = ref(false)
87
+
88
+ function blankValues(): Record<ThresholdField, string> {
89
+ return Object.fromEntries(THRESHOLDS.map((th) => [th.field, ''])) as Record<
90
+ ThresholdField,
91
+ string
92
+ >
93
+ }
94
+
95
+ /** The p99 ceiling is stored in ms and edited in MINUTES, which is how operators think of it. */
96
+ function toDisplay(field: ThresholdField, stored: number | undefined): string {
97
+ if (stored === undefined) return ''
98
+ return field === 'maxP99DurationMs' ? String(stored / 60_000) : String(stored)
99
+ }
100
+ function fromDisplay(field: ThresholdField, raw: string): number | undefined {
101
+ const trimmed = raw.trim()
102
+ if (trimmed === '') return undefined
103
+ const n = Number(trimmed)
104
+ if (!Number.isFinite(n)) return undefined
105
+ return field === 'maxP99DurationMs' ? Math.round(n * 60_000) : n
106
+ }
107
+
108
+ function hydrate() {
109
+ const stored = store.view?.config?.platformAlerts
110
+ muted.value = stored?.enabled === false
111
+ alertWindow.value = stored?.window ?? ''
112
+ const next = blankValues()
113
+ for (const th of THRESHOLDS) next[th.field] = toDisplay(th.field, stored?.thresholds?.[th.field])
114
+ values.value = next
115
+ }
116
+
117
+ onMounted(async () => {
118
+ // A sibling panel loads the same store on mount; only load when nothing is there yet.
119
+ if (!store.view && store.available !== false) {
120
+ try {
121
+ await store.load(props.accountId)
122
+ } catch {
123
+ // The deployment-settings panel surfaces the error text; what matters HERE is that the
124
+ // rest of the account's config never arrived, which `loaded` below turns into a refusal
125
+ // to save rather than a silent write of a config that is missing everything.
126
+ }
127
+ }
128
+ hydrate()
129
+ })
130
+ watch(() => store.view, hydrate)
131
+
132
+ /**
133
+ * Whether the account's CURRENT config is in hand.
134
+ *
135
+ * Load-bearing because a save REPLACES the whole non-secret config and this panel only edits one
136
+ * key of it: saving on top of a failed load would carry nothing forward and silently wipe the
137
+ * model policy, the run-credential floor and every other sibling setting. The store only clears
138
+ * `available` for a 503 (the settings module is unwired), so any other load failure leaves the
139
+ * panel rendered with a null view, which is exactly the state this guards.
140
+ */
141
+ const loaded = computed(() => !!store.view)
142
+
143
+ /**
144
+ * Fields whose text is not a number. Reported rather than dropped: `fromDisplay` returning
145
+ * `undefined` means "inherit" everywhere else, so silently coercing a typo into it would answer
146
+ * a mis-typed ceiling by quietly restoring the deployment default.
147
+ */
148
+ const invalidFields = computed(() =>
149
+ THRESHOLDS.filter((th) => {
150
+ const raw = values.value[th.field].trim()
151
+ return raw !== '' && !Number.isFinite(Number(raw))
152
+ }).map((th) => thresholdLabels.value[th.field]),
153
+ )
154
+
155
+ /** The overrides to persist: only the fields the admin actually filled in. */
156
+ function collectThresholds(): PlatformAlertThresholdOverrides {
157
+ const out: PlatformAlertThresholdOverrides = {}
158
+ for (const th of THRESHOLDS) {
159
+ const parsed = fromDisplay(th.field, values.value[th.field])
160
+ if (parsed !== undefined) out[th.field] = parsed
161
+ }
162
+ return out
163
+ }
164
+
165
+ async function save() {
166
+ if (!loaded.value) {
167
+ toast.add({ title: t('settings.platformAlerts.notLoaded'), color: 'error' })
168
+ return
169
+ }
170
+ if (invalidFields.value.length > 0) {
171
+ toast.add({
172
+ title: t('settings.platformAlerts.invalidNumbers'),
173
+ description: invalidFields.value.join(', '),
174
+ color: 'error',
175
+ })
176
+ return
177
+ }
178
+ const thresholds = collectThresholds()
179
+ // Each key is omitted rather than nulled when it carries no override: an absent key is what
180
+ // the backend reads as "inherit the deployment default", and a stored null would be a value.
181
+ const settings: PlatformAlertSettings = {
182
+ ...(muted.value ? { enabled: false } : {}),
183
+ ...(alertWindow.value ? { window: alertWindow.value } : {}),
184
+ ...(Object.keys(thresholds).length > 0 ? { thresholds } : {}),
185
+ }
186
+ saving.value = true
187
+ try {
188
+ // `config` fully replaces the stored non-secret config, so carry the rest forward (guarded
189
+ // by `loaded` above, or "the rest" would be nothing).
190
+ await store.save(props.accountId, {
191
+ config: { ...store.view?.config, platformAlerts: settings },
192
+ })
193
+ toast.add({
194
+ title: t('settings.platformAlerts.saved'),
195
+ icon: 'i-lucide-check',
196
+ color: 'success',
197
+ })
198
+ } catch (e) {
199
+ toast.add({
200
+ title: t('settings.platformAlerts.saveFailed'),
201
+ description: e instanceof Error ? e.message : String(e),
202
+ color: 'error',
203
+ })
204
+ } finally {
205
+ saving.value = false
206
+ }
207
+ }
208
+
209
+ function resetAll() {
210
+ muted.value = false
211
+ alertWindow.value = ''
212
+ values.value = blankValues()
213
+ }
214
+
215
+ const hasOverrides = computed(
216
+ () =>
217
+ muted.value ||
218
+ alertWindow.value !== '' ||
219
+ THRESHOLDS.some((th) => values.value[th.field].trim() !== ''),
220
+ )
221
+ </script>
222
+
223
+ <template>
224
+ <section
225
+ v-if="store.available !== false"
226
+ data-testid="account-platform-alerts"
227
+ class="space-y-3 border-t border-slate-800 pt-6"
228
+ >
229
+ <div>
230
+ <h4 class="text-sm font-semibold text-slate-200">
231
+ {{ t('settings.platformAlerts.title') }}
232
+ </h4>
233
+ <p class="text-[11px] text-slate-400">{{ t('settings.platformAlerts.description') }}</p>
234
+ </div>
235
+
236
+ <!--
237
+ The one-way switch. It is stated rather than presented as a symmetric toggle because it
238
+ genuinely is one: the deployment's env var decides whether the sweep runs at all, and no
239
+ stored row can start a timer that was never started.
240
+ -->
241
+ <div class="space-y-1">
242
+ <UCheckbox
243
+ v-model="muted"
244
+ size="sm"
245
+ :label="t('settings.platformAlerts.muteLabel')"
246
+ data-testid="platform-alerts-mute"
247
+ />
248
+ <p class="ps-6 text-[11px] text-slate-400">{{ t('settings.platformAlerts.muteHint') }}</p>
249
+ </div>
250
+
251
+ <div class="space-y-1">
252
+ <label class="text-[11px] font-medium text-slate-300">
253
+ {{ t('settings.platformAlerts.windowLabel') }}
254
+ </label>
255
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
256
+ <USelect
257
+ v-model="alertWindow"
258
+ :items="windowItems"
259
+ value-key="value"
260
+ size="sm"
261
+ data-testid="platform-alerts-window"
262
+ />
263
+ </div>
264
+ <p class="text-[11px] text-slate-400">{{ t('settings.platformAlerts.windowHint') }}</p>
265
+ </div>
266
+
267
+ <div class="space-y-2">
268
+ <label class="text-[11px] font-medium text-slate-300">
269
+ {{ t('settings.platformAlerts.thresholdsLabel') }}
270
+ </label>
271
+ <p class="text-[11px] text-slate-400">{{ t('settings.platformAlerts.inheritHint') }}</p>
272
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
273
+ <div v-for="th in THRESHOLDS" :key="th.field" class="space-y-1">
274
+ <label class="block text-[11px] text-slate-300" :for="`platform-alert-${th.field}`">
275
+ {{ thresholdLabels[th.field] }}
276
+ </label>
277
+ <UInput
278
+ :id="`platform-alert-${th.field}`"
279
+ v-model="values[th.field]"
280
+ type="number"
281
+ :step="th.step"
282
+ size="sm"
283
+ :placeholder="t('settings.platformAlerts.inheritPlaceholder')"
284
+ :data-testid="`platform-alert-${th.field}`"
285
+ />
286
+ <p class="text-[11px] leading-snug text-slate-500">{{ thresholdHints[th.field] }}</p>
287
+ </div>
288
+ </div>
289
+ </div>
290
+
291
+ <!--
292
+ A save REPLACES the whole account config and this sheet edits one key of it, so with the
293
+ current config not in hand there is nothing to carry forward. Say so and disable the
294
+ button rather than letting a click wipe the sibling settings.
295
+ -->
296
+ <p
297
+ v-if="!loaded"
298
+ class="rounded-lg border border-amber-800/60 bg-amber-950/30 px-3 py-2 text-xs text-amber-200"
299
+ data-testid="account-platform-alerts-unloaded"
300
+ >
301
+ {{ t('settings.platformAlerts.notLoaded') }}
302
+ </p>
303
+
304
+ <div class="flex gap-2">
305
+ <UButton
306
+ color="primary"
307
+ size="xs"
308
+ icon="i-lucide-save"
309
+ :loading="saving"
310
+ :disabled="!loaded || invalidFields.length > 0"
311
+ data-testid="account-platform-alerts-save"
312
+ @click="save"
313
+ >
314
+ {{ t('common.save') }}
315
+ </UButton>
316
+ <UButton
317
+ v-if="hasOverrides"
318
+ color="neutral"
319
+ variant="subtle"
320
+ size="xs"
321
+ icon="i-lucide-rotate-ccw"
322
+ data-testid="account-platform-alerts-reset"
323
+ @click="resetAll"
324
+ >
325
+ {{ t('settings.platformAlerts.reset') }}
326
+ </UButton>
327
+ </div>
328
+ </section>
329
+ </template>
@@ -5,6 +5,7 @@ import type { AccountRole } from '~/types/domain'
5
5
  import type { InvitationStatus } from '@cat-factory/contracts'
6
6
  import AccountDeploymentSettings from '~/components/layout/AccountDeploymentSettings.vue'
7
7
  import AccountModelPolicySettings from '~/components/layout/AccountModelPolicySettings.vue'
8
+ import AccountPlatformAlertSettings from '~/components/layout/AccountPlatformAlertSettings.vue'
8
9
  import AccountRunCredentialSettings from '~/components/layout/AccountRunCredentialSettings.vue'
9
10
  import SecretInput from '~/components/common/SecretInput.vue'
10
11
 
@@ -339,6 +340,14 @@ async function disconnectEmail() {
339
340
  <AccountModelPolicySettings :account-id="accountId" />
340
341
  </section>
341
342
 
343
+ <!-- per-account tuning for the platform-health alert sweep (admin-only). Not gated on
344
+ `modelPolicySupported`: the alert thresholds bind wherever account settings exist, and
345
+ a deployment that never opted the sweep in already renders the mute switch as the
346
+ one-way control it is. -->
347
+ <section v-if="isAdmin">
348
+ <AccountPlatformAlertSettings :account-id="accountId" />
349
+ </section>
350
+
342
351
  <!-- account-wide floor under each board's run-credential switch (admin-only). Not gated on
343
352
  `modelPolicySupported`: unlike a model policy this binds wherever account settings
344
353
  exist, and a deployment that could not enforce it would be the one case where saying
@@ -8,7 +8,7 @@ import type { ReviewEffort } from '~/types/merge'
8
8
  // (merge / confirm / retry) or dismissed. Hydrated from the snapshot and patched
9
9
  // live via the `notification` WorkspaceEvent.
10
10
 
11
- const { t, te } = useI18n()
11
+ const { t, te, d } = useI18n()
12
12
 
13
13
  const notifications = useNotificationsStore()
14
14
  const ui = useUiStore()
@@ -326,6 +326,44 @@ function revealVisualConfirm(n: Notification) {
326
326
  else if (n.blockId) ui.select(n.blockId)
327
327
  }
328
328
 
329
+ /**
330
+ * The failing runs a `platform_health` card is aggregating, captured when the alert fired.
331
+ * Empty for a card raised on a condition with no failing run behind it (a backlog or a stall),
332
+ * where the payload carries no list at all, which is the point: an empty list would read as
333
+ * "we looked and found no failures".
334
+ */
335
+ function failingRuns(n: Notification) {
336
+ return n.payload?.platformFailingRuns ?? []
337
+ }
338
+
339
+ /**
340
+ * How many of the workspace's failures the card is showing. Rendered only when the sample is
341
+ * SHORT of the total, so the card states what it left out instead of presenting the cap as the
342
+ * whole story.
343
+ */
344
+ function failingRunsOmitted(n: Notification): number {
345
+ return Math.max(0, (n.payload?.platformFailedTotal ?? 0) - failingRuns(n).length)
346
+ }
347
+
348
+ /**
349
+ * Whether a linked failing run can actually be opened. A run that has since aged out of the
350
+ * board's loaded set and carries no block is a link to nowhere, and rendering it as clickable
351
+ * would be worse than rendering it plainly: the operator would read "nothing happened" from a
352
+ * click that silently did nothing.
353
+ */
354
+ function canOpenFailingRun(run: { executionId: string; blockId: string | null }): boolean {
355
+ return !!execution.getInstance(run.executionId) || !!run.blockId
356
+ }
357
+
358
+ /**
359
+ * Open one failing run behind a platform-health alert: its observability drill-down when the
360
+ * run is loaded (the "why did this fail" surface), otherwise focus its task on the board.
361
+ */
362
+ function revealFailingRun(run: { executionId: string; blockId: string | null }) {
363
+ if (execution.getInstance(run.executionId)) ui.openObservability(run.executionId)
364
+ else if (run.blockId) ui.select(run.blockId)
365
+ }
366
+
329
367
  /**
330
368
  * Open the decision surface for a parked iteration-cap run: find the run's step that is
331
369
  * waiting on a human and open it through the universal step dispatch — which routes a
@@ -401,6 +439,43 @@ function revealDecision(n: Notification) {
401
439
  <UIcon name="i-lucide-external-link" class="h-3 w-3" />
402
440
  {{ t('layout.notifications.openPr') }}
403
441
  </a>
442
+ <!--
443
+ A platform-health card deep-links to the runs it aggregated, so the operator
444
+ lands on the evidence rather than only on the dashboard.
445
+ -->
446
+ <div
447
+ v-if="failingRuns(n).length"
448
+ class="mt-1.5 flex flex-col gap-0.5"
449
+ data-testid="notification-failing-runs"
450
+ >
451
+ <component
452
+ :is="canOpenFailingRun(run) ? 'button' : 'span'"
453
+ v-for="run in failingRuns(n)"
454
+ :key="run.executionId"
455
+ :type="canOpenFailingRun(run) ? 'button' : undefined"
456
+ class="flex items-center gap-1 text-start text-[11px]"
457
+ :class="
458
+ canOpenFailingRun(run)
459
+ ? 'text-sky-400 hover:underline'
460
+ : 'cursor-default text-slate-500'
461
+ "
462
+ :title="
463
+ canOpenFailingRun(run) ? undefined : t('layout.notifications.failingRunGone')
464
+ "
465
+ @click="canOpenFailingRun(run) && revealFailingRun(run)"
466
+ >
467
+ <UIcon name="i-lucide-circle-alert" class="h-3 w-3 shrink-0" />
468
+ <span class="truncate">{{
469
+ t('layout.notifications.failingRun', {
470
+ kind: run.failureKind,
471
+ at: d(new Date(run.createdAt), 'short'),
472
+ })
473
+ }}</span>
474
+ </component>
475
+ <span v-if="failingRunsOmitted(n) > 0" class="text-[11px] text-slate-500">
476
+ {{ t('layout.notifications.failingRunsMore', { count: failingRunsOmitted(n) }) }}
477
+ </span>
478
+ </div>
404
479
  <MergeEffortChips
405
480
  v-if="collectsEffort(n)"
406
481
  :model-value="effortFor(n)"
@@ -25,6 +25,8 @@ const WINDOWS: { value: PlatformObservabilityWindow; label: string }[] = [
25
25
  { value: '1h', label: t('platformObservability.window.oneHour') },
26
26
  { value: '24h', label: t('platformObservability.window.oneDay') },
27
27
  { value: '7d', label: t('platformObservability.window.sevenDays') },
28
+ { value: '30d', label: t('platformObservability.window.thirtyDays') },
29
+ { value: '90d', label: t('platformObservability.window.ninetyDays') },
28
30
  ]
29
31
 
30
32
  // Exhaustive enum→label map (tier-2 dynamic-key guard): a new AgentFailureKind fails the
@@ -48,6 +50,21 @@ function failureLabel(kind: string): string {
48
50
  return key ? t(key) : kind
49
51
  }
50
52
 
53
+ const DAY_MS = 24 * 60 * 60 * 1000
54
+
55
+ // How the window was answered. A rollup-backed window that has materialised NOTHING must not
56
+ // render as a quiet quarter, and one whose watermark is well behind `now` must not render its
57
+ // empty tail as idleness, so the banner distinguishes "no rollup yet", "the rollup is behind"
58
+ // and "up to date" rather than leaving all three to look like data.
59
+ const rollupState = computed<'none' | 'stale' | 'current' | null>(() => {
60
+ const v = view.value
61
+ if (!v || v.source !== 'daily-rollup') return null
62
+ if (v.rolledUpThrough == null) return 'none'
63
+ // A day of slack: the sweep materialises the CURRENT day, so being one bucket behind is the
64
+ // normal state between passes rather than a gap worth flagging.
65
+ return v.generatedAt - v.rolledUpThrough > 2 * DAY_MS ? 'stale' : 'current'
66
+ })
67
+
51
68
  // The largest failure count, so each taxonomy bar is drawn relative to the leader.
52
69
  const maxFailure = computed(() => Math.max(1, ...(view.value?.failures ?? []).map((f) => f.count)))
53
70
  // The largest total in any trend bucket, so each stacked column scales to the tallest.
@@ -58,6 +75,13 @@ const maxTrend = computed(() =>
58
75
  function barPct(count: number, max: number): number {
59
76
  return Math.round((count / max) * 100)
60
77
  }
78
+
79
+ // Share of a gate kind's runs the precheck satisfied outright, 0..1: the number the
80
+ // precheck-before-escalate design exists to move. Null (not 0) when nothing settled, because
81
+ // "no gates ran" is not "every gate needed a fixer".
82
+ function cleanRate(stat: { gates: number; cleanPasses: number }): number | null {
83
+ return stat.gates > 0 ? stat.cleanPasses / stat.gates : null
84
+ }
61
85
  function heightPct(count: number, max: number): number {
62
86
  // Floor a non-zero column to 4% so a single run is still visible in the sparkline.
63
87
  return count === 0 ? 0 : Math.max(4, Math.round((count / max) * 100))
@@ -170,6 +194,41 @@ watch(
170
194
  </div>
171
195
 
172
196
  <div v-else-if="view" class="mx-auto flex max-w-5xl flex-col gap-6">
197
+ <!--
198
+ Rollup provenance. An un-materialised rollup and an idle quarter produce the same
199
+ empty series, so the long windows say which one this is instead of showing
200
+ confident zeros.
201
+ -->
202
+ <p
203
+ v-if="rollupState === 'none'"
204
+ class="rounded-lg border border-amber-800/60 bg-amber-950/30 px-3 py-2 text-xs text-amber-200"
205
+ data-testid="operator-rollup-missing"
206
+ >
207
+ {{ t('platformObservability.rollup.none') }}
208
+ </p>
209
+ <p
210
+ v-else-if="rollupState === 'stale'"
211
+ class="rounded-lg border border-amber-800/60 bg-amber-950/30 px-3 py-2 text-xs text-amber-200"
212
+ data-testid="operator-rollup-stale"
213
+ >
214
+ {{
215
+ t('platformObservability.rollup.stale', {
216
+ date: d(new Date(view.rolledUpThrough ?? 0), 'short'),
217
+ })
218
+ }}
219
+ </p>
220
+ <p
221
+ v-else-if="rollupState === 'current'"
222
+ class="text-xs text-slate-500"
223
+ data-testid="operator-rollup-current"
224
+ >
225
+ {{
226
+ t('platformObservability.rollup.current', {
227
+ date: d(new Date(view.rolledUpThrough ?? 0), 'short'),
228
+ })
229
+ }}
230
+ </p>
231
+
173
232
  <!-- Outcome summary tiles -->
174
233
  <section>
175
234
  <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
@@ -271,6 +330,77 @@ watch(
271
330
  </div>
272
331
  </section>
273
332
 
333
+ <!-- Gate / CI-fixer attempt statistics -->
334
+ <section>
335
+ <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
336
+ {{ t('platformObservability.gates.title') }}
337
+ </h2>
338
+ <div class="overflow-x-auto rounded-lg border border-slate-800 bg-slate-900/40 p-4">
339
+ <p v-if="!view.gates.length" class="py-4 text-center text-xs text-slate-500">
340
+ {{ t('platformObservability.gates.empty') }}
341
+ </p>
342
+ <table v-else class="w-full text-left text-xs" data-testid="operator-gates">
343
+ <thead class="text-[11px] uppercase tracking-wide text-slate-500">
344
+ <tr>
345
+ <th class="pb-2 pe-3 font-medium">
346
+ {{ t('platformObservability.gates.gate') }}
347
+ </th>
348
+ <th class="pb-2 pe-3 text-end font-medium">
349
+ {{ t('platformObservability.gates.settled') }}
350
+ </th>
351
+ <th class="pb-2 pe-3 text-end font-medium">
352
+ {{ t('platformObservability.gates.cleanPasses') }}
353
+ </th>
354
+ <th class="pb-2 pe-3 text-end font-medium">
355
+ {{ t('platformObservability.gates.attempts') }}
356
+ </th>
357
+ <th class="pb-2 pe-3 text-end font-medium">
358
+ {{ t('platformObservability.gates.helperFailures') }}
359
+ </th>
360
+ <th class="pb-2 text-end font-medium">
361
+ {{ t('platformObservability.gates.exhausted') }}
362
+ </th>
363
+ </tr>
364
+ </thead>
365
+ <tbody class="text-slate-300">
366
+ <tr
367
+ v-for="g in view.gates"
368
+ :key="g.gateKind"
369
+ class="border-t border-slate-800/70"
370
+ >
371
+ <td class="py-2 pe-3">
372
+ <span class="font-medium text-slate-200">{{ g.gateKind }}</span>
373
+ <span v-if="g.helperKind" class="ms-1.5 text-slate-500"
374
+ >&rarr; {{ g.helperKind }}</span
375
+ >
376
+ </td>
377
+ <td class="py-2 pe-3 text-end tabular-nums">{{ g.gates }}</td>
378
+ <td class="py-2 pe-3 text-end tabular-nums">
379
+ <span class="text-emerald-400">{{ g.cleanPasses }}</span>
380
+ <span v-if="cleanRate(g) !== null" class="ms-1 text-slate-500"
381
+ >({{ n(cleanRate(g) ?? 0, 'percent') }})</span
382
+ >
383
+ </td>
384
+ <td class="py-2 pe-3 text-end tabular-nums">{{ g.attempts }}</td>
385
+ <td class="py-2 pe-3 text-end tabular-nums">
386
+ <span :class="g.helperFailures > 0 ? 'text-amber-400' : ''">{{
387
+ g.helperFailures
388
+ }}</span>
389
+ </td>
390
+ <td class="py-2 text-end tabular-nums">
391
+ <span :class="g.exhausted > 0 ? 'text-rose-400' : ''">{{
392
+ g.exhausted
393
+ }}</span>
394
+ </td>
395
+ </tr>
396
+ </tbody>
397
+ </table>
398
+ <p class="mt-3 text-[11px] leading-relaxed text-slate-500">
399
+ {{ t('platformObservability.gates.hint') }}
400
+ </p>
401
+ </div>
402
+ </section>
403
+
274
404
  <div class="grid gap-6 md:grid-cols-2">
275
405
  <!-- Failure taxonomy -->
276
406
  <section>