@checkmate-monitor/healthcheck-frontend 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ import React, { useState, Fragment } from "react";
2
+ import {
3
+ Table,
4
+ TableHeader,
5
+ TableRow,
6
+ TableHead,
7
+ TableBody,
8
+ TableCell,
9
+ HealthBadge,
10
+ LoadingSpinner,
11
+ Pagination,
12
+ Button,
13
+ } from "@checkmate-monitor/ui";
14
+ import { formatDistanceToNow, format } from "date-fns";
15
+ import { ChevronDown, ChevronRight, ExternalLink } from "lucide-react";
16
+ import { Link } from "react-router-dom";
17
+ import { healthcheckRoutes } from "@checkmate-monitor/healthcheck-common";
18
+ import { resolveRoute } from "@checkmate-monitor/common";
19
+
20
+ export interface HealthCheckRunDetailed {
21
+ id: string;
22
+ configurationId: string;
23
+ systemId: string;
24
+ status: "healthy" | "unhealthy" | "degraded";
25
+ result: Record<string, unknown>;
26
+ timestamp: Date;
27
+ }
28
+
29
+ export interface HealthCheckRunsTableProps {
30
+ runs: HealthCheckRunDetailed[];
31
+ loading: boolean;
32
+ emptyMessage?: string;
33
+ /** Show System ID and Configuration ID columns with link to detail page */
34
+ showFilterColumns?: boolean;
35
+ /** Number of columns for the expanded result row */
36
+ colSpan?: number;
37
+ /** Pagination state from usePagination hook */
38
+ pagination?: {
39
+ page: number;
40
+ totalPages: number;
41
+ total: number;
42
+ limit: number;
43
+ setPage: (page: number) => void;
44
+ setLimit: (limit: number) => void;
45
+ };
46
+ }
47
+
48
+ export const HealthCheckRunsTable: React.FC<HealthCheckRunsTableProps> = ({
49
+ runs,
50
+ loading,
51
+ emptyMessage = "No health check runs found.",
52
+ showFilterColumns = false,
53
+ colSpan,
54
+ pagination,
55
+ }) => {
56
+ const [expandedId, setExpandedId] = useState<string>();
57
+
58
+ const toggleExpand = (id: string) => {
59
+ setExpandedId(expandedId === id ? undefined : id);
60
+ };
61
+
62
+ // Calculate colspan based on columns shown
63
+ const calculatedColSpan = colSpan ?? (showFilterColumns ? 6 : 3);
64
+
65
+ if (loading) {
66
+ return <LoadingSpinner />;
67
+ }
68
+
69
+ if (runs.length === 0) {
70
+ return <p className="text-muted-foreground text-sm">{emptyMessage}</p>;
71
+ }
72
+
73
+ return (
74
+ <>
75
+ <div className="rounded-md border">
76
+ <Table>
77
+ <TableHeader>
78
+ <TableRow>
79
+ <TableHead className="w-10"></TableHead>
80
+ <TableHead className="w-24">Status</TableHead>
81
+ {showFilterColumns && (
82
+ <>
83
+ <TableHead>System ID</TableHead>
84
+ <TableHead>Configuration ID</TableHead>
85
+ </>
86
+ )}
87
+ <TableHead>Timestamp</TableHead>
88
+ {showFilterColumns && <TableHead className="w-16"></TableHead>}
89
+ </TableRow>
90
+ </TableHeader>
91
+ <TableBody>
92
+ {runs.map((run) => {
93
+ const isExpanded = expandedId === run.id;
94
+ return (
95
+ <Fragment key={run.id}>
96
+ <TableRow
97
+ className="cursor-pointer hover:bg-muted/50"
98
+ onClick={() => toggleExpand(run.id)}
99
+ >
100
+ <TableCell>
101
+ {isExpanded ? (
102
+ <ChevronDown className="h-4 w-4 text-muted-foreground" />
103
+ ) : (
104
+ <ChevronRight className="h-4 w-4 text-muted-foreground" />
105
+ )}
106
+ </TableCell>
107
+ <TableCell>
108
+ <HealthBadge status={run.status} />
109
+ </TableCell>
110
+ {showFilterColumns && (
111
+ <>
112
+ <TableCell className="font-mono text-xs">
113
+ {run.systemId}
114
+ </TableCell>
115
+ <TableCell className="font-mono text-xs">
116
+ {run.configurationId.slice(0, 8)}...
117
+ </TableCell>
118
+ </>
119
+ )}
120
+ <TableCell className="text-sm text-muted-foreground">
121
+ <span title={format(new Date(run.timestamp), "PPpp")}>
122
+ {formatDistanceToNow(new Date(run.timestamp), {
123
+ addSuffix: true,
124
+ })}
125
+ </span>
126
+ </TableCell>
127
+ {showFilterColumns && (
128
+ <TableCell>
129
+ <Button
130
+ variant="ghost"
131
+ size="sm"
132
+ asChild
133
+ onClick={(e) => e.stopPropagation()}
134
+ >
135
+ <Link
136
+ to={resolveRoute(
137
+ healthcheckRoutes.routes.historyDetail,
138
+ {
139
+ systemId: run.systemId,
140
+ configurationId: run.configurationId,
141
+ }
142
+ )}
143
+ >
144
+ <ExternalLink className="h-4 w-4" />
145
+ </Link>
146
+ </Button>
147
+ </TableCell>
148
+ )}
149
+ </TableRow>
150
+ {isExpanded && (
151
+ <TableRow>
152
+ <TableCell
153
+ colSpan={calculatedColSpan}
154
+ className="bg-muted/30 p-4"
155
+ >
156
+ <div className="space-y-2">
157
+ <h4 className="text-sm font-medium">Result Data</h4>
158
+ <pre className="text-xs bg-card rounded-md p-3 overflow-auto max-h-64 border">
159
+ {JSON.stringify(run.result, undefined, 2)}
160
+ </pre>
161
+ </div>
162
+ </TableCell>
163
+ </TableRow>
164
+ )}
165
+ </Fragment>
166
+ );
167
+ })}
168
+ </TableBody>
169
+ </Table>
170
+ </div>
171
+ {pagination && pagination.totalPages > 1 && (
172
+ <div className="mt-4">
173
+ <Pagination
174
+ page={pagination.page}
175
+ totalPages={pagination.totalPages}
176
+ onPageChange={pagination.setPage}
177
+ total={pagination.total}
178
+ limit={pagination.limit}
179
+ onPageSizeChange={pagination.setLimit}
180
+ showTotal
181
+ showPageSize
182
+ />
183
+ </div>
184
+ )}
185
+ </>
186
+ );
187
+ };
@@ -0,0 +1,46 @@
1
+ import React from "react";
2
+ import type { HealthCheckStatus } from "@checkmate-monitor/healthcheck-common";
3
+ import { cn } from "@checkmate-monitor/ui";
4
+
5
+ interface HealthCheckSparklineProps {
6
+ runs: Array<{
7
+ status: HealthCheckStatus;
8
+ }>;
9
+ className?: string;
10
+ }
11
+
12
+ const statusColors: Record<HealthCheckStatus, string> = {
13
+ healthy: "bg-success",
14
+ degraded: "bg-warning",
15
+ unhealthy: "bg-destructive",
16
+ };
17
+
18
+ /**
19
+ * Sparkline visualization showing recent health check runs.
20
+ * Each run is represented as a small colored rectangle.
21
+ * Runs are displayed newest (left) to oldest (right).
22
+ */
23
+ export const HealthCheckSparkline: React.FC<HealthCheckSparklineProps> = ({
24
+ runs,
25
+ className,
26
+ }) => {
27
+ // Ensure we show 25 slots (with empty placeholders if fewer runs)
28
+ const slots = Array.from({ length: 25 }, (_, i) => runs[i]?.status);
29
+
30
+ return (
31
+ <div className={cn("flex gap-0.5 items-center", className)}>
32
+ {slots.map((status, index) => (
33
+ <div
34
+ key={index}
35
+ className={cn(
36
+ "w-2 h-4 rounded-sm transition-all",
37
+ status ? statusColors[status] : "bg-muted/40"
38
+ )}
39
+ title={
40
+ status ? `Run ${index + 1}: ${status}` : `Run ${index + 1}: No data`
41
+ }
42
+ />
43
+ ))}
44
+ </div>
45
+ );
46
+ };
@@ -0,0 +1,190 @@
1
+ import {
2
+ BarChart,
3
+ Bar,
4
+ XAxis,
5
+ Tooltip,
6
+ ResponsiveContainer,
7
+ Cell,
8
+ } from "recharts";
9
+ import { format } from "date-fns";
10
+
11
+ export interface StatusDataPoint {
12
+ timestamp: Date;
13
+ status: "healthy" | "degraded" | "unhealthy";
14
+ }
15
+
16
+ export interface AggregatedStatusDataPoint {
17
+ bucketStart: Date;
18
+ healthyCount: number;
19
+ degradedCount: number;
20
+ unhealthyCount: number;
21
+ runCount: number;
22
+ bucketSize: "hourly" | "daily";
23
+ }
24
+
25
+ type RawStatusTimelineProps = {
26
+ type: "raw";
27
+ data: StatusDataPoint[];
28
+ height?: number;
29
+ };
30
+
31
+ type AggregatedStatusTimelineProps = {
32
+ type: "aggregated";
33
+ data: AggregatedStatusDataPoint[];
34
+ height?: number;
35
+ };
36
+
37
+ type HealthCheckStatusTimelineProps =
38
+ | RawStatusTimelineProps
39
+ | AggregatedStatusTimelineProps;
40
+
41
+ const statusColors = {
42
+ healthy: "hsl(var(--success))",
43
+ degraded: "hsl(var(--warning))",
44
+ unhealthy: "hsl(var(--destructive))",
45
+ };
46
+
47
+ /**
48
+ * Timeline bar chart showing health check status changes over time.
49
+ * For raw data: each bar represents a check run with color indicating status.
50
+ * For aggregated data: each bar shows the distribution of statuses in that bucket.
51
+ */
52
+ export const HealthCheckStatusTimeline: React.FC<
53
+ HealthCheckStatusTimelineProps
54
+ > = (props) => {
55
+ const { height = 60 } = props;
56
+
57
+ if (props.data.length === 0) {
58
+ return (
59
+ <div
60
+ className="flex items-center justify-center text-muted-foreground"
61
+ style={{ height }}
62
+ >
63
+ No status data available
64
+ </div>
65
+ );
66
+ }
67
+
68
+ const isAggregated = props.type === "aggregated";
69
+
70
+ // For raw data: transform to chart format
71
+ // For aggregated data: use stacked bar format
72
+ if (isAggregated) {
73
+ const aggData = props.data as AggregatedStatusDataPoint[];
74
+ const chartData = aggData.map((d) => ({
75
+ timestamp: d.bucketStart.getTime(),
76
+ healthy: d.healthyCount,
77
+ degraded: d.degradedCount,
78
+ unhealthy: d.unhealthyCount,
79
+ total: d.runCount,
80
+ }));
81
+
82
+ const timeFormat =
83
+ aggData[0]?.bucketSize === "daily" ? "MMM d" : "MMM d HH:mm";
84
+
85
+ return (
86
+ <ResponsiveContainer width="100%" height={height}>
87
+ <BarChart data={chartData} barGap={1}>
88
+ <XAxis
89
+ dataKey="timestamp"
90
+ type="number"
91
+ domain={["auto", "auto"]}
92
+ tickFormatter={(ts: number) => format(new Date(ts), timeFormat)}
93
+ stroke="hsl(var(--muted-foreground))"
94
+ fontSize={10}
95
+ />
96
+ <Tooltip
97
+ content={({ active, payload }) => {
98
+ if (!active || !payload?.length) return;
99
+ // Note: payload[0].payload is typed as `any` in recharts - this is a recharts limitation.
100
+ const data = payload[0].payload as (typeof chartData)[number];
101
+ return (
102
+ <div
103
+ className="rounded-md border bg-popover p-2 text-sm shadow-md"
104
+ style={{
105
+ backgroundColor: "hsl(var(--popover))",
106
+ border: "1px solid hsl(var(--border))",
107
+ }}
108
+ >
109
+ <p className="text-muted-foreground mb-1">
110
+ {format(new Date(data.timestamp), "MMM d, HH:mm")}
111
+ </p>
112
+ <div className="space-y-0.5">
113
+ <p className="text-success">Healthy: {data.healthy}</p>
114
+ <p className="text-warning">Degraded: {data.degraded}</p>
115
+ <p className="text-destructive">
116
+ Unhealthy: {data.unhealthy}
117
+ </p>
118
+ </div>
119
+ </div>
120
+ );
121
+ }}
122
+ />
123
+ <Bar dataKey="healthy" stackId="status" fill={statusColors.healthy} />
124
+ <Bar
125
+ dataKey="degraded"
126
+ stackId="status"
127
+ fill={statusColors.degraded}
128
+ />
129
+ <Bar
130
+ dataKey="unhealthy"
131
+ stackId="status"
132
+ fill={statusColors.unhealthy}
133
+ />
134
+ </BarChart>
135
+ </ResponsiveContainer>
136
+ );
137
+ }
138
+
139
+ // Raw data path
140
+ const rawData = props.data as StatusDataPoint[];
141
+ const chartData = rawData.toReversed().map((d) => ({
142
+ timestamp: d.timestamp.getTime(),
143
+ value: 1, // Fixed height for visibility
144
+ status: d.status,
145
+ }));
146
+
147
+ return (
148
+ <ResponsiveContainer width="100%" height={height}>
149
+ <BarChart data={chartData} barGap={1}>
150
+ <XAxis
151
+ dataKey="timestamp"
152
+ type="number"
153
+ domain={["auto", "auto"]}
154
+ tickFormatter={(ts: number) => format(new Date(ts), "HH:mm")}
155
+ stroke="hsl(var(--muted-foreground))"
156
+ fontSize={10}
157
+ />
158
+ <Tooltip
159
+ content={({ active, payload }) => {
160
+ if (!active || !payload?.length) return;
161
+ // Note: payload[0].payload is typed as `any` in recharts - this is a recharts limitation.
162
+ const data = payload[0].payload as (typeof chartData)[number];
163
+ return (
164
+ <div
165
+ className="rounded-md border bg-popover p-2 text-sm shadow-md"
166
+ style={{
167
+ backgroundColor: "hsl(var(--popover))",
168
+ border: "1px solid hsl(var(--border))",
169
+ }}
170
+ >
171
+ <p className="text-muted-foreground">
172
+ {format(new Date(data.timestamp), "MMM d, HH:mm:ss")}
173
+ </p>
174
+ <p className="font-medium capitalize">{data.status}</p>
175
+ </div>
176
+ );
177
+ }}
178
+ />
179
+ <Bar dataKey="value" radius={[2, 2, 0, 0]}>
180
+ {chartData.map((entry, index) => (
181
+ <Cell
182
+ key={index}
183
+ fill={statusColors[entry.status as keyof typeof statusColors]}
184
+ />
185
+ ))}
186
+ </Bar>
187
+ </BarChart>
188
+ </ResponsiveContainer>
189
+ );
190
+ };