@checkstack/healthcheck-frontend 0.17.1 → 0.18.1

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 CHANGED
@@ -1,5 +1,85 @@
1
1
  # @checkstack/healthcheck-frontend
2
2
 
3
+ ## 0.18.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [32d52c6]
8
+ - Updated dependencies [32d52c6]
9
+ - Updated dependencies [32d52c6]
10
+ - Updated dependencies [32d52c6]
11
+ - Updated dependencies [32d52c6]
12
+ - Updated dependencies [32d52c6]
13
+ - Updated dependencies [32d52c6]
14
+ - @checkstack/anomaly-common@1.0.0
15
+ - @checkstack/catalog-common@2.0.0
16
+ - @checkstack/healthcheck-common@1.0.0
17
+ - @checkstack/dashboard-frontend@0.6.0
18
+ - @checkstack/frontend-api@0.4.1
19
+ - @checkstack/auth-frontend@0.5.32
20
+ - @checkstack/ui@1.7.0
21
+ - @checkstack/satellite-common@0.3.1
22
+ - @checkstack/gitops-frontend@0.3.7
23
+
24
+ ## 0.18.0
25
+
26
+ ### Minor Changes
27
+
28
+ - a914b31: Streamline system → healthcheck assignment flow by allowing in-context creation in both directions.
29
+
30
+ - 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.
31
+ - 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.
32
+ - Pre-selects the originating system when the create flow is entered with a `?systemId=` query param, and forwards that param through the strategy picker.
33
+ - 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.
34
+
35
+ - ac1e5d4: Refactor Status Timeline and Assertion charts to use Recharts with cursor-tracking tooltips, downsampling, and proportional pass/fail stacking.
36
+
37
+ - Replaces div-based bar strips with Recharts `BarChart`, so hovering anywhere over the chart resolves the closest bucket.
38
+ - Adds a lightweight time x-axis with smart tick formatting based on the bucket interval.
39
+ - Caps bar count (60 for Status Timeline, 50 for Assertion) by aggregating adjacent buckets, so individual bars stay clickable on dense ranges.
40
+ - 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.
41
+
42
+ ### Patch Changes
43
+
44
+ - 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.
45
+
46
+ **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.
47
+
48
+ ```ts
49
+ // Before
50
+ export const ANOMALY_STATE_CHANGED = createSignal(
51
+ "anomaly.state_changed",
52
+ z.object({ ... }),
53
+ );
54
+
55
+ // After
56
+ export const ANOMALY_STATE_CHANGED = createSignal({
57
+ pluginMetadata,
58
+ event: "state_changed",
59
+ payloadSchema: z.object({ ... }),
60
+ });
61
+ ```
62
+
63
+ **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.
64
+
65
+ **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.
66
+
67
+ **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`.
68
+
69
+ 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.
70
+
71
+ - Updated dependencies [208ad71]
72
+ - @checkstack/signal-frontend@0.1.0
73
+ - @checkstack/frontend-api@0.4.0
74
+ - @checkstack/anomaly-common@0.3.0
75
+ - @checkstack/healthcheck-common@0.13.0
76
+ - @checkstack/satellite-common@0.3.0
77
+ - @checkstack/dashboard-frontend@0.5.1
78
+ - @checkstack/auth-frontend@0.5.31
79
+ - @checkstack/catalog-common@1.5.3
80
+ - @checkstack/gitops-frontend@0.3.6
81
+ - @checkstack/ui@1.6.1
82
+
3
83
  ## 0.17.1
4
84
 
5
85
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-frontend",
3
- "version": "0.17.1",
3
+ "version": "0.18.1",
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.1.0",
16
- "@checkstack/auth-frontend": "0.5.29",
17
- "@checkstack/catalog-common": "1.5.1",
18
- "@checkstack/common": "0.6.5",
19
- "@checkstack/dashboard-frontend": "0.4.6",
20
- "@checkstack/frontend-api": "0.3.10",
21
- "@checkstack/gitops-frontend": "0.3.4",
22
- "@checkstack/healthcheck-common": "0.11.0",
23
- "@checkstack/signal-frontend": "0.0.15",
24
- "@checkstack/ui": "1.5.1",
15
+ "@checkstack/anomaly-common": "0.3.0",
16
+ "@checkstack/auth-frontend": "0.5.31",
17
+ "@checkstack/catalog-common": "1.5.3",
18
+ "@checkstack/common": "0.7.0",
19
+ "@checkstack/dashboard-frontend": "0.5.1",
20
+ "@checkstack/frontend-api": "0.4.0",
21
+ "@checkstack/gitops-frontend": "0.3.6",
22
+ "@checkstack/healthcheck-common": "0.13.0",
23
+ "@checkstack/signal-frontend": "0.1.0",
24
+ "@checkstack/ui": "1.6.1",
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.0"
34
+ "@checkstack/satellite-common": "0.3.0"
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 { Badge, Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
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 mt-4">
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 flex-wrap border-b pb-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
- ): { passed: boolean; errorMessage?: string; timeLabel?: string }[] {
255
+ ): AssertionResult[] {
240
256
  return context.buckets.map((bucket) => {
241
257
  const failedCount = bucket.degradedCount + bucket.unhealthyCount;
242
- const passed = failedCount === 0;
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 (!passed) {
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
- passed,
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 passCount = results.filter((r) => r.passed).length;
297
- const passRate = Math.round((passCount / results.length) * 100);
298
- const allPassed = results.every((r) => r.passed);
299
- const allFailed = results.every((r) => !r.passed);
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 ${latestResult.passed ? "" : "text-red-600"}`}
501
+ className={`text-sm font-medium text-center ${latestPassed ? "" : "text-red-600"}`}
310
502
  >
