@checkstack/healthcheck-frontend 0.17.1 → 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 +59 -0
- package/package.json +12 -12
- package/src/auto-charts/AutoChartGrid.tsx +266 -69
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,64 @@
|
|
|
1
1
|
# @checkstack/healthcheck-frontend
|
|
2
2
|
|
|
3
|
+
## 0.18.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a914b31: Streamline system → healthcheck assignment flow by allowing in-context creation in both directions.
|
|
8
|
+
|
|
9
|
+
- Adds an "Assign to systems" multi-select section to the healthcheck create flow (new "Systems" tree node), so a fresh check can be wired to one or more systems in a single save.
|
|
10
|
+
- Adds a "+ Create new check" button on the system assignment IDE that opens the create flow pre-targeted at that system; on save, the new check is auto-assigned and the user is returned to the assignment IDE.
|
|
11
|
+
- Pre-selects the originating system when the create flow is entered with a `?systemId=` query param, and forwards that param through the strategy picker.
|
|
12
|
+
- Includes an info banner noting that health checks are reusable templates and can be assigned to additional systems at any time, to preserve the "configs are reusable" mental model.
|
|
13
|
+
|
|
14
|
+
- ac1e5d4: Refactor Status Timeline and Assertion charts to use Recharts with cursor-tracking tooltips, downsampling, and proportional pass/fail stacking.
|
|
15
|
+
|
|
16
|
+
- Replaces div-based bar strips with Recharts `BarChart`, so hovering anywhere over the chart resolves the closest bucket.
|
|
17
|
+
- Adds a lightweight time x-axis with smart tick formatting based on the bucket interval.
|
|
18
|
+
- Caps bar count (60 for Status Timeline, 50 for Assertion) by aggregating adjacent buckets, so individual bars stay clickable on dense ranges.
|
|
19
|
+
- Each downsampled Assertion bar is now stacked proportionally — green height shows passed runs and red height shows failed runs across the aggregated window, instead of a worst-case binary color.
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- 208ad71: Centralize realtime cache invalidation: signals now carry their owning `pluginId` end-to-end, and a single `SignalAutoInvalidator` mounted near the React Query client invalidates `[[pluginId]]` for every incoming signal automatically.
|
|
24
|
+
|
|
25
|
+
**Breaking change to `createSignal`** (`@checkstack/signal-common`): the factory now takes a single object argument with `pluginMetadata`, `event`, and `payloadSchema`. The signal id is constructed as `${pluginMetadata.pluginId}.${event}` and the resulting `Signal` carries a `pluginId` field. The `SignalMessage` wire envelope and `ServerToClientMessage` `signal` variant gained a `pluginId` field so the frontend can route invalidations without parsing the id.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// Before
|
|
29
|
+
export const ANOMALY_STATE_CHANGED = createSignal(
|
|
30
|
+
"anomaly.state_changed",
|
|
31
|
+
z.object({ ... }),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// After
|
|
35
|
+
export const ANOMALY_STATE_CHANGED = createSignal({
|
|
36
|
+
pluginMetadata,
|
|
37
|
+
event: "state_changed",
|
|
38
|
+
payloadSchema: z.object({ ... }),
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**New plugin field**: `FrontendPlugin.foreignSignals?: Signal<unknown>[]` lets a plugin opt its `[[pluginId]]` cache into invalidation when another plugin's signal fires (e.g. `dependency-frontend` declares `[SYSTEM_STATUS_CHANGED]` because dependency payloads embed system status). Same-plugin signals must NOT be listed — they are always auto-invalidated.
|
|
43
|
+
|
|
44
|
+
**Removed boilerplate**: per-component `useSignal(X, () => refetch())` and `useSignal(X, () => queryClient.invalidateQueries(...))` calls have been removed across `incident-frontend`, `maintenance-frontend`, `healthcheck-frontend`, `slo-frontend`, `dependency-frontend`, `satellite-frontend`, `announcement-frontend`, `notification-frontend`, and `dashboard-frontend`. The `NotificationBell` unread count is now derived directly from the `getUnreadCount` query (auto-invalidated) instead of a local state mirror.
|
|
45
|
+
|
|
46
|
+
**User-visible bug fix**: the system detail page anomaly widget (`SystemAnomalyWidget`) now updates in real-time when anomalies change, with no per-widget signal subscription required. The dashboard status page also stays fresh on `ANOMALY_STATE_CHANGED`, `ANOMALY_BASELINE_UPDATED`, and `ANOMALY_TREND_DETECTED`.
|
|
47
|
+
|
|
48
|
+
UI-state consumers that legitimately need a `useSignal` (the dashboard activity terminal, the queue lag alert, and the rolling-preset date refresh in `useHealthCheckData`) keep their handlers; the auto-invalidator runs alongside them.
|
|
49
|
+
|
|
50
|
+
- Updated dependencies [208ad71]
|
|
51
|
+
- @checkstack/signal-frontend@0.1.0
|
|
52
|
+
- @checkstack/frontend-api@0.4.0
|
|
53
|
+
- @checkstack/anomaly-common@0.3.0
|
|
54
|
+
- @checkstack/healthcheck-common@0.13.0
|
|
55
|
+
- @checkstack/satellite-common@0.3.0
|
|
56
|
+
- @checkstack/dashboard-frontend@0.5.1
|
|
57
|
+
- @checkstack/auth-frontend@0.5.31
|
|
58
|
+
- @checkstack/catalog-common@1.5.3
|
|
59
|
+
- @checkstack/gitops-frontend@0.3.6
|
|
60
|
+
- @checkstack/ui@1.6.1
|
|
61
|
+
|
|
3
62
|
## 0.17.1
|
|
4
63
|
|
|
5
64
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-frontend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.tsx",
|
|
6
6
|
"checkstack": {
|
|
@@ -12,16 +12,16 @@
|
|
|
12
12
|
"lint:code": "eslint . --max-warnings 0"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@checkstack/anomaly-common": "0.
|
|
16
|
-
"@checkstack/auth-frontend": "0.5.
|
|
17
|
-
"@checkstack/catalog-common": "1.5.
|
|
18
|
-
"@checkstack/common": "0.
|
|
19
|
-
"@checkstack/dashboard-frontend": "0.
|
|
20
|
-
"@checkstack/frontend-api": "0.3.
|
|
21
|
-
"@checkstack/gitops-frontend": "0.3.
|
|
22
|
-
"@checkstack/healthcheck-common": "0.
|
|
23
|
-
"@checkstack/signal-frontend": "0.0.
|
|
24
|
-
"@checkstack/ui": "1.
|
|
15
|
+
"@checkstack/anomaly-common": "0.2.0",
|
|
16
|
+
"@checkstack/auth-frontend": "0.5.30",
|
|
17
|
+
"@checkstack/catalog-common": "1.5.2",
|
|
18
|
+
"@checkstack/common": "0.7.0",
|
|
19
|
+
"@checkstack/dashboard-frontend": "0.5.0",
|
|
20
|
+
"@checkstack/frontend-api": "0.3.11",
|
|
21
|
+
"@checkstack/gitops-frontend": "0.3.5",
|
|
22
|
+
"@checkstack/healthcheck-common": "0.12.0",
|
|
23
|
+
"@checkstack/signal-frontend": "0.0.16",
|
|
24
|
+
"@checkstack/ui": "1.6.0",
|
|
25
25
|
"ajv": "^8.18.0",
|
|
26
26
|
"ajv-formats": "^3.0.1",
|
|
27
27
|
"date-fns": "^4.1.0",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"recharts": "^3.6.0",
|
|
32
32
|
"uuid": "^13.0.0",
|
|
33
33
|
"zod": "^4.2.1",
|
|
34
|
-
"@checkstack/satellite-common": "0.2.
|
|
34
|
+
"@checkstack/satellite-common": "0.2.1"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@checkstack/scripts": "0.1.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,7 +90,7 @@ 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
96
|
<div className="space-y-4">
|
|
@@ -186,7 +193,7 @@ function CollectorGroup({
|
|
|
186
193
|
|
|
187
194
|
return (
|
|
188
195
|
<div className="space-y-4">
|
|
189
|
-
<div className="flex items-center gap-2
|
|
196
|
+
<div className="flex flex-wrap items-center gap-2 pb-2 border-b">
|
|
190
197
|
<h3 className="text-lg font-semibold capitalize">
|
|
191
198
|
{group.displayName}
|
|
192
199
|
</h3>
|
|
@@ -233,20 +240,28 @@ function CollectorGroup({
|
|
|
233
240
|
* Returns array of results with timestamps/time spans in chronological order.
|
|
234
241
|
* Uses bucket counts with time span from aggregated data.
|
|
235
242
|
*/
|
|
243
|
+
interface AssertionResult {
|
|
244
|
+
passedCount: number;
|
|
245
|
+
failedCount: number;
|
|
246
|
+
errorMessage?: string;
|
|
247
|
+
timeLabel: string;
|
|
248
|
+
bucketStart: number;
|
|
249
|
+
bucketIntervalSeconds: number;
|
|
250
|
+
}
|
|
251
|
+
|
|
236
252
|
function getAllAssertionResults(
|
|
237
253
|
context: HealthCheckDiagramSlotContext,
|
|
238
254
|
_instanceKey: string,
|
|
239
|
-
):
|
|
255
|
+
): AssertionResult[] {
|
|
240
256
|
return context.buckets.map((bucket) => {
|
|
241
257
|
const failedCount = bucket.degradedCount + bucket.unhealthyCount;
|
|
242
|
-
const
|
|
258
|
+
const passedCount = bucket.healthyCount;
|
|
243
259
|
const bucketStart = new Date(bucket.bucketStart);
|
|
244
260
|
const bucketEnd = new Date(bucket.bucketEnd);
|
|
245
261
|
const timeSpan = `${format(bucketStart, "MMM d, HH:mm")} - ${format(bucketEnd, "HH:mm")}`;
|
|
246
262
|
|
|
247
|
-
// Build detailed error message showing breakdown by type
|
|
248
263
|
let errorMessage: string | undefined;
|
|
249
|
-
if (
|
|
264
|
+
if (failedCount > 0) {
|
|
250
265
|
const parts: string[] = [];
|
|
251
266
|
if (bucket.unhealthyCount > 0) {
|
|
252
267
|
parts.push(`${bucket.unhealthyCount} unhealthy`);
|
|
@@ -258,13 +273,183 @@ function getAllAssertionResults(
|
|
|
258
273
|
}
|
|
259
274
|
|
|
260
275
|
return {
|
|
261
|
-
|
|
276
|
+
passedCount,
|
|
277
|
+
failedCount,
|
|
262
278
|
errorMessage,
|
|
263
279
|
timeLabel: timeSpan,
|
|
280
|
+
bucketStart: bucketStart.getTime(),
|
|
281
|
+
bucketIntervalSeconds: bucket.bucketIntervalSeconds,
|
|
264
282
|
};
|
|
265
283
|
});
|
|
266
284
|
}
|
|
267
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
|
+
|
|
268
453
|
/**
|
|
269
454
|
* Card showing assertion pass/fail status with historical sparkline.
|
|
270
455
|
*/
|
|
@@ -275,6 +460,7 @@ function AssertionStatusCard({
|
|
|
275
460
|
context: HealthCheckDiagramSlotContext;
|
|
276
461
|
instanceKey: string;
|
|
277
462
|
}) {
|
|
463
|
+
const { isLowPower } = usePerformance();
|
|
278
464
|
const results = getAllAssertionResults(context, instanceKey);
|
|
279
465
|
|
|
280
466
|
if (results.length === 0) {
|
|
@@ -293,22 +479,28 @@ function AssertionStatusCard({
|
|
|
293
479
|
}
|
|
294
480
|
|
|
295
481
|
const latestResult = results.at(-1)!;
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
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;
|
|
300
494
|
|
|
301
495
|
return (
|
|
302
496
|
<Card
|
|
303
|
-
className={
|
|
304
|
-
latestResult.passed ? "" : "border-red-200 dark:border-red-900"
|
|
305
|
-
}
|
|
497
|
+
className={latestPassed ? "" : "border-red-200 dark:border-red-900"}
|
|
306
498
|
>
|
|
307
499
|
<CardHeader className="pb-2">
|
|
308
500
|
<CardTitle
|
|
309
|
-
className={`text-sm font-medium text-center ${
|
|
501
|
+
className={`text-sm font-medium text-center ${latestPassed ? "" : "text-red-600"}`}
|
|
310
502
|
>
|
|
311
|
-
{
|
|
503
|
+
{latestPassed ? "Assertion" : "Assertion Failed"}
|
|
312
504
|
</CardTitle>
|
|
313
505
|
</CardHeader>
|
|
314
506
|
<CardContent className="space-y-2 text-center">
|
|
@@ -316,13 +508,11 @@ function AssertionStatusCard({
|
|
|
316
508
|
<div className="flex items-center justify-center gap-2">
|
|
317
509
|
<div
|
|
318
510
|
className={`w-3 h-3 rounded-full ${
|
|
319
|
-
|
|
511
|
+
latestPassed ? "bg-green-500" : "bg-red-500"
|
|
320
512
|
}`}
|
|
321
513
|
/>
|
|
322
|
-
<span
|
|
323
|
-
|
|
324
|
-
>
|
|
325
|
-
{latestResult.passed ? "Passed" : "Failed"}
|
|
514
|
+
<span className={latestPassed ? "text-green-600" : "text-red-600"}>
|
|
515
|
+
{latestPassed ? "Passed" : "Failed"}
|
|
326
516
|
</span>
|
|
327
517
|
{!allPassed && !allFailed && (
|
|
328
518
|
<span className="text-xs text-muted-foreground">
|
|
@@ -332,29 +522,14 @@ function AssertionStatusCard({
|
|
|
332
522
|
</div>
|
|
333
523
|
|
|
334
524
|
{/* Error message if failed */}
|
|
335
|
-
{!
|
|
336
|
-
<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">
|
|
337
527
|
{latestResult.errorMessage}
|
|
338
528
|
</div>
|
|
339
529
|
)}
|
|
340
530
|
|
|
341
|
-
{/* Sparkline timeline -
|
|
342
|
-
<
|
|
343
|
-
{results.map((result, index) => {
|
|
344
|
-
const tooltip = result.timeLabel
|
|
345
|
-
? `${result.timeLabel}\n${result.passed ? "Passed" : result.errorMessage || "Failed"}`
|
|
346
|
-
: result.passed
|
|
347
|
-
? "Passed"
|
|
348
|
-
: "Failed";
|
|
349
|
-
return (
|
|
350
|
-
<SparklineTooltip key={index} content={tooltip}>
|
|
351
|
-
<div
|
|
352
|
-
className={`flex-1 h-full ${result.passed ? "bg-green-500" : "bg-red-500"} hover:opacity-80`}
|
|
353
|
-
/>
|
|
354
|
-
</SparklineTooltip>
|
|
355
|
-
);
|
|
356
|
-
})}
|
|
357
|
-
</div>
|
|
531
|
+
{/* Sparkline timeline - one bar per bucket, with cursor-tracking tooltip */}
|
|
532
|
+
<AssertionSparkline results={results} isLowPower={isLowPower} />
|
|
358
533
|
</CardContent>
|
|
359
534
|
</Card>
|
|
360
535
|
);
|
|
@@ -556,7 +731,7 @@ function CounterRenderer({ field, context }: ChartRendererProps) {
|
|
|
556
731
|
<div className="text-2xl font-bold">
|
|
557
732
|
{value}
|
|
558
733
|
{count > 1 && (
|
|
559
|
-
<span className="text-sm font-normal text-muted-foreground
|
|
734
|
+
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
|
560
735
|
({count}×)
|
|
561
736
|
</span>
|
|
562
737
|
)}
|
|
@@ -570,7 +745,7 @@ function CounterRenderer({ field, context }: ChartRendererProps) {
|
|
|
570
745
|
{entries.slice(0, 5).map(([value, count]) => (
|
|
571
746
|
<div key={value} className="flex items-center justify-between">
|
|
572
747
|
<span className="font-mono text-sm">{value}</span>
|
|
573
|
-
<span className="text-muted-foreground
|
|
748
|
+
<span className="text-sm text-muted-foreground">{count}×</span>
|
|
574
749
|
</div>
|
|
575
750
|
))}
|
|
576
751
|
{entries.length > 5 && (
|
|
@@ -602,7 +777,7 @@ function GaugeRenderer({ field, context, baseline }: ChartRendererProps) {
|
|
|
602
777
|
const data = [{ name: field.label, value: numValue, fill: fillColor }];
|
|
603
778
|
|
|
604
779
|
return (
|
|
605
|
-
<div className="flex flex-col gap-2
|
|
780
|
+
<div className="flex flex-col items-center gap-2">
|
|
606
781
|
<div className="flex items-center justify-center gap-3">
|
|
607
782
|
<ResponsiveContainer width={80} height={80}>
|
|
608
783
|
<RadialBarChart
|
|
@@ -634,13 +809,21 @@ function GaugeRenderer({ field, context, baseline }: ChartRendererProps) {
|
|
|
634
809
|
baseline.dominantValue !== null
|
|
635
810
|
) {
|
|
636
811
|
let expectedNum = Number(baseline.dominantValue);
|
|
637
|
-
if (
|
|
638
|
-
|
|
639
|
-
|
|
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;
|
|
640
823
|
}
|
|
641
824
|
if (!Number.isNaN(expectedNum)) {
|
|
642
825
|
return (
|
|
643
|
-
<div className="text-xs text-muted-foreground
|
|
826
|
+
<div className="mt-1 text-xs text-muted-foreground">
|
|
644
827
|
Expected: {expectedNum.toFixed(1)}
|
|
645
828
|
{unit}
|
|
646
829
|
</div>
|
|
@@ -652,7 +835,7 @@ function GaugeRenderer({ field, context, baseline }: ChartRendererProps) {
|
|
|
652
835
|
const min = Math.max(0, baseline.mean - baseline.stdDev * 3);
|
|
653
836
|
const max = baseline.mean + baseline.stdDev * 3;
|
|
654
837
|
return (
|
|
655
|
-
<div className="text-xs text-muted-foreground
|
|
838
|
+
<div className="mt-1 text-xs text-muted-foreground">
|
|
656
839
|
Expected: {baseline.mean.toFixed(1)}
|
|
657
840
|
{unit} (±{(baseline.stdDev * 3).toFixed(1)}) [{min.toFixed(1)} -{" "}
|
|
658
841
|
{max.toFixed(1)}]
|
|
@@ -764,7 +947,7 @@ function TextRenderer({ field, context, baseline }: ChartRendererProps) {
|
|
|
764
947
|
<div className="space-y-2">
|
|
765
948
|
{/* Current value with count */}
|
|
766
949
|
<div className="flex items-center justify-center gap-2">
|
|
767
|
-
<span className="text-sm
|
|
950
|
+
<span className="font-mono text-sm">{latestValue || "—"}</span>
|
|
768
951
|
{!allSame && (
|
|
769
952
|
<span className="text-xs text-muted-foreground">
|
|
770
953
|
({latestCount}/{valuesWithTime.length}×)
|
|
@@ -866,7 +1049,7 @@ function StatusRenderer({ field, context }: ChartRendererProps) {
|
|
|
866
1049
|
}
|
|
867
1050
|
|
|
868
1051
|
return (
|
|
869
|
-
<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">
|
|
870
1053
|
{String(value)}
|
|
871
1054
|
</div>
|
|
872
1055
|
);
|
|
@@ -907,11 +1090,14 @@ function LineChartRenderer({ field, context, baseline }: ChartRendererProps) {
|
|
|
907
1090
|
// — the same scalar the drift evaluator uses. Surfaced as a header chip rather
|
|
908
1091
|
// than a diagonal line because it's a rate, not an absolute value, and shares
|
|
909
1092
|
// no natural axis with the data series.
|
|
910
|
-
const projectedChange = baseline
|
|
911
|
-
|
|
912
|
-
const driftSigmas = baseline && baseline.stdDev > 0
|
|
913
|
-
? Math.abs(projectedChange) / baseline.stdDev
|
|
1093
|
+
const projectedChange = baseline
|
|
1094
|
+
? baseline.trendSlope * baseline.sampleCount
|
|
914
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;
|
|
915
1101
|
const isDrifting = driftSigmas >= 2;
|
|
916
1102
|
|
|
917
1103
|
const chartData = valuesWithTime.map((item, index) => ({
|
|
@@ -923,25 +1109,37 @@ function LineChartRenderer({ field, context, baseline }: ChartRendererProps) {
|
|
|
923
1109
|
return (
|
|
924
1110
|
<div className="space-y-2">
|
|
925
1111
|
{baseline ? (
|
|
926
|
-
<div className="flex items-center justify-between
|
|
927
|
-
<span className="text-warning
|
|
928
|
-
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)})
|
|
929
1117
|
</span>
|
|
930
1118
|
<div className="flex items-center gap-3">
|
|
931
1119
|
{showTrend && (
|
|
932
|
-
<span
|
|
933
|
-
|
|
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}
|
|
934
1130
|
</span>
|
|
935
1131
|
)}
|
|
936
1132
|
<span className="text-muted-foreground">
|
|
937
|
-
Avg: {avg.toFixed(1)}
|
|
1133
|
+
Avg: {avg.toFixed(1)}
|
|
1134
|
+
{unit}
|
|
938
1135
|
</span>
|
|
939
1136
|
</div>
|
|
940
1137
|
</div>
|
|
941
1138
|
) : (
|
|
942
|
-
<div className="flex items-center justify-end text-xs
|
|
1139
|
+
<div className="flex items-center justify-end px-1 text-xs">
|
|
943
1140
|
<span className="text-muted-foreground">
|
|
944
|
-
Avg: {avg.toFixed(1)}
|
|
1141
|
+
Avg: {avg.toFixed(1)}
|
|
1142
|
+
{unit}
|
|
945
1143
|
</span>
|
|
946
1144
|
</div>
|
|
947
1145
|
)}
|
|
@@ -992,8 +1190,7 @@ function LineChartRenderer({ field, context, baseline }: ChartRendererProps) {
|
|
|
992
1190
|
<ReferenceArea
|
|
993
1191
|
y1={Math.max(
|
|
994
1192
|
0,
|
|
995
|
-
baseline.mean -
|
|
996
|
-
(baseline.stdDev / Math.sqrt(avgRunCount)) * 3,
|
|
1193
|
+
baseline.mean - (baseline.stdDev / Math.sqrt(avgRunCount)) * 3,
|
|
997
1194
|
)}
|
|
998
1195
|
y2={
|
|
999
1196
|
baseline.mean + (baseline.stdDev / Math.sqrt(avgRunCount)) * 3
|
|
@@ -1021,13 +1218,13 @@ function LineChartRenderer({ field, context, baseline }: ChartRendererProps) {
|
|
|
1021
1218
|
};
|
|
1022
1219
|
return (
|
|
1023
1220
|
<div
|
|
1024
|
-
className="
|
|
1221
|
+
className="p-2 text-sm border rounded-md shadow-md bg-popover"
|
|
1025
1222
|
style={{
|
|
1026
1223
|
backgroundColor: "hsl(var(--popover))",
|
|
1027
1224
|
border: "1px solid hsl(var(--border))",
|
|
1028
1225
|
}}
|
|
1029
1226
|
>
|
|
1030
|
-
<p className="text-xs text-muted-foreground
|
|
1227
|
+
<p className="mb-1 text-xs text-muted-foreground">
|
|
1031
1228
|
{data.timeLabel}
|
|
1032
1229
|
</p>
|
|
1033
1230
|
<p className="font-medium">
|
|
@@ -1082,7 +1279,7 @@ function BarChartRenderer({ field, context }: ChartRendererProps) {
|
|
|
1082
1279
|
const data = payload[0].payload as { name: string; value: number };
|
|
1083
1280
|
return (
|
|
1084
1281
|
<div
|
|
1085
|
-
className="
|
|
1282
|
+
className="p-2 text-sm border rounded-md shadow-md bg-popover"
|
|
1086
1283
|
style={{
|
|
1087
1284
|
backgroundColor: "hsl(var(--popover))",
|
|
1088
1285
|
border: "1px solid hsl(var(--border))",
|
|
@@ -1172,7 +1369,7 @@ function PieChartRenderer({ field, context }: ChartRendererProps) {
|
|
|
1172
1369
|
};
|
|
1173
1370
|
return (
|
|
1174
1371
|
<div
|
|
1175
|
-
className="
|
|
1372
|
+
className="p-2 text-sm border rounded-md shadow-md bg-popover"
|
|
1176
1373
|
style={{
|
|
1177
1374
|
backgroundColor: "hsl(var(--popover))",
|
|
1178
1375
|
border: "1px solid hsl(var(--border))",
|