@checkstack/healthcheck-frontend 0.5.0 → 0.7.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 +113 -0
- package/package.json +1 -1
- package/src/auto-charts/AutoChartGrid.tsx +447 -99
- package/src/components/AggregatedDataBanner.tsx +3 -3
- package/src/components/CollectorList.tsx +63 -56
- package/src/components/ExpandedResultView.tsx +140 -0
- package/src/components/HealthCheckDiagram.tsx +0 -40
- package/src/components/HealthCheckHistory.tsx +3 -3
- package/src/components/HealthCheckLatencyChart.tsx +14 -4
- package/src/components/HealthCheckList.tsx +53 -11
- package/src/components/HealthCheckRunsTable.tsx +59 -230
- package/src/components/HealthCheckSparkline.tsx +12 -11
- package/src/components/HealthCheckStatusTimeline.tsx +133 -112
- package/src/components/HealthCheckSystemOverview.tsx +188 -47
- package/src/components/SparklineTooltip.tsx +51 -0
- package/src/hooks/useHealthCheckData.ts +78 -28
- package/src/index.tsx +6 -0
- package/src/pages/HealthCheckConfigPage.tsx +27 -5
- package/src/pages/HealthCheckHistoryDetailPage.tsx +62 -6
- package/src/pages/HealthCheckHistoryPage.tsx +6 -3
- package/src/utils/sparkline-downsampling.ts +88 -0
|
@@ -10,6 +10,7 @@ import { extractChartFields, getFieldValue } from "./schema-parser";
|
|
|
10
10
|
import { useStrategySchemas } from "./useStrategySchemas";
|
|
11
11
|
import type { HealthCheckDiagramSlotContext } from "../slots";
|
|
12
12
|
import type { StoredHealthCheckResult } from "@checkstack/healthcheck-common";
|
|
13
|
+
import { SparklineTooltip } from "../components/SparklineTooltip";
|
|
13
14
|
import { Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
|
|
14
15
|
import {
|
|
15
16
|
PieChart,
|
|
@@ -26,6 +27,11 @@ import {
|
|
|
26
27
|
RadialBarChart,
|
|
27
28
|
RadialBar,
|
|
28
29
|
} from "recharts";
|
|
30
|
+
import { format } from "date-fns";
|
|
31
|
+
import {
|
|
32
|
+
downsampleSparkline,
|
|
33
|
+
MAX_SPARKLINE_BARS,
|
|
34
|
+
} from "../utils/sparkline-downsampling";
|
|
29
35
|
|
|
30
36
|
interface AutoChartGridProps {
|
|
31
37
|
context: HealthCheckDiagramSlotContext;
|
|
@@ -110,7 +116,7 @@ interface CollectorGroupData {
|
|
|
110
116
|
*/
|
|
111
117
|
function buildCollectorGroups(
|
|
112
118
|
schemaFields: ChartField[],
|
|
113
|
-
instanceMap: Record<string, string[]
|
|
119
|
+
instanceMap: Record<string, string[]>,
|
|
114
120
|
): CollectorGroupData[] {
|
|
115
121
|
const groups: CollectorGroupData[] = [];
|
|
116
122
|
|
|
@@ -118,7 +124,7 @@ function buildCollectorGroups(
|
|
|
118
124
|
for (const [collectorId, instanceKeys] of Object.entries(instanceMap)) {
|
|
119
125
|
// Get fields for this collector type
|
|
120
126
|
const collectorFields = schemaFields.filter(
|
|
121
|
-
(f) => f.collectorId === collectorId
|
|
127
|
+
(f) => f.collectorId === collectorId,
|
|
122
128
|
);
|
|
123
129
|
if (collectorFields.length === 0) continue;
|
|
124
130
|
|
|
@@ -146,6 +152,8 @@ function buildCollectorGroups(
|
|
|
146
152
|
|
|
147
153
|
/**
|
|
148
154
|
* Renders a collector group with heading, assertion status, and field cards.
|
|
155
|
+
* Cards are organized into two sections: narrow cards that fill together,
|
|
156
|
+
* and wide timeline cards that span full width.
|
|
149
157
|
*/
|
|
150
158
|
function CollectorGroup({
|
|
151
159
|
group,
|
|
@@ -154,20 +162,41 @@ function CollectorGroup({
|
|
|
154
162
|
group: CollectorGroupData;
|
|
155
163
|
context: HealthCheckDiagramSlotContext;
|
|
156
164
|
}) {
|
|
157
|
-
//
|
|
158
|
-
const
|
|
165
|
+
// Separate fields into narrow (grid) and wide (full-width) categories
|
|
166
|
+
const narrowFields = group.fields.filter(
|
|
167
|
+
(f) => !WIDE_CHART_TYPES.has(f.chartType),
|
|
168
|
+
);
|
|
169
|
+
const wideFields = group.fields.filter((f) =>
|
|
170
|
+
WIDE_CHART_TYPES.has(f.chartType),
|
|
171
|
+
);
|
|
159
172
|
|
|
160
173
|
return (
|
|
161
|
-
<div>
|
|
162
|
-
<h4 className="text-sm font-medium text-muted-foreground
|
|
174
|
+
<div className="space-y-4">
|
|
175
|
+
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
|
163
176
|
{group.displayName}
|
|
164
177
|
</h4>
|
|
165
|
-
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
166
|
-
{/* Assertion status card */}
|
|
167
|
-
<AssertionStatusCard assertionFailed={assertionFailed} />
|
|
168
178
|
|
|
169
|
-
|
|
170
|
-
|
|
179
|
+
{/* Narrow cards grid - these pack together nicely */}
|
|
180
|
+
{narrowFields.length > 0 && (
|
|
181
|
+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
182
|
+
{narrowFields.map((field) => (
|
|
183
|
+
<AutoChartCard
|
|
184
|
+
key={`${field.instanceKey}-${field.name}`}
|
|
185
|
+
field={field}
|
|
186
|
+
context={context}
|
|
187
|
+
/>
|
|
188
|
+
))}
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
{/* Wide timeline cards - assertion plus timeline fields */}
|
|
193
|
+
<div className="space-y-4">
|
|
194
|
+
<AssertionStatusCard
|
|
195
|
+
context={context}
|
|
196
|
+
instanceKey={group.instanceKey}
|
|
197
|
+
/>
|
|
198
|
+
|
|
199
|
+
{wideFields.map((field) => (
|
|
171
200
|
<AutoChartCard
|
|
172
201
|
key={`${field.instanceKey}-${field.name}`}
|
|
173
202
|
field={field}
|
|
@@ -180,59 +209,142 @@ function CollectorGroup({
|
|
|
180
209
|
}
|
|
181
210
|
|
|
182
211
|
/**
|
|
183
|
-
* Get
|
|
212
|
+
* Get all assertion results for a specific collector instance.
|
|
213
|
+
* Returns array of results with timestamps/time spans in chronological order.
|
|
214
|
+
*
|
|
215
|
+
* For raw data: extracts run status with timestamp.
|
|
216
|
+
* For aggregated data: uses bucket counts with time span.
|
|
184
217
|
*/
|
|
185
|
-
function
|
|
218
|
+
function getAllAssertionResults(
|
|
186
219
|
context: HealthCheckDiagramSlotContext,
|
|
187
|
-
|
|
188
|
-
): string
|
|
189
|
-
if (context.type === "raw"
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
220
|
+
_instanceKey: string,
|
|
221
|
+
): { passed: boolean; errorMessage?: string; timeLabel?: string }[] {
|
|
222
|
+
if (context.type === "raw") {
|
|
223
|
+
return context.runs.map((run) => {
|
|
224
|
+
const result = run.result as StoredHealthCheckResult | undefined;
|
|
225
|
+
const isUnhealthy = result?.status === "unhealthy";
|
|
226
|
+
return {
|
|
227
|
+
passed: !isUnhealthy,
|
|
228
|
+
errorMessage: isUnhealthy ? result?.message : undefined,
|
|
229
|
+
timeLabel: format(new Date(run.timestamp), "MMM d, HH:mm:ss"),
|
|
230
|
+
};
|
|
231
|
+
});
|
|
197
232
|
}
|
|
198
|
-
|
|
233
|
+
|
|
234
|
+
// For aggregated data, return one result per bucket with time span
|
|
235
|
+
return context.buckets.map((bucket) => {
|
|
236
|
+
const failedCount = bucket.degradedCount + bucket.unhealthyCount;
|
|
237
|
+
const passed = failedCount === 0;
|
|
238
|
+
const bucketStart = new Date(bucket.bucketStart);
|
|
239
|
+
const bucketEnd = new Date(bucket.bucketEnd);
|
|
240
|
+
const timeSpan = `${format(bucketStart, "MMM d, HH:mm")} - ${format(bucketEnd, "HH:mm")}`;
|
|
241
|
+
return {
|
|
242
|
+
passed,
|
|
243
|
+
errorMessage: passed
|
|
244
|
+
? undefined
|
|
245
|
+
: `${failedCount} failed of ${bucket.runCount}`,
|
|
246
|
+
timeLabel: timeSpan,
|
|
247
|
+
};
|
|
248
|
+
});
|
|
199
249
|
}
|
|
200
250
|
|
|
201
251
|
/**
|
|
202
|
-
* Card showing assertion pass/fail status.
|
|
252
|
+
* Card showing assertion pass/fail status with historical sparkline.
|
|
203
253
|
*/
|
|
204
254
|
function AssertionStatusCard({
|
|
205
|
-
|
|
255
|
+
context,
|
|
256
|
+
instanceKey,
|
|
206
257
|
}: {
|
|
207
|
-
|
|
258
|
+
context: HealthCheckDiagramSlotContext;
|
|
259
|
+
instanceKey: string;
|
|
208
260
|
}) {
|
|
209
|
-
|
|
261
|
+
const results = getAllAssertionResults(context, instanceKey);
|
|
262
|
+
|
|
263
|
+
if (results.length === 0) {
|
|
210
264
|
return (
|
|
211
265
|
<Card>
|
|
212
266
|
<CardHeader className="pb-2">
|
|
213
267
|
<CardTitle className="text-sm font-medium">Assertion</CardTitle>
|
|
214
268
|
</CardHeader>
|
|
215
269
|
<CardContent>
|
|
216
|
-
<div className="
|
|
217
|
-
<div className="w-3 h-3 rounded-full bg-green-500" />
|
|
218
|
-
<span>Passed</span>
|
|
219
|
-
</div>
|
|
270
|
+
<div className="text-sm text-muted-foreground">No data</div>
|
|
220
271
|
</CardContent>
|
|
221
272
|
</Card>
|
|
222
273
|
);
|
|
223
274
|
}
|
|
224
275
|
|
|
276
|
+
const latestResult = results.at(-1)!;
|
|
277
|
+
const passCount = results.filter((r) => r.passed).length;
|
|
278
|
+
const passRate = Math.round((passCount / results.length) * 100);
|
|
279
|
+
const allPassed = results.every((r) => r.passed);
|
|
280
|
+
const allFailed = results.every((r) => !r.passed);
|
|
281
|
+
|
|
225
282
|
return (
|
|
226
|
-
<Card
|
|
283
|
+
<Card
|
|
284
|
+
className={
|
|
285
|
+
latestResult.passed ? "" : "border-red-200 dark:border-red-900"
|
|
286
|
+
}
|
|
287
|
+
>
|
|
227
288
|
<CardHeader className="pb-2">
|
|
228
|
-
<CardTitle
|
|
229
|
-
|
|
289
|
+
<CardTitle
|
|
290
|
+
className={`text-sm font-medium ${latestResult.passed ? "" : "text-red-600"}`}
|
|
291
|
+
>
|
|
292
|
+
{latestResult.passed ? "Assertion" : "Assertion Failed"}
|
|
230
293
|
</CardTitle>
|
|
231
294
|
</CardHeader>
|
|
232
|
-
<CardContent>
|
|
233
|
-
|
|
234
|
-
|
|
295
|
+
<CardContent className="space-y-2">
|
|
296
|
+
{/* Current status with rate */}
|
|
297
|
+
<div className="flex items-center gap-2">
|
|
298
|
+
<div
|
|
299
|
+
className={`w-3 h-3 rounded-full ${
|
|
300
|
+
latestResult.passed ? "bg-green-500" : "bg-red-500"
|
|
301
|
+
}`}
|
|
302
|
+
/>
|
|
303
|
+
<span
|
|
304
|
+
className={latestResult.passed ? "text-green-600" : "text-red-600"}
|
|
305
|
+
>
|
|
306
|
+
{latestResult.passed ? "Passed" : "Failed"}
|
|
307
|
+
</span>
|
|
308
|
+
{!allPassed && !allFailed && (
|
|
309
|
+
<span className="text-xs text-muted-foreground">
|
|
310
|
+
({passRate}% passed)
|
|
311
|
+
</span>
|
|
312
|
+
)}
|
|
235
313
|
</div>
|
|
314
|
+
|
|
315
|
+
{/* Error message if failed */}
|
|
316
|
+
{!latestResult.passed && latestResult.errorMessage && (
|
|
317
|
+
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-950 px-2 py-1 rounded truncate">
|
|
318
|
+
{latestResult.errorMessage}
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
321
|
+
|
|
322
|
+
{/* Sparkline timeline - always show for historical context */}
|
|
323
|
+
{(() => {
|
|
324
|
+
const buckets = downsampleSparkline(results);
|
|
325
|
+
return (
|
|
326
|
+
<div className="flex h-2 gap-px rounded">
|
|
327
|
+
{buckets.map((bucket, index) => {
|
|
328
|
+
const passedCount = bucket.items.filter((r) => r.passed).length;
|
|
329
|
+
const failedCount = bucket.items.length - passedCount;
|
|
330
|
+
const tooltip = bucket.timeLabel
|
|
331
|
+
? bucket.items.length > 1
|
|
332
|
+
? `${bucket.timeLabel}\n${passedCount} passed, ${failedCount} failed`
|
|
333
|
+
: `${bucket.timeLabel}\n${bucket.passed ? "Passed" : "Failed"}`
|
|
334
|
+
: bucket.passed
|
|
335
|
+
? "Passed"
|
|
336
|
+
: "Failed";
|
|
337
|
+
return (
|
|
338
|
+
<SparklineTooltip key={index} content={tooltip}>
|
|
339
|
+
<div
|
|
340
|
+
className={`flex-1 h-full ${bucket.passed ? "bg-green-500" : "bg-red-500"} hover:opacity-80`}
|
|
341
|
+
/>
|
|
342
|
+
</SparklineTooltip>
|
|
343
|
+
);
|
|
344
|
+
})}
|
|
345
|
+
</div>
|
|
346
|
+
);
|
|
347
|
+
})()}
|
|
236
348
|
</CardContent>
|
|
237
349
|
</Card>
|
|
238
350
|
);
|
|
@@ -243,7 +355,7 @@ function AssertionStatusCard({
|
|
|
243
355
|
* Returns a map from base collector ID (type) to array of instance UUIDs.
|
|
244
356
|
*/
|
|
245
357
|
function discoverCollectorInstances(
|
|
246
|
-
context: HealthCheckDiagramSlotContext
|
|
358
|
+
context: HealthCheckDiagramSlotContext,
|
|
247
359
|
): Record<string, string[]> {
|
|
248
360
|
const instanceMap: Record<string, Set<string>> = {};
|
|
249
361
|
|
|
@@ -302,6 +414,11 @@ interface AutoChartCardProps {
|
|
|
302
414
|
context: HealthCheckDiagramSlotContext;
|
|
303
415
|
}
|
|
304
416
|
|
|
417
|
+
/**
|
|
418
|
+
* Chart types that display historical timelines and benefit from wider display.
|
|
419
|
+
*/
|
|
420
|
+
const WIDE_CHART_TYPES = new Set(["line", "boolean", "text"]);
|
|
421
|
+
|
|
305
422
|
/**
|
|
306
423
|
* Individual chart card that renders based on field type.
|
|
307
424
|
*/
|
|
@@ -422,8 +539,8 @@ function GaugeRenderer({ field, context }: ChartRendererProps) {
|
|
|
422
539
|
numValue >= 90
|
|
423
540
|
? "hsl(var(--success))"
|
|
424
541
|
: numValue >= 70
|
|
425
|
-
|
|
426
|
-
|
|
542
|
+
? "hsl(var(--warning))"
|
|
543
|
+
: "hsl(var(--destructive))";
|
|
427
544
|
|
|
428
545
|
const data = [{ name: field.label, value: numValue, fill: fillColor }];
|
|
429
546
|
|
|
@@ -456,39 +573,176 @@ function GaugeRenderer({ field, context }: ChartRendererProps) {
|
|
|
456
573
|
}
|
|
457
574
|
|
|
458
575
|
/**
|
|
459
|
-
* Renders a boolean indicator
|
|
576
|
+
* Renders a boolean indicator with historical sparkline.
|
|
460
577
|
*/
|
|
461
578
|
function BooleanRenderer({ field, context }: ChartRendererProps) {
|
|
462
|
-
const
|
|
463
|
-
|
|
579
|
+
const valuesWithTime = getAllBooleanValuesWithTime(
|
|
580
|
+
field.name,
|
|
581
|
+
context,
|
|
582
|
+
field.instanceKey,
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
if (valuesWithTime.length === 0) {
|
|
586
|
+
return <div className="text-sm text-muted-foreground">No data</div>;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Calculate success rate
|
|
590
|
+
const trueCount = valuesWithTime.filter((v) => v.value === true).length;
|
|
591
|
+
const successRate = Math.round((trueCount / valuesWithTime.length) * 100);
|
|
592
|
+
const latestValue = valuesWithTime.at(-1)?.value;
|
|
593
|
+
const allSame = valuesWithTime.every(
|
|
594
|
+
(v) => v.value === valuesWithTime[0].value,
|
|
595
|
+
);
|
|
464
596
|
|
|
465
597
|
return (
|
|
466
|
-
<div className="
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
598
|
+
<div className="space-y-2">
|
|
599
|
+
{/* Current status with rate */}
|
|
600
|
+
<div className="flex items-center gap-2">
|
|
601
|
+
<div
|
|
602
|
+
className={`w-3 h-3 rounded-full ${
|
|
603
|
+
latestValue ? "bg-green-500" : "bg-red-500"
|
|
604
|
+
}`}
|
|
605
|
+
/>
|
|
606
|
+
<span className={latestValue ? "text-green-600" : "text-red-600"}>
|
|
607
|
+
{latestValue ? "Yes" : "No"}
|
|
608
|
+
</span>
|
|
609
|
+
{!allSame && (
|
|
610
|
+
<span className="text-xs text-muted-foreground">
|
|
611
|
+
({successRate}% success)
|
|
612
|
+
</span>
|
|
613
|
+
)}
|
|
614
|
+
</div>
|
|
615
|
+
|
|
616
|
+
{/* Sparkline timeline - always show for historical context */}
|
|
617
|
+
{(() => {
|
|
618
|
+
const buckets = downsampleSparkline(valuesWithTime);
|
|
619
|
+
return (
|
|
620
|
+
<div className="flex h-2 gap-px rounded">
|
|
621
|
+
{buckets.map((bucket, index) => {
|
|
622
|
+
const yesCount = bucket.items.filter((r) => r.value).length;
|
|
623
|
+
const noCount = bucket.items.length - yesCount;
|
|
624
|
+
const tooltip = bucket.timeLabel
|
|
625
|
+
? bucket.items.length > 1
|
|
626
|
+
? `${bucket.timeLabel}\n${yesCount} yes, ${noCount} no`
|
|
627
|
+
: `${bucket.timeLabel}\n${bucket.passed ? "Yes" : "No"}`
|
|
628
|
+
: bucket.passed
|
|
629
|
+
? "Yes"
|
|
630
|
+
: "No";
|
|
631
|
+
return (
|
|
632
|
+
<SparklineTooltip key={index} content={tooltip}>
|
|
633
|
+
<div
|
|
634
|
+
className={`flex-1 h-full ${bucket.passed ? "bg-green-500" : "bg-red-500"} hover:opacity-80`}
|
|
635
|
+
/>
|
|
636
|
+
</SparklineTooltip>
|
|
637
|
+
);
|
|
638
|
+
})}
|
|
639
|
+
</div>
|
|
640
|
+
);
|
|
641
|
+
})()}
|
|
475
642
|
</div>
|
|
476
643
|
);
|
|
477
644
|
}
|
|
478
645
|
|
|
479
646
|
/**
|
|
480
|
-
* Renders text value.
|
|
647
|
+
* Renders text value with historical sparkline for status-type fields.
|
|
481
648
|
*/
|
|
482
649
|
function TextRenderer({ field, context }: ChartRendererProps) {
|
|
483
|
-
const
|
|
484
|
-
|
|
650
|
+
const valuesWithTime = getAllStringValuesWithTime(
|
|
651
|
+
field.name,
|
|
652
|
+
context,
|
|
653
|
+
field.instanceKey,
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
if (valuesWithTime.length === 0) {
|
|
657
|
+
return <div className="text-sm text-muted-foreground">—</div>;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const latestValue = valuesWithTime.at(-1)?.value ?? "";
|
|
661
|
+
const uniqueValues = [...new Set(valuesWithTime.map((v) => v.value))];
|
|
662
|
+
const allSame = uniqueValues.length === 1;
|
|
663
|
+
const latestCount = valuesWithTime.filter(
|
|
664
|
+
(v) => v.value === latestValue,
|
|
665
|
+
).length;
|
|
485
666
|
|
|
486
667
|
return (
|
|
487
|
-
<div
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
668
|
+
<div className="space-y-2">
|
|
669
|
+
{/* Current value with count */}
|
|
670
|
+
<div className="flex items-center gap-2">
|
|
671
|
+
<span className="text-sm font-mono">{latestValue || "—"}</span>
|
|
672
|
+
{!allSame && (
|
|
673
|
+
<span className="text-xs text-muted-foreground">
|
|
674
|
+
({latestCount}/{valuesWithTime.length}×)
|
|
675
|
+
</span>
|
|
676
|
+
)}
|
|
677
|
+
</div>
|
|
678
|
+
|
|
679
|
+
{/* Sparkline timeline - always show for historical context */}
|
|
680
|
+
{(() => {
|
|
681
|
+
// Downsample for string values - bucket is "primary" if all values match latest
|
|
682
|
+
const bucketSize =
|
|
683
|
+
valuesWithTime.length <= MAX_SPARKLINE_BARS
|
|
684
|
+
? 1
|
|
685
|
+
: Math.ceil(valuesWithTime.length / MAX_SPARKLINE_BARS);
|
|
686
|
+
|
|
687
|
+
const buckets: Array<{
|
|
688
|
+
items: typeof valuesWithTime;
|
|
689
|
+
matchesLatest: boolean;
|
|
690
|
+
timeLabel?: string;
|
|
691
|
+
}> = [];
|
|
692
|
+
for (let i = 0; i < valuesWithTime.length; i += bucketSize) {
|
|
693
|
+
const items = valuesWithTime.slice(i, i + bucketSize);
|
|
694
|
+
const matchesLatest = items.every((v) => v.value === latestValue);
|
|
695
|
+
const startLabel = items[0]?.timeLabel;
|
|
696
|
+
const endLabel = items.at(-1)?.timeLabel;
|
|
697
|
+
buckets.push({
|
|
698
|
+
items,
|
|
699
|
+
matchesLatest,
|
|
700
|
+
timeLabel:
|
|
701
|
+
startLabel && endLabel && startLabel !== endLabel
|
|
702
|
+
? `${startLabel} - ${endLabel}`
|
|
703
|
+
: startLabel,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return (
|
|
708
|
+
<div className="flex h-2 gap-px rounded">
|
|
709
|
+
{buckets.map((bucket, index) => {
|
|
710
|
+
// Build value distribution for tooltip
|
|
711
|
+
let valueInfo: string;
|
|
712
|
+
if (bucket.items.length === 1) {
|
|
713
|
+
valueInfo = bucket.items[0]?.value ?? "";
|
|
714
|
+
} else {
|
|
715
|
+
// Count occurrences of each value
|
|
716
|
+
const counts: Record<string, number> = {};
|
|
717
|
+
for (const item of bucket.items) {
|
|
718
|
+
counts[item.value] = (counts[item.value] || 0) + 1;
|
|
719
|
+
}
|
|
720
|
+
// Format as "value: Nx" entries, sorted by count
|
|
721
|
+
valueInfo = Object.entries(counts)
|
|
722
|
+
.toSorted((a, b) => b[1] - a[1])
|
|
723
|
+
.slice(0, 3) // Show top 3
|
|
724
|
+
.map(([val, count]) => `${val}: ${count}×`)
|
|
725
|
+
.join(", ");
|
|
726
|
+
if (Object.keys(counts).length > 3) {
|
|
727
|
+
valueInfo += ` (+${Object.keys(counts).length - 3} more)`;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const tooltip = bucket.timeLabel
|
|
731
|
+
? `${bucket.timeLabel}\n${valueInfo}`
|
|
732
|
+
: valueInfo;
|
|
733
|
+
return (
|
|
734
|
+
<SparklineTooltip key={index} content={tooltip}>
|
|
735
|
+
<div
|
|
736
|
+
className={`flex-1 h-full ${
|
|
737
|
+
bucket.matchesLatest ? "bg-primary" : "bg-amber-500"
|
|
738
|
+
} hover:opacity-80`}
|
|
739
|
+
/>
|
|
740
|
+
</SparklineTooltip>
|
|
741
|
+
);
|
|
742
|
+
})}
|
|
743
|
+
</div>
|
|
744
|
+
);
|
|
745
|
+
})()}
|
|
492
746
|
</div>
|
|
493
747
|
);
|
|
494
748
|
}
|
|
@@ -515,20 +769,26 @@ function StatusRenderer({ field, context }: ChartRendererProps) {
|
|
|
515
769
|
* Renders an area chart for time series data using Recharts AreaChart.
|
|
516
770
|
*/
|
|
517
771
|
function LineChartRenderer({ field, context }: ChartRendererProps) {
|
|
518
|
-
const
|
|
772
|
+
const valuesWithTime = getAllValuesWithTime(
|
|
773
|
+
field.name,
|
|
774
|
+
context,
|
|
775
|
+
field.instanceKey,
|
|
776
|
+
);
|
|
519
777
|
const unit = field.unit ?? "";
|
|
520
778
|
|
|
521
|
-
if (
|
|
779
|
+
if (valuesWithTime.length === 0) {
|
|
522
780
|
return <div className="text-muted-foreground">No data</div>;
|
|
523
781
|
}
|
|
524
782
|
|
|
525
|
-
// Transform values to recharts data format
|
|
526
|
-
const chartData =
|
|
783
|
+
// Transform values to recharts data format with time labels
|
|
784
|
+
const chartData = valuesWithTime.map((item, index) => ({
|
|
527
785
|
index,
|
|
528
|
-
value,
|
|
786
|
+
value: item.value,
|
|
787
|
+
timeLabel: item.timeLabel,
|
|
529
788
|
}));
|
|
530
789
|
|
|
531
|
-
const avg =
|
|
790
|
+
const avg =
|
|
791
|
+
valuesWithTime.reduce((a, b) => a + b.value, 0) / valuesWithTime.length;
|
|
532
792
|
|
|
533
793
|
return (
|
|
534
794
|
<div className="space-y-2">
|
|
@@ -572,7 +832,10 @@ function LineChartRenderer({ field, context }: ChartRendererProps) {
|
|
|
572
832
|
<Tooltip
|
|
573
833
|
content={({ active, payload }) => {
|
|
574
834
|
if (!active || !payload?.length) return;
|
|
575
|
-
const data = payload[0].payload as {
|
|
835
|
+
const data = payload[0].payload as {
|
|
836
|
+
value: number;
|
|
837
|
+
timeLabel: string;
|
|
838
|
+
};
|
|
576
839
|
return (
|
|
577
840
|
<div
|
|
578
841
|
className="rounded-md border bg-popover p-2 text-sm shadow-md"
|
|
@@ -581,6 +844,9 @@ function LineChartRenderer({ field, context }: ChartRendererProps) {
|
|
|
581
844
|
border: "1px solid hsl(var(--border))",
|
|
582
845
|
}}
|
|
583
846
|
>
|
|
847
|
+
<p className="text-xs text-muted-foreground mb-1">
|
|
848
|
+
{data.timeLabel}
|
|
849
|
+
</p>
|
|
584
850
|
<p className="font-medium">
|
|
585
851
|
{data.value.toFixed(1)}
|
|
586
852
|
{unit}
|
|
@@ -771,7 +1037,7 @@ function PieChartRenderer({ field, context }: ChartRendererProps) {
|
|
|
771
1037
|
function getLatestValue(
|
|
772
1038
|
fieldName: string,
|
|
773
1039
|
context: HealthCheckDiagramSlotContext,
|
|
774
|
-
collectorId?: string
|
|
1040
|
+
collectorId?: string,
|
|
775
1041
|
): unknown {
|
|
776
1042
|
if (context.type === "raw") {
|
|
777
1043
|
const runs = context.runs;
|
|
@@ -787,8 +1053,8 @@ function getLatestValue(
|
|
|
787
1053
|
if (firstVal && typeof firstVal === "object" && !Array.isArray(firstVal)) {
|
|
788
1054
|
return combineRecordValues(allValues as Record<string, number>[]);
|
|
789
1055
|
}
|
|
790
|
-
// For simple values, return the latest
|
|
791
|
-
return allValues.at(
|
|
1056
|
+
// For simple values, return the latest (first in array since runs are newest-first)
|
|
1057
|
+
return allValues.at(0);
|
|
792
1058
|
} else {
|
|
793
1059
|
const buckets = context.buckets;
|
|
794
1060
|
if (buckets.length === 0) return undefined;
|
|
@@ -797,8 +1063,8 @@ function getLatestValue(
|
|
|
797
1063
|
const allValues = buckets.map((bucket) =>
|
|
798
1064
|
getFieldValue(
|
|
799
1065
|
bucket.aggregatedResult as Record<string, unknown>,
|
|
800
|
-
fieldName
|
|
801
|
-
)
|
|
1066
|
+
fieldName,
|
|
1067
|
+
),
|
|
802
1068
|
);
|
|
803
1069
|
|
|
804
1070
|
// If the values are record types (like statusCodeCounts), combine them
|
|
@@ -812,8 +1078,8 @@ function getLatestValue(
|
|
|
812
1078
|
.filter((v): v is number => typeof v === "number")
|
|
813
1079
|
.reduce((sum, v) => sum + v, 0);
|
|
814
1080
|
}
|
|
815
|
-
// For other types, return the latest
|
|
816
|
-
return allValues.at(
|
|
1081
|
+
// For other types, return the latest (first in array since buckets are newest-first)
|
|
1082
|
+
return allValues.at(0);
|
|
817
1083
|
}
|
|
818
1084
|
}
|
|
819
1085
|
|
|
@@ -822,7 +1088,7 @@ function getLatestValue(
|
|
|
822
1088
|
* Adds up the counts for each key.
|
|
823
1089
|
*/
|
|
824
1090
|
function combineRecordValues(
|
|
825
|
-
values: (Record<string, number> | undefined)[]
|
|
1091
|
+
values: (Record<string, number> | undefined)[],
|
|
826
1092
|
): Record<string, number> {
|
|
827
1093
|
const combined: Record<string, number> = {};
|
|
828
1094
|
for (const val of values) {
|
|
@@ -843,7 +1109,7 @@ function combineRecordValues(
|
|
|
843
1109
|
function getValueCounts(
|
|
844
1110
|
fieldName: string,
|
|
845
1111
|
context: HealthCheckDiagramSlotContext,
|
|
846
|
-
collectorId?: string
|
|
1112
|
+
collectorId?: string,
|
|
847
1113
|
): Record<string, number> {
|
|
848
1114
|
const counts: Record<string, number> = {};
|
|
849
1115
|
|
|
@@ -862,7 +1128,7 @@ function getValueCounts(
|
|
|
862
1128
|
const value = getFieldValue(
|
|
863
1129
|
bucket.aggregatedResult as Record<string, unknown>,
|
|
864
1130
|
fieldName,
|
|
865
|
-
collectorId
|
|
1131
|
+
collectorId,
|
|
866
1132
|
);
|
|
867
1133
|
if (value !== undefined && value !== null) {
|
|
868
1134
|
const key = String(value);
|
|
@@ -875,41 +1141,123 @@ function getValueCounts(
|
|
|
875
1141
|
}
|
|
876
1142
|
|
|
877
1143
|
/**
|
|
878
|
-
* Get all numeric values for a field
|
|
879
|
-
*
|
|
880
|
-
* For raw runs, the strategy-specific data is inside result.metadata.
|
|
881
|
-
* For aggregated buckets, the data is directly in aggregatedResult.
|
|
1144
|
+
* Get all numeric values for a field with time labels.
|
|
1145
|
+
* Returns values in chronological order with timestamps/time spans for tooltips.
|
|
882
1146
|
*/
|
|
883
|
-
function
|
|
1147
|
+
function getAllValuesWithTime(
|
|
884
1148
|
fieldName: string,
|
|
885
1149
|
context: HealthCheckDiagramSlotContext,
|
|
886
|
-
collectorId?: string
|
|
887
|
-
): number[] {
|
|
1150
|
+
collectorId?: string,
|
|
1151
|
+
): { value: number; timeLabel: string }[] {
|
|
888
1152
|
if (context.type === "raw") {
|
|
889
1153
|
return context.runs
|
|
890
1154
|
.map((run) => {
|
|
891
|
-
// result is typed as StoredHealthCheckResult with { status, latencyMs, message, metadata }
|
|
892
1155
|
const result = run.result as StoredHealthCheckResult;
|
|
893
|
-
|
|
1156
|
+
const value = getFieldValue(result?.metadata, fieldName, collectorId);
|
|
1157
|
+
if (typeof value !== "number") return;
|
|
1158
|
+
return {
|
|
1159
|
+
value,
|
|
1160
|
+
timeLabel: format(new Date(run.timestamp), "MMM d, HH:mm:ss"),
|
|
1161
|
+
};
|
|
894
1162
|
})
|
|
895
|
-
.filter(
|
|
1163
|
+
.filter(
|
|
1164
|
+
(v): v is { value: number; timeLabel: string } => v !== undefined,
|
|
1165
|
+
);
|
|
896
1166
|
}
|
|
897
1167
|
return context.buckets
|
|
898
|
-
.map((bucket) =>
|
|
899
|
-
getFieldValue(
|
|
1168
|
+
.map((bucket) => {
|
|
1169
|
+
const value = getFieldValue(
|
|
1170
|
+
bucket.aggregatedResult as Record<string, unknown>,
|
|
1171
|
+
fieldName,
|
|
1172
|
+
collectorId,
|
|
1173
|
+
);
|
|
1174
|
+
if (typeof value !== "number") return;
|
|
1175
|
+
const bucketStart = new Date(bucket.bucketStart);
|
|
1176
|
+
const bucketEnd = new Date(bucket.bucketEnd);
|
|
1177
|
+
return {
|
|
1178
|
+
value,
|
|
1179
|
+
timeLabel: `${format(bucketStart, "MMM d, HH:mm")} - ${format(bucketEnd, "HH:mm")}`,
|
|
1180
|
+
};
|
|
1181
|
+
})
|
|
1182
|
+
.filter((v): v is { value: number; timeLabel: string } => v !== undefined);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* Get all boolean values for a field from the context.
|
|
1187
|
+
* Returns values with time labels in chronological order for sparkline display.
|
|
1188
|
+
*/
|
|
1189
|
+
function getAllBooleanValuesWithTime(
|
|
1190
|
+
fieldName: string,
|
|
1191
|
+
context: HealthCheckDiagramSlotContext,
|
|
1192
|
+
collectorId?: string,
|
|
1193
|
+
): { value: boolean; timeLabel: string }[] {
|
|
1194
|
+
if (context.type === "raw") {
|
|
1195
|
+
return context.runs
|
|
1196
|
+
.map((run) => {
|
|
1197
|
+
const result = run.result as StoredHealthCheckResult;
|
|
1198
|
+
const value = getFieldValue(result?.metadata, fieldName, collectorId);
|
|
1199
|
+
if (typeof value !== "boolean") return;
|
|
1200
|
+
return {
|
|
1201
|
+
value,
|
|
1202
|
+
timeLabel: format(new Date(run.timestamp), "MMM d, HH:mm:ss"),
|
|
1203
|
+
};
|
|
1204
|
+
})
|
|
1205
|
+
.filter((v): v is { value: boolean; timeLabel: string } => v !== null);
|
|
1206
|
+
}
|
|
1207
|
+
return context.buckets
|
|
1208
|
+
.map((bucket) => {
|
|
1209
|
+
const value = getFieldValue(
|
|
900
1210
|
bucket.aggregatedResult as Record<string, unknown>,
|
|
901
1211
|
fieldName,
|
|
902
|
-
collectorId
|
|
903
|
-
)
|
|
904
|
-
|
|
905
|
-
|
|
1212
|
+
collectorId,
|
|
1213
|
+
);
|
|
1214
|
+
if (typeof value !== "boolean") return;
|
|
1215
|
+
const bucketStart = new Date(bucket.bucketStart);
|
|
1216
|
+
const bucketEnd = new Date(bucket.bucketEnd);
|
|
1217
|
+
return {
|
|
1218
|
+
value,
|
|
1219
|
+
timeLabel: `${format(bucketStart, "MMM d, HH:mm")} - ${format(bucketEnd, "HH:mm")}`,
|
|
1220
|
+
};
|
|
1221
|
+
})
|
|
1222
|
+
.filter((v): v is { value: boolean; timeLabel: string } => v !== null);
|
|
906
1223
|
}
|
|
907
1224
|
|
|
908
1225
|
/**
|
|
909
|
-
*
|
|
1226
|
+
* Get all string values for a field from the context.
|
|
1227
|
+
* Returns values with time labels in chronological order for sparkline display.
|
|
910
1228
|
*/
|
|
911
|
-
function
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1229
|
+
function getAllStringValuesWithTime(
|
|
1230
|
+
fieldName: string,
|
|
1231
|
+
context: HealthCheckDiagramSlotContext,
|
|
1232
|
+
collectorId?: string,
|
|
1233
|
+
): { value: string; timeLabel: string }[] {
|
|
1234
|
+
if (context.type === "raw") {
|
|
1235
|
+
return context.runs
|
|
1236
|
+
.map((run) => {
|
|
1237
|
+
const result = run.result as StoredHealthCheckResult;
|
|
1238
|
+
const value = getFieldValue(result?.metadata, fieldName, collectorId);
|
|
1239
|
+
if (typeof value !== "string") return;
|
|
1240
|
+
return {
|
|
1241
|
+
value,
|
|
1242
|
+
timeLabel: format(new Date(run.timestamp), "MMM d, HH:mm:ss"),
|
|
1243
|
+
};
|
|
1244
|
+
})
|
|
1245
|
+
.filter((v): v is { value: string; timeLabel: string } => v !== null);
|
|
1246
|
+
}
|
|
1247
|
+
return context.buckets
|
|
1248
|
+
.map((bucket) => {
|
|
1249
|
+
const value = getFieldValue(
|
|
1250
|
+
bucket.aggregatedResult as Record<string, unknown>,
|
|
1251
|
+
fieldName,
|
|
1252
|
+
collectorId,
|
|
1253
|
+
);
|
|
1254
|
+
if (typeof value !== "string") return;
|
|
1255
|
+
const bucketStart = new Date(bucket.bucketStart);
|
|
1256
|
+
const bucketEnd = new Date(bucket.bucketEnd);
|
|
1257
|
+
return {
|
|
1258
|
+
value,
|
|
1259
|
+
timeLabel: `${format(bucketStart, "MMM d, HH:mm")} - ${format(bucketEnd, "HH:mm")}`,
|
|
1260
|
+
};
|
|
1261
|
+
})
|
|
1262
|
+
.filter((v): v is { value: string; timeLabel: string } => v !== null);
|
|
915
1263
|
}
|