@checkstack/healthcheck-frontend 0.19.4 → 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 +116 -0
- package/package.json +14 -14
- package/src/components/EmptyRunsTableRow.tsx +27 -0
- package/src/components/HealthCheckDrawer.tsx +28 -2
- package/src/components/HealthCheckList.tsx +249 -73
- 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/HealthCheckConfigPage.tsx +134 -25
- package/src/pages/HealthCheckHistoryDetailPage.tsx +20 -2
- package/src/pages/HealthCheckHistoryPage.tsx +1 -1
|
@@ -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
|
+
};
|