@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.
@@ -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 (!isBugIntake.value) return true
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
- ...(isBugIntake.value ? { issueIntake: buildIssueIntake() } : {}),
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">{{ t('board.recurring.onDemandHint') }}</p>
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
- <div v-if="isBugIntake" class="space-y-3 rounded-lg border border-slate-800 p-3">
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