@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.
@@ -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
+ };