@checkstack/healthcheck-frontend 0.16.5 → 0.17.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,61 @@
1
1
  # @checkstack/healthcheck-frontend
2
2
 
3
+ ## 0.17.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 42b0832: Refactor auto-chart layout to make collector grouping more dominant. Chart titles now show only the metric label (e.g. "Avg Response Time") instead of the prefixed "{collectorId}: Metric" form. Collector groups display the collector name as a heading with a badge containing the full collector id. Cards now stack at full width and their contents are center-aligned.
8
+
9
+ ## 0.17.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 8d1ef12: ## Anomaly Detection & UI Improvements
14
+
15
+ ### Anomaly Detection Enhancements (Phase 2)
16
+
17
+ - **`@checkstack/anomaly-backend`**: Implemented background baseline analyzer jobs and anomaly trend deviation detection mechanics.
18
+ - **`@checkstack/anomaly-common`**: Added new baseline statistical logic and inference rules.
19
+ - **`@checkstack/anomaly-frontend`**: Added new Anomaly Widget and refactored system detail rendering to be more human-readable.
20
+ - **`@checkstack/dashboard-frontend`**: Refined the global anomaly widget and fixed hardcoded access gating to render appropriately.
21
+ - **`@checkstack/healthcheck-backend`**: Connected executor telemetry to the anomaly pipeline.
22
+ - **`@checkstack/healthcheck-frontend`**: Reconciled baseline display consistency in Drawer and charts.
23
+
24
+ ### Notification Identifiers
25
+
26
+ - **`@checkstack/incident-backend`**: Resolved system IDs to human-readable System Names within Incident notifications to eliminate ID-only alert content.
27
+ - **`@checkstack/maintenance-backend`**: Adopted the same resolution strategy for Maintenance notifications to keep parity.
28
+
29
+ ### UI Experience
30
+
31
+ - **`@checkstack/incident-frontend`**: Fixed the "Back to X" BackLink to properly use `react-router` hook `useNavigate` instead of doing a full application reload.
32
+ - **`@checkstack/healthcheck-frontend`**: Implemented `useNavigate` for seamless SPA back-linking.
33
+ - **`@checkstack/integration-frontend`**: Updated connections and delivery logs links to navigate without hard reloads.
34
+
35
+ - 8d1ef12: Phase 2 of anomaly detection: trend drift detection.
36
+
37
+ 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.
38
+
39
+ 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`.
40
+
41
+ ### Patch Changes
42
+
43
+ - Updated dependencies [8d1ef12]
44
+ - Updated dependencies [8d1ef12]
45
+ - Updated dependencies [8d1ef12]
46
+ - Updated dependencies [8d1ef12]
47
+ - @checkstack/healthcheck-common@0.12.0
48
+ - @checkstack/anomaly-common@0.2.0
49
+ - @checkstack/dashboard-frontend@0.5.0
50
+ - @checkstack/common@0.7.0
51
+ - @checkstack/ui@1.6.0
52
+ - @checkstack/satellite-common@0.2.1
53
+ - @checkstack/auth-frontend@0.5.30
54
+ - @checkstack/catalog-common@1.5.2
55
+ - @checkstack/frontend-api@0.3.11
56
+ - @checkstack/gitops-frontend@0.3.5
57
+ - @checkstack/signal-frontend@0.0.16
58
+
3
59
  ## 0.16.5
4
60
 
5
61
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-frontend",
3
- "version": "0.16.5",
3
+ "version": "0.17.1",
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",
@@ -10,7 +10,7 @@ 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 { Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
13
+ import { Badge, Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
14
14
  import {
15
15
  PieChart,
16
16
  Pie,
@@ -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
@@ -75,9 +86,14 @@ export function AutoChartGrid({ context }: AutoChartGridProps) {
75
86
  <div className="space-y-6 mt-4">
76
87
  {/* Strategy-level fields */}
77
88
  {strategyFields.length > 0 && (
78
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
89
+ <div className="space-y-4">
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>
@@ -101,6 +118,7 @@ interface CollectorGroupData {
101
118
  instanceKey: string;
102
119
  collectorId: string;
103
120
  displayName: string;
121
+ instanceLabel?: string;
104
122
  fields: ExpandedChartField[];
105
123
  }
106
124
 
@@ -123,15 +141,15 @@ function buildCollectorGroups(
123
141
 
124
142
  // Create a group for each instance
125
143
  for (const [index, instanceKey] of instanceKeys.entries()) {
126
- const displayName =
127
- instanceKeys.length === 1
128
- ? collectorId.split(".").pop() || collectorId
129
- : `${collectorId.split(".").pop() || collectorId} #${index + 1}`;
144
+ const displayName = collectorId.split(".").pop() || collectorId;
145
+ const instanceLabel =
146
+ instanceKeys.length > 1 ? `#${index + 1}` : undefined;
130
147
 
