@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.
@@ -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
+ &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 "@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
+ };