@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.
- package/CHANGELOG.md +63 -0
- package/package.json +36 -0
- package/src/api.ts +17 -0
- package/src/auto-charts/AutoChartGrid.tsx +383 -0
- package/src/auto-charts/extension.tsx +27 -0
- package/src/auto-charts/index.ts +12 -0
- package/src/auto-charts/schema-parser.ts +121 -0
- package/src/auto-charts/useStrategySchemas.ts +62 -0
- package/src/components/AggregatedDataBanner.tsx +24 -0
- package/src/components/HealthCheckDiagram.tsx +88 -0
- package/src/components/HealthCheckEditor.tsx +136 -0
- package/src/components/HealthCheckHistory.tsx +79 -0
- package/src/components/HealthCheckLatencyChart.tsx +168 -0
- package/src/components/HealthCheckList.tsx +84 -0
- package/src/components/HealthCheckMenuItems.tsx +27 -0
- package/src/components/HealthCheckRunsTable.tsx +187 -0
- package/src/components/HealthCheckSparkline.tsx +46 -0
- package/src/components/HealthCheckStatusTimeline.tsx +190 -0
- package/src/components/HealthCheckSystemOverview.tsx +328 -0
- package/src/components/SystemHealthBadge.tsx +46 -0
- package/src/components/SystemHealthCheckAssignment.tsx +869 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useHealthCheckData.ts +257 -0
- package/src/index.tsx +100 -0
- package/src/pages/HealthCheckConfigPage.tsx +151 -0
- package/src/pages/HealthCheckHistoryDetailPage.tsx +100 -0
- package/src/pages/HealthCheckHistoryPage.tsx +67 -0
- package/src/slots.tsx +185 -0
- package/tsconfig.json +6 -0
|
@@ -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
|
+
};
|