@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.
@@ -1,4 +1,5 @@
1
1
  import React, { useState } from "react";
2
+ import { useSearchParams } from "react-router-dom";
2
3
  import {
3
4
  usePluginClient,
4
5
  type SlotContext,
@@ -12,6 +13,7 @@ import {
12
13
  CardContent,
13
14
  CardHeader,
14
15
  CardTitle,
16
+ Button,
15
17
  } from "@checkstack/ui";
16
18
  import { Heart } from "lucide-react";
17
19
  import { HealthCheckSparkline } from "./HealthCheckSparkline";
@@ -22,6 +24,24 @@ import type {
22
24
  HealthCheckStatus,
23
25
  } from "@checkstack/healthcheck-common";
24
26
 
27
+ type HealthFilter = "all" | "failing" | "healthy";
28
+
29
+ const FILTERS: { value: HealthFilter; label: string }[] = [
30
+ { value: "all", label: "All" },
31
+ { value: "failing", label: "Failing" },
32
+ { value: "healthy", label: "Healthy" },
33
+ ];
34
+
35
+ function parseFilter(raw: string | null): HealthFilter {
36
+ return raw === "failing" || raw === "healthy" ? raw : "all";
37
+ }
38
+
39
+ function matchesFilter(state: HealthCheckStatus, filter: HealthFilter) {
40
+ if (filter === "all") return true;
41
+ if (filter === "healthy") return state === "healthy";
42
+ return state !== "healthy";
43
+ }
44
+
25
45
  type SlotProps = SlotContext<typeof SystemDetailsSlot>;
26
46
 
27
47
  /**
@@ -54,6 +74,8 @@ interface HealthCheckOverviewItem {
54
74
  export function HealthCheckSystemOverview(props: SlotProps) {
55
75
  const systemId = props.system.id;
56
76
  const healthCheckClient = usePluginClient(HealthCheckApi);
77
+ const [searchParams, setSearchParams] = useSearchParams();
78
+ const filter = parseFilter(searchParams.get("filter"));
57
79
 
58
80
  const [selectedCheck, setSelectedCheck] = useState<
59
81
  HealthCheckOverviewItem | undefined
@@ -82,6 +104,21 @@ export function HealthCheckSystemOverview(props: SlotProps) {
82
104
  }));
83
105
  }, [overviewData]);
84
106
 
107
+ const visible = React.useMemo(
108
+ () => overview.filter((item) => matchesFilter(item.state, filter)),
109
+ [overview, filter],
110
+ );
111
+
112
+ const setFilter = (next: HealthFilter) => {
113
+ const params = new URLSearchParams(searchParams);
114
+ if (next === "all") {
115
+ params.delete("filter");
116
+ } else {
117
+ params.set("filter", next);
118
+ }
119
+ setSearchParams(params, { replace: true });
120
+ };
121
+
85
122
  if (initialLoading) {
86
123
  return <LoadingSpinner />;
87
124
  }
@@ -94,16 +131,46 @@ export function HealthCheckSystemOverview(props: SlotProps) {
94
131
  <>
95
132
  <Card>
96
133
  <CardHeader className="pb-3">
97
- <div className="flex items-center gap-2">
98
- <Heart className="h-4 w-4 text-muted-foreground" />
99
- <CardTitle className="text-base font-semibold">
100
- Health Checks
101
- </CardTitle>
134
+ <div className="flex items-center justify-between gap-2">
135
+ <div className="flex items-center gap-2">
136
+ <Heart className="h-4 w-4 text-muted-foreground" />
137
+ <CardTitle className="text-base font-semibold">
138
+ Health Checks
139
+ </CardTitle>
140
+ </div>
141
+ <div
142
+ className="flex items-center gap-1 rounded-md border bg-muted/30 p-0.5"
143
+ role="tablist"
144
+ aria-label="Filter health checks"
145
+ >
146
+ {FILTERS.map((f) => {
147
+ const active = filter === f.value;
148
+ return (
149
+ <Button
150
+ key={f.value}
151
+ size="sm"
152
+ variant={active ? "secondary" : "ghost"}
153
+ className="h-7 px-2 text-xs"
154
+ onClick={() => setFilter(f.value)}
155
+ role="tab"
156
+ aria-selected={active}
157
+ >
158
+ {f.label}
159
+ </Button>
160
+ );
161
+ })}
162
+ </div>
102
163
  </div>
103
164
  </CardHeader>
104
165
  <CardContent className="p-0">
105
- <div className="divide-y divide-border">
106
- {overview.map((item) => (
166
+ {visible.length === 0 ? (
167
+ <div className="px-4 py-6 text-center text-xs text-muted-foreground">
168
+ No health checks match the{" "}
169
+ <span className="font-medium">{filter}</span> filter.
170
+ </div>
171
+ ) : (
172
+ <div className="divide-y divide-border">
173
+ {visible.map((item) => (
107
174
  <button
108
175
  key={item.configurationId}
109
176
  className="w-full px-4 py-3 text-left hover:bg-muted/50 transition-colors flex items-center gap-3"
@@ -133,8 +200,9 @@ export function HealthCheckSystemOverview(props: SlotProps) {
133
200
  {formatCompactTime(item.lastRunAt)}
134
201
  </span>
135
202
  </button>
136
- ))}
137
- </div>
203
+ ))}
204
+ </div>
205
+ )}
138
206
  </CardContent>
139
207
  </Card>
140
208
 
@@ -0,0 +1,79 @@
1
+ import React from "react";
2
+ import { CheckCircle, AlertTriangle } from "lucide-react";
3
+ import type { HealthCheckStatus } from "@checkstack/healthcheck-common";
4
+
5
+ /**
6
+ * Coarse status filter buckets for run-history surfaces. "Failing"
7
+ * collapses degraded and unhealthy because operators investigating a
8
+ * problem usually care about "anything not green" rather than the
9
+ * degraded/unhealthy distinction.
10
+ */
11
+ export type StatusFilter = "all" | "healthy" | "failing";
12
+
13
+ export const STATUS_FILTER_TO_STATUSES: Record<
14
+ StatusFilter,
15
+ HealthCheckStatus[] | undefined
16
+ > = {
17
+ all: undefined,
18
+ healthy: ["healthy"],
19
+ failing: ["degraded", "unhealthy"],
20
+ };
21
+
22
+ interface StatusFilterPillsProps {
23
+ value: StatusFilter;
24
+ onChange: (next: StatusFilter) => void;
25
+ }
26
+
27
+ const OPTIONS: {
28
+ value: StatusFilter;
29
+ label: string;
30
+ icon?: React.ComponentType<{ className?: string }>;
31
+ activeClass: string;
32
+ }[] = [
33
+ {
34
+ value: "all",
35
+ label: "All",
36
+ activeClass: "bg-primary text-primary-foreground",
37
+ },
38
+ {
39
+ value: "healthy",
40
+ label: "Healthy",
41
+ icon: CheckCircle,
42
+ activeClass: "bg-success text-white",
43
+ },
44
+ {
45
+ value: "failing",
46
+ label: "Failing",
47
+ icon: AlertTriangle,
48
+ activeClass: "bg-destructive text-destructive-foreground",
49
+ },
50
+ ];
51
+
52
+ export function StatusFilterPills({ value, onChange }: StatusFilterPillsProps) {
53
+ return (
54
+ <div className="flex items-center gap-2">
55
+ <span className="text-sm text-muted-foreground">Status:</span>
56
+ <div className="flex items-center gap-1">
57
+ {OPTIONS.map((opt) => {
58
+ const active = value === opt.value;
59
+ const Icon = opt.icon;
60
+ return (
61
+ <button
62
+ key={opt.value}
63
+ type="button"
64
+ onClick={() => onChange(opt.value)}
65
+ className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full transition-colors ${
66
+ active
67
+ ? opt.activeClass
68
+ : "bg-muted text-muted-foreground hover:bg-muted/80"
69
+ }`}
70
+ >
71
+ {Icon && <Icon className="h-3 w-3" />}
72
+ {opt.label}
73
+ </button>
74
+ );
75
+ })}
76
+ </div>
77
+ </div>
78
+ );
79
+ }
@@ -1,5 +1,13 @@
1
1
  import React from "react";
2
- import { Settings, Gauge, Database, Radio, Plus, Check } from "lucide-react";
2
+ import {
3
+ Settings,
4
+ Gauge,
5
+ Database,
6
+ Radio,
7
+ Plus,
8
+ Check,
9
+ Bell,
10
+ } from "lucide-react";
3
11
  import { IDETreeNode, IDETreeSection } from "@checkstack/ui";
4
12
  import { ExtensionSlot } from "@checkstack/frontend-api";
5
13
  import { AssignmentIDENodeSlot } from "../../slots";
@@ -97,6 +105,18 @@ export const AssignmentTree: React.FC<AssignmentTreeProps> = ({
97
105
  indent
98
106
  badge={assoc.satelliteCount > 0 ? `${assoc.satelliteCount}` : undefined}
99
107
  />
108
+ <IDETreeNode
109
+ nodeId={`notifications:${assoc.configurationId}`}
110
+ label="Notifications"
111
+ icon={Bell}
112
+ selected={
113
+ selectedNode === `notifications:${assoc.configurationId}`
114
+ }
115
+ onClick={() =>
116
+ onSelectNode(`notifications:${assoc.configurationId}`)
117
+ }
118
+ indent
119
+ />
100
120
  <ExtensionSlot
101
121
  slot={AssignmentIDENodeSlot}
102
122
  context={{
@@ -0,0 +1,385 @@
1
+ import React from "react";
2
+ import type { NotificationPolicy } from "@checkstack/healthcheck-common";
3
+ import { Button, Input, Label, Toggle, Tooltip } from "@checkstack/ui";
4
+
5
+ interface NotificationsPanelProps {
6
+ policy: NotificationPolicy;
7
+ onChange: (policy: NotificationPolicy) => void;
8
+ onSave: () => void;
9
+ saving: boolean;
10
+ isLocked?: boolean;
11
+ /**
12
+ * Inheritance state — only meaningful when the panel is rendered
13
+ * for an assignment (not the platform-defaults editor). When
14
+ * `false`, the panel shows a banner explaining that values are
15
+ * inherited and offers an "Override" action.
16
+ */
17
+ isOverridden?: boolean;
18
+ /** Switch to "use platform defaults" mode for this assignment. */
19
+ onUseDefaults?: () => void;
20
+ /** Start overriding (clones the current inherited values). */
21
+ onOverride?: () => void;
22
+ }
23
+
24
+ /**
25
+ * Panel for configuring per-association notification behaviour. All
26
+ * settings are scoped to a single (system, configuration) assignment
27
+ * — different checks on the same system are independent.
28
+ */
29
+ export const NotificationsPanel: React.FC<NotificationsPanelProps> = ({
30
+ policy,
31
+ onChange,
32
+ onSave,
33
+ saving,
34
+ isLocked,
35
+ isOverridden,
36
+ onUseDefaults,
37
+ onOverride,
38
+ }) => {
39
+ // Inheritance UI only applies when the panel is hosted by an
40
+ // assignment — the platform-defaults editor passes neither
41
+ // `isOverridden` nor the callbacks.
42
+ const inheritanceMode =
43
+ typeof isOverridden === "boolean" && (onUseDefaults || onOverride);
44
+ // The Override / Use-defaults buttons in the banner must stay
45
+ // clickable even while the form is locked; only saving / GitOps
46
+ // lock should disable them.
47
+ const actionsDisabled = saving || isLocked;
48
+ // While the assignment inherits, the form values themselves are
49
+ // visible but read-only — operators must click Override to edit
50
+ // them.
51
+ const disabled =
52
+ actionsDisabled || (inheritanceMode ? !isOverridden : false);
53
+ return (
54
+ <div className="p-6 space-y-4">
55
+ <div>
56
+ <h3 className="text-sm font-semibold">Notifications</h3>
57
+ <p className="text-xs text-muted-foreground mt-1">
58
+ Control which health state transitions notify subscribers for this
59
+ check, and when an incident is auto-opened for the system.
60
+ </p>
61
+ </div>
62
+
63
+ {inheritanceMode && (
64
+ <div
65
+ className={`p-3 rounded-lg border flex items-center justify-between gap-3 ${
66
+ isOverridden
67
+ ? "bg-warning/5 border-warning/30"
68
+ : "bg-muted/40 border-border"
69
+ }`}
70
+ >
71
+ <div className="text-xs">
72
+ {isOverridden ? (
73
+ <>
74
+ <span className="font-medium text-warning">
75
+ Custom override
76
+ </span>{" "}
77
+ — this check ignores the platform defaults.
78
+ </>
79
+ ) : (
80
+ <>
81
+ <span className="font-medium">Using platform defaults</span>{" "}
82
+ — fields below are read-only. Click Override to customise
83
+ them for this check only.
84
+ </>
85
+ )}
86
+ </div>
87
+ {isOverridden && onUseDefaults && (
88
+ <Button
89
+ size="sm"
90
+ variant="outline"
91
+ onClick={onUseDefaults}
92
+ disabled={actionsDisabled}
93
+ >
94
+ Use platform defaults
95
+ </Button>
96
+ )}
97
+ {!isOverridden && onOverride && (
98
+ <Button
99
+ size="sm"
100
+ variant="outline"
101
+ onClick={onOverride}
102
+ disabled={actionsDisabled}
103
+ >
104
+ Override
105
+ </Button>
106
+ )}
107
+ </div>
108
+ )}
109
+
110
+ {/* Suppress de-escalations */}
111
+ <div className="p-4 bg-muted/50 rounded-lg border space-y-3">
112
+ <div className="flex items-start justify-between gap-4">
113
+ <div className="flex-1 min-w-0">
114
+ <div className="flex items-center gap-2">
115
+ <Label className="text-sm font-medium">
116
+ Suppress de-escalation notifications
117
+ </Label>
118
+ <Tooltip content="When on, transitions from a worse state to a better one (but not back to healthy) are skipped. Recoveries and escalations still notify." />
119
+ </div>
120
+ <p className="text-xs text-muted-foreground mt-1">
121
+ Skips intermediate notifications like{" "}
122
+ <code className="text-[11px]">unhealthy &rarr; degraded</code>.
123
+ You still get notified when the system gets worse or fully
124
+ recovers.
125
+ </p>
126
+ </div>
127
+ <Toggle
128
+ checked={policy.suppressDeEscalations}
129
+ onCheckedChange={(checked: boolean) =>
130
+ onChange({ ...policy, suppressDeEscalations: checked })
131
+ }
132
+ disabled={disabled}
133
+ aria-label="Suppress de-escalation notifications"
134
+ />
135
+ </div>
136
+ </div>
137
+
138
+ {/* Auto-open incident */}
139
+ <div className="p-4 bg-muted/50 rounded-lg border space-y-4">
140
+ <div className="flex items-start justify-between gap-4">
141
+ <div className="flex-1 min-w-0">
142
+ <div className="flex items-center gap-2">
143
+ <Label className="text-sm font-medium">
144
+ Auto-open incident when this check is critical
145
+ </Label>
146
+ <Tooltip content="When either trigger below fires, an incident is auto-opened on the system. Different checks on the same system are independent — disabling here only affects this check." />
147
+ </div>
148
+ <p className="text-xs text-muted-foreground mt-1">
149
+ One incident per outage instead of one ping per state change.
150
+ Especially useful for Jira / Slack / email — the incident's
151
+ suppression silences downstream channels for the lifetime of
152
+ the incident.
153
+ </p>
154
+ </div>
155
+ <Toggle
156
+ checked={policy.autoOpenIncidentOnUnhealthy}
157
+ onCheckedChange={(checked: boolean) =>
158
+ onChange({ ...policy, autoOpenIncidentOnUnhealthy: checked })
159
+ }
160
+ disabled={disabled}
161
+ aria-label="Auto-open incident when this check is critical"
162
+ />
163
+ </div>
164
+
165
+ {policy.autoOpenIncidentOnUnhealthy && (
166
+ <div className="pl-4 border-l-2 border-border space-y-4">
167
+ {/* Suppress further notifications */}
168
+ <div className="flex items-start justify-between gap-4">
169
+ <div className="flex-1 min-w-0">
170
+ <Label className="text-sm">
171
+ Suppress further notifications while open
172
+ </Label>
173
+ <p className="text-xs text-muted-foreground mt-1">
174
+ Email, Jira, Slack all silenced for this system until the
175
+ incident is resolved.
176
+ </p>
177
+ </div>
178
+ <Toggle
179
+ checked={policy.useNotificationSuppression}
180
+ onCheckedChange={(checked: boolean) =>
181
+ onChange({
182
+ ...policy,
183
+ useNotificationSuppression: checked,
184
+ })
185
+ }
186
+ disabled={disabled}
187
+ aria-label="Suppress further notifications while open"
188
+ />
189
+ </div>
190
+
191
+ {/* Skip during maintenance */}
192
+ <div className="flex items-start justify-between gap-4">
193
+ <div className="flex-1 min-w-0">
194
+ <Label className="text-sm">
195
+ Skip during active maintenance
196
+ </Label>
197
+ <p className="text-xs text-muted-foreground mt-1">
198
+ No auto-incident is opened while the system has an active
199
+ maintenance window with suppression.
200
+ </p>
201
+ </div>
202
+ <Toggle
203
+ checked={policy.skipDuringMaintenance}
204
+ onCheckedChange={(checked: boolean) =>
205
+ onChange({ ...policy, skipDuringMaintenance: checked })
206
+ }
207
+ disabled={disabled}
208
+ aria-label="Skip auto-incident during active maintenance"
209
+ />
210
+ </div>
211
+
212
+ {/* Sustained-duration trigger */}
213
+ <div className="space-y-2 pt-2 border-t border-border">
214
+ <div className="flex items-center justify-between gap-4">
215
+ <div className="flex items-center gap-2">
216
+ <Label className="text-sm">
217
+ Open when unhealthy continuously
218
+ </Label>
219
+ <Tooltip content="Catches real outages: the check has stayed unhealthy for at least this long without recovering." />
220
+ </div>
221
+ <Toggle
222
+ checked={policy.sustainedUnhealthyTrigger.enabled}
223
+ onCheckedChange={(checked: boolean) =>
224
+ onChange({
225
+ ...policy,
226
+ sustainedUnhealthyTrigger: {
227
+ ...policy.sustainedUnhealthyTrigger,
228
+ enabled: checked,
229
+ },
230
+ })
231
+ }
232
+ disabled={disabled}
233
+ aria-label="Enable sustained-unhealthy trigger"
234
+ />
235
+ </div>
236
+ {policy.sustainedUnhealthyTrigger.enabled && (
237
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
238
+ <span>Open after</span>
239
+ <Input
240
+ type="number"
241
+ min={1}
242
+ className="h-8 w-16 text-center"
243
+ value={policy.sustainedUnhealthyTrigger.durationMinutes}
244
+ onChange={(e) =>
245
+ onChange({
246
+ ...policy,
247
+ sustainedUnhealthyTrigger: {
248
+ ...policy.sustainedUnhealthyTrigger,
249
+ durationMinutes:
250
+ Number.parseInt(e.target.value, 10) || 1,
251
+ },
252
+ })
253
+ }
254
+ disabled={disabled}
255
+ />
256
+ <span>minutes of continuous unhealthy state</span>
257
+ </div>
258
+ )}
259
+ </div>
260
+
261
+ {/* Flapping trigger */}
262
+ <div className="space-y-2 pt-2 border-t border-border">
263
+ <div className="flex items-center justify-between gap-4">
264
+ <div className="flex items-center gap-2">
265
+ <Label className="text-sm">Open on flapping</Label>
266
+ <Tooltip content="Catches checks that flip in and out of unhealthy too quickly for the sustained trigger to fire." />
267
+ </div>
268
+ <Toggle
269
+ checked={policy.flappingTrigger.enabled}
270
+ onCheckedChange={(checked: boolean) =>
271
+ onChange({
272
+ ...policy,
273
+ flappingTrigger: {
274
+ ...policy.flappingTrigger,
275
+ enabled: checked,
276
+ },
277
+ })
278
+ }
279
+ disabled={disabled}
280
+ aria-label="Enable flapping trigger"
281
+ />
282
+ </div>
283
+ {policy.flappingTrigger.enabled && (
284
+ <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
285
+ <span>Open after</span>
286
+ <Input
287
+ type="number"
288
+ min={1}
289
+ className="h-8 w-16 text-center"
290
+ value={policy.flappingTrigger.transitions}
291
+ onChange={(e) =>
292
+ onChange({
293
+ ...policy,
294
+ flappingTrigger: {
295
+ ...policy.flappingTrigger,
296
+ transitions:
297
+ Number.parseInt(e.target.value, 10) || 1,
298
+ },
299
+ })
300
+ }
301
+ disabled={disabled}
302
+ />
303
+ <span>transitions to unhealthy within</span>
304
+ <Input
305
+ type="number"
306
+ min={1}
307
+ className="h-8 w-16 text-center"
308
+ value={policy.flappingTrigger.windowMinutes}
309
+ onChange={(e) =>
310
+ onChange({
311
+ ...policy,
312
+ flappingTrigger: {
313
+ ...policy.flappingTrigger,
314
+ windowMinutes:
315
+ Number.parseInt(e.target.value, 10) || 1,
316
+ },
317
+ })
318
+ }
319
+ disabled={disabled}
320
+ />
321
+ <span>minutes</span>
322
+ </div>
323
+ )}
324
+ </div>
325
+
326
+ {/* Auto-close cooldown */}
327
+ <div className="space-y-2 pt-2 border-t border-border">
328
+ <div className="flex items-center gap-2">
329
+ <Label className="text-sm">Auto-close cooldown</Label>
330
+ <Tooltip content="Resolve the auto-incident once the system has stayed healthy for this long. Snapshotted per-incident at open time — later policy edits don't change in-flight incidents." />
331
+ </div>
332
+ <div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
333
+ <label className="inline-flex items-center gap-2 cursor-pointer">
334
+ <input
335
+ type="checkbox"
336
+ checked={policy.autoCloseAfterMinutes === null}
337
+ onChange={(e) =>
338
+ onChange({
339
+ ...policy,
340
+ autoCloseAfterMinutes: e.target.checked ? null : 30,
341
+ })
342
+ }
343
+ disabled={disabled}
344
+ />
345
+ <span>Never auto-close (manual resolve only)</span>
346
+ </label>
347
+ {policy.autoCloseAfterMinutes !== null && (
348
+ <div className="flex items-center gap-2">
349
+ <span>After</span>
350
+ <Input
351
+ type="number"
352
+ min={1}
353
+ className="h-8 w-16 text-center"
354
+ value={policy.autoCloseAfterMinutes}
355
+ onChange={(e) =>
356
+ onChange({
357
+ ...policy,
358
+ autoCloseAfterMinutes:
359
+ Number.parseInt(e.target.value, 10) || 1,
360
+ })
361
+ }
362
+ disabled={disabled}
363
+ />
364
+ <span>minutes of sustained healthy</span>
365
+ </div>
366
+ )}
367
+ </div>
368
+ </div>
369
+ </div>
370
+ )}
371
+ </div>
372
+
373
+ {/* Save button hides when the assignment is inheriting — there
374
+ is nothing to save. The Override button drives the transition
375
+ into edit mode. */}
376
+ {(!inheritanceMode || isOverridden) && (
377
+ <div className="flex justify-end pt-2 border-t">
378
+ <Button size="sm" onClick={onSave} disabled={disabled}>
379
+ {saving ? "Saving..." : "Save Notifications"}
380
+ </Button>
381
+ </div>
382
+ )}
383
+ </div>
384
+ );
385
+ };