@checkstack/healthcheck-frontend 0.19.5 → 0.20.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/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # @checkstack/healthcheck-frontend
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ba07ae2: Quiet down notification spam on flapping systems, auto-open incidents when a check goes critical, and let operators land directly on the broken checks.
8
+
9
+ Notification policy lives **per healthcheck assignment** (one row per `system × configuration`). Different checks on the same system are fully independent — disabling a setting on one check does not affect the others. Defaults preserve existing behaviour for `suppressDeEscalations`; **auto-incident defaults to on** for new and existing assignments.
10
+
11
+ - **`suppressDeEscalations`** (off by default). When on, transitions from a worse state to a better-but-still-failing state (e.g. `unhealthy → degraded`) no longer fire a notification. Escalations and full recoveries to `healthy` are unaffected. Resolved per assignment (the just-ran check is the one driving any aggregate transition).
12
+ - **`autoOpenIncidentOnUnhealthy`** (on by default). Either of two independent triggers can open the auto-incident:
13
+ - **`sustainedUnhealthyTrigger`** (default 30 min) — opens when the check stays continuously unhealthy for the configured duration. Catches real outages.
14
+ - **`flappingTrigger`** (default 3 transitions in 60 min) — opens when the check flips to unhealthy that many times in the window. Catches persistent flapping where each unhealthy phase is too brief for the sustained trigger.
15
+ Each trigger can be individually disabled. One incident per system: triggering checks attach to an existing active auto-incident.
16
+ - **`useNotificationSuppression`** (on by default, only meaningful when auto-open is on). Controls whether the auto-opened incident is created with `suppressNotifications: true` — leaving this off opens the incident but still pings operators on each transition.
17
+ - **`skipDuringMaintenance`** (on by default). No auto-incident is opened while the system has an active maintenance window with suppression. The system is intentionally down and shouldn't trip the on-call.
18
+ - **`autoCloseAfterMinutes`** (default 30). Auto-close cooldown is now per-assignment and snapshotted per-incident at open time — later policy edits don't alter in-flight incidents. Setting `null` ("Never auto-close") leaves the incident for manual resolution.
19
+ - **Require-recovery rule.** After any auto-incident closes (manual or auto), no new auto-incident can open until the check has logged at least one healthy run. Prevents a "operator dismissed but it's still broken" loop.
20
+ - **Auto-close worker** ticks every 60s and resolves auto-opened incidents whose systems have been healthy for their per-row `cooldownMinutes`. Rows with `null` cooldown are skipped entirely. Per-incident: failed close attempts are logged but never abort the sweep.
21
+ - **`incidentResolved` hook subscriber** syncs the auto-incident mapping when an operator manually resolves the incident, so the require-recovery rule sees the close immediately.
22
+ - **Platform-wide defaults.** New admin RPCs `getPlatformNotificationDefaults` / `setPlatformNotificationDefaults` (under the existing `healthcheck.configuration.{read,manage}` access rules) let operators set notification policy once for the whole instance. Per-assignment rows with `notificationPolicy: null` inherit the platform defaults at read time. UI: a "Notification defaults" button in the Assignment IDE opens a modal editor. The per-assignment Notifications panel shows an inheritance banner — "Using platform defaults" (read-only) with an "Override" button, or "Custom override" with a "Use platform defaults" button to revert. The all-or-nothing model keeps the mental model simple: each assignment is either fully inherited or fully overridden.
23
+ - **New service-level RPCs** on the incident plugin (`createAutoIncident`, `resolveAutoIncident`) let other plugins open/close incidents without a user context. Reused by the healthcheck auto-incident flow.
24
+ - **Health-state notification CTA** now deep-links to `?filter=failing` on the system detail page for non-recovery transitions (label changes to "View failing checks"). The system overview gains an `All / Failing / Healthy` segmented filter wired to the same `?filter=…` param.
25
+ - **Notification bell badge** now counts collapse groups instead of raw rows, so the number matches what the user sees in the notifications list. Built on `COUNT(DISTINCT COALESCE(collapse_key, id))` — notifications without a collapse key still each count as one.
26
+ - **`statusFilter` on `getHistory` / `getDetailedHistory`** lets the run-history page and the drawer's Recent Runs panel filter to `All / Healthy / Failing` via shared pills, with the page resetting to the first page on filter change.
27
+ - **Pagination defaults aligned with selector options.** Several pages defaulted to a page size (5 or 20) that wasn't in the dropdown's options (`[10, 25, 50, 100]`), so the page-size `<Select>` rendered empty. The drawer's Recent Runs now defaults to 10; the Run History, History List, and Delivery Logs pages now default to 25.
28
+
29
+ Includes Drizzle migrations adding the `notification_policy` jsonb column to `system_health_checks`, plus two new tables: `health_check_unhealthy_transitions` (for threshold counting) and `health_check_auto_incidents` (for mapping back to incident ids during auto-close).
30
+
31
+ ### Patch Changes
32
+
33
+ - Updated dependencies [ba07ae2]
34
+ - @checkstack/healthcheck-common@1.2.0
35
+ - @checkstack/dashboard-frontend@0.7.6
36
+ - @checkstack/satellite-common@0.5.3
37
+
3
38
  ## 0.19.5
4
39
 
5
40
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-frontend",
3
- "version": "0.19.5",
3
+ "version": "0.20.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.tsx",
@@ -13,18 +13,18 @@
13
13
  "lint:code": "eslint . --max-warnings 0"
14
14
  },
15
15
  "dependencies": {
16
- "@checkstack/anomaly-common": "1.2.1",
17
- "@checkstack/auth-frontend": "0.6.4",
18
- "@checkstack/catalog-common": "2.2.1",
19
- "@checkstack/common": "0.10.0",
20
- "@checkstack/dashboard-frontend": "0.7.4",
21
- "@checkstack/frontend-api": "0.5.1",
22
- "@checkstack/gitops-frontend": "0.4.4",
23
- "@checkstack/healthcheck-common": "1.1.1",
24
- "@checkstack/satellite-common": "0.5.1",
25
- "@checkstack/signal-frontend": "0.1.3",
26
- "@checkstack/tips-frontend": "0.2.4",
27
- "@checkstack/ui": "1.9.0",
16
+ "@checkstack/anomaly-common": "1.2.2",
17
+ "@checkstack/auth-frontend": "0.6.5",
18
+ "@checkstack/catalog-common": "2.2.2",
19
+ "@checkstack/common": "0.11.0",
20
+ "@checkstack/dashboard-frontend": "0.7.5",
21
+ "@checkstack/frontend-api": "0.5.2",
22
+ "@checkstack/gitops-frontend": "0.4.5",
23
+ "@checkstack/healthcheck-common": "1.1.2",
24
+ "@checkstack/satellite-common": "0.5.2",
25
+ "@checkstack/signal-frontend": "0.1.4",
26
+ "@checkstack/tips-frontend": "0.2.5",
27
+ "@checkstack/ui": "1.10.0",
28
28
  "ajv": "^8.18.0",
29
29
  "ajv-formats": "^3.0.1",
30
30
  "date-fns": "^4.1.0",
@@ -36,7 +36,7 @@
36
36
  "zod": "^4.2.1"
37
37
  },
