@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.
- package/CHANGELOG.md +73 -0
- package/package.json +1 -1
- package/src/auto-charts/AutoChartGrid.tsx +377 -78
- package/src/auto-charts/index.ts +1 -1
- package/src/auto-charts/schema-parser.ts +122 -51
- package/src/components/AssertionBuilder.tsx +425 -0
- package/src/components/CollectorList.tsx +303 -0
- package/src/components/HealthCheckDiagram.tsx +39 -62
- package/src/components/HealthCheckEditor.tsx +37 -8
- package/src/components/HealthCheckLatencyChart.tsx +119 -59
- package/src/components/HealthCheckRunsTable.tsx +142 -6
- package/src/components/HealthCheckStatusTimeline.tsx +35 -52
- package/src/components/HealthCheckSystemOverview.tsx +208 -185
- package/src/hooks/useCollectors.ts +63 -0
- package/src/hooks/useHealthCheckData.ts +52 -33
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,78 @@
|
|
|
1
1
|
# @checkstack/healthcheck-frontend
|
|
2
2
|
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f5b1f49: Added support for nested collector result display in auto-charts and history table.
|
|
8
|
+
|
|
9
|
+
- Updated `schema-parser.ts` to traverse `collectors.*` nested schemas and extract chart fields with dot-notation paths
|
|
10
|
+
- Added `getFieldValue()` support for dot-notation paths like `collectors.request.responseTimeMs`
|
|
11
|
+
- Added `ExpandedResultView` component to `HealthCheckRunsTable.tsx` that displays:
|
|
12
|
+
- Connection info (status, latency, connection time)
|
|
13
|
+
- Per-collector results as structured cards with key-value pairs
|
|
14
|
+
|
|
15
|
+
- f5b1f49: Added JSONPath assertions for response body validation and fully qualified strategy IDs.
|
|
16
|
+
|
|
17
|
+
**JSONPath Assertions:**
|
|
18
|
+
|
|
19
|
+
- Added `healthResultJSONPath()` factory in healthcheck-common for fields supporting JSONPath queries
|
|
20
|
+
- Extended AssertionBuilder with jsonpath field type showing path input (e.g., `$.data.status`)
|
|
21
|
+
- Added `jsonPath` field to `CollectorAssertionSchema` for persistence
|
|
22
|
+
- HTTP Request collector body field now supports JSONPath assertions
|
|
23
|
+
|
|
24
|
+
**Fully Qualified Strategy IDs:**
|
|
25
|
+
|
|
26
|
+
- HealthCheckRegistry now uses scoped factories like CollectorRegistry
|
|
27
|
+
- Strategies are stored with `pluginId.strategyId` format
|
|
28
|
+
- Added `getStrategiesWithMeta()` method to HealthCheckRegistry interface
|
|
29
|
+
- Router returns qualified IDs so frontend can correctly fetch collectors
|
|
30
|
+
|
|
31
|
+
**UI Improvements:**
|
|
32
|
+
|
|
33
|
+
- Save button disabled when collector configs have invalid required fields
|
|
34
|
+
- Fixed nested button warning in CollectorList accordion
|
|
35
|
+
|
|
36
|
+
### Patch Changes
|
|
37
|
+
|
|
38
|
+
- Updated dependencies [f5b1f49]
|
|
39
|
+
- Updated dependencies [f5b1f49]
|
|
40
|
+
- Updated dependencies [f5b1f49]
|
|
41
|
+
- Updated dependencies [f5b1f49]
|
|
42
|
+
- @checkstack/healthcheck-common@0.1.0
|
|
43
|
+
- @checkstack/common@0.0.3
|
|
44
|
+
- @checkstack/ui@0.0.4
|
|
45
|
+
- @checkstack/catalog-common@0.0.3
|
|
46
|
+
- @checkstack/frontend-api@0.0.3
|
|
47
|
+
- @checkstack/signal-frontend@0.0.4
|
|
48
|
+
|
|
49
|
+
## 0.0.3
|
|
50
|
+
|
|
51
|
+
### Patch Changes
|
|
52
|
+
|
|
53
|
+
- cb82e4d: Improved `counter` and `pie` auto-chart types to show frequency distributions instead of just the latest value. Both chart types now count occurrences of each unique value across all runs/buckets, making them more intuitive for visualizing data like HTTP status codes.
|
|
54
|
+
|
|
55
|
+
Changed HTTP health check chart annotations: `statusCode` now uses `pie` chart (distribution view), `contentType` now uses `counter` chart (frequency count).
|
|
56
|
+
|
|
57
|
+
Fixed scrollbar hopping when health check signals update the accordion content. All charts now update silently without layout shift or loading state flicker.
|
|
58
|
+
|
|
59
|
+
Refactored health check visualization architecture:
|
|
60
|
+
|
|
61
|
+
- `HealthCheckStatusTimeline` and `HealthCheckLatencyChart` now accept `HealthCheckDiagramSlotContext` directly, handling data transformation internally
|
|
62
|
+
- `HealthCheckDiagram` refactored to accept context from parent, ensuring all visualizations share the same data source and update together on signals
|
|
63
|
+
- `HealthCheckSystemOverview` simplified to use `useHealthCheckData` hook for consolidated data fetching with automatic signal-driven refresh
|
|
64
|
+
|
|
65
|
+
Added `silentRefetch()` method to `usePagination` hook for background data refreshes without showing loading indicators.
|
|
66
|
+
|
|
67
|
+
Fixed `useSignal` hook to use a ref pattern internally, preventing stale closure issues. Callbacks now always access the latest values without requiring manual memoization or refs in consumer components.
|
|
68
|
+
|
|
69
|
+
Added signal handling to `useHealthCheckData` hook for automatic chart refresh when health check runs complete.
|
|
70
|
+
|
|
71
|
+
- Updated dependencies [cb82e4d]
|
|
72
|
+
- @checkstack/healthcheck-common@0.0.3
|
|
73
|
+
- @checkstack/signal-frontend@0.0.3
|
|
74
|
+
- @checkstack/ui@0.0.3
|
|
75
|
+
|
|
3
76
|
## 0.0.2
|
|
4
77
|
|
|
5
78
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -10,12 +10,22 @@ 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 { Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
|
|
13
14
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
PieChart,
|
|
16
|
+
Pie,
|
|
17
|
+
Cell,
|
|
18
|
+
BarChart,
|
|
19
|
+
Bar,
|
|
20
|
+
XAxis,
|
|
21
|
+
YAxis,
|
|
22
|
+
Tooltip,
|
|
23
|
+
ResponsiveContainer,
|
|
24
|
+
AreaChart,
|
|
25
|
+
Area,
|
|
26
|
+
RadialBarChart,
|
|
27
|
+
RadialBar,
|
|
28
|
+
} from "recharts";
|
|
19
29
|
|
|
20
30
|
interface AutoChartGridProps {
|
|
21
31
|
context: HealthCheckDiagramSlotContext;
|
|
@@ -102,6 +112,9 @@ function ChartRenderer({ field, context }: ChartRendererProps) {
|
|
|
102
112
|
case "bar": {
|
|
103
113
|
return <BarChartRenderer field={field} context={context} />;
|
|
104
114
|
}
|
|
115
|
+
case "pie": {
|
|
116
|
+
return <PieChartRenderer field={field} context={context} />;
|
|
117
|
+
}
|
|
105
118
|
case "boolean": {
|
|
106
119
|
return <BooleanRenderer field={field} context={context} />;
|
|
107
120
|
}
|
|
@@ -122,27 +135,53 @@ function ChartRenderer({ field, context }: ChartRendererProps) {
|
|
|
122
135
|
// =============================================================================
|
|
123
136
|
|
|
124
137
|
/**
|
|
125
|
-
* Renders a
|
|
138
|
+
* Renders a counter showing frequency distribution of all unique values.
|
|
139
|
+
* Counts how many times each value appears across all runs/buckets.
|
|
126
140
|
*/
|
|
127
141
|
function CounterRenderer({ field, context }: ChartRendererProps) {
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
const unit = field.unit ?? "";
|
|
142
|
+
const counts = getValueCounts(field.name, context);
|
|
143
|
+
const entries = Object.entries(counts);
|
|
131
144
|
|
|
132
|
-
|
|
133
|
-
<div className="text-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
145
|
+
if (entries.length === 0) {
|
|
146
|
+
return <div className="text-muted-foreground">No data</div>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Sort by count (descending) then by value
|
|
150
|
+
entries.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
151
|
+
|
|
152
|
+
// If there's only one unique value, show it prominently with count
|
|
153
|
+
if (entries.length === 1) {
|
|
154
|
+
const [value, count] = entries[0];
|
|
155
|
+
return (
|
|
156
|
+
<div className="text-2xl font-bold">
|
|
157
|
+
{value}
|
|
158
|
+
<span className="text-sm font-normal text-muted-foreground ml-2">
|
|
159
|
+
({count}×)
|
|
138
160
|
</span>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Multiple unique values: show as a compact list
|
|
166
|
+
return (
|
|
167
|
+
<div className="space-y-1">
|
|
168
|
+
{entries.slice(0, 5).map(([value, count]) => (
|
|
169
|
+
<div key={value} className="flex items-center justify-between">
|
|
170
|
+
<span className="font-mono text-sm">{value}</span>
|
|
171
|
+
<span className="text-muted-foreground text-sm">{count}×</span>
|
|
172
|
+
</div>
|
|
173
|
+
))}
|
|
174
|
+
{entries.length > 5 && (
|
|
175
|
+
<div className="text-xs text-muted-foreground">
|
|
176
|
+
+{entries.length - 5} more
|
|
177
|
+
</div>
|
|
139
178
|
)}
|
|
140
179
|
</div>
|
|
141
180
|
);
|
|
142
181
|
}
|
|
143
182
|
|
|
144
183
|
/**
|
|
145
|
-
* Renders a percentage gauge visualization.
|
|
184
|
+
* Renders a percentage gauge visualization using Recharts RadialBarChart.
|
|
146
185
|
*/
|
|
147
186
|
function GaugeRenderer({ field, context }: ChartRendererProps) {
|
|
148
187
|
const value = getLatestValue(field.name, context);
|
|
@@ -151,38 +190,36 @@ function GaugeRenderer({ field, context }: ChartRendererProps) {
|
|
|
151
190
|
const unit = field.unit ?? "%";
|
|
152
191
|
|
|
153
192
|
// Determine color based on value (for rates: higher is better)
|
|
154
|
-
const
|
|
193
|
+
const fillColor =
|
|
155
194
|
numValue >= 90
|
|
156
|
-
? "
|
|
195
|
+
? "hsl(var(--success))"
|
|
157
196
|
: numValue >= 70
|
|
158
|
-
? "
|
|
159
|
-
: "
|
|
197
|
+
? "hsl(var(--warning))"
|
|
198
|
+
: "hsl(var(--destructive))";
|
|
199
|
+
|
|
200
|
+
const data = [{ name: field.label, value: numValue, fill: fillColor }];
|
|
160
201
|
|
|
161
202
|
return (
|
|
162
203
|
<div className="flex items-center gap-3">
|
|
163
|
-
<
|
|
164
|
-
<
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
fill
|
|
178
|
-
className={colorClass.replace("text-", "stroke-")}
|
|
179
|
-
strokeWidth="3"
|
|
180
|
-
strokeDasharray={`${numValue} 100`}
|
|
181
|
-
strokeLinecap="round"
|
|
204
|
+
<ResponsiveContainer width={80} height={80}>
|
|
205
|
+
<RadialBarChart
|
|
206
|
+
cx="50%"
|
|
207
|
+
cy="50%"
|
|
208
|
+
innerRadius="60%"
|
|
209
|
+
outerRadius="100%"
|
|
210
|
+
barSize={8}
|
|
211
|
+
data={data}
|
|
212
|
+
startAngle={90}
|
|
213
|
+
endAngle={-270}
|
|
214
|
+
>
|
|
215
|
+
<RadialBar
|
|
216
|
+
dataKey="value"
|
|
217
|
+
cornerRadius={4}
|
|
218
|
+
background={{ fill: "hsl(var(--muted))" }}
|
|
182
219
|
/>
|
|
183
|
-
</
|
|
184
|
-
</
|
|
185
|
-
<div className=
|
|
220
|
+
</RadialBarChart>
|
|
221
|
+
</ResponsiveContainer>
|
|
222
|
+
<div className="text-2xl font-bold" style={{ color: fillColor }}>
|
|
186
223
|
{numValue.toFixed(1)}
|
|
187
224
|
{unit}
|
|
188
225
|
</div>
|
|
@@ -247,8 +284,7 @@ function StatusRenderer({ field, context }: ChartRendererProps) {
|
|
|
247
284
|
}
|
|
248
285
|
|
|
249
286
|
/**
|
|
250
|
-
* Renders
|
|
251
|
-
* For now, shows min/avg/max summary. Full charts can be added later.
|
|
287
|
+
* Renders an area chart for time series data using Recharts AreaChart.
|
|
252
288
|
*/
|
|
253
289
|
function LineChartRenderer({ field, context }: ChartRendererProps) {
|
|
254
290
|
const values = getAllValues(field.name, context);
|
|
@@ -258,31 +294,81 @@ function LineChartRenderer({ field, context }: ChartRendererProps) {
|
|
|
258
294
|
return <div className="text-muted-foreground">No data</div>;
|
|
259
295
|
}
|
|
260
296
|
|
|
261
|
-
|
|
262
|
-
const
|
|
297
|
+
// Transform values to recharts data format
|
|
298
|
+
const chartData = values.map((value, index) => ({
|
|
299
|
+
index,
|
|
300
|
+
value,
|
|
301
|
+
}));
|
|
302
|
+
|
|
263
303
|
const avg = values.reduce((a, b) => a + b, 0) / values.length;
|
|
264
304
|
|
|
265
305
|
return (
|
|
266
|
-
<div className="space-y-
|
|
267
|
-
<div className="text-
|
|
268
|
-
{avg.toFixed(1)}
|
|
306
|
+
<div className="space-y-2">
|
|
307
|
+
<div className="text-lg font-medium">
|
|
308
|
+
Avg: {avg.toFixed(1)}
|
|
269
309
|
{unit && (
|
|
270
310
|
<span className="text-sm font-normal text-muted-foreground ml-1">
|
|
271
311
|
{unit}
|
|
272
312
|
</span>
|
|
273
313
|
)}
|
|
274
314
|
</div>
|
|
275
|
-
<
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
315
|
+
<ResponsiveContainer width="100%" height={60}>
|
|
316
|
+
<AreaChart data={chartData}>
|
|
317
|
+
<defs>
|
|
318
|
+
<linearGradient
|
|
319
|
+
id={`gradient-${field.name}`}
|
|
320
|
+
x1="0"
|
|
321
|
+
y1="0"
|
|
322
|
+
x2="0"
|
|
323
|
+
y2="1"
|
|
324
|
+
>
|
|
325
|
+
<stop
|
|
326
|
+
offset="5%"
|
|
327
|
+
stopColor="hsl(var(--primary))"
|
|
328
|
+
stopOpacity={0.3}
|
|
329
|
+
/>
|
|
330
|
+
<stop
|
|
331
|
+
offset="95%"
|
|
332
|
+
stopColor="hsl(var(--primary))"
|
|
333
|
+
stopOpacity={0}
|
|
334
|
+
/>
|
|
335
|
+
</linearGradient>
|
|
336
|
+
</defs>
|
|
337
|
+
<Area
|
|
338
|
+
type="monotone"
|
|
339
|
+
dataKey="value"
|
|
340
|
+
stroke="hsl(var(--primary))"
|
|
341
|
+
fill={`url(#gradient-${field.name})`}
|
|
342
|
+
strokeWidth={2}
|
|
343
|
+
/>
|
|
344
|
+
<Tooltip
|
|
345
|
+
content={({ active, payload }) => {
|
|
346
|
+
if (!active || !payload?.length) return;
|
|
347
|
+
const data = payload[0].payload as { value: number };
|
|
348
|
+
return (
|
|
349
|
+
<div
|
|
350
|
+
className="rounded-md border bg-popover p-2 text-sm shadow-md"
|
|
351
|
+
style={{
|
|
352
|
+
backgroundColor: "hsl(var(--popover))",
|
|
353
|
+
border: "1px solid hsl(var(--border))",
|
|
354
|
+
}}
|
|
355
|
+
>
|
|
356
|
+
<p className="font-medium">
|
|
357
|
+
{data.value.toFixed(1)}
|
|
358
|
+
{unit}
|
|
359
|
+
</p>
|
|
360
|
+
</div>
|
|
361
|
+
);
|
|
362
|
+
}}
|
|
363
|
+
/>
|
|
364
|
+
</AreaChart>
|
|
365
|
+
</ResponsiveContainer>
|
|
280
366
|
</div>
|
|
281
367
|
);
|
|
282
368
|
}
|
|
283
369
|
|
|
284
370
|
/**
|
|
285
|
-
* Renders a bar chart for record values.
|
|
371
|
+
* Renders a horizontal bar chart for record values using Recharts BarChart.
|
|
286
372
|
*/
|
|
287
373
|
function BarChartRenderer({ field, context }: ChartRendererProps) {
|
|
288
374
|
const value = getLatestValue(field.name, context);
|
|
@@ -291,25 +377,154 @@ function BarChartRenderer({ field, context }: ChartRendererProps) {
|
|
|
291
377
|
return <div className="text-muted-foreground">No data</div>;
|
|
292
378
|
}
|
|
293
379
|
|
|
294
|
-
const entries = Object.entries(value as Record<string, number>).slice(0,
|
|
295
|
-
const
|
|
380
|
+
const entries = Object.entries(value as Record<string, number>).slice(0, 8);
|
|
381
|
+
const chartData = entries.map(([name, value]) => ({ name, value }));
|
|
296
382
|
|
|
297
383
|
return (
|
|
298
|
-
<
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
384
|
+
<ResponsiveContainer
|
|
385
|
+
width="100%"
|
|
386
|
+
height={Math.max(100, entries.length * 28)}
|
|
387
|
+
>
|
|
388
|
+
<BarChart
|
|
389
|
+
data={chartData}
|
|
390
|
+
layout="vertical"
|
|
391
|
+
margin={{ left: 20, right: 20 }}
|
|
392
|
+
>
|
|
393
|
+
<XAxis type="number" hide />
|
|
394
|
+
<YAxis
|
|
395
|
+
type="category"
|
|
396
|
+
dataKey="name"
|
|
397
|
+
tickLine={false}
|
|
398
|
+
axisLine={false}
|
|
399
|
+
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
|
400
|
+
width={50}
|
|
401
|
+
/>
|
|
402
|
+
<Tooltip
|
|
403
|
+
content={({ active, payload }) => {
|
|
404
|
+
if (!active || !payload?.length) return;
|
|
405
|
+
const data = payload[0].payload as { name: string; value: number };
|
|
406
|
+
return (
|
|
407
|
+
<div
|
|
408
|
+
className="rounded-md border bg-popover p-2 text-sm shadow-md"
|
|
409
|
+
style={{
|
|
410
|
+
backgroundColor: "hsl(var(--popover))",
|
|
411
|
+
border: "1px solid hsl(var(--border))",
|
|
412
|
+
}}
|
|
413
|
+
>
|
|
414
|
+
<p className="font-medium">
|
|
415
|
+
{data.name}: {data.value}
|
|
416
|
+
</p>
|
|
417
|
+
</div>
|
|
418
|
+
);
|
|
419
|
+
}}
|
|
420
|
+
/>
|
|
421
|
+
<Bar dataKey="value" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} />
|
|
422
|
+
</BarChart>
|
|
423
|
+
</ResponsiveContainer>
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Color palette for pie segments
|
|
428
|
+
const CHART_COLORS = [
|
|
429
|
+
"hsl(var(--chart-1))",
|
|
430
|
+
"hsl(var(--chart-2))",
|
|
431
|
+
"hsl(var(--chart-3))",
|
|
432
|
+
"hsl(var(--chart-4))",
|
|
433
|
+
"hsl(var(--chart-5))",
|
|
434
|
+
"hsl(var(--primary))",
|
|
435
|
+
"hsl(var(--secondary))",
|
|
436
|
+
"hsl(var(--muted))",
|
|
437
|
+
];
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Renders a pie chart for category distribution values using Recharts PieChart.
|
|
441
|
+
* Supports both pre-aggregated objects (like statusCodeCounts) and simple values
|
|
442
|
+
* that need to be counted (like statusCode).
|
|
443
|
+
*/
|
|
444
|
+
function PieChartRenderer({ field, context }: ChartRendererProps) {
|
|
445
|
+
// First, try to get a pre-aggregated object value
|
|
446
|
+
const value = getLatestValue(field.name, context);
|
|
447
|
+
|
|
448
|
+
// Determine the data source: use pre-aggregated object or count simple values
|
|
449
|
+
let dataRecord: Record<string, number>;
|
|
450
|
+
// eslint-disable-next-line unicorn/prefer-ternary
|
|
451
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
452
|
+
// Already an object (like statusCodeCounts from aggregated schema)
|
|
453
|
+
dataRecord = value as Record<string, number>;
|
|
454
|
+
} else {
|
|
455
|
+
// Simple values (like statusCode) - count occurrences
|
|
456
|
+
dataRecord = getValueCounts(field.name, context);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const entries = Object.entries(dataRecord).slice(0, 8);
|
|
460
|
+
const total = entries.reduce((sum, [, v]) => sum + v, 0);
|
|
461
|
+
|
|
462
|
+
if (total === 0) {
|
|
463
|
+
return <div className="text-muted-foreground">No data</div>;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const chartData = entries.map(([name, value]) => ({ name, value }));
|
|
467
|
+
|
|
468
|
+
return (
|
|
469
|
+
<div className="flex items-center gap-4">
|
|
470
|
+
<ResponsiveContainer width={100} height={100}>
|
|
471
|
+
<PieChart>
|
|
472
|
+
<Pie
|
|
473
|
+
data={chartData}
|
|
474
|
+
dataKey="value"
|
|
475
|
+
nameKey="name"
|
|
476
|
+
cx="50%"
|
|
477
|
+
cy="50%"
|
|
478
|
+
innerRadius={25}
|
|
479
|
+
outerRadius={45}
|
|
480
|
+
strokeWidth={0}
|
|
481
|
+
>
|
|
482
|
+
{chartData.map((_, index) => (
|
|
483
|
+
<Cell
|
|
484
|
+
key={`cell-${index}`}
|
|
485
|
+
fill={CHART_COLORS[index % CHART_COLORS.length]}
|
|
486
|
+
/>
|
|
487
|
+
))}
|
|
488
|
+
</Pie>
|
|
489
|
+
<Tooltip
|
|
490
|
+
content={({ active, payload }) => {
|
|
491
|
+
if (!active || !payload?.length) return;
|
|
492
|
+
const data = payload[0].payload as {
|
|
493
|
+
name: string;
|
|
494
|
+
value: number;
|
|
495
|
+
};
|
|
496
|
+
return (
|
|
497
|
+
<div
|
|
498
|
+
className="rounded-md border bg-popover p-2 text-sm shadow-md"
|
|
499
|
+
style={{
|
|
500
|
+
backgroundColor: "hsl(var(--popover))",
|
|
501
|
+
border: "1px solid hsl(var(--border))",
|
|
502
|
+
}}
|
|
503
|
+
>
|
|
504
|
+
<p className="font-medium">
|
|
505
|
+
{data.name}: {data.value} (
|
|
506
|
+
{((data.value / total) * 100).toFixed(0)}%)
|
|
507
|
+
</p>
|
|
508
|
+
</div>
|
|
509
|
+
);
|
|
510
|
+
}}
|
|
511
|
+
/>
|
|
512
|
+
</PieChart>
|
|
513
|
+
</ResponsiveContainer>
|
|
514
|
+
<div className="flex-1 space-y-1 text-xs">
|
|
515
|
+
{chartData.map((item, i) => (
|
|
516
|
+
<div key={item.name} className="flex items-center gap-2">
|
|
305
517
|
<div
|
|
306
|
-
className="h-
|
|
307
|
-
style={{
|
|
518
|
+
className="w-3 h-3 rounded-sm"
|
|
519
|
+
style={{ backgroundColor: CHART_COLORS[i % CHART_COLORS.length] }}
|
|
308
520
|
/>
|
|
521
|
+
<span className="text-muted-foreground">{item.name}</span>
|
|
522
|
+
<span className="ml-auto font-medium">
|
|
523
|
+
{item.value} ({((item.value / total) * 100).toFixed(0)}%)
|
|
524
|
+
</span>
|
|
309
525
|
</div>
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
))}
|
|
526
|
+
))}
|
|
527
|
+
</div>
|
|
313
528
|
</div>
|
|
314
529
|
);
|
|
315
530
|
}
|
|
@@ -319,10 +534,11 @@ function BarChartRenderer({ field, context }: ChartRendererProps) {
|
|
|
319
534
|
// =============================================================================
|
|
320
535
|
|
|
321
536
|
/**
|
|
322
|
-
* Get the
|
|
537
|
+
* Get the aggregated value for a field from the context.
|
|
323
538
|
*
|
|
324
|
-
* For raw runs
|
|
325
|
-
* For aggregated buckets
|
|
539
|
+
* For raw runs: returns the latest value from result.metadata
|
|
540
|
+
* For aggregated buckets: combines record values (counters) across ALL buckets,
|
|
541
|
+
* or returns the latest for non-aggregatable types.
|
|
326
542
|
*/
|
|
327
543
|
function getLatestValue(
|
|
328
544
|
fieldName: string,
|
|
@@ -331,19 +547,102 @@ function getLatestValue(
|
|
|
331
547
|
if (context.type === "raw") {
|
|
332
548
|
const runs = context.runs;
|
|
333
549
|
if (runs.length === 0) return undefined;
|
|
334
|
-
//
|
|
335
|
-
const
|
|
336
|
-
|
|
550
|
+
// For raw runs, aggregate across all runs for record types
|
|
551
|
+
const allValues = runs.map((run) => {
|
|
552
|
+
const result = run.result as StoredHealthCheckResult | undefined;
|
|
553
|
+
return getFieldValue(result?.metadata, fieldName);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// If the values are record types (like statusCodeCounts), combine them
|
|
557
|
+
const firstVal = allValues.find((v) => v !== undefined);
|
|
558
|
+
if (firstVal && typeof firstVal === "object" && !Array.isArray(firstVal)) {
|
|
559
|
+
return combineRecordValues(allValues as Record<string, number>[]);
|
|
560
|
+
}
|
|
561
|
+
// For simple values, return the latest
|
|
562
|
+
return allValues.at(-1);
|
|
337
563
|
} else {
|
|
338
564
|
const buckets = context.buckets;
|
|
339
565
|
if (buckets.length === 0) return undefined;
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
566
|
+
|
|
567
|
+
// Get all values for this field from all buckets
|
|
568
|
+
const allValues = buckets.map((bucket) =>
|
|
569
|
+
getFieldValue(
|
|
570
|
+
bucket.aggregatedResult as Record<string, unknown>,
|
|
571
|
+
fieldName
|
|
572
|
+
)
|
|
343
573
|
);
|
|
574
|
+
|
|
575
|
+
// If the values are record types (like statusCodeCounts), combine them
|
|
576
|
+
const firstVal = allValues.find((v) => v !== undefined);
|
|
577
|
+
if (firstVal && typeof firstVal === "object" && !Array.isArray(firstVal)) {
|
|
578
|
+
return combineRecordValues(allValues as Record<string, number>[]);
|
|
579
|
+
}
|
|
580
|
+
// For simple values (like errorCount), sum them
|
|
581
|
+
if (typeof firstVal === "number") {
|
|
582
|
+
return allValues
|
|
583
|
+
.filter((v): v is number => typeof v === "number")
|
|
584
|
+
.reduce((sum, v) => sum + v, 0);
|
|
585
|
+
}
|
|
586
|
+
// For other types, return the latest
|
|
587
|
+
return allValues.at(-1);
|
|
344
588
|
}
|
|
345
589
|
}
|
|
346
590
|
|
|
591
|
+
/**
|
|
592
|
+
* Combine record values (like statusCodeCounts) across multiple buckets/runs.
|
|
593
|
+
* Adds up the counts for each key.
|
|
594
|
+
*/
|
|
595
|
+
function combineRecordValues(
|
|
596
|
+
values: (Record<string, number> | undefined)[]
|
|
597
|
+
): Record<string, number> {
|
|
598
|
+
const combined: Record<string, number> = {};
|
|
599
|
+
for (const val of values) {
|
|
600
|
+
if (!val || typeof val !== "object") continue;
|
|
601
|
+
for (const [key, count] of Object.entries(val)) {
|
|
602
|
+
if (typeof count === "number") {
|
|
603
|
+
combined[key] = (combined[key] || 0) + count;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return combined;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Count occurrences of each unique value for a field across all runs/buckets.
|
|
612
|
+
* Returns a record mapping each unique value to its count.
|
|
613
|
+
*/
|
|
614
|
+
function getValueCounts(
|
|
615
|
+
fieldName: string,
|
|
616
|
+
context: HealthCheckDiagramSlotContext
|
|
617
|
+
): Record<string, number> {
|
|
618
|
+
const counts: Record<string, number> = {};
|
|
619
|
+
|
|
620
|
+
if (context.type === "raw") {
|
|
621
|
+
for (const run of context.runs) {
|
|
622
|
+
const result = run.result as StoredHealthCheckResult | undefined;
|
|
623
|
+
const value = getFieldValue(result?.metadata, fieldName);
|
|
624
|
+
if (value !== undefined && value !== null) {
|
|
625
|
+
const key = String(value);
|
|
626
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
} else {
|
|
630
|
+
// For aggregated buckets, we need to look at each bucket's data
|
|
631
|
+
for (const bucket of context.buckets) {
|
|
632
|
+
const value = getFieldValue(
|
|
633
|
+
bucket.aggregatedResult as Record<string, unknown>,
|
|
634
|
+
fieldName
|
|
635
|
+
);
|
|
636
|
+
if (value !== undefined && value !== null) {
|
|
637
|
+
const key = String(value);
|
|
638
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return counts;
|
|
644
|
+
}
|
|
645
|
+
|
|
347
646
|
/**
|
|
348
647
|
* Get all numeric values for a field from the context.
|
|
349
648
|
*
|
package/src/auto-charts/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export { extractChartFields, getFieldValue } from "./schema-parser";
|
|
9
|
-
export type { ChartField
|
|
9
|
+
export type { ChartField } from "./schema-parser";
|
|
10
10
|
export { AutoChartGrid } from "./AutoChartGrid";
|
|
11
11
|
export { useStrategySchemas } from "./useStrategySchemas";
|
|
12
12
|
export { autoChartExtension } from "./extension";
|