@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,62 @@
1
+ /**
2
+ * Hook to fetch and cache strategy schemas.
3
+ */
4
+
5
+ import { useEffect, useState } from "react";
6
+ import { useApi } from "@checkmate-monitor/frontend-api";
7
+ import { healthCheckApiRef } from "../api";
8
+
9
+ interface StrategySchemas {
10
+ resultSchema: Record<string, unknown> | undefined;
11
+ aggregatedResultSchema: Record<string, unknown> | undefined;
12
+ }
13
+
14
+ /**
15
+ * Fetch and cache strategy schemas for auto-chart rendering.
16
+ *
17
+ * @param strategyId - The strategy ID to fetch schemas for
18
+ * @returns Schemas for the strategy, or undefined if not found
19
+ */
20
+ export function useStrategySchemas(strategyId: string): {
21
+ schemas: StrategySchemas | undefined;
22
+ loading: boolean;
23
+ } {
24
+ const api = useApi(healthCheckApiRef);
25
+ const [schemas, setSchemas] = useState<StrategySchemas | undefined>();
26
+ const [loading, setLoading] = useState(true);
27
+
28
+ useEffect(() => {
29
+ let cancelled = false;
30
+
31
+ async function fetchSchemas() {
32
+ try {
33
+ const strategies = await api.getStrategies();
34
+ const strategy = strategies.find((s) => s.id === strategyId);
35
+
36
+ if (!cancelled && strategy) {
37
+ setSchemas({
38
+ resultSchema:
39
+ (strategy.resultSchema as Record<string, unknown>) ?? undefined,
40
+ aggregatedResultSchema:
41
+ (strategy.aggregatedResultSchema as Record<string, unknown>) ??
42
+ undefined,
43
+ });
44
+ }
45
+ } catch (error) {
46
+ console.error("Failed to fetch strategy schemas:", error);
47
+ } finally {
48
+ if (!cancelled) {
49
+ setLoading(false);
50
+ }
51
+ }
52
+ }
53
+
54
+ fetchSchemas();
55
+
56
+ return () => {
57
+ cancelled = true;
58
+ };
59
+ }, [api, strategyId]);
60
+
61
+ return { schemas, loading };
62
+ }
@@ -0,0 +1,24 @@
1
+ import { InfoBanner } from "@checkmate-monitor/ui";
2
+
3
+ interface AggregatedDataBannerProps {
4
+ bucketSize: "hourly" | "daily";
5
+ rawRetentionDays: number;
6
+ }
7
+
8
+ /**
9
+ * Banner shown when viewing aggregated health check data.
10
+ * Informs users about the aggregation level and how to see detailed data.
11
+ */
12
+ export function AggregatedDataBanner({
13
+ bucketSize,
14
+ rawRetentionDays,
15
+ }: AggregatedDataBannerProps) {
16
+ const bucketLabel = bucketSize === "hourly" ? "hourly" : "daily";
17
+
18
+ return (
19
+ <InfoBanner variant="info">
20
+ Showing {bucketLabel} aggregates. For per-run data, select a range ≤{" "}
21
+ {rawRetentionDays} days.
22
+ </InfoBanner>
23
+ );
24
+ }
@@ -0,0 +1,88 @@
1
+ import { ExtensionSlot } from "@checkmate-monitor/frontend-api";
2
+ import { LoadingSpinner, InfoBanner } from "@checkmate-monitor/ui";
3
+ import { useHealthCheckData } from "../hooks/useHealthCheckData";
4
+ import { HealthCheckDiagramSlot } from "../slots";
5
+ import { AggregatedDataBanner } from "./AggregatedDataBanner";
6
+
7
+ interface HealthCheckDiagramProps {
8
+ systemId: string;
9
+ configurationId: string;
10
+ strategyId: string;
11
+ dateRange: {
12
+ startDate: Date;
13
+ endDate: Date;
14
+ };
15
+ limit?: number;
16
+ offset?: number;
17
+ }
18
+
19
+ /**
20
+ * Wrapper component that handles loading health check data and rendering
21
+ * the diagram extension slot with the appropriate context (raw or aggregated).
22
+ *
23
+ * Automatically determines whether to use raw or aggregated data based on
24
+ * the date range and the configured rawRetentionDays.
25
+ */
26
+ export function HealthCheckDiagram({
27
+ systemId,
28
+ configurationId,
29
+ strategyId,
30
+ dateRange,
31
+ limit,
32
+ offset,
33
+ }: HealthCheckDiagramProps) {
34
+ const {
35
+ context,
36
+ loading,
37
+ hasPermission,
38
+ permissionLoading,
39
+ isAggregated,
40
+ retentionConfig,
41
+ } = useHealthCheckData({
42
+ systemId,
43
+ configurationId,
44
+ strategyId,
45
+ dateRange,
46
+ limit,
47
+ offset,
48
+ });
49
+
50
+ if (permissionLoading) {
51
+ return <LoadingSpinner />;
52
+ }
53
+
54
+ if (!hasPermission) {
55
+ return (
56
+ <InfoBanner variant="info">
57
+ Additional strategy-specific visualizations are available with the
58
+ &quot;Read Health Check Details&quot; permission.
59
+ </InfoBanner>
60
+ );
61
+ }
62
+
63
+ if (loading) {
64
+ return <LoadingSpinner />;
65
+ }
66
+
67
+ if (!context) {
68
+ return;
69
+ }
70
+
71
+ // Determine bucket size from context for aggregated data
72
+ const bucketSize =
73
+ context.type === "aggregated" && context.buckets.length > 0
74
+ ? context.buckets[0].bucketSize
75
+ : "hourly";
76
+
77
+ return (
78
+ <>
79
+ {isAggregated && (
80
+ <AggregatedDataBanner
81
+ bucketSize={bucketSize}
82
+ rawRetentionDays={retentionConfig.rawRetentionDays}
83
+ />
84
+ )}
85
+ <ExtensionSlot slot={HealthCheckDiagramSlot} context={context} />
86
+ </>
87
+ );
88
+ }
@@ -0,0 +1,136 @@
1
+ import React, { useState } from "react";
2
+ import {
3
+ HealthCheckConfiguration,
4
+ HealthCheckStrategyDto,
5
+ CreateHealthCheckConfiguration,
6
+ } from "@checkmate-monitor/healthcheck-common";
7
+ import {
8
+ Button,
9
+ Input,
10
+ Label,
11
+ PluginConfigForm,
12
+ useToast,
13
+ Dialog,
14
+ DialogContent,
15
+ DialogHeader,
16
+ DialogTitle,
17
+ DialogFooter,
18
+ } from "@checkmate-monitor/ui";
19
+
20
+ interface HealthCheckEditorProps {
21
+ strategies: HealthCheckStrategyDto[];
22
+ initialData?: HealthCheckConfiguration;
23
+ onSave: (data: CreateHealthCheckConfiguration) => Promise<void>;
24
+ onCancel: () => void;
25
+ open: boolean;
26
+ }
27
+
28
+ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
29
+ strategies,
30
+ initialData,
31
+ onSave,
32
+ onCancel,
33
+ open,
34
+ }) => {
35
+ const [name, setName] = useState(initialData?.name || "");
36
+ const [strategyId, setStrategyId] = useState(initialData?.strategyId || "");
37
+ const [interval, setInterval] = useState(
38
+ initialData?.intervalSeconds?.toString() || "60"
39
+ );
40
+ const [config, setConfig] = useState<Record<string, unknown>>(
41
+ (initialData?.config as Record<string, unknown>) || {}
42
+ );
43
+
44
+ const toast = useToast();
45
+ const [loading, setLoading] = useState(false);
46
+
47
+ // Reset form when dialog opens with new data
48
+ React.useEffect(() => {
49
+ if (open) {
50
+ setName(initialData?.name || "");
51
+ setStrategyId(initialData?.strategyId || "");
52
+ setInterval(initialData?.intervalSeconds?.toString() || "60");
53
+ setConfig((initialData?.config as Record<string, unknown>) || {});
54
+ }
55
+ }, [open, initialData]);
56
+
57
+ const handleSave = async (e: React.FormEvent) => {
58
+ e.preventDefault();
59
+ setLoading(true);
60
+ try {
61
+ await onSave({
62
+ name,
63
+ strategyId,
64
+ intervalSeconds: Number.parseInt(interval, 10),
65
+ config,
66
+ });
67
+ } catch (error) {
68
+ const message =
69
+ error instanceof Error ? error.message : "Failed to save health check";
70
+ toast.error(message);
71
+ console.error(error);
72
+ } finally {
73
+ setLoading(false);
74
+ }
75
+ };
76
+
77
+ return (
78
+ <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onCancel()}>
79
+ <DialogContent size="lg">
80
+ <form onSubmit={handleSave}>
81
+ <DialogHeader>
82
+ <DialogTitle>
83
+ {initialData ? "Edit Health Check" : "Create Health Check"}
84
+ </DialogTitle>
85
+ </DialogHeader>
86
+
87
+ <div className="space-y-4 py-4">
88
+ <div className="space-y-2">
89
+ <Label htmlFor="name">Name</Label>
90
+ <Input
91
+ id="name"
92
+ value={name}
93
+ onChange={(e) => setName(e.target.value)}
94
+ required
95
+ />
96
+ </div>
97
+
98
+ <div className="space-y-2">
99
+ <Label htmlFor="interval">Interval (seconds)</Label>
100
+ <Input
101
+ id="interval"
102
+ type="number"
103
+ min="1"
104
+ value={interval}
105
+ onChange={(e) => setInterval(e.target.value)}
106
+ required
107
+ />
108
+ </div>
109
+
110
+ <PluginConfigForm
111
+ label="Strategy"
112
+ plugins={strategies}
113
+ selectedPluginId={strategyId}
114
+ onPluginChange={(id) => {
115
+ setStrategyId(id);
116
+ setConfig({});
117
+ }}
118
+ config={config}
119
+ onConfigChange={setConfig}
120
+ disabled={!!initialData}
121
+ />
122
+ </div>
123
+
124
+ <DialogFooter>
125
+ <Button type="button" variant="outline" onClick={onCancel}>
126
+ Cancel
127
+ </Button>
128
+ <Button type="submit" disabled={loading}>
129
+ {loading ? "Saving..." : "Save"}
130
+ </Button>
131
+ </DialogFooter>
132
+ </form>
133
+ </DialogContent>
134
+ </Dialog>
135
+ );
136
+ };
@@ -0,0 +1,79 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { useApi, type SlotContext } from "@checkmate-monitor/frontend-api";
3
+ import { healthCheckApiRef, HealthCheckRunPublic } from "../api";
4
+ import { SystemDetailsSlot } from "@checkmate-monitor/catalog-common";
5
+ import {
6
+ Table,
7
+ TableHeader,
8
+ TableRow,
9
+ TableHead,
10
+ TableBody,
11
+ TableCell,
12
+ HealthBadge,
13
+ LoadingSpinner,
14
+ } from "@checkmate-monitor/ui";
15
+ import { formatDistanceToNow } from "date-fns";
16
+
17
+ // Props inferred from SystemDetailsSlot context, with optional additional props
18
+ type SlotProps = SlotContext<typeof SystemDetailsSlot>;
19
+ interface Props extends SlotProps {
20
+ configurationId?: string;
21
+ limit?: number;
22
+ }
23
+
24
+ export const HealthCheckHistory: React.FC<SlotProps> = (props) => {
25
+ const { system, configurationId, limit } = props as Props;
26
+ const systemId = system?.id;
27
+
28
+ const healthCheckApi = useApi(healthCheckApiRef);
29
+ const [history, setHistory] = useState<HealthCheckRunPublic[]>([]);
30
+ const [loading, setLoading] = useState(true);
31
+
32
+ useEffect(() => {
33
+ // If it's used in a context that doesn't provide systemId or configurationId,
34
+ // we might want to skip or handle it.
35
+ healthCheckApi
36
+ .getHistory({ systemId, configurationId, limit })
37
+ .then((response) => setHistory(response.runs))
38
+ .finally(() => setLoading(false));
39
+ }, [healthCheckApi, systemId, configurationId, limit]);
40
+
41
+ if (loading) return <LoadingSpinner />;
42
+
43
+ if (history.length === 0) {
44
+ return (
45
+ <p className="text-muted-foreground text-sm">
46
+ No health check history found.
47
+ </p>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <div className="rounded-md border">
53
+ <Table>
54
+ <TableHeader>
55
+ <TableRow>
56
+ <TableHead>Status</TableHead>
57
+ <TableHead>Time</TableHead>
58
+ </TableRow>
59
+ </TableHeader>
60
+ <TableBody>
61
+ {history.map((run) => (
62
+ <TableRow key={run.id}>
63
+ <TableCell>
64
+ <HealthBadge status={run.status} />
65
+ </TableCell>
66
+ <TableCell className="text-sm text-muted-foreground">
67
+ {run.timestamp
68
+ ? formatDistanceToNow(new Date(run.timestamp), {
69
+ addSuffix: true,
70
+ })
71
+ : "Unknown"}
72
+ </TableCell>
73
+ </TableRow>
74
+ ))}
75
+ </TableBody>
76
+ </Table>
77
+ </div>
78
+ );
79
+ };
@@ -0,0 +1,168 @@
1
+ import {
2
+ AreaChart,
3
+ Area,
4
+ XAxis,
5
+ YAxis,
6
+ Tooltip,
7
+ ResponsiveContainer,
8
+ ReferenceLine,
9
+ } from "recharts";
10
+ import { format } from "date-fns";
11
+
12
+ export interface LatencyDataPoint {
13
+ timestamp: Date;
14
+ latencyMs: number;
15
+ status: "healthy" | "degraded" | "unhealthy";
16
+ }
17
+
18
+ export interface AggregatedLatencyDataPoint {
19
+ bucketStart: Date;
20
+ avgLatencyMs: number;
21
+ minLatencyMs?: number;
22
+ maxLatencyMs?: number;
23
+ bucketSize: "hourly" | "daily";
24
+ }
25
+
26
+ type RawLatencyChartProps = {
27
+ type: "raw";
28
+ data: LatencyDataPoint[];
29
+ height?: number;
30
+ showAverage?: boolean;
31
+ };
32
+
33
+ type AggregatedLatencyChartProps = {
34
+ type: "aggregated";
35
+ data: AggregatedLatencyDataPoint[];
36
+ height?: number;
37
+ showAverage?: boolean;
38
+ };
39
+
40
+ type HealthCheckLatencyChartProps =
41
+ | RawLatencyChartProps
42
+ | AggregatedLatencyChartProps;
43
+
44
+ /**
45
+ * Area chart showing health check latency over time.
46
+ * Supports both raw per-run data and aggregated bucket data.
47
+ * Uses HSL CSS variables for theming consistency.
48
+ */
49
+ export const HealthCheckLatencyChart: React.FC<HealthCheckLatencyChartProps> = (
50
+ props
51
+ ) => {
52
+ const { height = 200, showAverage = true } = props;
53
+
54
+ if (props.data.length === 0) {
55
+ return (
56
+ <div
57
+ className="flex items-center justify-center text-muted-foreground"
58
+ style={{ height }}
59
+ >
60
+ No latency data available
61
+ </div>
62
+ );
63
+ }
64
+
65
+ // Transform data based on type
66
+ const isAggregated = props.type === "aggregated";
67
+
68
+ const chartData = isAggregated
69
+ ? (props.data as AggregatedLatencyDataPoint[]).map((d) => ({
70
+ timestamp: d.bucketStart.getTime(),
71
+ latencyMs: d.avgLatencyMs,
72
+ minLatencyMs: d.minLatencyMs,
73
+ maxLatencyMs: d.maxLatencyMs,
74
+ }))
75
+ : (props.data as LatencyDataPoint[]).toReversed().map((d) => ({
76
+ timestamp: d.timestamp.getTime(),
77
+ latencyMs: d.latencyMs,
78
+ }));
79
+
80
+ // Calculate average latency
81
+ const avgLatency =
82
+ chartData.length > 0
83
+ ? chartData.reduce((sum, d) => sum + d.latencyMs, 0) / chartData.length
84
+ : 0;
85
+
86
+ // Format based on bucket size for aggregated data
87
+ const timeFormat = isAggregated
88
+ ? (props.data as AggregatedLatencyDataPoint[])[0]?.bucketSize === "daily"
89
+ ? "MMM d"
90
+ : "MMM d HH:mm"
91
+ : "HH:mm";
92
+
93
+ return (
94
+ <ResponsiveContainer width="100%" height={height}>
95
+ <AreaChart data={chartData}>
96
+ <defs>
97
+ <linearGradient id="latencyGradient" x1="0" y1="0" x2="0" y2="1">
98
+ <stop
99
+ offset="5%"
100
+ stopColor="hsl(var(--primary))"
101
+ stopOpacity={0.3}
102
+ />
103
+ <stop
104
+ offset="95%"
105
+ stopColor="hsl(var(--primary))"
106
+ stopOpacity={0}
107
+ />
108
+ </linearGradient>
109
+ </defs>
110
+ <XAxis
111
+ dataKey="timestamp"
112
+ type="number"
113
+ domain={["auto", "auto"]}
114
+ tickFormatter={(ts: number) => format(new Date(ts), timeFormat)}
115
+ stroke="hsl(var(--muted-foreground))"
116
+ fontSize={12}
117
+ />
118
+ <YAxis
119
+ stroke="hsl(var(--muted-foreground))"
120
+ fontSize={12}
121
+ tickFormatter={(v: number) => `${v}ms`}
122
+ />
123
+ <Tooltip<number, "latencyMs">
124
+ content={({ active, payload }) => {
125
+ if (!active || !payload?.length) return;
126
+ // Note: payload[0].payload is typed as `any` in recharts - this is a recharts limitation.
127
+ // The Payload.payload property holds our data row but recharts can't infer its shape.
128
+ const data = payload[0].payload as (typeof chartData)[number];
129
+ return (
130
+ <div
131
+ className="rounded-md border bg-popover p-2 text-sm shadow-md"
132
+ style={{
133
+ backgroundColor: "hsl(var(--popover))",
134
+ border: "1px solid hsl(var(--border))",
135
+ }}
136
+ >
137
+ <p className="text-muted-foreground">
138
+ {format(new Date(data.timestamp), "MMM d, HH:mm:ss")}
139
+ </p>
140
+ <p className="font-medium">{data.latencyMs}ms</p>
141
+ </div>
142
+ );
143
+ }}
144
+ />
145
+ {showAverage && (
146
+ <ReferenceLine
147
+ y={avgLatency}
148
+ stroke="hsl(var(--muted-foreground))"
149
+ strokeDasharray="3 3"
150
+ label={{
151
+ value: `Avg: ${avgLatency.toFixed(0)}ms`,
152
+ position: "right",
153
+ fill: "hsl(var(--muted-foreground))",
154
+ fontSize: 12,
155
+ }}
156
+ />
157
+ )}
158
+ <Area
159
+ type="monotone"
160
+ dataKey="latencyMs"
161
+ stroke="hsl(var(--primary))"
162
+ fill="url(#latencyGradient)"
163
+ strokeWidth={2}
164
+ />
165
+ </AreaChart>
166
+ </ResponsiveContainer>
167
+ );
168
+ };
@@ -0,0 +1,84 @@
1
+ import React from "react";
2
+ import {
3
+ HealthCheckConfiguration,
4
+ HealthCheckStrategyDto,
5
+ } from "@checkmate-monitor/healthcheck-common";
6
+ import {
7
+ Table,
8
+ TableBody,
9
+ TableCell,
10
+ TableHead,
11
+ TableHeader,
12
+ TableRow,
13
+ Button,
14
+ } from "@checkmate-monitor/ui";
15
+ import { Trash2, Edit } from "lucide-react";
16
+
17
+ interface HealthCheckListProps {
18
+ configurations: HealthCheckConfiguration[];
19
+ strategies: HealthCheckStrategyDto[];
20
+ onEdit: (config: HealthCheckConfiguration) => void;
21
+ onDelete: (id: string) => void;
22
+ }
23
+
24
+ export const HealthCheckList: React.FC<HealthCheckListProps> = ({
25
+ configurations,
26
+ strategies,
27
+ onEdit,
28
+ onDelete,
29
+ }) => {
30
+ const getStrategyName = (id: string) => {
31
+ return strategies.find((s) => s.id === id)?.displayName || id;
32
+ };
33
+
34
+ return (
35
+ <div className="rounded-md border bg-card">
36
+ <Table>
37
+ <TableHeader>
38
+ <TableRow>
39
+ <TableHead>Name</TableHead>
40
+ <TableHead>Strategy</TableHead>
41
+ <TableHead>Interval (s)</TableHead>
42
+ <TableHead className="text-right">Actions</TableHead>
43
+ </TableRow>
44
+ </TableHeader>
45
+ <TableBody>
46
+ {configurations.length === 0 ? (
47
+ <TableRow>
48
+ <TableCell colSpan={4} className="h-24 text-center">
49
+ No health checks configured.
50
+ </TableCell>
51
+ </TableRow>
52
+ ) : (
53
+ configurations.map((config) => (
54
+ <TableRow key={config.id}>
55
+ <TableCell className="font-medium">{config.name}</TableCell>
56
+ <TableCell>{getStrategyName(config.strategyId)}</TableCell>
57
+ <TableCell>{config.intervalSeconds}</TableCell>
58
+ <TableCell className="text-right">
59
+ <div className="flex justify-end gap-2">
60
+ <Button
61
+ variant="ghost"
62
+ size="icon"
63
+ onClick={() => onEdit(config)}
64
+ >
65
+ <Edit className="h-4 w-4" />
66
+ </Button>
67
+ <Button
68
+ variant="ghost"
69
+ size="icon"
70
+ className="text-destructive hover:text-destructive"
71
+ onClick={() => onDelete(config.id)}
72
+ >
73
+ <Trash2 className="h-4 w-4" />
74
+ </Button>
75
+ </div>
76
+ </TableCell>
77
+ </TableRow>
78
+ ))
79
+ )}
80
+ </TableBody>
81
+ </Table>
82
+ </div>
83
+ );
84
+ };
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+ import { Link } from "react-router-dom";
3
+ import { Activity } from "lucide-react";
4
+ import { useApi, permissionApiRef } from "@checkmate-monitor/frontend-api";
5
+ import { DropdownMenuItem } from "@checkmate-monitor/ui";
6
+ import { resolveRoute } from "@checkmate-monitor/common";
7
+ import { healthcheckRoutes } from "@checkmate-monitor/healthcheck-common";
8
+
9
+ export const HealthCheckMenuItems = () => {
10
+ const permissionApi = useApi(permissionApiRef);
11
+ const { allowed: canRead, loading } = permissionApi.useResourcePermission(
12
+ "healthcheck",
13
+ "read"
14
+ );
15
+
16
+ if (loading || !canRead) {
17
+ return <React.Fragment />;
18
+ }
19
+
20
+ return (
21
+ <Link to={resolveRoute(healthcheckRoutes.routes.config)}>
22
+ <DropdownMenuItem icon={<Activity className="w-4 h-4" />}>
23
+ Health Checks
24
+ </DropdownMenuItem>
25
+ </Link>
26
+ );
27
+ };