@checkstack/anomaly-frontend 0.3.1 → 0.4.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 +73 -0
- package/package.json +13 -13
- package/src/components/AnomalyConfigPanel.tsx +5 -11
- package/src/components/AnomalyFieldOverridesEditor.tsx +881 -283
- package/src/components/AnomalySettingsForm.tsx +90 -120
- package/src/components/AnomalyTemplatePanel.tsx +5 -18
- package/src/components/useAnomalyFields.ts +6 -0
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ChevronDown,
|
|
4
|
+
RotateCcw,
|
|
5
|
+
TrendingUp,
|
|
6
|
+
TrendingDown,
|
|
7
|
+
ArrowUpDown,
|
|
8
|
+
Tag,
|
|
9
|
+
Wand2,
|
|
10
|
+
} from "lucide-react";
|
|
2
11
|
import {
|
|
3
12
|
Label,
|
|
4
13
|
Toggle,
|
|
@@ -13,6 +22,8 @@ import {
|
|
|
13
22
|
AccordionTrigger,
|
|
14
23
|
AccordionContent,
|
|
15
24
|
Slider,
|
|
25
|
+
Input,
|
|
26
|
+
Tooltip,
|
|
16
27
|
} from "@checkstack/ui";
|
|
17
28
|
import type {
|
|
18
29
|
AnomalyFieldConfig,
|
|
@@ -30,12 +41,139 @@ interface AnomalyFieldOverridesEditorProps {
|
|
|
30
41
|
key: keyof AnomalyFieldConfig,
|
|
31
42
|
value: number | boolean | AnomalyDirection | undefined,
|
|
32
43
|
) => void;
|
|
44
|
+
/**
|
|
45
|
+
* Applies a partial patch to a field's override atomically. Required for
|
|
46
|
+
* presets, which set multiple keys at once — sequential `onChange` calls
|
|
47
|
+
* race against stale parent state.
|
|
48
|
+
*/
|
|
49
|
+
onPatchField?: (field: string, patch: Partial<AnomalyFieldConfig>) => void;
|
|
50
|
+
/** Removes the field's entire override entry, restoring plugin defaults. */
|
|
51
|
+
onResetField?: (field: string) => void;
|
|
33
52
|
parentEnabled: boolean;
|
|
34
53
|
isLocked?: boolean;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Engine constants — used as the very last fallback when a field has neither
|
|
57
|
+
// a user override nor a schema-declared default. Mirror of the values in
|
|
58
|
+
// `core/anomaly-common/src/engine/config.ts`.
|
|
59
|
+
const ENGINE_DEFAULT_SENSITIVITY = 1;
|
|
60
|
+
const ENGINE_DEFAULT_CONFIRMATION_WINDOW = 3;
|
|
61
|
+
const ENGINE_DEFAULT_DRIFT_ENABLED = true;
|
|
62
|
+
const ENGINE_DEFAULT_DRIFT_THRESHOLD = 2;
|
|
63
|
+
|
|
64
|
+
type Preset = "strict" | "balanced" | "relaxed" | "custom";
|
|
65
|
+
|
|
66
|
+
interface PresetSpec {
|
|
67
|
+
sensitivity: number;
|
|
68
|
+
confirmationWindow: number;
|
|
69
|
+
/** Multiplier applied to the plugin's default absolute floor. */
|
|
70
|
+
absoluteFloorMultiplier: number;
|
|
71
|
+
/** Multiplier applied to the plugin's default relative floor. */
|
|
72
|
+
relativeFloorMultiplier: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const PRESETS: Record<Exclude<Preset, "custom">, PresetSpec> = {
|
|
76
|
+
strict: {
|
|
77
|
+
sensitivity: 0.7,
|
|
78
|
+
confirmationWindow: 2,
|
|
79
|
+
absoluteFloorMultiplier: 0,
|
|
80
|
+
relativeFloorMultiplier: 0,
|
|
81
|
+
},
|
|
82
|
+
balanced: {
|
|
83
|
+
sensitivity: 1,
|
|
84
|
+
confirmationWindow: 3,
|
|
85
|
+
absoluteFloorMultiplier: 1,
|
|
86
|
+
relativeFloorMultiplier: 1,
|
|
87
|
+
},
|
|
88
|
+
relaxed: {
|
|
89
|
+
sensitivity: 1.5,
|
|
90
|
+
confirmationWindow: 5,
|
|
91
|
+
absoluteFloorMultiplier: 2,
|
|
92
|
+
relativeFloorMultiplier: 2,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const PRESET_COPY: Record<
|
|
97
|
+
Exclude<Preset, "custom">,
|
|
98
|
+
{ label: string; description: string }
|
|
99
|
+
> = {
|
|
100
|
+
strict: {
|
|
101
|
+
label: "Strict",
|
|
102
|
+
description:
|
|
103
|
+
"Catches more deviations early. Best for critical metrics where false positives are tolerable.",
|
|
104
|
+
},
|
|
105
|
+
balanced: {
|
|
106
|
+
label: "Balanced",
|
|
107
|
+
description:
|
|
108
|
+
"Recommended. Uses the plugin's tuned defaults — alerts on sustained, meaningful deviations.",
|
|
109
|
+
},
|
|
110
|
+
relaxed: {
|
|
111
|
+
label: "Relaxed",
|
|
112
|
+
description:
|
|
113
|
+
"Reduces noise on bursty metrics. Only large, sustained changes will alert.",
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const APPROX_EQ = (a: number, b: number) => Math.abs(a - b) < 1e-6;
|
|
118
|
+
|
|
119
|
+
interface DirectionOption {
|
|
120
|
+
value: AnomalyDirection | "auto";
|
|
121
|
+
label: string;
|
|
122
|
+
description: string;
|
|
123
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const DIRECTION_OPTIONS: DirectionOption[] = [
|
|
127
|
+
{
|
|
128
|
+
value: "auto",
|
|
129
|
+
label: "Auto-detect",
|
|
130
|
+
description: "Use the plugin's recommendation",
|
|
131
|
+
icon: Wand2,
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
value: "lower-is-better",
|
|
135
|
+
label: "Lower is better",
|
|
136
|
+
description: "Alert when the value rises (e.g. latency, errors)",
|
|
137
|
+
icon: TrendingDown,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
value: "higher-is-better",
|
|
141
|
+
label: "Higher is better",
|
|
142
|
+
description: "Alert when the value drops (e.g. success rate, uptime)",
|
|
143
|
+
icon: TrendingUp,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
value: "deviation",
|
|
147
|
+
label: "Either direction",
|
|
148
|
+
description: "Alert on any unusual move (e.g. traffic, player count)",
|
|
149
|
+
icon: ArrowUpDown,
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
value: "dominance",
|
|
153
|
+
label: "Categorical flip",
|
|
154
|
+
description: "Alert when a stable value (e.g. status) changes",
|
|
155
|
+
icon: Tag,
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
function describeBehavior(direction: AnomalyDirection | undefined): string {
|
|
160
|
+
switch (direction) {
|
|
161
|
+
case "higher-is-better": {
|
|
162
|
+
return "drops below";
|
|
163
|
+
}
|
|
164
|
+
case "lower-is-better": {
|
|
165
|
+
return "rises above";
|
|
166
|
+
}
|
|
167
|
+
case "deviation": {
|
|
168
|
+
return "moves away from";
|
|
169
|
+
}
|
|
170
|
+
case "dominance": {
|
|
171
|
+
return "flips from";
|
|
172
|
+
}
|
|
173
|
+
default: {
|
|
174
|
+
return "deviates from";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
39
177
|
}
|
|
40
178
|
|
|
41
179
|
export function AnomalyFieldOverridesEditor({
|
|
@@ -44,59 +182,124 @@ export function AnomalyFieldOverridesEditor({
|
|
|
44
182
|
availableFields,
|
|
45
183
|
fieldOverrides,
|
|
46
184
|
onChange,
|
|
185
|
+
onPatchField,
|
|
186
|
+
onResetField,
|
|
47
187
|
parentEnabled,
|
|
48
188
|
isLocked,
|
|
49
|
-
defaultSensitivity,
|
|
50
|
-
defaultConfirmationWindow,
|
|
51
|
-
defaultDriftEnabled = true,
|
|
52
|
-
defaultDriftThreshold = 2,
|
|
53
189
|
}: AnomalyFieldOverridesEditorProps) {
|
|
54
190
|
if (availableFields.length === 0) {
|
|
55
191
|
return <></>;
|
|
56
192
|
}
|
|
57
193
|
|
|
58
|
-
//
|
|
194
|
+
// Compute the "effective" config for a field (override > schema > engine default).
|
|
195
|
+
const getEffective = (fieldMeta: AnomalyFieldMeta) => {
|
|
196
|
+
const override = fieldOverrides[fieldMeta.path];
|
|
197
|
+
return {
|
|
198
|
+
sensitivity:
|
|
199
|
+
override?.sensitivity ??
|
|
200
|
+
fieldMeta.defaultSensitivity ??
|
|
201
|
+
ENGINE_DEFAULT_SENSITIVITY,
|
|
202
|
+
confirmationWindow:
|
|
203
|
+
override?.confirmationWindow ??
|
|
204
|
+
fieldMeta.defaultConfirmationWindow ??
|
|
205
|
+
ENGINE_DEFAULT_CONFIRMATION_WINDOW,
|
|
206
|
+
direction: override?.direction ?? fieldMeta.defaultDirection,
|
|
207
|
+
driftEnabled:
|
|
208
|
+
override?.driftEnabled ??
|
|
209
|
+
fieldMeta.defaultDriftEnabled ??
|
|
210
|
+
ENGINE_DEFAULT_DRIFT_ENABLED,
|
|
211
|
+
driftThreshold:
|
|
212
|
+
override?.driftThreshold ??
|
|
213
|
+
fieldMeta.defaultDriftThreshold ??
|
|
214
|
+
ENGINE_DEFAULT_DRIFT_THRESHOLD,
|
|
215
|
+
minAbsoluteDelta:
|
|
216
|
+
override?.minAbsoluteDelta ?? fieldMeta.defaultMinAbsoluteDelta ?? 0,
|
|
217
|
+
minRelativeDelta:
|
|
218
|
+
override?.minRelativeDelta ?? fieldMeta.defaultMinRelativeDelta ?? 0,
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const detectPreset = (fieldMeta: AnomalyFieldMeta): Preset => {
|
|
223
|
+
const eff = getEffective(fieldMeta);
|
|
224
|
+
const pluginAbs = fieldMeta.defaultMinAbsoluteDelta ?? 0;
|
|
225
|
+
const pluginRel = fieldMeta.defaultMinRelativeDelta ?? 0;
|
|
226
|
+
|
|
227
|
+
for (const [name, spec] of Object.entries(PRESETS) as [
|
|
228
|
+
Exclude<Preset, "custom">,
|
|
229
|
+
PresetSpec,
|
|
230
|
+
][]) {
|
|
231
|
+
if (
|
|
232
|
+
APPROX_EQ(eff.sensitivity, spec.sensitivity) &&
|
|
233
|
+
eff.confirmationWindow === spec.confirmationWindow &&
|
|
234
|
+
APPROX_EQ(
|
|
235
|
+
eff.minAbsoluteDelta,
|
|
236
|
+
pluginAbs * spec.absoluteFloorMultiplier,
|
|
237
|
+
) &&
|
|
238
|
+
APPROX_EQ(
|
|
239
|
+
eff.minRelativeDelta,
|
|
240
|
+
pluginRel * spec.relativeFloorMultiplier,
|
|
241
|
+
)
|
|
242
|
+
) {
|
|
243
|
+
return name;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return "custom";
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const applyPreset = (
|
|
250
|
+
fieldMeta: AnomalyFieldMeta,
|
|
251
|
+
preset: Exclude<Preset, "custom">,
|
|
252
|
+
) => {
|
|
253
|
+
const spec = PRESETS[preset];
|
|
254
|
+
const pluginAbs = fieldMeta.defaultMinAbsoluteDelta ?? 0;
|
|
255
|
+
const pluginRel = fieldMeta.defaultMinRelativeDelta ?? 0;
|
|
256
|
+
const patch: Partial<AnomalyFieldConfig> = {
|
|
257
|
+
sensitivity: spec.sensitivity,
|
|
258
|
+
confirmationWindow: spec.confirmationWindow,
|
|
259
|
+
minAbsoluteDelta: pluginAbs * spec.absoluteFloorMultiplier,
|
|
260
|
+
minRelativeDelta: pluginRel * spec.relativeFloorMultiplier,
|
|
261
|
+
};
|
|
262
|
+
if (onPatchField) {
|
|
263
|
+
onPatchField(fieldMeta.path, patch);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
// Fallback for callers that didn't wire the atomic patch path.
|
|
267
|
+
// This is racy across multiple keys but keeps the editor functional
|
|
268
|
+
// in tests / minimal setups.
|
|
269
|
+
for (const [key, value] of Object.entries(patch) as [
|
|
270
|
+
keyof AnomalyFieldConfig,
|
|
271
|
+
number,
|
|
272
|
+
][]) {
|
|
273
|
+
onChange(fieldMeta.path, key, value);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// A field is overridden if any user-set value differs from the plugin/parent default.
|
|
59
278
|
const isFieldOverridden = (fieldMeta: AnomalyFieldMeta) => {
|
|
60
279
|
const override = fieldOverrides[fieldMeta.path];
|
|
61
280
|
if (!override) return false;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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;
|
|
281
|
+
const keys: (keyof AnomalyFieldConfig)[] = [
|
|
282
|
+
"enabled",
|
|
283
|
+
"sensitivity",
|
|
284
|
+
"confirmationWindow",
|
|
285
|
+
"direction",
|
|
286
|
+
"driftEnabled",
|
|
287
|
+
"driftThreshold",
|
|
288
|
+
"minAbsoluteDelta",
|
|
289
|
+
"minRelativeDelta",
|
|
290
|
+
];
|
|
291
|
+
return keys.some((k) => override[k] !== undefined);
|
|
88
292
|
};
|
|
89
293
|
|
|
90
|
-
// Auto-expand overridden fields
|
|
91
294
|
const overriddenFields = availableFields
|
|
92
295
|
.filter((fieldMeta) => isFieldOverridden(fieldMeta))
|
|
93
|
-
.map(meta => meta.path);
|
|
296
|
+
.map((meta) => meta.path);
|
|
94
297
|
|
|
95
298
|
return (
|
|
96
299
|
<div className="mt-10 space-y-6">
|
|
97
300
|
<div>
|
|
98
301
|
<h3 className="text-lg font-medium tracking-tight">{title}</h3>
|
|
99
|
-
<p className="text-sm text-muted-foreground
|
|
302
|
+
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
100
303
|
</div>
|
|
101
304
|
|
|
102
305
|
<Accordion
|
|
@@ -104,259 +307,654 @@ export function AnomalyFieldOverridesEditor({
|
|
|
104
307
|
defaultValue={overriddenFields}
|
|
105
308
|
className="w-full space-y-3"
|
|
106
309
|
>
|
|
107
|
-
{availableFields.map((fieldMeta) =>
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
310
|
+
{availableFields.map((fieldMeta) => (
|
|
311
|
+
<FieldAccordionItem
|
|
312
|
+
key={fieldMeta.path}
|
|
313
|
+
fieldMeta={fieldMeta}
|
|
314
|
+
override={fieldOverrides[fieldMeta.path]}
|
|
315
|
+
isOverridden={isFieldOverridden(fieldMeta)}
|
|
316
|
+
preset={detectPreset(fieldMeta)}
|
|
317
|
+
effective={getEffective(fieldMeta)}
|
|
318
|
+
parentEnabled={parentEnabled}
|
|
319
|
+
isLocked={isLocked}
|
|
320
|
+
onChange={onChange}
|
|
321
|
+
onResetField={onResetField}
|
|
322
|
+
applyPreset={(preset) => applyPreset(fieldMeta, preset)}
|
|
323
|
+
/>
|
|
324
|
+
))}
|
|
325
|
+
</Accordion>
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
interface EffectiveFieldConfig {
|
|
331
|
+
sensitivity: number;
|
|
332
|
+
confirmationWindow: number;
|
|
333
|
+
direction: AnomalyDirection | undefined;
|
|
334
|
+
driftEnabled: boolean;
|
|
335
|
+
driftThreshold: number;
|
|
336
|
+
minAbsoluteDelta: number;
|
|
337
|
+
minRelativeDelta: number;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
interface FieldAccordionItemProps {
|
|
341
|
+
fieldMeta: AnomalyFieldMeta;
|
|
342
|
+
override: AnomalyFieldConfig | undefined;
|
|
343
|
+
isOverridden: boolean;
|
|
344
|
+
preset: Preset;
|
|
345
|
+
effective: EffectiveFieldConfig;
|
|
346
|
+
parentEnabled: boolean;
|
|
347
|
+
isLocked?: boolean;
|
|
348
|
+
onChange: AnomalyFieldOverridesEditorProps["onChange"];
|
|
349
|
+
onResetField?: AnomalyFieldOverridesEditorProps["onResetField"];
|
|
350
|
+
applyPreset: (preset: Exclude<Preset, "custom">) => void;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function FieldAccordionItem({
|
|
354
|
+
fieldMeta,
|
|
355
|
+
override,
|
|
356
|
+
isOverridden,
|
|
357
|
+
preset,
|
|
358
|
+
effective,
|
|
359
|
+
parentEnabled,
|
|
360
|
+
isLocked,
|
|
361
|
+
onChange,
|
|
362
|
+
onResetField,
|
|
363
|
+
applyPreset,
|
|
364
|
+
}: FieldAccordionItemProps) {
|
|
365
|
+
const [advancedOpen, setAdvancedOpen] = useState(preset === "custom");
|
|
366
|
+
// Tracks an explicit "Custom" click — needed because clicking Custom doesn't
|
|
367
|
+
// change values, so `detectPreset` still resolves to whatever the prior
|
|
368
|
+
// preset was. Cleared when the user picks a real preset.
|
|
369
|
+
const [manualCustom, setManualCustom] = useState(false);
|
|
370
|
+
const displayedPreset: Preset = manualCustom ? "custom" : preset;
|
|
371
|
+
const field = fieldMeta.path;
|
|
372
|
+
const fieldEnabled = override?.enabled ?? fieldMeta.defaultEnabled;
|
|
373
|
+
|
|
374
|
+
const parts = field.split(".");
|
|
375
|
+
const fieldName = parts.pop() || field;
|
|
376
|
+
const pathParts = parts;
|
|
377
|
+
|
|
378
|
+
const isCategorical =
|
|
379
|
+
effective.direction === "dominance" ||
|
|
380
|
+
(!effective.direction &&
|
|
381
|
+
(fieldMeta.type === "string" || fieldMeta.type === "boolean"));
|
|
382
|
+
|
|
383
|
+
const summary = buildSummary({
|
|
384
|
+
enabled: fieldEnabled,
|
|
385
|
+
isCategorical,
|
|
386
|
+
direction: effective.direction,
|
|
387
|
+
confirmationWindow: effective.confirmationWindow,
|
|
388
|
+
minAbsoluteDelta: effective.minAbsoluteDelta,
|
|
389
|
+
minRelativeDelta: effective.minRelativeDelta,
|
|
390
|
+
unit: fieldMeta.unit,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const handleResetClick = (e: React.MouseEvent) => {
|
|
394
|
+
e.stopPropagation();
|
|
395
|
+
if (onResetField) {
|
|
396
|
+
onResetField(field);
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
return (
|
|
401
|
+
<AccordionItem
|
|
402
|
+
value={field}
|
|
403
|
+
className={`
|
|
404
|
+
rounded-xl border bg-card text-card-foreground shadow-sm transition-all duration-200 overflow-hidden
|
|
405
|
+
${isOverridden ? "border-primary/40 shadow-md" : "border-border/40 opacity-80 hover:opacity-100"}
|
|
406
|
+
`}
|
|
407
|
+
>
|
|
408
|
+
<AccordionTrigger className="px-5 py-4 hover:no-underline">
|
|
409
|
+
<div className="flex items-center justify-between flex-1 gap-4 mr-4">
|
|
410
|
+
<div className="space-y-1 text-left">
|
|
411
|
+
{pathParts.length > 0 && (
|
|
412
|
+
<div className="flex flex-wrap items-center gap-1 text-[10px] text-muted-foreground font-mono">
|
|
413
|
+
{pathParts.map((part, idx) => (
|
|
414
|
+
<React.Fragment key={idx}>
|
|
415
|
+
{idx > 0 && <span className="opacity-40">▶</span>}
|
|
416
|
+
<span>{part}</span>
|
|
417
|
+
</React.Fragment>
|
|
418
|
+
))}
|
|
419
|
+
</div>
|
|
420
|
+
)}
|
|
421
|
+
<div
|
|
422
|
+
className={`text-sm font-semibold tracking-tight font-mono ${isOverridden ? "text-primary" : ""}`}
|
|
423
|
+
>
|
|
424
|
+
{fieldName}
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
|
|
428
|
+
<div className="flex items-center gap-3">
|
|
429
|
+
{isOverridden && onResetField && !isLocked && (
|
|
430
|
+
<button
|
|
431
|
+
type="button"
|
|
432
|
+
onClick={handleResetClick}
|
|
433
|
+
className="inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
|
|
434
|
+
title="Reset this field to plugin defaults"
|
|
435
|
+
>
|
|
436
|
+
<RotateCcw className="w-3 h-3" />
|
|
437
|
+
Reset
|
|
438
|
+
</button>
|
|
439
|
+
)}
|
|
440
|
+
{isOverridden ? (
|
|
441
|
+
<Badge
|
|
442
|
+
variant={fieldEnabled ? "default" : "secondary"}
|
|
443
|
+
className="text-[10px] uppercase tracking-wider font-semibold"
|
|
444
|
+
>
|
|
445
|
+
{fieldEnabled ? "Custom" : "Ignored"}
|
|
446
|
+
</Badge>
|
|
447
|
+
) : (
|
|
448
|
+
<Badge
|
|
449
|
+
variant="outline"
|
|
450
|
+
className="text-[10px] uppercase tracking-wider font-semibold opacity-50"
|
|
451
|
+
>
|
|
452
|
+
Default
|
|
453
|
+
</Badge>
|
|
454
|
+
)}
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
</AccordionTrigger>
|
|
458
|
+
|
|
459
|
+
<AccordionContent className="px-5 pt-2 pb-5 border-t border-border/30">
|
|
460
|
+
<div className="mt-4 space-y-5">
|
|
461
|
+
{/* Plain-language summary of effective config */}
|
|
462
|
+
<p className="text-xs leading-relaxed text-muted-foreground bg-muted/30 border border-border/40 rounded-md px-3 py-2">
|
|
463
|
+
{summary}
|
|
464
|
+
</p>
|
|
465
|
+
|
|
466
|
+
{/* Enable/disable */}
|
|
467
|
+
<div className="flex items-center justify-between p-3 border rounded-lg bg-muted/30">
|
|
468
|
+
<div className="space-y-0.5">
|
|
469
|
+
<Label className="text-sm font-medium">
|
|
470
|
+
Monitor this field
|
|
471
|
+
</Label>
|
|
472
|
+
<div className="text-xs text-muted-foreground">
|
|
473
|
+
Turn off to ignore anomalies for this metric.
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
<Toggle
|
|
477
|
+
checked={fieldEnabled}
|
|
478
|
+
onCheckedChange={(val) => onChange(field, "enabled", val)}
|
|
479
|
+
disabled={!parentEnabled || isLocked}
|
|
480
|
+
/>
|
|
481
|
+
</div>
|
|
482
|
+
|
|
483
|
+
{fieldEnabled && (
|
|
484
|
+
<>
|
|
485
|
+
{/* Layer 1: Sensitivity preset */}
|
|
486
|
+
{!isCategorical && (
|
|
487
|
+
<PresetSelector
|
|
488
|
+
value={displayedPreset}
|
|
489
|
+
onChange={(p) => {
|
|
490
|
+
if (p === "custom") {
|
|
491
|
+
setManualCustom(true);
|
|
492
|
+
setAdvancedOpen(true);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
setManualCustom(false);
|
|
496
|
+
applyPreset(p);
|
|
497
|
+
}}
|
|
498
|
+
disabled={!parentEnabled || isLocked}
|
|
499
|
+
/>
|
|
500
|
+
)}
|
|
501
|
+
|
|
502
|
+
{/* Layer 2: Behavior */}
|
|
503
|
+
<BehaviorSelector
|
|
504
|
+
value={override?.direction ?? "auto"}
|
|
505
|
+
pluginDefault={fieldMeta.defaultDirection}
|
|
506
|
+
onChange={(val) =>
|
|
507
|
+
onChange(
|
|
508
|
+
field,
|
|
509
|
+
"direction",
|
|
510
|
+
val === "auto" ? undefined : val,
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
disabled={!parentEnabled || isLocked}
|
|
514
|
+
/>
|
|
515
|
+
|
|
516
|
+
{/* Layer 3: Advanced (collapsed by default unless preset is "custom") */}
|
|
517
|
+
<AdvancedDisclosure
|
|
518
|
+
open={advancedOpen}
|
|
519
|
+
onToggle={() => setAdvancedOpen((v) => !v)}
|
|
520
|
+
>
|
|
521
|
+
<AdvancedControls
|
|
522
|
+
field={field}
|
|
523
|
+
fieldMeta={fieldMeta}
|
|
524
|
+
override={override}
|
|
525
|
+
effective={effective}
|
|
526
|
+
isCategorical={isCategorical}
|
|
527
|
+
parentEnabled={parentEnabled}
|
|
528
|
+
isLocked={isLocked}
|
|
529
|
+
onChange={onChange}
|
|
530
|
+
/>
|
|
531
|
+
</AdvancedDisclosure>
|
|
532
|
+
</>
|
|
533
|
+
)}
|
|
534
|
+
</div>
|
|
535
|
+
</AccordionContent>
|
|
536
|
+
</AccordionItem>
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
interface PresetSelectorProps {
|
|
541
|
+
value: Preset;
|
|
542
|
+
onChange: (preset: Preset) => void;
|
|
543
|
+
disabled?: boolean;
|
|
544
|
+
}
|
|
124
545
|
|
|
546
|
+
function PresetSelector({ value, onChange, disabled }: PresetSelectorProps) {
|
|
547
|
+
const options: Preset[] = ["strict", "balanced", "relaxed", "custom"];
|
|
548
|
+
const description =
|
|
549
|
+
value === "custom"
|
|
550
|
+
? "Custom — fine-tuned values that don't match a preset. Edit them in the advanced section below."
|
|
551
|
+
: PRESET_COPY[value].description;
|
|
552
|
+
|
|
553
|
+
return (
|
|
554
|
+
<div className="space-y-2">
|
|
555
|
+
<div className="flex items-center gap-2">
|
|
556
|
+
<Label className="text-xs font-semibold tracking-wider uppercase text-muted-foreground">
|
|
557
|
+
Sensitivity preset
|
|
558
|
+
</Label>
|
|
559
|
+
<Tooltip content="Presets bundle sensitivity, confirmation window, and noise floors into one choice. Pick Custom to fine-tune each value." />
|
|
560
|
+
</div>
|
|
561
|
+
<div className="inline-flex items-stretch p-1 border rounded-lg bg-muted/30 gap-0.5">
|
|
562
|
+
{options.map((opt) => {
|
|
563
|
+
const selected = value === opt;
|
|
125
564
|
return (
|
|
126
|
-
<
|
|
127
|
-
key={
|
|
128
|
-
|
|
565
|
+
<button
|
|
566
|
+
key={opt}
|
|
567
|
+
type="button"
|
|
568
|
+
onClick={() => onChange(opt)}
|
|
569
|
+
disabled={disabled}
|
|
129
570
|
className={`
|
|
130
|
-
|
|
131
|
-
${
|
|
571
|
+
px-4 py-1.5 text-xs font-semibold rounded-md transition-all
|
|
572
|
+
${
|
|
573
|
+
selected
|
|
574
|
+
? "bg-background text-foreground shadow-sm"
|
|
575
|
+
: "text-muted-foreground hover:text-foreground"
|
|
576
|
+
}
|
|
577
|
+
disabled:opacity-50 disabled:cursor-not-allowed
|
|
578
|
+
capitalize
|
|
132
579
|
`}
|
|
133
580
|
>
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
581
|
+
{opt}
|
|
582
|
+
</button>
|
|
583
|
+
);
|
|
584
|
+
})}
|
|
585
|
+
</div>
|
|
586
|
+
<p className="text-[10.5px] text-muted-foreground leading-relaxed pt-1">
|
|
587
|
+
{description}
|
|
588
|
+
</p>
|
|
589
|
+
</div>
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
interface BehaviorSelectorProps {
|
|
594
|
+
value: AnomalyDirection | "auto";
|
|
595
|
+
pluginDefault?: AnomalyDirection;
|
|
596
|
+
onChange: (value: AnomalyDirection | "auto") => void;
|
|
597
|
+
disabled?: boolean;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function BehaviorSelector({
|
|
601
|
+
value,
|
|
602
|
+
pluginDefault,
|
|
603
|
+
onChange,
|
|
604
|
+
disabled,
|
|
605
|
+
}: BehaviorSelectorProps) {
|
|
606
|
+
const selectedOption =
|
|
607
|
+
DIRECTION_OPTIONS.find((o) => o.value === value) ?? DIRECTION_OPTIONS[0];
|
|
608
|
+
const Icon = selectedOption.icon;
|
|
153
609
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
610
|
+
return (
|
|
611
|
+
<div className="space-y-2">
|
|
612
|
+
<div className="flex items-center gap-2">
|
|
613
|
+
<Label className="text-xs font-semibold tracking-wider uppercase text-muted-foreground">
|
|
614
|
+
Behavior
|
|
615
|
+
</Label>
|
|
616
|
+
<Tooltip content="Tells the detector which direction is bad. Auto-detect uses the plugin's recommendation." />
|
|
617
|
+
</div>
|
|
618
|
+
<Select
|
|
619
|
+
value={value}
|
|
620
|
+
onValueChange={(val) => onChange(val as AnomalyDirection | "auto")}
|
|
621
|
+
disabled={disabled}
|
|
622
|
+
>
|
|
623
|
+
<SelectTrigger className="h-10 transition-colors bg-background/50 focus:bg-background">
|
|
624
|
+
<SelectValue placeholder="Auto-detect">
|
|
625
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
626
|
+
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
|
|
627
|
+
<span className="truncate">{selectedOption.label}</span>
|
|
628
|
+
{value === "auto" && pluginDefault && (
|
|
629
|
+
<span className="text-[10px] text-muted-foreground italic ml-1 truncate">
|
|
630
|
+
(plugin: {DIRECTION_OPTIONS.find((o) => o.value === pluginDefault)?.label})
|
|
631
|
+
</span>
|
|
632
|
+
)}
|
|
633
|
+
</div>
|
|
634
|
+
</SelectValue>
|
|
635
|
+
</SelectTrigger>
|
|
636
|
+
<SelectContent>
|
|
637
|
+
{DIRECTION_OPTIONS.map((opt) => {
|
|
638
|
+
const OptIcon = opt.icon;
|
|
639
|
+
return (
|
|
640
|
+
<SelectItem key={opt.value} value={opt.value}>
|
|
641
|
+
<div className="flex items-start gap-2 py-0.5">
|
|
642
|
+
<OptIcon className="w-4 h-4 mt-0.5 text-muted-foreground shrink-0" />
|
|
643
|
+
<div className="flex flex-col">
|
|
644
|
+
<span className="text-sm">{opt.label}</span>
|
|
645
|
+
<span className="text-[10.5px] text-muted-foreground leading-snug">
|
|
646
|
+
{opt.description}
|
|
647
|
+
</span>
|
|
170
648
|
</div>
|
|
171
649
|
</div>
|
|
172
|
-
</
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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>
|
|
650
|
+
</SelectItem>
|
|
651
|
+
);
|
|
652
|
+
})}
|
|
653
|
+
</SelectContent>
|
|
654
|
+
</Select>
|
|
655
|
+
</div>
|
|
656
|
+
);
|
|
657
|
+
}
|
|
191
658
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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>
|
|
659
|
+
interface AdvancedDisclosureProps {
|
|
660
|
+
open: boolean;
|
|
661
|
+
onToggle: () => void;
|
|
662
|
+
children: React.ReactNode;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function AdvancedDisclosure({
|
|
666
|
+
open,
|
|
667
|
+
onToggle,
|
|
668
|
+
children,
|
|
669
|
+
}: AdvancedDisclosureProps) {
|
|
670
|
+
return (
|
|
671
|
+
<div className="border border-border/40 rounded-md bg-background/30">
|
|
672
|
+
<button
|
|
673
|
+
type="button"
|
|
674
|
+
onClick={onToggle}
|
|
675
|
+
className="flex items-center justify-between w-full px-3 py-2 text-xs font-semibold tracking-wider uppercase text-muted-foreground hover:text-foreground transition-colors"
|
|
676
|
+
>
|
|
677
|
+
<span>Advanced</span>
|
|
678
|
+
<ChevronDown
|
|
679
|
+
className={`w-4 h-4 transition-transform ${open ? "rotate-180" : ""}`}
|
|
680
|
+
/>
|
|
681
|
+
</button>
|
|
682
|
+
{open && (
|
|
683
|
+
<div className="px-4 pt-1 pb-4 border-t border-border/30 space-y-5">
|
|
684
|
+
{children}
|
|
685
|
+
</div>
|
|
686
|
+
)}
|
|
360
687
|
</div>
|
|
361
688
|
);
|
|
362
689
|
}
|
|
690
|
+
|
|
691
|
+
interface AdvancedControlsProps {
|
|
692
|
+
field: string;
|
|
693
|
+
fieldMeta: AnomalyFieldMeta;
|
|
694
|
+
override: AnomalyFieldConfig | undefined;
|
|
695
|
+
effective: EffectiveFieldConfig;
|
|
696
|
+
isCategorical: boolean;
|
|
697
|
+
parentEnabled: boolean;
|
|
698
|
+
isLocked?: boolean;
|
|
699
|
+
onChange: AnomalyFieldOverridesEditorProps["onChange"];
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function AdvancedControls({
|
|
703
|
+
field,
|
|
704
|
+
fieldMeta,
|
|
705
|
+
override,
|
|
706
|
+
effective,
|
|
707
|
+
isCategorical,
|
|
708
|
+
parentEnabled,
|
|
709
|
+
isLocked,
|
|
710
|
+
onChange,
|
|
711
|
+
}: AdvancedControlsProps) {
|
|
712
|
+
const disabled = !parentEnabled || isLocked;
|
|
713
|
+
|
|
714
|
+
return (
|
|
715
|
+
<>
|
|
716
|
+
<div className="grid gap-5 lg:grid-cols-2 pt-4">
|
|
717
|
+
<div className="flex flex-col space-y-2">
|
|
718
|
+
<div className="flex items-center justify-between">
|
|
719
|
+
<Label className="text-xs font-semibold tracking-wider uppercase text-muted-foreground">
|
|
720
|
+
{isCategorical ? "Stability threshold" : "Sensitivity"}
|
|
721
|
+
</Label>
|
|
722
|
+
<Tooltip
|
|
723
|
+
content={
|
|
724
|
+
isCategorical
|
|
725
|
+
? "Required dominance ratio = 0.9 × sensitivity. Lower = alerts even on noisier baselines."
|
|
726
|
+
: "Multiplier on the μ ± 3σ band. Higher = wider band = fewer alerts."
|
|
727
|
+
}
|
|
728
|
+
/>
|
|
729
|
+
</div>
|
|
730
|
+
<div className="px-1 pt-2 pb-1">
|
|
731
|
+
<Slider
|
|
732
|
+
value={[effective.sensitivity]}
|
|
733
|
+
min={0.5}
|
|
734
|
+
max={3}
|
|
735
|
+
step={0.1}
|
|
736
|
+
onValueChange={(val) => onChange(field, "sensitivity", val[0])}
|
|
737
|
+
disabled={disabled}
|
|
738
|
+
/>
|
|
739
|
+
</div>
|
|
740
|
+
<div className="flex justify-between items-center text-[10px] font-mono text-muted-foreground pt-1">
|
|
741
|
+
<span>0.5 (more alerts)</span>
|
|
742
|
+
<span className="font-semibold text-foreground bg-muted px-1.5 py-0.5 rounded">
|
|
743
|
+
{effective.sensitivity.toFixed(1)}x
|
|
744
|
+
</span>
|
|
745
|
+
<span>3.0 (fewer)</span>
|
|
746
|
+
</div>
|
|
747
|
+
</div>
|
|
748
|
+
|
|
749
|
+
<div className="flex flex-col space-y-2">
|
|
750
|
+
<div className="flex items-center justify-between">
|
|
751
|
+
<Label className="text-xs font-semibold tracking-wider uppercase text-muted-foreground">
|
|
752
|
+
Confirmation window
|
|
753
|
+
</Label>
|
|
754
|
+
<Tooltip content="Number of consecutive bad checks required before raising an alert. Higher = fewer false positives from one-off blips." />
|
|
755
|
+
</div>
|
|
756
|
+
<div className="px-1 pt-2 pb-1">
|
|
757
|
+
<Slider
|
|
758
|
+
value={[effective.confirmationWindow]}
|
|
759
|
+
min={1}
|
|
760
|
+
max={10}
|
|
761
|
+
step={1}
|
|
762
|
+
onValueChange={(val) =>
|
|
763
|
+
onChange(field, "confirmationWindow", val[0])
|
|
764
|
+
}
|
|
765
|
+
disabled={disabled}
|
|
766
|
+
/>
|
|
767
|
+
</div>
|
|
768
|
+
<div className="flex justify-between items-center text-[10px] font-mono text-muted-foreground pt-1">
|
|
769
|
+
<span>1 run</span>
|
|
770
|
+
<span className="font-semibold text-foreground bg-muted px-1.5 py-0.5 rounded">
|
|
771
|
+
{effective.confirmationWindow} runs
|
|
772
|
+
</span>
|
|
773
|
+
<span>10 runs</span>
|
|
774
|
+
</div>
|
|
775
|
+
</div>
|
|
776
|
+
</div>
|
|
777
|
+
|
|
778
|
+
{!isCategorical && (
|
|
779
|
+
<div className="grid gap-5 pt-4 border-t lg:grid-cols-2 border-border/30">
|
|
780
|
+
<div className="flex flex-col space-y-2">
|
|
781
|
+
<div className="flex items-center justify-between">
|
|
782
|
+
<Label className="text-xs font-semibold tracking-wider uppercase text-muted-foreground">
|
|
783
|
+
Min absolute Δ {fieldMeta.unit ? `(${fieldMeta.unit})` : ""}
|
|
784
|
+
</Label>
|
|
785
|
+
<Tooltip content="Anomaly only fires when the value differs from the recent average by at least this amount. Suppresses tiny absolute swings on low-baseline metrics." />
|
|
786
|
+
</div>
|
|
787
|
+
<Input
|
|
788
|
+
type="number"
|
|
789
|
+
min={0}
|
|
790
|
+
step="any"
|
|
791
|
+
inputMode="decimal"
|
|
792
|
+
value={effective.minAbsoluteDelta}
|
|
793
|
+
onChange={(e) => {
|
|
794
|
+
const raw = e.target.value;
|
|
795
|
+
const parsed = raw === "" ? Number.NaN : Number(raw);
|
|
796
|
+
const next =
|
|
797
|
+
raw === ""
|
|
798
|
+
? (undefined as number | undefined)
|
|
799
|
+
: !Number.isFinite(parsed) || parsed < 0
|
|
800
|
+
? null
|
|
801
|
+
: parsed;
|
|
802
|
+
if (next === null) return;
|
|
803
|
+
onChange(field, "minAbsoluteDelta", next);
|
|
804
|
+
}}
|
|
805
|
+
disabled={disabled}
|
|
806
|
+
className="h-10 transition-colors bg-background/50 focus:bg-background"
|
|
807
|
+
/>
|
|
808
|
+
<p className="text-[10.5px] text-muted-foreground leading-relaxed">
|
|
809
|
+
Anomaly only fires when the value differs from the recent average
|
|
810
|
+
by at least this amount. Use 0 to disable.
|
|
811
|
+
{fieldMeta.defaultMinAbsoluteDelta !== undefined &&
|
|
812
|
+
fieldMeta.defaultMinAbsoluteDelta > 0 &&
|
|
813
|
+
override?.minAbsoluteDelta === undefined && (
|
|
814
|
+
<span className="ml-1 italic opacity-70">
|
|
815
|
+
Plugin default: {fieldMeta.defaultMinAbsoluteDelta}
|
|
816
|
+
{fieldMeta.unit ? ` ${fieldMeta.unit}` : ""}.
|
|
817
|
+
</span>
|
|
818
|
+
)}
|
|
819
|
+
</p>
|
|
820
|
+
</div>
|
|
821
|
+
|
|
822
|
+
<div className="flex flex-col space-y-2">
|
|
823
|
+
<div className="flex items-center justify-between">
|
|
824
|
+
<Label className="text-xs font-semibold tracking-wider uppercase text-muted-foreground">
|
|
825
|
+
Min relative Δ (%)
|
|
826
|
+
</Label>
|
|
827
|
+
<Tooltip content="Anomaly only fires when the proportional change exceeds this percentage of the recent average. Suppresses small proportional changes on high-magnitude metrics." />
|
|
828
|
+
</div>
|
|
829
|
+
<Input
|
|
830
|
+
type="number"
|
|
831
|
+
min={0}
|
|
832
|
+
max={100}
|
|
833
|
+
step="any"
|
|
834
|
+
inputMode="decimal"
|
|
835
|
+
value={(effective.minRelativeDelta * 100).toString()}
|
|
836
|
+
onChange={(e) => {
|
|
837
|
+
const raw = e.target.value;
|
|
838
|
+
const parsed = raw === "" ? Number.NaN : Number(raw);
|
|
839
|
+
const next =
|
|
840
|
+
raw === ""
|
|
841
|
+
? (undefined as number | undefined)
|
|
842
|
+
: !Number.isFinite(parsed) || parsed < 0
|
|
843
|
+
? null
|
|
844
|
+
: parsed / 100;
|
|
845
|
+
if (next === null) return;
|
|
846
|
+
onChange(field, "minRelativeDelta", next);
|
|
847
|
+
}}
|
|
848
|
+
disabled={disabled}
|
|
849
|
+
className="h-10 transition-colors bg-background/50 focus:bg-background"
|
|
850
|
+
/>
|
|
851
|
+
<p className="text-[10.5px] text-muted-foreground leading-relaxed">
|
|
852
|
+
Anomaly only fires when the proportional change exceeds this
|
|
853
|
+
percentage of the recent average. Use 0 to disable.
|
|
854
|
+
{fieldMeta.defaultMinRelativeDelta !== undefined &&
|
|
855
|
+
fieldMeta.defaultMinRelativeDelta > 0 &&
|
|
856
|
+
override?.minRelativeDelta === undefined && (
|
|
857
|
+
<span className="ml-1 italic opacity-70">
|
|
858
|
+
Plugin default:{" "}
|
|
859
|
+
{(fieldMeta.defaultMinRelativeDelta * 100).toFixed(0)}%.
|
|
860
|
+
</span>
|
|
861
|
+
)}
|
|
862
|
+
</p>
|
|
863
|
+
</div>
|
|
864
|
+
</div>
|
|
865
|
+
)}
|
|
866
|
+
|
|
867
|
+
{!isCategorical && (
|
|
868
|
+
<div className="grid gap-5 pt-4 border-t lg:grid-cols-3 border-border/30">
|
|
869
|
+
<div className="flex flex-col space-y-2">
|
|
870
|
+
<div className="flex items-center justify-between">
|
|
871
|
+
<Label className="text-xs font-semibold tracking-wider uppercase text-muted-foreground">
|
|
872
|
+
Trend drift
|
|
873
|
+
</Label>
|
|
874
|
+
<Tooltip content="Catches gradual degradation that never triggers a single-spike alert." />
|
|
875
|
+
</div>
|
|
876
|
+
<div className="flex items-center justify-between p-2 border rounded-md bg-muted/30">
|
|
877
|
+
<span className="text-xs text-muted-foreground">
|
|
878
|
+
{effective.driftEnabled ? "Detecting drift" : "Drift muted"}
|
|
879
|
+
</span>
|
|
880
|
+
<Toggle
|
|
881
|
+
checked={effective.driftEnabled}
|
|
882
|
+
onCheckedChange={(val) => onChange(field, "driftEnabled", val)}
|
|
883
|
+
disabled={disabled}
|
|
884
|
+
/>
|
|
885
|
+
</div>
|
|
886
|
+
</div>
|
|
887
|
+
|
|
888
|
+
<div className="flex flex-col space-y-2 lg:col-span-2">
|
|
889
|
+
<div className="flex items-center justify-between">
|
|
890
|
+
<Label className="text-xs font-semibold tracking-wider uppercase text-muted-foreground">
|
|
891
|
+
Drift threshold
|
|
892
|
+
</Label>
|
|
893
|
+
<Tooltip content="|slope × n| > threshold × σ × sensitivity. Higher = only sharper trends qualify as drift." />
|
|
894
|
+
</div>
|
|
895
|
+
<div className="px-1 pt-2 pb-1">
|
|
896
|
+
<Slider
|
|
897
|
+
value={[effective.driftThreshold]}
|
|
898
|
+
min={1}
|
|
899
|
+
max={4}
|
|
900
|
+
step={0.1}
|
|
901
|
+
onValueChange={(val) =>
|
|
902
|
+
onChange(field, "driftThreshold", val[0])
|
|
903
|
+
}
|
|
904
|
+
disabled={disabled || !effective.driftEnabled}
|
|
905
|
+
/>
|
|
906
|
+
</div>
|
|
907
|
+
<div className="flex justify-between items-center text-[10px] font-mono text-muted-foreground pt-1">
|
|
908
|
+
<span>1.0 (more)</span>
|
|
909
|
+
<span className="font-semibold text-foreground bg-muted px-1.5 py-0.5 rounded">
|
|
910
|
+
{effective.driftThreshold.toFixed(1)}σ
|
|
911
|
+
</span>
|
|
912
|
+
<span>4.0 (fewer)</span>
|
|
913
|
+
</div>
|
|
914
|
+
</div>
|
|
915
|
+
</div>
|
|
916
|
+
)}
|
|
917
|
+
</>
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function buildSummary({
|
|
922
|
+
enabled,
|
|
923
|
+
isCategorical,
|
|
924
|
+
direction,
|
|
925
|
+
confirmationWindow,
|
|
926
|
+
minAbsoluteDelta,
|
|
927
|
+
minRelativeDelta,
|
|
928
|
+
unit,
|
|
929
|
+
}: {
|
|
930
|
+
enabled: boolean;
|
|
931
|
+
isCategorical: boolean;
|
|
932
|
+
direction: AnomalyDirection | undefined;
|
|
933
|
+
confirmationWindow: number;
|
|
934
|
+
minAbsoluteDelta: number;
|
|
935
|
+
minRelativeDelta: number;
|
|
936
|
+
unit?: string;
|
|
937
|
+
}): string {
|
|
938
|
+
if (!enabled) {
|
|
939
|
+
return "Anomaly detection is off for this field.";
|
|
940
|
+
}
|
|
941
|
+
if (isCategorical) {
|
|
942
|
+
return `Alerts when the value flips from its dominant state for ${confirmationWindow} consecutive ${pluralize("check", confirmationWindow)}.`;
|
|
943
|
+
}
|
|
944
|
+
const verb = describeBehavior(direction);
|
|
945
|
+
const checks = `${confirmationWindow} consecutive ${pluralize("check", confirmationWindow)}`;
|
|
946
|
+
const floors: string[] = [];
|
|
947
|
+
if (minAbsoluteDelta > 0) {
|
|
948
|
+
floors.push(`at least ${minAbsoluteDelta}${unit ? ` ${unit}` : ""}`);
|
|
949
|
+
}
|
|
950
|
+
if (minRelativeDelta > 0) {
|
|
951
|
+
floors.push(`at least ${(minRelativeDelta * 100).toFixed(0)}%`);
|
|
952
|
+
}
|
|
953
|
+
const floorClause =
|
|
954
|
+
floors.length > 0 ? ` and the change is ${floors.join(" or ")}` : "";
|
|
955
|
+
return `Alerts when the value ${verb} the recent baseline for ${checks}${floorClause}.`;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function pluralize(word: string, count: number): string {
|
|
959
|
+
return count === 1 ? word : `${word}s`;
|
|
960
|
+
}
|