@checkstack/healthcheck-frontend 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,9 +8,10 @@ import {
8
8
  TableCell,
9
9
  HealthBadge,
10
10
  Pagination,
11
+ Spinner,
11
12
  } from "@checkstack/ui";
12
13
  import { formatDistanceToNow, format } from "date-fns";
13
- import { ExternalLink, Loader2, Satellite, Server } from "lucide-react";
14
+ import { ExternalLink, Satellite, Server, Layers } from "lucide-react";
14
15
  import { useNavigate } from "react-router-dom";
15
16
  import { healthcheckRoutes } from "@checkstack/healthcheck-common";
16
17
  import { resolveRoute } from "@checkstack/common";
@@ -23,16 +24,32 @@ export interface HealthCheckRunDetailed {
23
24
  status: "healthy" | "unhealthy" | "degraded";
24
25
  result: Record<string, unknown>;
25
26
  timestamp: Date;
27
+ /**
28
+ * Environment this run executed for (per-environment fan-out). undefined =
29
+ * env-less run (opt-out / no membership).
30
+ */
31
+ environmentId?: string;
26
32
  /** Source ID for result attribution (undefined = local core, UUID = satellite) */
27
33
  sourceId?: string;
28
34
  /** Human-readable source label (e.g. "Local" or "EU West (eu-west-1)") */
29
35
  sourceLabel?: string;
30
36
  }
31
37
 
