@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
|
@@ -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
|
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { useEffect } from "react";
|
|
1
|
+
import { useEffect, useMemo } from "react";
|
|
2
2
|
import { useSearchParams, useNavigate } from "react-router-dom";
|
|
3
3
|
import {
|
|
4
4
|
usePluginClient,
|
|
5
|
+
useQueryClient,
|
|
5
6
|
wrapInSuspense,
|
|
6
7
|
accessApiRef,
|
|
7
8
|
useApi,
|
|
@@ -14,20 +15,36 @@ import {
|
|
|
14
15
|
pluginMetadata as healthcheckPluginMetadata,
|
|
15
16
|
} from "@checkstack/healthcheck-common";
|
|
16
17
|
import { Tip } from "@checkstack/tips-frontend";
|
|
17
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
HealthCheckList,
|
|
20
|
+
HealthCheckListSkeleton,
|
|
21
|
+
} from "../components/HealthCheckList";
|
|
18
22
|
import {
|
|
19
23
|
Button,
|
|
20
24
|
ConfirmationModal,
|
|
25
|
+
ListEmptyState,
|
|
21
26
|
PageLayout,
|
|
27
|
+
QueryErrorState,
|
|
22
28
|
useToast,
|
|
29
|
+
toastError,
|
|
23
30
|
} from "@checkstack/ui";
|
|
24
31
|
import { Plus, History, Activity } from "lucide-react";
|
|
25
32
|
import { Link } from "react-router-dom";
|
|
26
|
-
import { resolveRoute
|
|
33
|
+
import { resolveRoute } from "@checkstack/common";
|
|
27
34
|
import { useState } from "react";
|
|
28
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Shape of the `healthcheck.getConfigurations` query output. Threaded
|
|
38
|
+
* through the optimistic pause/resume patches so cache reads/writes
|
|
39
|
+
* match the loader's surface.
|
|
40
|
+
*/
|
|
41
|
+
type ConfigurationsQueryData = {
|
|
42
|
+
configurations: HealthCheckConfiguration[];
|
|
43
|
+
};
|
|
44
|
+
|
|
29
45
|
const HealthCheckConfigPageContent = () => {
|
|
30
46
|
const healthCheckClient = usePluginClient(HealthCheckApi);
|
|
47
|
+
const queryClient = useQueryClient();
|
|
31
48
|
const accessApi = useApi(accessApiRef);
|
|
32
49
|
const toast = useToast();
|
|
33
50
|
const navigate = useNavigate();
|
|
@@ -43,9 +60,26 @@ const HealthCheckConfigPageContent = () => {
|
|
|
43
60
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
|
44
61
|
const [idToDelete, setIdToDelete] = useState<string | undefined>();
|
|
45
62
|
|
|
63
|
+
// Mirrors oRPC's `generateOperationKey([path], { type, input })` for
|
|
64
|
+
// the parameterless `getConfigurations` loader. Captured in a memo so
|
|
65
|
+
// the pause/resume optimistic patches address the exact same cache
|
|
66
|
+
// entry the loader writes. See `docs/frontend/optimistic-updates.md`
|
|
67
|
+
// for the query-key contract.
|
|
68
|
+
const configurationsQueryKey = useMemo(
|
|
69
|
+
() =>
|
|
70
|
+
[
|
|
71
|
+
["healthcheck", "getConfigurations"],
|
|
72
|
+
{ input: {}, type: "query" },
|
|
73
|
+
] as const,
|
|
74
|
+
[],
|
|
75
|
+
);
|
|
76
|
+
|
|
46
77
|
// Fetch configurations with useQuery
|
|
47
|
-
const
|
|
48
|
-
|
|
78
|
+
const configurationsQuery = healthCheckClient.getConfigurations.useQuery({});
|
|
79
|
+
const {
|
|
80
|
+
data: configurationsData,
|
|
81
|
+
refetch: refetchConfigurations,
|
|
82
|
+
} = configurationsQuery;
|
|
49
83
|
|
|
50
84
|
// Fetch strategies with useQuery
|
|
51
85
|
const { data: strategies = [] } = healthCheckClient.getStrategies.useQuery(
|
|
@@ -72,25 +106,85 @@ const HealthCheckConfigPageContent = () => {
|
|
|
72
106
|
void refetchConfigurations();
|
|
73
107
|
},
|
|
74
108
|
onError: (error) => {
|
|
75
|
-
toast
|
|
109
|
+
toastError(toast, "Failed to delete health check", error);
|
|
76
110
|
},
|
|
77
111
|
});
|
|
78
112
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
113
|
+
// Mutation: Pause configuration — optimistic.
|
|
114
|
+
//
|
|
115
|
+
// Toggle, low risk; same four-step pattern as `markAsRead` on the
|
|
116
|
+
// notifications page (see `docs/frontend/optimistic-updates.md`):
|
|
117
|
+
// 1. onMutate flips `paused: true` on the matching row in the cache.
|
|
118
|
+
// 2. onError rolls back from the snapshot, then surfaces a toast.
|
|
119
|
+
// 3. onSettled invalidates so server truth settles in either branch.
|
|
120
|
+
// 4. No success toast — the row's pause badge IS the feedback.
|
|
121
|
+
const pauseMutation = healthCheckClient.pauseConfiguration.useMutation<{
|
|
122
|
+
previous: ConfigurationsQueryData | undefined;
|
|
123
|
+
}>({
|
|
124
|
+
onMutate: async (configId) => {
|
|
125
|
+
await queryClient.cancelQueries({ queryKey: configurationsQueryKey });
|
|
126
|
+
const previous = queryClient.getQueryData<ConfigurationsQueryData>(
|
|
127
|
+
configurationsQueryKey,
|
|
128
|
+
);
|
|
129
|
+
if (previous) {
|
|
130
|
+
queryClient.setQueryData<ConfigurationsQueryData>(
|
|
131
|
+
configurationsQueryKey,
|
|
132
|
+
{
|
|
133
|
+
...previous,
|
|
134
|
+
configurations: previous.configurations.map((c) =>
|
|
135
|
+
c.id === configId ? { ...c, paused: true } : c,
|
|
136
|
+
),
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return { previous };
|
|
82
141
|
},
|
|
83
|
-
onError: (error) => {
|
|
84
|
-
|
|
142
|
+
onError: (error, _vars, ctx) => {
|
|
143
|
+
if (ctx?.previous) {
|
|
144
|
+
queryClient.setQueryData(configurationsQueryKey, ctx.previous);
|
|
145
|
+
}
|
|
146
|
+
toastError(toast, "Failed to pause health check", error);
|
|
147
|
+
},
|
|
148
|
+
onSettled: () => {
|
|
149
|
+
void queryClient.invalidateQueries({
|
|
150
|
+
queryKey: configurationsQueryKey,
|
|
151
|
+
});
|
|
85
152
|
},
|
|
86
153
|
});
|
|
87
154
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
155
|
+
// Mutation: Resume configuration — optimistic. Mirror of `pause`
|
|
156
|
+
// with `paused: false`. See `pauseMutation` above for the contract.
|
|
157
|
+
const resumeMutation = healthCheckClient.resumeConfiguration.useMutation<{
|
|
158
|
+
previous: ConfigurationsQueryData | undefined;
|
|
159
|
+
}>({
|
|
160
|
+
onMutate: async (configId) => {
|
|
161
|
+
await queryClient.cancelQueries({ queryKey: configurationsQueryKey });
|
|
162
|
+
const previous = queryClient.getQueryData<ConfigurationsQueryData>(
|
|
163
|
+
configurationsQueryKey,
|
|
164
|
+
);
|
|
165
|
+
if (previous) {
|
|
166
|
+
queryClient.setQueryData<ConfigurationsQueryData>(
|
|
167
|
+
configurationsQueryKey,
|
|
168
|
+
{
|
|
169
|
+
...previous,
|
|
170
|
+
configurations: previous.configurations.map((c) =>
|
|
171
|
+
c.id === configId ? { ...c, paused: false } : c,
|
|
172
|
+
),
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return { previous };
|
|
91
177
|
},
|
|
92
|
-
onError: (error) => {
|
|
93
|
-
|
|
178
|
+
onError: (error, _vars, ctx) => {
|
|
179
|
+
if (ctx?.previous) {
|
|
180
|
+
queryClient.setQueryData(configurationsQueryKey, ctx.previous);
|
|
181
|
+
}
|
|
182
|
+
toastError(toast, "Failed to resume health check", error);
|
|
183
|
+
},
|
|
184
|
+
onSettled: () => {
|
|
185
|
+
void queryClient.invalidateQueries({
|
|
186
|
+
queryKey: configurationsQueryKey,
|
|
187
|
+
});
|
|
94
188
|
},
|
|
95
189
|
});
|
|
96
190
|
|
|
@@ -145,15 +239,30 @@ const HealthCheckConfigPageContent = () => {
|
|
|
145
239
|
</div>
|
|
146
240
|
}
|
|
147
241
|
>
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
242
|
+
{configurationsQuery.isLoading ? (
|
|
243
|
+
<HealthCheckListSkeleton />
|
|
244
|
+
) : configurationsQuery.isError ? (
|
|
245
|
+
<QueryErrorState
|
|
246
|
+
error={configurationsQuery.error}
|
|
247
|
+
onRetry={() => void configurationsQuery.refetch()}
|
|
248
|
+
resource="health checks"
|
|
249
|
+
/>
|
|
250
|
+
) : configurations.length === 0 ? (
|
|
251
|
+
<ListEmptyState
|
|
252
|
+
resource="health checks"
|
|
253
|
+
description="No health checks have been configured yet. Create one to start monitoring a system."
|
|
254
|
+
/>
|
|
255
|
+
) : (
|
|
256
|
+
<HealthCheckList
|
|
257
|
+
configurations={configurations}
|
|
258
|
+
strategies={strategies}
|
|
259
|
+
onEdit={handleEdit}
|
|
260
|
+
onDelete={handleDelete}
|
|
261
|
+
onPause={(id) => pauseMutation.mutate(id)}
|
|
262
|
+
onResume={(id) => resumeMutation.mutate(id)}
|
|
263
|
+
canManage={canManage}
|
|
264
|
+
/>
|
|
265
|
+
)}
|
|
157
266
|
|
|
158
267
|
<ConfirmationModal
|
|
159
268
|
isOpen={isDeleteModalOpen}
|
|
@@ -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({
|