311
- {latestResult.passed ? "Assertion" : "Assertion Failed"}
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
- latestResult.passed ? "bg-green-500" : "bg-red-500"
511
+ latestPassed ? "bg-green-500" : "bg-red-500"
320
512
  }`}
321
513
  />
322
- <span
323
- className={latestResult.passed ? "text-green-600" : "text-red-600"}
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
- {!latestResult.passed && latestResult.errorMessage && (
336
- <div className="text-sm text-red-600 bg-red-50 dark:bg-red-950 px-2 py-1 rounded truncate">
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 - render each bucket as a bar */}
342
- <div className="flex h-2 gap-px rounded">
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 ml-2">
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 text-sm">{count}×</span>
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 items-center">
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 (baseline.dominantValue === "true" || baseline.dominantValue === "false") {
638
- const ratio = baseline.dominantRatio ?? (baseline.dominantValue === "true" ? 1 : 0);
639
- expectedNum = baseline.dominantValue === "true" ? (ratio * 100) : ((1 - ratio) * 100);
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 mt-1">
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 mt-1">
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 font-mono">{latestValue || "—"}</span>
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 px-2 py-1 rounded truncate">
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 ? baseline.trendSlope * baseline.sampleCount : 0;
911
- const showTrend = !!baseline && Math.abs(projectedChange) > 0.01;
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 text-xs px-1 gap-3">
927
- <span className="text-warning font-medium">
928
- Expected: {baseline.mean.toFixed(1)}{unit} (±{((baseline.stdDev / Math.sqrt(avgRunCount)) * 3).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 className={isDrifting ? "text-warning font-medium" : "text-muted-foreground"}>
933
- Trend: {projectedChange >= 0 ? "↑ +" : "↓ "}{projectedChange.toFixed(1)}{unit}
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)}{unit}
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 px-1">
1139
+ <div className="flex items-center justify-end px-1 text-xs">
943
1140
  <span className="text-muted-foreground">
944
- Avg: {avg.toFixed(1)}{unit}
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="rounded-md border bg-popover p-2 text-sm shadow-md"
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 mb-1">
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="rounded-md border bg-popover p-2 text-sm shadow-md"
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="rounded-md border bg-popover p-2 text-sm shadow-md"
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))",