@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,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
|
+
"Read Health Check Details" 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
|
+
};
|