@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.
@@ -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
+ };
@@ -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: "general" | "thresholds" | "retention" | "execution";
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 "general" | "thresholds" | "retention" | "execution",
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 { HealthCheckList } from "../components/HealthCheckList";
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, extractErrorMessage} from "@checkstack/common";
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 { data: configurationsData, refetch: refetchConfigurations } =
48
- healthCheckClient.getConfigurations.useQuery({});
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.error(extractErrorMessage(error, "Failed to delete"));
109
+ toastError(toast, "Failed to delete health check", error);
76
110
  },
77
111
  });
78
112
 
79
- const pauseMutation = healthCheckClient.pauseConfiguration.useMutation({
80
- onSuccess: () => {
81
- void refetchConfigurations();
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
- toast.error(extractErrorMessage(error, "Failed to pause"));
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
- const resumeMutation = healthCheckClient.resumeConfiguration.useMutation({
89
- onSuccess: () => {
90
- void refetchConfigurations();
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
- toast.error(extractErrorMessage(error, "Failed to resume"));
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
- <HealthCheckList
149
- configurations={configurations}
150
- strategies={strategies}
151
- onEdit={handleEdit}
152
- onDelete={handleDelete}
153
- onPause={(id) => pauseMutation.mutate(id)}
154
- onResume={(id) => resumeMutation.mutate(id)}
155
- canManage={canManage}
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: 20 });
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="No health check runs found for this configuration."
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: 20 });
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({