@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 CHANGED
@@ -1,5 +1,121 @@
1
1
  # @checkstack/healthcheck-frontend
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ba07ae2: Quiet down notification spam on flapping systems, auto-open incidents when a check goes critical, and let operators land directly on the broken checks.
8
+
9
+ Notification policy lives **per healthcheck assignment** (one row per `system × configuration`). Different checks on the same system are fully independent — disabling a setting on one check does not affect the others. Defaults preserve existing behaviour for `suppressDeEscalations`; **auto-incident defaults to on** for new and existing assignments.
10
+
11
+ - **`suppressDeEscalations`** (off by default). When on, transitions from a worse state to a better-but-still-failing state (e.g. `unhealthy → degraded`) no longer fire a notification. Escalations and full recoveries to `healthy` are unaffected. Resolved per assignment (the just-ran check is the one driving any aggregate transition).
12
+ - **`autoOpenIncidentOnUnhealthy`** (on by default). Either of two independent triggers can open the auto-incident:
13
+ - **`sustainedUnhealthyTrigger`** (default 30 min) — opens when the check stays continuously unhealthy for the configured duration. Catches real outages.
14
+ - **`flappingTrigger`** (default 3 transitions in 60 min) — opens when the check flips to unhealthy that many times in the window. Catches persistent flapping where each unhealthy phase is too brief for the sustained trigger.
15
+ Each trigger can be individually disabled. One incident per system: triggering checks attach to an existing active auto-incident.
16
+ - **`useNotificationSuppression`** (on by default, only meaningful when auto-open is on). Controls whether the auto-opened incident is created with `suppressNotifications: true` — leaving this off opens the incident but still pings operators on each transition.
17
+ - **`skipDuringMaintenance`** (on by default). No auto-incident is opened while the system has an active maintenance window with suppression. The system is intentionally down and shouldn't trip the on-call.
18
+ - **`autoCloseAfterMinutes`** (default 30). Auto-close cooldown is now per-assignment and snapshotted per-incident at open time — later policy edits don't alter in-flight incidents. Setting `null` ("Never auto-close") leaves the incident for manual resolution.
19
+ - **Require-recovery rule.** After any auto-incident closes (manual or auto), no new auto-incident can open until the check has logged at least one healthy run. Prevents a "operator dismissed but it's still broken" loop.
20
+ - **Auto-close worker** ticks every 60s and resolves auto-opened incidents whose systems have been healthy for their per-row `cooldownMinutes`. Rows with `null` cooldown are skipped entirely. Per-incident: failed close attempts are logged but never abort the sweep.
21
+ - **`incidentResolved` hook subscriber** syncs the auto-incident mapping when an operator manually resolves the incident, so the require-recovery rule sees the close immediately.
22
+ - **Platform-wide defaults.** New admin RPCs `getPlatformNotificationDefaults` / `setPlatformNotificationDefaults` (under the existing `healthcheck.configuration.{read,manage}` access rules) let operators set notification policy once for the whole instance. Per-assignment rows with `notificationPolicy: null` inherit the platform defaults at read time. UI: a "Notification defaults" button in the Assignment IDE opens a modal editor. The per-assignment Notifications panel shows an inheritance banner — "Using platform defaults" (read-only) with an "Override" button, or "Custom override" with a "Use platform defaults" button to revert. The all-or-nothing model keeps the mental model simple: each assignment is either fully inherited or fully overridden.
23
+ - **New service-level RPCs** on the incident plugin (`createAutoIncident`, `resolveAutoIncident`) let other plugins open/close incidents without a user context. Reused by the healthcheck auto-incident flow.
24
+ - **Health-state notification CTA** now deep-links to `?filter=failing` on the system detail page for non-recovery transitions (label changes to "View failing checks"). The system overview gains an `All / Failing / Healthy` segmented filter wired to the same `?filter=…` param.
25
+ - **Notification bell badge** now counts collapse groups instead of raw rows, so the number matches what the user sees in the notifications list. Built on `COUNT(DISTINCT COALESCE(collapse_key, id))` — notifications without a collapse key still each count as one.
26
+ - **`statusFilter` on `getHistory` / `getDetailedHistory`** lets the run-history page and the drawer's Recent Runs panel filter to `All / Healthy / Failing` via shared pills, with the page resetting to the first page on filter change.
27
+ - **Pagination defaults aligned with selector options.** Several pages defaulted to a page size (5 or 20) that wasn't in the dropdown's options (`[10, 25, 50, 100]`), so the page-size `<Select>` rendered empty. The drawer's Recent Runs now defaults to 10; the Run History, History List, and Delivery Logs pages now default to 25.
28
+
29
+ Includes Drizzle migrations adding the `notification_policy` jsonb column to `system_health_checks`, plus two new tables: `health_check_unhealthy_transitions` (for threshold counting) and `health_check_auto_incidents` (for mapping back to incident ids during auto-close).
30
+
31
+ ### Patch Changes
32
+
33
+ - Updated dependencies [ba07ae2]
34
+ - @checkstack/healthcheck-common@1.2.0
35
+ - @checkstack/dashboard-frontend@0.7.6
36
+ - @checkstack/satellite-common@0.5.3
37
+
38
+ ## 0.19.5
39
+
40
+ ### Patch Changes
41
+
42
+ - f23f3c9: Retrofit the highest-traffic configuration list tables
43
+ (`HealthCheckList`, `SloConfigPage`, and the integration
44
+ `DeliveryLogsPage`) onto the `ResponsiveTable` + `MobileCardList`
45
+ primitives from `@checkstack/ui`. On `sm` and up each page still
46
+ renders the unchanged 5- to 7-column table; below that breakpoint a
47
+ sibling stacked-card layout surfaces the same data with the resource
48
+ name + status badge at the top, secondary columns in a muted line, and
49
+ the existing action buttons in a right-aligned footer. The
50
+ `HealthCheckListSkeleton` placeholder mirrors both branches so the page
51
+ no longer jumps when data resolves. No business logic, column order,
52
+ or query inputs changed.
53
+ - f23f3c9: Establish the canonical optimistic-UI pattern for oRPC mutations
54
+ (`onMutate` snapshot / patch, `onError` rollback, `onSettled`
55
+ invalidate) and apply it to the two highest-frequency toggles where
56
+ perceived latency was most visible:
57
+
58
+ - `markAsRead` on the Notifications page — clicking the check on a
59
+ notification card now flips the read state immediately instead of
60
+ waiting for the round-trip.
61
+ - `pauseConfiguration` / `resumeConfiguration` on the Health Check
62
+ Config page — pause/resume now flip the row's badge instantly,
63
+ rolling back on server error.
64
+
65
+ The wrapper type for `useMutation` on each plugin client gained an
66
+ optional `TContext` generic so optimistic sites can return a snapshot
67
+ from `onMutate` and consume it in `onError` without `unknown` casts.
68
+ The runtime behaviour and the auto-invalidation on success are
69
+ unchanged; the change is additive on the type surface only.
70
+
71
+ Full pattern and "when NOT to use it" guidance live in
72
+ `docs/frontend/optimistic-updates.md`.
73
+
74
+ - f23f3c9: Gate decorative motion and blur effects behind
75
+ `usePerformance().isLowPower` on a focused set of high-traffic plugin
76
+ pages (Dashboard, Dependency map, System node, Notification bell,
77
+ Announcement banner / cards, Anomaly field overrides editor, SLO
78
+ attribution chart, Catalog droppable group). Hover scales, backdrop
79
+ blurs, `animate-pulse`/`animate-ping` accents, and entry transitions
80
+ now drop to static states on low-power devices; functional UX
81
+ transitions (Drawer/Dialog open-close, colour transitions) are left
82
+ alone.
83
+
84
+ Standardise the post-mutation error-toast voice on plugin pages by
85
+ migrating multi-clause `toast.error(extractErrorMessage(error, "Failed
86
+ to X"))` call sites onto the `toastError(toast, "Failed to X", error)`
87
+ helper from `@checkstack/ui`. The helper applies the canonical
88
+ `"action: message"` prefix and 100-character truncation in one place,
89
+ and the now-orphaned `extractErrorMessage` imports are dropped from
90
+ the affected files. No business logic or component APIs changed.
91
+
92
+ - f23f3c9: Standardise the empty / loading / error story on key list pages using
93
+ the shared `ListEmptyState`, `QueryErrorState`, and `Skeleton`
94
+ primitives from `@checkstack/ui`. Each affected page now branches
95
+ through the same `isLoading -> isError -> empty -> data` ladder, so
96
+ failed queries surface a retry-able inline error instead of silently
97
+ rendering an empty table, and loading states match the final layout
98
+ rather than flashing a generic spinner. No layout, business logic, or
99
+ query input shapes changed.
100
+ - Updated dependencies [f23f3c9]
101
+ - Updated dependencies [f23f3c9]
102
+ - Updated dependencies [f23f3c9]
103
+ - Updated dependencies [f23f3c9]
104
+ - Updated dependencies [f23f3c9]
105
+ - Updated dependencies [f23f3c9]
106
+ - @checkstack/common@0.11.0
107
+ - @checkstack/auth-frontend@0.6.5
108
+ - @checkstack/frontend-api@0.5.2
109
+ - @checkstack/dashboard-frontend@0.7.5
110
+ - @checkstack/gitops-frontend@0.4.5
111
+ - @checkstack/ui@1.10.0
112
+ - @checkstack/anomaly-common@1.2.2
113
+ - @checkstack/catalog-common@2.2.2
114
+ - @checkstack/healthcheck-common@1.1.2
115
+ - @checkstack/satellite-common@0.5.2
116
+ - @checkstack/tips-frontend@0.2.5
117
+ - @checkstack/signal-frontend@0.1.4
118
+
3
119
  ## 0.19.4
