@checkstack/healthcheck-frontend 0.0.2 → 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.
@@ -8,50 +8,130 @@ import {
8
8
  ReferenceLine,
9
9
  } from "recharts";
10
10
  import { format } from "date-fns";
11
+ import type { HealthCheckDiagramSlotContext } from "../slots";
11
12
 
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[];
13
+ interface HealthCheckLatencyChartProps {
14
+ context: HealthCheckDiagramSlotContext;
36
15
  height?: number;
37
16
  showAverage?: boolean;
38
- };
39
-
40
- type HealthCheckLatencyChartProps =
41
- | RawLatencyChartProps
42
- | AggregatedLatencyChartProps;
17
+ }
43
18
 
44
19
  /**
45
20
  * Area chart showing health check latency over time.
46
21
  * Supports both raw per-run data and aggregated bucket data.
47
22
  * Uses HSL CSS variables for theming consistency.
48
23
  */
49
- export const HealthCheckLatencyChart: React.FC<HealthCheckLatencyChartProps> = (
50
- props
51
- ) => {
52
- const { height = 200, showAverage = true } = props;
24
+ export const HealthCheckLatencyChart: React.FC<
25
+ HealthCheckLatencyChartProps
26
+ > = ({ context, height = 200, showAverage = true }) => {
27
+ if (context.type === "aggregated") {
28
+ const buckets = context.buckets.filter((b) => b.avgLatencyMs !== undefined);
29
+
30
+ if (buckets.length === 0) {
31
+ return (
32
+ <div
33
+ className="flex items-center justify-center text-muted-foreground"
34
+ style={{ height }}
35
+ >
36
+ No latency data available
37
+ </div>
38
+ );
39
+ }
40
+
41
+ const chartData = buckets.map((d) => ({
42
+ timestamp: new Date(d.bucketStart).getTime(),
43
+ latencyMs: d.avgLatencyMs!,
44
+ minLatencyMs: d.minLatencyMs,
45
+ maxLatencyMs: d.maxLatencyMs,
46
+ }));
47
+
48
+ const avgLatency =
49
+ chartData.length > 0
50
+ ? chartData.reduce((sum, d) => sum + d.latencyMs, 0) / chartData.length
51
+ : 0;
52
+
53
+ const timeFormat =
54
+ buckets[0]?.bucketSize === "daily" ? "MMM d" : "MMM d HH:mm";
53
55
 
54
- if (props.data.length === 0) {
56
+ return (
57
+ <ResponsiveContainer width="100%" height={height}>
58
+ <AreaChart data={chartData}>
59
+ <defs>
60
+ <linearGradient id="latencyGradient" x1="0" y1="0" x2="0" y2="1">
61
+ <stop
62
+ offset="5%"
63
+ stopColor="hsl(var(--primary))"
64
+ stopOpacity={0.3}
65
+ />
66
+ <stop
67
+ offset="95%"
68
+ stopColor="hsl(var(--primary))"
69
+ stopOpacity={0}
70
+ />
71
+ </linearGradient>
72
+ </defs>
73
+ <XAxis
74
+ dataKey="timestamp"
75
+ type="number"
76
+ domain={["auto", "auto"]}
77
+ tickFormatter={(ts: number) => format(new Date(ts), timeFormat)}
78
+ stroke="hsl(var(--muted-foreground))"
79
+ fontSize={12}
80
+ />
81
+ <YAxis
82
+ stroke="hsl(var(--muted-foreground))"
83
+ fontSize={12}
84
+ tickFormatter={(v: number) => `${v}ms`}
85
+ />
86
+ <Tooltip<number, "latencyMs">
87
+ content={({ active, payload }) => {
88
+ if (!active || !payload?.length) return;
89
+ const data = payload[0].payload as (typeof chartData)[number];
90
+ return (
91
+ <div
92
+ className="rounded-md border bg-popover p-2 text-sm shadow-md"
93
+ style={{
94
+ backgroundColor: "hsl(var(--popover))",
95
+ border: "1px solid hsl(var(--border))",
96
+ }}
97
+ >
98
+ <p className="text-muted-foreground">
99
+ {format(new Date(data.timestamp), "MMM d, HH:mm:ss")}
100
+ </p>
101
+ <p className="font-medium">{data.latencyMs}ms</p>
102
+ </div>
103
+ );
104
+ }}
105
+ />
106
+ {showAverage && (
107
+ <ReferenceLine
108
+ y={avgLatency}
109
+ stroke="hsl(var(--muted-foreground))"
110
+ strokeDasharray="3 3"
111
+ label={{
112
+ value: `Avg: ${avgLatency.toFixed(0)}ms`,
113
+ position: "right",
114
+ fill: "hsl(var(--muted-foreground))",
115
+ fontSize: 12,
116
+ }}
117
+ />
118
+ )}
119
+ <Area
120
+ type="monotone"
121
+ dataKey="latencyMs"
122
+ stroke="hsl(var(--primary))"
123
+ fill="url(#latencyGradient)"
124
+ strokeWidth={2}
125
+ />
126
+ </AreaChart>
127
+ </ResponsiveContainer>
128
+ );
129
+ }
130
+
131
+ // Raw data path
132
+ const runs = context.runs.filter((r) => r.latencyMs !== undefined);
133
+
134
+ if (runs.length === 0) {
55
135
  return (
56
136
  <div
57
137
  className="flex items-center justify-center text-muted-foreground"
@@ -62,34 +142,16 @@ export const HealthCheckLatencyChart: React.FC<HealthCheckLatencyChartProps> = (
62
142
  );
63
143
  }
64
144
 
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
- }));
145
+ const chartData = runs.toReversed().map((d) => ({
146
+ timestamp: new Date(d.timestamp).getTime(),
147
+ latencyMs: d.latencyMs!,
148
+ }));
79
149
 
80
- // Calculate average latency
81
150
  const avgLatency =
82
151
  chartData.length > 0
83
152
  ? chartData.reduce((sum, d) => sum + d.latencyMs, 0) / chartData.length
84
153
  : 0;
85
154
 
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
155
  return (
94
156
  <ResponsiveContainer width="100%" height={height}>
95
157
  <AreaChart data={chartData}>
@@ -111,7 +173,7 @@ export const HealthCheckLatencyChart: React.FC<HealthCheckLatencyChartProps> = (
111
173
  dataKey="timestamp"
112
174
  type="number"
113
175
  domain={["auto", "auto"]}
114
- tickFormatter={(ts: number) => format(new Date(ts), timeFormat)}
176
+ tickFormatter={(ts: number) => format(new Date(ts), "HH:mm")}
115
177
  stroke="hsl(var(--muted-foreground))"
116
178
  fontSize={12}
117
179
  />
@@ -123,8 +185,6 @@ export const HealthCheckLatencyChart: React.FC<HealthCheckLatencyChartProps> = (
123
185
  <Tooltip<number, "latencyMs">
124
186
  content={({ active, payload }) => {
125
187
  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
188
  const data = payload[0].payload as (typeof chartData)[number];
129
189
  return (
130
190
  <div
@@ -153,12 +153,7 @@ export const HealthCheckRunsTable: React.FC<HealthCheckRunsTableProps> = ({
153
153
  colSpan={calculatedColSpan}
154
154
  className="bg-muted/30 p-4"
155
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>
156
+ <ExpandedResultView result={run.result} />
162
157
  </TableCell>
163
158
  </TableRow>
164
159
  )}
@@ -185,3 +180,144 @@ export const HealthCheckRunsTable: React.FC<HealthCheckRunsTableProps> = ({
185
180
  </>
186
181
  );
187
182
  };
183
+
184
+ // =============================================================================
185
+ // EXPANDED RESULT VIEW
186
+ // =============================================================================
187
+
188
+ interface ExpandedResultViewProps {
189
+ result: Record<string, unknown>;
190
+ }
191
+
192
+ /**
193
+ * Displays the result data in a structured format.
194
+ * Shows collector results as cards with key-value pairs.
195
+ */
196
+ function ExpandedResultView({ result }: ExpandedResultViewProps) {
197
+ const metadata = result.metadata as Record<string, unknown> | undefined;
198
+ const rawCollectors = metadata?.collectors;
199
+
200
+ // Type guard for collectors object
201
+ const collectors: Record<string, Record<string, unknown>> | undefined =
202
+ rawCollectors &&
203
+ typeof rawCollectors === "object" &&
204
+ !Array.isArray(rawCollectors)
205
+ ? (rawCollectors as Record<string, Record<string, unknown>>)
206
+ : undefined;
207
+
208
+ // Check if we have collectors to display
209
+ const collectorEntries = collectors ? Object.entries(collectors) : [];
210
+
211
+ // Extract connection time as typed value
212
+ const connectionTimeMs = metadata?.connectionTimeMs as number | undefined;
213
+
214
+ return (
215
+ <div className="space-y-4">
216
+ <div className="flex gap-4 text-sm">
217
+ <div>
218
+ <span className="text-muted-foreground">Status: </span>
219
+ <span className="font-medium">{String(result.status)}</span>
220
+ </div>
221
+ <div>
222
+ <span className="text-muted-foreground">Latency: </span>
223
+ <span className="font-medium">{String(result.latencyMs)}ms</span>
224
+ </div>
225
+ {connectionTimeMs !== undefined && (
226
+ <div>
227
+ <span className="text-muted-foreground">Connection: </span>
228
+ <span className="font-medium">{connectionTimeMs}ms</span>
229
+ </div>
230
+ )}
231
+ </div>
232
+
233
+ {collectorEntries.length > 0 && (
234
+ <div className="space-y-3">
235
+ <h4 className="text-sm font-medium">Collector Results</h4>
236
+ <div className="grid gap-3 md:grid-cols-2">
237
+ {collectorEntries.map(([collectorId, collectorResult]) => (
238
+ <CollectorResultCard
239
+ key={collectorId}
240
+ collectorId={collectorId}
241
+ result={collectorResult}
242
+ />
243
+ ))}
244
+ </div>
245
+ </div>
246
+ )}
247
+
248
+ {result.message ? (
249
+ <div className="text-sm text-muted-foreground">
250
+ {String(result.message)}
251
+ </div>
252
+ ) : undefined}
253
+ </div>
254
+ );
255
+ }
256
+
257
+ interface CollectorResultCardProps {
258
+ collectorId: string;
259
+ result: Record<string, unknown>;
260
+ }
261
+
262
+ /**
263
+ * Card displaying a single collector's result values.
264
+ */
265
+ function CollectorResultCard({
266
+ collectorId,
267
+ result,
268
+ }: CollectorResultCardProps) {
269
+ if (!result || typeof result !== "object") {
270
+ return;
271
+ }
272
+
273
+ // Filter out null/undefined values
274
+ const entries = Object.entries(result).filter(
275
+ ([, value]) => value !== null && value !== undefined
276
+ );
277
+
278
+ return (
279
+ <div className="rounded-md border bg-card p-3 space-y-2">
280
+ <h5 className="text-sm font-medium text-primary">{collectorId}</h5>
281
+ <div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
282
+ {entries.map(([key, value]) => (
283
+ <div key={key} className="contents">
284
+ <span className="text-muted-foreground truncate">
285
+ {formatKey(key)}
286
+ </span>
287
+ <span className="font-mono text-xs truncate" title={String(value)}>
288
+ {formatValue(value)}
289
+ </span>
290
+ </div>
291
+ ))}
292
+ </div>
293
+ </div>
294
+ );
295
+ }
296
+
297
+ /**
298
+ * Format a camelCase key to a readable label.
299
+ */
300
+ function formatKey(key: string): string {
301
+ return key
302
+ .replaceAll(/([a-z])([A-Z])/g, "$1 $2")
303
+ .replaceAll(/^./, (c) => c.toUpperCase());
304
+ }
305
+
306
+ /**
307
+ * Format a value for display.
308
+ */
309
+ function formatValue(value: unknown): string {
310
+ if (value === null || value === undefined) return "—";
311
+ if (typeof value === "boolean") return value ? "Yes" : "No";
312
+ if (typeof value === "number") {
313
+ return Number.isInteger(value) ? String(value) : value.toFixed(2);
314
+ }
315
+ if (Array.isArray(value)) {
316
+ return value.length > 3
317
+ ? `[${value.slice(0, 3).join(", ")}…]`
318
+ : `[${value.join(", ")}]`;
319
+ }
320
+ if (typeof value === "object") return JSON.stringify(value);
321
+ const str = String(value);
322
+ return str.length > 50 ? `${str.slice(0, 47)}…` : str;
323
+ }
@@ -7,36 +7,12 @@ import {
7
7
  Cell,
8
8
  } from "recharts";
9
9
  import { format } from "date-fns";
10
+ import type { HealthCheckDiagramSlotContext } from "../slots";
10
11
 
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[];
12
+ interface HealthCheckStatusTimelineProps {
13
+ context: HealthCheckDiagramSlotContext;
28
14
  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;
15
+ }
40
16
 
41
17
  const statusColors = {
42
18
  healthy: "hsl(var(--success))",
@@ -51,28 +27,23 @@ const statusColors = {
51
27
  */
52
28
  export const HealthCheckStatusTimeline: React.FC<
53
29
  HealthCheckStatusTimelineProps
54
- > = (props) => {
55
- const { height = 60 } = props;
30
+ > = ({ context, height = 60 }) => {
31
+ if (context.type === "aggregated") {
32
+ const buckets = context.buckets;
56
33
 
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";
34
+ if (buckets.length === 0) {
35
+ return (
36
+ <div
37
+ className="flex items-center justify-center text-muted-foreground"
38
+ style={{ height }}
39
+ >
40
+ No status data available
41
+ </div>
42
+ );
43
+ }
69
44
 
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(),
45
+ const chartData = buckets.map((d) => ({
46
+ timestamp: new Date(d.bucketStart).getTime(),
76
47
  healthy: d.healthyCount,
77
48
  degraded: d.degradedCount,
78
49
  unhealthy: d.unhealthyCount,
@@ -80,7 +51,7 @@ export const HealthCheckStatusTimeline: React.FC<
80
51
  }));
81
52
 
82
53
  const timeFormat =
83
- aggData[0]?.bucketSize === "daily" ? "MMM d" : "MMM d HH:mm";
54
+ buckets[0]?.bucketSize === "daily" ? "MMM d" : "MMM d HH:mm";
84
55
 
85
56
  return (
86
57
  <ResponsiveContainer width="100%" height={height}>
@@ -137,9 +108,21 @@ export const HealthCheckStatusTimeline: React.FC<
137
108
  }
138
109
 
139
110
  // Raw data path
140
- const rawData = props.data as StatusDataPoint[];
141
- const chartData = rawData.toReversed().map((d) => ({
142
- timestamp: d.timestamp.getTime(),
111
+ const runs = context.runs;
112
+
113
+ if (runs.length === 0) {
114
+ return (
115
+ <div
116
+ className="flex items-center justify-center text-muted-foreground"
117
+ style={{ height }}
118
+ >
119
+ No status data available
120
+ </div>
121
+ );
122
+ }
123
+
124
+ const chartData = runs.toReversed().map((d) => ({
125
+ timestamp: new Date(d.timestamp).getTime(),
143
126
  value: 1, // Fixed height for visibility
144
127
  status: d.status,
145
128
  }));