@checkstack/healthcheck-frontend 0.14.2 → 0.16.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,52 @@
1
1
  # @checkstack/healthcheck-frontend
2
2
 
3
+ ## 0.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 80cbc51: Enforce GitOps provenance lock on backend API endpoints to prevent manual configuration drift for synchronized resources.
8
+
9
+ ### Patch Changes
10
+
11
+ - @checkstack/dashboard-frontend@0.4.1
12
+
13
+ ## 0.15.0
14
+
15
+ ### Minor Changes
16
+
17
+ - bb1fea0: Redesign system detail page with hero banner, two-column layout, plugin metric tiles, and health check slide-over drawer.
18
+
19
+ ### New Components
20
+
21
+ - **MetricTile** (`@checkstack/ui`): Compact stat tile with icon, label, value, variant coloring
22
+ - **Sheet** (`@checkstack/ui`): Slide-over drawer built on Radix Dialog primitives
23
+
24
+ ### New Extension Slot
25
+
26
+ - **SystemOverviewMetricsSlot** (`@checkstack/catalog-common`): Plugin-contributed at-a-glance metric tiles in the system detail hero banner
27
+
28
+ ### Layout Changes
29
+
30
+ - System detail page now uses a hero banner with breadcrumb, status badges, and metric tile strip
31
+ - Two-column layout: monitoring content (left) and system context (right)
32
+ - Health checks rendered as compact card rows instead of heavy accordions
33
+ - Clicking a health check opens a slide-over drawer with summary tiles, timeline charts, and recent runs
34
+ - Right column uses lightweight borderless sections with dividers instead of heavy Card wrappers
35
+
36
+ ### Plugin Extensions
37
+
38
+ - Health check, SLO, Incident, and Maintenance plugins each contribute a metric tile to the hero banner
39
+
40
+ ### Patch Changes
41
+
42
+ - Updated dependencies [bb1fea0]
43
+ - Updated dependencies [bb1fea0]
44
+ - @checkstack/dashboard-frontend@0.4.0
45
+ - @checkstack/ui@1.4.0
46
+ - @checkstack/catalog-common@1.4.0
47
+ - @checkstack/auth-frontend@0.5.26
48
+ - @checkstack/gitops-frontend@0.3.1
49
+
3
50
  ## 0.14.2
4
51
 
5
52
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-frontend",
3
- "version": "0.14.2",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "main": "src/index.tsx",
6
6
  "checkstack": {
@@ -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
+ };