4
120
 
5
121
  ### 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.20.0",
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",
19
- "@checkstack/common": "0.10.0",
20
- "@checkstack/dashboard-frontend": "0.7.3",
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",
25
- "@checkstack/signal-frontend": "0.1.3",
26
- "@checkstack/tips-frontend": "0.2.3",
27
- "@checkstack/ui": "1.8.3",
16
+ "@checkstack/anomaly-common": "1.2.2",
17
+ "@checkstack/auth-frontend": "0.6.5",
18
+ "@checkstack/catalog-common": "2.2.2",
19
+ "@checkstack/common": "0.11.0",
20
+ "@checkstack/dashboard-frontend": "0.7.5",
21
+ "@checkstack/frontend-api": "0.5.2",
22
+ "@checkstack/gitops-frontend": "0.4.5",
23
+ "@checkstack/healthcheck-common": "1.1.2",
24
+ "@checkstack/satellite-common": "0.5.2",
25
+ "@checkstack/signal-frontend": "0.1.4",
26
+ "@checkstack/tips-frontend": "0.2.5",
27
+ "@checkstack/ui": "1.10.0",
28
28
  "ajv": "^8.18.0",
29
29
  "ajv-formats": "^3.0.1",
30
30
  "date-fns": "^4.1.0",
@@ -36,7 +36,7 @@
36
36
  "zod": "^4.2.1"
