@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 +35 -0
- package/package.json +14 -14
- package/src/components/EmptyRunsTableRow.tsx +27 -0
- package/src/components/HealthCheckDrawer.tsx +28 -2
- package/src/components/HealthCheckRunsTable.tsx +11 -4
- package/src/components/HealthCheckSystemOverview.tsx +77 -9
- package/src/components/StatusFilterPills.tsx +79 -0
- package/src/components/assignments/AssignmentTree.tsx +21 -1
- package/src/components/assignments/NotificationsPanel.tsx +385 -0
- package/src/components/assignments/PlatformDefaultsDialog.tsx +90 -0
- package/src/pages/AssignmentIDEPage.tsx +163 -4
- package/src/pages/HealthCheckHistoryDetailPage.tsx +20 -2
- package/src/pages/HealthCheckHistoryPage.tsx +1 -1
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.
|
|
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.
|
|
17
|
-
"@checkstack/auth-frontend": "0.6.
|
|
18
|
-
"@checkstack/catalog-common": "2.2.
|
|
19
|
-
"@checkstack/common": "0.
|
|
20
|
-
"@checkstack/dashboard-frontend": "0.7.
|
|
21
|
-
"@checkstack/frontend-api": "0.5.
|
|
22
|
-
"@checkstack/gitops-frontend": "0.4.
|
|
23
|
-
"@checkstack/healthcheck-common": "1.1.
|
|
24
|
-
"@checkstack/satellite-common": "0.5.
|
|
25
|
-
"@checkstack/signal-frontend": "0.1.
|
|
26
|
-
"@checkstack/tips-frontend": "0.2.
|
|
27
|
-
"@checkstack/ui": "1.
|
|
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.
|
|
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:
|
|
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
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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 {
|
|
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 → 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 "Use platform defaults". 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:
|
|
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
|
|
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:
|
|
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=
|
|
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:
|
|
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({
|