@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.
@@ -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>
@@ -6,7 +6,12 @@
6
6
  // serves and tick which to enable. Save persists the endpoint; the enabled models then surface
7
7
  // automatically in the per-workspace model picker. One endpoint per runner type.
8
8
  import { computed, ref, watch } from 'vue'
9
- import { LOCAL_RUNNER_DEFAULTS, LOCAL_RUNNER_LABELS, type LocalRunner } from '~/types/localModels'
9
+ import {
10
+ LOCAL_RUNNER_DEFAULTS,
11
+ LOCAL_RUNNER_LABELS,
12
+ type LocalRunner,
13
+ type LocalRunnerUrlReason,
14
+ } from '~/types/localModels'
10
15
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
11
16
  import SecretInput from '~/components/common/SecretInput.vue'
12
17
 
@@ -36,6 +41,23 @@ const RUNNERS: { value: LocalRunner; label: string }[] = (
36
41
  Object.keys(LOCAL_RUNNER_LABELS) as LocalRunner[]
37
42
  ).map((value) => ({ value, label: LOCAL_RUNNER_LABELS[value] }))
38
43
 
44
+ // Why the deployment refuses a runner URL, in translated copy. An exhaustive Record keyed
45
+ // off the contracts union, so adding a reason backend-side fails this typecheck instead of
46
+ // rendering the backend's English (which stays available as the "details" line).
47
+ const URL_REASON_KEYS = {
48
+ invalid_url: 'settings.localModelEndpoints.urlReason.invalid_url',
49
+ scheme_not_allowed: 'settings.localModelEndpoints.urlReason.scheme_not_allowed',
50
+ credentials_not_allowed: 'settings.localModelEndpoints.urlReason.credentials_not_allowed',
51
+ query_or_fragment_not_allowed:
52
+ 'settings.localModelEndpoints.urlReason.query_or_fragment_not_allowed',
53
+ host_not_loopback: 'settings.localModelEndpoints.urlReason.host_not_loopback',
54
+ host_not_local: 'settings.localModelEndpoints.urlReason.host_not_local',
55
+ } as const satisfies Record<LocalRunnerUrlReason, string>
56
+
57
+ function urlReasonText(reason: LocalRunnerUrlReason): string {
58
+ return t(URL_REASON_KEYS[reason])
59
+ }
60
+
39
61
  // ---- add / edit draft ------------------------------------------------------
40
62
  const provider = ref<LocalRunner>('ollama')
41
63
  const label = ref('')
@@ -45,6 +67,9 @@ const apiKey = ref('')
45
67
  const discovered = ref<string[]>([])
46
68
  const selected = ref<string[]>([])
47
69
  const testError = ref<string | null>(null)
70
+ // The backend's own wording, kept as DETAIL beside a translated refusal rather than being
71
+ // shown as the description (it names env vars an operator, not this user, acts on).
72
+ const testErrorDetail = ref<string | null>(null)
48
73
  const tested = ref(false)
49
74
  const testing = ref(false)
50
75
  const busy = ref(false)
