@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 +56 -0
- package/package.json +8 -7
- package/src/auto-charts/AutoChartGrid.tsx +302 -84
- package/src/auto-charts/SingleRunChartGrid.tsx +16 -13
- package/src/auto-charts/schema-parser.ts +1 -4
- package/src/components/HealthCheckDrawer.tsx +8 -0
- package/src/components/HealthCheckLatencyChart.tsx +57 -15
- package/src/components/assignments/AssignmentTree.tsx +15 -5
- package/src/components/editor/EditorTree.tsx +19 -1
- package/src/index.tsx +8 -1
- package/src/pages/AssignmentIDEPage.tsx +17 -1
- package/src/pages/HealthCheckHistoryDetailPage.tsx +1 -1
- package/src/pages/HealthCheckIDEPage.tsx +40 -23
- package/src/slots.tsx +41 -0
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.
|
|
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/
|
|
16
|
-
"@checkstack/
|
|
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.
|
|
19
|
-
"@checkstack/frontend-api": "0.3.
|
|
20
|
-
"@checkstack/gitops-frontend": "0.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.
|
|
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="
|
|
89
|
+
<div className="space-y-4">
|
|
79
90
|
{strategyFields.map((field) => (
|
|
80
|
-
<AutoChartCard
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
//
|
|
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
|
-
<
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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">
|
|
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">
|
|
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
|
|
483
|
+
return (
|
|
484
|
+
<LineChartRenderer
|
|
485
|
+
field={field}
|
|
486
|
+
context={context}
|
|
487
|
+
baseline={baseline}
|
|
488
|
+
/>
|
|
489
|
+
);
|
|
421
490
|
}
|
|
422
491
|
case "gauge": {
|
|
423
|
-
return
|
|
492
|
+
return (
|
|
493
|
+
<GaugeRenderer field={field} context={context} baseline={baseline} />
|
|
494
|
+
);
|
|
424
495
|
}
|
|
425
496
|
case "counter": {
|
|
426
|
-
return
|
|
497
|
+
return (
|
|
498
|
+
<CounterRenderer field={field} context={context} baseline={baseline} />
|
|
499
|
+
);
|
|
427
500
|
}
|
|
428
501
|
case "bar": {
|
|
429
|
-
return
|
|
502
|
+
return (
|
|
503
|
+
<BarChartRenderer field={field} context={context} baseline={baseline} />
|
|
504
|
+
);
|
|
430
505
|
}
|
|
431
506
|
case "pie": {
|
|
432
|
-
return
|
|
507
|
+
return (
|
|
508
|
+
<PieChartRenderer field={field} context={context} baseline={baseline} />
|
|
509
|
+
);
|
|
433
510
|
}
|
|
434
511
|
case "boolean": {
|
|
435
|
-
return
|
|
512
|
+
return (
|
|
513
|
+
<BooleanRenderer field={field} context={context} baseline={baseline} />
|
|
514
|
+
);
|
|
436
515
|
}
|
|
437
516
|
case "text": {
|
|
438
|
-
return
|
|
517
|
+
return (
|
|
518
|
+
<TextRenderer field={field} context={context} baseline={baseline} />
|
|
519
|
+
);
|
|
439
520
|
}
|
|
440
521
|
case "status": {
|
|
441
|
-
return
|
|
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
|
|
523
|
-
<
|
|
524
|
-
<
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
{
|
|
543
|
-
|
|
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
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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="
|
|
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
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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="
|
|
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">
|
|
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
|
-
|
|
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
|
-
<
|
|
61
|
-
|
|
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
|
-
{
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
// =============================================================================
|