@checkstack/healthcheck-frontend 0.17.0 → 0.18.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 +65 -0
- package/package.json +12 -12
- package/src/auto-charts/AutoChartGrid.tsx +308 -101
- package/src/auto-charts/SingleRunChartGrid.tsx +16 -13
- package/src/auto-charts/schema-parser.ts +1 -4
- package/src/components/HealthCheckDrawer.tsx +3 -15
- package/src/components/HealthCheckStatusTimeline.tsx +164 -72
- package/src/components/HealthCheckSystemOverview.tsx +7 -27
- package/src/components/SystemHealthBadge.tsx +9 -23
- package/src/components/editor/EditorPanel.tsx +25 -0
- package/src/components/editor/EditorTree.tsx +23 -1
- package/src/components/editor/SystemsSection.tsx +126 -0
- package/src/hooks/useHealthCheckData.ts +30 -27
- package/src/pages/AssignmentIDEPage.tsx +23 -7
- package/src/pages/HealthCheckIDEPage.tsx +77 -3
- package/src/pages/StrategyPickerPage.tsx +9 -2
|
@@ -10,7 +10,14 @@ import { extractChartFields, getFieldValue } from "./schema-parser";
|
|
|
10
10
|
import { useStrategySchemas } from "./useStrategySchemas";
|
|
11
11
|
import type { HealthCheckDiagramSlotContext } from "../slots";
|
|
12
12
|
import { SparklineTooltip } from "../components/SparklineTooltip";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
Badge,
|
|
15
|
+
Card,
|
|
16
|
+
CardContent,
|
|
17
|
+
CardHeader,
|
|
18
|
+
CardTitle,
|
|
19
|
+
usePerformance,
|
|
20
|
+
} from "@checkstack/ui";
|
|
14
21
|
import {
|
|
15
22
|
PieChart,
|
|
16
23
|
Pie,
|
|
@@ -83,10 +90,10 @@ export function AutoChartGrid({ context }: AutoChartGridProps) {
|
|
|
83
90
|
const collectorGroups = buildCollectorGroups(schemaFields, instanceMap);
|
|
84
91
|
|
|
85
92
|
return (
|
|
86
|
-
<div className="space-y-6
|
|
93
|
+
<div className="mt-4 space-y-6">
|
|
87
94
|
{/* Strategy-level fields */}
|
|
88
95
|
{strategyFields.length > 0 && (
|
|
89
|
-
<div className="
|
|
96
|
+
<div className="space-y-4">
|
|
90
97
|
{strategyFields.map((field) => (
|
|
91
98
|
<AutoChartCard
|
|
92
99
|
key={field.name}
|
|
@@ -118,6 +125,7 @@ interface CollectorGroupData {
|
|
|
118
125
|
instanceKey: string;
|
|
119
126
|
collectorId: string;
|
|
120
127
|
displayName: string;
|
|
128
|
+
instanceLabel?: string;
|
|
121
129
|
fields: ExpandedChartField[];
|
|
122
130
|
}
|
|
123
131
|
|
|
@@ -140,15 +148,15 @@ function buildCollectorGroups(
|
|
|
140
148
|
|
|
141
149
|
// Create a group for each instance
|
|
142
150
|
for (const [index, instanceKey] of instanceKeys.entries()) {
|
|
143
|
-
const displayName =
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
: `${collectorId.split(".").pop() || collectorId} #${index + 1}`;
|
|
151
|
+
const displayName = collectorId.split(".").pop() || collectorId;
|
|
152
|
+
const instanceLabel =
|
|
153
|
+
instanceKeys.length > 1 ? `#${index + 1}` : undefined;
|
|
147
154
|
|
|
148
155
|
groups.push({
|
|
149
156
|
instanceKey,
|
|
150
157
|
collectorId,
|
|
151
158
|
displayName,
|
|
159
|
+
instanceLabel,
|
|
152
160
|
fields: collectorFields.map((field) => ({
|
|
153
161
|
...field,
|
|
154
162
|
instanceKey,
|
|
@@ -174,7 +182,8 @@ function CollectorGroup({
|
|
|
174
182
|
context: HealthCheckDiagramSlotContext;
|
|
175
183
|
baselines: AnomalyBaselineDto[];
|
|
176
184
|
}) {
|
|
177
|
-
//
|
|
185
|
+
// Order: narrow (summary) cards first, then wide timeline cards.
|
|
186
|
+
// Layout is now fully stacked at 100% width.
|
|
178
187
|
const narrowFields = group.fields.filter(
|
|
179
188
|
(f) => !WIDE_CHART_TYPES.has(f.chartType),
|
|
180
189
|
);
|
|
@@ -184,26 +193,30 @@ function CollectorGroup({
|
|
|
184
193
|
|
|
185
194
|
return (
|
|
186
195
|
<div className="space-y-4">
|
|
187
|
-
<
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
/>
|
|
201
|
-
))}
|
|
202
|
-
</div>
|
|
203
|
-
)}
|
|
196
|
+
<div className="flex flex-wrap items-center gap-2 pb-2 border-b">
|
|
197
|
+
<h3 className="text-lg font-semibold capitalize">
|
|
198
|
+
{group.displayName}
|
|
199
|
+
</h3>
|
|
200
|
+
{group.instanceLabel && (
|
|
201
|
+
<span className="text-sm font-medium text-muted-foreground">
|
|
202
|
+
{group.instanceLabel}
|
|
203
|
+
</span>
|
|
204
|
+
)}
|
|
205
|
+
<Badge variant="outline" className="font-mono">
|
|
206
|
+
{group.collectorId}
|
|
207
|
+
</Badge>
|
|
208
|
+
</div>
|
|
204
209
|
|
|
205
|
-
{/* Wide timeline cards - assertion plus timeline fields */}
|
|
206
210
|
<div className="space-y-4">
|
|
211
|
+
{narrowFields.map((field) => (
|
|
212
|
+
<AutoChartCard
|
|
213
|
+
key={`${field.instanceKey}-${field.name}`}
|
|
214
|
+
field={field}
|
|
215
|
+
context={context}
|
|
216
|
+
baselines={baselines}
|
|
217
|
+
/>
|
|
218
|
+
))}
|
|
219
|
+
|
|
207
220
|
<AssertionStatusCard
|
|
208
221
|
context={context}
|
|
209
222
|
instanceKey={group.instanceKey}
|
|
@@ -227,20 +240,28 @@ function CollectorGroup({
|
|
|
227
240
|
* Returns array of results with timestamps/time spans in chronological order.
|
|
228
241
|
* Uses bucket counts with time span from aggregated data.
|
|
229
242
|
*/
|
|
243
|
+
interface AssertionResult {
|
|
244
|
+
passedCount: number;
|
|
245
|
+
failedCount: number;
|
|
246
|
+
errorMessage?: string;
|
|
247
|
+
timeLabel: string;
|
|
248
|
+
bucketStart: number;
|
|
249
|
+
bucketIntervalSeconds: number;
|
|
250
|
+
}
|
|
251
|
+
|
|
230
252
|
function getAllAssertionResults(
|
|
231
253
|
context: HealthCheckDiagramSlotContext,
|
|
232
254
|
_instanceKey: string,
|
|
233
|
-
):
|
|
255
|
+
): AssertionResult[] {
|
|
234
256
|
return context.buckets.map((bucket) => {
|
|
235
257
|
const failedCount = bucket.degradedCount + bucket.unhealthyCount;
|
|
236
|
-
const
|
|
258
|
+
const passedCount = bucket.healthyCount;
|
|
237
259
|
const bucketStart = new Date(bucket.bucketStart);
|
|
238
260
|
const bucketEnd = new Date(bucket.bucketEnd);
|
|
239
261
|
const timeSpan = `${format(bucketStart, "MMM d, HH:mm")} - ${format(bucketEnd, "HH:mm")}`;
|
|
240
262
|
|
|
241
|
-
// Build detailed error message showing breakdown by type
|
|
242
263
|
let errorMessage: string | undefined;
|
|
243
|
-
if (
|
|
264
|
+
if (failedCount > 0) {
|
|
244
265
|
const parts: string[] = [];
|
|
245
266
|
if (bucket.unhealthyCount > 0) {
|
|
246
267
|
parts.push(`${bucket.unhealthyCount} unhealthy`);
|
|
@@ -252,13 +273,183 @@ function getAllAssertionResults(
|
|
|
252
273
|
}
|
|
253
274
|
|
|
254
275
|
return {
|
|
255
|
-
|
|
276
|
+
passedCount,
|
|
277
|
+
failedCount,
|
|
256
278
|
errorMessage,
|
|
257
279
|
timeLabel: timeSpan,
|
|
280
|
+
bucketStart: bucketStart.getTime(),
|
|
281
|
+
bucketIntervalSeconds: bucket.bucketIntervalSeconds,
|
|
258
282
|
};
|
|
259
283
|
});
|
|
260
284
|
}
|
|
261
285
|
|
|
286
|
+
interface AssertionDatum {
|
|
287
|
+
bucketStart: number;
|
|
288
|
+
passed: number;
|
|
289
|
+
failed: number;
|
|
290
|
+
total: number;
|
|
291
|
+
timeLabel: string;
|
|
292
|
+
errorMessage?: string;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const MAX_ASSERTION_BARS = 50;
|
|
296
|
+
|
|
297
|
+
function downsampleAssertion(results: AssertionResult[]): AssertionDatum[] {
|
|
298
|
+
if (results.length === 0) return [];
|
|
299
|
+
|
|
300
|
+
const groupSize = Math.max(1, Math.ceil(results.length / MAX_ASSERTION_BARS));
|
|
301
|
+
const out: AssertionDatum[] = [];
|
|
302
|
+
|
|
303
|
+
for (let i = 0; i < results.length; i += groupSize) {
|
|
304
|
+
const slice = results.slice(i, i + groupSize);
|
|
305
|
+
const first = slice[0];
|
|
306
|
+
let passed = 0;
|
|
307
|
+
let failed = 0;
|
|
308
|
+
const failureMessages: string[] = [];
|
|
309
|
+
for (const r of slice) {
|
|
310
|
+
passed += r.passedCount;
|
|
311
|
+
failed += r.failedCount;
|
|
312
|
+
if (r.errorMessage) failureMessages.push(r.errorMessage);
|
|
313
|
+
}
|
|
314
|
+
const startLabel = first.timeLabel.split(" - ")[0];
|
|
315
|
+
const endLabel = slice.at(-1)?.timeLabel.split(" - ").pop() ?? startLabel;
|
|
316
|
+
const timeLabel =
|
|
317
|
+
slice.length === 1 ? first.timeLabel : `${startLabel} – ${endLabel}`;
|
|
318
|
+
|
|
319
|
+
out.push({
|
|
320
|
+
bucketStart: first.bucketStart,
|
|
321
|
+
passed,
|
|
322
|
+
failed,
|
|
323
|
+
total: passed + failed,
|
|
324
|
+
timeLabel,
|
|
325
|
+
errorMessage:
|
|
326
|
+
failed > 0
|
|
327
|
+
? failureMessages.length === 1
|
|
328
|
+
? failureMessages[0]
|
|
329
|
+
: `${failed} of ${passed + failed} runs failed`
|
|
330
|
+
: undefined,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function AssertionSparkline({
|
|
338
|
+
results,
|
|
339
|
+
isLowPower,
|
|
340
|
+
}: {
|
|
341
|
+
results: AssertionResult[];
|
|
342
|
+
isLowPower: boolean;
|
|
343
|
+
}) {
|
|
344
|
+
const data = downsampleAssertion(results);
|
|
345
|
+
if (data.length === 0) return <></>;
|
|
346
|
+
|
|
347
|
+
const intervalSeconds = results[0]?.bucketIntervalSeconds ?? 3600;
|
|
348
|
+
const effectiveInterval =
|
|
349
|
+
intervalSeconds *
|
|
350
|
+
Math.max(1, Math.ceil(results.length / MAX_ASSERTION_BARS));
|
|
351
|
+
const tickFmt =
|
|
352
|
+
effectiveInterval >= 86_400
|
|
353
|
+
? "MMM d"
|
|
354
|
+
: effectiveInterval >= 3600
|
|
355
|
+
? "MMM d HH:mm"
|
|
356
|
+
: "HH:mm";
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<div className="w-full h-12">
|
|
360
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
361
|
+
<BarChart
|
|
362
|
+
data={data}
|
|
363
|
+
margin={{ top: 2, right: 2, left: 2, bottom: 0 }}
|
|
364
|
+
barCategoryGap={2}
|
|
365
|
+
>
|
|
366
|
+
<XAxis
|
|
367
|
+
dataKey="bucketStart"
|
|
368
|
+
tickFormatter={(value: number) => format(new Date(value), tickFmt)}
|
|
369
|
+
stroke="hsl(var(--muted-foreground))"
|
|
370
|
+
fontSize={10}
|
|
371
|
+
minTickGap={48}
|
|
372
|
+
tickLine={false}
|
|
373
|
+
axisLine={false}
|
|
374
|
+
interval="preserveStartEnd"
|
|
375
|
+
/>
|
|
376
|
+
<YAxis hide domain={[0, "dataMax"]} />
|
|
377
|
+
<Tooltip
|
|
378
|
+
cursor={{ fill: "hsl(var(--muted))", fillOpacity: 0.3 }}
|
|
379
|
+
content={(props) => (
|
|
380
|
+
<AssertionTooltip
|
|
381
|
+
active={props.active}
|
|
382
|
+
payload={
|
|
383
|
+
props.payload as unknown as
|
|
384
|
+
| { payload: AssertionDatum }[]
|
|
385
|
+
| undefined
|
|
386
|
+
}
|
|
387
|
+
/>
|
|
388
|
+
)}
|
|
389
|
+
/>
|
|
390
|
+
<Bar
|
|
391
|
+
dataKey="passed"
|
|
392
|
+
stackId="assertion"
|
|
393
|
+
fill="hsl(var(--success))"
|
|
394
|
+
isAnimationActive={!isLowPower}
|
|
395
|
+
/>
|
|
396
|
+
<Bar
|
|
397
|
+
dataKey="failed"
|
|
398
|
+
stackId="assertion"
|
|
399
|
+
fill="hsl(var(--destructive))"
|
|
400
|
+
isAnimationActive={!isLowPower}
|
|
401
|
+
/>
|
|
402
|
+
</BarChart>
|
|
403
|
+
</ResponsiveContainer>
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function AssertionTooltip({
|
|
409
|
+
active,
|
|
410
|
+
payload,
|
|
411
|
+
}: {
|
|
412
|
+
active?: boolean;
|
|
413
|
+
payload?: { payload: AssertionDatum }[];
|
|
414
|
+
}) {
|
|
415
|
+
if (!active || !payload?.length) return <></>;
|
|
416
|
+
const datum = payload[0].payload;
|
|
417
|
+
const passRate =
|
|
418
|
+
datum.total > 0 ? Math.round((datum.passed / datum.total) * 100) : 0;
|
|
419
|
+
return (
|
|
420
|
+
<div
|
|
421
|
+
className="p-2 text-xs rounded-md shadow-md"
|
|
422
|
+
style={{
|
|
423
|
+
backgroundColor: "hsl(var(--popover))",
|
|
424
|
+
border: "1px solid hsl(var(--border))",
|
|
425
|
+
color: "hsl(var(--popover-foreground))",
|
|
426
|
+
}}
|
|
427
|
+
>
|
|
428
|
+
<p className="mb-1 text-muted-foreground">{datum.timeLabel}</p>
|
|
429
|
+
<div className="space-y-0.5">
|
|
430
|
+
<p>
|
|
431
|
+
<span style={{ color: "hsl(var(--success))" }}>●</span> Passed:{" "}
|
|
432
|
+
{datum.passed}
|
|
433
|
+
</p>
|
|
434
|
+
{datum.failed > 0 && (
|
|
435
|
+
<p>
|
|
436
|
+
<span style={{ color: "hsl(var(--destructive))" }}>●</span> Failed:{" "}
|
|
437
|
+
{datum.failed}
|
|
438
|
+
</p>
|
|
439
|
+
)}
|
|
440
|
+
{datum.total > 0 && (
|
|
441
|
+
<p className="pt-0.5 text-muted-foreground">{passRate}% passed</p>
|
|
442
|
+
)}
|
|
443
|
+
{datum.errorMessage && (
|
|
444
|
+
<p className="italic text-muted-foreground max-w-[240px] truncate">
|
|
445
|
+
{datum.errorMessage}
|
|
446
|
+
</p>
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
262
453
|
/**
|
|
263
454
|
* Card showing assertion pass/fail status with historical sparkline.
|
|
264
455
|
*/
|
|
@@ -269,15 +460,18 @@ function AssertionStatusCard({
|
|
|
269
460
|
context: HealthCheckDiagramSlotContext;
|
|
270
461
|
instanceKey: string;
|
|
271
462
|
}) {
|
|
463
|
+
const { isLowPower } = usePerformance();
|
|
272
464
|
const results = getAllAssertionResults(context, instanceKey);
|
|
273
465
|
|
|
274
466
|
if (results.length === 0) {
|
|
275
467
|
return (
|
|
276
468
|
<Card>
|
|
277
469
|
<CardHeader className="pb-2">
|
|
278
|
-
<CardTitle className="text-sm font-medium">
|
|
470
|
+
<CardTitle className="text-sm font-medium text-center">
|
|
471
|
+
Assertion
|
|
472
|
+
</CardTitle>
|
|
279
473
|
</CardHeader>
|
|
280
|
-
<CardContent>
|
|
474
|
+
<CardContent className="text-center">
|
|
281
475
|
<div className="text-sm text-muted-foreground">No data</div>
|
|
282
476
|
</CardContent>
|
|
283
477
|
</Card>
|
|
@@ -285,36 +479,40 @@ function AssertionStatusCard({
|
|
|
285
479
|
}
|
|
286
480
|
|
|
287
481
|
const latestResult = results.at(-1)!;
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const
|
|
482
|
+
const latestPassed = latestResult.failedCount === 0;
|
|
483
|
+
let totalPassed = 0;
|
|
484
|
+
let totalFailed = 0;
|
|
485
|
+
for (const r of results) {
|
|
486
|
+
totalPassed += r.passedCount;
|
|
487
|
+
totalFailed += r.failedCount;
|
|
488
|
+
}
|
|
489
|
+
const totalRuns = totalPassed + totalFailed;
|
|
490
|
+
const passRate =
|
|
491
|
+
totalRuns > 0 ? Math.round((totalPassed / totalRuns) * 100) : 100;
|
|
492
|
+
const allPassed = totalFailed === 0;
|
|
493
|
+
const allFailed = totalPassed === 0 && totalFailed > 0;
|
|
292
494
|
|
|
293
495
|
return (
|
|
294
496
|
<Card
|
|
295
|
-
className={
|
|
296
|
-
latestResult.passed ? "" : "border-red-200 dark:border-red-900"
|
|
297
|
-
}
|
|
497
|
+
className={latestPassed ? "" : "border-red-200 dark:border-red-900"}
|
|
298
498
|
>
|
|
299
499
|
<CardHeader className="pb-2">
|
|
300
500
|
<CardTitle
|
|
301
|
-
className={`text-sm font-medium ${
|
|
501
|
+
className={`text-sm font-medium text-center ${latestPassed ? "" : "text-red-600"}`}
|
|
302
502
|
>
|
|
303
|
-
{
|
|
503
|
+
{latestPassed ? "Assertion" : "Assertion Failed"}
|
|
304
504
|
</CardTitle>
|
|
305
505
|
</CardHeader>
|
|
306
|
-
<CardContent className="space-y-2">
|
|
506
|
+
<CardContent className="space-y-2 text-center">
|
|
307
507
|
{/* Current status with rate */}
|
|
308
|
-
<div className="flex items-center gap-2">
|
|
508
|
+
<div className="flex items-center justify-center gap-2">
|
|
309
509
|
<div
|
|
310
510
|
className={`w-3 h-3 rounded-full ${
|
|
311
|
-
|
|
511
|
+
latestPassed ? "bg-green-500" : "bg-red-500"
|
|
312
512
|
}`}
|
|
313
513
|
/>
|
|
314
|
-
<span
|
|
315
|
-
|
|
316
|
-
>
|
|
317
|
-
{latestResult.passed ? "Passed" : "Failed"}
|
|
514
|
+
<span className={latestPassed ? "text-green-600" : "text-red-600"}>
|
|
515
|
+
{latestPassed ? "Passed" : "Failed"}
|
|
318
516
|
</span>
|
|
319
517
|
{!allPassed && !allFailed && (
|
|
320
518
|
<span className="text-xs text-muted-foreground">
|
|
@@ -324,29 +522,14 @@ function AssertionStatusCard({
|
|
|
324
522
|
</div>
|
|
325
523
|
|
|
326
524
|
{/* Error message if failed */}
|
|
327
|
-
{!
|
|
328
|
-
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-950
|
|
525
|
+
{!latestPassed && latestResult.errorMessage && (
|
|
526
|
+
<div className="px-2 py-1 text-sm text-red-600 truncate rounded bg-red-50 dark:bg-red-950">
|
|
329
527
|
{latestResult.errorMessage}
|
|
330
528
|
</div>
|
|
331
529
|
)}
|
|
332
530
|
|
|
333
|
-
{/* Sparkline timeline -
|
|
334
|
-
<
|
|
335
|
-
{results.map((result, index) => {
|
|
336
|
-
const tooltip = result.timeLabel
|
|
337
|
-
? `${result.timeLabel}\n${result.passed ? "Passed" : result.errorMessage || "Failed"}`
|
|
338
|
-
: result.passed
|
|
339
|
-
? "Passed"
|
|
340
|
-
: "Failed";
|
|
341
|
-
return (
|
|
342
|
-
<SparklineTooltip key={index} content={tooltip}>
|
|
343
|
-
<div
|
|
344
|
-
className={`flex-1 h-full ${result.passed ? "bg-green-500" : "bg-red-500"} hover:opacity-80`}
|
|
345
|
-
/>
|
|
346
|
-
</SparklineTooltip>
|
|
347
|
-
);
|
|
348
|
-
})}
|
|
349
|
-
</div>
|
|
531
|
+
{/* Sparkline timeline - one bar per bucket, with cursor-tracking tooltip */}
|
|
532
|
+
<AssertionSparkline results={results} isLowPower={isLowPower} />
|
|
350
533
|
</CardContent>
|
|
351
534
|
</Card>
|
|
352
535
|
);
|
|
@@ -449,9 +632,11 @@ function AutoChartCard({ field, context, baselines }: AutoChartCardProps) {
|
|
|
449
632
|
return (
|
|
450
633
|
<Card>
|
|
451
634
|
<CardHeader className="pb-2">
|
|
452
|
-
<CardTitle className="text-sm font-medium">
|
|
635
|
+
<CardTitle className="text-sm font-medium text-center">
|
|
636
|
+
{field.label}
|
|
637
|
+
</CardTitle>
|
|
453
638
|
</CardHeader>
|
|
454
|
-
<CardContent>
|
|
639
|
+
<CardContent className="flex flex-col items-center text-center [&>*]:w-full">
|
|
455
640
|
<ChartRenderer field={field} context={context} baseline={baseline} />
|
|
456
641
|
</CardContent>
|
|
457
642
|
</Card>
|
|
@@ -546,7 +731,7 @@ function CounterRenderer({ field, context }: ChartRendererProps) {
|
|
|
546
731
|
<div className="text-2xl font-bold">
|
|
547
732
|
{value}
|
|
548
733
|
{count > 1 && (
|
|
549
|
-
<span className="text-sm font-normal text-muted-foreground
|
|
734
|
+
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
|
550
735
|
({count}×)
|
|
551
736
|
</span>
|
|
552
737
|
)}
|
|
@@ -560,7 +745,7 @@ function CounterRenderer({ field, context }: ChartRendererProps) {
|
|
|
560
745
|
{entries.slice(0, 5).map(([value, count]) => (
|
|
561
746
|
<div key={value} className="flex items-center justify-between">
|
|
562
747
|
<span className="font-mono text-sm">{value}</span>
|
|
563
|
-
<span className="text-muted-foreground
|
|
748
|
+
<span className="text-sm text-muted-foreground">{count}×</span>
|
|
564
749
|
</div>
|
|
565
750
|
))}
|
|
566
751
|
{entries.length > 5 && (
|
|
@@ -592,8 +777,8 @@ function GaugeRenderer({ field, context, baseline }: ChartRendererProps) {
|
|
|
592
777
|
const data = [{ name: field.label, value: numValue, fill: fillColor }];
|
|
593
778
|
|
|
594
779
|
return (
|
|
595
|
-
<div className="flex flex-col gap-2">
|
|
596
|
-
<div className="flex items-center gap-3">
|
|
780
|
+
<div className="flex flex-col items-center gap-2">
|
|
781
|
+
<div className="flex items-center justify-center gap-3">
|
|
597
782
|
<ResponsiveContainer width={80} height={80}>
|
|
598
783
|
<RadialBarChart
|
|
599
784
|
cx="50%"
|
|
@@ -624,13 +809,21 @@ function GaugeRenderer({ field, context, baseline }: ChartRendererProps) {
|
|
|
624
809
|
baseline.dominantValue !== null
|
|
625
810
|
) {
|
|
626
811
|
let expectedNum = Number(baseline.dominantValue);
|
|
627
|
-
if (
|
|
628
|
-
|
|
629
|
-
|
|
812
|
+
if (
|
|
813
|
+
baseline.dominantValue === "true" ||
|
|
814
|
+
baseline.dominantValue === "false"
|
|
815
|
+
) {
|
|
816
|
+
const ratio =
|
|
817
|
+
baseline.dominantRatio ??
|
|
818
|
+
(baseline.dominantValue === "true" ? 1 : 0);
|
|
819
|
+
expectedNum =
|
|
820
|
+
baseline.dominantValue === "true"
|
|
821
|
+
? ratio * 100
|
|
822
|
+
: (1 - ratio) * 100;
|
|
630
823
|
}
|
|
631
824
|
if (!Number.isNaN(expectedNum)) {
|
|
632
825
|
return (
|
|
633
|
-
<div className="text-xs text-muted-foreground
|
|
826
|
+
<div className="mt-1 text-xs text-muted-foreground">
|
|
634
827
|
Expected: {expectedNum.toFixed(1)}
|
|
635
828
|
{unit}
|
|
636
829
|
</div>
|
|
@@ -642,7 +835,7 @@ function GaugeRenderer({ field, context, baseline }: ChartRendererProps) {
|
|
|
642
835
|
const min = Math.max(0, baseline.mean - baseline.stdDev * 3);
|
|
643
836
|
const max = baseline.mean + baseline.stdDev * 3;
|
|
644
837
|
return (
|
|
645
|
-
<div className="text-xs text-muted-foreground
|
|
838
|
+
<div className="mt-1 text-xs text-muted-foreground">
|
|
646
839
|
Expected: {baseline.mean.toFixed(1)}
|
|
647
840
|
{unit} (±{(baseline.stdDev * 3).toFixed(1)}) [{min.toFixed(1)} -{" "}
|
|
648
841
|
{max.toFixed(1)}]
|
|
@@ -681,7 +874,7 @@ function BooleanRenderer({ field, context, baseline }: ChartRendererProps) {
|
|
|
681
874
|
return (
|
|
682
875
|
<div className="space-y-2">
|
|
683
876
|
{/* Current status with rate */}
|
|
684
|
-
<div className="flex items-center gap-2">
|
|
877
|
+
<div className="flex items-center justify-center gap-2">
|
|
685
878
|
<div
|
|
686
879
|
className={`w-3 h-3 rounded-full ${
|
|
687
880
|
latestValue ? "bg-green-500" : "bg-red-500"
|
|
@@ -753,8 +946,8 @@ function TextRenderer({ field, context, baseline }: ChartRendererProps) {
|
|
|
753
946
|
return (
|
|
754
947
|
<div className="space-y-2">
|
|
755
948
|
{/* Current value with count */}
|
|
756
|
-
<div className="flex items-center gap-2">
|
|
757
|
-
<span className="text-sm
|
|
949
|
+
<div className="flex items-center justify-center gap-2">
|
|
950
|
+
<span className="font-mono text-sm">{latestValue || "—"}</span>
|
|
758
951
|
{!allSame && (
|
|
759
952
|
<span className="text-xs text-muted-foreground">
|
|
760
953
|
({latestCount}/{valuesWithTime.length}×)
|
|
@@ -856,7 +1049,7 @@ function StatusRenderer({ field, context }: ChartRendererProps) {
|
|
|
856
1049
|
}
|
|
857
1050
|
|
|
858
1051
|
return (
|
|
859
|
-
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-950
|
|
1052
|
+
<div className="px-2 py-1 text-sm text-red-600 truncate rounded bg-red-50 dark:bg-red-950">
|
|
860
1053
|
{String(value)}
|
|
861
1054
|
</div>
|
|
862
1055
|
);
|
|
@@ -897,11 +1090,14 @@ function LineChartRenderer({ field, context, baseline }: ChartRendererProps) {
|
|
|
897
1090
|
// — the same scalar the drift evaluator uses. Surfaced as a header chip rather
|
|
898
1091
|
// than a diagonal line because it's a rate, not an absolute value, and shares
|
|
899
1092
|
// no natural axis with the data series.
|
|
900
|
-
const projectedChange = baseline
|
|
901
|
-
|
|
902
|
-
const driftSigmas = baseline && baseline.stdDev > 0
|
|
903
|
-
? Math.abs(projectedChange) / baseline.stdDev
|
|
1093
|
+
const projectedChange = baseline
|
|
1094
|
+
? baseline.trendSlope * baseline.sampleCount
|
|
904
1095
|
: 0;
|
|
1096
|
+
const showTrend = !!baseline && Math.abs(projectedChange) > 0.01;
|
|
1097
|
+
const driftSigmas =
|
|
1098
|
+
baseline && baseline.stdDev > 0
|
|
1099
|
+
? Math.abs(projectedChange) / baseline.stdDev
|
|
1100
|
+
: 0;
|
|
905
1101
|
const isDrifting = driftSigmas >= 2;
|
|
906
1102
|
|
|
907
1103
|
const chartData = valuesWithTime.map((item, index) => ({
|
|
@@ -913,25 +1109,37 @@ function LineChartRenderer({ field, context, baseline }: ChartRendererProps) {
|
|
|
913
1109
|
return (
|
|
914
1110
|
<div className="space-y-2">
|
|
915
1111
|
{baseline ? (
|
|
916
|
-
<div className="flex items-center justify-between
|
|
917
|
-
<span className="text-warning
|
|
918
|
-
Expected: {baseline.mean.toFixed(1)}
|
|
1112
|
+
<div className="flex items-center justify-between gap-3 px-1 text-xs">
|
|
1113
|
+
<span className="font-medium text-warning">
|
|
1114
|
+
Expected: {baseline.mean.toFixed(1)}
|
|
1115
|
+
{unit} (±
|
|
1116
|
+
{((baseline.stdDev / Math.sqrt(avgRunCount)) * 3).toFixed(1)})
|
|
919
1117
|
</span>
|
|
920
1118
|
<div className="flex items-center gap-3">
|
|
921
1119
|
{showTrend && (
|
|
922
|
-
<span
|
|
923
|
-
|
|
1120
|
+
<span
|
|
1121
|
+
className={
|
|
1122
|
+
isDrifting
|
|
1123
|
+
? "text-warning font-medium"
|
|
1124
|
+
: "text-muted-foreground"
|
|
1125
|
+
}
|
|
1126
|
+
>
|
|
1127
|
+
Trend: {projectedChange >= 0 ? "↑ +" : "↓ "}
|
|
1128
|
+
{projectedChange.toFixed(1)}
|
|
1129
|
+
{unit}
|
|
924
1130
|
</span>
|
|
925
1131
|
)}
|
|
926
1132
|
<span className="text-muted-foreground">
|
|
927
|
-
Avg: {avg.toFixed(1)}
|
|
1133
|
+
Avg: {avg.toFixed(1)}
|
|
1134
|
+
{unit}
|
|
928
1135
|
</span>
|
|
929
1136
|
</div>
|
|
930
1137
|
</div>
|
|
931
1138
|
) : (
|
|
932
|
-
<div className="flex items-center justify-end text-xs
|
|
1139
|
+
<div className="flex items-center justify-end px-1 text-xs">
|
|
933
1140
|
<span className="text-muted-foreground">
|
|
934
|
-
Avg: {avg.toFixed(1)}
|
|
1141
|
+
Avg: {avg.toFixed(1)}
|
|
1142
|
+
{unit}
|
|
935
1143
|
</span>
|
|
936
1144
|
</div>
|
|
937
1145
|
)}
|
|
@@ -982,8 +1190,7 @@ function LineChartRenderer({ field, context, baseline }: ChartRendererProps) {
|
|
|
982
1190
|
<ReferenceArea
|
|
983
1191
|
y1={Math.max(
|
|
984
1192
|
0,
|
|
985
|
-
baseline.mean -
|
|
986
|
-
(baseline.stdDev / Math.sqrt(avgRunCount)) * 3,
|
|
1193
|
+
baseline.mean - (baseline.stdDev / Math.sqrt(avgRunCount)) * 3,
|
|
987
1194
|
)}
|
|
988
1195
|
y2={
|
|
989
1196
|
baseline.mean + (baseline.stdDev / Math.sqrt(avgRunCount)) * 3
|
|
@@ -1011,13 +1218,13 @@ function LineChartRenderer({ field, context, baseline }: ChartRendererProps) {
|
|
|
1011
1218
|
};
|
|
1012
1219
|
return (
|
|
1013
1220
|
<div
|
|
1014
|
-
className="
|
|
1221
|
+
className="p-2 text-sm border rounded-md shadow-md bg-popover"
|
|
1015
1222
|
style={{
|
|
1016
1223
|
backgroundColor: "hsl(var(--popover))",
|
|
1017
1224
|
border: "1px solid hsl(var(--border))",
|
|
1018
1225
|
}}
|
|
1019
1226
|
>
|
|
1020
|
-
<p className="text-xs text-muted-foreground
|
|
1227
|
+
<p className="mb-1 text-xs text-muted-foreground">
|
|
1021
1228
|
{data.timeLabel}
|
|
1022
1229
|
</p>
|
|
1023
1230
|
<p className="font-medium">
|
|
@@ -1072,7 +1279,7 @@ function BarChartRenderer({ field, context }: ChartRendererProps) {
|
|
|
1072
1279
|
const data = payload[0].payload as { name: string; value: number };
|
|
1073
1280
|
return (
|
|
1074
1281
|
<div
|
|
1075
|
-
className="
|
|
1282
|
+
className="p-2 text-sm border rounded-md shadow-md bg-popover"
|
|
1076
1283
|
style={{
|
|
1077
1284
|
backgroundColor: "hsl(var(--popover))",
|
|
1078
1285
|
border: "1px solid hsl(var(--border))",
|
|
@@ -1162,7 +1369,7 @@ function PieChartRenderer({ field, context }: ChartRendererProps) {
|
|
|
1162
1369
|
};
|
|
1163
1370
|
return (
|
|
1164
1371
|
<div
|
|
1165
|
-
className="
|
|
1372
|
+
className="p-2 text-sm border rounded-md shadow-md bg-popover"
|
|
1166
1373
|
style={{
|
|
1167
1374
|
backgroundColor: "hsl(var(--popover))",
|
|
1168
1375
|
border: "1px solid hsl(var(--border))",
|