@@ -84,6 +109,7 @@ async function test() {
84
109
  if (!baseUrl.value.trim()) return
85
110
  testing.value = true
86
111
  testError.value = null
112
+ testErrorDetail.value = null
87
113
  try {
88
114
  const result = await store.test({
89
115
  provider: provider.value,
@@ -98,7 +124,12 @@ async function test() {
98
124
  selected.value = keep.length ? keep : [...result.models]
99
125
  testError.value = null
100
126
  } else {
101
- testError.value = result.error ?? t('settings.localModelEndpoints.unreachable')
127
+ // A policy refusal describes itself in the user's language; the backend's English
128
+ // stays as the detail line. A genuine reachability failure has no reason vocabulary.
129
+ testError.value = result.errorReason
130
+ ? urlReasonText(result.errorReason)
131
+ : (result.error ?? t('settings.localModelEndpoints.unreachable'))
132
+ testErrorDetail.value = result.errorReason ? (result.error ?? null) : null
102
133
  }
103
134
  } catch (e) {
104
135
  testError.value = e instanceof Error ? e.message : String(e)
@@ -217,6 +248,12 @@ async function remove(p: LocalRunner) {
217
248
  · {{ t('settings.localModelEndpoints.keySet') }}</template
218
249
  >
219
250
  </div>
251
+ <!-- A row whose URL the deployment no longer permits: its models are withheld
252
+ from the picker, so this is the only place that can say why. -->
253
+ <div v-if="e.urlBlockedReason" class="mt-1 text-[11px] text-amber-400">
254
+ {{ t('settings.localModelEndpoints.blocked') }}
255
+ <span class="block text-amber-300/70">{{ urlReasonText(e.urlBlockedReason) }}</span>
256
+ </div>
220
257
  </div>
221
258
  <div class="flex items-center gap-1">
222
259
  <UButton
@@ -300,7 +337,12 @@ async function remove(p: LocalRunner) {
300
337
  >
301
338
  {{ t('settings.localModelEndpoints.testConnection') }}
302
339
  </UButton>
303
- <span v-if="testError" class="text-xs text-rose-400">{{ testError }}</span>
340
+ <span v-if="testError" class="text-xs text-rose-400">
341
+ {{ testError }}
342
+ <span v-if="testErrorDetail" class="block text-[11px] text-rose-300/70">{{
343
+ testErrorDetail
344
+ }}</span>
345
+ </span>
304
346
  <span v-else-if="tested && discovered.length" class="text-xs text-emerald-400">
305
347
  {{
306
348
  t(
@@ -31,6 +31,12 @@ export type {
31
31
  PlatformOutcomeTotals,
32
32
  PlatformTrendPoint,
33
33
  PlatformFailureSlice,
34
+ PlatformGateStat,
35
+ PlatformTrendSource,
36
+ PlatformAlertSettings,
37
+ PlatformAlertThresholdOverrides,
38
+ PlatformAlertWindow,
39
+ PlatformFailingRun,
34
40
  ReportWindow,
35
41
  ReportSpendDimension,
36
42
  ReportActivityDimension,
@@ -14,6 +14,7 @@
14
14
  export type {
15
15
  LocalRunner,
16
16
  LocalModelEndpoint,
17
+ LocalRunnerUrlReason,
17
18
  UpsertLocalModelEndpointInput,
18
19
  TestLocalModelEndpointInput,
19
20
  LocalModelEndpointTestResult,
@@ -957,6 +957,15 @@
957
957
  "confirmRemove": {
958
958
  "title": "Diesen Runner entfernen?",
959
959
  "body": "\"{name}\" wird entfernt. Dies kann nicht rückgängig gemacht werden."
960
+ },
961
+ "blocked": "Die URL dieses Runners ist auf dieser Installation nicht erlaubt, daher sind seine Modelle in der Auswahl ausgeblendet.",
962
+ "urlReason": {
963
+ "invalid_url": "Das ist keine gültige URL.",
964
+ "scheme_not_allowed": "Eine Runner-URL muss mit http:// oder https:// beginnen.",
965
+ "credentials_not_allowed": "Eine Runner-URL darf keinen Benutzernamen und kein Passwort enthalten.",
966
+ "query_or_fragment_not_allowed": "Gib nur die Basis-URL ein, ohne \"?\"-Abfrage und ohne \"#\"-Fragment.",
967
+ "host_not_loopback": "Diese Installation erreicht nur Runner auf ihrem eigenen Rechner (localhost). Bitte einen Betreiber, Runner im lokalen Netzwerk zu erlauben.",
968
+ "host_not_local": "Ein Runner muss auf deinem eigenen Rechner oder in deinem lokalen Netzwerk laufen. Öffentliche Hosts sind nicht erlaubt."
960
969
  }
961
970
  },
962
971
  "modelPolicy": {
@@ -1076,6 +1085,48 @@
1076
1085
  "offHint": "Nicht gesetzt: Jedes Board entscheidet selbst. Bestehende Boards behalten ihre Auswahl.",
1077
1086
  "saved": "Richtlinie für Ausführungs-Anmeldedaten gespeichert",
1078
1087
  "saveFailed": "Richtlinie für Ausführungs-Anmeldedaten konnte nicht gespeichert werden"
1088
+ },
1089
+ "platformAlerts": {
1090
+ "title": "Plattform-Zustandswarnungen",
1091
+ "description": "Obergrenzen, gegen die der Laufzustand dieses Kontos geprüft wird. Leere Felder übernehmen die Vorgaben der Installation.",
1092
+ "muteLabel": "Warnungen für dieses Konto stummschalten",
1093
+ "muteHint": "Warnungen lassen sich hier nur abschalten. Ob die Prüfung überhaupt läuft, ist eine Einstellung der Installation.",
1094
+ "windowLabel": "Auswertungsfenster",
1095
+ "windowHint": "Wie weit jede Bedingung zurückblickt. Ein längeres Fenster reagiert langsamer und bleibt ruhiger.",
1096
+ "thresholdsLabel": "Schwellenwerte",
1097
+ "inheritHint": "Ein leeres Feld übernimmt die Vorgabe der Installation. Eine Null ist ein echter Wert, kein leeres Feld.",
1098
+ "inheritPlaceholder": "Übernommen",
1099
+ "reset": "Überschreibungen löschen",
1100
+ "saved": "Warneinstellungen gespeichert",
1101
+ "saveFailed": "Warneinstellungen konnten nicht gespeichert werden",
1102
+ "window": {
1103
+ "inherit": "Vorgabe der Installation",
1104
+ "oneHour": "Letzte Stunde",
1105
+ "oneDay": "Letzte 24 Stunden",
1106
+ "sevenDays": "Letzte 7 Tage"
1107
+ },
1108
+ "thresholds": {
1109
+ "minRuns": "Mindestanzahl Läufe",
1110
+ "maxFailureRate": "Obergrenze Fehlerquote",
1111
+ "maxP99DurationMs": "Obergrenze p99-Dauer (Minuten)",
1112
+ "maxBacklog": "Obergrenze Rückstau",
1113
+ "stalledBuckets": "Leere Intervalle bis zum Stillstand",
1114
+ "minStalledPriorRuns": "Läufe, bevor Stillstand zählt",
1115
+ "maxFailureKindShare": "Anteil der dominanten Ursache",
1116
+ "maxSweepFailures": "Fehlschläge der Prüfung in Folge"
1117
+ },
1118
+ "hints": {
1119
+ "minRuns": "Abgeschlossene Läufe, die das Fenster braucht, bevor die Fehlerwarnungen auslösen können.",
1120
+ "maxFailureRate": "Anteil der Läufe, die fehlschlagen dürfen, von 0 bis 1.",
1121
+ "maxP99DurationMs": "Wie lange die langsamsten Läufe dauern dürfen.",
1122
+ "maxBacklog": "Unfertige Läufe, die gleichzeitig unterwegs sein dürfen.",
1123
+ "stalledBuckets": "Abschließende leere Intervalle im Verlauf, die als Stillstand gelten.",
1124
+ "minStalledPriorRuns": "Läufe, die der frühere Teil des Fensters getragen haben muss. Null warnt bei jeder Stille.",
1125
+ "maxFailureKindShare": "Anteil der Fehler, den eine einzelne Ursache ausmachen darf, über 0 und bis 1.",
1126
+ "maxSweepFailures": "Aufeinanderfolgende fehlgeschlagene Durchläufe einer Hintergrundprüfung."
1127
+ },
1128
+ "notLoaded": "Die aktuellen Einstellungen dieses Kontos konnten nicht geladen werden; ein Speichern würde die übrigen überschreiben. Bitte vor dem Bearbeiten neu laden.",
1129
+ "invalidNumbers": "Diese Obergrenzen sind keine Zahlen"
1079
1130
  }
1080
1131
  },
1081
1132
  "inspector": {
@@ -2064,7 +2115,10 @@
2064
2115
  "budget_paused": "Als gelesen markieren",
2065
2116
  "key_drift": "Veraltete Zugangsdaten entfernen",
2066
2117
  "merge_tag_request": "Aufwand erfassen"
2067
- }
2118
+ },
2119
+ "failingRun": "{kind} · {at}",
2120
+ "failingRunsMore": "{count} weitere nicht angezeigt",
2121
+ "failingRunGone": "Dieser Lauf ist nicht mehr geladen und hat keine Aufgabe zum Öffnen."
2068
2122
  },
2069
2123
  "aiProvidersBanner": {
2070
2124
  "setup": {
@@ -2587,16 +2641,25 @@
2587
2641
  "addFailedTitle": "Wiederkehrende Pipeline konnte nicht hinzugefügt werden",
2588
2642
  "onDemand": "On-Demand (nur manuell)",
2589
2643
  "onDemandHint": "Läuft nur, wenn Sie ihn auslösen, ohne Zeitplan. Da Sie jedes Mal anwesend sind, kann seine Aufgabe ein Modell mit individueller Nutzung verwenden.",
2644
+ "onDemandLockedHint": "Fest aktiviert: Ein per Tracker ausgelöster Zeitplan wird von Webhooks gesteuert und hat daher keinen eigenen Rhythmus.",
2590
2645
  "intake": "Issue-Aufnahme",
2591
2646
  "intakeHint": "Jeder Lauf wählt ein passendes offenes Issue aus dem Tracker und bearbeitet es von Anfang bis Ende.",
2592
2647
  "intakeNoSources": "Verbinden Sie zuerst eine Task-Quelle, um Issues daraus zu ziehen.",
2593
2648
  "intakeGithubRepo": "Repository",
2649
+ "intakeBoardId": "Board-ID",
2650
+ "intakeBoardIdHelp": "Das Board, Projekt oder die Warteschlange, auf die dieser Tracker die Aufnahme eingrenzt. Das Format gibt der Tracker vor.",
2651
+ "trackerTrigger": "Läufe durch Tracker-Webhooks starten",
2652
+ "trackerTriggerHint": "Ein Ticket, das den Filtern unten entspricht, wird als eigene Aufgabe importiert und auf dieser Pipeline ausgeführt. Erfordert einen bedarfsgesteuerten Zeitplan.",
2653
+ "intakeDispatchQueueHint": "Ein passendes Ereignis startet diesen Zeitplan, der das älteste passende Ticket des Boards übernimmt. Geeignet für einen Rückstand, bei dem die Plattform entscheidet, was als Nächstes bearbeitet wird.",
2654
+ "intakeDispatchPerTicketHint": "Ein passendes Ereignis importiert genau dieses Ticket als eigene Aufgabe und führt die Pipeline darauf aus. Geeignet für bereits gesichtete Tickets.",
2594
2655
  "intakeTitleFragment": "Titel enthält",
2595
2656
  "intakeTitleFragmentPlaceholder": "z. B. crash",
2596
2657
  "intakeLabels": "Labels",
2597
2658
  "intakeLabelsPlaceholder": "durch Komma getrennt",
2598
2659
  "intakeIssueType": "Issue-Typ",
2599
- "intakeInProgressLabel": "In-Bearbeitung-Label"
2660
+ "intakeInProgressLabel": "In-Bearbeitung-Label",
2661
+ "refusalPerTicketRequiresOnDemand": "Ein per Tracker ausgelöster Zeitplan muss bedarfsgesteuert sein. Ein Rhythmus-Tick enthält kein Ticket, das übergeben werden könnte.",
2662
+ "refusalPerTicketConflictsWithBugIntake": "Diese Pipeline wählt ihr Issue selbst vom Board und kann daher nicht zusätzlich von einem eingehenden Ticket gesteuert werden. Wählen Sie eine Pipeline ohne Bug-Intake-Schritt."
2600
2663
  },
2601
2664
  "failure": {
2602
2665
  "containerFailedToStart": "Container konnte nicht gestartet werden",
@@ -3235,7 +3298,9 @@
3235
3298
  "window": {
3236
3299
  "oneHour": "Letzte Stunde",
3237
3300
  "oneDay": "Letzte 24 Stunden",
3238
- "sevenDays": "Letzte 7 Tage"
3301
+ "sevenDays": "Letzte 7 Tage",
3302
+ "thirtyDays": "Letzte 30 Tage",
3303
+ "ninetyDays": "Letzte 90 Tage"
3239
3304
  },
3240
3305
  "outcomes": {
3241
3306
  "title": "Lauf-Ergebnisse",
@@ -3285,6 +3350,22 @@
3285
3350
  },
3286
3351
  "live": {
3287
3352
  "title": "Jetzt aktiv"
3353
+ },
3354
+ "rollup": {
3355
+ "none": "Die tägliche Aggregation hat noch nichts erzeugt. Dieses Zeitfenster ist also mangels Daten leer, nicht mangels Läufen.",
3356
+ "stale": "Die tägliche Aggregation reicht nur bis {date}. Alles danach sind fehlende Daten, keine ruhige Zeit.",
3357
+ "current": "Aus der täglichen Aggregation, vollständig bis {date}."
3358
+ },
3359
+ "gates": {
3360
+ "title": "Gate-Versuche",
3361
+ "empty": "In diesem Zeitfenster wurde kein Gate abgeschlossen.",
3362
+ "gate": "Gate",
3363
+ "settled": "Abgeschlossen",
3364
+ "cleanPasses": "Ohne Fixer bestanden",
3365
+ "attempts": "Fixer-Versuche",
3366
+ "helperFailures": "Fixer-Fehlschläge",
3367
+ "exhausted": "An Menschen übergeben",
3368
+ "hint": "Ohne Fixer bestanden heißt: die Vorprüfung war zufrieden und es wurde gar kein Fixer gestartet. Fixer-Fehlschläge sind Versuche, deren eigener Job abgestürzt ist, im Unterschied zu Versuchen, die liefen und die Prüfung rot ließen."
3288
3369
  }
3289
3370
  },
3290
3371
  "reports": {
@@ -362,16 +362,25 @@
362
362
  "addFailedTitle": "Could not add recurring pipeline",
363
363
  "onDemand": "On-demand (manual only)",
364
364
  "onDemandHint": "Runs only when you trigger it, with no schedule. Because you are present each time, its task may use an individual-usage subscription model.",
365
+ "onDemandLockedHint": "Locked on: a tracker-triggered schedule is driven by webhooks, so it has no cadence of its own.",
365
366
  "intake": "Issue intake",
366
367
  "intakeHint": "Each run picks one matching open issue from the tracker and works it end to end.",
367
368
  "intakeNoSources": "Connect a task source first to pull issues from it.",
368
369
  "intakeGithubRepo": "Repository",
370
+ "intakeBoardId": "Board id",
371
+ "intakeBoardIdHelp": "The board, project or queue this tracker scopes intake to. Its format is defined by the tracker.",
372
+ "trackerTrigger": "Start runs from tracker webhooks",
373
+ "trackerTriggerHint": "A ticket matching the filters below is imported as its own task and run on this pipeline. Needs an on-demand schedule.",
374
+ "intakeDispatchQueueHint": "A matching event starts this schedule, which picks up the oldest matching issue on the board. Use this for a backlog, where the platform decides what is worked next.",
375
+ "intakeDispatchPerTicketHint": "A matching event imports that ticket as its own task and runs the pipeline on it. Use this for tickets someone has already triaged.",
369
376
  "intakeTitleFragment": "Title contains",
370
377
  "intakeTitleFragmentPlaceholder": "e.g. crash",
371
378
  "intakeLabels": "Labels",
372
379
  "intakeLabelsPlaceholder": "comma-separated",
373
380
  "intakeIssueType": "Issue type",
374
- "intakeInProgressLabel": "In-progress label"
381
+ "intakeInProgressLabel": "In-progress label",
382
+ "refusalPerTicketRequiresOnDemand": "A tracker-triggered schedule must be on-demand. A cadence tick carries no ticket to dispatch.",
383
+ "refusalPerTicketConflictsWithBugIntake": "This pipeline picks its own issue from the board, so it cannot also be driven by a pushed ticket. Choose a pipeline without a bug-intake step."
375
384
  },
376
385
  "failure": {
377
386
  "containerFailedToStart": "Container failed to start",
@@ -1637,7 +1646,9 @@
1637
1646
  "window": {
1638
1647
  "oneHour": "Last hour",
1639
1648
  "oneDay": "Last 24 hours",
1640
- "sevenDays": "Last 7 days"
1649
+ "sevenDays": "Last 7 days",
1650
+ "thirtyDays": "Last 30 days",
1651
+ "ninetyDays": "Last 90 days"
1641
1652
  },
1642
1653
  "outcomes": {
1643
1654
  "title": "Run outcomes",
@@ -1687,6 +1698,22 @@
1687
1698
  },
1688
1699
  "live": {
1689
1700
  "title": "Live now"
1701
+ },
1702
+ "rollup": {
1703
+ "none": "The daily rollup has produced nothing yet, so this window is empty for lack of data, not for lack of runs.",
1704
+ "stale": "The daily rollup only reaches {date}. Anything after that is missing data, not idle time.",
1705
+ "current": "From the daily rollup, complete through {date}."
1706
+ },
1707
+ "gates": {
1708
+ "title": "Gate attempts",
1709
+ "empty": "No gate settled in this window.",
1710
+ "gate": "Gate",
1711
+ "settled": "Settled",
1712
+ "cleanPasses": "Clean passes",
1713
+ "attempts": "Fixer attempts",
1714
+ "helperFailures": "Fixer failures",
1715
+ "exhausted": "Handed to a human",
1716
+ "hint": "A clean pass is a gate the precheck satisfied without starting a fixer at all. Fixer failures are attempts whose own job crashed, as opposed to attempts that ran and left the check red."
1690
1717
  }
1691
1718
  },
1692
1719
  "reports": {
@@ -2041,7 +2068,10 @@
2041
2068
  "budget_paused": "Mark read",
2042
2069
  "key_drift": "Drop stale credentials",
2043
2070
  "merge_tag_request": "Record effort"
2044
- }
2071
+ },
2072
+ "failingRun": "{kind} · {at}",
2073
+ "failingRunsMore": "{count} more not shown",
2074
+ "failingRunGone": "This run is no longer loaded and has no task to open."
2045
2075
  },
2046
2076
  "aiProvidersBanner": {
2047
2077
  "setup": {
@@ -3333,6 +3363,15 @@
3333
3363
  "confirmRemove": {
3334
3364
  "title": "Remove this runner?",
3335
3365
  "body": "\"{name}\" will be removed. This can't be undone."
3366
+ },
3367
+ "blocked": "This runner's URL is not allowed on this deployment, so its models are hidden from the picker.",
3368
+ "urlReason": {
3369
+ "invalid_url": "That is not a valid URL.",
3370
+ "scheme_not_allowed": "A runner URL must start with http:// or https://.",
3371
+ "credentials_not_allowed": "A runner URL must not contain a username or password.",
3372
+ "query_or_fragment_not_allowed": "Enter the base URL only, with no \"?\" query and no \"#\" fragment.",
3373
+ "host_not_loopback": "This deployment only reaches runners on its own machine (localhost). Ask an operator to allow local-network runners.",
3374
+ "host_not_local": "A runner must be on your own machine or your local network. Public hosts are not allowed."
3336
3375
  }
3337
3376
  },
3338
3377
  "modelPolicy": {
@@ -3452,6 +3491,48 @@
3452
3491
  "offHint": "Not set: each board decides for itself. Existing boards keep whatever they have chosen.",
3453
3492
  "saved": "Run credential policy saved",
3454
3493
  "saveFailed": "Could not save the run credential policy"
3494
+ },
3495
+ "platformAlerts": {
3496
+ "title": "Platform health alerts",
3497
+ "description": "Ceilings this account's own run health is checked against. Blank fields inherit the deployment defaults.",
3498
+ "muteLabel": "Mute alerts for this account",
3499
+ "muteHint": "Alerts can only be switched off here. Whether the sweep runs at all is a deployment setting.",
3500
+ "windowLabel": "Evaluation window",
3501
+ "windowHint": "How far back each condition looks. A longer window reacts more slowly and stays quieter.",
3502
+ "thresholdsLabel": "Thresholds",
3503
+ "inheritHint": "Leave a field empty to inherit the deployment default. A zero is a real setting, not an empty one.",
3504
+ "inheritPlaceholder": "Inherited",
3505
+ "reset": "Clear overrides",
3506
+ "saved": "Alert settings saved",
3507
+ "saveFailed": "Could not save the alert settings",
3508
+ "window": {
3509
+ "inherit": "Deployment default",
3510
+ "oneHour": "Last hour",
3511
+ "oneDay": "Last 24 hours",
3512
+ "sevenDays": "Last 7 days"
3513
+ },
3514
+ "thresholds": {
3515
+ "minRuns": "Minimum runs",
3516
+ "maxFailureRate": "Failure rate ceiling",
3517
+ "maxP99DurationMs": "p99 duration ceiling (minutes)",
3518
+ "maxBacklog": "Backlog ceiling",
3519
+ "stalledBuckets": "Empty buckets before a stall",
3520
+ "minStalledPriorRuns": "Runs before a stall counts",
3521
+ "maxFailureKindShare": "Dominant failure share",
3522
+ "maxSweepFailures": "Sweep failures in a row"
3523
+ },
3524
+ "hints": {
3525
+ "minRuns": "Finished runs the window needs before the failure alerts can fire.",
3526
+ "maxFailureRate": "Share of runs that may fail, from 0 to 1.",
3527
+ "maxP99DurationMs": "How long the slowest runs may take.",
3528
+ "maxBacklog": "Unfinished runs that may be in flight at once.",
3529
+ "stalledBuckets": "Trailing empty trend buckets that count as a stall.",
3530
+ "minStalledPriorRuns": "Runs the earlier part of the window must have carried. Zero alerts on any silence.",
3531
+ "maxFailureKindShare": "Share of failures one cause may account for, above 0 and up to 1.",
3532
+ "maxSweepFailures": "Consecutive failed passes of one background sweep."
3533
+ },
3534
+ "notLoaded": "This account's current settings could not be loaded, so saving would overwrite the rest of them. Reload before editing.",
3535
+ "invalidNumbers": "These ceilings are not numbers"
3455
3536
  }
3456
3537
  },
3457
3538
  "providers": {