@cat-factory/app 0.210.1 → 0.212.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/board/RecurringPipelineModal.vue +145 -7
- 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/LocalModelEndpointsPanel.vue +45 -3
- package/app/types/execution.ts +6 -0
- package/app/types/localModels.ts +1 -0
- package/i18n/locales/de.json +84 -3
- package/i18n/locales/en.json +84 -3
- package/i18n/locales/es.json +84 -3
- package/i18n/locales/fr.json +84 -3
- package/i18n/locales/he.json +84 -3
- package/i18n/locales/it.json +84 -3
- package/i18n/locales/ja.json +84 -3
- package/i18n/locales/pl.json +84 -3
- package/i18n/locales/tr.json +84 -3
- package/i18n/locales/uk.json +84 -3
- package/package.json +2 -2
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
// where that pipeline files its ticket) and saved alongside.
|
|
8
8
|
import type { IssueIntakeConfig, Recurrence, ScheduleTemplate } from '~/types/recurring'
|
|
9
9
|
import type { TaskSourceKind } from '~/types/domain'
|
|
10
|
+
import type { IssueIntakeRefusalReason } from '@cat-factory/contracts'
|
|
11
|
+
import { BUILTIN_TASK_SOURCE_KINDS } from '@cat-factory/contracts'
|
|
12
|
+
import { apiErrorReason } from '~/composables/api/errors'
|
|
10
13
|
import { pipelineAllowedForSchedule } from '~/utils/pipeline'
|
|
11
14
|
|
|
12
15
|
const ui = useUiStore()
|
|
@@ -17,7 +20,7 @@ const tracker = useTrackerStore()
|
|
|
17
20
|
const tasks = useTasksStore()
|
|
18
21
|
const toast = useToast()
|
|
19
22
|
const access = useWorkspaceAccess()
|
|
20
|
-
const { t } = useI18n()
|
|
23
|
+
const { t, te } = useI18n()
|
|
21
24
|
|
|
22
25
|
const open = computed({
|
|
23
26
|
get: () => ui.addRecurringFrameId !== null,
|
|
@@ -50,6 +53,68 @@ const intakeSource = ref<TaskSourceKind | null>(null)
|
|
|
50
53
|
const intakeJiraProjectKey = ref('')
|
|
51
54
|
const intakeLinearTeamId = ref('')
|
|
52
55
|
const intakeGithubRepo = ref('')
|
|
56
|
+
/**
|
|
57
|
+
* The board scope for a DEPLOYMENT-REGISTERED source, held opaquely. Its own field rather than
|
|
58
|
+
* reusing one of the three above, mirroring `issueIntakeConfigSchema.board.boardId`: only that
|
|
59
|
+
* deployment's provider knows what its board id means, so the form carries the string and the
|
|
60
|
+
* label stays generic.
|
|
61
|
+
*/
|
|
62
|
+
const intakeBoardId = ref('')
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Opt in to driving THIS schedule from tracker webhooks, for a pipeline that has no `bug-intake`
|
|
66
|
+
* step. Enabling it also switches the schedule to on-demand, because the two are inseparable: a
|
|
67
|
+
* cadence tick carries no triggering ticket, and the server refuses that combination.
|
|
68
|
+
*/
|
|
69
|
+
const trackerTrigger = ref(false)
|
|
70
|
+
|
|
71
|
+
function setTrackerTrigger(on: boolean): void {
|
|
72
|
+
trackerTrigger.value = on
|
|
73
|
+
if (on) onDemand.value = true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* On-demand is FORCED, not merely defaulted, while the tracker trigger is on: the server refuses a
|
|
78
|
+
* per-ticket schedule that could also fire on a cadence, and setting the switch once at opt-in time
|
|
79
|
+
* left the refusal reachable by turning it back off afterwards. The switch is disabled rather than
|
|
80
|
+
* hidden, so the state it is locked into stays visible and the reason is stated beside it.
|
|
81
|
+
*/
|
|
82
|
+
const onDemandLocked = computed(() => trackerTrigger.value)
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Translated copy for a refused intake configuration, keyed off the backend's machine-readable
|
|
86
|
+
* `details.reason`.
|
|
87
|
+
*
|
|
88
|
+
* The form makes both refusals unrepresentable, so this is the SECOND line rather than the first:
|
|
89
|
+
* a stale form whose pipeline gained a `bug-intake` step since it opened, or an API client driving
|
|
90
|
+
* the same endpoint, still reaches them, and the backend does not localize its prose. An EXHAUSTIVE
|
|
91
|
+
* `Record` over the contracts union is the drift guard: a new refusal reason fails this typecheck
|
|
92
|
+
* until it has copy, which a runtime `t()` lookup could not catch.
|
|
93
|
+
*/
|
|
94
|
+
const INTAKE_REFUSAL_KEYS: Record<IssueIntakeRefusalReason, string> = {
|
|
95
|
+
per_ticket_requires_on_demand: 'board.recurring.refusalPerTicketRequiresOnDemand',
|
|
96
|
+
per_ticket_conflicts_with_bug_intake: 'board.recurring.refusalPerTicketConflictsWithBugIntake',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function intakeRefusalCopy(error: unknown): string | null {
|
|
100
|
+
const reason = apiErrorReason(error)
|
|
101
|
+
if (!reason || !(reason in INTAKE_REFUSAL_KEYS)) return null
|
|
102
|
+
// `te` before `t`, the `usePipelineErrorToast` idiom: a locale missing the key falls through to
|
|
103
|
+
// the backend's prose rather than rendering the key path at the user.
|
|
104
|
+
const key = INTAKE_REFUSAL_KEYS[reason as IssueIntakeRefusalReason]
|
|
105
|
+
return te(key) ? t(key) : null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether the picked source is one this build ships (and so has a vendor-specific board field).
|
|
110
|
+
*
|
|
111
|
+
* Read from the contracts constant rather than re-listing the three ids: the vocabulary has one
|
|
112
|
+
* owner, and a fourth built-in source would otherwise render the opaque board field here while
|
|
113
|
+
* every other surface offered its vendor one.
|
|
114
|
+
*/
|
|
115
|
+
const intakeSourceIsBuiltin = computed(() =>
|
|
116
|
+
(BUILTIN_TASK_SOURCE_KINDS as readonly string[]).includes(intakeSource.value ?? ''),
|
|
117
|
+
)
|
|
53
118
|
const intakeTitleFragment = ref('')
|
|
54
119
|
const intakeLabels = ref('') // comma-separated in the UI, sent as an array
|
|
55
120
|
const intakeIssueType = ref('')
|
|
@@ -98,6 +163,23 @@ const isBugIntake = computed(() => {
|
|
|
98
163
|
(kind, i) => kind === 'bug-intake' && pipeline.enabled?.[i] !== false,
|
|
99
164
|
)
|
|
100
165
|
})
|
|
166
|
+
/**
|
|
167
|
+
* Whether the intake section is shown, and in which DISPATCH mode — both DERIVED from the picked
|
|
168
|
+
* pipeline rather than chosen, because the two modes are not interchangeable:
|
|
169
|
+
*
|
|
170
|
+
* - a `bug-intake` pipeline pulls its own work from the board, so a pushed event can only mean
|
|
171
|
+
* "drain the queue now" (`queue`);
|
|
172
|
+
* - any other pipeline has no step that picks work, so a pushed event can only mean "run THIS
|
|
173
|
+
* ticket" (`per-ticket`).
|
|
174
|
+
*
|
|
175
|
+
* Deriving it makes the combination the server refuses (`per-ticket` on a `bug-intake` pipeline)
|
|
176
|
+
* unrepresentable here, instead of offering it and reporting a validation error afterwards.
|
|
177
|
+
*/
|
|
178
|
+
const showIntake = computed(() => isBugIntake.value || trackerTrigger.value)
|
|
179
|
+
const intakeDispatch = computed<'queue' | 'per-ticket'>(() =>
|
|
180
|
+
isBugIntake.value ? 'queue' : 'per-ticket',
|
|
181
|
+
)
|
|
182
|
+
|
|
101
183
|
// Sources that can back intake right now (connected / App-installed AND enabled).
|
|
102
184
|
const intakeSources = computed(() => tasks.offeredSources)
|
|
103
185
|
|
|
@@ -119,6 +201,8 @@ watch(open, (isOpen) => {
|
|
|
119
201
|
intakeJiraProjectKey.value = ''
|
|
120
202
|
intakeLinearTeamId.value = ''
|
|
121
203
|
intakeGithubRepo.value = ''
|
|
204
|
+
intakeBoardId.value = ''
|
|
205
|
+
trackerTrigger.value = false
|
|
122
206
|
intakeTitleFragment.value = ''
|
|
123
207
|
intakeLabels.value = ''
|
|
124
208
|
intakeIssueType.value = ''
|
|
@@ -147,6 +231,8 @@ const { requestClose } = useUnsavedGuard({
|
|
|
147
231
|
intakeJiraProjectKey: intakeJiraProjectKey.value.trim(),
|
|
148
232
|
intakeLinearTeamId: intakeLinearTeamId.value.trim(),
|
|
149
233
|
intakeGithubRepo: intakeGithubRepo.value.trim(),
|
|
234
|
+
intakeBoardId: intakeBoardId.value.trim(),
|
|
235
|
+
trackerTrigger: trackerTrigger.value,
|
|
150
236
|
intakeTitleFragment: intakeTitleFragment.value.trim(),
|
|
151
237
|
intakeLabels: intakeLabels.value.trim(),
|
|
152
238
|
intakeIssueType: intakeIssueType.value.trim(),
|
|
@@ -156,10 +242,13 @@ const { requestClose } = useUnsavedGuard({
|
|
|
156
242
|
|
|
157
243
|
// The board field required for the picked source must be filled before a bug-intake schedule saves.
|
|
158
244
|
const intakeReady = computed(() => {
|
|
159
|
-
if (!
|
|
245
|
+
if (!showIntake.value) return true
|
|
160
246
|
if (intakeSource.value === 'jira') return intakeJiraProjectKey.value.trim().length > 0
|
|
161
247
|
if (intakeSource.value === 'linear') return intakeLinearTeamId.value.trim().length > 0
|
|
162
248
|
if (intakeSource.value === 'github') return intakeGithubRepo.value.trim().length > 0
|
|
249
|
+
// A registered source is scoped by its opaque board id. Falling through to `false` here would
|
|
250
|
+
// make its schedule permanently unsaveable rather than merely unscoped.
|
|
251
|
+
if (intakeSource.value) return intakeBoardId.value.trim().length > 0
|
|
163
252
|
return false
|
|
164
253
|
})
|
|
165
254
|
|
|
@@ -181,6 +270,9 @@ function buildIssueIntake(): IssueIntakeConfig {
|
|
|
181
270
|
...(source === 'github' && intakeGithubRepo.value.trim()
|
|
182
271
|
? { githubRepo: intakeGithubRepo.value.trim() }
|
|
183
272
|
: {}),
|
|
273
|
+
...(!intakeSourceIsBuiltin.value && intakeBoardId.value.trim()
|
|
274
|
+
? { boardId: intakeBoardId.value.trim() }
|
|
275
|
+
: {}),
|
|
184
276
|
},
|
|
185
277
|
predicates: {
|
|
186
278
|
...(intakeTitleFragment.value.trim()
|
|
@@ -192,6 +284,9 @@ function buildIssueIntake(): IssueIntakeConfig {
|
|
|
192
284
|
...(source === 'github' && intakeInProgressLabel.value.trim()
|
|
193
285
|
? { inProgressLabel: intakeInProgressLabel.value.trim() }
|
|
194
286
|
: {}),
|
|
287
|
+
// Sent only when it differs from the default, so an ordinary bug-intake schedule's stored
|
|
288
|
+
// config is byte-for-byte what it was before the mode existed.
|
|
289
|
+
...(intakeDispatch.value === 'per-ticket' ? { dispatch: 'per-ticket' as const } : {}),
|
|
195
290
|
}
|
|
196
291
|
}
|
|
197
292
|
|
|
@@ -227,13 +322,13 @@ async function add() {
|
|
|
227
322
|
onDemand: onDemand.value,
|
|
228
323
|
...(onDemand.value ? {} : { recurrence: recurrence.value }),
|
|
229
324
|
...(description.value.trim() ? { description: description.value.trim() } : {}),
|
|
230
|
-
...(
|
|
325
|
+
...(showIntake.value ? { issueIntake: buildIssueIntake() } : {}),
|
|
231
326
|
})
|
|
232
327
|
ui.closeAddRecurring()
|
|
233
328
|
} catch (e) {
|
|
234
329
|
toast.add({
|
|
235
330
|
title: t('board.recurring.addFailedTitle'),
|
|
236
|
-
description: e instanceof Error ? e.message : String(e),
|
|
331
|
+
description: intakeRefusalCopy(e) ?? (e instanceof Error ? e.message : String(e)),
|
|
237
332
|
icon: 'i-lucide-triangle-alert',
|
|
238
333
|
color: 'error',
|
|
239
334
|
})
|
|
@@ -287,10 +382,16 @@ async function add() {
|
|
|
287
382
|
</UFormField>
|
|
288
383
|
|
|
289
384
|
<div class="flex items-start gap-2 rounded-lg border border-slate-800 p-3">
|
|
290
|
-
<USwitch v-model="onDemand" size="sm" class="mt-0.5" />
|
|
385
|
+
<USwitch v-model="onDemand" :disabled="onDemandLocked" size="sm" class="mt-0.5" />
|
|
291
386
|
<div class="space-y-0.5">
|
|
292
387
|
<p class="text-xs font-medium text-slate-200">{{ t('board.recurring.onDemand') }}</p>
|
|
293
|
-
<p class="text-[11px] text-slate-500">
|
|
388
|
+
<p class="text-[11px] text-slate-500">
|
|
389
|
+
{{
|
|
390
|
+
onDemandLocked
|
|
391
|
+
? t('board.recurring.onDemandLockedHint')
|
|
392
|
+
: t('board.recurring.onDemandHint')
|
|
393
|
+
}}
|
|
394
|
+
</p>
|
|
294
395
|
</div>
|
|
295
396
|
</div>
|
|
296
397
|
|
|
@@ -356,7 +457,29 @@ async function add() {
|
|
|
356
457
|
</UFormField>
|
|
357
458
|
</div>
|
|
358
459
|
|
|
359
|
-
|
|
460
|
+
<!--
|
|
461
|
+
A pipeline with no `bug-intake` step has no step that picks work, so tracker intake is an
|
|
462
|
+
OPT-IN here: turning it on makes a matching webhook event run that ticket as its own task.
|
|
463
|
+
A `bug-intake` pipeline needs no toggle — it cannot run without intake config at all.
|
|
464
|
+
-->
|
|
465
|
+
<div v-if="!isBugIntake" class="flex items-start gap-2">
|
|
466
|
+
<USwitch
|
|
467
|
+
:model-value="trackerTrigger"
|
|
468
|
+
size="sm"
|
|
469
|
+
class="mt-0.5"
|
|
470
|
+
@update:model-value="setTrackerTrigger"
|
|
471
|
+
/>
|
|
472
|
+
<div>
|
|
473
|
+
<p class="text-xs font-medium text-slate-200">
|
|
474
|
+
{{ t('board.recurring.trackerTrigger') }}
|
|
475
|
+
</p>
|
|
476
|
+
<p class="text-[11px] text-slate-500">
|
|
477
|
+
{{ t('board.recurring.trackerTriggerHint') }}
|
|
478
|
+
</p>
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
|
|
482
|
+
<div v-if="showIntake" class="space-y-3 rounded-lg border border-slate-800 p-3">
|
|
360
483
|
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
361
484
|
{{ t('board.recurring.intake') }}
|
|
362
485
|
</p>
|
|
@@ -410,8 +533,23 @@ async function add() {
|
|
|
410
533
|
<!-- A GitHub repo ref is always the literal `owner/name` path, never localized. -->
|
|
411
534
|
<UInput v-model="intakeGithubRepo" placeholder="owner/name" class="w-full" />
|
|
412
535
|
</UFormField>
|
|
536
|
+
<UFormField
|
|
537
|
+
v-if="intakeSource && !intakeSourceIsBuiltin"
|
|
538
|
+
:label="t('board.recurring.intakeBoardId')"
|
|
539
|
+
:help="t('board.recurring.intakeBoardIdHelp')"
|
|
540
|
+
required
|
|
541
|
+
>
|
|
542
|
+
<UInput v-model="intakeBoardId" class="w-full" />
|
|
543
|
+
</UFormField>
|
|
413
544
|
|
|
414
545
|
<template v-if="intakeSource">
|
|
546
|
+
<p class="text-[11px] text-slate-500">
|
|
547
|
+
{{
|
|
548
|
+
intakeDispatch === 'per-ticket'
|
|
549
|
+
? t('board.recurring.intakeDispatchPerTicketHint')
|
|
550
|
+
: t('board.recurring.intakeDispatchQueueHint')
|
|
551
|
+
}}
|
|
552
|
+
</p>
|
|
415
553
|
<UFormField :label="t('board.recurring.intakeTitleFragment')">
|
|
416
554
|
<UInput
|
|
417
555
|
v-model="intakeTitleFragment"
|
|
@@ -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
|