@checkstack/healthcheck-frontend 0.14.1 → 0.15.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,51 @@
|
|
|
1
1
|
# @checkstack/healthcheck-frontend
|
|
2
2
|
|
|
3
|
+
## 0.15.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- bb1fea0: Redesign system detail page with hero banner, two-column layout, plugin metric tiles, and health check slide-over drawer.
|
|
8
|
+
|
|
9
|
+
### New Components
|
|
10
|
+
|
|
11
|
+
- **MetricTile** (`@checkstack/ui`): Compact stat tile with icon, label, value, variant coloring
|
|
12
|
+
- **Sheet** (`@checkstack/ui`): Slide-over drawer built on Radix Dialog primitives
|
|
13
|
+
|
|
14
|
+
### New Extension Slot
|
|
15
|
+
|
|
16
|
+
- **SystemOverviewMetricsSlot** (`@checkstack/catalog-common`): Plugin-contributed at-a-glance metric tiles in the system detail hero banner
|
|
17
|
+
|
|
18
|
+
### Layout Changes
|
|
19
|
+
|
|
20
|
+
- System detail page now uses a hero banner with breadcrumb, status badges, and metric tile strip
|
|
21
|
+
- Two-column layout: monitoring content (left) and system context (right)
|
|
22
|
+
- Health checks rendered as compact card rows instead of heavy accordions
|
|
23
|
+
- Clicking a health check opens a slide-over drawer with summary tiles, timeline charts, and recent runs
|
|
24
|
+
- Right column uses lightweight borderless sections with dividers instead of heavy Card wrappers
|
|
25
|
+
|
|
26
|
+
### Plugin Extensions
|
|
27
|
+
|
|
28
|
+
- Health check, SLO, Incident, and Maintenance plugins each contribute a metric tile to the hero banner
|
|
29
|
+
|
|
30
|
+
### Patch Changes
|
|
31
|
+
|
|
32
|
+
- Updated dependencies [bb1fea0]
|
|
33
|
+
- Updated dependencies [bb1fea0]
|
|
34
|
+
- @checkstack/dashboard-frontend@0.4.0
|
|
35
|
+
- @checkstack/ui@1.4.0
|
|
36
|
+
- @checkstack/catalog-common@1.4.0
|
|
37
|
+
- @checkstack/auth-frontend@0.5.26
|
|
38
|
+
- @checkstack/gitops-frontend@0.3.1
|
|
39
|
+
|
|
40
|
+
## 0.14.2
|
|
41
|
+
|
|
42
|
+
### Patch Changes
|
|
43
|
+
|
|
44
|
+
- Updated dependencies [8ef367a]
|
|
45
|
+
- Updated dependencies [cb65e9d]
|
|
46
|
+
- @checkstack/gitops-frontend@0.3.0
|
|
47
|
+
- @checkstack/dashboard-frontend@0.3.35
|
|
48
|
+
|
|
3
49
|
## 0.14.1
|
|
4
50
|
|
|
5
51
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-frontend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.tsx",
|
|
6
6
|
"checkstack": {
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
"@checkstack/auth-frontend": "0.5.25",
|
|
16
16
|
"@checkstack/catalog-common": "1.3.1",
|
|
17
17
|
"@checkstack/common": "0.6.5",
|
|
18
|
-
"@checkstack/dashboard-frontend": "0.3.
|
|
18
|
+
"@checkstack/dashboard-frontend": "0.3.34",
|
|
19
19
|
"@checkstack/frontend-api": "0.3.9",
|
|
20
|
-
"@checkstack/gitops-frontend": "0.2.
|
|
20
|
+
"@checkstack/gitops-frontend": "0.2.1",
|
|
21
21
|
"@checkstack/healthcheck-common": "0.11.0",
|
|
22
22
|
"@checkstack/signal-frontend": "0.0.15",
|
|
23
23
|
"@checkstack/ui": "1.3.6",
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import React, { useState, useCallback, useRef } from "react";
|
|
2
|
+
import { Loader2, ExternalLink, Server } from "lucide-react";
|
|
3
|
+
import { Satellite as SatelliteIcon } from "lucide-react";
|
|
4
|
+
import {
|
|
5
|
+
ExtensionSlot,
|
|
6
|
+
usePluginClient,
|
|
7
|
+
useApi,
|
|
8
|
+
accessApiRef,
|
|
9
|
+
} from "@checkstack/frontend-api";
|
|
10
|
+
import { useSignal } from "@checkstack/signal-frontend";
|
|
11
|
+
import {
|
|
12
|
+
HEALTH_CHECK_RUN_COMPLETED,
|
|
13
|
+
HealthCheckApi,
|
|
14
|
+
healthCheckAccess,
|
|
15
|
+
healthcheckRoutes,
|
|
16
|
+
} from "@checkstack/healthcheck-common";
|
|
17
|
+
import { SatelliteApi, satelliteAccess } from "@checkstack/satellite-common";
|
|
18
|
+
import { resolveRoute } from "@checkstack/common";
|
|
19
|
+
import {
|
|
20
|
+
HealthBadge,
|
|
21
|
+
LoadingSpinner,
|
|
22
|
+
Table,
|
|
23
|
+
TableHeader,
|
|
24
|
+
TableRow,
|
|
25
|
+
TableHead,
|
|
26
|
+
TableBody,
|
|
27
|
+
TableCell,
|
|
28
|
+
Pagination,
|
|
29
|
+
usePagination,
|
|
30
|
+
usePaginationSync,
|
|
31
|
+
DateRangeFilter,
|
|
32
|
+
getPresetRange,
|
|
33
|
+
DateRangePreset,
|
|
34
|
+
detectPreset,
|
|
35
|
+
PRESETS,
|
|
36
|
+
Card,
|
|
37
|
+
CardContent,
|
|
38
|
+
CardHeader,
|
|
39
|
+
CardTitle,
|
|
40
|
+
MetricTile,
|
|
41
|
+
Sheet,
|
|
42
|
+
SheetContent,
|
|
43
|
+
SheetHeader,
|
|
44
|
+
SheetTitle,
|
|
45
|
+
SheetDescription,
|
|
46
|
+
SheetBody,
|
|
47
|
+
Badge,
|
|
48
|
+
Accordion,
|
|
49
|
+
AccordionItem,
|
|
50
|
+
AccordionTrigger,
|
|
51
|
+
AccordionContent,
|
|
52
|
+
} from "@checkstack/ui";
|
|
53
|
+
import { formatDistanceToNow } from "date-fns";
|
|
54
|
+
import { useNavigate, Link } from "react-router-dom";
|
|
55
|
+
import { HealthCheckStatusTimeline } from "./HealthCheckStatusTimeline";
|
|
56
|
+
import { HealthCheckLatencyChart } from "./HealthCheckLatencyChart";
|
|
57
|
+
import { useHealthCheckData } from "../hooks/useHealthCheckData";
|
|
58
|
+
import { AggregatedDataBanner } from "./AggregatedDataBanner";
|
|
59
|
+
import { HealthCheckDiagramSlot } from "../slots";
|
|
60
|
+
import { Heart, Clock, CheckCircle, AlertTriangle } from "lucide-react";
|
|
61
|
+
|
|
62
|
+
import type {
|
|
63
|
+
StateThresholds,
|
|
64
|
+
HealthCheckStatus,
|
|
65
|
+
} from "@checkstack/healthcheck-common";
|
|
66
|
+
|
|
67
|
+
interface HealthCheckOverviewItem {
|
|
68
|
+
configurationId: string;
|
|
69
|
+
strategyId: string;
|
|
70
|
+
name: string;
|
|
71
|
+
state: HealthCheckStatus;
|
|
72
|
+
intervalSeconds: number;
|
|
73
|
+
lastRunAt?: Date;
|
|
74
|
+
stateThresholds?: StateThresholds;
|
|
75
|
+
recentStatusHistory: HealthCheckStatus[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface HealthCheckDrawerProps {
|
|
79
|
+
item: HealthCheckOverviewItem;
|
|
80
|
+
systemId: string;
|
|
81
|
+
open: boolean;
|
|
82
|
+
onOpenChange: (open: boolean) => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const getVariantForStatus = (
|
|
86
|
+
status: HealthCheckStatus,
|
|
87
|
+
): "success" | "warning" | "destructive" | "default" => {
|
|
88
|
+
switch (status) {
|
|
89
|
+
case "healthy": {
|
|
90
|
+
return "success";
|
|
91
|
+
}
|
|
92
|
+
case "degraded": {
|
|
93
|
+
return "warning";
|
|
94
|
+
}
|
|
95
|
+
case "unhealthy": {
|
|
96
|
+
return "destructive";
|
|
97
|
+
}
|
|
98
|
+
default: {
|
|
99
|
+
return "default";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
|
|
105
|
+
item,
|
|
106
|
+
systemId,
|
|
107
|
+
open,
|
|
108
|
+
onOpenChange,
|
|
109
|
+
}) => {
|
|
110
|
+
const healthCheckClient = usePluginClient(HealthCheckApi);
|
|
111
|
+
const satelliteClient = usePluginClient(SatelliteApi);
|
|
112
|
+
const navigate = useNavigate();
|
|
113
|
+
const accessApi = useApi(accessApiRef);
|
|
114
|
+
const { allowed: canViewDetails } = accessApi.useAccess(
|
|
115
|
+
healthCheckAccess.details,
|
|
116
|
+
);
|
|
117
|
+
const { allowed: canReadSatellites } = accessApi.useAccess(
|
|
118
|
+
satelliteAccess.satellite.read,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Fetch satellites for source filter
|
|
122
|
+
const { data: satellitesData } = satelliteClient.listSatellites.useQuery(
|
|
123
|
+
{},
|
|
124
|
+
{ enabled: canReadSatellites },
|
|
125
|
+
);
|
|
126
|
+
const satellites = satellitesData?.satellites ?? [];
|
|
127
|
+
|
|
128
|
+
// Date range state
|
|
129
|
+
const [dateRange, setDateRange] = useState(() =>
|
|
130
|
+
getPresetRange(DateRangePreset.Last24Hours),
|
|
131
|
+
);
|
|
132
|
+
const [isRollingPreset, setIsRollingPreset] = useState(true);
|
|
133
|
+
const [sourceFilter, setSourceFilter] = useState<string | undefined>();
|
|
134
|
+
|
|
135
|
+
const activePreset = detectPreset(dateRange);
|
|
136
|
+
const activePresetLabel = PRESETS.find((p) => p.id === activePreset)?.shortLabel ?? "Custom";
|
|
137
|
+
|
|
138
|
+
const activeSourceName = sourceFilter === "local"
|
|
139
|
+
? "Local"
|
|
140
|
+
: sourceFilter
|
|
141
|
+
? satellites.find(s => s.id === sourceFilter)?.name ?? "Unknown"
|
|
142
|
+
: "All";
|
|
143
|
+
|
|
144
|
+
const handleDateRangeChange = useCallback(
|
|
145
|
+
(newRange: { startDate: Date; endDate: Date }) => {
|
|
146
|
+
setDateRange(newRange);
|
|
147
|
+
const isNearNow =
|
|
148
|
+
Math.abs(newRange.endDate.getTime() - Date.now()) < 60_000;
|
|
149
|
+
setIsRollingPreset(isNearNow);
|
|
150
|
+
setPendingCustomRange(undefined);
|
|
151
|
+
},
|
|
152
|
+
[],
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const [pendingCustomRange, setPendingCustomRange] = useState<
|
|
156
|
+
{ startDate: Date; endDate: Date } | undefined
|
|
157
|
+
>();
|
|
158
|
+
|
|
159
|
+
const handleCustomDateChange = useCallback(
|
|
160
|
+
(newRange: { startDate: Date; endDate: Date }) => {
|
|
161
|
+
setPendingCustomRange(newRange);
|
|
162
|
+
},
|
|
163
|
+
[],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const handleApplyCustomRange = useCallback(() => {
|
|
167
|
+
if (pendingCustomRange) {
|
|
168
|
+
setDateRange(pendingCustomRange);
|
|
169
|
+
setIsRollingPreset(false);
|
|
170
|
+
setPendingCustomRange(undefined);
|
|
171
|
+
}
|
|
172
|
+
}, [pendingCustomRange]);
|
|
173
|
+
|
|
174
|
+
// Chart data hook
|
|
175
|
+
const {
|
|
176
|
+
context: chartContext,
|
|
177
|
+
loading: chartLoading,
|
|
178
|
+
isFetching: chartFetching,
|
|
179
|
+
bucketIntervalSeconds,
|
|
180
|
+
} = useHealthCheckData({
|
|
181
|
+
systemId,
|
|
182
|
+
configurationId: item.configurationId,
|
|
183
|
+
strategyId: item.strategyId,
|
|
184
|
+
dateRange,
|
|
185
|
+
sourceFilter,
|
|
186
|
+
isRollingPreset,
|
|
187
|
+
onDateRangeRefresh: (newEndDate) => {
|
|
188
|
+
setDateRange((prev) => ({ ...prev, endDate: newEndDate }));
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Pagination for history table
|
|
193
|
+
const pagination = usePagination({ defaultLimit: 5 });
|
|
194
|
+
|
|
195
|
+
const {
|
|
196
|
+
data: historyData,
|
|
197
|
+
isLoading: historyLoading,
|
|
198
|
+
refetch,
|
|
199
|
+
} = healthCheckClient.getHistory.useQuery({
|
|
200
|
+
systemId,
|
|
201
|
+
configurationId: item.configurationId,
|
|
202
|
+
limit: pagination.limit,
|
|
203
|
+
offset: pagination.offset,
|
|
204
|
+
startDate: dateRange.startDate,
|
|
205
|
+
sourceFilter,
|
|
206
|
+
sortOrder: "desc",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
usePaginationSync(pagination, historyData?.total);
|
|
210
|
+
|
|
211
|
+
const prevRunsRef = useRef(historyData?.runs ?? []);
|
|
212
|
+
const rawRuns = historyData?.runs ?? [];
|
|
213
|
+
const displayRuns =
|
|
214
|
+
historyLoading && prevRunsRef.current.length > 0
|
|
215
|
+
? prevRunsRef.current
|
|
216
|
+
: rawRuns;
|
|
217
|
+
if (!historyLoading && rawRuns.length > 0) {
|
|
218
|
+
prevRunsRef.current = rawRuns;
|
|
219
|
+
}
|
|
220
|
+
const runs = displayRuns;
|
|
221
|
+
|
|
222
|
+
// Realtime updates
|
|
223
|
+
useSignal(HEALTH_CHECK_RUN_COMPLETED, ({ systemId: changedId }) => {
|
|
224
|
+
if (changedId === systemId) {
|
|
225
|
+
void refetch();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
231
|
+
<SheetContent size="lg">
|
|
232
|
+
<SheetHeader>
|
|
233
|
+
<div className="flex items-center justify-between pr-8 gap-4">
|
|
234
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
235
|
+
<SheetTitle className="truncate">{item.name}</SheetTitle>
|
|
236
|
+
<Badge variant="secondary" className="hidden sm:inline-flex shrink-0">
|
|
237
|
+
{item.strategyId}
|
|
238
|
+
</Badge>
|
|
239
|
+
</div>
|
|
240
|
+
<Link
|
|
241
|
+
to={resolveRoute(healthcheckRoutes.routes.historyDetail, {
|
|
242
|
+
systemId,
|
|
243
|
+
configurationId: item.configurationId,
|
|
244
|
+
})}
|
|
245
|
+
className="text-sm text-primary hover:underline flex items-center gap-1 shrink-0"
|
|
246
|
+
>
|
|
247
|
+
<span className="hidden sm:inline">Open Full Detail</span>
|
|
248
|
+
<span className="sm:hidden">Details</span>
|
|
249
|
+
<ExternalLink className="h-3.5 w-3.5" />
|
|
250
|
+
</Link>
|
|
251
|
+
</div>
|
|
252
|
+
<SheetDescription className="sr-only">
|
|
253
|
+
Health check details for {item.name}
|
|
254
|
+
</SheetDescription>
|
|
255
|
+
</SheetHeader>
|
|
256
|
+
|
|
257
|
+
<SheetBody className="space-y-6">
|
|
258
|
+
{/* Zone 1 — Summary Metric Tiles */}
|
|
259
|
+
<div className="grid grid-cols-2 gap-3">
|
|
260
|
+
<MetricTile
|
|
261
|
+
icon={Heart}
|
|
262
|
+
label="Status"
|
|
263
|
+
value={item.state}
|
|
264
|
+
variant={getVariantForStatus(item.state)}
|
|
265
|
+
/>
|
|
266
|
+
<MetricTile
|
|
267
|
+
icon={Clock}
|
|
268
|
+
label="Last Run"
|
|
269
|
+
value={
|
|
270
|
+
item.lastRunAt
|
|
271
|
+
? formatDistanceToNow(item.lastRunAt, { addSuffix: true })
|
|
272
|
+
: "Never"
|
|
273
|
+
}
|
|
274
|
+
/>
|
|
275
|
+
<MetricTile
|
|
276
|
+
icon={CheckCircle}
|
|
277
|
+
label="Interval"
|
|
278
|
+
value={`${item.intervalSeconds}s`}
|
|
279
|
+
/>
|
|
280
|
+
<MetricTile
|
|
281
|
+
icon={AlertTriangle}
|
|
282
|
+
label="Recent History"
|
|
283
|
+
value={`${item.recentStatusHistory.filter((s) => s === "healthy").length}/${item.recentStatusHistory.length} healthy`}
|
|
284
|
+
variant={
|
|
285
|
+
item.recentStatusHistory.includes("unhealthy")
|
|
286
|
+
? "destructive"
|
|
287
|
+
: "success"
|
|
288
|
+
}
|
|
289
|
+
/>
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
{/* Zone 2 — Timeline Charts */}
|
|
293
|
+
<div className="space-y-6">
|
|
294
|
+
{/* Filters */}
|
|
295
|
+
<Accordion type="single" collapsible className="w-full">
|
|
296
|
+
<AccordionItem value="filters" className="border-none">
|
|
297
|
+
<AccordionTrigger className="py-2 text-sm text-muted-foreground hover:no-underline hover:text-foreground">
|
|
298
|
+
<div className="flex items-center gap-3 text-xs">
|
|
299
|
+
<span className="font-medium mr-1 text-foreground">Filters</span>
|
|
300
|
+
<div className="flex items-center gap-1.5 bg-muted/50 text-muted-foreground px-2 py-0.5 rounded-md border border-border/50">
|
|
301
|
+
<Clock className="h-3.5 w-3.5" />
|
|
302
|
+
{activePresetLabel}
|
|
303
|
+
</div>
|
|
304
|
+
{canReadSatellites && satellites.length > 0 && (
|
|
305
|
+
<div className="flex items-center gap-1.5 bg-muted/50 text-muted-foreground px-2 py-0.5 rounded-md border border-border/50">
|
|
306
|
+
{sourceFilter && sourceFilter !== "local" ? (
|
|
307
|
+
<SatelliteIcon className="h-3.5 w-3.5 text-orange-500" />
|
|
308
|
+
) : (
|
|
309
|
+
<Server className="h-3.5 w-3.5" />
|
|
310
|
+
)}
|
|
311
|
+
{activeSourceName}
|
|
312
|
+
</div>
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
</AccordionTrigger>
|
|
316
|
+
<AccordionContent>
|
|
317
|
+
<div className="flex flex-col gap-4 pt-2">
|
|
318
|
+
<div className="flex items-center gap-3 flex-wrap">
|
|
319
|
+
<DateRangeFilter
|
|
320
|
+
value={pendingCustomRange ?? dateRange}
|
|
321
|
+
onChange={handleDateRangeChange}
|
|
322
|
+
onCustomChange={handleCustomDateChange}
|
|
323
|
+
disabled={chartFetching}
|
|
324
|
+
/>
|
|
325
|
+
{pendingCustomRange && (
|
|
326
|
+
<button
|
|
327
|
+
onClick={handleApplyCustomRange}
|
|
328
|
+
disabled={
|
|
329
|
+
chartFetching ||
|
|
330
|
+
pendingCustomRange.startDate >= pendingCustomRange.endDate
|
|
331
|
+
}
|
|
332
|
+
className="px-3 py-1.5 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
|
333
|
+
>
|
|
334
|
+
Apply
|
|
335
|
+
</button>
|
|
336
|
+
)}
|
|
337
|
+
{chartFetching && (
|
|
338
|
+
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground ml-2" />
|
|
339
|
+
)}
|
|
340
|
+
</div>
|
|
341
|
+
|
|
342
|
+
{/* Source filter */}
|
|
343
|
+
{canReadSatellites && satellites.length > 0 && (
|
|
344
|
+
<div className="flex items-center gap-3 flex-wrap">
|
|
345
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground mr-1">
|
|
346
|
+
<Server className="h-4 w-4" />
|
|
347
|
+
<span className="font-medium text-foreground">Source:</span>
|
|
348
|
+
</div>
|
|
349
|
+
<div className="flex items-center gap-1.5">
|
|
350
|
+
<button
|
|
351
|
+
onClick={() => setSourceFilter(undefined)}
|
|
352
|
+
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-md transition-colors ${
|
|
353
|
+
sourceFilter === undefined
|
|
354
|
+
? "bg-primary text-primary-foreground"
|
|
355
|
+
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
|
356
|
+
}`}
|
|
357
|
+
>
|
|
358
|
+
All
|
|
359
|
+
</button>
|
|
360
|
+
<button
|
|
361
|
+
onClick={() => setSourceFilter("local")}
|
|
362
|
+
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-md transition-colors ${
|
|
363
|
+
sourceFilter === "local"
|
|
364
|
+
? "bg-primary text-primary-foreground"
|
|
365
|
+
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
|
366
|
+
}`}
|
|
367
|
+
>
|
|
368
|
+
<Server className="h-3.5 w-3.5" />
|
|
369
|
+
Local
|
|
370
|
+
</button>
|
|
371
|
+
{satellites.map((sat) => (
|
|
372
|
+
<button
|
|
373
|
+
key={sat.id}
|
|
374
|
+
onClick={() => setSourceFilter(sat.id)}
|
|
375
|
+
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-md transition-colors ${
|
|
376
|
+
sourceFilter === sat.id
|
|
377
|
+
? "bg-orange-500 text-white"
|
|
378
|
+
: "bg-orange-500/10 text-orange-600 hover:bg-orange-500/20"
|
|
379
|
+
}`}
|
|
380
|
+
>
|
|
381
|
+
<SatelliteIcon className="h-3.5 w-3.5" />
|
|
382
|
+
{sat.name}
|
|
383
|
+
</button>
|
|
384
|
+
))}
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
)}
|
|
388
|
+
</div>
|
|
389
|
+
</AccordionContent>
|
|
390
|
+
</AccordionItem>
|
|
391
|
+
</Accordion>
|
|
392
|
+
|
|
393
|
+
{/* Charts */}
|
|
394
|
+
{chartLoading ? (
|
|
395
|
+
<LoadingSpinner />
|
|
396
|
+
) : chartContext && chartContext.buckets.length > 0 ? (
|
|
397
|
+
<div className="space-y-4">
|
|
398
|
+
{bucketIntervalSeconds && (
|
|
399
|
+
<AggregatedDataBanner
|
|
400
|
+
bucketIntervalSeconds={bucketIntervalSeconds}
|
|
401
|
+
checkIntervalSeconds={item.intervalSeconds}
|
|
402
|
+
/>
|
|
403
|
+
)}
|
|
404
|
+
<Card>
|
|
405
|
+
<CardHeader className="pb-2">
|
|
406
|
+
<CardTitle className="text-sm font-medium">
|
|
407
|
+
Status Timeline
|
|
408
|
+
</CardTitle>
|
|
409
|
+
</CardHeader>
|
|
410
|
+
<CardContent>
|
|
411
|
+
<HealthCheckStatusTimeline
|
|
412
|
+
context={chartContext}
|
|
413
|
+
height={40}
|
|
414
|
+
/>
|
|
415
|
+
</CardContent>
|
|
416
|
+
</Card>
|
|
417
|
+
<Card>
|
|
418
|
+
<CardHeader className="pb-2">
|
|
419
|
+
<CardTitle className="text-sm font-medium">
|
|
420
|
+
Average Execution Duration
|
|
421
|
+
</CardTitle>
|
|
422
|
+
</CardHeader>
|
|
423
|
+
<CardContent>
|
|
424
|
+
<HealthCheckLatencyChart
|
|
425
|
+
context={chartContext}
|
|
426
|
+
height={120}
|
|
427
|
+
showAverage
|
|
428
|
+
/>
|
|
429
|
+
</CardContent>
|
|
430
|
+
</Card>
|
|
431
|
+
<ExtensionSlot
|
|
432
|
+
slot={HealthCheckDiagramSlot}
|
|
433
|
+
context={chartContext}
|
|
434
|
+
/>
|
|
435
|
+
</div>
|
|
436
|
+
) : (
|
|
437
|
+
<div className="text-center text-muted-foreground py-4 text-sm">
|
|
438
|
+
No chart data available
|
|
439
|
+
</div>
|
|
440
|
+
)}
|
|
441
|
+
</div>
|
|
442
|
+
|
|
443
|
+
{/* Zone 3 — Recent Runs */}
|
|
444
|
+
{runs.length > 0 && (
|
|
445
|
+
<div className="space-y-3">
|
|
446
|
+
<div className="flex items-center gap-4">
|
|
447
|
+
<div className="flex-1 h-px bg-border" />
|
|
448
|
+
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
|
449
|
+
Recent Runs
|
|
450
|
+
{historyLoading && (
|
|
451
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
452
|
+
)}
|
|
453
|
+
</span>
|
|
454
|
+
<div className="flex-1 h-px bg-border" />
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
<div className="rounded-md border">
|
|
458
|
+
<Table>
|
|
459
|
+
<TableHeader>
|
|
460
|
+
<TableRow>
|
|
461
|
+
<TableHead className="w-24">Status</TableHead>
|
|
462
|
+
<TableHead>Time</TableHead>
|
|
463
|
+
<TableHead>Source</TableHead>
|
|
464
|
+
</TableRow>
|
|
465
|
+
</TableHeader>
|
|
466
|
+
<TableBody>
|
|
467
|
+
{runs.map((run) => (
|
|
468
|
+
<TableRow
|
|
469
|
+
key={run.id}
|
|
470
|
+
className={`${
|
|
471
|
+
canViewDetails
|
|
472
|
+
? "cursor-pointer hover:bg-muted/50"
|
|
473
|
+
: ""
|
|
474
|
+
} ${historyLoading ? "opacity-50" : ""}`}
|
|
475
|
+
onClick={
|
|
476
|
+
canViewDetails
|
|
477
|
+
? () =>
|
|
478
|
+
navigate(
|
|
479
|
+
resolveRoute(
|
|
480
|
+
healthcheckRoutes.routes.historyRun,
|
|
481
|
+
{
|
|
482
|
+
systemId,
|
|
483
|
+
configurationId: item.configurationId,
|
|
484
|
+
runId: run.id,
|
|
485
|
+
},
|
|
486
|
+
),
|
|
487
|
+
)
|
|
488
|
+
: undefined
|
|
489
|
+
}
|
|
490
|
+
>
|
|
491
|
+
<TableCell>
|
|
492
|
+
<HealthBadge status={run.status} />
|
|
493
|
+
</TableCell>
|
|
494
|
+
<TableCell className="text-muted-foreground">
|
|
495
|
+
{formatDistanceToNow(new Date(run.timestamp), {
|
|
496
|
+
addSuffix: true,
|
|
497
|
+
})}
|
|
498
|
+
</TableCell>
|
|
499
|
+
<TableCell>
|
|
500
|
+
{run.sourceId ? (
|
|
501
|
+
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-orange-500/10 text-orange-600">
|
|
502
|
+
<SatelliteIcon className="h-3 w-3" />
|
|
503
|
+
{run.sourceLabel ?? "Remote"}
|
|
504
|
+
</span>
|
|
505
|
+
) : (
|
|
506
|
+
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
|
|
507
|
+
<Server className="h-3 w-3" />
|
|
508
|
+
{run.sourceLabel ?? "Local"}
|
|
509
|
+
</span>
|
|
510
|
+
)}
|
|
511
|
+
</TableCell>
|
|
512
|
+
</TableRow>
|
|
513
|
+
))}
|
|
514
|
+
</TableBody>
|
|
515
|
+
</Table>
|
|
516
|
+
</div>
|
|
517
|
+
<div className="flex items-center justify-between">
|
|
518
|
+
<Pagination
|
|
519
|
+
page={pagination.page}
|
|
520
|
+
totalPages={pagination.totalPages}
|
|
521
|
+
onPageChange={pagination.setPage}
|
|
522
|
+
total={pagination.total}
|
|
523
|
+
limit={pagination.limit}
|
|
524
|
+
onPageSizeChange={pagination.setLimit}
|
|
525
|
+
showPageSize
|
|
526
|
+
showTotal
|
|
527
|
+
/>
|
|
528
|
+
</div>
|
|
529
|
+
<div className="text-center">
|
|
530
|
+
<Link
|
|
531
|
+
to={resolveRoute(healthcheckRoutes.routes.historyDetail, {
|
|
532
|
+
systemId,
|
|
533
|
+
configurationId: item.configurationId,
|
|
534
|
+
})}
|
|
535
|
+
className="text-sm text-primary hover:underline"
|
|
536
|
+
>
|
|
537
|
+
View all runs →
|
|
538
|
+
</Link>
|
|
539
|
+
</div>
|
|
540
|
+
</div>
|
|
541
|
+
)}
|
|
542
|
+
</SheetBody>
|
|
543
|
+
</SheetContent>
|
|
544
|
+
</Sheet>
|
|
545
|
+
);
|
|
546
|
+
};
|
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
import React, { useState, useCallback
|
|
2
|
-
import { Loader2 } from "lucide-react";
|
|
1
|
+
import React, { useState, useCallback } from "react";
|
|
3
2
|
import {
|
|
4
|
-
ExtensionSlot,
|
|
5
3
|
usePluginClient,
|
|
6
|
-
useApi,
|
|
7
|
-
accessApiRef,
|
|
8
4
|
type SlotContext,
|
|
9
5
|
} from "@checkstack/frontend-api";
|
|
10
6
|
import { useSignal } from "@checkstack/signal-frontend";
|
|
@@ -12,54 +8,42 @@ import { SystemDetailsSlot } from "@checkstack/catalog-common";
|
|
|
12
8
|
import {
|
|
13
9
|
HEALTH_CHECK_RUN_COMPLETED,
|
|
14
10
|
HealthCheckApi,
|
|
15
|
-
healthCheckAccess,
|
|
16
|
-
healthcheckRoutes,
|
|
17
11
|
} from "@checkstack/healthcheck-common";
|
|
18
|
-
import { SatelliteApi, satelliteAccess } from "@checkstack/satellite-common";
|
|
19
|
-
import { resolveRoute } from "@checkstack/common";
|
|
20
12
|
import {
|
|
21
13
|
HealthBadge,
|
|
22
14
|
LoadingSpinner,
|
|
23
|
-
Table,
|
|
24
|
-
TableHeader,
|
|
25
|
-
TableRow,
|
|
26
|
-
TableHead,
|
|
27
|
-
TableBody,
|
|
28
|
-
TableCell,
|
|
29
|
-
Tooltip,
|
|
30
|
-
Pagination,
|
|
31
|
-
usePagination,
|
|
32
|
-
usePaginationSync,
|
|
33
|
-
DateRangeFilter,
|
|
34
|
-
getPresetRange,
|
|
35
|
-
DateRangePreset,
|
|
36
15
|
Card,
|
|
37
16
|
CardContent,
|
|
38
17
|
CardHeader,
|
|
39
18
|
CardTitle,
|
|
40
19
|
} from "@checkstack/ui";
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
ChevronDown,
|
|
44
|
-
ChevronRight,
|
|
45
|
-
Satellite as SatelliteIcon,
|
|
46
|
-
Server,
|
|
47
|
-
} from "lucide-react";
|
|
48
|
-
import { useNavigate } from "react-router-dom";
|
|
20
|
+
import { Heart } from "lucide-react";
|
|
49
21
|
import { HealthCheckSparkline } from "./HealthCheckSparkline";
|
|
50
|
-
import {
|
|
51
|
-
import { HealthCheckStatusTimeline } from "./HealthCheckStatusTimeline";
|
|
52
|
-
import { useHealthCheckData } from "../hooks/useHealthCheckData";
|
|
22
|
+
import { HealthCheckDrawer } from "./HealthCheckDrawer";
|
|
53
23
|
|
|
54
24
|
import type {
|
|
55
25
|
StateThresholds,
|
|
56
26
|
HealthCheckStatus,
|
|
57
27
|
} from "@checkstack/healthcheck-common";
|
|
58
|
-
import { AggregatedDataBanner } from "./AggregatedDataBanner";
|
|
59
|
-
import { HealthCheckDiagramSlot } from "../slots";
|
|
60
28
|
|
|
61
29
|
type SlotProps = SlotContext<typeof SystemDetailsSlot>;
|
|
62
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Compact relative time formatter that prevents layout shift.
|
|
33
|
+
* Returns fixed-width strings like "< 1m", "5m", "2h", "3d".
|
|
34
|
+
*/
|
|
35
|
+
function formatCompactTime(date: Date | undefined): string {
|
|
36
|
+
if (!date) return "—";
|
|
37
|
+
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
38
|
+
if (seconds < 60) return "< 1m";
|
|
39
|
+
const minutes = Math.floor(seconds / 60);
|
|
40
|
+
if (minutes < 60) return `${minutes}m`;
|
|
41
|
+
const hours = Math.floor(minutes / 60);
|
|
42
|
+
if (hours < 24) return `${hours}h`;
|
|
43
|
+
const days = Math.floor(hours / 24);
|
|
44
|
+
return `${days}d`;
|
|
45
|
+
}
|
|
46
|
+
|
|
63
47
|
interface HealthCheckOverviewItem {
|
|
64
48
|
configurationId: string;
|
|
65
49
|
strategyId: string;
|
|
@@ -71,386 +55,13 @@ interface HealthCheckOverviewItem {
|
|
|
71
55
|
recentStatusHistory: HealthCheckStatus[];
|
|
72
56
|
}
|
|
73
57
|
|
|
74
|
-
interface ExpandedRowProps {
|
|
75
|
-
item: HealthCheckOverviewItem;
|
|
76
|
-
systemId: string;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
|
|
80
|
-
const healthCheckClient = usePluginClient(HealthCheckApi);
|
|
81
|
-
const satelliteClient = usePluginClient(SatelliteApi);
|
|
82
|
-
const navigate = useNavigate();
|
|
83
|
-
const accessApi = useApi(accessApiRef);
|
|
84
|
-
const { allowed: canViewDetails } = accessApi.useAccess(
|
|
85
|
-
healthCheckAccess.details,
|
|
86
|
-
);
|
|
87
|
-
const { allowed: canReadSatellites } = accessApi.useAccess(
|
|
88
|
-
satelliteAccess.satellite.read,
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
// Fetch satellites for source filter (only if user has access)
|
|
92
|
-
const { data: satellitesData } = satelliteClient.listSatellites.useQuery(
|
|
93
|
-
{},
|
|
94
|
-
{ enabled: canReadSatellites },
|
|
95
|
-
);
|
|
96
|
-
const satellites = satellitesData?.satellites ?? [];
|
|
97
|
-
|
|
98
|
-
// Date range state for filtering - default to last 24 hours
|
|
99
|
-
const [dateRange, setDateRange] = useState(() =>
|
|
100
|
-
getPresetRange(DateRangePreset.Last24Hours),
|
|
101
|
-
);
|
|
102
|
-
// Track if a rolling preset is active (vs custom range)
|
|
103
|
-
const [isRollingPreset, setIsRollingPreset] = useState(true);
|
|
104
|
-
const [sourceFilter, setSourceFilter] = useState<string | undefined>();
|
|
105
|
-
|
|
106
|
-
// Callback to handle date range changes from the filter
|
|
107
|
-
const handleDateRangeChange = useCallback(
|
|
108
|
-
(newRange: { startDate: Date; endDate: Date }) => {
|
|
109
|
-
setDateRange(newRange);
|
|
110
|
-
// Check if this is a rolling preset by comparing endDate to now (within 1 minute)
|
|
111
|
-
const isNearNow =
|
|
112
|
-
Math.abs(newRange.endDate.getTime() - Date.now()) < 60_000;
|
|
113
|
-
setIsRollingPreset(isNearNow);
|
|
114
|
-
// Clear any pending custom range when preset is selected
|
|
115
|
-
setPendingCustomRange(undefined);
|
|
116
|
-
},
|
|
117
|
-
[],
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
// Local state for custom date picker - only applied when user clicks Apply
|
|
121
|
-
const [pendingCustomRange, setPendingCustomRange] = useState<
|
|
122
|
-
| {
|
|
123
|
-
startDate: Date;
|
|
124
|
-
endDate: Date;
|
|
125
|
-
}
|
|
126
|
-
| undefined
|
|
127
|
-
>();
|
|
128
|
-
|
|
129
|
-
// Handle custom date changes - store locally until Apply
|
|
130
|
-
const handleCustomDateChange = useCallback(
|
|
131
|
-
(newRange: { startDate: Date; endDate: Date }) => {
|
|
132
|
-
setPendingCustomRange(newRange);
|
|
133
|
-
},
|
|
134
|
-
[],
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
// Apply pending custom range
|
|
138
|
-
const handleApplyCustomRange = useCallback(() => {
|
|
139
|
-
if (pendingCustomRange) {
|
|
140
|
-
setDateRange(pendingCustomRange);
|
|
141
|
-
setIsRollingPreset(false);
|
|
142
|
-
setPendingCustomRange(undefined);
|
|
143
|
-
}
|
|
144
|
-
}, [pendingCustomRange]);
|
|
145
|
-
|
|
146
|
-
// Use shared hook for chart data - handles both raw and aggregated modes
|
|
147
|
-
// and includes signal handling for automatic refresh
|
|
148
|
-
const {
|
|
149
|
-
context: chartContext,
|
|
150
|
-
loading: chartLoading,
|
|
151
|
-
isFetching: chartFetching,
|
|
152
|
-
bucketIntervalSeconds,
|
|
153
|
-
} = useHealthCheckData({
|
|
154
|
-
systemId,
|
|
155
|
-
configurationId: item.configurationId,
|
|
156
|
-
strategyId: item.strategyId,
|
|
157
|
-
dateRange,
|
|
158
|
-
sourceFilter,
|
|
159
|
-
isRollingPreset,
|
|
160
|
-
// Update endDate to current time when new runs are detected (only for rolling presets)
|
|
161
|
-
onDateRangeRefresh: (newEndDate) => {
|
|
162
|
-
setDateRange((prev) => ({ ...prev, endDate: newEndDate }));
|
|
163
|
-
},
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
// Pagination state for history table
|
|
167
|
-
const pagination = usePagination({ defaultLimit: 10 });
|
|
168
|
-
|
|
169
|
-
// Fetch paginated history with useQuery - newest first for table
|
|
170
|
-
const {
|
|
171
|
-
data: historyData,
|
|
172
|
-
isLoading: loading,
|
|
173
|
-
refetch,
|
|
174
|
-
} = healthCheckClient.getHistory.useQuery({
|
|
175
|
-
systemId,
|
|
176
|
-
configurationId: item.configurationId,
|
|
177
|
-
limit: pagination.limit,
|
|
178
|
-
offset: pagination.offset,
|
|
179
|
-
startDate: dateRange.startDate,
|
|
180
|
-
// Don't pass endDate - backend defaults to 'now' so new runs are included
|
|
181
|
-
sourceFilter,
|
|
182
|
-
sortOrder: "desc",
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
// Sync total from response
|
|
186
|
-
usePaginationSync(pagination, historyData?.total);
|
|
187
|
-
|
|
188
|
-
// Preserve previous runs during loading to prevent layout shift
|
|
189
|
-
const prevRunsRef = useRef(historyData?.runs ?? []);
|
|
190
|
-
const rawRuns = historyData?.runs ?? [];
|
|
191
|
-
const displayRuns =
|
|
192
|
-
loading && prevRunsRef.current.length > 0 ? prevRunsRef.current : rawRuns;
|
|
193
|
-
if (!loading && rawRuns.length > 0) {
|
|
194
|
-
prevRunsRef.current = rawRuns;
|
|
195
|
-
}
|
|
196
|
-
const runs = displayRuns;
|
|
197
|
-
|
|
198
|
-
// Listen for realtime health check updates to refresh history table
|
|
199
|
-
// Charts are refreshed automatically by useHealthCheckData
|
|
200
|
-
useSignal(HEALTH_CHECK_RUN_COMPLETED, ({ systemId: changedId }) => {
|
|
201
|
-
if (changedId === systemId) {
|
|
202
|
-
void refetch();
|
|
203
|
-
}
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
const thresholdDescription = item.stateThresholds
|
|
207
|
-
? item.stateThresholds.mode === "consecutive"
|
|
208
|
-
? `Consecutive mode: Healthy after ${item.stateThresholds.healthy.minSuccessCount} success(es), Degraded after ${item.stateThresholds.degraded.minFailureCount} failure(s), Unhealthy after ${item.stateThresholds.unhealthy.minFailureCount} failure(s)`
|
|
209
|
-
: `Window mode (${item.stateThresholds.windowSize} runs): Degraded at ${item.stateThresholds.degraded.minFailureCount}+ failures, Unhealthy at ${item.stateThresholds.unhealthy.minFailureCount}+ failures`
|
|
210
|
-
: "Using default thresholds";
|
|
211
|
-
|
|
212
|
-
// Render charts - charts handle data transformation internally
|
|
213
|
-
const renderCharts = () => {
|
|
214
|
-
if (chartLoading) {
|
|
215
|
-
return <LoadingSpinner />;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (!chartContext) {
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Check if we have data to show
|
|
223
|
-
const hasData = chartContext.buckets.length > 0;
|
|
224
|
-
|
|
225
|
-
if (!hasData) {
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
return (
|
|
230
|
-
<div className="space-y-4">
|
|
231
|
-
{bucketIntervalSeconds && (
|
|
232
|
-
<AggregatedDataBanner
|
|
233
|
-
bucketIntervalSeconds={bucketIntervalSeconds}
|
|
234
|
-
checkIntervalSeconds={item.intervalSeconds}
|
|
235
|
-
/>
|
|
236
|
-
)}
|
|
237
|
-
{/* Status Timeline */}
|
|
238
|
-
<Card>
|
|
239
|
-
<CardHeader className="pb-2">
|
|
240
|
-
<CardTitle className="text-sm font-medium">
|
|
241
|
-
Status Timeline
|
|
242
|
-
</CardTitle>
|
|
243
|
-
</CardHeader>
|
|
244
|
-
<CardContent>
|
|
245
|
-
<HealthCheckStatusTimeline context={chartContext} height={50} />
|
|
246
|
-
</CardContent>
|
|
247
|
-
</Card>
|
|
248
|
-
{/* Execution Duration Chart */}
|
|
249
|
-
<Card>
|
|
250
|
-
<CardHeader className="pb-2">
|
|
251
|
-
<CardTitle className="text-sm font-medium">
|
|
252
|
-
Average Execution Duration
|
|
253
|
-
</CardTitle>
|
|
254
|
-
</CardHeader>
|
|
255
|
-
<CardContent>
|
|
256
|
-
<HealthCheckLatencyChart
|
|
257
|
-
context={chartContext}
|
|
258
|
-
height={150}
|
|
259
|
-
showAverage
|
|
260
|
-
/>
|
|
261
|
-
</CardContent>
|
|
262
|
-
</Card>
|
|
263
|
-
{/* Extension Slot for custom strategy-specific diagrams */}
|
|
264
|
-
<ExtensionSlot slot={HealthCheckDiagramSlot} context={chartContext} />
|
|
265
|
-
</div>
|
|
266
|
-
);
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
return (
|
|
270
|
-
<div className="p-4 bg-muted/30 border-t space-y-4">
|
|
271
|
-
<div className="flex flex-wrap gap-4 text-sm">
|
|
272
|
-
<div>
|
|
273
|
-
<span className="text-muted-foreground">Strategy:</span>{" "}
|
|
274
|
-
<span className="font-medium">{item.strategyId}</span>
|
|
275
|
-
</div>
|
|
276
|
-
<div>
|
|
277
|
-
<span className="text-muted-foreground">Interval:</span>{" "}
|
|
278
|
-
<span className="font-medium">{item.intervalSeconds}s</span>
|
|
279
|
-
</div>
|
|
280
|
-
<div className="flex items-center gap-1">
|
|
281
|
-
<span className="text-muted-foreground">Thresholds:</span>{" "}
|
|
282
|
-
<Tooltip content={thresholdDescription} />
|
|
283
|
-
</div>
|
|
284
|
-
</div>
|
|
285
|
-
|
|
286
|
-
{/* Date Range Filter with Loading Spinner */}
|
|
287
|
-
<div className="flex items-center gap-3 flex-wrap">
|
|
288
|
-
<DateRangeFilter
|
|
289
|
-
value={pendingCustomRange ?? dateRange}
|
|
290
|
-
onChange={handleDateRangeChange}
|
|
291
|
-
onCustomChange={handleCustomDateChange}
|
|
292
|
-
disabled={chartFetching}
|
|
293
|
-
/>
|
|
294
|
-
{pendingCustomRange && (
|
|
295
|
-
<button
|
|
296
|
-
onClick={handleApplyCustomRange}
|
|
297
|
-
disabled={
|
|
298
|
-
chartFetching ||
|
|
299
|
-
pendingCustomRange.startDate >= pendingCustomRange.endDate
|
|
300
|
-
}
|
|
301
|
-
className="px-3 py-1.5 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
302
|
-
>
|
|
303
|
-
Apply
|
|
304
|
-
</button>
|
|
305
|
-
)}
|
|
306
|
-
{chartFetching && (
|
|
307
|
-
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
308
|
-
)}
|
|
309
|
-
</div>
|
|
310
|
-
{/* Source filter (visible when satellites exist and user has read access) */}
|
|
311
|
-
{canReadSatellites && satellites.length > 0 && (
|
|
312
|
-
<div className="flex items-center gap-2">
|
|
313
|
-
<span className="text-xs text-muted-foreground">Source:</span>
|
|
314
|
-
<div className="flex items-center gap-1">
|
|
315
|
-
<button
|
|
316
|
-
onClick={() => setSourceFilter(undefined)}
|
|
317
|
-
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full transition-colors ${
|
|
318
|
-
sourceFilter === undefined
|
|
319
|
-
? "bg-primary text-primary-foreground"
|
|
320
|
-
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
|
321
|
-
}`}
|
|
322
|
-
>
|
|
323
|
-
All
|
|
324
|
-
</button>
|
|
325
|
-
<button
|
|
326
|
-
onClick={() => setSourceFilter("local")}
|
|
327
|
-
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full transition-colors ${
|
|
328
|
-
sourceFilter === "local"
|
|
329
|
-
? "bg-primary text-primary-foreground"
|
|
330
|
-
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
|
331
|
-
}`}
|
|
332
|
-
>
|
|
333
|
-
<Server className="h-3 w-3" />
|
|
334
|
-
Local
|
|
335
|
-
</button>
|
|
336
|
-
{satellites.map((sat) => (
|
|
337
|
-
<button
|
|
338
|
-
key={sat.id}
|
|
339
|
-
onClick={() => setSourceFilter(sat.id)}
|
|
340
|
-
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full transition-colors ${
|
|
341
|
-
sourceFilter === sat.id
|
|
342
|
-
? "bg-orange-500 text-white"
|
|
343
|
-
: "bg-orange-500/10 text-orange-600 hover:bg-orange-500/20"
|
|
344
|
-
}`}
|
|
345
|
-
>
|
|
346
|
-
<SatelliteIcon className="h-3 w-3" />
|
|
347
|
-
{sat.name}
|
|
348
|
-
</button>
|
|
349
|
-
))}
|
|
350
|
-
</div>
|
|
351
|
-
</div>
|
|
352
|
-
)}
|
|
353
|
-
|
|
354
|
-
{/* Charts Section */}
|
|
355
|
-
{renderCharts()}
|
|
356
|
-
|
|
357
|
-
{loading && prevRunsRef.current.length === 0 ? (
|
|
358
|
-
<LoadingSpinner />
|
|
359
|
-
) : runs.length > 0 ? (
|
|
360
|
-
<>
|
|
361
|
-
{/* Divider between charts and table */}
|
|
362
|
-
<div className="flex items-center gap-4 pt-4">
|
|
363
|
-
<div className="flex-1 h-px bg-border" />
|
|
364
|
-
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
|
365
|
-
Recent Runs
|
|
366
|
-
{loading && <Loader2 className="h-3 w-3 animate-spin" />}
|
|
367
|
-
</span>
|
|
368
|
-
<div className="flex-1 h-px bg-border" />
|
|
369
|
-
</div>
|
|
370
|
-
|
|
371
|
-
<div className="rounded-md border">
|
|
372
|
-
<Table>
|
|
373
|
-
<TableHeader>
|
|
374
|
-
<TableRow>
|
|
375
|
-
<TableHead className="w-24">Status</TableHead>
|
|
376
|
-
<TableHead>Time</TableHead>
|
|
377
|
-
<TableHead>Source</TableHead>
|
|
378
|
-
</TableRow>
|
|
379
|
-
</TableHeader>
|
|
380
|
-
<TableBody>
|
|
381
|
-
{runs.map((run) => (
|
|
382
|
-
<TableRow
|
|
383
|
-
key={run.id}
|
|
384
|
-
className={`${
|
|
385
|
-
canViewDetails ? "cursor-pointer hover:bg-muted/50" : ""
|
|
386
|
-
} ${loading ? "opacity-50" : ""}`}
|
|
387
|
-
onClick={
|
|
388
|
-
canViewDetails
|
|
389
|
-
? () =>
|
|
390
|
-
navigate(
|
|
391
|
-
resolveRoute(
|
|
392
|
-
healthcheckRoutes.routes.historyRun,
|
|
393
|
-
{
|
|
394
|
-
systemId,
|
|
395
|
-
configurationId: item.configurationId,
|
|
396
|
-
runId: run.id,
|
|
397
|
-
},
|
|
398
|
-
),
|
|
399
|
-
)
|
|
400
|
-
: undefined
|
|
401
|
-
}
|
|
402
|
-
>
|
|
403
|
-
<TableCell>
|
|
404
|
-
<HealthBadge status={run.status} />
|
|
405
|
-
</TableCell>
|
|
406
|
-
<TableCell className="text-muted-foreground">
|
|
407
|
-
{formatDistanceToNow(new Date(run.timestamp), {
|
|
408
|
-
addSuffix: true,
|
|
409
|
-
})}
|
|
410
|
-
</TableCell>
|
|
411
|
-
<TableCell>
|
|
412
|
-
{run.sourceId ? (
|
|
413
|
-
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-orange-500/10 text-orange-600">
|
|
414
|
-
<SatelliteIcon className="h-3 w-3" />
|
|
415
|
-
{run.sourceLabel ?? "Remote"}
|
|
416
|
-
</span>
|
|
417
|
-
) : (
|
|
418
|
-
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
|
|
419
|
-
<Server className="h-3 w-3" />
|
|
420
|
-
{run.sourceLabel ?? "Local"}
|
|
421
|
-
</span>
|
|
422
|
-
)}
|
|
423
|
-
</TableCell>
|
|
424
|
-
</TableRow>
|
|
425
|
-
))}
|
|
426
|
-
</TableBody>
|
|
427
|
-
</Table>
|
|
428
|
-
</div>
|
|
429
|
-
<Pagination
|
|
430
|
-
page={pagination.page}
|
|
431
|
-
totalPages={pagination.totalPages}
|
|
432
|
-
onPageChange={pagination.setPage}
|
|
433
|
-
total={pagination.total}
|
|
434
|
-
limit={pagination.limit}
|
|
435
|
-
onPageSizeChange={pagination.setLimit}
|
|
436
|
-
showPageSize
|
|
437
|
-
showTotal
|
|
438
|
-
/>
|
|
439
|
-
</>
|
|
440
|
-
) : (
|
|
441
|
-
<div className="text-center text-muted-foreground py-4">
|
|
442
|
-
No runs recorded yet
|
|
443
|
-
</div>
|
|
444
|
-
)}
|
|
445
|
-
</div>
|
|
446
|
-
);
|
|
447
|
-
};
|
|
448
|
-
|
|
449
58
|
export function HealthCheckSystemOverview(props: SlotProps) {
|
|
450
59
|
const systemId = props.system.id;
|
|
451
60
|
const healthCheckClient = usePluginClient(HealthCheckApi);
|
|
452
61
|
|
|
453
|
-
const [
|
|
62
|
+
const [selectedCheck, setSelectedCheck] = useState<
|
|
63
|
+
HealthCheckOverviewItem | undefined
|
|
64
|
+
>();
|
|
454
65
|
|
|
455
66
|
// Fetch health check overview using useQuery
|
|
456
67
|
const {
|
|
@@ -496,59 +107,68 @@ export function HealthCheckSystemOverview(props: SlotProps) {
|
|
|
496
107
|
}
|
|
497
108
|
|
|
498
109
|
if (overview.length === 0) {
|
|
499
|
-
return
|
|
500
|
-
<div className="text-center text-muted-foreground py-4">
|
|
501
|
-
No health checks configured
|
|
502
|
-
</div>
|
|
503
|
-
);
|
|
110
|
+
return;
|
|
504
111
|
}
|
|
505
112
|
|
|
506
113
|
return (
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
<
|
|
521
|
-
{
|
|
522
|
-
|
|
523
|
-
)
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
<
|
|
527
|
-
|
|
528
|
-
<HealthBadge status={item.state} />
|
|
529
|
-
</div>
|
|
530
|
-
</div>
|
|
531
|
-
{/* Details row: last run + sparkline */}
|
|
532
|
-
<div className="ml-7 mt-1 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
|
533
|
-
<span className="text-sm text-muted-foreground">
|
|
534
|
-
Last run:{" "}
|
|
535
|
-
{item.lastRunAt
|
|
536
|
-
? formatDistanceToNow(item.lastRunAt, { addSuffix: true })
|
|
537
|
-
: "never"}
|
|
114
|
+
<>
|
|
115
|
+
<Card>
|
|
116
|
+
<CardHeader className="pb-3">
|
|
117
|
+
<div className="flex items-center gap-2">
|
|
118
|
+
<Heart className="h-4 w-4 text-muted-foreground" />
|
|
119
|
+
<CardTitle className="text-base font-semibold">
|
|
120
|
+
Health Checks
|
|
121
|
+
</CardTitle>
|
|
122
|
+
</div>
|
|
123
|
+
</CardHeader>
|
|
124
|
+
<CardContent className="p-0">
|
|
125
|
+
<div className="divide-y divide-border">
|
|
126
|
+
{overview.map((item) => (
|
|
127
|
+
<button
|
|
128
|
+
key={item.configurationId}
|
|
129
|
+
className="w-full px-4 py-3 text-left hover:bg-muted/50 transition-colors flex items-center gap-3"
|
|
130
|
+
onClick={() => setSelectedCheck(item)}
|
|
131
|
+
>
|
|
132
|
+
{/* Check name */}
|
|
133
|
+
<span className="font-medium truncate flex-1 min-w-0 text-sm">
|
|
134
|
+
{item.name}
|
|
538
135
|
</span>
|
|
136
|
+
|
|
137
|
+
{/* Status badge */}
|
|
138
|
+
<HealthBadge status={item.state} />
|
|
139
|
+
|
|
140
|
+
{/* Sparkline */}
|
|
539
141
|
{item.recentStatusHistory.length > 0 && (
|
|
540
|
-
<
|
|
541
|
-
|
|
542
|
-
status
|
|
543
|
-
|
|
544
|
-
|
|
142
|
+
<div className="hidden sm:block shrink-0">
|
|
143
|
+
<HealthCheckSparkline
|
|
144
|
+
runs={item.recentStatusHistory.map((status) => ({
|
|
145
|
+
status,
|
|
146
|
+
}))}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
545
149
|
)}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
150
|
+
|
|
151
|
+
{/* Last run — compact fixed-width to prevent shift */}
|
|
152
|
+
<span className="hidden md:block text-xs text-muted-foreground w-10 text-right shrink-0 tabular-nums">
|
|
153
|
+
{formatCompactTime(item.lastRunAt)}
|
|
154
|
+
</span>
|
|
155
|
+
</button>
|
|
156
|
+
))}
|
|
549
157
|
</div>
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
158
|
+
</CardContent>
|
|
159
|
+
</Card>
|
|
160
|
+
|
|
161
|
+
{/* Slide-over Drawer */}
|
|
162
|
+
{selectedCheck && (
|
|
163
|
+
<HealthCheckDrawer
|
|
164
|
+
item={selectedCheck}
|
|
165
|
+
systemId={systemId}
|
|
166
|
+
open={!!selectedCheck}
|
|
167
|
+
onOpenChange={(open) => {
|
|
168
|
+
if (!open) setSelectedCheck(undefined);
|
|
169
|
+
}}
|
|
170
|
+
/>
|
|
171
|
+
)}
|
|
172
|
+
</>
|
|
553
173
|
);
|
|
554
174
|
}
|
|
@@ -35,7 +35,7 @@ export const SystemHealthBadge: React.FC<Props> = ({ system }) => {
|
|
|
35
35
|
{
|
|
36
36
|
enabled: !badgeData && !!system?.id,
|
|
37
37
|
staleTime: 30_000, // Prevent unnecessary refetches
|
|
38
|
-
}
|
|
38
|
+
},
|
|
39
39
|
);
|
|
40
40
|
|
|
41
41
|
const localStatus = healthData?.status;
|
|
@@ -50,6 +50,6 @@ export const SystemHealthBadge: React.FC<Props> = ({ system }) => {
|
|
|
50
50
|
// Use provider data if available, otherwise use local state
|
|
51
51
|
const status = providerStatus ?? localStatus;
|
|
52
52
|
|
|
53
|
-
if (!status) return <></>;
|
|
53
|
+
if (!status || status === "healthy") return <></>;
|
|
54
54
|
return <HealthBadge status={status} />;
|
|
55
55
|
};
|