131
148
  groups.push({
132
149
  instanceKey,
133
150
  collectorId,
134
151
  displayName,
152
+ instanceLabel,
135
153
  fields: collectorFields.map((field) => ({
136
154
  ...field,
137
155
  instanceKey,
@@ -151,11 +169,14 @@ function buildCollectorGroups(
151
169
  function CollectorGroup({
152
170
  group,
153
171
  context,
172
+ baselines,
154
173
  }: {
155
174
  group: CollectorGroupData;
156
175
  context: HealthCheckDiagramSlotContext;
176
+ baselines: AnomalyBaselineDto[];
157
177
  }) {
158
- // Separate fields into narrow (grid) and wide (full-width) categories
178
+ // Order: narrow (summary) cards first, then wide timeline cards.
179
+ // Layout is now fully stacked at 100% width.
159
180
  const narrowFields = group.fields.filter(
160
181
  (f) => !WIDE_CHART_TYPES.has(f.chartType),
161
182
  );
@@ -165,25 +186,30 @@ function CollectorGroup({
165
186
 
166
187
  return (
167
188
  <div className="space-y-4">
168
- <h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
169
- {group.displayName}
170
- </h4>
171
-
172
- {/* Narrow cards grid - these pack together nicely */}
173
- {narrowFields.length > 0 && (
174
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
175
- {narrowFields.map((field) => (
176
- <AutoChartCard
177
- key={`${field.instanceKey}-${field.name}`}
178
- field={field}
179
- context={context}
180
- />
181
- ))}
182
- </div>
183
- )}
189
+ <div className="flex items-center gap-2 flex-wrap border-b pb-2">
190
+ <h3 className="text-lg font-semibold capitalize">
191
+ {group.displayName}
192
+ </h3>
193
+ {group.instanceLabel && (
194
+ <span className="text-sm font-medium text-muted-foreground">
195
+ {group.instanceLabel}
196
+ </span>
197
+ )}
198
+ <Badge variant="outline" className="font-mono">
199
+ {group.collectorId}
200
+ </Badge>
201
+ </div>
184
202
 
185
- {/* Wide timeline cards - assertion plus timeline fields */}
186
203
  <div className="space-y-4">
204
+ {narrowFields.map((field) => (
205
+ <AutoChartCard
206
+ key={`${field.instanceKey}-${field.name}`}
207
+ field={field}
208
+ context={context}
209
+ baselines={baselines}
210
+ />
211
+ ))}
212
+
187
213
  <AssertionStatusCard
188
214
  context={context}
189
215
  instanceKey={group.instanceKey}
@@ -194,6 +220,7 @@ function CollectorGroup({
194
220
  key={`${field.instanceKey}-${field.name}`}
195
221
  field={field}
196
222
  context={context}
223
+ baselines={baselines}
197
224
  />
198
225
  ))}
199
226
  </div>
@@ -254,9 +281,11 @@ function AssertionStatusCard({
254
281
  return (
255
282
  <Card>
256
283
  <CardHeader className="pb-2">
257
- <CardTitle className="text-sm font-medium">Assertion</CardTitle>
284
+ <CardTitle className="text-sm font-medium text-center">
285
+ Assertion
286
+ </CardTitle>
258
287
  </CardHeader>
259
- <CardContent>
288
+ <CardContent className="text-center">
260
289
  <div className="text-sm text-muted-foreground">No data</div>
261
290
  </CardContent>
262
291
  </Card>
@@ -277,14 +306,14 @@ function AssertionStatusCard({
277
306
  >
278
307
  <CardHeader className="pb-2">
279
308
  <CardTitle
280
- className={`text-sm font-medium ${latestResult.passed ? "" : "text-red-600"}`}
309
+ className={`text-sm font-medium text-center ${latestResult.passed ? "" : "text-red-600"}`}
281
310
  >
282
311
  {latestResult.passed ? "Assertion" : "Assertion Failed"}
283
312
  </CardTitle>
284
313
  </CardHeader>
285
- <CardContent className="space-y-2">
314
+ <CardContent className="space-y-2 text-center">
286
315
  {/* Current status with rate */}
287
- <div className="flex items-center gap-2">
316
+ <div className="flex items-center justify-center gap-2">
288
317
  <div
289
318
  className={`w-3 h-3 rounded-full ${
290
319
  latestResult.passed ? "bg-green-500" : "bg-red-500"
@@ -383,6 +412,7 @@ interface ExpandedChartField extends ChartField {
383
412
  interface AutoChartCardProps {
384
413
  field: ExpandedChartField;
385
414
  context: HealthCheckDiagramSlotContext;
415
+ baselines?: AnomalyBaselineDto[];
386
416
  }
387
417
 
388
418
  /**
@@ -393,14 +423,46 @@ const WIDE_CHART_TYPES = new Set(["line", "boolean", "text"]);
393
423
  /**
394
424
  * Individual chart card that renders based on field type.
395
425
  */
396
- function AutoChartCard({ field, context }: AutoChartCardProps) {
426
+ function AutoChartCard({ field, context, baselines }: AutoChartCardProps) {
427
+ const fullFieldPath = field.collectorId
428
+ ? `collectors.${field.collectorId}.${field.name}`
429
+ : field.name;
430
+
431
+ let baseline = baselines?.find((b) => b.fieldPath === fullFieldPath);
432
+
433
+ // If no exact match, try mapping aggregated field names back to raw field names
434
+ if (!baseline) {
435
+ let rawFieldName = field.name;
436
+ if (rawFieldName.startsWith("avg")) {
437
+ rawFieldName =
438
+ rawFieldName.charAt(3).toLowerCase() + rawFieldName.slice(4);
439
+ } else if (rawFieldName.startsWith("min")) {
440
+ rawFieldName =
441
+ rawFieldName.charAt(3).toLowerCase() + rawFieldName.slice(4);
442
+ } else if (rawFieldName.startsWith("max")) {
443
+ rawFieldName =
444
+ rawFieldName.charAt(3).toLowerCase() + rawFieldName.slice(4);
445
+ } else if (rawFieldName === "successRate") {
446
+ rawFieldName = "success";
447
+ }
448
+
449
+ if (rawFieldName !== field.name) {
450
+ const rawFullFieldPath = field.collectorId
451
+ ? `collectors.${field.collectorId}.${rawFieldName}`
452
+ : rawFieldName;
453
+ baseline = baselines?.find((b) => b.fieldPath === rawFullFieldPath);
454
+ }
455
+ }
456
+
397
457
  return (
398
458
  <Card>
399
459
  <CardHeader className="pb-2">
400
- <CardTitle className="text-sm font-medium">{field.label}</CardTitle>
460
+ <CardTitle className="text-sm font-medium text-center">
461
+ {field.label}
462
+ </CardTitle>
401
463
  </CardHeader>
402
- <CardContent>
403
- <ChartRenderer field={field} context={context} />
464
+ <CardContent className="flex flex-col items-center text-center [&>*]:w-full">
465
+ <ChartRenderer field={field} context={context} baseline={baseline} />
404
466
  </CardContent>
405
467
  </Card>
406
468
  );
@@ -409,36 +471,57 @@ function AutoChartCard({ field, context }: AutoChartCardProps) {
409
471
  interface ChartRendererProps {
410
472
  field: ExpandedChartField;
411
473
  context: HealthCheckDiagramSlotContext;
474
+ baseline?: AnomalyBaselineDto;
412
475
  }
413
476
 
414
477
  /**
415
478
  * Dispatches to appropriate chart renderer based on chart type.
416
479
  */
417
- function ChartRenderer({ field, context }: ChartRendererProps) {
480
+ function ChartRenderer({ field, context, baseline }: ChartRendererProps) {
418
481
  switch (field.chartType) {
419
482
  case "line": {
420
- return <LineChartRenderer field={field} context={context} />;
483
+ return (
484
+ <LineChartRenderer
485
+ field={field}
486
+ context={context}
487
+ baseline={baseline}
488
+ />
489
+ );
421
490
  }
422
491
  case "gauge": {
423
- return <GaugeRenderer field={field} context={context} />;
492
+ return (
493
+ <GaugeRenderer field={field} context={context} baseline={baseline} />
494
+ );
424
495
  }
425
496
  case "counter": {
426
- return <CounterRenderer field={field} context={context} />;
497
+ return (
498
+ <CounterRenderer field={field} context={context} baseline={baseline} />
499
+ );
427
500
  }
428
501
  case "bar": {
429
- return <BarChartRenderer field={field} context={context} />;
502
+ return (
503
+ <BarChartRenderer field={field} context={context} baseline={baseline} />
504
+ );
430
505
  }
431
506
  case "pie": {
432
- return <PieChartRenderer field={field} context={context} />;
507
+ return (
508
+ <PieChartRenderer field={field} context={context} baseline={baseline} />
509
+ );
433
510
  }
434
511
  case "boolean": {
435
- return <BooleanRenderer field={field} context={context} />;
512
+ return (
513
+ <BooleanRenderer field={field} context={context} baseline={baseline} />
514
+ );
436
515
  }
437
516
  case "text": {
438
- return <TextRenderer field={field} context={context} />;
517
+ return (
518
+ <TextRenderer field={field} context={context} baseline={baseline} />
519
+ );
439
520
  }
440
521
  case "status": {
441
- return <StatusRenderer field={field} context={context} />;
522
+ return (
523
+ <StatusRenderer field={field} context={context} baseline={baseline} />
524
+ );
442
525
  }
443
526
  default: {
444
527
  return;
@@ -502,7 +585,7 @@ function CounterRenderer({ field, context }: ChartRendererProps) {
502
585
  /**
503
586
  * Renders a percentage gauge visualization using Recharts RadialBarChart.
504
587
  */
505
- function GaugeRenderer({ field, context }: ChartRendererProps) {
588
+ function GaugeRenderer({ field, context, baseline }: ChartRendererProps) {
506
589
  const value = getLatestValue(field.name, context, field.instanceKey);
507
590
  const numValue =
508
591
  typeof value === "number" ? Math.min(100, Math.max(0, value)) : 0;
@@ -519,29 +602,66 @@ function GaugeRenderer({ field, context }: ChartRendererProps) {
519
602
  const data = [{ name: field.label, value: numValue, fill: fillColor }];
520
603
 
521
604
  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}
605
+ <div className="flex flex-col gap-2 items-center">
606
+ <div className="flex items-center justify-center gap-3">
607
+ <ResponsiveContainer width={80} height={80}>
608
+ <RadialBarChart
609
+ cx="50%"
610
+ cy="50%"
611
+ innerRadius="60%"
612
+ outerRadius="100%"
613
+ barSize={8}
614
+ data={data}
615
+ startAngle={90}
616
+ endAngle={-270}
617
+ >
618
+ <RadialBar
619
+ dataKey="value"
620
+ cornerRadius={4}
621
+ background={{ fill: "hsl(var(--muted))" }}
622
+ />
623
+ </RadialBarChart>
624
+ </ResponsiveContainer>
625
+ <div className="text-2xl font-bold" style={{ color: fillColor }}>
626
+ {numValue.toFixed(1)}
627
+ {unit}
628
+ </div>
544
629
  </div>
630
+ {baseline &&
631
+ (() => {
632
+ if (
633
+ baseline.dominantValue !== undefined &&
634
+ baseline.dominantValue !== null
635
+ ) {
636
+ 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);
640
+ }
641
+ if (!Number.isNaN(expectedNum)) {
642
+ return (
643
+ <div className="text-xs text-muted-foreground mt-1">
644
+ Expected: {expectedNum.toFixed(1)}
645
+ {unit}
646
+ </div>
647
+ );
648
+ }
649
+ }
650
+
651
+ if (typeof baseline.mean === "number") {
652
+ const min = Math.max(0, baseline.mean - baseline.stdDev * 3);
653
+ const max = baseline.mean + baseline.stdDev * 3;
654
+ return (
655
+ <div className="text-xs text-muted-foreground mt-1">
656
+ Expected: {baseline.mean.toFixed(1)}
657
+ {unit} (±{(baseline.stdDev * 3).toFixed(1)}) [{min.toFixed(1)} -{" "}
658
+ {max.toFixed(1)}]
659
+ </div>
660
+ );
661
+ }
662
+
663
+ return <></>;
664
+ })()}
545
665
  </div>
546
666
  );
547
667
  }
@@ -549,7 +669,7 @@ function GaugeRenderer({ field, context }: ChartRendererProps) {
549
669
  /**
550
670
  * Renders a boolean indicator with historical sparkline.
551
671
  */
552
- function BooleanRenderer({ field, context }: ChartRendererProps) {
672
+ function BooleanRenderer({ field, context, baseline }: ChartRendererProps) {
553
673
  const valuesWithTime = getAllBooleanValuesWithTime(
554
674
  field.name,
555
675
  context,
@@ -571,7 +691,7 @@ function BooleanRenderer({ field, context }: ChartRendererProps) {
571
691
  return (
572
692
  <div className="space-y-2">
573
693
  {/* Current status with rate */}
574
- <div className="flex items-center gap-2">
694
+ <div className="flex items-center justify-center gap-2">
575
695
  <div
576
696
  className={`w-3 h-3 rounded-full ${
577
697
  latestValue ? "bg-green-500" : "bg-red-500"
@@ -587,6 +707,17 @@ function BooleanRenderer({ field, context }: ChartRendererProps) {
587
707
  )}
588
708
  </div>
589
709
 
710
+ {baseline &&
711
+ baseline.dominantValue !== undefined &&
712
+ baseline.dominantValue !== null && (
713
+ <div className="text-xs text-muted-foreground">
714
+ Expected: {String(baseline.dominantValue)}
715
+ {baseline.dominantRatio
716
+ ? ` (${(baseline.dominantRatio * 100).toFixed(0)}%)`
717
+ : ""}
718
+ </div>
719
+ )}
720
+
590
721
  {/* Sparkline timeline - render each value as a bar */}
591
722
  <div className="flex h-2 gap-px rounded">
592
723
  {valuesWithTime.map((item, index) => {
@@ -611,7 +742,7 @@ function BooleanRenderer({ field, context }: ChartRendererProps) {
611
742
  /**
612
743
  * Renders text value with historical sparkline for status-type fields.
613
744
  */
614
- function TextRenderer({ field, context }: ChartRendererProps) {
745
+ function TextRenderer({ field, context, baseline }: ChartRendererProps) {
615
746
  const valuesWithTime = getAllStringValuesWithTime(
616
747
  field.name,
617
748
  context,
@@ -632,7 +763,7 @@ function TextRenderer({ field, context }: ChartRendererProps) {
632
763
  return (
633
764
  <div className="space-y-2">
634
765
  {/* Current value with count */}
635
- <div className="flex items-center gap-2">
766
+ <div className="flex items-center justify-center gap-2">
636
767
  <span className="text-sm font-mono">{latestValue || "—"}</span>
637
768
  {!allSame && (
638
769
  <span className="text-xs text-muted-foreground">
@@ -641,6 +772,17 @@ function TextRenderer({ field, context }: ChartRendererProps) {
641
772
  )}
642
773
  </div>
643
774
 
775
+ {baseline &&
776
+ baseline.dominantValue !== undefined &&
777
+ baseline.dominantValue !== null && (
778
+ <div className="text-xs text-muted-foreground">
779
+ Expected: {String(baseline.dominantValue)}
780
+ {baseline.dominantRatio
781
+ ? ` (${(baseline.dominantRatio * 100).toFixed(0)}%)`
782
+ : ""}
783
+ </div>
784
+ )}
785
+
644
786
  {/* Sparkline timeline - always show for historical context */}
645
787
  {(() => {
646
788
  // Downsample for string values - bucket is "primary" if all values match latest
@@ -733,7 +875,7 @@ function StatusRenderer({ field, context }: ChartRendererProps) {
733
875
  /**
734
876
  * Renders an area chart for time series data using Recharts AreaChart.
735
877
  */
736
- function LineChartRenderer({ field, context }: ChartRendererProps) {
878
+ function LineChartRenderer({ field, context, baseline }: ChartRendererProps) {
737
879
  const valuesWithTime = getAllValuesWithTime(
738
880
  field.name,
739
881
  context,
@@ -745,28 +887,69 @@ function LineChartRenderer({ field, context }: ChartRendererProps) {
745
887
  return <div className="text-muted-foreground">No data</div>;
746
888
  }
747
889
 
748
- // Transform values to recharts data format with time labels
890
+ const avg =
891
+ valuesWithTime.reduce((a, b) => a + b.value, 0) / valuesWithTime.length;
892
+
893
+ // Calculate average runs per bucket to adjust the visual standard deviation.
894
+ // Since the chart plots the *average* of the bucket, the standard deviation
895
+ // of that average is sigma / sqrt(n). This makes the baseline band visually
896
+ // correct for the aggregated data being displayed.
897
+ const totalRuns = context.buckets.reduce(
898
+ (sum, b) => sum + (b.runCount || 1),
899
+ 0,
900
+ );
901
+ const avgRunCount = Math.max(
902
+ 1,
903
+ totalRuns / Math.max(1, context.buckets.length),
904
+ );
905
+
906
+ // Trend is the slope projected over the baseline window: slope × sampleCount
907
+ // — the same scalar the drift evaluator uses. Surfaced as a header chip rather
908
+ // than a diagonal line because it's a rate, not an absolute value, and shares
909
+ // 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
914
+ : 0;
915
+ const isDrifting = driftSigmas >= 2;
916
+
749
917
  const chartData = valuesWithTime.map((item, index) => ({
750
918
  index,
751
919
  value: item.value,
752
920
  timeLabel: item.timeLabel,
753
921
  }));
754
922
 
755
- const avg =
756
- valuesWithTime.reduce((a, b) => a + b.value, 0) / valuesWithTime.length;
757
-
758
923
  return (
759
924
  <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}
925
+ {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)})
765
929
  </span>
766
- )}
767
- </div>
768
- <ResponsiveContainer width="100%" height={60}>
769
- <AreaChart data={chartData}>
930
+ <div className="flex items-center gap-3">
931
+ {showTrend && (
932
+ <span className={isDrifting ? "text-warning font-medium" : "text-muted-foreground"}>
933
+ Trend: {projectedChange >= 0 ? "↑ +" : "↓ "}{projectedChange.toFixed(1)}{unit}
934
+ </span>
935
+ )}
936
+ <span className="text-muted-foreground">
937
+ Avg: {avg.toFixed(1)}{unit}
938
+ </span>
939
+ </div>
940
+ </div>
941
+ ) : (
942
+ <div className="flex items-center justify-end text-xs px-1">
943
+ <span className="text-muted-foreground">
944
+ Avg: {avg.toFixed(1)}{unit}
945
+ </span>
946
+ </div>
947
+ )}
948
+ <ResponsiveContainer width="100%" height={120}>
949
+ <AreaChart
950
+ data={chartData}
951
+ margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
952
+ >
770
953
  <defs>
771
954
  <linearGradient
772
955
  id={`gradient-${field.name}`}
@@ -787,6 +970,41 @@ function LineChartRenderer({ field, context }: ChartRendererProps) {
787
970
  />
788
971
  </linearGradient>
789
972
  </defs>
973
+ <XAxis
974
+ dataKey="index"
975
+ type="number"
976
+ domain={["dataMin", "dataMax"]}
977
+ tickFormatter={(index: number) => {
978
+ const label = chartData[index]?.timeLabel;
979
+ return label ? label.split(" - ")[0] : "";
980
+ }}
981
+ stroke="hsl(var(--muted-foreground))"
982
+ fontSize={12}
983
+ minTickGap={30}
984
+ />
985
+ <YAxis
986
+ stroke="hsl(var(--muted-foreground))"
987
+ fontSize={12}
988
+ tickFormatter={(v: number) => `${v}${unit}`}
989
+ width={60}
990
+ />
991
+ {baseline && (
992
+ <ReferenceArea
993
+ y1={Math.max(
994
+ 0,
995
+ baseline.mean -
996
+ (baseline.stdDev / Math.sqrt(avgRunCount)) * 3,
997
+ )}
998
+ y2={
999
+ baseline.mean + (baseline.stdDev / Math.sqrt(avgRunCount)) * 3
1000
+ }
1001
+ fill="hsl(var(--warning))"
1002
+ fillOpacity={0.08}
1003
+ stroke="hsl(var(--warning))"
1004
+ strokeOpacity={0.4}
1005
+ strokeWidth={1}
1006
+ />
1007
+ )}
790
1008
  <Area
791
1009
  type="monotone"
792
1010
  dataKey="value"
@@ -8,7 +8,7 @@
8
8
  import type { ChartField } from "./schema-parser";
9
9
  import { extractChartFields, getFieldValue } from "./schema-parser";
10
10
  import { useStrategySchemas } from "./useStrategySchemas";
11
- import { Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
11
+ import { Badge, Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
12
12
  import { RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
13
13
 
14
14
  interface SingleRunChartGridProps {
@@ -62,7 +62,7 @@ export function SingleRunChartGrid({
62
62
  <div className="space-y-6">
63
63
  {/* Strategy-level fields */}
64
64
  {strategyFields.length > 0 && (
65
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
65
+ <div className="space-y-4">
66
66
  {strategyFields.map((field) => (
67
67
  <SingleValueCard
68
68
  key={field.name}
@@ -124,12 +124,13 @@ function CollectorSection({
124
124
 
125
125
  return (
126
126
  <div className="space-y-4">
127
- <div className="flex items-center gap-2">
128
- <h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
129
- {displayName}
130
- </h4>
131
- <span className="text-xs text-muted-foreground">
132
- ({instanceId.slice(0, 8)})
127
+ <div className="flex items-center gap-2 flex-wrap border-b pb-2">
128
+ <h3 className="text-lg font-semibold capitalize">{displayName}</h3>
129
+ <Badge variant="outline" className="font-mono">
130
+ {collectorId}
131
+ </Badge>
132
+ <span className="text-xs text-muted-foreground font-mono">
133
+ {instanceId.slice(0, 8)}
133
134
  </span>
134
135
  </div>
135
136
 
@@ -165,7 +166,7 @@ function CollectorSection({
165
166
  </Card>
166
167
  )}
167
168
 
168
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
169
+ <div className="space-y-4">
169
170
  {fields.map((field) => (
170
171
  <SingleValueCard
171
172
  key={field.name}
@@ -190,9 +191,11 @@ function SingleValueCard({ field, value }: SingleValueCardProps) {
190
191
  return (
191
192
  <Card>
192
193
  <CardHeader className="pb-2">
193
- <CardTitle className="text-sm font-medium">{field.label}</CardTitle>
194
+ <CardTitle className="text-sm font-medium text-center">
195
+ {field.label}
196
+ </CardTitle>
194
197
  </CardHeader>
195
- <CardContent>
198
+ <CardContent className="flex flex-col items-center text-center [&>*]:w-full">
196
199
  <SingleValueRenderer field={field} value={value} />
197
200
  </CardContent>
198
201
  </Card>
@@ -292,7 +295,7 @@ function GaugeRenderer({ value, unit }: { value: unknown; unit?: string }) {
292
295
  const data = [{ name: "value", value: clampedValue, fill: fillColor }];
293
296
 
294
297
  return (
295
- <div className="flex items-center gap-3">
298
+ <div className="flex items-center justify-center gap-3">
296
299
  <ResponsiveContainer width={80} height={80}>
297
300
  <RadialBarChart
298
301
  cx="50%"
@@ -330,7 +333,7 @@ function BooleanRenderer({ value }: { value: unknown }) {
330
333
  const boolValue = Boolean(value);
331
334
 
332
335
  return (
333
- <div className="flex items-center gap-2">
336
+ <div className="flex items-center justify-center gap-2">
334
337
  <div
335
338
  className={`w-3 h-3 rounded-full ${
336
339
  boolValue ? "bg-green-500" : "bg-red-500"
@@ -108,12 +108,9 @@ function extractFieldsFromProperties(
108
108
  if (!chartType) continue;
109
109
 
110
110
  // Use just field name - collectorId is stored separately for data lookup
111
+ // and surfaced via a separate badge in the group header
111
112
  const field = extractSingleField(fieldName, prop);
112
113
  field.collectorId = collectorId;
113
- // Prefix label with collector ID for clarity
114
- if (!prop["x-chart-label"]?.includes(collectorId)) {
115
- field.label = `${collectorId}: ${field.label}`;
116
- }
117
114
  fields.push(field);
118
115
  }
119
116
 
@@ -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
@@ -448,6 +449,20 @@ const AssignmentIDEPageContent = () => {
448
449
  />
449
450
  );
450
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
+ }
451
466
  }
452
467
  };
453
468
 
@@ -480,6 +495,7 @@ const AssignmentIDEPageContent = () => {
480
495
  <IDELayout
481
496
  tree={
482
497
  <AssignmentTree
498
+ systemId={systemId ?? ""}
483
499
  assigned={assignedConfigs}
484
500
  available={availableConfigs}
485
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
  // =============================================================================