38
+ export interface EnvironmentLabel {
39
+ id: string;
40
+ name: string;
41
+ }
42
+
32
43
  export interface HealthCheckRunsTableProps {
33
44
  runs: HealthCheckRunDetailed[];
34
45
  loading: boolean;
35
46
  emptyMessage?: string;
47
+ /**
48
+ * Optional id -> name map for the Environment column. When a run's
49
+ * `environmentId` is present, its display name is looked up here (falling
50
+ * back to the id). Env-less runs render a muted dash.
51
+ */
52
+ environmentLabels?: EnvironmentLabel[];
36
53
  /** Show System ID and Configuration ID columns with link to detail page */
37
54
  showFilterColumns?: boolean;
38
55
  /** Number of columns for the expanded result row */
@@ -52,11 +69,15 @@ export const HealthCheckRunsTable: React.FC<HealthCheckRunsTableProps> = ({
52
69
  runs,
53
70
  loading,
54
71
  emptyMessage = "No health check runs found.",
72
+ environmentLabels,
55
73
  showFilterColumns = false,
56
74
  pagination,
57
75
  }) => {
58
76
  const navigate = useNavigate();
59
77
  const prevRunsRef = useRef(runs);
78
+ const envNameById = new Map(
79
+ (environmentLabels ?? []).map((e) => [e.id, e.name]),
80
+ );
60
81
 
61
82
  // Keep previous runs during loading to prevent layout shift
62
83
  const displayRuns =
@@ -75,9 +96,9 @@ export const HealthCheckRunsTable: React.FC<HealthCheckRunsTableProps> = ({
75
96
  );
76
97
  };
77
98
 
78
- // 3 base columns (Status, Timestamp, Source) + 3 extras when
99
+ // 4 base columns (Status, Timestamp, Environment, Source) + 3 extras when
79
100
  // showFilterColumns is on (System ID, Configuration ID, link icon).
80
- const columnCount = showFilterColumns ? 6 : 3;
101
+ const columnCount = showFilterColumns ? 7 : 4;
81
102
  const showEmptyRow = !loading && displayRuns.length === 0;
82
103
 
83
104
  return (
@@ -89,7 +110,9 @@ export const HealthCheckRunsTable: React.FC<HealthCheckRunsTableProps> = ({
89
110
  <TableHead className="w-24">
90
111
  <span className="flex items-center gap-2">
91
112
  Status
92
- {loading && <Loader2 className="h-3 w-3 animate-spin" />}
113
+ {loading && (
114
+ <Spinner size="sm" className="h-3 w-3" />
115
+ )}
93
116
  </span>
94
117
  </TableHead>
95
118
  {showFilterColumns && (
@@ -99,6 +122,7 @@ export const HealthCheckRunsTable: React.FC<HealthCheckRunsTableProps> = ({
99
122
  </>
100
123
  )}
101
124
  <TableHead>Timestamp</TableHead>
125
+ <TableHead>Environment</TableHead>
102
126
  <TableHead>Source</TableHead>
103
127
  {showFilterColumns && <TableHead className="w-16"></TableHead>}
104
128
  </TableRow>
@@ -135,6 +159,16 @@ export const HealthCheckRunsTable: React.FC<HealthCheckRunsTableProps> = ({
135
159
  })}
136
160
  </span>
137
161
  </TableCell>
162
+ <TableCell>
163
+ {run.environmentId ? (
164
+ <span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
165
+ <Layers className="h-3 w-3" />
166
+ {envNameById.get(run.environmentId) ?? run.environmentId}
167
+ </span>
168
+ ) : (
169
+ <span className="text-xs text-muted-foreground">None</span>
170
+ )}
171
+ </TableCell>
138
172
  <TableCell>
139
173
  {run.sourceId ? (
140
174
  <span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-orange-500/10 text-orange-600">
@@ -1,4 +1,4 @@
1
- import React, { useState } from "react";
1
+ import React, { useState, lazy, Suspense } from "react";
2
2
  import { useSearchParams } from "react-router-dom";
3
3
  import {
4
4
  usePluginClient,
@@ -17,7 +17,13 @@ import {
17
17
  } from "@checkstack/ui";
18
18
  import { Heart } from "lucide-react";
19
19
  import { HealthCheckSparkline } from "./HealthCheckSparkline";
20
- import { HealthCheckDrawer } from "./HealthCheckDrawer";
20
+ // Lazy-loaded: the drawer pulls in the recharts-based latency/timeline charts
21
+ // (~300 KB). This component is an eagerly-registered slot extension, so a static
22
+ // import would ship recharts in the initial bundle. The drawer only renders when
23
+ // a check is selected, so deferring it keeps charts out of the initial load.
24
+ const HealthCheckDrawer = lazy(() =>
25
+ import("./HealthCheckDrawer").then((m) => ({ default: m.HealthCheckDrawer })),
26
+ );
21
27
 
22
28
  import type {
23
29
  StateThresholds,
@@ -206,16 +212,18 @@ export function HealthCheckSystemOverview(props: SlotProps) {
206
212
  </CardContent>
207
213
  </Card>
208
214
 
209
- {/* Slide-over Drawer */}
215
+ {/* Slide-over Drawer (lazy: loads the chart bundle on first open) */}
210
216
  {selectedCheck && (
211
- <HealthCheckDrawer
212
- item={selectedCheck}
213
- systemId={systemId}
214
- open={!!selectedCheck}
215
- onOpenChange={(open) => {
216
- if (!open) setSelectedCheck(undefined);
217
- }}
218
- />
217
+ <Suspense fallback={null}>
218
+ <HealthCheckDrawer
219
+ item={selectedCheck}
220
+ systemId={systemId}
221
+ open={!!selectedCheck}
222
+ onOpenChange={(open) => {
223
+ if (!open) setSelectedCheck(undefined);
224
+ }}
225
+ />
226
+ </Suspense>
219
227
  )}
220
228
  </>
221
229
  );
@@ -0,0 +1,76 @@
1
+ import React, { useEffect, useMemo } from "react";
2
+ import { usePluginClient, type SlotContext } from "@checkstack/frontend-api";
3
+ import { resolveRoute } from "@checkstack/common";
4
+ import {
5
+ SystemSignalsSlot,
6
+ type SystemSignal,
7
+ type SystemSignalsMap,
8
+ } from "@checkstack/catalog-common";
9
+ import { healthcheckRoutes } from "@checkstack/healthcheck-common";
10
+ import { HealthCheckApi } from "../api";
11
+
12
+ type Props = SlotContext<typeof SystemSignalsSlot>;
13
+
14
+ const SOURCE_ID = "healthcheck";
15
+
16
+ /**
17
+ * Reports per-system health as dashboard signals. Bulk-fetches health for all
18
+ * overview systems in one request and contributes a signal for every system
19
+ * that is degraded or unhealthy, deep-linking to the failing check's history
20
+ * (or the system's check assignments when no specific check is failing).
21
+ * Renders nothing — it is a headless filler for {@link SystemSignalsSlot}.
22
+ */
23
+ export const HealthSignalsFiller: React.FC<Props> = ({
24
+ systemIds,
25
+ onSignals,
26
+ }) => {
27
+ const healthCheckClient = usePluginClient(HealthCheckApi);
28
+
29
+ const { data } = healthCheckClient.getBulkSystemHealthStatus.useQuery(
30
+ { systemIds },
31
+ { enabled: systemIds.length > 0, staleTime: 30_000 },
32
+ );
33
+
34
+ const signals = useMemo<SystemSignalsMap>(() => {
35
+ const result: SystemSignalsMap = {};
36
+ if (!data) return result;
37
+
38
+ for (const systemId of systemIds) {
39
+ const status = data.statuses[systemId];
40
+ if (!status || status.status === "healthy") continue;
41
+
42
+ const failing = status.checkStatuses.filter(
43
+ (c) => c.status !== "healthy",
44
+ );
45
+ const failingCheck = failing[0];
46
+ const href = failingCheck
47
+ ? resolveRoute(healthcheckRoutes.routes.historyDetail, {
48
+ systemId,
49
+ configurationId: failingCheck.configurationId,
50
+ })
51
+ : resolveRoute(healthcheckRoutes.routes.assignments, { systemId });
52
+
53
+ const detail =
54
+ status.checkStatuses.length > 0
55
+ ? `${failing.length} of ${status.checkStatuses.length} checks failing`
56
+ : undefined;
57
+
58
+ const signal: SystemSignal = {
59
+ source: SOURCE_ID,
60
+ tone: status.status === "unhealthy" ? "error" : "warn",
61
+ label: status.status === "unhealthy" ? "Unhealthy" : "Degraded",
62
+ detail,
63
+ href,
64
+ iconName: "Activity",
65
+ };
66
+ result[systemId] = [signal];
67
+ }
68
+ return result;
69
+ }, [data, systemIds]);
70
+
71
+ useEffect(() => {
72
+ onSignals(SOURCE_ID, signals);
73
+ }, [signals, onSignals]);
74
+
75
+ return null;
76
+ };
@@ -2,7 +2,8 @@ import React from "react";
2
2
  import { usePluginClient, type SlotContext } from "@checkstack/frontend-api";
3
3
  import { SystemStateBadgesSlot } from "@checkstack/catalog-common";
4
4
  import { HealthCheckApi } from "../api";
5
- import { HealthBadge } from "@checkstack/ui";
5
+ import { StatusBadge } from "@checkstack/ui";
6
+ import { Activity } from "lucide-react";
6
7
  import { useSystemBadgeDataOptional } from "@checkstack/dashboard-frontend";
7
8
 
8
9
  type Props = SlotContext<typeof SystemStateBadgesSlot>;
@@ -37,5 +38,9 @@ export const SystemHealthBadge: React.FC<Props> = ({ system }) => {
37
38
  const status = providerStatus ?? localStatus;
38
39
 
39
40
  if (!status || status === "healthy") return <></>;
40
- return <HealthBadge status={status} />;
41
+ return status === "unhealthy" ? (
42
+ <StatusBadge tone="error" icon={Activity} label="Unhealthy" />
43
+ ) : (
44
+ <StatusBadge tone="warn" icon={Activity} label="Degraded" />
45
+ );
41
46
  };
@@ -1,6 +1,10 @@
1
1
  import React from "react";
2
2
  import { Checkbox, Label, Tooltip } from "@checkstack/ui";
3
- import { Satellite } from "lucide-react";
3
+ import { Satellite, Layers } from "lucide-react";
4
+ import {
5
+ modeFromEnvironmentIds,
6
+ type EnvironmentSelectorMode,
7
+ } from "./environment-selector.logic";
4
8
 
5
9
  interface SatelliteDto {
6
10
  id: string;
@@ -9,12 +13,26 @@ interface SatelliteDto {
9
13
  status: "online" | "offline";
10
14
  }
11
15
 
16
+ interface EnvironmentDto {
17
+ id: string;
18
+ name: string;
19
+ }
20
+
12
21
  interface ExecutionPanelProps {
13
22
  includeLocal: boolean;
14
23
  satelliteIds: string[];
15
24
  satellites: SatelliteDto[];
16
25
  onToggleLocal: () => void;
17
26
  onToggleSatellite: (satelliteId: string) => void;
27
+ /**
28
+ * Per-assignment environment selector value. null = all current
29
+ * environments; [] = opt out (env-less); non-empty = those env ids.
30
+ */
31
+ environmentIds: string[] | null;
32
+ /** Environments the system currently belongs to. */
33
+ environments: EnvironmentDto[];
34
+ onSetEnvironmentMode: (mode: EnvironmentSelectorMode) => void;
35
+ onToggleEnvironment: (environmentId: string) => void;
18
36
  saving: boolean;
19
37
  isLocked?: boolean;
20
38
  }
@@ -29,11 +47,24 @@ export const ExecutionPanel: React.FC<ExecutionPanelProps> = ({
29
47
  satellites,
30
48
  onToggleLocal,
31
49
  onToggleSatellite,
50
+ environmentIds,
51
+ environments,
52
+ onSetEnvironmentMode,
53
+ onToggleEnvironment,
32
54
  saving,
33
55
  isLocked,
34
56
  }) => {
35
57
  const hasSatellites = satelliteIds.length > 0;
36
58
  const willRunAnywhere = includeLocal || hasSatellites;
59
+ const envMode = modeFromEnvironmentIds(environmentIds);
60
+ const selectedEnvIds = new Set<string>(
61
+ environmentIds === null ? [] : environmentIds,
62
+ );
63
+ const envModes: { value: EnvironmentSelectorMode; label: string; hint: string }[] = [
64
+ { value: "all", label: "All environments", hint: "Run once per environment the system belongs to" },
65
+ { value: "specific", label: "Specific", hint: "Run only for the selected environments" },
66
+ { value: "none", label: "None", hint: "Run once with no environment" },
67
+ ];
37
68
 
38
69
  return (
39
70
  <div className="p-6 space-y-4">
@@ -118,6 +149,65 @@ export const ExecutionPanel: React.FC<ExecutionPanelProps> = ({
118
149
  )}
119
150
  </div>
120
151
 
152
+ {/* Environment Selector */}
153
+ <div className="space-y-2">
154
+ <div className="flex items-center gap-2">
155
+ <Layers className="h-3.5 w-3.5 text-muted-foreground" />
156
+ <Label className="text-sm font-medium">Environments</Label>
157
+ <Tooltip content="Fan this check out into one run per environment. The custom fields of each environment are exposed to scripts and templating." />
158
+ </div>
159
+ <p className="text-xs text-muted-foreground">
160
+ Choose how this check fans out across the system's environments.
161
+ </p>
162
+
163
+ <div className="space-y-1.5">
164
+ {envModes.map((m) => (
165
+ <label
166
+ key={m.value}
167
+ className="flex items-start gap-3 p-2.5 rounded-md border hover:bg-muted/30 transition-colors cursor-pointer"
168
+ >
169
+ <input
170
+ type="radio"
171
+ name="environment-mode"
172
+ className="mt-1"
173
+ checked={envMode === m.value}
174
+ disabled={saving || isLocked}
175
+ onChange={() => onSetEnvironmentMode(m.value)}
176
+ />
177
+ <div className="flex-1 min-w-0">
178
+ <span className="text-sm font-medium">{m.label}</span>
179
+ <p className="text-xs text-muted-foreground mt-0.5">{m.hint}</p>
180
+ </div>
181
+ </label>
182
+ ))}
183
+ </div>
184
+
185
+ {envMode === "specific" && (
186
+ <div className="space-y-1.5 pl-6">
187
+ {environments.length === 0 ? (
188
+ <p className="text-sm text-muted-foreground italic py-2">
189
+ This system has no environments. Attach environments to the
190
+ system in the catalog first.
191
+ </p>
192
+ ) : (
193
+ environments.map((env) => (
194
+ <div
195
+ key={env.id}
196
+ className="flex items-center gap-3 p-2 rounded-md border hover:bg-muted/30 transition-colors"
197
+ >
198
+ <Checkbox
199
+ checked={selectedEnvIds.has(env.id)}
200
+ onCheckedChange={() => onToggleEnvironment(env.id)}
201
+ disabled={saving || isLocked}
202
+ />
203
+ <span className="text-sm truncate">{env.name}</span>
204
+ </div>
205
+ ))
206
+ )}
207
+ </div>
208
+ )}
209
+ </div>
210
+
121
211
  {/* Execution Summary */}
122
212
  <div className="p-3 bg-muted/30 rounded-lg border text-xs text-muted-foreground">
123
213
  <span className="font-medium">Execution: </span>
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import type { NotificationPolicy } from "@checkstack/healthcheck-common";
3
- import { Button, Input, Label, Toggle, Tooltip } from "@checkstack/ui";
3
+ import { Button, Label, Toggle, Tooltip } from "@checkstack/ui";
4
4
 
5
5
  interface NotificationsPanelProps {
6
6
  policy: NotificationPolicy;
@@ -25,6 +25,12 @@ interface NotificationsPanelProps {
25
25
  * Panel for configuring per-association notification behaviour. All
26
26
  * settings are scoped to a single (system, configuration) assignment
27
27
  * — different checks on the same system are independent.
28
+ *
29
+ * Auto-incident opening/closing is no longer configured here: it ships
30
+ * as ordinary user automations. Flapping thresholds likewise moved onto
31
+ * the automation engine's windowed-count gate (the
32
+ * `healthcheck.system_health_changed` trigger's `window` block). What
33
+ * remains is the de-escalation notification preference.
28
34
  */
29
35
  export const NotificationsPanel: React.FC<NotificationsPanelProps> = ({
30
36
  policy,
@@ -56,7 +62,7 @@ export const NotificationsPanel: React.FC<NotificationsPanelProps> = ({
56
62
  <h3 className="text-sm font-semibold">Notifications</h3>
57
63
  <p className="text-xs text-muted-foreground mt-1">
58
64
  Control which health state transitions notify subscribers for this
59
- check, and when an incident is auto-opened for the system.
65
+ check.
60
66
  </p>
61
67
  </div>
62
68
 
@@ -135,241 +141,6 @@ export const NotificationsPanel: React.FC<NotificationsPanelProps> = ({
135
141
  </div>
136
142
  </div>
137
143
 
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
144
  {/* Save button hides when the assignment is inheriting — there
374
145
  is nothing to save. The Override button drives the transition
375
146
  into edit mode. */}