@checkstack/healthcheck-frontend 0.19.4 → 0.19.5

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 CHANGED
@@ -1,5 +1,86 @@
1
1
  # @checkstack/healthcheck-frontend
2
2
 
3
+ ## 0.19.5
4
+
5
+ ### Patch Changes
6
+
7
+ - f23f3c9: Retrofit the highest-traffic configuration list tables
8
+ (`HealthCheckList`, `SloConfigPage`, and the integration
9
+ `DeliveryLogsPage`) onto the `ResponsiveTable` + `MobileCardList`
10
+ primitives from `@checkstack/ui`. On `sm` and up each page still
11
+ renders the unchanged 5- to 7-column table; below that breakpoint a
12
+ sibling stacked-card layout surfaces the same data with the resource
13
+ name + status badge at the top, secondary columns in a muted line, and
14
+ the existing action buttons in a right-aligned footer. The
15
+ `HealthCheckListSkeleton` placeholder mirrors both branches so the page
16
+ no longer jumps when data resolves. No business logic, column order,
17
+ or query inputs changed.
18
+ - f23f3c9: Establish the canonical optimistic-UI pattern for oRPC mutations
19
+ (`onMutate` snapshot / patch, `onError` rollback, `onSettled`
20
+ invalidate) and apply it to the two highest-frequency toggles where
21
+ perceived latency was most visible:
22
+
23
+ - `markAsRead` on the Notifications page — clicking the check on a
24
+ notification card now flips the read state immediately instead of
25
+ waiting for the round-trip.
26
+ - `pauseConfiguration` / `resumeConfiguration` on the Health Check
27
+ Config page — pause/resume now flip the row's badge instantly,
28
+ rolling back on server error.
29
+
30
+ The wrapper type for `useMutation` on each plugin client gained an
31
+ optional `TContext` generic so optimistic sites can return a snapshot
32
+ from `onMutate` and consume it in `onError` without `unknown` casts.
33
+ The runtime behaviour and the auto-invalidation on success are
34
+ unchanged; the change is additive on the type surface only.
35
+
36
+ Full pattern and "when NOT to use it" guidance live in
37
+ `docs/frontend/optimistic-updates.md`.
38
+
39
+ - f23f3c9: Gate decorative motion and blur effects behind
40
+ `usePerformance().isLowPower` on a focused set of high-traffic plugin
41
+ pages (Dashboard, Dependency map, System node, Notification bell,
42
+ Announcement banner / cards, Anomaly field overrides editor, SLO
43
+ attribution chart, Catalog droppable group). Hover scales, backdrop
44
+ blurs, `animate-pulse`/`animate-ping` accents, and entry transitions
45
+ now drop to static states on low-power devices; functional UX
46
+ transitions (Drawer/Dialog open-close, colour transitions) are left
47
+ alone.
48
+
49
+ Standardise the post-mutation error-toast voice on plugin pages by
50
+ migrating multi-clause `toast.error(extractErrorMessage(error, "Failed
51
+ to X"))` call sites onto the `toastError(toast, "Failed to X", error)`
52
+ helper from `@checkstack/ui`. The helper applies the canonical
53
+ `"action: message"` prefix and 100-character truncation in one place,
54
+ and the now-orphaned `extractErrorMessage` imports are dropped from
55
+ the affected files. No business logic or component APIs changed.
56
+
57
+ - f23f3c9: Standardise the empty / loading / error story on key list pages using
58
+ the shared `ListEmptyState`, `QueryErrorState`, and `Skeleton`
59
+ primitives from `@checkstack/ui`. Each affected page now branches
60
+ through the same `isLoading -> isError -> empty -> data` ladder, so
61
+ failed queries surface a retry-able inline error instead of silently
62
+ rendering an empty table, and loading states match the final layout
63
+ rather than flashing a generic spinner. No layout, business logic, or
64
+ query input shapes changed.
65
+ - Updated dependencies [f23f3c9]
66
+ - Updated dependencies [f23f3c9]
67
+ - Updated dependencies [f23f3c9]
68
+ - Updated dependencies [f23f3c9]
69
+ - Updated dependencies [f23f3c9]
70
+ - Updated dependencies [f23f3c9]
71
+ - @checkstack/common@0.11.0
72
+ - @checkstack/auth-frontend@0.6.5
73
+ - @checkstack/frontend-api@0.5.2
74
+ - @checkstack/dashboard-frontend@0.7.5
75
+ - @checkstack/gitops-frontend@0.4.5
76
+ - @checkstack/ui@1.10.0
77
+ - @checkstack/anomaly-common@1.2.2
78
+ - @checkstack/catalog-common@2.2.2
79
+ - @checkstack/healthcheck-common@1.1.2
80
+ - @checkstack/satellite-common@0.5.2
81
+ - @checkstack/tips-frontend@0.2.5
82
+ - @checkstack/signal-frontend@0.1.4
83
+
3
84
  ## 0.19.4