38
38
  "devDependencies": {
39
- "@checkstack/scripts": "0.3.2",
39
+ "@checkstack/scripts": "0.3.3",
40
40
  "@checkstack/tsconfig": "0.0.7",
41
41
  "@types/react": "^18.2.0",
42
42
  "typescript": "^5.0.0"
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+ import { TableRow, TableCell } from "@checkstack/ui";
3
+
4
+ interface EmptyRunsTableRowProps {
5
+ /** Number of columns in the parent table (drives `colSpan`). */
6
+ colSpan: number;
7
+ /** Message to display in the empty cell. Plain strings or rich nodes. */
8
+ children: React.ReactNode;
9
+ }
10
+
11
+ /**
12
+ * Single-row empty state rendered inside a runs table body. Centralises
13
+ * the look so the drawer's Recent Runs and the Run History page can
14
+ * share the same in-table empty state.
15
+ */
16
+ export function EmptyRunsTableRow({ colSpan, children }: EmptyRunsTableRowProps) {
17
+ return (
18
+ <TableRow>
19
+ <TableCell
20
+ colSpan={colSpan}
21
+ className="text-center text-xs text-muted-foreground py-6"
22
+ >
23
+ {children}
24
+ </TableCell>
25
+ </TableRow>
26
+ );
27
+ }
@@ -56,6 +56,12 @@ import { HealthCheckLatencyChart } from "./HealthCheckLatencyChart";
56
56
  import { useHealthCheckData } from "../hooks/useHealthCheckData";
57
57
  import { AggregatedDataBanner } from "./AggregatedDataBanner";
58
58
  import { HealthCheckDiagramSlot } from "../slots";
59
+ import {
60
+ StatusFilterPills,
61
+ STATUS_FILTER_TO_STATUSES,
62
+ type StatusFilter,
63
+ } from "./StatusFilterPills";
64
+ import { EmptyRunsTableRow } from "./EmptyRunsTableRow";
59
65
  import { Heart, Clock, CheckCircle, AlertTriangle } from "lucide-react";
60
66
 
61
67
  import type {
@@ -130,6 +136,8 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
130
136
  );
131
137
  const [isRollingPreset, setIsRollingPreset] = useState(true);
132
138
  const [sourceFilter, setSourceFilter] = useState<string | undefined>();
139
+ const [runsStatusFilter, setRunsStatusFilter] =
140
+ useState<StatusFilter>("all");
133
141
 
134
142
  const activePreset = detectPreset(dateRange);
135
143
  const activePresetLabel = PRESETS.find((p) => p.id === activePreset)?.shortLabel ?? "Custom";
@@ -195,7 +203,7 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
195
203
  );
196
204
 
197
205
  // Pagination for history table
198
- const pagination = usePagination({ defaultLimit: 5 });
206
+ const pagination = usePagination({ defaultLimit: 10 });
199
207
 
200
208
  const { data: historyData, isLoading: historyLoading } =
201
209
  healthCheckClient.getHistory.useQuery({
@@ -205,6 +213,7 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
205
213
  offset: pagination.offset,
206
214
  startDate: dateRange.startDate,
207
215
  sourceFilter,
216
+ statusFilter: STATUS_FILTER_TO_STATUSES[runsStatusFilter],
208
217
  sortOrder: "desc",
209
218
  });
210
219
 
@@ -437,7 +446,7 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
437
446
  </div>
438
447
 
439
448
  {/* Zone 3 — Recent Runs */}
