@checkstack/anomaly-frontend 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,50 @@
1
+ # @checkstack/anomaly-frontend
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8d1ef12: ## Anomaly Detection & UI Improvements
8
+
9
+ ### Anomaly Detection Enhancements (Phase 2)
10
+
11
+ - **`@checkstack/anomaly-backend`**: Implemented background baseline analyzer jobs and anomaly trend deviation detection mechanics.
12
+ - **`@checkstack/anomaly-common`**: Added new baseline statistical logic and inference rules.
13
+ - **`@checkstack/anomaly-frontend`**: Added new Anomaly Widget and refactored system detail rendering to be more human-readable.
14
+ - **`@checkstack/dashboard-frontend`**: Refined the global anomaly widget and fixed hardcoded access gating to render appropriately.
15
+ - **`@checkstack/healthcheck-backend`**: Connected executor telemetry to the anomaly pipeline.
16
+ - **`@checkstack/healthcheck-frontend`**: Reconciled baseline display consistency in Drawer and charts.
17
+
18
+ ### Notification Identifiers
19
+
20
+ - **`@checkstack/incident-backend`**: Resolved system IDs to human-readable System Names within Incident notifications to eliminate ID-only alert content.
21
+ - **`@checkstack/maintenance-backend`**: Adopted the same resolution strategy for Maintenance notifications to keep parity.
22
+
23
+ ### UI Experience
24
+
25
+ - **`@checkstack/incident-frontend`**: Fixed the "Back to X" BackLink to properly use `react-router` hook `useNavigate` instead of doing a full application reload.
26
+ - **`@checkstack/healthcheck-frontend`**: Implemented `useNavigate` for seamless SPA back-linking.
27
+ - **`@checkstack/integration-frontend`**: Updated connections and delivery logs links to navigate without hard reloads.
28
+
29
+ - 8d1ef12: Phase 2 of anomaly detection: trend drift detection.
30
+
31
+ The background baseline analyzer now computes a linear regression slope across each field's chronologically-ordered history and runs a `detectDrift` evaluator that catches gradual "creeping degradation" never reaching the 3σ spike threshold. Drifts share the same `anomalies` table as spike anomalies via a new `kind` column (`spike` | `drift`, default `spike`); the existing suspicious → anomaly → recovered lifecycle is reused, ticking at the analyzer's hourly cadence with a default 2-run confirmation window.
32
+
33
+ User-facing additions: a Trend Drift toggle and threshold slider on both the template and assignment anomaly settings panels (with per-field overrides), drift rows in the System Anomaly widget, dashed regression-line overlays on the auto-generated line charts, and a new `ANOMALY_TREND_DETECTED` signal for live UI updates. Plugin authors can disable drift per chartable field via `x-anomaly-drift-enabled: false` or tighten/loosen it via `x-anomaly-drift-threshold`.
34
+
35
+ - 8d1ef12: Added Categorical Anomaly Detection (Dominance Drift) support for non-numeric healthcheck values, and introduced Slider UI components for sensitivity and confirmation window anomaly settings.
36
+
37
+ ### Patch Changes
38
+
39
+ - Updated dependencies [8d1ef12]
40
+ - Updated dependencies [8d1ef12]
41
+ - Updated dependencies [8d1ef12]
42
+ - Updated dependencies [8d1ef12]
43
+ - @checkstack/healthcheck-common@0.12.0
44
+ - @checkstack/anomaly-common@0.2.0
45
+ - @checkstack/healthcheck-frontend@0.17.0
46
+ - @checkstack/common@0.7.0
47
+ - @checkstack/ui@1.6.0
48
+ - @checkstack/catalog-common@1.5.2
49
+ - @checkstack/frontend-api@0.3.11
50
+ - @checkstack/signal-frontend@0.0.16
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@checkstack/anomaly-frontend",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "src/index.tsx",
6
+ "checkstack": {
7
+ "type": "frontend"
8
+ },
9
+ "scripts": {
10
+ "typecheck": "tsc --noEmit",
11
+ "lint": "bun run lint:code",
12
+ "lint:code": "eslint . --max-warnings 0"
13
+ },
14
+ "dependencies": {
15
+ "@checkstack/anomaly-common": "0.1.0",
16
+ "@checkstack/catalog-common": "1.5.1",
17
+ "@checkstack/common": "0.6.5",
18
+ "@checkstack/frontend-api": "0.3.10",
19
+ "@checkstack/healthcheck-common": "0.11.0",
20
+ "@checkstack/healthcheck-frontend": "0.16.5",
21
+ "@checkstack/signal-frontend": "0.0.15",
22
+ "@checkstack/ui": "1.5.1",
23
+ "date-fns": "^4.1.0",
24
+ "lucide-react": "^0.344.0",
25
+ "react": "^18.2.0",
26
+ "react-router-dom": "^7.14.2",
27
+ "zod": "^4.2.1"
28
+ },
29
+ "devDependencies": {
30
+ "@checkstack/scripts": "0.1.2",
31
+ "@checkstack/tsconfig": "0.0.5",
32
+ "@types/react": "^18.2.0",
33
+ "typescript": "^5.0.0"
34
+ }
35
+ }
@@ -0,0 +1,154 @@
1
+ import { useState, useEffect } from "react";
2
+ import {
3
+ Card,
4
+ CardHeader,
5
+ CardTitle,
6
+ CardDescription,
7
+ CardContent,
8
+ CardFooter,
9
+ Button,
10
+ useToast,
11
+ } from "@checkstack/ui";
12
+ import { Activity, Save } from "lucide-react";
13
+ import type { AssignmentIDEContext } from "@checkstack/healthcheck-frontend";
14
+ import { usePluginClient } from "@checkstack/frontend-api";
15
+ import {
16
+ AnomalyApi,
17
+ type AnomalyFieldConfig,
18
+ } from "@checkstack/anomaly-common";
19
+ import { useAnomalyFields } from "./useAnomalyFields";
20
+ import {
21
+ AnomalySettingsForm,
22
+ type AnomalySettingsFormValues,
23
+ } from "./AnomalySettingsForm";
24
+
25
+ const DEFAULT_VALUES: AnomalySettingsFormValues = {
26
+ enabled: true,
27
+ sensitivity: 1,
28
+ confirmationWindow: 3,
29
+ driftEnabled: true,
30
+ driftThreshold: 2,
31
+ fieldOverrides: {},
32
+ };
33
+
34
+ export function AnomalyConfigPanel({ context }: { context: AssignmentIDEContext }) {
35
+ const toast = useToast();
36
+ const anomalyClient = usePluginClient(AnomalyApi);
37
+
38
+ const [values, setValues] = useState<AnomalySettingsFormValues>(DEFAULT_VALUES);
39
+
40
+ const { data: configRecord, isLoading: isLoadingConfig } =
41
+ anomalyClient.getAnomalyAssignmentConfig.useQuery(
42
+ { systemId: context.systemId, configurationId: context.configurationId },
43
+ { enabled: !!context.systemId && !!context.configurationId },
44
+ );
45
+
46
+ const { data: templateRecord, isLoading: isLoadingTemplate } =
47
+ anomalyClient.getAnomalyConfig.useQuery(
48
+ { configurationId: context.configurationId },
49
+ { enabled: !!context.configurationId },
50
+ );
51
+
52
+ const baseAvailableFields = useAnomalyFields(context.configurationId);
53
+
54
+ // Cascade template-level field overrides into the available-fields metadata so
55
+ // each row's "default" reflects the template, not the engine fallback.
56
+ const availableFields = baseAvailableFields.map((field) => {
57
+ const templateOverride = templateRecord?.data?.fieldOverrides?.[field.path];
58
+ return {
59
+ ...field,
60
+ defaultEnabled: templateOverride?.enabled ?? field.defaultEnabled,
61
+ defaultSensitivity: templateOverride?.sensitivity ?? field.defaultSensitivity,
62
+ defaultConfirmationWindow:
63
+ templateOverride?.confirmationWindow ?? field.defaultConfirmationWindow,
64
+ defaultDirection: templateOverride?.direction ?? field.defaultDirection,
65
+ defaultDriftEnabled:
66
+ templateOverride?.driftEnabled ?? field.defaultDriftEnabled,
67
+ defaultDriftThreshold:
68
+ templateOverride?.driftThreshold ?? field.defaultDriftThreshold,
69
+ };
70
+ });
71
+
72
+ useEffect(() => {
73
+ if (configRecord?.data) {
74
+ const tpl = templateRecord?.data;
75
+ setValues({
76
+ enabled: configRecord.data.enabled ?? true,
77
+ sensitivity: configRecord.data.sensitivity ?? tpl?.sensitivity ?? 1,
78
+ confirmationWindow:
79
+ configRecord.data.confirmationWindow ?? tpl?.confirmationWindow ?? 3,
80
+ driftEnabled:
81
+ configRecord.data.driftEnabled ?? tpl?.driftEnabled ?? true,
82
+ driftThreshold:
83
+ configRecord.data.driftThreshold ?? tpl?.driftThreshold ?? 2,
84
+ fieldOverrides:
85
+ (configRecord.data.fieldOverrides as Record<string, AnomalyFieldConfig>) ??
86
+ {},
87
+ });
88
+ }
89
+ }, [configRecord, templateRecord]);
90
+
91
+ const updateMutation = anomalyClient.updateAnomalyAssignmentConfig.useMutation({
92
+ onSuccess: () => toast.success("Assignment exceptions saved"),
93
+ onError: () => toast.error("Failed to save assignment exceptions"),
94
+ });
95
+
96
+ const handleChange = <K extends keyof AnomalySettingsFormValues>(
97
+ key: K,
98
+ value: AnomalySettingsFormValues[K],
99
+ ) => {
100
+ setValues((prev) => ({ ...prev, [key]: value }));
101
+ };
102
+
103
+ const handleSave = () => {
104
+ updateMutation.mutate({
105
+ systemId: context.systemId,
106
+ configurationId: context.configurationId,
107
+ config: values,
108
+ });
109
+ };
110
+
111
+ if (isLoadingConfig || isLoadingTemplate) {
112
+ return <div className="p-6 text-muted-foreground">Loading anomaly exceptions...</div>;
113
+ }
114
+
115
+ return (
116
+ <Card className="flex flex-col h-full border-0 rounded-none shadow-none">
117
+ <CardHeader className="pb-4">
118
+ <div className="flex items-center gap-2">
119
+ <Activity className="h-5 w-5 text-primary" />
120
+ <div>
121
+ <CardTitle className="text-lg">Assignment Exceptions</CardTitle>
122
+ <CardDescription>
123
+ Override the anomaly detection defaults for this specific system assignment.
124
+ </CardDescription>
125
+ </div>
126
+ </div>
127
+ </CardHeader>
128
+
129
+ <CardContent className="flex-1 space-y-6 overflow-y-auto">
130
+ <AnomalySettingsForm
131
+ values={values}
132
+ onChange={handleChange}
133
+ availableFields={availableFields}
134
+ isLocked={context.isLocked}
135
+ variant="assignment"
136
+ />
137
+ </CardContent>
138
+
139
+ <CardFooter className="justify-end border-t pt-4">
140
+ <Button
141
+ onClick={handleSave}
142
+ disabled={updateMutation.isPending || context.isLocked}
143
+ >
144
+ {updateMutation.isPending ? "Saving..." : (
145
+ <>
146
+ <Save className="mr-2 h-4 w-4" />
147
+ Save Exceptions
148
+ </>
149
+ )}
150
+ </Button>
151
+ </CardFooter>
152
+ </Card>
153
+ );
154
+ }
@@ -0,0 +1,362 @@
1
+ import React from "react";
2
+ import {
3
+ Label,
4
+ Toggle,
5
+ Badge,
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ Accordion,
12
+ AccordionItem,
13
+ AccordionTrigger,
14
+ AccordionContent,
15
+ Slider,
16
+ } from "@checkstack/ui";
17
+ import type {
18
+ AnomalyFieldConfig,
19
+ AnomalyDirection,
20
+ } from "@checkstack/anomaly-common";
21
+ import type { AnomalyFieldMeta } from "./useAnomalyFields";
22
+
23
+ interface AnomalyFieldOverridesEditorProps {
24
+ title: string;
25
+ description: string;
26
+ availableFields: AnomalyFieldMeta[];
27
+ fieldOverrides: Record<string, AnomalyFieldConfig>;
28
+ onChange: (
29
+ field: string,
30
+ key: keyof AnomalyFieldConfig,
31
+ value: number | boolean | AnomalyDirection | undefined,
32
+ ) => void;
33
+ parentEnabled: boolean;
34
+ isLocked?: boolean;
35
+ defaultSensitivity: number;
36
+ defaultConfirmationWindow: number;
37
+ defaultDriftEnabled?: boolean;
38
+ defaultDriftThreshold?: number;
39
+ }
40
+
41
+ export function AnomalyFieldOverridesEditor({
42
+ title,
43
+ description,
44
+ availableFields,
45
+ fieldOverrides,
46
+ onChange,
47
+ parentEnabled,
48
+ isLocked,
49
+ defaultSensitivity,
50
+ defaultConfirmationWindow,
51
+ defaultDriftEnabled = true,
52
+ defaultDriftThreshold = 2,
53
+ }: AnomalyFieldOverridesEditorProps) {
54
+ if (availableFields.length === 0) {
55
+ return <></>;
56
+ }
57
+
58
+ // A field is only considered "overridden" if its configured values differ from the parent defaults.
59
+ const isFieldOverridden = (fieldMeta: AnomalyFieldMeta) => {
60
+ const override = fieldOverrides[fieldMeta.path];
61
+ if (!override) return false;
62
+
63
+ if (override.enabled !== undefined && override.enabled !== fieldMeta.defaultEnabled)
64
+ return true;
65
+ if (
66
+ override.sensitivity !== undefined &&
67
+ override.sensitivity !== (fieldMeta.defaultSensitivity ?? defaultSensitivity)
68
+ )
69
+ return true;
70
+ if (
71
+ override.confirmationWindow !== undefined &&
72
+ override.confirmationWindow !== (fieldMeta.defaultConfirmationWindow ?? defaultConfirmationWindow)
73
+ )
74
+ return true;
75
+ if (override.direction !== undefined) return true;
76
+ if (
77
+ override.driftEnabled !== undefined &&
78
+ override.driftEnabled !== (fieldMeta.defaultDriftEnabled ?? defaultDriftEnabled)
79
+ )
80
+ return true;
81
+ if (
82
+ override.driftThreshold !== undefined &&
83
+ override.driftThreshold !== (fieldMeta.defaultDriftThreshold ?? defaultDriftThreshold)
84
+ )
85
+ return true;
86
+
87
+ return false;
88
+ };
89
+
90
+ // Auto-expand overridden fields
91
+ const overriddenFields = availableFields
92
+ .filter((fieldMeta) => isFieldOverridden(fieldMeta))
93
+ .map(meta => meta.path);
94
+
95
+ return (
96
+ <div className="mt-10 space-y-6">
97
+ <div>
98
+ <h3 className="text-lg font-medium tracking-tight">{title}</h3>
99
+ <p className="text-sm text-muted-foreground mt-1">{description}</p>
100
+ </div>
101
+
102
+ <Accordion
103
+ type="multiple"
104
+ defaultValue={overriddenFields}
105
+ className="w-full space-y-3"
106
+ >
107
+ {availableFields.map((fieldMeta) => {
108
+ const field = fieldMeta.path;
109
+ const override = fieldOverrides[field];
110
+ const isOverridden = isFieldOverridden(fieldMeta);
111
+ const fieldEnabled = override?.enabled ?? fieldMeta.defaultEnabled;
112
+
113
+ // Split the field path into namespace breadcrumbs and the actual property name
114
+ const parts = field.split(".");
115
+ const fieldName = parts.pop() || field;
116
+ const pathParts = parts;
117
+
118
+ const isCategorical =
119
+ override?.direction === "dominance" ||
120
+ fieldMeta.defaultDirection === "dominance" ||
121
+ ((!override?.direction) &&
122
+ (!fieldMeta.defaultDirection) &&
123
+ (fieldMeta.type === "string" || fieldMeta.type === "boolean"));
124
+
125
+ return (
126
+ <AccordionItem
127
+ key={field}
128
+ value={field}
129
+ className={`
130
+ rounded-xl border bg-card text-card-foreground shadow-sm transition-all duration-200 overflow-hidden
131
+ ${isOverridden ? "border-primary/40 shadow-md" : "border-border/40 opacity-80 hover:opacity-100"}
132
+ `}
133
+ >
134
+ <AccordionTrigger className="hover:no-underline px-5 py-4">
135
+ <div className="flex flex-1 items-center justify-between gap-4 mr-4">
136
+ <div className="space-y-1 text-left">
137
+ {pathParts.length > 0 && (
138
+ <div className="flex flex-wrap items-center gap-1 text-[10px] text-muted-foreground font-mono">
139
+ {pathParts.map((part, idx) => (
140
+ <React.Fragment key={idx}>
141
+ {idx > 0 && <span className="opacity-40">▶</span>}
142
+ <span>{part}</span>
143
+ </React.Fragment>
144
+ ))}
145
+ </div>
146
+ )}
147
+ <div
148
+ className={`text-sm font-semibold tracking-tight font-mono ${isOverridden ? "text-primary" : ""}`}
149
+ >
150
+ {fieldName}
151
+ </div>
152
+ </div>
153
+
154
+ <div className="flex items-center gap-3">
155
+ {isOverridden ? (
156
+ <Badge
157
+ variant={fieldEnabled ? "default" : "secondary"}
158
+ className="text-[10px] uppercase tracking-wider font-semibold"
159
+ >
160
+ {fieldEnabled ? "Custom Override" : "Ignored"}
161
+ </Badge>
162
+ ) : (
163
+ <Badge
164
+ variant="outline"
165
+ className="text-[10px] uppercase tracking-wider font-semibold opacity-50"
166
+ >
167
+ Default Settings
168
+ </Badge>
169
+ )}
170
+ </div>
171
+ </div>
172
+ </AccordionTrigger>
173
+
174
+ <AccordionContent className="px-5 pb-5 pt-2 border-t border-border/30">
175
+ <div className="space-y-6 mt-4">
176
+ <div className="flex items-center justify-between bg-muted/30 p-3 rounded-lg border">
177
+ <div className="space-y-0.5">
178
+ <Label className="text-sm font-medium">
179
+ Enable Custom Monitoring
180
+ </Label>
181
+ <div className="text-xs text-muted-foreground">
182
+ Toggle to ignore anomalies for this specific field.
183
+ </div>
184
+ </div>
185
+ <Toggle
186
+ checked={fieldEnabled}
187
+ onCheckedChange={(val) => onChange(field, "enabled", val)}
188
+ disabled={!parentEnabled || isLocked}
189
+ />
190
+ </div>
191
+
192
+ {fieldEnabled && (
193
+ <div className="grid gap-6 lg:grid-cols-3">
194
+ <div className="space-y-2 flex flex-col">
195
+ <Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
196
+ {isCategorical ? "Dominance Threshold" : "Sensitivity Multiplier"}
197
+ </Label>
198
+ <div className="pt-2 pb-1 px-1">
199
+ <Slider
200
+ value={[override?.sensitivity ?? fieldMeta.defaultSensitivity ?? defaultSensitivity]}
201
+ min={0.5}
202
+ max={3}
203
+ step={0.1}
204
+ onValueChange={(val) =>
205
+ onChange(
206
+ field,
207
+ "sensitivity",
208
+ val[0],
209
+ )
210
+ }
211
+ disabled={!parentEnabled || isLocked}
212
+ />
213
+ </div>
214
+ <div className="flex justify-between items-center text-[10px] font-mono text-muted-foreground pt-1">
215
+ <span>0.5 (More)</span>
216
+ <span className="font-semibold text-foreground bg-muted px-1.5 py-0.5 rounded">
217
+ {(override?.sensitivity ?? fieldMeta.defaultSensitivity ?? defaultSensitivity).toFixed(1)}x
218
+ </span>
219
+ <span>3.0 (Fewer)</span>
220
+ </div>
221
+ <p className="text-[10.5px] text-muted-foreground leading-relaxed pt-1">
222
+ {isCategorical
223
+ ? "Controls the strictness for categorical drift. A lower threshold requires absolute stability (e.g., 99%) before trusting a deviation, while a higher threshold allows drifting even if the baseline is noisy (e.g., 45%)."
224
+ : "Controls the strictness of the baseline boundaries. A higher multiplier widens the acceptable range, resulting in fewer alerts (lower sensitivity)."}
225
+ </p>
226
+ </div>
227
+ <div className="space-y-2 flex flex-col">
228
+ <Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
229
+ Confirmation Window
230
+ </Label>
231
+ <div className="pt-2 pb-1 px-1">
232
+ <Slider
233
+ value={[override?.confirmationWindow ?? fieldMeta.defaultConfirmationWindow ?? defaultConfirmationWindow]}
234
+ min={1}
235
+ max={10}
236
+ step={1}
237
+ onValueChange={(val) =>
238
+ onChange(
239
+ field,
240
+ "confirmationWindow",
241
+ val[0],
242
+ )
243
+ }
244
+ disabled={!parentEnabled || isLocked}
245
+ />
246
+ </div>
247
+ <div className="flex justify-between items-center text-[10px] font-mono text-muted-foreground pt-1">
248
+ <span>1 Run</span>
249
+ <span className="font-semibold text-foreground bg-muted px-1.5 py-0.5 rounded">
250
+ {override?.confirmationWindow ?? fieldMeta.defaultConfirmationWindow ?? defaultConfirmationWindow} Runs
251
+ </span>
252
+ <span>10 Runs</span>
253
+ </div>
254
+ <p className="text-[10.5px] text-muted-foreground leading-relaxed pt-1">
255
+ Consecutive anomalous data points required before an alert is officially raised, preventing alert fatigue from isolated network spikes.
256
+ </p>
257
+ </div>
258
+ <div className="space-y-2 flex flex-col">
259
+ <Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
260
+ Behavior
261
+ </Label>
262
+ <Select
263
+ value={override?.direction ?? "auto"}
264
+ onValueChange={(val) =>
265
+ onChange(
266
+ field,
267
+ "direction",
268
+ val === "auto"
269
+ ? undefined
270
+ : (val as AnomalyDirection),
271
+ )
272
+ }
273
+ disabled={!parentEnabled || isLocked}
274
+ >
275
+ <SelectTrigger className="h-10 bg-background/50 focus:bg-background transition-colors">
276
+ <SelectValue placeholder="Auto-inferred" />
277
+ </SelectTrigger>
278
+ <SelectContent>
279
+ <SelectItem value="auto">Auto-inferred</SelectItem>
280
+ <SelectItem value="deviation">
281
+ Any Deviation
282
+ </SelectItem>
283
+ <SelectItem value="higher-is-better">
284
+ Higher is Better
285
+ </SelectItem>
286
+ <SelectItem value="lower-is-better">
287
+ Lower is Better
288
+ </SelectItem>
289
+ <SelectItem value="dominance">
290
+ Dominance Drift
291
+ </SelectItem>
292
+ </SelectContent>
293
+ </Select>
294
+ <p className="text-[10.5px] text-muted-foreground leading-relaxed pt-1">
295
+ Override the auto-inferred behavior logic. "Higher is Better" ignores positive spikes, while "Lower is Better" ignores sudden drops.
296
+ </p>
297
+ </div>
298
+ </div>
299
+ )}
300
+
301
+ {fieldEnabled && !isCategorical && (
302
+ <div className="grid gap-6 lg:grid-cols-3 border-t border-border/30 pt-4">
303
+ <div className="space-y-2 flex flex-col">
304
+ <Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
305
+ Trend Drift
306
+ </Label>
307
+ <div className="flex items-center justify-between bg-muted/30 p-2 rounded-md border">
308
+ <span className="text-xs text-muted-foreground">
309
+ {(override?.driftEnabled ?? fieldMeta.defaultDriftEnabled ?? defaultDriftEnabled)
310
+ ? "Detecting drift"
311
+ : "Drift muted"}
312
+ </span>
313
+ <Toggle
314
+ checked={override?.driftEnabled ?? fieldMeta.defaultDriftEnabled ?? defaultDriftEnabled}
315
+ onCheckedChange={(val) => onChange(field, "driftEnabled", val)}
316
+ disabled={!parentEnabled || isLocked}
317
+ />
318
+ </div>
319
+ <p className="text-[10.5px] text-muted-foreground leading-relaxed pt-1">
320
+ Trend drift catches gradual degradation that never triggers a spike alert.
321
+ </p>
322
+ </div>
323
+ <div className="space-y-2 flex flex-col lg:col-span-2">
324
+ <Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
325
+ Drift Threshold (σ)
326
+ </Label>
327
+ <div className="pt-2 pb-1 px-1">
328
+ <Slider
329
+ value={[override?.driftThreshold ?? fieldMeta.defaultDriftThreshold ?? defaultDriftThreshold]}
330
+ min={1}
331
+ max={4}
332
+ step={0.1}
333
+ onValueChange={(val) => onChange(field, "driftThreshold", val[0])}
334
+ disabled={
335
+ !parentEnabled ||
336
+ isLocked ||
337
+ !(override?.driftEnabled ?? fieldMeta.defaultDriftEnabled ?? defaultDriftEnabled)
338
+ }
339
+ />
340
+ </div>
341
+ <div className="flex justify-between items-center text-[10px] font-mono text-muted-foreground pt-1">
342
+ <span>1.0σ (More)</span>
343
+ <span className="font-semibold text-foreground bg-muted px-1.5 py-0.5 rounded">
344
+ {(override?.driftThreshold ?? fieldMeta.defaultDriftThreshold ?? defaultDriftThreshold).toFixed(1)}σ
345
+ </span>
346
+ <span>4.0σ (Fewer)</span>
347
+ </div>
348
+ <p className="text-[10.5px] text-muted-foreground leading-relaxed pt-1">
349
+ Drift fires when |slope × n| exceeds this many standard deviations across the baseline window.
350
+ </p>
351
+ </div>
352
+ </div>
353
+ )}
354
+ </div>
355
+ </AccordionContent>
356
+ </AccordionItem>
357
+ );
358
+ })}
359
+ </Accordion>
360
+ </div>
361
+ );
362
+ }