@checkstack/healthcheck-frontend 0.0.2
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 +135 -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 +33 -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 +380 -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 +99 -0
- package/src/pages/HealthCheckConfigPage.tsx +164 -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,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility to extract chart metadata from JSON Schema.
|
|
3
|
+
*
|
|
4
|
+
* Parses JSON Schema objects and extracts x-chart-type, x-chart-label,
|
|
5
|
+
* and x-chart-unit metadata for auto-chart rendering.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Available chart types for auto-generated visualizations.
|
|
10
|
+
* Mirrors the backend ChartType but defined locally since frontend
|
|
11
|
+
* cannot import from backend-api.
|
|
12
|
+
*/
|
|
13
|
+
export type ChartType =
|
|
14
|
+
| "line"
|
|
15
|
+
| "bar"
|
|
16
|
+
| "counter"
|
|
17
|
+
| "gauge"
|
|
18
|
+
| "boolean"
|
|
19
|
+
| "text"
|
|
20
|
+
| "status";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Chart field information extracted from JSON Schema.
|
|
24
|
+
*/
|
|
25
|
+
export interface ChartField {
|
|
26
|
+
/** Field name in the schema */
|
|
27
|
+
name: string;
|
|
28
|
+
/** Chart type to render */
|
|
29
|
+
chartType: ChartType;
|
|
30
|
+
/** Human-readable label (defaults to name) */
|
|
31
|
+
label: string;
|
|
32
|
+
/** Optional unit suffix (e.g., 'ms', '%') */
|
|
33
|
+
unit?: string;
|
|
34
|
+
/** JSON Schema type (number, string, boolean, etc.) */
|
|
35
|
+
schemaType: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* JSON Schema property with potential chart metadata.
|
|
40
|
+
*/
|
|
41
|
+
interface JsonSchemaProperty {
|
|
42
|
+
type?: string;
|
|
43
|
+
"x-chart-type"?: ChartType;
|
|
44
|
+
"x-chart-label"?: string;
|
|
45
|
+
"x-chart-unit"?: string;
|
|
46
|
+
items?: JsonSchemaProperty;
|
|
47
|
+
additionalProperties?: JsonSchemaProperty;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* JSON Schema object structure.
|
|
52
|
+
*/
|
|
53
|
+
interface JsonSchema {
|
|
54
|
+
type?: string;
|
|
55
|
+
properties?: Record<string, JsonSchemaProperty>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract chart fields from a JSON Schema.
|
|
60
|
+
*
|
|
61
|
+
* Looks for properties with x-chart-type metadata and extracts
|
|
62
|
+
* relevant chart configuration.
|
|
63
|
+
*
|
|
64
|
+
* @param schema - JSON Schema object
|
|
65
|
+
* @returns Array of chart fields with metadata
|
|
66
|
+
*/
|
|
67
|
+
export function extractChartFields(
|
|
68
|
+
schema: Record<string, unknown> | null | undefined
|
|
69
|
+
): ChartField[] {
|
|
70
|
+
if (!schema) return [];
|
|
71
|
+
|
|
72
|
+
const typed = schema as JsonSchema;
|
|
73
|
+
if (typed.type !== "object" || !typed.properties) return [];
|
|
74
|
+
|
|
75
|
+
const fields: ChartField[] = [];
|
|
76
|
+
|
|
77
|
+
for (const [name, prop] of Object.entries(typed.properties)) {
|
|
78
|
+
const chartType = prop["x-chart-type"];
|
|
79
|
+
if (!chartType) continue;
|
|
80
|
+
|
|
81
|
+
// Determine the underlying schema type
|
|
82
|
+
let schemaType = prop.type ?? "unknown";
|
|
83
|
+
if (prop.type === "array" && prop.items?.type) {
|
|
84
|
+
schemaType = `array<${prop.items.type}>`;
|
|
85
|
+
}
|
|
86
|
+
if (prop.additionalProperties?.type) {
|
|
87
|
+
schemaType = `record<${prop.additionalProperties.type}>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fields.push({
|
|
91
|
+
name,
|
|
92
|
+
chartType,
|
|
93
|
+
label: prop["x-chart-label"] ?? formatFieldName(name),
|
|
94
|
+
unit: prop["x-chart-unit"],
|
|
95
|
+
schemaType,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return fields;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Convert camelCase or snake_case field name to human-readable label.
|
|
104
|
+
*/
|
|
105
|
+
function formatFieldName(name: string): string {
|
|
106
|
+
return name
|
|
107
|
+
.replaceAll(/([a-z])([A-Z])/g, "$1 $2") // camelCase
|
|
108
|
+
.replaceAll("_", " ") // snake_case
|
|
109
|
+
.replaceAll(/\b\w/g, (c) => c.toUpperCase()); // Capitalize
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get the value for a field from a data object.
|
|
114
|
+
*/
|
|
115
|
+
export function getFieldValue(
|
|
116
|
+
data: Record<string, unknown> | undefined,
|
|
117
|
+
fieldName: string
|
|
118
|
+
): unknown {
|
|
119
|
+
if (!data) return undefined;
|
|
120
|
+
return data[fieldName];
|
|
121
|
+
}
|
|
@@ -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 "@checkstack/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 "@checkstack/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 "@checkstack/frontend-api";
|
|
2
|
+
import { LoadingSpinner, InfoBanner } from "@checkstack/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 "@checkstack/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 "@checkstack/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 "@checkstack/frontend-api";
|
|
3
|
+
import { healthCheckApiRef, HealthCheckRunPublic } from "../api";
|
|
4
|
+
import { SystemDetailsSlot } from "@checkstack/catalog-common";
|
|
5
|
+
import {
|
|
6
|
+
Table,
|
|
7
|
+
TableHeader,
|
|
8
|
+
TableRow,
|
|
9
|
+
TableHead,
|
|
10
|
+
TableBody,
|
|
11
|
+
TableCell,
|
|
12
|
+
HealthBadge,
|
|
13
|
+
LoadingSpinner,
|
|
14
|
+
} from "@checkstack/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
|
+
};
|