@checkstack/healthcheck-frontend 0.19.5 → 0.21.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.
@@ -0,0 +1,385 @@
1
+ import React from "react";
2
+ import type { NotificationPolicy } from "@checkstack/healthcheck-common";
3
+ import { Button, Input, Label, Toggle, Tooltip } from "@checkstack/ui";
4
+
5
+ interface NotificationsPanelProps {
6
+ policy: NotificationPolicy;
7
+ onChange: (policy: NotificationPolicy) => void;
8
+ onSave: () => void;
9
+ saving: boolean;
10
+ isLocked?: boolean;
11
+ /**
12
+ * Inheritance state — only meaningful when the panel is rendered
13
+ * for an assignment (not the platform-defaults editor). When
14
+ * `false`, the panel shows a banner explaining that values are
15
+ * inherited and offers an "Override" action.
16
+ */
17
+ isOverridden?: boolean;
18
+ /** Switch to "use platform defaults" mode for this assignment. */
19
+ onUseDefaults?: () => void;
20
+ /** Start overriding (clones the current inherited values). */
21
+ onOverride?: () => void;
22
+ }
23
+
24
+ /**
25
+ * Panel for configuring per-association notification behaviour. All
26
+ * settings are scoped to a single (system, configuration) assignment
27
+ * — different checks on the same system are independent.
28
+ */
29
+ export const NotificationsPanel: React.FC<NotificationsPanelProps> = ({
30
+ policy,
31
+ onChange,
32
+ onSave,
33
+ saving,
34
+ isLocked,
35
+ isOverridden,
36
+ onUseDefaults,
37
+ onOverride,
38
+ }) => {
39
+ // Inheritance UI only applies when the panel is hosted by an
40
+ // assignment — the platform-defaults editor passes neither
41
+ // `isOverridden` nor the callbacks.
42
+ const inheritanceMode =
43
+ typeof isOverridden === "boolean" && (onUseDefaults || onOverride);
44
+ // The Override / Use-defaults buttons in the banner must stay
45
+ // clickable even while the form is locked; only saving / GitOps
46
+ // lock should disable them.
47
+ const actionsDisabled = saving || isLocked;
48
+ // While the assignment inherits, the form values themselves are
49
+ // visible but read-only — operators must click Override to edit
50
+ // them.
51
+ const disabled =
52
+ actionsDisabled || (inheritanceMode ? !isOverridden : false);
53
+ return (
54
+ <div className="p-6 space-y-4">
55
+ <div>
56
+ <h3 className="text-sm font-semibold">Notifications</h3>
57
+ <p className="text-xs text-muted-foreground mt-1">
58
+ Control which health state transitions notify subscribers for this
59
+ check, and when an incident is auto-opened for the system.
60
+ </p>
61
+ </div>
62
+
63
+ {inheritanceMode && (
64
+ <div
65
+ className={`p-3 rounded-lg border flex items-center justify-between gap-3 ${
66
+ isOverridden
67
+ ? "bg-warning/5 border-warning/30"
68
+ : "bg-muted/40 border-border"
69
+ }`}
70
+ >
71
+ <div className="text-xs">
72
+ {isOverridden ? (
73
+ <>
74
+ <span className="font-medium text-warning">
75
+ Custom override
76
+ </span>{" "}
77
+ — this check ignores the platform defaults.
78
+ </>
79
+ ) : (
80
+ <>
81
+ <span className="font-medium">Using platform defaults</span>{" "}
82
+ — fields below are read-only. Click Override to customise
83
+ them for this check only.
84
+ </>
85
+ )}
86
+ </div>
87
+ {isOverridden && onUseDefaults && (
88
+ <Button
89
+ size="sm"
90
+ variant="outline"
91
+ onClick={onUseDefaults}
92
+ disabled={actionsDisabled}
93
+ >
94
+ Use platform defaults
95
+ </Button>
96
+ )}
97
+ {!isOverridden && onOverride && (
98
+ <Button
99
+ size="sm"
100
+ variant="outline"
101
+ onClick={onOverride}
102
+ disabled={actionsDisabled}
103
+ >
104
+ Override
105
+ </Button>
106
+ )}
107
+ </div>
108
+ )}
109
+
110
+ {/* Suppress de-escalations */}
111
+ <div className="p-4 bg-muted/50 rounded-lg border space-y-3">
112
+ <div className="flex items-start justify-between gap-4">
113
+ <div className="flex-1 min-w-0">
114
+ <div className="flex items-center gap-2">
115
+ <Label className="text-sm font-medium">
116
+ Suppress de-escalation notifications
117
+ </Label>
118
+ <Tooltip content="When on, transitions from a worse state to a better one (but not back to healthy) are skipped. Recoveries and escalations still notify." />
119
+ </div>
120
+ <p className="text-xs text-muted-foreground mt-1">
121
+ Skips intermediate notifications like{" "}
122
+ <code className="text-[11px]">unhealthy &rarr; degraded</code>.
123
+ You still get notified when the system gets worse or fully
124
+ recovers.
125
+ </p>
126
+ </div>
127
+ <Toggle
128
+ checked={policy.suppressDeEscalations}
129
+ onCheckedChange={(checked: boolean) =>
130
+ onChange({ ...policy, suppressDeEscalations: checked })
131
+ }
132
+ disabled={disabled}
133
+ aria-label="Suppress de-escalation notifications"
134
+ />
135
+ </div>
136
+ </div>
137
+
138
+ {/* Auto-open incident */}
139
+ <div className="p-4 bg-muted/50 rounded-lg border space-y-4">
140
+ <div className="flex items-start justify-between gap-4">
141
+ <div className="flex-1 min-w-0">
142
+ <div className="flex items-center gap-2">
143
+ <Label className="text-sm font-medium">
144
+ Auto-open incident when this check is critical
145
+ </Label>
146
+ <Tooltip content="When either trigger below fires, an incident is auto-opened on the system. Different checks on the same system are independent — disabling here only affects this check." />
147
+ </div>
148
+ <p className="text-xs text-muted-foreground mt-1">
149
+ One incident per outage instead of one ping per state change.
150
+ Especially useful for Jira / Slack / email — the incident's
151
+ suppression silences downstream channels for the lifetime of
152
+ the incident.
153
+ </p>
154
+ </div>
155
+ <Toggle
156
+ checked={policy.autoOpenIncidentOnUnhealthy}
157
+ onCheckedChange={(checked: boolean) =>
158
+ onChange({ ...policy, autoOpenIncidentOnUnhealthy: checked })
159
+ }
160
+ disabled={disabled}
161
+ aria-label="Auto-open incident when this check is critical"
162
+ />
163
+ </div>
164
+
165
+ {policy.autoOpenIncidentOnUnhealthy && (
166
+ <div className="pl-4 border-l-2 border-border space-y-4">
167
+ {/* Suppress further notifications */}
168
+ <div className="flex items-start justify-between gap-4">
169
+ <div className="flex-1 min-w-0">
170
+ <Label className="text-sm">
171
+ Suppress further notifications while open
172
+ </Label>
173
+ <p className="text-xs text-muted-foreground mt-1">
174
+ Email, Jira, Slack all silenced for this system until the
175
+ incident is resolved.
176
+ </p>
177
+ </div>
178
+ <Toggle
179
+ checked={policy.useNotificationSuppression}
180
+ onCheckedChange={(checked: boolean) =>
181
+ onChange({
182
+ ...policy,
183
+ useNotificationSuppression: checked,
184
+ })
185
+ }
186
+ disabled={disabled}
187
+ aria-label="Suppress further notifications while open"
188
+ />
189
+ </div>
190
+
191
+ {/* Skip during maintenance */}
192
+ <div className="flex items-start justify-between gap-4">
193
+ <div className="flex-1 min-w-0">
194
+ <Label className="text-sm">
195
+ Skip during active maintenance
196
+ </Label>
197
+ <p className="text-xs text-muted-foreground mt-1">
198
+ No auto-incident is opened while the system has an active
199
+ maintenance window with suppression.
200
+ </p>
201
+ </div>
202
+ <Toggle
203
+ checked={policy.skipDuringMaintenance}
204
+ onCheckedChange={(checked: boolean) =>
205
+ onChange({ ...policy, skipDuringMaintenance: checked })
206
+ }
207
+ disabled={disabled}
208
+ aria-label="Skip auto-incident during active maintenance"
209
+ />
210
+ </div>
211
+
212
+ {/* Sustained-duration trigger */}
213
+ <div className="space-y-2 pt-2 border-t border-border">
214
+ <div className="flex items-center justify-between gap-4">
215
+ <div className="flex items-center gap-2">
216
+ <Label className="text-sm">
217
+ Open when unhealthy continuously
218
+ </Label>
219
+ <Tooltip content="Catches real outages: the check has stayed unhealthy for at least this long without recovering." />
220
+ </div>
221
+ <Toggle
222
+ checked={policy.sustainedUnhealthyTrigger.enabled}
223
+ onCheckedChange={(checked: boolean) =>
224
+ onChange({
225
+ ...policy,
226
+ sustainedUnhealthyTrigger: {
227
+ ...policy.sustainedUnhealthyTrigger,
228
+ enabled: checked,
229
+ },
230
+ })
231
+ }
232
+ disabled={disabled}
233
+ aria-label="Enable sustained-unhealthy trigger"
234
+ />
235
+ </div>
236
+ {policy.sustainedUnhealthyTrigger.enabled && (
237
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
238
+ <span>Open after</span>
239
+ <Input
240
+ type="number"
241
+ min={1}
242
+ className="h-8 w-16 text-center"
243
+ value={policy.sustainedUnhealthyTrigger.durationMinutes}
244
+ onChange={(e) =>
245
+ onChange({
246
+ ...policy,
247
+ sustainedUnhealthyTrigger: {
248
+ ...policy.sustainedUnhealthyTrigger,
249
+ durationMinutes:
250
+ Number.parseInt(e.target.value, 10) || 1,
251
+ },
252
+ })
253
+ }
254
+ disabled={disabled}
255
+ />
256
+ <span>minutes of continuous unhealthy state</span>
257
+ </div>
258
+ )}
259
+ </div>
260
+
261
+ {/* Flapping trigger */}
262
+ <div className="space-y-2 pt-2 border-t border-border">
263
+ <div className="flex items-center justify-between gap-4">
264
+ <div className="flex items-center gap-2">
265
+ <Label className="text-sm">Open on flapping</Label>
266
+ <Tooltip content="Catches checks that flip in and out of unhealthy too quickly for the sustained trigger to fire." />
267
+ </div>
268
+ <Toggle
269
+ checked={policy.flappingTrigger.enabled}
270
+ onCheckedChange={(checked: boolean) =>
271
+ onChange({
272
+ ...policy,
273
+ flappingTrigger: {
274
+ ...policy.flappingTrigger,
275
+ enabled: checked,
276
+ },
277
+ })
278
+ }
279
+ disabled={disabled}
280
+ aria-label="Enable flapping trigger"
281
+ />
282
+ </div>
283
+ {policy.flappingTrigger.enabled && (
284
+ <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
285
+ <span>Open after</span>
286
+ <Input
287
+ type="number"
288
+ min={1}
289
+ className="h-8 w-16 text-center"
290
+ value={policy.flappingTrigger.transitions}
291
+ onChange={(e) =>
292
+ onChange({
293
+ ...policy,
294
+ flappingTrigger: {
295
+ ...policy.flappingTrigger,
296
+ transitions:
297
+ Number.parseInt(e.target.value, 10) || 1,
298
+ },
299
+ })
300
+ }
301
+ disabled={disabled}
302
+ />
303
+ <span>transitions to unhealthy within</span>
304
+ <Input
305
+ type="number"
306
+ min={1}
307
+ className="h-8 w-16 text-center"
308
+ value={policy.flappingTrigger.windowMinutes}
309
+ onChange={(e) =>
310
+ onChange({
311
+ ...policy,
312
+ flappingTrigger: {
313
+ ...policy.flappingTrigger,
314
+ windowMinutes:
315
+ Number.parseInt(e.target.value, 10) || 1,
316
+ },
317
+ })
318
+ }
319
+ disabled={disabled}
320
+ />
321
+ <span>minutes</span>
322
+ </div>
323
+ )}
324
+ </div>
325
+
326
+ {/* Auto-close cooldown */}
327
+ <div className="space-y-2 pt-2 border-t border-border">
328
+ <div className="flex items-center gap-2">
329
+ <Label className="text-sm">Auto-close cooldown</Label>
330
+ <Tooltip content="Resolve the auto-incident once the system has stayed healthy for this long. Snapshotted per-incident at open time — later policy edits don't change in-flight incidents." />
331
+ </div>
332
+ <div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
333
+ <label className="inline-flex items-center gap-2 cursor-pointer">
334
+ <input
335
+ type="checkbox"
336
+ checked={policy.autoCloseAfterMinutes === null}
337
+ onChange={(e) =>
338
+ onChange({
339
+ ...policy,
340
+ autoCloseAfterMinutes: e.target.checked ? null : 30,
341
+ })
342
+ }
343
+ disabled={disabled}
344
+ />
345
+ <span>Never auto-close (manual resolve only)</span>
346
+ </label>
347
+ {policy.autoCloseAfterMinutes !== null && (
348
+ <div className="flex items-center gap-2">
349
+ <span>After</span>
350
+ <Input
351
+ type="number"
352
+ min={1}
353
+ className="h-8 w-16 text-center"
354
+ value={policy.autoCloseAfterMinutes}
355
+ onChange={(e) =>
356
+ onChange({
357
+ ...policy,
358
+ autoCloseAfterMinutes:
359
+ Number.parseInt(e.target.value, 10) || 1,
360
+ })
361
+ }
362
+ disabled={disabled}
363
+ />
364
+ <span>minutes of sustained healthy</span>
365
+ </div>
366
+ )}
367
+ </div>
368
+ </div>
369
+ </div>
370
+ )}
371
+ </div>
372
+
373
+ {/* Save button hides when the assignment is inheriting — there
374
+ is nothing to save. The Override button drives the transition
375
+ into edit mode. */}
376
+ {(!inheritanceMode || isOverridden) && (
377
+ <div className="flex justify-end pt-2 border-t">
378
+ <Button size="sm" onClick={onSave} disabled={disabled}>
379
+ {saving ? "Saving..." : "Save Notifications"}
380
+ </Button>
381
+ </div>
382
+ )}
383
+ </div>
384
+ );
385
+ };
@@ -0,0 +1,90 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import {
3
+ DEFAULT_NOTIFICATION_POLICY,
4
+ HealthCheckApi,
5
+ type NotificationPolicy,
6
+ } from "@checkstack/healthcheck-common";
7
+ import { usePluginClient } from "@checkstack/frontend-api";
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ DialogDescription,
14
+ LoadingSpinner,
15
+ useToast,
16
+ } from "@checkstack/ui";
17
+ import { extractErrorMessage } from "@checkstack/common";
18
+ import { NotificationsPanel } from "./NotificationsPanel";
19
+
20
+ interface PlatformDefaultsDialogProps {
21
+ open: boolean;
22
+ onOpenChange: (open: boolean) => void;
23
+ }
24
+
25
+ /**
26
+ * Modal editor for platform-wide notification defaults. Reuses the
27
+ * per-assignment NotificationsPanel because the shape is identical —
28
+ * the only difference is where it reads from and writes to.
29
+ *
30
+ * Once saved, every assignment with `notificationPolicy = null`
31
+ * (the "Use platform defaults" state) picks up the new values on the
32
+ * next read. In-flight auto-incidents are unaffected — their cooldown
33
+ * was snapshotted at open time.
34
+ */
35
+ export const PlatformDefaultsDialog: React.FC<PlatformDefaultsDialogProps> = ({
36
+ open,
37
+ onOpenChange,
38
+ }) => {
39
+ const client = usePluginClient(HealthCheckApi);
40
+ const toast = useToast();
41
+
42
+ const { data, isLoading, refetch } =
43
+ client.getPlatformNotificationDefaults.useQuery(undefined, {
44
+ enabled: open,
45
+ });
46
+
47
+ const setMutation = client.setPlatformNotificationDefaults.useMutation({
48
+ onSuccess: () => {
49
+ toast.success("Platform notification defaults saved");
50
+ void refetch();
51
+ onOpenChange(false);
52
+ },
53
+ onError: (error) =>
54
+ toast.error(extractErrorMessage(error, "Failed to save defaults")),
55
+ });
56
+
57
+ const [draft, setDraft] = useState<NotificationPolicy>(
58
+ DEFAULT_NOTIFICATION_POLICY,
59
+ );
60
+
61
+ useEffect(() => {
62
+ if (data) setDraft(data);
63
+ }, [data]);
64
+
65
+ return (
66
+ <Dialog open={open} onOpenChange={onOpenChange}>
67
+ <DialogContent size="lg">
68
+ <DialogHeader>
69
+ <DialogTitle>Platform notification defaults</DialogTitle>
70
+ <DialogDescription>
71
+ Edits here apply to every health-check assignment that is set
72
+ to &quot;Use platform defaults&quot;. Assignments with a custom
73
+ override are unaffected.
74
+ </DialogDescription>
75
+ </DialogHeader>
76
+
77
+ {isLoading ? (
78
+ <LoadingSpinner />
79
+ ) : (
80
+ <NotificationsPanel
81
+ policy={draft}
82
+ onChange={setDraft}
83
+ onSave={() => setMutation.mutate(draft)}
84
+ saving={setMutation.isPending}
85
+ />
86
+ )}
87
+ </DialogContent>
88
+ </Dialog>
89
+ );
90
+ };
@@ -70,6 +70,8 @@ export const CollectorSection: React.FC<CollectorSectionProps> = ({
70
70
  onValidChange={onValidChange}
71
71
  {...healthcheckScriptContext({
72
72
  collectorConfigSchema: collectorDef.configSchema,
73
+ // Surface the user's own `env` keys as `$`-completions.
74
+ customEnv: entry.config.env,
73
75
  })}
74
76
  />
75
77
  </div>