@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 +50 -0
- package/package.json +35 -0
- package/src/components/AnomalyConfigPanel.tsx +154 -0
- package/src/components/AnomalyFieldOverridesEditor.tsx +362 -0
- package/src/components/AnomalySettingsForm.tsx +211 -0
- package/src/components/AnomalyTemplatePanel.tsx +133 -0
- package/src/components/SystemAnomalyBadge.tsx +71 -0
- package/src/components/SystemAnomalyWidget.tsx +283 -0
- package/src/components/useAnomalyFields.ts +70 -0
- package/src/index.tsx +1 -0
- package/src/plugin.tsx +78 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { Label, Toggle, Slider } from "@checkstack/ui";
|
|
2
|
+
import type {
|
|
3
|
+
AnomalyDirection,
|
|
4
|
+
AnomalyFieldConfig,
|
|
5
|
+
} from "@checkstack/anomaly-common";
|
|
6
|
+
import { AnomalyFieldOverridesEditor } from "./AnomalyFieldOverridesEditor";
|
|
7
|
+
import type { AnomalyFieldMeta } from "./useAnomalyFields";
|
|
8
|
+
|
|
9
|
+
export interface AnomalySettingsFormValues {
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
sensitivity: number;
|
|
12
|
+
confirmationWindow: number;
|
|
13
|
+
driftEnabled: boolean;
|
|
14
|
+
driftThreshold: number;
|
|
15
|
+
fieldOverrides: Record<string, AnomalyFieldConfig>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AnomalySettingsFormProps {
|
|
19
|
+
values: AnomalySettingsFormValues;
|
|
20
|
+
onChange: <K extends keyof AnomalySettingsFormValues>(
|
|
21
|
+
key: K,
|
|
22
|
+
value: AnomalySettingsFormValues[K],
|
|
23
|
+
) => void;
|
|
24
|
+
availableFields: AnomalyFieldMeta[];
|
|
25
|
+
isLocked?: boolean;
|
|
26
|
+
/** Copy variant — template defaults vs assignment overrides. */
|
|
27
|
+
variant: "template" | "assignment";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const COPY = {
|
|
31
|
+
template: {
|
|
32
|
+
enabledLabel: "Enable Anomaly Detection by Default",
|
|
33
|
+
enabledDescription:
|
|
34
|
+
"Run background analysis to detect deviations from expected behavior across all systems using this template.",
|
|
35
|
+
sensitivityLabel: "Global Sensitivity Multiplier",
|
|
36
|
+
confirmationLabel: "Confirmation Window",
|
|
37
|
+
fieldOverridesTitle: "Global Field-Level Defaults",
|
|
38
|
+
fieldOverridesDescription:
|
|
39
|
+
"Set default anomaly behavior for specific metrics collected by this health check.",
|
|
40
|
+
},
|
|
41
|
+
assignment: {
|
|
42
|
+
enabledLabel: "Enable Assignment Exceptions",
|
|
43
|
+
enabledDescription: "Run background analysis for this specific system",
|
|
44
|
+
sensitivityLabel: "Sensitivity Multiplier Override",
|
|
45
|
+
confirmationLabel: "Confirmation Window Override",
|
|
46
|
+
fieldOverridesTitle: "Field-Level Overrides",
|
|
47
|
+
fieldOverridesDescription:
|
|
48
|
+
"Override anomaly settings for specific metrics collected by this health check.",
|
|
49
|
+
},
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
export function AnomalySettingsForm({
|
|
53
|
+
values,
|
|
54
|
+
onChange,
|
|
55
|
+
availableFields,
|
|
56
|
+
isLocked,
|
|
57
|
+
variant,
|
|
58
|
+
}: AnomalySettingsFormProps) {
|
|
59
|
+
const copy = COPY[variant];
|
|
60
|
+
const {
|
|
61
|
+
enabled,
|
|
62
|
+
sensitivity,
|
|
63
|
+
confirmationWindow,
|
|
64
|
+
driftEnabled,
|
|
65
|
+
driftThreshold,
|
|
66
|
+
fieldOverrides,
|
|
67
|
+
} = values;
|
|
68
|
+
|
|
69
|
+
const handleFieldOverrideChange = (
|
|
70
|
+
field: string,
|
|
71
|
+
key: keyof AnomalyFieldConfig,
|
|
72
|
+
value: number | boolean | AnomalyDirection | undefined,
|
|
73
|
+
) => {
|
|
74
|
+
const next = { ...fieldOverrides };
|
|
75
|
+
next[field] = { ...next[field], [key]: value };
|
|
76
|
+
onChange("fieldOverrides", next);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="space-y-4">
|
|
81
|
+
<div className="flex items-center justify-between p-4 border rounded-md">
|
|
82
|
+
<div className="space-y-0.5">
|
|
83
|
+
<Label className="text-base font-medium">{copy.enabledLabel}</Label>
|
|
84
|
+
<div className="text-sm text-muted-foreground">{copy.enabledDescription}</div>
|
|
85
|
+
</div>
|
|
86
|
+
<div className="flex items-center gap-3">
|
|
87
|
+
<span className="text-sm font-medium">{enabled ? "Enabled" : "Disabled"}</span>
|
|
88
|
+
<Toggle
|
|
89
|
+
checked={enabled}
|
|
90
|
+
onCheckedChange={(val) => onChange("enabled", val)}
|
|
91
|
+
disabled={isLocked}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div className="grid gap-6 md:grid-cols-2 p-4 border rounded-md">
|
|
97
|
+
<div className="space-y-2">
|
|
98
|
+
<Label htmlFor="sensitivity">{copy.sensitivityLabel}</Label>
|
|
99
|
+
<div className="pt-4 pb-2 px-1">
|
|
100
|
+
<Slider
|
|
101
|
+
id="sensitivity"
|
|
102
|
+
value={[sensitivity]}
|
|
103
|
+
min={0.5}
|
|
104
|
+
max={3}
|
|
105
|
+
step={0.1}
|
|
106
|
+
onValueChange={(val) => onChange("sensitivity", val[0])}
|
|
107
|
+
disabled={!enabled || isLocked}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
<div className="flex justify-between items-center text-[10px] font-mono text-muted-foreground pt-1">
|
|
111
|
+
<span>0.5 (More)</span>
|
|
112
|
+
<span className="font-semibold text-foreground bg-muted px-1.5 py-0.5 rounded">
|
|
113
|
+
{sensitivity.toFixed(1)}x
|
|
114
|
+
</span>
|
|
115
|
+
<span>3.0 (Fewer)</span>
|
|
116
|
+
</div>
|
|
117
|
+
{variant === "template" && (
|
|
118
|
+
<p className="text-xs text-muted-foreground pt-2">
|
|
119
|
+
Higher multiplier = wider expected range (fewer alerts).
|
|
120
|
+
</p>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div className="space-y-2">
|
|
125
|
+
<Label htmlFor="confirmationWindow">{copy.confirmationLabel}</Label>
|
|
126
|
+
<div className="pt-4 pb-2 px-1">
|
|
127
|
+
<Slider
|
|
128
|
+
id="confirmationWindow"
|
|
129
|
+
value={[confirmationWindow]}
|
|
130
|
+
min={1}
|
|
131
|
+
max={10}
|
|
132
|
+
step={1}
|
|
133
|
+
onValueChange={(val) => onChange("confirmationWindow", val[0])}
|
|
134
|
+
disabled={!enabled || isLocked}
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
<div className="flex justify-between items-center text-[10px] font-mono text-muted-foreground pt-1">
|
|
138
|
+
<span>1 Run</span>
|
|
139
|
+
<span className="font-semibold text-foreground bg-muted px-1.5 py-0.5 rounded">
|
|
140
|
+
{confirmationWindow} Runs
|
|
141
|
+
</span>
|
|
142
|
+
<span>10 Runs</span>
|
|
143
|
+
</div>
|
|
144
|
+
{variant === "template" && (
|
|
145
|
+
<p className="text-xs text-muted-foreground pt-2">
|
|
146
|
+
Number of consecutive anomalous runs required before an alert is triggered.
|
|
147
|
+
</p>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div className="grid gap-6 md:grid-cols-2 p-4 border rounded-md">
|
|
153
|
+
<div className="flex items-center justify-between md:col-span-2">
|
|
154
|
+
<div className="space-y-0.5">
|
|
155
|
+
<Label className="text-base font-medium">Trend Drift Detection</Label>
|
|
156
|
+
<div className="text-sm text-muted-foreground">
|
|
157
|
+
Catch slow, gradual degradation that never triggers a spike alert.
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
<div className="flex items-center gap-3">
|
|
161
|
+
<span className="text-sm font-medium">{driftEnabled ? "Enabled" : "Disabled"}</span>
|
|
162
|
+
<Toggle
|
|
163
|
+
checked={driftEnabled}
|
|
164
|
+
onCheckedChange={(val) => onChange("driftEnabled", val)}
|
|
165
|
+
disabled={!enabled || isLocked}
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div className="space-y-2 md:col-span-2">
|
|
171
|
+
<Label htmlFor="driftThreshold">Drift Threshold (σ)</Label>
|
|
172
|
+
<div className="pt-4 pb-2 px-1">
|
|
173
|
+
<Slider
|
|
174
|
+
id="driftThreshold"
|
|
175
|
+
value={[driftThreshold]}
|
|
176
|
+
min={1}
|
|
177
|
+
max={4}
|
|
178
|
+
step={0.1}
|
|
179
|
+
onValueChange={(val) => onChange("driftThreshold", val[0])}
|
|
180
|
+
disabled={!enabled || !driftEnabled || isLocked}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
<div className="flex justify-between items-center text-[10px] font-mono text-muted-foreground pt-1">
|
|
184
|
+
<span>1.0σ (More)</span>
|
|
185
|
+
<span className="font-semibold text-foreground bg-muted px-1.5 py-0.5 rounded">
|
|
186
|
+
{driftThreshold.toFixed(1)}σ
|
|
187
|
+
</span>
|
|
188
|
+
<span>4.0σ (Fewer)</span>
|
|
189
|
+
</div>
|
|
190
|
+
<p className="text-xs text-muted-foreground">
|
|
191
|
+
Drift fires when the projected change over the baseline window exceeds this many standard deviations.
|
|
192
|
+
</p>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<AnomalyFieldOverridesEditor
|
|
197
|
+
title={copy.fieldOverridesTitle}
|
|
198
|
+
description={copy.fieldOverridesDescription}
|
|
199
|
+
availableFields={availableFields}
|
|
200
|
+
fieldOverrides={fieldOverrides}
|
|
201
|
+
onChange={handleFieldOverrideChange}
|
|
202
|
+
parentEnabled={enabled}
|
|
203
|
+
isLocked={isLocked}
|
|
204
|
+
defaultSensitivity={sensitivity}
|
|
205
|
+
defaultConfirmationWindow={confirmationWindow}
|
|
206
|
+
defaultDriftEnabled={driftEnabled}
|
|
207
|
+
defaultDriftThreshold={driftThreshold}
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
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 { HealthCheckConfigIDEContext } 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 AnomalyTemplatePanel({ context }: { context: HealthCheckConfigIDEContext }) {
|
|
35
|
+
const toast = useToast();
|
|
36
|
+
const anomalyClient = usePluginClient(AnomalyApi);
|
|
37
|
+
|
|
38
|
+
const [values, setValues] = useState<AnomalySettingsFormValues>(DEFAULT_VALUES);
|
|
39
|
+
// Template-only settings — preserved on save but not surfaced as form controls.
|
|
40
|
+
const [baselineWindow, setBaselineWindow] = useState("7d");
|
|
41
|
+
const [notify, setNotify] = useState(true);
|
|
42
|
+
|
|
43
|
+
const { data: configRecord, isLoading } = anomalyClient.getAnomalyConfig.useQuery(
|
|
44
|
+
{ configurationId: context.configurationId },
|
|
45
|
+
{ enabled: !!context.configurationId },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const availableFields = useAnomalyFields(context.configurationId);
|
|
49
|
+
|
|
50
|
+
const updateMutation = anomalyClient.updateAnomalyConfig.useMutation({
|
|
51
|
+
onSuccess: () => toast.success("Anomaly detection settings saved"),
|
|
52
|
+
onError: () => toast.error("Failed to save settings"),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (configRecord?.data) {
|
|
57
|
+
setValues({
|
|
58
|
+
enabled: configRecord.data.enabled ?? true,
|
|
59
|
+
sensitivity: configRecord.data.sensitivity ?? 1,
|
|
60
|
+
confirmationWindow: configRecord.data.confirmationWindow ?? 3,
|
|
61
|
+
driftEnabled: configRecord.data.driftEnabled ?? true,
|
|
62
|
+
driftThreshold: configRecord.data.driftThreshold ?? 2,
|
|
63
|
+
fieldOverrides:
|
|
64
|
+
(configRecord.data.fieldOverrides as Record<string, AnomalyFieldConfig>) ??
|
|
65
|
+
{},
|
|
66
|
+
});
|
|
67
|
+
setBaselineWindow(configRecord.data.baselineWindow ?? "7d");
|
|
68
|
+
setNotify(configRecord.data.notify ?? true);
|
|
69
|
+
}
|
|
70
|
+
}, [configRecord]);
|
|
71
|
+
|
|
72
|
+
const handleChange = <K extends keyof AnomalySettingsFormValues>(
|
|
73
|
+
key: K,
|
|
74
|
+
value: AnomalySettingsFormValues[K],
|
|
75
|
+
) => {
|
|
76
|
+
setValues((prev) => ({ ...prev, [key]: value }));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const handleSave = () => {
|
|
80
|
+
updateMutation.mutate({
|
|
81
|
+
configurationId: context.configurationId,
|
|
82
|
+
config: {
|
|
83
|
+
...values,
|
|
84
|
+
baselineWindow,
|
|
85
|
+
notify,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (isLoading) {
|
|
91
|
+
return <div className="p-6 text-muted-foreground">Loading anomaly settings...</div>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Card className="flex flex-col h-full border-0 rounded-none shadow-none">
|
|
96
|
+
<CardHeader className="pb-4">
|
|
97
|
+
<div className="flex items-center gap-2">
|
|
98
|
+
<Activity className="h-5 w-5 text-primary" />
|
|
99
|
+
<div>
|
|
100
|
+
<CardTitle className="text-lg">Template Anomaly Defaults</CardTitle>
|
|
101
|
+
<CardDescription>
|
|
102
|
+
Configure the default machine learning sensitivity and baseline windows for this health check template. These settings cascade to all assignments unless specifically overridden.
|
|
103
|
+
</CardDescription>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</CardHeader>
|
|
107
|
+
|
|
108
|
+
<CardContent className="flex-1 space-y-6 overflow-y-auto">
|
|
109
|
+
<AnomalySettingsForm
|
|
110
|
+
values={values}
|
|
111
|
+
onChange={handleChange}
|
|
112
|
+
availableFields={availableFields}
|
|
113
|
+
isLocked={context.isLocked}
|
|
114
|
+
variant="template"
|
|
115
|
+
/>
|
|
116
|
+
</CardContent>
|
|
117
|
+
|
|
118
|
+
<CardFooter className="pt-4 border-t flex justify-end">
|
|
119
|
+
<Button
|
|
120
|
+
onClick={handleSave}
|
|
121
|
+
disabled={updateMutation.isPending || context.isLocked}
|
|
122
|
+
>
|
|
123
|
+
{updateMutation.isPending ? "Saving..." : (
|
|
124
|
+
<>
|
|
125
|
+
<Save className="mr-2 h-4 w-4" />
|
|
126
|
+
Save Defaults
|
|
127
|
+
</>
|
|
128
|
+
)}
|
|
129
|
+
</Button>
|
|
130
|
+
</CardFooter>
|
|
131
|
+
</Card>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import { usePluginClient, type SlotContext } from "@checkstack/frontend-api";
|
|
3
|
+
import { SystemStateBadgesSlot } from "@checkstack/catalog-common";
|
|
4
|
+
import { AnomalyApi, type AnomalyState } from "@checkstack/anomaly-common";
|
|
5
|
+
import { Badge } from "@checkstack/ui";
|
|
6
|
+
import { AlertTriangle, HelpCircle } from "lucide-react";
|
|
7
|
+
|
|
8
|
+
type Props = SlotContext<typeof SystemStateBadgesSlot>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Renders an anomaly status badge for a system.
|
|
12
|
+
*
|
|
13
|
+
* Fetches ONLY active anomalies (state = "anomaly") and suspicious states
|
|
14
|
+
* in two separate queries. React Query deduplicates these globally across
|
|
15
|
+
* all badge instances on the dashboard, so even with 50+ badges we only
|
|
16
|
+
* issue 2 requests total. Recovered anomalies are excluded to prevent
|
|
17
|
+
* unbounded growth in the response payload.
|
|
18
|
+
*/
|
|
19
|
+
export const SystemAnomalyBadge: React.FC<Props> = ({ system }) => {
|
|
20
|
+
const anomalyClient = usePluginClient(AnomalyApi);
|
|
21
|
+
|
|
22
|
+
// Fetch confirmed anomalies — deduped across all badge instances via React Query
|
|
23
|
+
const { data: confirmedAnomalies = [] } = anomalyClient.getAnomalies.useQuery(
|
|
24
|
+
{ state: "anomaly", limit: 500 },
|
|
25
|
+
{ staleTime: 30_000 },
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// Fetch suspicious states — deduped across all badge instances via React Query
|
|
29
|
+
const { data: suspiciousAnomalies = [] } =
|
|
30
|
+
anomalyClient.getAnomalies.useQuery(
|
|
31
|
+
{ state: "suspicious", limit: 500 },
|
|
32
|
+
{ staleTime: 30_000 },
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Find the worst state for this specific system
|
|
36
|
+
const systemState = useMemo(() => {
|
|
37
|
+
// Check confirmed anomalies first (worst state)
|
|
38
|
+
if (confirmedAnomalies.some((a) => a.systemId === system?.id)) {
|
|
39
|
+
return "anomaly" as AnomalyState;
|
|
40
|
+
}
|
|
41
|
+
// Check suspicious states
|
|
42
|
+
if (suspiciousAnomalies.some((a) => a.systemId === system?.id)) {
|
|
43
|
+
return "suspicious" as AnomalyState;
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}, [confirmedAnomalies, suspiciousAnomalies, system?.id]);
|
|
47
|
+
|
|
48
|
+
if (!systemState) return <></>;
|
|
49
|
+
|
|
50
|
+
if (systemState === "anomaly") {
|
|
51
|
+
return (
|
|
52
|
+
<Badge
|
|
53
|
+
variant="warning"
|
|
54
|
+
className="flex items-center gap-1 shrink-0 cursor-default"
|
|
55
|
+
>
|
|
56
|
+
<AlertTriangle className="h-3 w-3" />
|
|
57
|
+
Anomaly
|
|
58
|
+
</Badge>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Badge
|
|
64
|
+
variant="outline"
|
|
65
|
+
className="flex items-center gap-1 shrink-0 cursor-default border-warning/50 text-warning"
|
|
66
|
+
>
|
|
67
|
+
<HelpCircle className="h-3 w-3" />
|
|
68
|
+
Suspicious
|
|
69
|
+
</Badge>
|
|
70
|
+
);
|
|
71
|
+
};
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { usePluginClient, type SlotContext } from "@checkstack/frontend-api";
|
|
3
|
+
import { SystemDetailsSlot } from "@checkstack/catalog-common";
|
|
4
|
+
import { AnomalyApi, type AnomalyDto } from "@checkstack/anomaly-common";
|
|
5
|
+
import { healthcheckRoutes } from "@checkstack/healthcheck-common";
|
|
6
|
+
import { resolveRoute } from "@checkstack/common";
|
|
7
|
+
import {
|
|
8
|
+
Card,
|
|
9
|
+
CardHeader,
|
|
10
|
+
CardTitle,
|
|
11
|
+
CardContent,
|
|
12
|
+
Badge,
|
|
13
|
+
} from "@checkstack/ui";
|
|
14
|
+
import { Activity, AlertTriangle, HelpCircle, TrendingUp, TrendingDown, ArrowRight, LineChart } from "lucide-react";
|
|
15
|
+
import { formatDistanceToNow } from "date-fns";
|
|
16
|
+
import { Link } from "react-router-dom";
|
|
17
|
+
|
|
18
|
+
type Props = SlotContext<typeof SystemDetailsSlot>;
|
|
19
|
+
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
// Field Path Humanization
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parses a raw anomaly field path like
|
|
26
|
+
* `collectors.healthcheck-http.request.responseTimeMs`
|
|
27
|
+
* into structured display info:
|
|
28
|
+
* { label: "Response Time", source: "HTTP Request", strategy: "healthcheck-http" }
|
|
29
|
+
*/
|
|
30
|
+
function parseFieldPath(fieldPath: string): {
|
|
31
|
+
label: string;
|
|
32
|
+
source: string;
|
|
33
|
+
strategy: string;
|
|
34
|
+
} {
|
|
35
|
+
// Field paths follow the pattern: collectors.<strategyId>.<collectorId>.<fieldName>
|
|
36
|
+
const parts = fieldPath.split(".");
|
|
37
|
+
|
|
38
|
+
if (parts[0] !== "collectors" || parts.length < 3) {
|
|
39
|
+
return { label: fieldPath, source: "", strategy: "" };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const strategyId = parts[1]; // e.g. "healthcheck-http"
|
|
43
|
+
const fieldName = parts.at(-1) ?? fieldPath; // e.g. "responseTimeMs"
|
|
44
|
+
|
|
45
|
+
// Extract collector id (everything between strategy and field)
|
|
46
|
+
const collectorParts = parts.slice(2, -1); // e.g. ["request"]
|
|
47
|
+
const collectorId = collectorParts.join(" "); // e.g. "request"
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
label: humanizeFieldName(fieldName),
|
|
51
|
+
source: humanizeCollectorSource(strategyId, collectorId),
|
|
52
|
+
strategy: strategyId,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Convert camelCase/snake_case field names to human-readable labels */
|
|
57
|
+
function humanizeFieldName(name: string): string {
|
|
58
|
+
// Strip common suffixes for cleaner display
|
|
59
|
+
const cleaned = name
|
|
60
|
+
.replace(/Ms$/, "")
|
|
61
|
+
.replace(/Seconds$/, "")
|
|
62
|
+
.replace(/Bytes$/, "")
|
|
63
|
+
.replace(/Count$/, "");
|
|
64
|
+
|
|
65
|
+
// camelCase → spaces
|
|
66
|
+
const spaced = cleaned.replaceAll(/([a-z])([A-Z])/g, "$1 $2");
|
|
67
|
+
|
|
68
|
+
// snake_case → spaces
|
|
69
|
+
const words = spaced.replaceAll("_", " ");
|
|
70
|
+
|
|
71
|
+
// Capitalize
|
|
72
|
+
return words
|
|
73
|
+
.split(" ")
|
|
74
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
75
|
+
.join(" ");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Convert strategy + collector into a readable source label */
|
|
79
|
+
function humanizeCollectorSource(strategyId: string, collectorId: string): string {
|
|
80
|
+
// Strip "healthcheck-" prefix if present
|
|
81
|
+
const cleanStrategy = strategyId.replace(/^healthcheck-/, "").toUpperCase();
|
|
82
|
+
|
|
83
|
+
if (collectorId) {
|
|
84
|
+
const cleanCollector = collectorId
|
|
85
|
+
.split(" ")
|
|
86
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
87
|
+
.join(" ");
|
|
88
|
+
return `${cleanStrategy} · ${cleanCollector}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return cleanStrategy;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
95
|
+
// Anomaly Row Component
|
|
96
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function AnomalyRow({ anomaly, systemId }: { anomaly: AnomalyDto; systemId: string }) {
|
|
99
|
+
const isSuspicious = anomaly.state === "suspicious";
|
|
100
|
+
const isDrift = anomaly.kind === "drift";
|
|
101
|
+
const parsed = parseFieldPath(anomaly.fieldPath);
|
|
102
|
+
const deviationValue = anomaly.deviation?.toFixed(1) ?? "—";
|
|
103
|
+
const isAbove = anomaly.direction === "above";
|
|
104
|
+
|
|
105
|
+
const detailLink = resolveRoute(healthcheckRoutes.routes.historyDetail, {
|
|
106
|
+
systemId,
|
|
107
|
+
configurationId: anomaly.configurationId,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const StateIcon = isDrift
|
|
111
|
+
? LineChart
|
|
112
|
+
: isSuspicious
|
|
113
|
+
? HelpCircle
|
|
114
|
+
: AlertTriangle;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<Link
|
|
118
|
+
to={detailLink}
|
|
119
|
+
className="group flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors cursor-pointer"
|
|
120
|
+
>
|
|
121
|
+
{/* State icon */}
|
|
122
|
+
<div className={`shrink-0 rounded-full p-1.5 ${
|
|
123
|
+
isSuspicious
|
|
124
|
+
? "bg-warning/10 text-warning/70"
|
|
125
|
+
: "bg-warning/15 text-warning"
|
|
126
|
+
}`}>
|
|
127
|
+
<StateIcon className="h-3.5 w-3.5" />
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{/* Content */}
|
|
131
|
+
<div className="flex-1 min-w-0">
|
|
132
|
+
<div className="flex items-center gap-2">
|
|
133
|
+
<span className="text-sm font-medium truncate">
|
|
134
|
+
{parsed.label}
|
|
135
|
+
</span>
|
|
136
|
+
{parsed.source && (
|
|
137
|
+
<span className="text-[10px] text-muted-foreground bg-muted/80 px-1.5 py-0.5 rounded font-medium shrink-0">
|
|
138
|
+
{parsed.source}
|
|
139
|
+
</span>
|
|
140
|
+
)}
|
|
141
|
+
{isDrift && (
|
|
142
|
+
<span className="text-[10px] text-muted-foreground bg-muted/40 px-1.5 py-0.5 rounded font-medium shrink-0">
|
|
143
|
+
drift
|
|
144
|
+
</span>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground">
|
|
148
|
+
{isDrift ? (
|
|
149
|
+
<span>
|
|
150
|
+
Trending {isAbove ? "up" : "down"} from baseline {anomaly.baselineValue?.toFixed(1) ?? "—"}
|
|
151
|
+
</span>
|
|
152
|
+
) : (
|
|
153
|
+
<>
|
|
154
|
+
<span className="font-mono tabular-nums">{anomaly.observedValue}</span>
|
|
155
|
+
<span className="opacity-50">
|
|
156
|
+
{isAbove ? "↑" : "↓"} baseline {anomaly.baselineValue?.toFixed(1)}
|
|
157
|
+
</span>
|
|
158
|
+
</>
|
|
159
|
+
)}
|
|
160
|
+
<span className="opacity-40">·</span>
|
|
161
|
+
<span>
|
|
162
|
+
{isSuspicious ? "Detected" : "Confirmed"}{" "}
|
|
163
|
+
{formatDistanceToNow(
|
|
164
|
+
new Date(isSuspicious ? anomaly.startedAt : (anomaly.confirmedAt ?? anomaly.startedAt)),
|
|
165
|
+
{ addSuffix: true },
|
|
166
|
+
)}
|
|
167
|
+
</span>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{/* Deviation badge + arrow */}
|
|
172
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
173
|
+
<Badge
|
|
174
|
+
variant={isSuspicious ? "outline" : "warning"}
|
|
175
|
+
className={`text-[10px] h-5 px-1.5 font-mono tabular-nums gap-1 ${
|
|
176
|
+
isSuspicious ? "border-warning/40 text-warning" : ""
|
|
177
|
+
}`}
|
|
178
|
+
>
|
|
179
|
+
{isAbove ? (
|
|
180
|
+
<TrendingUp className="h-2.5 w-2.5" />
|
|
181
|
+
) : (
|
|
182
|
+
<TrendingDown className="h-2.5 w-2.5" />
|
|
183
|
+
)}
|
|
184
|
+
{deviationValue}σ
|
|
185
|
+
</Badge>
|
|
186
|
+
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
|
187
|
+
</div>
|
|
188
|
+
</Link>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
193
|
+
// Main Widget
|
|
194
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
export const SystemAnomalyWidget: React.FC<Props> = ({ system }) => {
|
|
197
|
+
const anomalyClient = usePluginClient(AnomalyApi);
|
|
198
|
+
|
|
199
|
+
// Fetch only active anomalies — exclude recovered ones.
|
|
200
|
+
// Two queries with React Query deduplication: confirmed anomalies + suspicious.
|
|
201
|
+
const { data: confirmedAnomalies = [], isLoading: loadingConfirmed } =
|
|
202
|
+
anomalyClient.getAnomalies.useQuery(
|
|
203
|
+
{ systemId: system.id, state: "anomaly", limit: 10 },
|
|
204
|
+
{ staleTime: 30_000 },
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const { data: suspiciousAnomalies = [], isLoading: loadingSuspicious } =
|
|
208
|
+
anomalyClient.getAnomalies.useQuery(
|
|
209
|
+
{ systemId: system.id, state: "suspicious", limit: 10 },
|
|
210
|
+
{ staleTime: 30_000 },
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const isLoading = loadingConfirmed || loadingSuspicious;
|
|
214
|
+
|
|
215
|
+
// Confirmed anomalies first, then suspicious
|
|
216
|
+
const activeAnomalies = [...confirmedAnomalies, ...suspiciousAnomalies];
|
|
217
|
+
|
|
218
|
+
if (isLoading) {
|
|
219
|
+
return (
|
|
220
|
+
<Card>
|
|
221
|
+
<CardHeader>
|
|
222
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
223
|
+
<Activity className="h-4 w-4" />
|
|
224
|
+
System Anomalies
|
|
225
|
+
</CardTitle>
|
|
226
|
+
</CardHeader>
|
|
227
|
+
<CardContent>
|
|
228
|
+
<div className="flex h-16 items-center justify-center text-sm text-muted-foreground">
|
|
229
|
+
Loading anomalies...
|
|
230
|
+
</div>
|
|
231
|
+
</CardContent>
|
|
232
|
+
</Card>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (activeAnomalies.length === 0) {
|
|
237
|
+
return <></>;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const confirmedCount = confirmedAnomalies.length;
|
|
241
|
+
const suspiciousCount = suspiciousAnomalies.length;
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<Card>
|
|
245
|
+
<CardHeader>
|
|
246
|
+
<div className="flex items-center justify-between">
|
|
247
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
248
|
+
<Activity className="h-4 w-4 text-warning" />
|
|
249
|
+
System Anomalies
|
|
250
|
+
</CardTitle>
|
|
251
|
+
<div className="flex items-center gap-1.5">
|
|
252
|
+
{confirmedCount > 0 && (
|
|
253
|
+
<Badge variant="warning" className="text-[10px] h-5 px-1.5 gap-1">
|
|
254
|
+
<AlertTriangle className="h-2.5 w-2.5" />
|
|
255
|
+
{confirmedCount}
|
|
256
|
+
</Badge>
|
|
257
|
+
)}
|
|
258
|
+
{suspiciousCount > 0 && (
|
|
259
|
+
<Badge
|
|
260
|
+
variant="outline"
|
|
261
|
+
className="text-[10px] h-5 px-1.5 gap-1 border-warning/40 text-warning"
|
|
262
|
+
>
|
|
263
|
+
<HelpCircle className="h-2.5 w-2.5" />
|
|
264
|
+
{suspiciousCount}
|
|
265
|
+
</Badge>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
</CardHeader>
|
|
270
|
+
<CardContent className="p-0">
|
|
271
|
+
<div className="flex flex-col divide-y">
|
|
272
|
+
{activeAnomalies.map((anomaly) => (
|
|
273
|
+
<AnomalyRow
|
|
274
|
+
key={anomaly.id}
|
|
275
|
+
anomaly={anomaly}
|
|
276
|
+
systemId={system.id}
|
|
277
|
+
/>
|
|
278
|
+
))}
|
|
279
|
+
</div>
|
|
280
|
+
</CardContent>
|
|
281
|
+
</Card>
|
|
282
|
+
);
|
|
283
|
+
};
|