@checkstack/healthcheck-frontend 0.16.4 → 0.17.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 CHANGED
@@ -1,5 +1,74 @@
1
1
  # @checkstack/healthcheck-frontend
2
2
 
3
+ ## 0.17.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8d1ef12: ## Anomaly Detection & UI Improvements
8
+
9
+ ### Anomaly Detection Enhancements (Phase 2)
10
+
11
+ - **`@checkstack/anomaly-backend`**: Implemented background baseline analyzer jobs and anomaly trend deviation detection mechanics.
12
+ - **`@checkstack/anomaly-common`**: Added new baseline statistical logic and inference rules.
13
+ - **`@checkstack/anomaly-frontend`**: Added new Anomaly Widget and refactored system detail rendering to be more human-readable.
14
+ - **`@checkstack/dashboard-frontend`**: Refined the global anomaly widget and fixed hardcoded access gating to render appropriately.
15
+ - **`@checkstack/healthcheck-backend`**: Connected executor telemetry to the anomaly pipeline.
16
+ - **`@checkstack/healthcheck-frontend`**: Reconciled baseline display consistency in Drawer and charts.
17
+
18
+ ### Notification Identifiers
19
+
20
+ - **`@checkstack/incident-backend`**: Resolved system IDs to human-readable System Names within Incident notifications to eliminate ID-only alert content.
21
+ - **`@checkstack/maintenance-backend`**: Adopted the same resolution strategy for Maintenance notifications to keep parity.
22
+
23
+ ### UI Experience
24
+
25
+ - **`@checkstack/incident-frontend`**: Fixed the "Back to X" BackLink to properly use `react-router` hook `useNavigate` instead of doing a full application reload.
26
+ - **`@checkstack/healthcheck-frontend`**: Implemented `useNavigate` for seamless SPA back-linking.
27
+ - **`@checkstack/integration-frontend`**: Updated connections and delivery logs links to navigate without hard reloads.
28
+
29
+ - 8d1ef12: Phase 2 of anomaly detection: trend drift detection.
30
+
31
+ The background baseline analyzer now computes a linear regression slope across each field's chronologically-ordered history and runs a `detectDrift` evaluator that catches gradual "creeping degradation" never reaching the 3σ spike threshold. Drifts share the same `anomalies` table as spike anomalies via a new `kind` column (`spike` | `drift`, default `spike`); the existing suspicious → anomaly → recovered lifecycle is reused, ticking at the analyzer's hourly cadence with a default 2-run confirmation window.
32
+
33
+ User-facing additions: a Trend Drift toggle and threshold slider on both the template and assignment anomaly settings panels (with per-field overrides), drift rows in the System Anomaly widget, dashed regression-line overlays on the auto-generated line charts, and a new `ANOMALY_TREND_DETECTED` signal for live UI updates. Plugin authors can disable drift per chartable field via `x-anomaly-drift-enabled: false` or tighten/loosen it via `x-anomaly-drift-threshold`.
34
+
35
+ ### Patch Changes
36
+
37
+ - Updated dependencies [8d1ef12]
38
+ - Updated dependencies [8d1ef12]
39
+ - Updated dependencies [8d1ef12]
40
+ - Updated dependencies [8d1ef12]
41
+ - @checkstack/healthcheck-common@0.12.0
42
+ - @checkstack/anomaly-common@0.2.0
43
+ - @checkstack/dashboard-frontend@0.5.0
44
+ - @checkstack/common@0.7.0
45
+ - @checkstack/ui@1.6.0
46
+ - @checkstack/satellite-common@0.2.1
47
+ - @checkstack/auth-frontend@0.5.30
48
+ - @checkstack/catalog-common@1.5.2
49
+ - @checkstack/frontend-api@0.3.11
50
+ - @checkstack/gitops-frontend@0.3.5
51
+ - @checkstack/signal-frontend@0.0.16
52
+
53
+ ## 0.16.5
54
+
55
+ ### Patch Changes
56
+
57
+ - c4e7560: Fix data integrity, cache invalidation, and mobile UI issues
58
+
59
+ - **Centralized mutation cache invalidation**: Every mutation now automatically invalidates its plugin's query cache on success via the shared `createProcedureHook` in `orpc-query.tsx`. This ensures all views stay in sync without requiring individual components to remember manual `invalidateQueries` calls.
60
+ - **Fixed oRPC query key matching**: Query keys use nested arrays (`[["pluginId"]]`) to correctly match oRPC's `[pathArray, options]` key structure. Fixed the broken flat-string pattern in `SystemBadgeDataProvider`.
61
+ - **Fixed hourly aggregation duplication**: Added `NULLS NOT DISTINCT` to the `health_check_aggregates` unique constraint so local runs (`source_id = NULL`) correctly conflict-match instead of creating duplicate hourly buckets. Includes a migration to clean up existing duplicates.
62
+ - **Fixed modal scrolling on mobile**: Added `max-height` + `overflow-y-auto` to `ConfirmationModal`, and refactored `Dialog` from translate-centering to flex-centering with `dvh` units for reliable mobile scroll containment.
63
+
64
+ - Updated dependencies [c4e7560]
65
+ - @checkstack/frontend-api@0.3.10
66
+ - @checkstack/dashboard-frontend@0.4.6
67
+ - @checkstack/ui@1.5.1
68
+ - @checkstack/auth-frontend@0.5.29
69
+ - @checkstack/catalog-common@1.5.1
70
+ - @checkstack/gitops-frontend@0.3.4
71
+
3
72
  ## 0.16.4
4
73
 
5
74
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-frontend",
3
- "version": "0.16.4",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "main": "src/index.tsx",
6
6
  "checkstack": {
@@ -12,15 +12,16 @@
12
12
  "lint:code": "eslint . --max-warnings 0"
13
13
  },