440
- {runs.length > 0 && (
449
+ {(runs.length > 0 || runsStatusFilter !== "all") && (
441
450
  <div className="space-y-3">
442
451
  <div className="flex items-center gap-4">
443
452
  <div className="flex-1 h-px bg-border" />
@@ -450,6 +459,16 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
450
459
  <div className="flex-1 h-px bg-border" />
451
460
  </div>
452
461
 
462
+ <div className="flex justify-end">
463
+ <StatusFilterPills
464
+ value={runsStatusFilter}
465
+ onChange={(next) => {
466
+ setRunsStatusFilter(next);
467
+ pagination.setPage(1);
468
+ }}
469
+ />
470
+ </div>
471
+
453
472
  <div className="rounded-md border">
454
473
  <Table>
455
474
  <TableHeader>
@@ -460,6 +479,13 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
460
479
  </TableRow>
461
480
  </TableHeader>
462
481
  <TableBody>
482
+ {runs.length === 0 && !historyLoading && (
483
+ <EmptyRunsTableRow colSpan={3}>
484
+ No runs match the{" "}
485
+ <span className="font-medium">{runsStatusFilter}</span>{" "}
486
+ filter.
487
+ </EmptyRunsTableRow>
488
+ )}
463
489
  {runs.map((run) => (
464
490
  <TableRow
465
491
  key={run.id}
@@ -14,6 +14,7 @@ import { ExternalLink, Loader2, Satellite, Server } from "lucide-react";
14
14
  import { useNavigate } from "react-router-dom";
15
15
  import { healthcheckRoutes } from "@checkstack/healthcheck-common";
16
16
  import { resolveRoute } from "@checkstack/common";
17
+ import { EmptyRunsTableRow } from "./EmptyRunsTableRow";
17
18
 
18
19
  export interface HealthCheckRunDetailed {
19
20
  id: string;
@@ -64,10 +65,6 @@ export const HealthCheckRunsTable: React.FC<HealthCheckRunsTableProps> = ({
64
65
  prevRunsRef.current = runs;
65
66
  }
66
67
 
67
- if (!loading && runs.length === 0 && prevRunsRef.current.length === 0) {
68
- return <p className="text-muted-foreground text-sm">{emptyMessage}</p>;
69
- }
70
-
71
68
  const handleRowClick = (run: HealthCheckRunDetailed) => {
72
69
  navigate(
73
70
  resolveRoute(healthcheckRoutes.routes.historyRun, {
@@ -78,6 +75,11 @@ export const HealthCheckRunsTable: React.FC<HealthCheckRunsTableProps> = ({
78
75
  );
79
76
  };
80
77
 
78
+ // 3 base columns (Status, Timestamp, Source) + 3 extras when
79
+ // showFilterColumns is on (System ID, Configuration ID, link icon).
80
+ const columnCount = showFilterColumns ? 6 : 3;
81
+ const showEmptyRow = !loading && displayRuns.length === 0;
82
+
81
83
  return (
82
84
  <>
83
85
  <div className="rounded-md border">
@@ -102,6 +104,11 @@ export const HealthCheckRunsTable: React.FC<HealthCheckRunsTableProps> = ({
102
104
  </TableRow>
103
105
  </TableHeader>
104
106
  <TableBody>
107
+ {showEmptyRow && (
108
+ <EmptyRunsTableRow colSpan={columnCount}>
109
+ {emptyMessage}
110
+ </EmptyRunsTableRow>
111
+ )}
105
112
  {displayRuns.map((run) => (
106
113
  <TableRow
107
114
  key={run.id}
@@ -1,4 +1,5 @@
1
1
  import React, { useState } from "react";
2
+ import { useSearchParams } from "react-router-dom";
2
3
  import {
3
4
  usePluginClient,
4
5
  type SlotContext,
@@ -12,6 +13,7 @@ import {
12
13
  CardContent,
13
14
  CardHeader,
14
15
  CardTitle,
16
+ Button,
15
17
  } from "@checkstack/ui";
16
18
  import { Heart } from "lucide-react";
17
19
  import { HealthCheckSparkline } from "./HealthCheckSparkline";
@@ -22,6 +24,24 @@ import type {
22
24
  HealthCheckStatus,
23
25
  } from "@checkstack/healthcheck-common";
24
26
 
27
+ type HealthFilter = "all" | "failing" | "healthy";
28
+
29
+ const FILTERS: { value: HealthFilter; label: string }[] = [
30
+ { value: "all", label: "All" },
31
+ { value: "failing", label: "Failing" },
32
+ { value: "healthy", label: "Healthy" },
33
+ ];
34
+
35
+ function parseFilter(raw: string | null): HealthFilter {
36
+ return raw === "failing" || raw === "healthy" ? raw : "all";
37
+ }
38
+
39
+ function matchesFilter(state: HealthCheckStatus, filter: HealthFilter) {
40
+ if (filter === "all") return true;
41
+ if (filter === "healthy") return state === "healthy";
42
+ return state !== "healthy";
43
+ }
44
+
25
45
  type SlotProps = SlotContext<typeof SystemDetailsSlot>;
26
46
 
27
47
  /**
@@ -54,6 +74,8 @@ interface HealthCheckOverviewItem {
54
74
  export function HealthCheckSystemOverview(props: SlotProps) {
55
75
  const systemId = props.system.id;
56
76
  const healthCheckClient = usePluginClient(HealthCheckApi);
77
+ const [searchParams, setSearchParams] = useSearchParams();
78
+ const filter = parseFilter(searchParams.get("filter"));
57
79
 
58
80
  const [selectedCheck, setSelectedCheck] = useState<
59
81
  HealthCheckOverviewItem | undefined
@@ -82,6 +104,21 @@ export function HealthCheckSystemOverview(props: SlotProps) {
82
104
  }));
83
105
  }, [overviewData]);
84
106
 
107
+ const visible = React.useMemo(
108
+ () => overview.filter((item) => matchesFilter(item.state, filter)),
109
+ [overview, filter],
110
+ );
111
+
112
+ const setFilter = (next: HealthFilter) => {
113
+ const params = new URLSearchParams(searchParams);
114
+ if (next === "all") {
115
+ params.delete("filter");
116
+ } else {
117
+ params.set("filter", next);
118
+ }
119
+ setSearchParams(params, { replace: true });
120
+ };
121
+
85
122
  if (initialLoading) {
86
123
  return <LoadingSpinner />;
87
124
  }
@@ -94,16 +131,46 @@ export function HealthCheckSystemOverview(props: SlotProps) {
94
131
  <>
95
132
  <Card>
96
133
  <CardHeader className="pb-3">
97
- <div className="flex items-center gap-2">
98
- <Heart className="h-4 w-4 text-muted-foreground" />
99
- <CardTitle className="text-base font-semibold">
100
- Health Checks
101
- </CardTitle>
134
+ <div className="flex items-center justify-between gap-2">
135
+ <div className="flex items-center gap-2">
136
+ <Heart className="h-4 w-4 text-muted-foreground" />
137
+ <CardTitle className="text-base font-semibold">
138
+ Health Checks
139
+ </CardTitle>
140
+ </div>
141
+ <div
142
+ className="flex items-center gap-1 rounded-md border bg-muted/30 p-0.5"
143
+ role="tablist"
144
+ aria-label="Filter health checks"
145
+ >
146
+ {FILTERS.map((f) => {
147
+ const active = filter === f.value;
148
+ return (
149
+ <Button
150
+ key={f.value}
151
+ size="sm"
152
+ variant={active ? "secondary" : "ghost"}
153
+ className="h-7 px-2 text-xs"
154
+ onClick={() => setFilter(f.value)}
155
+ role="tab"
156
+ aria-selected={active}
157
+ >
158
+ {f.label}
159
+ </Button>
160
+ );
161
+ })}
162
+ </div>
102
163
  </div>
103
164
  </CardHeader>
104
165
  <CardContent className="p-0">
105
- <div className="divide-y divide-border">
106
- {overview.map((item) => (
166
+ {visible.length === 0 ? (
167
+ <div className="px-4 py-6 text-center text-xs text-muted-foreground">
168
+ No health checks match the{" "}
169
+ <span className="font-medium">{filter}</span> filter.
170
+ </div>
171
+ ) : (
172
+ <div className="divide-y divide-border">
173
+ {visible.map((item) => (
107
174
  <button
108
175
  key={item.configurationId}
109
176
  className="w-full px-4 py-3 text-left hover:bg-muted/50 transition-colors flex items-center gap-3"
@@ -133,8 +200,9 @@ export function HealthCheckSystemOverview(props: SlotProps) {
133
200
  {formatCompactTime(item.lastRunAt)}
134
201
  </span>
135
202
  </button>
136
- ))}
137
- </div>
203
+ ))}
204
+ </div>
205
+ )}
138
206
  </CardContent>
139
207
  </Card>
140
208
 
@@ -0,0 +1,79 @@
1
+ import React from "react";
2
+ import { CheckCircle, AlertTriangle } from "lucide-react";
3
+ import type { HealthCheckStatus } from "@checkstack/healthcheck-common";
4
+
5
+ /**
6
+ * Coarse status filter buckets for run-history surfaces. "Failing"
7
+ * collapses degraded and unhealthy because operators investigating a
8
+ * problem usually care about "anything not green" rather than the
9
+ * degraded/unhealthy distinction.
10
+ */
11
+ export type StatusFilter = "all" | "healthy" | "failing";
12
+
13
+ export const STATUS_FILTER_TO_STATUSES: Record<
14
+ StatusFilter,
15
+ HealthCheckStatus[] | undefined
16
+ > = {
17
+ all: undefined,
18
+ healthy: ["healthy"],
19
+ failing: ["degraded", "unhealthy"],
20
+ };
21
+
22
+ interface StatusFilterPillsProps {
23
+ value: StatusFilter;
24
+ onChange: (next: StatusFilter) => void;
25
+ }
26
+
27
+ const OPTIONS: {
28
+ value: StatusFilter;
29
+ label: string;
30
+ icon?: React.ComponentType<{ className?: string }>;
31
+ activeClass: string;
32
+ }[] = [
33
+ {
34
+ value: "all",
35
+ label: "All",
36
+ activeClass: "bg-primary text-primary-foreground",
37
+ },
38
+ {
39
+ value: "healthy",
40
+ label: "Healthy",
41
+ icon: CheckCircle,
42
+ activeClass: "bg-success text-white",
43
+ },
44
+ {
45
+ value: "failing",
46
+ label: "Failing",
47
+ icon: AlertTriangle,
48
+ activeClass: "bg-destructive text-destructive-foreground",
49
+ },
50
+ ];
51
+
52
+ export function StatusFilterPills({ value, onChange }: StatusFilterPillsProps) {
53
+ return (
54
+ <div className="flex items-center gap-2">
55
+ <span className="text-sm text-muted-foreground">Status:</span>
56
+ <div className="flex items-center gap-1">
57
+ {OPTIONS.map((opt) => {
58
+ const active = value === opt.value;
59
+ const Icon = opt.icon;
60
+ return (
61
+ <button
62
+ key={opt.value}
63
+ type="button"
64
+ onClick={() => onChange(opt.value)}
65
+ className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full transition-colors ${
66
+ active
67
+ ? opt.activeClass
68
+ : "bg-muted text-muted-foreground hover:bg-muted/80"
69
+ }`}
70
+ >
71
+ {Icon && <Icon className="h-3 w-3" />}
72
+ {opt.label}
73
+ </button>
74
+ );
75
+ })}
76
+ </div>
77
+ </div>
78
+ );
79
+ }
@@ -1,5 +1,13 @@
1
1
  import React from "react";
2
- import { Settings, Gauge, Database, Radio, Plus, Check } from "lucide-react";
2
+ import {
3
+ Settings,
4
+ Gauge,
5
+ Database,
6
+ Radio,
7
+ Plus,
8
+ Check,
9
+ Bell,
10
+ } from "lucide-react";
3
11
  import { IDETreeNode, IDETreeSection } from "@checkstack/ui";
4
12
  import { ExtensionSlot } from "@checkstack/frontend-api";
5
13
  import { AssignmentIDENodeSlot } from "../../slots";
@@ -97,6 +105,18 @@ export const AssignmentTree: React.FC<AssignmentTreeProps> = ({
97
105
  indent
98
106
  badge={assoc.satelliteCount > 0 ? `${assoc.satelliteCount}` : undefined}
99
107
  />
108
+ <IDETreeNode
109
+ nodeId={`notifications:${assoc.configurationId}`}
110
+ label="Notifications"
111
+ icon={Bell}
112
+ selected={
113
+ selectedNode === `notifications:${assoc.configurationId}`
114
+ }
115
+ onClick={() =>
116
+ onSelectNode(`notifications:${assoc.configurationId}`)
117
+ }
118
+ indent
119
+ />
100
120
  <ExtensionSlot
101
121
  slot={AssignmentIDENodeSlot}
102
122
  context={{
@@ -0,0 +1,385 @@
1
+ import React from "react";
2
+ import type { NotificationPolicy } from "@checkstack/healthcheck-common";
3
+ import { Button, Input, Label, Toggle, Tooltip } from "@checkstack/ui";
4
+
5
+ interface NotificationsPanelProps {
6
+ policy: NotificationPolicy;
7
+ onChange: (policy: NotificationPolicy) => void;
8
+ onSave: () => void;
9
+ saving: boolean;
10
+ isLocked?: boolean;
11
+ /**
12
+ * Inheritance state — only meaningful when the panel is rendered
13
+ * for an assignment (not the platform-defaults editor). When
14
+ * `false`, the panel shows a banner explaining that values are
15
+ * inherited and offers an "Override" action.
16
+ */
17
+ isOverridden?: boolean;
18
+ /** Switch to "use platform defaults" mode for this assignment. */
19
+ onUseDefaults?: () => void;
20
+ /** Start overriding (clones the current inherited values). */
21
+ onOverride?: () => void;
22
+ }
23
+
24
+ /**
25
+ * Panel for configuring per-association notification behaviour. All
26
+ * settings are scoped to a single (system, configuration) assignment
27
+ * — different checks on the same system are independent.
28
+ */
29
+ export const NotificationsPanel: React.FC<NotificationsPanelProps> = ({
30
+ policy,
31
+ onChange,
32
+ onSave,
33
+ saving,
34
+ isLocked,
35
+ isOverridden,
36
+ onUseDefaults,
37
+ onOverride,
38
+ }) => {
39
+ // Inheritance UI only applies when the panel is hosted by an
40
+ // assignment — the platform-defaults editor passes neither
41
+ // `isOverridden` nor the callbacks.
42
+ const inheritanceMode =
43
+ typeof isOverridden === "boolean" && (onUseDefaults || onOverride);
44
+ // The Override / Use-defaults buttons in the banner must stay
45
+ // clickable even while the form is locked; only saving / GitOps
46
+ // lock should disable them.
47
+ const actionsDisabled = saving || isLocked;
48
+ // While the assignment inherits, the form values themselves are
49
+ // visible but read-only — operators must click Override to edit
50
+ // them.
51
+ const disabled =
52
+ actionsDisabled || (inheritanceMode ? !isOverridden : false);
53
+ return (
54
+ <div className="p-6 space-y-4">
55
+ <div>
56
+ <h3 className="text-sm font-semibold">Notifications</h3>
57
+ <p className="text-xs text-muted-foreground mt-1">
58
+ Control which health state transitions notify subscribers for this
59
+ check, and when an incident is auto-opened for the system.
60
+ </p>
61
+ </div>
62
+
63
+ {inheritanceMode && (
64
+ <div
65
+ className={`p-3 rounded-lg border flex items-center justify-between gap-3 ${
66
+ isOverridden
67
+ ? "bg-warning/5 border-warning/30"
68
+ : "bg-muted/40 border-border"
69
+ }`}
70
+ >
71
+ <div className="text-xs">
72
+ {isOverridden ? (
73
+ <>
74
+ <span className="font-medium text-warning">
75
+ Custom override
76
+ </span>{" "}
77
+ — this check ignores the platform defaults.
78
+ </>
79
+ ) : (
80
+ <>
81
+ <span className="font-medium">Using platform defaults</span>{" "}
82
+ — fields below are read-only. Click Override to customise
83
+ them for this check only.
84
+ </>
85
+ )}
86
+ </div>
87
+ {isOverridden && onUseDefaults && (
88
+ <Button
89
+ size="sm"
90
+ variant="outline"
91
+ onClick={onUseDefaults}
92
+ disabled={actionsDisabled}
93
+ >
94
+ Use platform defaults
95
+ </Button>
96
+ )}
97
+ {!isOverridden && onOverride && (
98
+ <Button
99
+ size="sm"
100
+ variant="outline"
101
+ onClick={onOverride}
102
+ disabled={actionsDisabled}
103
+ >
104
+ Override
105
+ </Button>
106
+ )}
107
+ </div>
108
+ )}
109
+
110
+ {/* Suppress de-escalations */}
111
+ <div className="p-4 bg-muted/50 rounded-lg border space-y-3">
112
+ <div className="flex items-start justify-between gap-4">
113
+ <div className="flex-1 min-w-0">
114
+ <div className="flex items-center gap-2">
115
+ <Label className="text-sm font-medium">
116
+ Suppress de-escalation notifications
117
+ </Label>
118
+ <Tooltip content="When on, transitions from a worse state to a better one (but not back to healthy) are skipped. Recoveries and escalations still notify." />
119
+ </div>
120
+ <p className="text-xs text-muted-foreground mt-1">
121
+ Skips intermediate notifications like{" "}
122
+ <code className="text-[11px]">unhealthy &rarr; degraded</code>.
123
+ You still get notified when the system gets worse or fully
124
+ recovers.
125
+ </p>
126
+ </div>
127
+ <Toggle
128
+ checked={policy.suppressDeEscalations}
129
+ onCheckedChange={(checked: boolean) =>
130
+ onChange({ ...policy, suppressDeEscalations: checked })
131
+ }
132
+ disabled={disabled}
133
+ aria-label="Suppress de-escalation notifications"
134
+ />
135
+ </div>
136
+ </div>
137
+
138
+ {/* Auto-open incident */}
139
+ <div className="p-4 bg-muted/50 rounded-lg border space-y-4">
140
+ <div className="flex items-start justify-between gap-4">
141
+ <div className="flex-1 min-w-0">
142
+ <div className="flex items-center gap-2">
143
+ <Label className="text-sm font-medium">
144
+ Auto-open incident when this check is critical
145
+ </Label>
146
+ <Tooltip content="When either trigger below fires, an incident is auto-opened on the system. Different checks on the same system are independent — disabling here only affects this check." />
147
+ </div>
148
+ <p className="text-xs text-muted-foreground mt-1">
149
+ One incident per outage instead of one ping per state change.
150
+ Especially useful for Jira / Slack / email — the incident's
151
+ suppression silences downstream channels for the lifetime of
152
+ the incident.
153
+ </p>
154
+ </div>
155
+ <Toggle
156
+ checked={policy.autoOpenIncidentOnUnhealthy}
157
+ onCheckedChange={(checked: boolean) =>
158
+ onChange({ ...policy, autoOpenIncidentOnUnhealthy: checked })
159
+ }
160
+ disabled={disabled}
161
+ aria-label="Auto-open incident when this check is critical"
162
+ />
163
+ </div>
164
+
165
+ {policy.autoOpenIncidentOnUnhealthy && (
166
+ <div className="pl-4 border-l-2 border-border space-y-4">
167
+ {/* Suppress further notifications */}
168
+ <div className="flex items-start justify-between gap-4">
169
+ <div className="flex-1 min-w-0">
170
+ <Label className="text-sm">
171
+ Suppress further notifications while open
172
+ </Label>
173
+ <p className="text-xs text-muted-foreground mt-1">
174
+ Email, Jira, Slack all silenced for this system until the
175
+ incident is resolved.
176
+ </p>
177
+ </div>
178
+ <Toggle
179
+ checked={policy.useNotificationSuppression}
180
+ onCheckedChange={(checked: boolean) =>
181
+ onChange({
182
+ ...policy,
183
+ useNotificationSuppression: checked,
184
+ })
185
+ }
186
+ disabled={disabled}
187
+ aria-label="Suppress further notifications while open"
188
+ />
189
+ </div>
190
+
191
+ {/* Skip during maintenance */}
192
+ <div className="flex items-start justify-between gap-4">
193
+ <div className="flex-1 min-w-0">
194
+ <Label className="text-sm">
195
+ Skip during active maintenance
196
+ </Label>
197
+ <p className="text-xs text-muted-foreground mt-1">
198
+ No auto-incident is opened while the system has an active
199
+ maintenance window with suppression.
200
+ </p>
201
+ </div>
202
+ <Toggle
203
+ checked={policy.skipDuringMaintenance}
204
+ onCheckedChange={(checked: boolean) =>
205
+ onChange({ ...policy, skipDuringMaintenance: checked })
206
+ }
207
+ disabled={disabled}
208
+ aria-label="Skip auto-incident during active maintenance"
209
+ />
210
+ </div>
211
+
212
+ {/* Sustained-duration trigger */}
213
+ <div className="space-y-2 pt-2 border-t border-border">
214
+ <div className="flex items-center justify-between gap-4">
215
+ <div className="flex items-center gap-2">
216
+ <Label className="text-sm">
217
+ Open when unhealthy continuously
218
+ </Label>
219
+ <Tooltip content="Catches real outages: the check has stayed unhealthy for at least this long without recovering." />
220
+ </div>
221
+ <Toggle
222
+ checked={policy.sustainedUnhealthyTrigger.enabled}
223
+ onCheckedChange={(checked: boolean) =>
224
+ onChange({
225
+ ...policy,
226
+ sustainedUnhealthyTrigger: {
227
+ ...policy.sustainedUnhealthyTrigger,
228
+ enabled: checked,
229
+ },
230
+ })
231
+ }
232
+ disabled={disabled}
233
+ aria-label="Enable sustained-unhealthy trigger"
234
+ />
235
+ </div>
236
+ {policy.sustainedUnhealthyTrigger.enabled && (
237
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
238
+ <span>Open after</span>
239
+ <Input
240
+ type="number"
241
+ min={1}
242
+ className="h-8 w-16 text-center"
243
+ value={policy.sustainedUnhealthyTrigger.durationMinutes}
244
+ onChange={(e) =>
245
+ onChange({
246
+ ...policy,
247
+ sustainedUnhealthyTrigger: {
248
+ ...policy.sustainedUnhealthyTrigger,
249
+ durationMinutes:
250
+ Number.parseInt(e.target.value, 10) || 1,
251
+ },
252
+ })
253
+ }
254
+ disabled={disabled}
255
+ />
256
+ <span>minutes of continuous unhealthy state</span>
257
+ </div>
258
+ )}
259
+ </div>
260
+
261
+ {/* Flapping trigger */}
262
+ <div className="space-y-2 pt-2 border-t border-border">
263
+ <div className="flex items-center justify-between gap-4">
264
+ <div className="flex items-center gap-2">
265
+ <Label className="text-sm">Open on flapping</Label>
266
+ <Tooltip content="Catches checks that flip in and out of unhealthy too quickly for the sustained trigger to fire." />
267
+ </div>
268
+ <Toggle
269
+ checked={policy.flappingTrigger.enabled}
270
+ onCheckedChange={(checked: boolean) =>
271
+ onChange({
272
+ ...policy,
273
+ flappingTrigger: {
274
+ ...policy.flappingTrigger,
275
+ enabled: checked,
276
+ },
277
+ })
278
+ }
279
+ disabled={disabled}
280
+ aria-label="Enable flapping trigger"
281
+ />
282
+ </div>
283
+ {policy.flappingTrigger.enabled && (
284
+ <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
285
+ <span>Open after</span>
286
+ <Input
287
+ type="number"
288
+ min={1}
289
+ className="h-8 w-16 text-center"
290
+ value={policy.flappingTrigger.transitions}
291
+ onChange={(e) =>
292
+ onChange({
293
+ ...policy,
294
+ flappingTrigger: {
295
+ ...policy.flappingTrigger,
296
+ transitions:
297
+ Number.parseInt(e.target.value, 10) || 1,
298
+ },
299
+ })
300
+ }
301
+ disabled={disabled}
302
+ />
303
+ <span>transitions to unhealthy within</span>
304
+ <Input
305
+ type="number"
306
+ min={1}
307
+ className="h-8 w-16 text-center"
308
+ value={policy.flappingTrigger.windowMinutes}
309
+ onChange={(e) =>
310
+ onChange({
311
+ ...policy,
312
+ flappingTrigger: {
313
+ ...policy.flappingTrigger,
314
+ windowMinutes:
315
+ Number.parseInt(e.target.value, 10) || 1,
316
+ },
317
+ })
318
+ }
319
+ disabled={disabled}
320
+ />
321
+ <span>minutes</span>
322
+ </div>
323
+ )}
324
+ </div>
325
+
326
+ {/* Auto-close cooldown */}
327
+ <div className="space-y-2 pt-2 border-t border-border">
328
+ <div className="flex items-center gap-2">
329
+ <Label className="text-sm">Auto-close cooldown</Label>
330
+ <Tooltip content="Resolve the auto-incident once the system has stayed healthy for this long. Snapshotted per-incident at open time — later policy edits don't change in-flight incidents." />
331
+ </div>
332
+ <div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
333
+ <label className="inline-flex items-center gap-2 cursor-pointer">
334
+ <input
335
+ type="checkbox"
336
+ checked={policy.autoCloseAfterMinutes === null}
337
+ onChange={(e) =>
338
+ onChange({
339
+ ...policy,
340
+ autoCloseAfterMinutes: e.target.checked ? null : 30,
341
+ })
342
+ }
343
+ disabled={disabled}
344
+ />
345
+ <span>Never auto-close (manual resolve only)</span>
346
+ </label>
347
+ {policy.autoCloseAfterMinutes !== null && (
348
+ <div className="flex items-center gap-2">
349
+ <span>After</span>
350
+ <Input
351
+ type="number"
352
+ min={1}
353
+ className="h-8 w-16 text-center"
354
+ value={policy.autoCloseAfterMinutes}
355
+ onChange={(e) =>
356
+ onChange({
357
+ ...policy,
358
+ autoCloseAfterMinutes:
359
+ Number.parseInt(e.target.value, 10) || 1,
360
+ })
361
+ }
362
+ disabled={disabled}
363
+ />
364
+ <span>minutes of sustained healthy</span>
365
+ </div>
366
+ )}
367
+ </div>
368
+ </div>
369
+ </div>
370
+ )}
371
+ </div>
372
+
373
+ {/* Save button hides when the assignment is inheriting — there
374
+ is nothing to save. The Override button drives the transition
375
+ into edit mode. */}
376
+ {(!inheritanceMode || isOverridden) && (
377
+ <div className="flex justify-end pt-2 border-t">
378
+ <Button size="sm" onClick={onSave} disabled={disabled}>
379
+ {saving ? "Saving..." : "Save Notifications"}
380
+ </Button>
381
+ </div>
382
+ )}
383
+ </div>
384
+ );
385
+ };
@@ -0,0 +1,90 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import {
3
+ DEFAULT_NOTIFICATION_POLICY,
4
+ HealthCheckApi,
5
+ type NotificationPolicy,
6
+ } from "@checkstack/healthcheck-common";
7
+ import { usePluginClient } from "@checkstack/frontend-api";
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ DialogDescription,
14
+ LoadingSpinner,
15
+ useToast,
16
+ } from "@checkstack/ui";
17
+ import { extractErrorMessage } from "@checkstack/common";
18
+ import { NotificationsPanel } from "./NotificationsPanel";
19
+
20
+ interface PlatformDefaultsDialogProps {
21
+ open: boolean;
22
+ onOpenChange: (open: boolean) => void;
23
+ }
24
+
25
+ /**
26
+ * Modal editor for platform-wide notification defaults. Reuses the
27
+ * per-assignment NotificationsPanel because the shape is identical —
28
+ * the only difference is where it reads from and writes to.
29
+ *
30
+ * Once saved, every assignment with `notificationPolicy = null`
31
+ * (the "Use platform defaults" state) picks up the new values on the
32
+ * next read. In-flight auto-incidents are unaffected — their cooldown
33
+ * was snapshotted at open time.
34
+ */
35
+ export const PlatformDefaultsDialog: React.FC<PlatformDefaultsDialogProps> = ({
36
+ open,
37
+ onOpenChange,
38
+ }) => {
39
+ const client = usePluginClient(HealthCheckApi);
40
+ const toast = useToast();
41
+
42
+ const { data, isLoading, refetch } =
43
+ client.getPlatformNotificationDefaults.useQuery(undefined, {
44
+ enabled: open,
45
+ });
46
+
47
+ const setMutation = client.setPlatformNotificationDefaults.useMutation({
48
+ onSuccess: () => {
49
+ toast.success("Platform notification defaults saved");
50
+ void refetch();
51
+ onOpenChange(false);
52
+ },
53
+ onError: (error) =>
54
+ toast.error(extractErrorMessage(error, "Failed to save defaults")),
55
+ });
56
+
57
+ const [draft, setDraft] = useState<NotificationPolicy>(
58
+ DEFAULT_NOTIFICATION_POLICY,
59
+ );
60
+
61
+ useEffect(() => {
62
+ if (data) setDraft(data);
63
+ }, [data]);
64
+
65
+ return (
66
+ <Dialog open={open} onOpenChange={onOpenChange}>
67
+ <DialogContent size="lg">
68
+ <DialogHeader>
69
+ <DialogTitle>Platform notification defaults</DialogTitle>
70
+ <DialogDescription>
71
+ Edits here apply to every health-check assignment that is set
72
+ to &quot;Use platform defaults&quot;. Assignments with a custom
73
+ override are unaffected.
74
+ </DialogDescription>
75
+ </DialogHeader>
76
+
77
+ {isLoading ? (
78
+ <LoadingSpinner />
79
+ ) : (
80
+ <NotificationsPanel
81
+ policy={draft}
82
+ onChange={setDraft}
83
+ onSave={() => setMutation.mutate(draft)}
84
+ saving={setMutation.isPending}
85
+ />
86
+ )}
87
+ </DialogContent>
88
+ </Dialog>
89
+ );
90
+ };
@@ -6,10 +6,14 @@ import { SatelliteApi } from "@checkstack/satellite-common";
6
6
  import {
7
7
  DEFAULT_STATE_THRESHOLDS,
8
8
  DEFAULT_RETENTION_CONFIG,
9
+ DEFAULT_NOTIFICATION_POLICY,
10
+ } from "@checkstack/healthcheck-common";
11
+ import type {
12
+ StateThresholds,
13
+ NotificationPolicy,
9
14
  } from "@checkstack/healthcheck-common";
10
- import type { StateThresholds } from "@checkstack/healthcheck-common";
11
15
  import { PageLayout, IDELayout, useToast, BackLink, Button } from "@checkstack/ui";
12
- import { Settings, Plus } from "lucide-react";
16
+ import { Settings, Plus, Bell } from "lucide-react";
13
17
  import { extractErrorMessage, resolveRoute } from "@checkstack/common";
14
18
  import { catalogRoutes } from "@checkstack/catalog-common";
15
19
  import { healthcheckRoutes } from "@checkstack/healthcheck-common";
@@ -25,6 +29,8 @@ import {
25
29
  type RetentionData,
26
30
  } from "../components/assignments/RetentionPanel";
27
31
  import { ExecutionPanel } from "../components/assignments/ExecutionPanel";
32
+ import { NotificationsPanel } from "../components/assignments/NotificationsPanel";
33
+ import { PlatformDefaultsDialog } from "../components/assignments/PlatformDefaultsDialog";
28
34
  import { AssignmentIDEPanelSlot } from "../slots";
29
35
 
30
36
  // =============================================================================
@@ -32,12 +38,22 @@ import { AssignmentIDEPanelSlot } from "../slots";
32
38
  // =============================================================================
33
39
 
34
40
  function parseNodeId(nodeId: AssignmentNodeId): {
35
- panel: "general" | "thresholds" | "retention" | "execution";
41
+ panel:
42
+ | "general"
43
+ | "thresholds"
44
+ | "retention"
45
+ | "execution"
46
+ | "notifications";
36
47
  configId: string;
37
48
  } {
38
49
  const [panel, ...rest] = nodeId.split(":") as [string, ...string[]];
39
50
  return {
40
- panel: panel as "general" | "thresholds" | "retention" | "execution",
51
+ panel: panel as
52
+ | "general"
53
+ | "thresholds"
54
+ | "retention"
55
+ | "execution"
56
+ | "notifications",
41
57
  configId: rest.join(":"),
42
58
  };
43
59
  }
@@ -80,6 +96,16 @@ const AssignmentIDEPageContent = () => {
80
96
  const [retentionData, setRetentionData] = useState<
81
97
  Record<string, RetentionData>
82
98
  >({});
99
+ const [localNotificationPolicy, setLocalNotificationPolicy] = useState<
100
+ Record<string, NotificationPolicy>
101
+ >({});
102
+ const [platformDefaultsOpen, setPlatformDefaultsOpen] = useState(false);
103
+
104
+ // Platform notification defaults — used as the fallback for any
105
+ // assignment that hasn't overridden them. Refetched whenever the
106
+ // platform-defaults dialog closes so changes propagate immediately.
107
+ const { data: platformDefaults } =
108
+ healthCheckClient.getPlatformNotificationDefaults.useQuery();
83
109
 
84
110
  const configs = useMemo(
85
111
  () => configurationsData?.configurations ?? [],
@@ -214,6 +240,7 @@ const AssignmentIDEPageContent = () => {
214
240
  stateThresholds: assoc.stateThresholds,
215
241
  satelliteIds: assoc.satelliteIds,
216
242
  includeLocal: assoc.includeLocal,
243
+ notificationPolicy: assoc.notificationPolicy,
217
244
  },
218
245
  });
219
246
  };
@@ -238,6 +265,9 @@ const AssignmentIDEPageContent = () => {
238
265
  configurationId: configId,
239
266
  enabled: assoc.enabled,
240
267
  stateThresholds: thresholds,
268
+ satelliteIds: assoc.satelliteIds,
269
+ includeLocal: assoc.includeLocal,
270
+ notificationPolicy: assoc.notificationPolicy,
241
271
  },
242
272
  },
243
273
  {
@@ -253,6 +283,96 @@ const AssignmentIDEPageContent = () => {
253
283
  );
254
284
  };
255
285
 
286
+ const handleNotificationPolicyChange = (
287
+ configId: string,
288
+ policy: NotificationPolicy,
289
+ ) => {
290
+ setLocalNotificationPolicy((prev) => ({ ...prev, [configId]: policy }));
291
+ };
292
+
293
+ const handleSaveNotificationPolicy = (configId: string) => {
294
+ if (!systemId) return;
295
+ const assoc = associations.find((a) => a.configurationId === configId);
296
+ if (!assoc) return;
297
+ const policy =
298
+ localNotificationPolicy[configId] ??
299
+ assoc.notificationPolicy ??
300
+ platformDefaults ??
301
+ DEFAULT_NOTIFICATION_POLICY;
302
+
303
+ associateMutation.mutate(
304
+ {
305
+ systemId,
306
+ body: {
307
+ configurationId: configId,
308
+ enabled: assoc.enabled,
309
+ stateThresholds: assoc.stateThresholds,
310
+ satelliteIds: assoc.satelliteIds,
311
+ includeLocal: assoc.includeLocal,
312
+ notificationPolicy: policy,
313
+ },
314
+ },
315
+ {
316
+ onSuccess: () => {
317
+ toast.success("Notification policy saved");
318
+ setLocalNotificationPolicy((prev) => {
319
+ const next = { ...prev };
320
+ delete next[configId];
321
+ return next;
322
+ });
323
+ },
324
+ },
325
+ );
326
+ };
327
+
328
+ /**
329
+ * Revert this assignment to platform defaults. Sends an undefined
330
+ * `notificationPolicy` which is persisted as null and re-resolves to
331
+ * the platform defaults on the next read.
332
+ */
333
+ const handleUseDefaultsForAssignment = (configId: string) => {
334
+ if (!systemId) return;
335
+ const assoc = associations.find((a) => a.configurationId === configId);
336
+ if (!assoc) return;
337
+
338
+ associateMutation.mutate(
339
+ {
340
+ systemId,
341
+ body: {
342
+ configurationId: configId,
343
+ enabled: assoc.enabled,
344
+ stateThresholds: assoc.stateThresholds,
345
+ satelliteIds: assoc.satelliteIds,
346
+ includeLocal: assoc.includeLocal,
347
+ notificationPolicy: undefined,
348
+ },
349
+ },
350
+ {
351
+ onSuccess: () => {
352
+ toast.success("Reverted to platform defaults");
353
+ setLocalNotificationPolicy((prev) => {
354
+ const next = { ...prev };
355
+ delete next[configId];
356
+ return next;
357
+ });
358
+ },
359
+ },
360
+ );
361
+ };
362
+
363
+ /**
364
+ * Start customising — clone the current platform defaults into the
365
+ * draft state so the operator has a baseline to edit, then persist.
366
+ * The persistence step is what flips the row out of "inherit" mode.
367
+ */
368
+ const handleOverrideForAssignment = (configId: string) => {
369
+ const baseline = platformDefaults ?? DEFAULT_NOTIFICATION_POLICY;
370
+ setLocalNotificationPolicy((prev) => ({ ...prev, [configId]: baseline }));
371
+ // Defer the actual save: operators may want to tweak the cloned
372
+ // baseline before persisting. The Save button at the bottom of
373
+ // the panel handles it.
374
+ };
375
+
256
376
  const handleToggleSatellite = (configId: string, satelliteId: string) => {
257
377
  if (!systemId) return;
258
378
  const assoc = associations.find((a) => a.configurationId === configId);
@@ -272,6 +392,7 @@ const AssignmentIDEPageContent = () => {
272
392
  stateThresholds: assoc.stateThresholds,
273
393
  satelliteIds: newIds,
274
394
  includeLocal: assoc.includeLocal,
395
+ notificationPolicy: assoc.notificationPolicy,
275
396
  },
276
397
  });
277
398
  };
@@ -289,6 +410,7 @@ const AssignmentIDEPageContent = () => {
289
410
  stateThresholds: assoc.stateThresholds,
290
411
  satelliteIds: assoc.satelliteIds,
291
412
  includeLocal: !assoc.includeLocal,
413
+ notificationPolicy: assoc.notificationPolicy,
292
414
  },
293
415
  });
294
416
  };
@@ -450,6 +572,31 @@ const AssignmentIDEPageContent = () => {
450
572
  />
451
573
  );
452
574
  }
575
+ case "notifications": {
576
+ // Is the operator actively editing a draft? Drafts are stored
577
+ // when override starts, so the presence of a draft AND the
578
+ // assignment being persisted-as-override mean the same thing.
579
+ const draft = localNotificationPolicy[configId];
580
+ const isOverridden =
581
+ draft !== undefined || assoc.notificationPolicy !== undefined;
582
+ const policy =
583
+ draft ??
584
+ assoc.notificationPolicy ??
585
+ platformDefaults ??
586
+ DEFAULT_NOTIFICATION_POLICY;
587
+ return (
588
+ <NotificationsPanel
589
+ policy={policy}
590
+ onChange={(p) => handleNotificationPolicyChange(configId, p)}
591
+ onSave={() => handleSaveNotificationPolicy(configId)}
592
+ saving={saving}
593
+ isLocked={isLocked}
594
+ isOverridden={isOverridden}
595
+ onOverride={() => handleOverrideForAssignment(configId)}
596
+ onUseDefaults={() => handleUseDefaultsForAssignment(configId)}
597
+ />
598
+ );
599
+ }
453
600
  default: {
454
601
  return (
455
602
  <ExtensionSlot
@@ -482,6 +629,14 @@ const AssignmentIDEPageContent = () => {
482
629
  maxWidth="full"
483
630
  actions={
484
631
  <div className="flex items-center gap-2">
632
+ <Button
633
+ size="sm"
634
+ variant="outline"
635
+ onClick={() => setPlatformDefaultsOpen(true)}
636
+ >
637
+ <Bell className="mr-2 h-4 w-4" />
638
+ Notification defaults
639
+ </Button>
485
640
  {!isLocked && systemId && (
486
641
  <Button
487
642
  size="sm"
@@ -522,6 +677,10 @@ const AssignmentIDEPageContent = () => {
522
677
  }
523
678
  panel={renderPanel()}
524
679
  />
680
+ <PlatformDefaultsDialog
681
+ open={platformDefaultsOpen}
682
+ onOpenChange={setPlatformDefaultsOpen}
683
+ />
525
684
  </PageLayout>
526
685
  );
527
686
  };
@@ -35,6 +35,11 @@ import {
35
35
  } from "../components/HealthCheckRunsTable";
36
36
  import { ExpandedResultView } from "../components/ExpandedResultView";
37
37
  import { SingleRunChartGrid } from "../auto-charts";
38
+ import {
39
+ StatusFilterPills,
40
+ STATUS_FILTER_TO_STATUSES,
41
+ type StatusFilter,
42
+ } from "../components/StatusFilterPills";
38
43
 
39
44
  const HealthCheckHistoryDetailPageContent = () => {
40
45
  const { systemId, configurationId, runId } = useParams<{
@@ -53,9 +58,10 @@ const HealthCheckHistoryDetailPageContent = () => {
53
58
 
54
59
  const [dateRange, setDateRange] = useState<DateRange>(getDefaultDateRange);
55
60
  const [sourceFilter, setSourceFilter] = useState<string | undefined>();
61
+ const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
56
62
 
57
63
  // Pagination state
58
- const pagination = usePagination({ defaultLimit: 20 });
64
+ const pagination = usePagination({ defaultLimit: 25 });
59
65
 
60
66
  // Fetch satellites for the source filter dropdown
61
67
  const { data: satellitesData } = satelliteClient.listSatellites.useQuery({});
@@ -89,6 +95,7 @@ const HealthCheckHistoryDetailPageContent = () => {
89
95
  startDate: dateRange.startDate,
90
96
  endDate: dateRange.endDate,
91
97
  sourceFilter,
98
+ statusFilter: STATUS_FILTER_TO_STATUSES[statusFilter],
92
99
  limit: pagination.limit,
93
100
  offset: pagination.offset,
94
101
  sortOrder: "desc",
@@ -166,6 +173,13 @@ const HealthCheckHistoryDetailPageContent = () => {
166
173
  <CardContent>
167
174
  <div className="flex flex-wrap items-center gap-3 mb-4">
168
175
  <DateRangeFilter value={dateRange} onChange={setDateRange} />
176
+ <StatusFilterPills
177
+ value={statusFilter}
178
+ onChange={(next) => {
179
+ setStatusFilter(next);
180
+ pagination.setPage(1);
181
+ }}
182
+ />
169
183
  {/* Source filter */}
170
184
  <div className="flex items-center gap-2">
171
185
  <span className="text-sm text-muted-foreground">Source:</span>
@@ -211,7 +225,11 @@ const HealthCheckHistoryDetailPageContent = () => {
211
225
  <HealthCheckRunsTable
212
226
  runs={runs}
213
227
  loading={isLoading}
214
- emptyMessage="No health check runs found for this configuration."
228
+ emptyMessage={
229
+ statusFilter !== "all" || sourceFilter !== undefined
230
+ ? "No runs match the current filters."
231
+ : "No health check runs found for this configuration."
232
+ }
215
233
  pagination={pagination}
216
234
  />
217
235
  </CardContent>
@@ -31,7 +31,7 @@ const HealthCheckHistoryPageContent = () => {
31
31
  );
32
32
 
33
33
  // Pagination state
34
- const pagination = usePagination({ defaultLimit: 20 });
34
+ const pagination = usePagination({ defaultLimit: 25 });
35
35
 
36
36
  // Fetch data with useQuery - newest first for table display
37
37
  const { data, isLoading } = healthCheckClient.getDetailedHistory.useQuery({