@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.
@@ -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
- defaultSensitivity: number;
36
- defaultConfirmationWindow: number;
37
- defaultDriftEnabled?: boolean;
38
- defaultDriftThreshold?: number;
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
- // A field is only considered "overridden" if its configured values differ from the parent defaults.
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
- 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;
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 mt-1">{description}</p>
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
- 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"));
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
- <AccordionItem
127
- key={field}
128
- value={field}
565
+ <button
566
+ key={opt}
567
+ type="button"
568
+ onClick={() => onChange(opt)}
569
+ disabled={disabled}
129
570
  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"}
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
- <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>
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
- <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
- )}
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
- </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>
650
+ </SelectItem>
651
+ );
652
+ })}
653
+ </SelectContent>
654
+ </Select>
655
+ </div>
656
+ );
657
+ }
191
658
 
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>
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
+ }