@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.
- package/app/components/layout/AccountPlatformAlertSettings.vue +329 -0
- package/app/components/layout/AccountTeamSettings.vue +9 -0
- package/app/components/layout/NotificationsInbox.vue +76 -1
- package/app/components/panels/OperatorDashboardPanel.vue +130 -0
- package/app/components/settings/ModelConfigurationPanel.vue +37 -0
- package/app/components/settings/ProviderPreferenceEditor.logic.spec.ts +104 -0
- package/app/components/settings/ProviderPreferenceEditor.logic.ts +81 -0
- package/app/components/settings/ProviderPreferenceEditor.vue +180 -0
- package/app/types/execution.ts +6 -0
- package/i18n/locales/de.json +92 -4
- package/i18n/locales/en.json +92 -4
- package/i18n/locales/es.json +92 -4
- package/i18n/locales/fr.json +92 -4
- package/i18n/locales/he.json +92 -4
- package/i18n/locales/it.json +92 -4
- package/i18n/locales/ja.json +92 -4
- package/i18n/locales/pl.json +92 -4
- package/i18n/locales/tr.json +92 -4
- package/i18n/locales/uk.json +92 -4
- package/package.json +2 -2
|
@@ -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
|
+
>→ {{ 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>
|