4
85
 
5
86
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-frontend",
3
- "version": "0.19.4",
3
+ "version": "0.19.5",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.tsx",
@@ -13,18 +13,18 @@
13
13
  "lint:code": "eslint . --max-warnings 0"
14
14
  },
15
15
  "dependencies": {
16
- "@checkstack/anomaly-common": "1.2.0",
17
- "@checkstack/auth-frontend": "0.6.3",
18
- "@checkstack/catalog-common": "2.2.0",
16
+ "@checkstack/anomaly-common": "1.2.1",
17
+ "@checkstack/auth-frontend": "0.6.4",
18
+ "@checkstack/catalog-common": "2.2.1",
19
19
  "@checkstack/common": "0.10.0",
20
- "@checkstack/dashboard-frontend": "0.7.3",
20
+ "@checkstack/dashboard-frontend": "0.7.4",
21
21
  "@checkstack/frontend-api": "0.5.1",
22
- "@checkstack/gitops-frontend": "0.4.3",
23
- "@checkstack/healthcheck-common": "1.1.0",
24
- "@checkstack/satellite-common": "0.5.0",
22
+ "@checkstack/gitops-frontend": "0.4.4",
23
+ "@checkstack/healthcheck-common": "1.1.1",
24
+ "@checkstack/satellite-common": "0.5.1",
25
25
  "@checkstack/signal-frontend": "0.1.3",
26
- "@checkstack/tips-frontend": "0.2.3",
27
- "@checkstack/ui": "1.8.3",
26
+ "@checkstack/tips-frontend": "0.2.4",
27
+ "@checkstack/ui": "1.9.0",
28
28
  "ajv": "^8.18.0",
29
29
  "ajv-formats": "^3.0.1",
30
30
  "date-fns": "^4.1.0",
@@ -12,6 +12,10 @@ import {
12
12
  TableRow,
13
13
  Button,
14
14
  Badge,
15
+ Skeleton,
16
+ ResponsiveTable,
17
+ MobileCardList,
18
+ Card,
15
19
  } from "@checkstack/ui";
16
20
  import { Trash2, Edit, Pause, Play } from "lucide-react";
17
21
  import { useProvenanceLock } from "@checkstack/gitops-frontend";
@@ -40,26 +44,20 @@ export const HealthCheckList: React.FC<HealthCheckListProps> = ({
40
44
  };
41
45
 
42
46
  return (
43
- <div className="rounded-md border bg-card">
44
- <Table>
45
- <TableHeader>
46
- <TableRow>
47
- <TableHead>Name</TableHead>
48
- <TableHead>Strategy</TableHead>
49
- <TableHead>Interval (s)</TableHead>
50
- <TableHead>Status</TableHead>
51
- <TableHead className="text-right">Actions</TableHead>
52
- </TableRow>
53
- </TableHeader>
54
- <TableBody>
55
- {configurations.length === 0 ? (
47
+ <>
48
+ <ResponsiveTable className="rounded-md border bg-card">
49
+ <Table>
50
+ <TableHeader>
56
51
  <TableRow>
57
- <TableCell colSpan={5} className="h-24 text-center">
58
- No health checks configured.
59
- </TableCell>
52
+ <TableHead>Name</TableHead>
53
+ <TableHead>Strategy</TableHead>
54
+ <TableHead>Interval (s)</TableHead>
55
+ <TableHead>Status</TableHead>
56
+ <TableHead className="text-right">Actions</TableHead>
60
57
  </TableRow>
61
- ) : (
62
- configurations.map((config) => (
58
+ </TableHeader>
59
+ <TableBody>
60
+ {configurations.map((config) => (
63
61
  <HealthCheckRow
64
62
  key={config.id}
65
63
  config={config}
@@ -70,11 +68,107 @@ export const HealthCheckList: React.FC<HealthCheckListProps> = ({
70
68
  onResume={onResume}
71
69
  canManage={canManage}
72
70
  />
73
- ))
74
- )}
75
- </TableBody>
76
- </Table>
77
- </div>
71
+ ))}
72
+ </TableBody>
73
+ </Table>
74
+ </ResponsiveTable>
75
+
76
+ <MobileCardList>
77
+ {configurations.map((config) => (
78
+ <HealthCheckMobileCard
79
+ key={config.id}
80
+ config={config}
81
+ strategyName={getStrategyName(config.strategyId)}
82
+ onEdit={onEdit}
83
+ onDelete={onDelete}
84
+ onPause={onPause}
85
+ onResume={onResume}
86
+ canManage={canManage}
87
+ />
88
+ ))}
89
+ </MobileCardList>
90
+ </>
91
+ );
92
+ };
93
+
94
+ interface HealthCheckListSkeletonProps {
95
+ /**
96
+ * Number of placeholder rows to render. Defaults to 4 so the skeleton
97
+ * roughly matches a typical first-page configuration list.
98
+ */
99
+ rows?: number;
100
+ }
101
+
102
+ /**
103
+ * HealthCheckListSkeleton mirrors the shape of {@link HealthCheckList} so
104
+ * the page doesn't jump on load. Renders the same table chrome with
105
+ * `Skeleton` placeholders in each cell on desktop, and a stacked card
106
+ * skeleton on mobile to mirror the {@link MobileCardList} layout.
107
+ */
108
+ export const HealthCheckListSkeleton: React.FC<
109
+ HealthCheckListSkeletonProps
110
+ > = ({ rows = 4 }) => {
111
+ return (
112
+ <>
113
+ <ResponsiveTable className="rounded-md border bg-card">
114
+ <Table>
115
+ <TableHeader>
116
+ <TableRow>
117
+ <TableHead>Name</TableHead>
118
+ <TableHead>Strategy</TableHead>
119
+ <TableHead>Interval (s)</TableHead>
120
+ <TableHead>Status</TableHead>
121
+ <TableHead className="text-right">Actions</TableHead>
122
+ </TableRow>
123
+ </TableHeader>
124
+ <TableBody>
125
+ {Array.from({ length: rows }, (_, index) => (
126
+ <TableRow key={index}>
127
+ <TableCell>
128
+ <Skeleton className="h-4 w-32" />
129
+ </TableCell>
130
+ <TableCell>
131
+ <Skeleton className="h-4 w-24" />
132
+ </TableCell>
133
+ <TableCell>
134
+ <Skeleton className="h-4 w-12" />
135
+ </TableCell>
136
+ <TableCell>
137
+ <Skeleton className="h-5 w-16 rounded-full" />
138
+ </TableCell>
139
+ <TableCell className="text-right">
140
+ <div className="flex justify-end gap-2">
141
+ <Skeleton className="h-8 w-8" />
142
+ <Skeleton className="h-8 w-8" />
143
+ <Skeleton className="h-8 w-8" />
144
+ </div>
145
+ </TableCell>
146
+ </TableRow>
147
+ ))}
148
+ </TableBody>
149
+ </Table>
150
+ </ResponsiveTable>
151
+
152
+ <MobileCardList>
153
+ {Array.from({ length: rows }, (_, index) => (
154
+ <Card key={index} className="p-3">
155
+ <div className="flex items-center justify-between gap-2">
156
+ <Skeleton className="h-4 w-32" />
157
+ <Skeleton className="h-5 w-16 rounded-full" />
158
+ </div>
159
+ <div className="mt-2 flex items-center gap-2">
160
+ <Skeleton className="h-3 w-24" />
161
+ <Skeleton className="h-3 w-12" />
162
+ </div>
163
+ <div className="mt-3 flex justify-end gap-2">
164
+ <Skeleton className="h-8 w-8" />
165
+ <Skeleton className="h-8 w-8" />
166
+ <Skeleton className="h-8 w-8" />
167
+ </div>
168
+ </Card>
169
+ ))}
170
+ </MobileCardList>
171
+ </>
78
172
  );
79
173
  };
80
174
 
@@ -115,57 +209,139 @@ const HealthCheckRow: React.FC<HealthCheckRowProps> = ({
115
209
  )}
116
210
  </TableCell>
117
211
  <TableCell className="text-right">
118
- <div className="flex justify-end gap-2">
119
- {canManage &&
120
- onPause &&
121
- onResume &&
122
- (config.paused ? (
123
- <Button
124
- variant="ghost"
125
- size="icon"
126
- onClick={() => onResume(config.id)}
127
- title={isLocked ? "Managed by GitOps" : "Resume health check"}
128
- disabled={isLocked}
129
- >
130
- <Play className="h-4 w-4" />
131
- </Button>
132
- ) : (
133
- <Button
134
- variant="ghost"
135
- size="icon"
136
- onClick={() => onPause(config.id)}
137
- title={isLocked ? "Managed by GitOps" : "Pause health check"}
138
- disabled={isLocked}
139
- >
140
- <Pause className="h-4 w-4" />
141
- </Button>
142
- ))}
143
- <Button
144
- variant="ghost"
145
- size="icon"
146
- onClick={() => onEdit(config)}
147
- title={
148
- isLocked
149
- ? "View configuration (Managed by GitOps)"
150
- : "Edit configuration"
151
- }
152
- >
153
- <Edit className="h-4 w-4" />
154
- </Button>
155
- {canManage && (
156
- <Button
157
- variant="ghost"
158
- size="icon"
159
- className="text-destructive hover:text-destructive"
160
- onClick={() => onDelete(config.id)}
161
- disabled={isLocked}
162
- title={isLocked ? "Managed by GitOps" : "Delete configuration"}
163
- >
164
- <Trash2 className="h-4 w-4" />
165
- </Button>
166
- )}
167
- </div>
212
+ <HealthCheckActionButtons
213
+ config={config}
214
+ isLocked={isLocked}
215
+ onEdit={onEdit}
216
+ onDelete={onDelete}
217
+ onPause={onPause}
218
+ onResume={onResume}
219
+ canManage={canManage}
220
+ />
168
221
  </TableCell>
169
222
  </TableRow>
170
223
  );
171
224
  };
225
+
226
+ interface HealthCheckMobileCardProps {
227
+ config: HealthCheckConfiguration;
228
+ strategyName: string;
229
+ onEdit: (config: HealthCheckConfiguration) => void;
230
+ onDelete: (id: string) => void;
231
+ onPause?: (id: string) => void;
232
+ onResume?: (id: string) => void;
233
+ canManage: boolean;
234
+ }
235
+
236
+ const HealthCheckMobileCard: React.FC<HealthCheckMobileCardProps> = ({
237
+ config,
238
+ strategyName,
239
+ onEdit,
240
+ onDelete,
241
+ onPause,
242
+ onResume,
243
+ canManage,
244
+ }) => {
245
+ const { isLocked } = useProvenanceLock({
246
+ kind: "Healthcheck",
247
+ entityId: config.id,
248
+ });
249
+
250
+ return (
251
+ <Card className={`p-3 ${config.paused ? "opacity-60" : ""}`}>
252
+ <div className="flex items-start justify-between gap-2">
253
+ <span className="font-medium truncate">{config.name}</span>
254
+ {config.paused ? (
255
+ <Badge variant="secondary">Paused</Badge>
256
+ ) : (
257
+ <Badge variant="default">Active</Badge>
258
+ )}
259
+ </div>
260
+ <div className="mt-1 text-xs text-muted-foreground">
261
+ {strategyName} &middot; every {config.intervalSeconds}s
262
+ </div>
263
+ <div className="mt-3 flex justify-end gap-2">
264
+ <HealthCheckActionButtons
265
+ config={config}
266
+ isLocked={isLocked}
267
+ onEdit={onEdit}
268
+ onDelete={onDelete}
269
+ onPause={onPause}
270
+ onResume={onResume}
271
+ canManage={canManage}
272
+ />
273
+ </div>
274
+ </Card>
275
+ );
276
+ };
277
+
278
+ interface HealthCheckActionButtonsProps {
279
+ config: HealthCheckConfiguration;
280
+ isLocked: boolean;
281
+ onEdit: (config: HealthCheckConfiguration) => void;
282
+ onDelete: (id: string) => void;
283
+ onPause?: (id: string) => void;
284
+ onResume?: (id: string) => void;
285
+ canManage: boolean;
286
+ }
287
+
288
+ const HealthCheckActionButtons: React.FC<HealthCheckActionButtonsProps> = ({
289
+ config,
290
+ isLocked,
291
+ onEdit,
292
+ onDelete,
293
+ onPause,
294
+ onResume,
295
+ canManage,
296
+ }) => (
297
+ <div className="flex justify-end gap-2">
298
+ {canManage &&
299
+ onPause &&
300
+ onResume &&
301
+ (config.paused ? (
302
+ <Button
303
+ variant="ghost"
304
+ size="icon"
305
+ onClick={() => onResume(config.id)}
306
+ title={isLocked ? "Managed by GitOps" : "Resume health check"}
307
+ disabled={isLocked}
308
+ >
309
+ <Play className="h-4 w-4" />
310
+ </Button>
311
+ ) : (
312
+ <Button
313
+ variant="ghost"
314
+ size="icon"
315
+ onClick={() => onPause(config.id)}
316
+ title={isLocked ? "Managed by GitOps" : "Pause health check"}
317
+ disabled={isLocked}
318
+ >
319
+ <Pause className="h-4 w-4" />
320
+ </Button>
321
+ ))}
322
+ <Button
323
+ variant="ghost"
324
+ size="icon"
325
+ onClick={() => onEdit(config)}
326
+ title={
327
+ isLocked
328
+ ? "View configuration (Managed by GitOps)"
329
+ : "Edit configuration"
330
+ }
331
+ >
332
+ <Edit className="h-4 w-4" />
333
+ </Button>
334
+ {canManage && (
335
+ <Button
336
+ variant="ghost"
337
+ size="icon"
338
+ className="text-destructive hover:text-destructive"
339
+ onClick={() => onDelete(config.id)}
340
+ disabled={isLocked}
341
+ title={isLocked ? "Managed by GitOps" : "Delete configuration"}
342
+ >
343
+ <Trash2 className="h-4 w-4" />
344
+ </Button>
345
+ )}
346
+ </div>
347
+ );
@@ -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}