14
14
  "dependencies": {
15
- "@checkstack/auth-frontend": "0.5.28",
16
- "@checkstack/catalog-common": "1.4.1",
15
+ "@checkstack/anomaly-common": "0.1.0",
16
+ "@checkstack/auth-frontend": "0.5.29",
17
+ "@checkstack/catalog-common": "1.5.1",
17
18
  "@checkstack/common": "0.6.5",
18
- "@checkstack/dashboard-frontend": "0.4.4",
19
- "@checkstack/frontend-api": "0.3.9",
20
- "@checkstack/gitops-frontend": "0.3.3",
19
+ "@checkstack/dashboard-frontend": "0.4.6",
20
+ "@checkstack/frontend-api": "0.3.10",
21
+ "@checkstack/gitops-frontend": "0.3.4",
21
22
  "@checkstack/healthcheck-common": "0.11.0",
22
23
  "@checkstack/signal-frontend": "0.0.15",
23
- "@checkstack/ui": "1.5.0",
24
+ "@checkstack/ui": "1.5.1",
24
25
  "ajv": "^8.18.0",
25
26
  "ajv-formats": "^3.0.1",
26
27
  "date-fns": "^4.1.0",
@@ -25,7 +25,13 @@ import {
25
25
  Area,
26
26
  RadialBarChart,
27
27
  RadialBar,
28
+ ReferenceArea,
28
29
  } from "recharts";
30
+ import {
31
+ AnomalyApi,
32
+ type AnomalyBaselineDto,
33
+ } from "@checkstack/anomaly-common";
34
+ import { usePluginClient } from "@checkstack/frontend-api";
29
35
  import { format } from "date-fns";
30
36
  import { MAX_SPARKLINE_BARS } from "../utils/sparkline-downsampling";
31
37
 
@@ -41,6 +47,11 @@ interface AutoChartGridProps {
41
47
  */
42
48
  export function AutoChartGrid({ context }: AutoChartGridProps) {
43
49
  const { schemas, loading } = useStrategySchemas(context.strategyId);
50
+ const anomalyClient = usePluginClient(AnomalyApi);
51
+ const { data: baselines = [] } = anomalyClient.getAnomalyBaselines.useQuery({
52
+ systemId: context.systemId,
53
+ configurationId: context.configurationId,
54
+ });
44
55
 
45
56
  if (loading) {
46
57
  return; // Don't show loading state, let custom charts render first
@@ -77,7 +88,12 @@ export function AutoChartGrid({ context }: AutoChartGridProps) {
77
88
  {strategyFields.length > 0 && (
78
89
  <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
79
90
  {strategyFields.map((field) => (
80
- <AutoChartCard key={field.name} field={field} context={context} />
91
+ <AutoChartCard
92
+ key={field.name}
93
+ field={field}
94
+ context={context}
95
+ baselines={baselines}
96
+ />
81
97
  ))}
82
98
  </div>
83
99
  )}
@@ -88,6 +104,7 @@ export function AutoChartGrid({ context }: AutoChartGridProps) {
88
104
  key={group.instanceKey}
89
105
  group={group}
90
106
  context={context}
107
+ baselines={baselines}
91
108
  />
92
109
  ))}
93
110
  </div>
@@ -151,9 +168,11 @@ function buildCollectorGroups(
151
168
  function CollectorGroup({
152
169
  group,
153
170
  context,
171
+ baselines,
154
172
  }: {
155
173
  group: CollectorGroupData;
156
174
  context: HealthCheckDiagramSlotContext;
175
+ baselines: AnomalyBaselineDto[];
157
176
  }) {
158
177
  // Separate fields into narrow (grid) and wide (full-width) categories
159
178
  const narrowFields = group.fields.filter(
@@ -177,6 +196,7 @@ function CollectorGroup({
177
196
  key={`${field.instanceKey}-${field.name}`}
178
197
  field={field}
179
198
  context={context}
199
+ baselines={baselines}
180
200
  />
181
201
  ))}
182
202
  </div>
@@ -194,6 +214,7 @@ function CollectorGroup({
194
214
  key={`${field.instanceKey}-${field.name}`}
195
215
  field={field}
196
216
  context={context}
217
+ baselines={baselines}
197
218
  />
198
219
  ))}
199
220
  </div>
@@ -383,6 +404,7 @@ interface ExpandedChartField extends ChartField {
383
404
  interface AutoChartCardProps {
384
405
  field: ExpandedChartField;
385
406
  context: HealthCheckDiagramSlotContext;
407
+ baselines?: AnomalyBaselineDto[];
386
408
  }
387
409
 
388
410
  /**
@@ -393,14 +415,44 @@ const WIDE_CHART_TYPES = new Set(["line", "boolean", "text"]);
393
415
  /**
394
416
  * Individual chart card that renders based on field type.
395
417
  */
396
- function AutoChartCard({ field, context }: AutoChartCardProps) {
418
+ function AutoChartCard({ field, context, baselines }: AutoChartCardProps) {
419
+ const fullFieldPath = field.collectorId
420
+ ? `collectors.${field.collectorId}.${field.name}`
421
+ : field.name;
422
+
423
+ let baseline = baselines?.find((b) => b.fieldPath === fullFieldPath);
424
+
425
+ // If no exact match, try mapping aggregated field names back to raw field names
426
+ if (!baseline) {
427
+ let rawFieldName = field.name;
428
+ if (rawFieldName.startsWith("avg")) {
429
+ rawFieldName =
430
+ rawFieldName.charAt(3).toLowerCase() + rawFieldName.slice(4);
431
+ } else if (rawFieldName.startsWith("min")) {
432
+ rawFieldName =
433
+ rawFieldName.charAt(3).toLowerCase() + rawFieldName.slice(4);
434
+ } else if (rawFieldName.startsWith("max")) {
435
+ rawFieldName =
436
+ rawFieldName.charAt(3).toLowerCase() + rawFieldName.slice(4);
437
+ } else if (rawFieldName === "successRate") {
438
+ rawFieldName = "success";
439
+ }
440
+
441
+ if (rawFieldName !== field.name) {
442
+ const rawFullFieldPath = field.collectorId
443
+ ? `collectors.${field.collectorId}.${rawFieldName}`
444
+ : rawFieldName;
445
+ baseline = baselines?.find((b) => b.fieldPath === rawFullFieldPath);
446
+ }
447
+ }
448
+
397
449
  return (
398
450
  <Card>
399
451
  <CardHeader className="pb-2">
400
452
  <CardTitle className="text-sm font-medium">{field.label}</CardTitle>
401
453
  </CardHeader>
402
454
  <CardContent>
403
- <ChartRenderer field={field} context={context} />
455
+ <ChartRenderer field={field} context={context} baseline={baseline} />
404
456
  </CardContent>
405
457
  </Card>
406
458
  );
@@ -409,36 +461,57 @@ function AutoChartCard({ field, context }: AutoChartCardProps) {
409
461
  interface ChartRendererProps {
410
462
  field: ExpandedChartField;
411
463
  context: HealthCheckDiagramSlotContext;
464
+ baseline?: AnomalyBaselineDto;
412
465
  }
413
466
 
414
467
  /**
415
468
  * Dispatches to appropriate chart renderer based on chart type.
416
469
  */
417
- function ChartRenderer({ field, context }: ChartRendererProps) {
470
+ function ChartRenderer({ field, context, baseline }: ChartRendererProps) {
418
471
  switch (field.chartType) {
419
472
  case "line": {
420
- return <LineChartRenderer field={field} context={context} />;
473
+ return (
474
+ <LineChartRenderer
475
+ field={field}
476
+ context={context}
477
+ baseline={baseline}
478
+ />
479
+ );
421
480
  }
422
481
  case "gauge": {
423
- return <GaugeRenderer field={field} context={context} />;
482
+ return (
483
+ <GaugeRenderer field={field} context={context} baseline={baseline} />
484
+ );
424
485
  }
425
486
  case "counter": {
426
- return <CounterRenderer field={field} context={context} />;
487
+ return (
488
+ <CounterRenderer field={field} context={context} baseline={baseline} />
489
+ );
427
490
  }
428
491
  case "bar": {
429
- return <BarChartRenderer field={field} context={context} />;
492
+ return (
493
+ <BarChartRenderer field={field} context={context} baseline={baseline} />
494
+ );
430
495
  }
431
496
  case "pie": {
432
- return <PieChartRenderer field={field} context={context} />;
497
+ return (
498
+ <PieChartRenderer field={field} context={context} baseline={baseline} />
499
+ );
433
500
  }
434
501
  case "boolean": {
435
- return <BooleanRenderer field={field} context={context} />;
502
+ return (
503
+ <BooleanRenderer field={field} context={context} baseline={baseline} />
504
+ );
436
505
  }
437
506
  case "text": {
438
- return <TextRenderer field={field} context={context} />;
507
+ return (
508
+ <TextRenderer field={field} context={context} baseline={baseline} />
509
+ );
439
510
  }
440
511
  case "status": {
441
- return <StatusRenderer field={field} context={context} />;
512
+ return (
513
+ <StatusRenderer field={field} context={context} baseline={baseline} />
514
+ );
442
515
  }
443
516
  default: {
444
517
  return;
@@ -502,7 +575,7 @@ function CounterRenderer({ field, context }: ChartRendererProps) {
502
575
  /**
503
576
  * Renders a percentage gauge visualization using Recharts RadialBarChart.
504
577
  */
505
- function GaugeRenderer({ field, context }: ChartRendererProps) {
578
+ function GaugeRenderer({ field, context, baseline }: ChartRendererProps) {
506
579
  const value = getLatestValue(field.name, context, field.instanceKey);
507
580
  const numValue =
508
581
  typeof value === "number" ? Math.min(100, Math.max(0, value)) : 0;
@@ -519,29 +592,66 @@ function GaugeRenderer({ field, context }: ChartRendererProps) {
519
592
  const data = [{ name: field.label, value: numValue, fill: fillColor }];
520
593
 
521
594
  return (
522
- <div className="flex items-center gap-3">
523
- <ResponsiveContainer width={80} height={80}>
524
- <RadialBarChart
525
- cx="50%"
526
- cy="50%"
527
- innerRadius="60%"
528
- outerRadius="100%"
529
- barSize={8}
530
- data={data}
531
- startAngle={90}
532
- endAngle={-270}
533
- >
534
- <RadialBar
535
- dataKey="value"
536
- cornerRadius={4}
537
- background={{ fill: "hsl(var(--muted))" }}
538
- />
539
- </RadialBarChart>
540
- </ResponsiveContainer>
541
- <div className="text-2xl font-bold" style={{ color: fillColor }}>
542
- {numValue.toFixed(1)}
543
- {unit}
595
+ <div className="flex flex-col gap-2">
596
+ <div className="flex items-center gap-3">
597
+ <ResponsiveContainer width={80} height={80}>
598
+ <RadialBarChart
599
+ cx="50%"
600
+ cy="50%"
601
+ innerRadius="60%"
602
+ outerRadius="100%"
603
+ barSize={8}
604
+ data={data}
605
+ startAngle={90}
606
+ endAngle={-270}
607
+ >
608
+ <RadialBar
609
+ dataKey="value"
610
+ cornerRadius={4}
611
+ background={{ fill: "hsl(var(--muted))" }}
612
+ />
613
+ </RadialBarChart>
614
+ </ResponsiveContainer>
615
+ <div className="text-2xl font-bold" style={{ color: fillColor }}>
616
+ {numValue.toFixed(1)}
617
+ {unit}
618
+ </div>
544
619
  </div>
620
+ {baseline &&
621
+ (() => {
622
+ if (
623
+ baseline.dominantValue !== undefined &&
624
+ baseline.dominantValue !== null
625
+ ) {
626
+ let expectedNum = Number(baseline.dominantValue);
627
+ if (baseline.dominantValue === "true" || baseline.dominantValue === "false") {
628
+ const ratio = baseline.dominantRatio ?? (baseline.dominantValue === "true" ? 1 : 0);
629
+ expectedNum = baseline.dominantValue === "true" ? (ratio * 100) : ((1 - ratio) * 100);
630
+ }
631
+ if (!Number.isNaN(expectedNum)) {
632
+ return (
633
+ <div className="text-xs text-muted-foreground mt-1">
634
+ Expected: {expectedNum.toFixed(1)}
635
+ {unit}
636
+ </div>
637
+ );
638
+ }
639
+ }
640
+
641
+ if (typeof baseline.mean === "number") {
642
+ const min = Math.max(0, baseline.mean - baseline.stdDev * 3);
643
+ const max = baseline.mean + baseline.stdDev * 3;
644
+ return (
645
+ <div className="text-xs text-muted-foreground mt-1">
646
+ Expected: {baseline.mean.toFixed(1)}
647
+ {unit} (±{(baseline.stdDev * 3).toFixed(1)}) [{min.toFixed(1)} -{" "}
648
+ {max.toFixed(1)}]
649
+ </div>
650
+ );
651
+ }
652
+
653
+ return <></>;
654
+ })()}
545
655
  </div>
546
656
  );
547
657
  }
@@ -549,7 +659,7 @@ function GaugeRenderer({ field, context }: ChartRendererProps) {
549
659
  /**
550
660
  * Renders a boolean indicator with historical sparkline.
551
661
  */
552
- function BooleanRenderer({ field, context }: ChartRendererProps) {
662
+ function BooleanRenderer({ field, context, baseline }: ChartRendererProps) {
553
663
  const valuesWithTime = getAllBooleanValuesWithTime(
554
664
  field.name,
555
665
  context,
@@ -587,6 +697,17 @@ function BooleanRenderer({ field, context }: ChartRendererProps) {
587
697
  )}
588
698
  </div>
589
699
 
700
+ {baseline &&
701
+ baseline.dominantValue !== undefined &&
702
+ baseline.dominantValue !== null && (
703
+ <div className="text-xs text-muted-foreground">
704
+ Expected: {String(baseline.dominantValue)}
705
+ {baseline.dominantRatio
706
+ ? ` (${(baseline.dominantRatio * 100).toFixed(0)}%)`
707
+ : ""}
708
+ </div>
709
+ )}
710
+
590
711
  {/* Sparkline timeline - render each value as a bar */}
591
712
  <div className="flex h-2 gap-px rounded">
592
713
  {valuesWithTime.map((item, index) => {
@@ -611,7 +732,7 @@ function BooleanRenderer({ field, context }: ChartRendererProps) {
611
732
  /**
612
733
  * Renders text value with historical sparkline for status-type fields.
613
734
  */
614
- function TextRenderer({ field, context }: ChartRendererProps) {
735
+ function TextRenderer({ field, context, baseline }: ChartRendererProps) {
615
736
  const valuesWithTime = getAllStringValuesWithTime(
616
737
  field.name,
617
738
  context,
@@ -641,6 +762,17 @@ function TextRenderer({ field, context }: ChartRendererProps) {
641
762
  )}
642
763
  </div>
643
764
 
765
+ {baseline &&
766
+ baseline.dominantValue !== undefined &&
767
+ baseline.dominantValue !== null && (
768
+ <div className="text-xs text-muted-foreground">
769
+ Expected: {String(baseline.dominantValue)}
770
+ {baseline.dominantRatio
771
+ ? ` (${(baseline.dominantRatio * 100).toFixed(0)}%)`
772
+ : ""}
773
+ </div>
774
+ )}
775
+
644
776
  {/* Sparkline timeline - always show for historical context */}
645
777
  {(() => {
646
778
  // Downsample for string values - bucket is "primary" if all values match latest
@@ -733,7 +865,7 @@ function StatusRenderer({ field, context }: ChartRendererProps) {
733
865
  /**
734
866
  * Renders an area chart for time series data using Recharts AreaChart.
735
867
  */
736
- function LineChartRenderer({ field, context }: ChartRendererProps) {
868
+ function LineChartRenderer({ field, context, baseline }: ChartRendererProps) {
737
869
  const valuesWithTime = getAllValuesWithTime(
738
870
  field.name,
739
871
  context,
@@ -745,28 +877,69 @@ function LineChartRenderer({ field, context }: ChartRendererProps) {
745
877
  return <div className="text-muted-foreground">No data</div>;
746
878
  }
747
879
 
748
- // Transform values to recharts data format with time labels
880
+ const avg =
881
+ valuesWithTime.reduce((a, b) => a + b.value, 0) / valuesWithTime.length;
882
+
883
+ // Calculate average runs per bucket to adjust the visual standard deviation.
884
+ // Since the chart plots the *average* of the bucket, the standard deviation
885
+ // of that average is sigma / sqrt(n). This makes the baseline band visually
886
+ // correct for the aggregated data being displayed.
887
+ const totalRuns = context.buckets.reduce(
888
+ (sum, b) => sum + (b.runCount || 1),
889
+ 0,
890
+ );
891
+ const avgRunCount = Math.max(
892
+ 1,
893
+ totalRuns / Math.max(1, context.buckets.length),
894
+ );
895
+
896
+ // Trend is the slope projected over the baseline window: slope × sampleCount
897
+ // — the same scalar the drift evaluator uses. Surfaced as a header chip rather
898
+ // than a diagonal line because it's a rate, not an absolute value, and shares
899
+ // no natural axis with the data series.
900
+ const projectedChange = baseline ? baseline.trendSlope * baseline.sampleCount : 0;
901
+ const showTrend = !!baseline && Math.abs(projectedChange) > 0.01;
902
+ const driftSigmas = baseline && baseline.stdDev > 0
903
+ ? Math.abs(projectedChange) / baseline.stdDev
904
+ : 0;
905
+ const isDrifting = driftSigmas >= 2;
906
+
749
907
  const chartData = valuesWithTime.map((item, index) => ({
750
908
  index,
751
909
  value: item.value,
752
910
  timeLabel: item.timeLabel,
753
911
  }));
754
912
 
755
- const avg =
756
- valuesWithTime.reduce((a, b) => a + b.value, 0) / valuesWithTime.length;
757
-
758
913
  return (
759
914
  <div className="space-y-2">
760
- <div className="text-lg font-medium">
761
- Avg: {avg.toFixed(1)}
762
- {unit && (
763
- <span className="text-sm font-normal text-muted-foreground ml-1">
764
- {unit}
915
+ {baseline ? (
916
+ <div className="flex items-center justify-between text-xs px-1 gap-3">
917
+ <span className="text-warning font-medium">
918
+ Expected: {baseline.mean.toFixed(1)}{unit} (±{((baseline.stdDev / Math.sqrt(avgRunCount)) * 3).toFixed(1)})
765
919
  </span>
766
- )}
767
- </div>
768
- <ResponsiveContainer width="100%" height={60}>
769
- <AreaChart data={chartData}>
920
+ <div className="flex items-center gap-3">
921
+ {showTrend && (
922
+ <span className={isDrifting ? "text-warning font-medium" : "text-muted-foreground"}>
923
+ Trend: {projectedChange >= 0 ? "↑ +" : "↓ "}{projectedChange.toFixed(1)}{unit}
924
+ </span>
925
+ )}
926
+ <span className="text-muted-foreground">
927
+ Avg: {avg.toFixed(1)}{unit}
928
+ </span>
929
+ </div>
930
+ </div>
931
+ ) : (
932
+ <div className="flex items-center justify-end text-xs px-1">
933
+ <span className="text-muted-foreground">
934
+ Avg: {avg.toFixed(1)}{unit}
935
+ </span>
936
+ </div>
937
+ )}
938
+ <ResponsiveContainer width="100%" height={120}>
939
+ <AreaChart
940
+ data={chartData}
941
+ margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
942
+ >
770
943
  <defs>
771
944
  <linearGradient
772
945
  id={`gradient-${field.name}`}
@@ -787,6 +960,41 @@ function LineChartRenderer({ field, context }: ChartRendererProps) {
787
960
  />
788
961
  </linearGradient>
789
962
  </defs>
963
+ <XAxis
964
+ dataKey="index"
965
+ type="number"
966
+ domain={["dataMin", "dataMax"]}
967
+ tickFormatter={(index: number) => {
968
+ const label = chartData[index]?.timeLabel;
969
+ return label ? label.split(" - ")[0] : "";
970
+ }}
971
+ stroke="hsl(var(--muted-foreground))"
972
+ fontSize={12}
973
+ minTickGap={30}
974
+ />
975
+ <YAxis
976
+ stroke="hsl(var(--muted-foreground))"
977
+ fontSize={12}
978
+ tickFormatter={(v: number) => `${v}${unit}`}
979
+ width={60}
980
+ />
981
+ {baseline && (
982
+ <ReferenceArea
983
+ y1={Math.max(
984
+ 0,
985
+ baseline.mean -
986
+ (baseline.stdDev / Math.sqrt(avgRunCount)) * 3,
987
+ )}
988
+ y2={
989
+ baseline.mean + (baseline.stdDev / Math.sqrt(avgRunCount)) * 3
990
+ }
991
+ fill="hsl(var(--warning))"
992
+ fillOpacity={0.08}
993
+ stroke="hsl(var(--warning))"
994
+ strokeOpacity={0.4}
995
+ strokeWidth={1}
996
+ />
997
+ )}
790
998
  <Area
791
999
  type="monotone"
792
1000
  dataKey="value"
@@ -15,6 +15,7 @@ import {
15
15
  healthcheckRoutes,
16
16
  } from "@checkstack/healthcheck-common";
17
17
  import { SatelliteApi, satelliteAccess } from "@checkstack/satellite-common";
18
+ import { AnomalyApi } from "@checkstack/anomaly-common";
18
19
  import { resolveRoute } from "@checkstack/common";
19
20
  import {
20
21
  HealthBadge,
@@ -189,6 +190,12 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
189
190
  },
190
191
  });
191
192
 
193
+ const anomalyClient = usePluginClient(AnomalyApi);
194
+ const { data: baselines = [] } = anomalyClient.getAnomalyBaselines.useQuery(
195
+ { systemId, configurationId: item.configurationId },
196
+ { enabled: !!systemId && !!item.configurationId }
197
+ );
198
+
192
199
  // Pagination for history table
193
200
  const pagination = usePagination({ defaultLimit: 5 });
194
201
 
@@ -425,6 +432,7 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
425
432
  context={chartContext}
426
433
  height={120}
427
434
  showAverage
435
+ baselines={baselines}
428
436
  />
429
437
  </CardContent>
430
438
  </Card>
@@ -5,15 +5,18 @@ import {
5
5
  YAxis,
6
6
  Tooltip,
7
7
  ResponsiveContainer,
8
- ReferenceLine,
8
+ ReferenceArea,
9
9
  } from "recharts";
10
10
  import { format } from "date-fns";
11
11
  import type { HealthCheckDiagramSlotContext } from "../slots";
12
12
 
13
+ import type { AnomalyBaselineDto } from "@checkstack/anomaly-common";
14
+
13
15
  interface HealthCheckLatencyChartProps {
14
16
  context: HealthCheckDiagramSlotContext;
15
17
  height?: number;
16
18
  showAverage?: boolean;
19
+ baselines?: AnomalyBaselineDto[];
17
20
  }
18
21
 
19
22
  /**
@@ -23,7 +26,7 @@ interface HealthCheckLatencyChartProps {
23
26
  */
24
27
  export const HealthCheckLatencyChart: React.FC<
25
28
  HealthCheckLatencyChartProps
26
- > = ({ context, height = 200, showAverage = true }) => {
29
+ > = ({ context, height = 200, showAverage = true, baselines }) => {
27
30
  const buckets = context.buckets.filter((b) => b.avgLatencyMs !== undefined);
28
31
 
29
32
  if (buckets.length === 0) {
@@ -37,6 +40,20 @@ export const HealthCheckLatencyChart: React.FC<
37
40
  );
38
41
  }
39
42
 
43
+ const baseline = baselines?.find((b) => b.fieldPath === "latencyMs");
44
+ const totalRuns = context.buckets.reduce((sum, b) => sum + (b.runCount || 1), 0);
45
+ const avgRunCount = Math.max(1, totalRuns / Math.max(1, context.buckets.length));
46
+
47
+ // Trend is the slope projected over the baseline window — a rate, surfaced in
48
+ // the header chip rather than as a chart line so it doesn't share a y-axis
49
+ // with absolute latency values.
50
+ const projectedChange = baseline ? baseline.trendSlope * baseline.sampleCount : 0;
51
+ const showTrend = !!baseline && Math.abs(projectedChange) > 0.01;
52
+ const driftSigmas = baseline && baseline.stdDev > 0
53
+ ? Math.abs(projectedChange) / baseline.stdDev
54
+ : 0;
55
+ const isDrifting = driftSigmas >= 2;
56
+
40
57
  const chartData = buckets.map((d) => ({
41
58
  timestamp: new Date(d.bucketStart).getTime(),
42
59
  bucketEndTimestamp: new Date(d.bucketEnd).getTime(),
@@ -57,8 +74,34 @@ export const HealthCheckLatencyChart: React.FC<
57
74
  : "MMM d, HH:mm";
58
75
 
59
76
  return (
60
- <ResponsiveContainer width="100%" height={height}>
61
- <AreaChart data={chartData}>
77
+ <div className="space-y-2">
78
+ {showAverage && (
79
+ baseline ? (
80
+ <div className="flex items-center justify-between text-xs px-1 gap-3">
81
+ <span className="text-warning font-medium">
82
+ Expected: {baseline.mean.toFixed(0)}ms (±{((baseline.stdDev / Math.sqrt(avgRunCount)) * 3).toFixed(0)})
83
+ </span>
84
+ <div className="flex items-center gap-3">
85
+ {showTrend && (
86
+ <span className={isDrifting ? "text-warning font-medium" : "text-muted-foreground"}>
87
+ Trend: {projectedChange >= 0 ? "↑ +" : "↓ "}{projectedChange.toFixed(0)}ms
88
+ </span>
89
+ )}
90
+ <span className="text-muted-foreground">
91
+ Avg: {avgLatency.toFixed(0)}ms
92
+ </span>
93
+ </div>
94
+ </div>
95
+ ) : (
96
+ <div className="flex items-center justify-end text-xs px-1">
97
+ <span className="text-muted-foreground">
98
+ Avg: {avgLatency.toFixed(0)}ms
99
+ </span>
100
+ </div>
101
+ )
102
+ )}
103
+ <ResponsiveContainer width="100%" height={height}>
104
+ <AreaChart data={chartData}>
62
105
  <defs>
63
106
  <linearGradient id="latencyGradient" x1="0" y1="0" x2="0" y2="1">
64
107
  <stop
@@ -109,17 +152,15 @@ export const HealthCheckLatencyChart: React.FC<
109
152
  );
110
153
  }}
111
154
  />
112
- {showAverage && (
113
- <ReferenceLine
114
- y={avgLatency}
115
- stroke="hsl(var(--muted-foreground))"
116
- strokeDasharray="3 3"
117
- label={{
118
- value: `Avg: ${avgLatency.toFixed(0)}ms`,
119
- position: "right",
120
- fill: "hsl(var(--muted-foreground))",
121
- fontSize: 12,
122
- }}
155
+ {baseline && (
156
+ <ReferenceArea
157
+ y1={Math.max(0, baseline.mean - (baseline.stdDev / Math.sqrt(avgRunCount)) * 3)}
158
+ y2={baseline.mean + (baseline.stdDev / Math.sqrt(avgRunCount)) * 3}
159
+ fill="hsl(var(--warning))"
160
+ fillOpacity={0.08}
161
+ stroke="hsl(var(--warning))"
162
+ strokeOpacity={0.4}
163
+ strokeWidth={1}
123
164
  />
124
165
  )}
125
166
  <Area
@@ -131,5 +172,6 @@ export const HealthCheckLatencyChart: React.FC<
131
172
  />
132
173
  </AreaChart>
133
174
  </ResponsiveContainer>
175
+ </div>
134
176
  );
135
177
  };
@@ -1,16 +1,14 @@
1
1
  import React from "react";
2
2
  import { Settings, Gauge, Database, Radio, Plus, Check } from "lucide-react";
3
3
  import { IDETreeNode, IDETreeSection } from "@checkstack/ui";
4
+ import { ExtensionSlot } from "@checkstack/frontend-api";
5
+ import { AssignmentIDENodeSlot } from "../../slots";
4
6
 
5
7
  // =============================================================================
6
8
  // TYPES
7
9
  // =============================================================================
8
10
 
9
- export type AssignmentNodeId =
10
- | `general:${string}`
11
- | `thresholds:${string}`
12
- | `retention:${string}`
13
- | `execution:${string}`;
11
+ export type AssignmentNodeId = string;
14
12
 
15
13
  interface AssignmentConfig {
16
14
  configurationId: string;
@@ -20,6 +18,7 @@ interface AssignmentConfig {
20
18
  }
21
19
 
22
20
  interface AssignmentTreeProps {
21
+ systemId: string;
23
22
  assigned: AssignmentConfig[];
24
23
  available: Array<{ id: string; name: string; strategyId: string }>;
25
24
  selectedNode: AssignmentNodeId | undefined;
@@ -33,6 +32,7 @@ interface AssignmentTreeProps {
33
32
  // =============================================================================
34
33
 
35
34
  export const AssignmentTree: React.FC<AssignmentTreeProps> = ({
35
+ systemId,
36
36
  assigned,
37
37
  available,
38
38
  selectedNode,
@@ -97,6 +97,16 @@ export const AssignmentTree: React.FC<AssignmentTreeProps> = ({
97
97
  indent
98
98
  badge={assoc.satelliteCount > 0 ? `${assoc.satelliteCount}` : undefined}
99
99
  />
100
+ <ExtensionSlot
101
+ slot={AssignmentIDENodeSlot}
102
+ context={{
103
+ systemId,
104
+ configurationId: assoc.configurationId,
105
+ selectedNode,
106
+ onSelectNode,
107
+ isLocked
108
+ }}
109
+ />
100
110
  </div>
101
111
  ))}
102
112
 
@@ -10,6 +10,8 @@ import {
10
10
  IDETreeSection,
11
11
  type ValidationIssue,
12
12
  } from "@checkstack/ui";
13
+ import { ExtensionSlot } from "@checkstack/frontend-api";
14
+ import { HealthCheckConfigIDENodeSlot } from "../../slots";
13
15
 
14
16
  // =============================================================================
15
17
  // TYPES
@@ -19,7 +21,8 @@ export type TreeNodeId =
19
21
  | "general"
20
22
  | "access"
21
23
  | "collector-picker"
22
- | `collector:${string}`;
24
+ | `collector:${string}`
25
+ | (string & {});
23
26
 
24
27
  interface EditorTreeProps {
25
28
  collectors: CollectorConfigEntry[];
@@ -29,6 +32,7 @@ interface EditorTreeProps {
29
32
  onAddCollector: (collectorId: string) => void;
30
33
  validationIssues: ValidationIssue[];
31
34
  strategyId: string;
35
+ configId?: string;
32
36
  }
33
37
 
34
38
  // =============================================================================
@@ -42,6 +46,7 @@ export const EditorTree: React.FC<EditorTreeProps> = ({
42
46
  onSelectNode,
43
47
  validationIssues,
44
48
  strategyId,
49
+ configId,
45
50
  }) => {
46
51
  // Check if there are addable collectors remaining
47
52
  const hasAddableCollectors = useMemo(() => {
@@ -115,6 +120,19 @@ export const EditorTree: React.FC<EditorTreeProps> = ({
115
120
  onClick={() => onSelectNode("access")}
116
121
  issues={validationIssues}
117
122
  />
123
+
124
+ {/* Plugin Configuration Slots */}
125
+ {configId && (
126
+ <ExtensionSlot
127
+ slot={HealthCheckConfigIDENodeSlot}
128
+ context={{
129
+ configurationId: configId,
130
+ strategyId,
131
+ selectedNode,
132
+ onSelectNode,
133
+ }}
134
+ />
135
+ )}
118
136
  </div>
119
137
  );
120
138
  };
package/src/index.tsx CHANGED
@@ -29,13 +29,20 @@ import {
29
29
  // Export slot definitions for other plugins to use
30
30
  export {
31
31
  HealthCheckDiagramSlot,
32
- createDiagramExtensionFactory,
32
+ AssignmentIDENodeSlot,
33
+ AssignmentIDEPanelSlot,
34
+ HealthCheckConfigIDENodeSlot,
35
+ HealthCheckConfigIDEPanelSlot,
33
36
  type HealthCheckDiagramSlotContext,
37
+ type AssignmentIDEContext,
38
+ type HealthCheckConfigIDEContext,
39
+ createDiagramExtensionFactory,
34
40
  type TypedAggregatedBucket,
35
41
  } from "./slots";
36
42
 
37
43
  // Export hooks for reusable data fetching
38
44
  export { useHealthCheckData } from "./hooks";
45
+ export { useStrategySchemas } from "./auto-charts/useStrategySchemas";
39
46
 
40
47
  export default createFrontendPlugin({
41
48
  metadata: pluginMetadata,
@@ -1,6 +1,6 @@
1
1
  import { useState, useEffect, useMemo } from "react";
2
2
  import { useParams, useNavigate } from "react-router-dom";
3
- import { usePluginClient, wrapInSuspense } from "@checkstack/frontend-api";
3
+ import { usePluginClient, wrapInSuspense, ExtensionSlot } from "@checkstack/frontend-api";
4
4
  import { HealthCheckApi } from "../api";
5
5
  import { SatelliteApi } from "@checkstack/satellite-common";
6
6
  import {
@@ -24,6 +24,7 @@ import {
24
24
  type RetentionData,
25
25
  } from "../components/assignments/RetentionPanel";
26
26
  import { ExecutionPanel } from "../components/assignments/ExecutionPanel";
27
+ import { AssignmentIDEPanelSlot } from "../slots";
27
28
 
28
29
  // =============================================================================
29
30
  // HELPERS
@@ -144,7 +145,9 @@ const AssignmentIDEPageContent = () => {
144
145
  // --- Mutations ---
145
146
 
146
147
  const associateMutation = healthCheckClient.associateSystem.useMutation({
147
- onSuccess: () => void refetchAssociations(),
148
+ onSuccess: () => {
149
+ void refetchAssociations();
150
+ },
148
151
  onError: (error) =>
149
152
  toast.error(extractErrorMessage(error, "Failed to update")),
150
153
  });
@@ -152,7 +155,7 @@ const AssignmentIDEPageContent = () => {
152
155
  const disassociateMutation = healthCheckClient.disassociateSystem.useMutation(
153
156
  {
154
157
  onSuccess: () => {
155
- toast.success("Health check unassigned");
158
+ toast.success("Health check unassigned");
156
159
  void refetchAssociations();
157
160
  },
158
161
  onError: (error) =>
@@ -162,7 +165,9 @@ const AssignmentIDEPageContent = () => {
162
165
 
163
166
  const updateRetentionMutation =
164
167
  healthCheckClient.updateRetentionConfig.useMutation({
165
- onSuccess: () => toast.success("Retention settings saved"),
168
+ onSuccess: () => {
169
+ toast.success("Retention settings saved");
170
+ },
166
171
  onError: (error) =>
167
172
  toast.error(extractErrorMessage(error, "Failed to save")),
168
173
  });
@@ -444,6 +449,20 @@ const AssignmentIDEPageContent = () => {
444
449
  />
445
450
  );
446
451
  }
452
+ default: {
453
+ return (
454
+ <ExtensionSlot
455
+ slot={AssignmentIDEPanelSlot}
456
+ context={{
457
+ systemId: systemId ?? "",
458
+ configurationId: configId,
459
+ selectedNode,
460
+ onSelectNode: setSelectedNode,
461
+ isLocked
462
+ }}
463
+ />
464
+ );
465
+ }
447
466
  }
448
467
  };
449
468
 
@@ -476,6 +495,7 @@ const AssignmentIDEPageContent = () => {
476
495
  <IDELayout
477
496
  tree={
478
497
  <AssignmentTree
498
+ systemId={systemId ?? ""}
479
499
  assigned={assignedConfigs}
480
500
  available={availableConfigs}
481
501
  selectedNode={selectedNode}
@@ -121,7 +121,7 @@ const HealthCheckHistoryDetailPageContent = () => {
121
121
  loading={accessLoading}
122
122
  allowed={canManage}
123
123
  actions={
124
- <BackLink to={resolveRoute(healthcheckRoutes.routes.history)}>
124
+ <BackLink onClick={() => navigate(resolveRoute(healthcheckRoutes.routes.history))}>
125
125
  Back to All History
126
126
  </BackLink>
127
127
  }
@@ -3,8 +3,10 @@ import { useParams, useSearchParams, useNavigate } from "react-router-dom";
3
3
  import {
4
4
  usePluginClient,
5
5
  wrapInSuspense,
6
+ ExtensionSlot,
6
7
  } from "@checkstack/frontend-api";
7
8
  import { HealthCheckApi } from "../api";
9
+ import { HealthCheckConfigIDEPanelSlot } from "../slots";
8
10
  import {
9
11
  healthcheckRoutes,
10
12
  type CollectorConfigEntry,
@@ -349,32 +351,47 @@ const HealthCheckIDEPageContent = () => {
349
351
  onAddCollector={handleCollectorAdd}
350
352
  validationIssues={validationIssues}
351
353
  strategyId={activeStrategyId ?? ""}
354
+ configId={configId}
352
355
  />
353
356
  }
354
357
  panel={
355
- <EditorPanel
356
- selectedNode={selectedNode}
357
- formState={formState}
358
- strategy={activeStrategy}
359
- availableCollectors={availableCollectors}
360
- collectorsLoading={collectorsLoading}
361
- isEditMode={isEditMode}
362
- configId={configId}
363
- onNameChange={(name) => updateField("name", name)}
364
- onIntervalChange={(interval) =>
365
- updateField("intervalSeconds", interval)
366
- }
367
- onStrategyConfigChange={(config) =>
368
- updateField("strategyConfig", config)
369
- }
370
- onStrategyConfigValidChange={setStrategyConfigValid}
371
- onCollectorConfigChange={handleCollectorConfigChange}
372
- onCollectorAssertionsChange={handleCollectorAssertionsChange}
373
- onCollectorValidChange={handleCollectorValidChange}
374
- onCollectorRemove={handleCollectorRemove}
375
- onCollectorAdd={handleCollectorAdd}
376
- strategyId={activeStrategyId ?? ""}
377
- />
358
+ <>
359
+ <EditorPanel
360
+ selectedNode={selectedNode}
361
+ formState={formState}
362
+ strategy={activeStrategy}
363
+ availableCollectors={availableCollectors}
364
+ collectorsLoading={collectorsLoading}
365
+ isEditMode={isEditMode}
366
+ configId={configId}
367
+ onNameChange={(name) => updateField("name", name)}
368
+ onIntervalChange={(interval) =>
369
+ updateField("intervalSeconds", interval)
370
+ }
371
+ onStrategyConfigChange={(config) =>
372
+ updateField("strategyConfig", config)
373
+ }
374
+ onStrategyConfigValidChange={setStrategyConfigValid}
375
+ onCollectorConfigChange={handleCollectorConfigChange}
376
+ onCollectorAssertionsChange={handleCollectorAssertionsChange}
377
+ onCollectorValidChange={handleCollectorValidChange}
378
+ onCollectorRemove={handleCollectorRemove}
379
+ onCollectorAdd={handleCollectorAdd}
380
+ strategyId={activeStrategyId ?? ""}
381
+ />
382
+ {configId && (
383
+ <ExtensionSlot
384
+ slot={HealthCheckConfigIDEPanelSlot}
385
+ context={{
386
+ configurationId: configId,
387
+ strategyId: activeStrategyId ?? "",
388
+ selectedNode,
389
+ onSelectNode: setSelectedNode,
390
+ isLocked,
391
+ }}
392
+ />
393
+ )}
394
+ </>
378
395
  }
379
396
  issues={validationIssues}
380
397
  onIssueClick={(nodeId) => setSelectedNode(nodeId as TreeNodeId)}
package/src/slots.tsx CHANGED
@@ -44,6 +44,47 @@ export const HealthCheckDiagramSlot = createSlot<HealthCheckDiagramSlotContext>(
44
44
  "healthcheck.diagram",
45
45
  );
46
46
 
47
+ export interface AssignmentIDEContext {
48
+ systemId: string;
49
+ configurationId: string;
50
+ selectedNode: string | undefined;
51
+ onSelectNode: (nodeId: string) => void;
52
+ isLocked?: boolean;
53
+ }
54
+
55
+ /**
56
+ * Extension slot for adding items to the Assignment IDE tree
57
+ */
58
+ export const AssignmentIDENodeSlot = createSlot<AssignmentIDEContext>(
59
+ "healthcheck.assignment.ide.node"
60
+ );
61
+
62
+ export const AssignmentIDEPanelSlot = createSlot<AssignmentIDEContext>(
63
+ "healthcheck.assignment.ide.panel"
64
+ );
65
+
66
+ export interface HealthCheckConfigIDEContext {
67
+ configurationId: string;
68
+ strategyId: string;
69
+ selectedNode: string | undefined;
70
+ onSelectNode: (nodeId: string) => void;
71
+ isLocked?: boolean;
72
+ }
73
+
74
+ /**
75
+ * Extension slot for adding items to the Health Check Configuration IDE tree
76
+ */
77
+ export const HealthCheckConfigIDENodeSlot = createSlot<HealthCheckConfigIDEContext>(
78
+ "healthcheck.config.ide.node"
79
+ );
80
+
81
+ /**
82
+ * Extension slot for rendering the panel of a Health Check Configuration IDE item
83
+ */
84
+ export const HealthCheckConfigIDEPanelSlot = createSlot<HealthCheckConfigIDEContext>(
85
+ "healthcheck.config.ide.panel"
86
+ );
87
+
47
88
  // =============================================================================
48
89
  // DIAGRAM EXTENSION FACTORY
49
90
  // =============================================================================