37
37
  },
38
38
  "devDependencies": {
39
- "@checkstack/scripts": "0.3.2",
39
+ "@checkstack/scripts": "0.3.3",
40
40
  "@checkstack/tsconfig": "0.0.7",
41
41
  "@types/react": "^18.2.0",
42
42
  "typescript": "^5.0.0"
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+ import { TableRow, TableCell } from "@checkstack/ui";
3
+
4
+ interface EmptyRunsTableRowProps {
5
+ /** Number of columns in the parent table (drives `colSpan`). */
6
+ colSpan: number;
7
+ /** Message to display in the empty cell. Plain strings or rich nodes. */
8
+ children: React.ReactNode;
9
+ }
10
+
11
+ /**
12
+ * Single-row empty state rendered inside a runs table body. Centralises
13
+ * the look so the drawer's Recent Runs and the Run History page can
14
+ * share the same in-table empty state.
15
+ */
16
+ export function EmptyRunsTableRow({ colSpan, children }: EmptyRunsTableRowProps) {
17
+ return (
18
+ <TableRow>
19
+ <TableCell
20
+ colSpan={colSpan}
21
+ className="text-center text-xs text-muted-foreground py-6"
22
+ >
23
+ {children}
24
+ </TableCell>
25
+ </TableRow>
26
+ );
27
+ }
@@ -56,6 +56,12 @@ import { HealthCheckLatencyChart } from "./HealthCheckLatencyChart";
56
56
  import { useHealthCheckData } from "../hooks/useHealthCheckData";
57
57
  import { AggregatedDataBanner } from "./AggregatedDataBanner";
58
58
  import { HealthCheckDiagramSlot } from "../slots";
59
+ import {
60
+ StatusFilterPills,
61
+ STATUS_FILTER_TO_STATUSES,
62
+ type StatusFilter,
63
+ } from "./StatusFilterPills";
64
+ import { EmptyRunsTableRow } from "./EmptyRunsTableRow";
59
65
  import { Heart, Clock, CheckCircle, AlertTriangle } from "lucide-react";
60
66
 
61
67
  import type {
@@ -130,6 +136,8 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
130
136
  );
131
137
  const [isRollingPreset, setIsRollingPreset] = useState(true);
132
138
  const [sourceFilter, setSourceFilter] = useState<string | undefined>();
139
+ const [runsStatusFilter, setRunsStatusFilter] =
140
+ useState<StatusFilter>("all");
133
141
 
134
142
  const activePreset = detectPreset(dateRange);
135
143
  const activePresetLabel = PRESETS.find((p) => p.id === activePreset)?.shortLabel ?? "Custom";
@@ -195,7 +203,7 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
195
203
  );
196
204
 
197
205
  // Pagination for history table
198
- const pagination = usePagination({ defaultLimit: 5 });
206
+ const pagination = usePagination({ defaultLimit: 10 });
199
207
 
200
208
  const { data: historyData, isLoading: historyLoading } =
201
209
  healthCheckClient.getHistory.useQuery({
@@ -205,6 +213,7 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
205
213
  offset: pagination.offset,
206
214
  startDate: dateRange.startDate,
207
215
  sourceFilter,
216
+ statusFilter: STATUS_FILTER_TO_STATUSES[runsStatusFilter],
208
217
  sortOrder: "desc",
209
218
  });
210
219
 
@@ -437,7 +446,7 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
437
446
  </div>
438
447
 
439
448
  {/* Zone 3 — Recent Runs */}
440
- {runs.length > 0 && (
449
+ {(runs.length > 0 || runsStatusFilter !== "all") && (
441
450
  <div className="space-y-3">
442
451
  <div className="flex items-center gap-4">
443
452
  <div className="flex-1 h-px bg-border" />
@@ -450,6 +459,16 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
450
459
  <div className="flex-1 h-px bg-border" />
451
460
  </div>
452
461
 
462
+ <div className="flex justify-end">
463
+ <StatusFilterPills
464
+ value={runsStatusFilter}
465
+ onChange={(next) => {
466
+ setRunsStatusFilter(next);
467
+ pagination.setPage(1);
468
+ }}
469
+ />
470
+ </div>
471
+
453
472
  <div className="rounded-md border">
454
473
  <Table>
455
474
  <TableHeader>
@@ -460,6 +479,13 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
460
479
  </TableRow>
461
480
  </TableHeader>
462
481
  <TableBody>
482
+ {runs.length === 0 && !historyLoading && (
483
+ <EmptyRunsTableRow colSpan={3}>
484
+ No runs match the{" "}
485
+ <span className="font-medium">{runsStatusFilter}</span>{" "}
486
+ filter.
487
+ </EmptyRunsTableRow>
488
+ )}
463
489
  {runs.map((run) => (
464
490
  <TableRow
465
491
  key={run.id}
@@ -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
+ );
@@ -14,6 +14,7 @@ import { ExternalLink, Loader2, Satellite, Server } from "lucide-react";
14
14
  import { useNavigate } from "react-router-dom";
15
15
  import { healthcheckRoutes } from "@checkstack/healthcheck-common";
16
16
  import { resolveRoute } from "@checkstack/common";
17
+ import { EmptyRunsTableRow } from "./EmptyRunsTableRow";
17
18
 
18
19
  export interface HealthCheckRunDetailed {
19
20
  id: string;
@@ -64,10 +65,6 @@ export const HealthCheckRunsTable: React.FC<HealthCheckRunsTableProps> = ({
64
65
  prevRunsRef.current = runs;
65
66
  }
66
67
 
67
- if (!loading && runs.length === 0 && prevRunsRef.current.length === 0) {
68
- return <p className="text-muted-foreground text-sm">{emptyMessage}</p>;
69
- }
70
-
71
68
  const handleRowClick = (run: HealthCheckRunDetailed) => {
72
69
  navigate(
73
70
  resolveRoute(healthcheckRoutes.routes.historyRun, {
@@ -78,6 +75,11 @@ export const HealthCheckRunsTable: React.FC<HealthCheckRunsTableProps> = ({
78
75
  );
79
76
  };
80
77
 
78
+ // 3 base columns (Status, Timestamp, Source) + 3 extras when
79
+ // showFilterColumns is on (System ID, Configuration ID, link icon).
80
+ const columnCount = showFilterColumns ? 6 : 3;
81
+ const showEmptyRow = !loading && displayRuns.length === 0;
82
+
81
83
  return (
82
84
  <>
83
85
  <div className="rounded-md border">
@@ -102,6 +104,11 @@ export const HealthCheckRunsTable: React.FC<HealthCheckRunsTableProps> = ({
102
104
  </TableRow>
103
105
  </TableHeader>
104
106
  <TableBody>
107
+ {showEmptyRow && (
108
+ <EmptyRunsTableRow colSpan={columnCount}>
109
+ {emptyMessage}
110
+ </EmptyRunsTableRow>
111
+ )}
105
112
  {displayRuns.map((run) => (
106
113
  <TableRow
107
114
  key={run.id}