@checkstack/anomaly-frontend 0.5.13 → 0.5.14
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 +92 -0
- package/package.json +12 -12
- package/src/components/AnomalyFieldMuteList.tsx +6 -7
- package/src/components/AnomalyFieldOverridesEditor.tsx +11 -11
- package/src/components/AnomalySettingsForm.tsx +2 -2
- package/src/components/SystemAnomalyWidget.tsx +100 -51
- package/src/components/anomalyTone.logic.test.ts +44 -0
- package/src/components/anomalyTone.logic.ts +51 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,97 @@
|
|
|
1
1
|
# @checkstack/anomaly-frontend
|
|
2
2
|
|
|
3
|
+
## 0.5.14
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 8cad340: Design-system rework: a premium, consistent UI language across the platform.
|
|
8
|
+
|
|
9
|
+
Foundation (`@checkstack/ui` + the shared Tailwind preset):
|
|
10
|
+
|
|
11
|
+
- A token system wired into the shared preset so it generates app-wide: a
|
|
12
|
+
surface elevation ramp (`surface` / `surface-2` / `surface-inset`), the
|
|
13
|
+
aurora gradient stops, a colorblind-safe `status` triad, and `grid-line`.
|
|
14
|
+
- A density model (`comfortable` / `compact`) via `--d-*` vars + `DensityProvider`
|
|
15
|
+
/ `useDensity`, with a user-menu density toggle, plus the polished
|
|
16
|
+
skeleton / empty / error state set.
|
|
17
|
+
- Honest, token-driven chart primitives (`TimeSeriesChart`, `Sparkline`,
|
|
18
|
+
`RadialGauge` / aurora hero, `RequestWaterfall`, `UptimeRibbon`).
|
|
19
|
+
- A signature aurora moment per page: `PageHeader` paints its icon strokes with
|
|
20
|
+
the aurora gradient and adds a hairline; `Card` gains soft layered depth.
|
|
21
|
+
|
|
22
|
+
Shell + surfaces:
|
|
23
|
+
|
|
24
|
+
- The app shell adopts the elevation ramp (header `surface-2`, sidebar
|
|
25
|
+
`surface`, content on the ambient base).
|
|
26
|
+
- The system-health dashboard, health-check latency / single-run views, and the
|
|
27
|
+
SLO dashboard are reskinned onto the primitives (aurora confidence gauge,
|
|
28
|
+
honest p50/p95 latency, request waterfall, number-led status cards).
|
|
29
|
+
|
|
30
|
+
App-wide adoption + premium rework:
|
|
31
|
+
|
|
32
|
+
- Every plugin frontend adopts the tokens, status triad, density, and elevation.
|
|
33
|
+
- The highest-impact surfaces in each plugin are then redesigned to a premium
|
|
34
|
+
bar: real depth, number-led hierarchy, multi-encoded status (pill + dot +
|
|
35
|
+
accent stripe), and refined list/table density. Several plugins extract pure
|
|
36
|
+
tone/label/format logic into unit-tested modules.
|
|
37
|
+
|
|
38
|
+
Alerts:
|
|
39
|
+
|
|
40
|
+
- Every alert/callout is unified onto a single premium `Alert` (depth surface +
|
|
41
|
+
status-accent stripe + toned icon chip, variant-driven).
|
|
42
|
+
|
|
43
|
+
BREAKING CHANGE: the duplicate `InfoBanner` component (and its sub-components)
|
|
44
|
+
is removed; use `Alert` instead - it is a drop-in replacement with the same
|
|
45
|
+
variants and composable parts.
|
|
46
|
+
|
|
47
|
+
- 8cad340: Enlarge the per-field mute/unmute icon button in the anomaly field mute list to a
|
|
48
|
+
40px touch target (`h-10 w-10`) so it is reliably tappable on touch devices.
|
|
49
|
+
- 8cad340: Adopt the canonical `toastError` helper from `@checkstack/ui` for error toasts.
|
|
50
|
+
|
|
51
|
+
Error toasts that previously called `toast.error(extractErrorMessage(error, "Failed to X"))`
|
|
52
|
+
(or interpolated `Failed to X: ${extractErrorMessage(error)}` strings) now use
|
|
53
|
+
`toastError(toast, "Failed to X", error)`. This centralizes the
|
|
54
|
+
"Failed to <action>: <message>" voice and applies the shared 100-character
|
|
55
|
+
truncation. Error toasts that did not previously prefix the action now gain the
|
|
56
|
+
canonical prefix; success toasts and terse validation one-liners are unchanged.
|
|
57
|
+
|
|
58
|
+
- Updated dependencies [8cad340]
|
|
59
|
+
- Updated dependencies [8cad340]
|
|
60
|
+
- Updated dependencies [8cad340]
|
|
61
|
+
- Updated dependencies [8cad340]
|
|
62
|
+
- Updated dependencies [8cad340]
|
|
63
|
+
- Updated dependencies [8cad340]
|
|
64
|
+
- Updated dependencies [8cad340]
|
|
65
|
+
- Updated dependencies [8cad340]
|
|
66
|
+
- Updated dependencies [8cad340]
|
|
67
|
+
- Updated dependencies [8cad340]
|
|
68
|
+
- Updated dependencies [8cad340]
|
|
69
|
+
- Updated dependencies [8cad340]
|
|
70
|
+
- Updated dependencies [8cad340]
|
|
71
|
+
- Updated dependencies [8cad340]
|
|
72
|
+
- Updated dependencies [8cad340]
|
|
73
|
+
- Updated dependencies [8cad340]
|
|
74
|
+
- Updated dependencies [8cad340]
|
|
75
|
+
- Updated dependencies [8cad340]
|
|
76
|
+
- Updated dependencies [8cad340]
|
|
77
|
+
- Updated dependencies [8cad340]
|
|
78
|
+
- Updated dependencies [8cad340]
|
|
79
|
+
- Updated dependencies [8cad340]
|
|
80
|
+
- Updated dependencies [8cad340]
|
|
81
|
+
- Updated dependencies [8cad340]
|
|
82
|
+
- Updated dependencies [8cad340]
|
|
83
|
+
- Updated dependencies [8cad340]
|
|
84
|
+
- @checkstack/ui@1.17.0
|
|
85
|
+
- @checkstack/healthcheck-frontend@0.25.0
|
|
86
|
+
- @checkstack/notification-frontend@0.6.0
|
|
87
|
+
- @checkstack/notification-common@1.4.0
|
|
88
|
+
- @checkstack/healthcheck-common@1.8.0
|
|
89
|
+
- @checkstack/common@0.17.0
|
|
90
|
+
- @checkstack/frontend-api@0.11.1
|
|
91
|
+
- @checkstack/anomaly-common@1.5.2
|
|
92
|
+
- @checkstack/catalog-common@2.4.2
|
|
93
|
+
- @checkstack/signal-frontend@0.2.6
|
|
94
|
+
|
|
3
95
|
## 0.5.13
|
|
4
96
|
|
|
5
97
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/anomaly-frontend",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.14",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.tsx",
|
|
@@ -13,16 +13,16 @@
|
|
|
13
13
|
"lint:code": "eslint . --max-warnings 0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@checkstack/anomaly-common": "1.5.
|
|
17
|
-
"@checkstack/catalog-common": "2.4.
|
|
18
|
-
"@checkstack/common": "0.
|
|
19
|
-
"@checkstack/frontend-api": "0.11.
|
|
20
|
-
"@checkstack/healthcheck-common": "1.
|
|
21
|
-
"@checkstack/healthcheck-frontend": "0.
|
|
22
|
-
"@checkstack/notification-common": "1.
|
|
23
|
-
"@checkstack/notification-frontend": "0.
|
|
24
|
-
"@checkstack/signal-frontend": "0.2.
|
|
25
|
-
"@checkstack/ui": "1.
|
|
16
|
+
"@checkstack/anomaly-common": "1.5.2",
|
|
17
|
+
"@checkstack/catalog-common": "2.4.2",
|
|
18
|
+
"@checkstack/common": "0.17.0",
|
|
19
|
+
"@checkstack/frontend-api": "0.11.1",
|
|
20
|
+
"@checkstack/healthcheck-common": "1.8.0",
|
|
21
|
+
"@checkstack/healthcheck-frontend": "0.25.0",
|
|
22
|
+
"@checkstack/notification-common": "1.4.0",
|
|
23
|
+
"@checkstack/notification-frontend": "0.6.0",
|
|
24
|
+
"@checkstack/signal-frontend": "0.2.6",
|
|
25
|
+
"@checkstack/ui": "1.17.0",
|
|
26
26
|
"date-fns": "^4.4.0",
|
|
27
27
|
"lucide-react": "^1.17.0",
|
|
28
28
|
"react": "19.2.7",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"zod": "^4.2.1"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@checkstack/scripts": "0.6.
|
|
33
|
+
"@checkstack/scripts": "0.6.3",
|
|
34
34
|
"@checkstack/tsconfig": "0.0.7",
|
|
35
35
|
"@types/react": "^19.0.0",
|
|
36
36
|
"typescript": "^5.0.0"
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Bell, BellOff } from "lucide-react";
|
|
3
|
-
import { Button, useToast } from "@checkstack/ui";
|
|
3
|
+
import { Button, useToast, toastError } from "@checkstack/ui";
|
|
4
4
|
import { usePluginClient, useApi, accessApiRef } from "@checkstack/frontend-api";
|
|
5
5
|
import { AnomalyApi } from "@checkstack/anomaly-common";
|
|
6
|
-
import { extractErrorMessage } from "@checkstack/common";
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* Sub-controls panel rendered inside the generic SubscriptionRow when a
|
|
@@ -50,12 +49,12 @@ export const AnomalyFieldMuteList: React.FC<{
|
|
|
50
49
|
const muteMutation = anomalyClient.muteAnomalyNotification.useMutation({
|
|
51
50
|
onSuccess: () => void refetchMutes(),
|
|
52
51
|
onError: (error) =>
|
|
53
|
-
toast
|
|
52
|
+
toastError(toast, "Failed to mute notifications", error),
|
|
54
53
|
});
|
|
55
54
|
const unmuteMutation = anomalyClient.unmuteAnomalyNotification.useMutation({
|
|
56
55
|
onSuccess: () => void refetchMutes(),
|
|
57
56
|
onError: (error) =>
|
|
58
|
-
toast
|
|
57
|
+
toastError(toast, "Failed to unmute notifications", error),
|
|
59
58
|
});
|
|
60
59
|
|
|
61
60
|
const isPending = muteMutation.isPending || unmuteMutation.isPending;
|
|
@@ -109,11 +108,11 @@ export const AnomalyFieldMuteList: React.FC<{
|
|
|
109
108
|
|
|
110
109
|
{fieldPaths.length === 0 ? (
|
|
111
110
|
<div className="text-xs text-muted-foreground italic">
|
|
112
|
-
No tracked fields yet
|
|
111
|
+
No tracked fields yet - mute will become available once anomalies
|
|
113
112
|
are observed.
|
|
114
113
|
</div>
|
|
115
114
|
) : (
|
|
116
|
-
<div className="flex flex-col divide-y rounded-md border">
|
|
115
|
+
<div className="flex flex-col divide-y rounded-md border bg-surface">
|
|
117
116
|
{fieldPaths.map((fp) => {
|
|
118
117
|
const muted = mutedFields.has(fp) || isSystemMuted;
|
|
119
118
|
return (
|
|
@@ -127,7 +126,7 @@ export const AnomalyFieldMuteList: React.FC<{
|
|
|
127
126
|
type="button"
|
|
128
127
|
variant="ghost"
|
|
129
128
|
size="icon"
|
|
130
|
-
className="h-
|
|
129
|
+
className="h-10 w-10 shrink-0"
|
|
131
130
|
disabled={isPending || isSystemMuted}
|
|
132
131
|
title={muted ? "Unmute this field" : "Mute this field"}
|
|
133
132
|
onClick={() => handleToggle(fp, mutedFields.has(fp))}
|
|
@@ -107,7 +107,7 @@ const PRESET_COPY: Record<
|
|
|
107
107
|
balanced: {
|
|
108
108
|
label: "Balanced",
|
|
109
109
|
description:
|
|
110
|
-
"Recommended. Uses the plugin's tuned defaults
|
|
110
|
+
"Recommended. Uses the plugin's tuned defaults - alerts on sustained, meaningful deviations.",
|
|
111
111
|
},
|
|
112
112
|
relaxed: {
|
|
113
113
|
label: "Relaxed",
|
|
@@ -465,12 +465,12 @@ function FieldAccordionItem({
|
|
|
465
465
|
<AccordionContent className="px-5 pt-2 pb-5 border-t border-border/30">
|
|
466
466
|
<div className="mt-4 space-y-5">
|
|
467
467
|
{/* Plain-language summary of effective config */}
|
|
468
|
-
<p className="text-xs leading-relaxed text-muted-foreground bg-
|
|
468
|
+
<p className="text-xs leading-relaxed text-muted-foreground bg-surface-inset border border-border/40 rounded-md px-3 py-2">
|
|
469
469
|
{summary}
|
|
470
470
|
</p>
|
|
471
471
|
|
|
472
472
|
{/* Enable/disable */}
|
|
473
|
-
<div className="flex items-center justify-between p-3 border rounded-lg bg-
|
|
473
|
+
<div className="flex items-center justify-between p-3 border rounded-lg bg-surface-inset">
|
|
474
474
|
<div className="space-y-0.5">
|
|
475
475
|
<Label className="text-sm font-medium">
|
|
476
476
|
Monitor this field
|
|
@@ -554,7 +554,7 @@ function PresetSelector({ value, onChange, disabled }: PresetSelectorProps) {
|
|
|
554
554
|
const options: Preset[] = ["strict", "balanced", "relaxed", "custom"];
|
|
555
555
|
const description =
|
|
556
556
|
value === "custom"
|
|
557
|
-
? "Custom
|
|
557
|
+
? "Custom - fine-tuned values that don't match a preset. Edit them in the advanced section below."
|
|
558
558
|
: PRESET_COPY[value].description;
|
|
559
559
|
|
|
560
560
|
return (
|
|
@@ -565,7 +565,7 @@ function PresetSelector({ value, onChange, disabled }: PresetSelectorProps) {
|
|
|
565
565
|
</Label>
|
|
566
566
|
<Tooltip content="Presets bundle sensitivity, confirmation window, and noise floors into one choice. Pick Custom to fine-tune each value." />
|
|
567
567
|
</div>
|
|
568
|
-
<div className="inline-flex items-stretch p-1 border rounded-lg bg-
|
|
568
|
+
<div className="inline-flex items-stretch p-1 border rounded-lg bg-surface-inset gap-0.5">
|
|
569
569
|
{options.map((opt) => {
|
|
570
570
|
const selected = value === opt;
|
|
571
571
|
return (
|
|
@@ -578,7 +578,7 @@ function PresetSelector({ value, onChange, disabled }: PresetSelectorProps) {
|
|
|
578
578
|
"px-4 py-1.5 text-xs font-semibold rounded-md",
|
|
579
579
|
!isLowPower && "transition-all",
|
|
580
580
|
selected
|
|
581
|
-
? "bg-
|
|
581
|
+
? "bg-surface-2 text-foreground shadow-sm"
|
|
582
582
|
: "text-muted-foreground hover:text-foreground",
|
|
583
583
|
"disabled:opacity-50 disabled:cursor-not-allowed capitalize",
|
|
584
584
|
)}
|
|
@@ -625,7 +625,7 @@ function BehaviorSelector({
|
|
|
625
625
|
onValueChange={(val) => onChange(val as AnomalyDirection | "auto")}
|
|
626
626
|
disabled={disabled}
|
|
627
627
|
>
|
|
628
|
-
<SelectTrigger className="h-10 transition-colors bg-
|
|
628
|
+
<SelectTrigger className="h-10 transition-colors bg-surface focus:bg-surface-2">
|
|
629
629
|
<SelectValue placeholder="Auto-detect">
|
|
630
630
|
<div className="flex items-center gap-2 min-w-0">
|
|
631
631
|
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
|
|
@@ -674,7 +674,7 @@ function AdvancedDisclosure({
|
|
|
674
674
|
}: AdvancedDisclosureProps) {
|
|
675
675
|
const { isLowPower } = usePerformance();
|
|
676
676
|
return (
|
|
677
|
-
<div className="border border-border/40 rounded-md bg-
|
|
677
|
+
<div className="border border-border/40 rounded-md bg-surface-inset">
|
|
678
678
|
<button
|
|
679
679
|
type="button"
|
|
680
680
|
onClick={onToggle}
|
|
@@ -813,7 +813,7 @@ function AdvancedControls({
|
|
|
813
813
|
onChange(field, "minAbsoluteDelta", next);
|
|
814
814
|
}}
|
|
815
815
|
disabled={disabled}
|
|
816
|
-
className="h-10 transition-colors bg-
|
|
816
|
+
className="h-10 transition-colors bg-surface focus:bg-surface-2"
|
|
817
817
|
/>
|
|
818
818
|
<p className="text-[10.5px] text-muted-foreground leading-relaxed">
|
|
819
819
|
Anomaly only fires when the value differs from the recent average
|
|
@@ -856,7 +856,7 @@ function AdvancedControls({
|
|
|
856
856
|
onChange(field, "minRelativeDelta", next);
|
|
857
857
|
}}
|
|
858
858
|
disabled={disabled}
|
|
859
|
-
className="h-10 transition-colors bg-
|
|
859
|
+
className="h-10 transition-colors bg-surface focus:bg-surface-2"
|
|
860
860
|
/>
|
|
861
861
|
<p className="text-[10.5px] text-muted-foreground leading-relaxed">
|
|
862
862
|
Anomaly only fires when the proportional change exceeds this
|
|
@@ -883,7 +883,7 @@ function AdvancedControls({
|
|
|
883
883
|
</Label>
|
|
884
884
|
<Tooltip content="Catches gradual degradation that never triggers a single-spike alert." />
|
|
885
885
|
</div>
|
|
886
|
-
<div className="flex items-center justify-between p-2 border rounded-md bg-
|
|
886
|
+
<div className="flex items-center justify-between p-2 border rounded-md bg-surface-inset">
|
|
887
887
|
<span className="text-xs text-muted-foreground">
|
|
888
888
|
{effective.driftEnabled ? "Detecting drift" : "Drift muted"}
|
|
889
889
|
</span>
|
|
@@ -103,7 +103,7 @@ export function AnomalySettingsForm({
|
|
|
103
103
|
|
|
104
104
|
return (
|
|
105
105
|
<div className="space-y-4">
|
|
106
|
-
<div className="flex items-center justify-between
|
|
106
|
+
<div className="flex items-center justify-between rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface p-[var(--d-pad)] shadow-[0_1px_2px_hsl(var(--foreground)/0.04)]">
|
|
107
107
|
<div className="space-y-0.5">
|
|
108
108
|
<Label className="text-base font-medium">{copy.enabledLabel}</Label>
|
|
109
109
|
<div className="text-sm text-muted-foreground">
|
|
@@ -122,7 +122,7 @@ export function AnomalySettingsForm({
|
|
|
122
122
|
</div>
|
|
123
123
|
</div>
|
|
124
124
|
|
|
125
|
-
<div className="grid gap-6 md:grid-cols-2
|
|
125
|
+
<div className="grid gap-6 md:grid-cols-2 rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface p-[var(--d-pad)] shadow-[0_1px_2px_hsl(var(--foreground)/0.04)]">
|
|
126
126
|
<div className="space-y-2">
|
|
127
127
|
<Label htmlFor="baselineWindow" className="text-sm font-medium">
|
|
128
128
|
Baseline window
|
|
@@ -18,8 +18,8 @@ import {
|
|
|
18
18
|
CardHeader,
|
|
19
19
|
CardTitle,
|
|
20
20
|
CardContent,
|
|
21
|
-
Badge,
|
|
22
21
|
Button,
|
|
22
|
+
cn,
|
|
23
23
|
useToast,
|
|
24
24
|
} from "@checkstack/ui";
|
|
25
25
|
import {
|
|
@@ -36,6 +36,11 @@ import {
|
|
|
36
36
|
} from "lucide-react";
|
|
37
37
|
import { formatDistanceToNow } from "date-fns";
|
|
38
38
|
import { Link } from "react-router-dom";
|
|
39
|
+
import {
|
|
40
|
+
anomalyToneStyles,
|
|
41
|
+
toneForState,
|
|
42
|
+
widgetTone,
|
|
43
|
+
} from "./anomalyTone.logic";
|
|
39
44
|
|
|
40
45
|
type Props = SlotContext<typeof SystemDetailsSlot>;
|
|
41
46
|
|
|
@@ -144,6 +149,8 @@ function AnomalyRow({
|
|
|
144
149
|
const deviationValue = anomaly.deviation?.toFixed(1) ?? "—";
|
|
145
150
|
const isAbove = anomaly.direction === "above";
|
|
146
151
|
|
|
152
|
+
const tone = anomalyToneStyles(toneForState(anomaly.state));
|
|
153
|
+
|
|
147
154
|
const detailLink = resolveRoute(healthcheckRoutes.routes.historyDetail, {
|
|
148
155
|
systemId,
|
|
149
156
|
configurationId: anomaly.configurationId,
|
|
@@ -155,19 +162,30 @@ function AnomalyRow({
|
|
|
155
162
|
? HelpCircle
|
|
156
163
|
: AlertTriangle;
|
|
157
164
|
|
|
165
|
+
const stateLabel = isDrift
|
|
166
|
+
? "drift"
|
|
167
|
+
: isSuspicious
|
|
168
|
+
? "suspicious"
|
|
169
|
+
: "confirmed";
|
|
170
|
+
|
|
171
|
+
const DirectionIcon = isAbove ? TrendingUp : TrendingDown;
|
|
172
|
+
|
|
158
173
|
return (
|
|
159
174
|
<Link
|
|
160
175
|
to={detailLink}
|
|
161
|
-
className="group flex items-center gap-
|
|
176
|
+
className="group flex items-center gap-4 px-4 py-3 hover:bg-surface-inset transition-colors cursor-pointer"
|
|
162
177
|
>
|
|
163
|
-
{/*
|
|
164
|
-
<
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
178
|
+
{/* Status column — multi-encoded: hue + dot + icon + label. */}
|
|
179
|
+
<span
|
|
180
|
+
className={cn(
|
|
181
|
+
"inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
|
|
182
|
+
tone.pill,
|
|
183
|
+
)}
|
|
184
|
+
>
|
|
185
|
+
<span className={cn("size-1.5 rounded-full", tone.dot)} aria-hidden />
|
|
186
|
+
<StateIcon className="h-3 w-3" />
|
|
187
|
+
{stateLabel}
|
|
188
|
+
</span>
|
|
171
189
|
|
|
172
190
|
{/* Content */}
|
|
173
191
|
<div className="flex-1 min-w-0">
|
|
@@ -176,15 +194,10 @@ function AnomalyRow({
|
|
|
176
194
|
{parsed.label}
|
|
177
195
|
</span>
|
|
178
196
|
{parsed.source ? (
|
|
179
|
-
<span className="text-
|
|
197
|
+
<span className="text-xs text-muted-foreground shrink-0">
|
|
180
198
|
{parsed.source}
|
|
181
199
|
</span>
|
|
182
200
|
) : undefined}
|
|
183
|
-
{isDrift && (
|
|
184
|
-
<span className="text-[10px] text-muted-foreground bg-muted/40 px-1.5 py-0.5 rounded font-medium shrink-0">
|
|
185
|
-
drift
|
|
186
|
-
</span>
|
|
187
|
-
)}
|
|
188
201
|
{isMuted && (
|
|
189
202
|
<span className="text-[10px] text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded font-medium shrink-0 inline-flex items-center gap-0.5">
|
|
190
203
|
<BellOff className="h-2.5 w-2.5" />
|
|
@@ -216,27 +229,28 @@ function AnomalyRow({
|
|
|
216
229
|
</div>
|
|
217
230
|
</div>
|
|
218
231
|
|
|
219
|
-
{/* Deviation
|
|
220
|
-
<div className="flex
|
|
221
|
-
<
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
226
|
-
>
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
232
|
+
{/* Deviation hero figure */}
|
|
233
|
+
<div className="flex shrink-0 flex-col items-end leading-none">
|
|
234
|
+
<span className="flex items-baseline gap-0.5">
|
|
235
|
+
<span className={cn("text-2xl font-bold tabular-nums", tone.text)}>
|
|
236
|
+
{deviationValue}
|
|
237
|
+
</span>
|
|
238
|
+
<span className={cn("text-sm font-semibold", tone.text)}>σ</span>
|
|
239
|
+
</span>
|
|
240
|
+
<span className="mt-1 inline-flex items-center gap-0.5 text-[11px] text-muted-foreground">
|
|
241
|
+
<DirectionIcon className="h-3 w-3" />
|
|
242
|
+
vs baseline
|
|
243
|
+
</span>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{/* Row actions — calm at rest, revealed on hover. */}
|
|
247
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
234
248
|
{canMute && (
|
|
235
249
|
<Button
|
|
236
250
|
type="button"
|
|
237
251
|
variant="ghost"
|
|
238
252
|
size="icon"
|
|
239
|
-
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
|
253
|
+
className="h-7 w-7 text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
|
|
240
254
|
disabled={isToggling}
|
|
241
255
|
title={
|
|
242
256
|
isMuted
|
|
@@ -261,7 +275,7 @@ function AnomalyRow({
|
|
|
261
275
|
type="button"
|
|
262
276
|
variant="ghost"
|
|
263
277
|
size="icon"
|
|
264
|
-
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
|
278
|
+
className="h-7 w-7 text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
|
|
265
279
|
disabled={isSuppressing}
|
|
266
280
|
title="Suppress this anomaly until it changes again"
|
|
267
281
|
onClick={(event) => {
|
|
@@ -367,7 +381,7 @@ export const SystemAnomalyWidget: React.FC<Props> = ({ system }) => {
|
|
|
367
381
|
|
|
368
382
|
if (isLoading) {
|
|
369
383
|
return (
|
|
370
|
-
<Card>
|
|
384
|
+
<Card className="relative overflow-hidden rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface shadow-[0_1px_2px_hsl(var(--foreground)/0.04),0_10px_30px_-14px_hsl(var(--foreground)/0.12)]">
|
|
371
385
|
<CardHeader>
|
|
372
386
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
373
387
|
<Activity className="h-4 w-4" />
|
|
@@ -389,32 +403,67 @@ export const SystemAnomalyWidget: React.FC<Props> = ({ system }) => {
|
|
|
389
403
|
|
|
390
404
|
const confirmedCount = confirmedAnomalies.length;
|
|
391
405
|
const suspiciousCount = suspiciousAnomalies.length;
|
|
406
|
+
const activeCount = activeAnomalies.length;
|
|
392
407
|
|
|
393
408
|
const isToggling = muteMutation.isPending || unmuteMutation.isPending;
|
|
394
409
|
|
|
410
|
+
const accent = anomalyToneStyles(widgetTone({ confirmedCount }));
|
|
411
|
+
const confirmedStyles = anomalyToneStyles("warn");
|
|
412
|
+
const suspiciousStyles = anomalyToneStyles("unknown");
|
|
413
|
+
|
|
395
414
|
return (
|
|
396
|
-
<Card>
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
415
|
+
<Card className="relative overflow-hidden rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface shadow-[0_1px_2px_hsl(var(--foreground)/0.04),0_10px_30px_-14px_hsl(var(--foreground)/0.12)] transition-all">
|
|
416
|
+
{/* Status accent stripe: worst active state encoded by hue + position. */}
|
|
417
|
+
<span
|
|
418
|
+
className={cn("absolute inset-y-0 left-0 w-1", accent.accent)}
|
|
419
|
+
aria-hidden
|
|
420
|
+
/>
|
|
421
|
+
<CardHeader className="pl-6">
|
|
422
|
+
<div className="flex items-start justify-between gap-3">
|
|
423
|
+
<div className="flex items-center gap-3">
|
|
424
|
+
<span className="text-3xl font-bold tabular-nums leading-none text-foreground">
|
|
425
|
+
{activeCount}
|
|
426
|
+
</span>
|
|
427
|
+
<div className="min-w-0">
|
|
428
|
+
<CardTitle className="flex items-center gap-1.5 text-base">
|
|
429
|
+
<Activity className="h-4 w-4 text-status-warn" />
|
|
430
|
+
System Anomalies
|
|
431
|
+
</CardTitle>
|
|
432
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
433
|
+
active signals
|
|
434
|
+
</p>
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
|
404
438
|
{confirmedCount > 0 && (
|
|
405
|
-
<
|
|
406
|
-
|
|
439
|
+
<span
|
|
440
|
+
className={cn(
|
|
441
|
+
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
|
|
442
|
+
confirmedStyles.pill,
|
|
443
|
+
)}
|
|
444
|
+
>
|
|
445
|
+
<span
|
|
446
|
+
className={cn("size-1.5 rounded-full", confirmedStyles.dot)}
|
|
447
|
+
aria-hidden
|
|
448
|
+
/>
|
|
449
|
+
<AlertTriangle className="h-3 w-3" />
|
|
407
450
|
{confirmedCount}
|
|
408
|
-
</
|
|
451
|
+
</span>
|
|
409
452
|
)}
|
|
410
453
|
{suspiciousCount > 0 && (
|
|
411
|
-
<
|
|
412
|
-
|
|
413
|
-
|
|
454
|
+
<span
|
|
455
|
+
className={cn(
|
|
456
|
+
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
|
|
457
|
+
suspiciousStyles.pill,
|
|
458
|
+
)}
|
|
414
459
|
>
|
|
415
|
-
<
|
|
460
|
+
<span
|
|
461
|
+
className={cn("size-1.5 rounded-full", suspiciousStyles.dot)}
|
|
462
|
+
aria-hidden
|
|
463
|
+
/>
|
|
464
|
+
<HelpCircle className="h-3 w-3" />
|
|
416
465
|
{suspiciousCount}
|
|
417
|
-
</
|
|
466
|
+
</span>
|
|
418
467
|
)}
|
|
419
468
|
{canMute && (
|
|
420
469
|
<Button
|
|
@@ -447,7 +496,7 @@ export const SystemAnomalyWidget: React.FC<Props> = ({ system }) => {
|
|
|
447
496
|
</div>
|
|
448
497
|
</CardHeader>
|
|
449
498
|
<CardContent className="p-0">
|
|
450
|
-
<div className="flex flex-col divide-y">
|
|
499
|
+
<div className="flex flex-col divide-y divide-border/60">
|
|
451
500
|
{activeAnomalies.map((anomaly) => (
|
|
452
501
|
<AnomalyRow
|
|
453
502
|
key={anomaly.id}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
anomalyToneStyles,
|
|
4
|
+
toneForState,
|
|
5
|
+
widgetTone,
|
|
6
|
+
} from "./anomalyTone.logic";
|
|
7
|
+
|
|
8
|
+
describe("toneForState", () => {
|
|
9
|
+
it("maps suspicious to unknown", () => {
|
|
10
|
+
expect(toneForState("suspicious")).toBe("unknown");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("maps confirmed (anomaly) to warn", () => {
|
|
14
|
+
expect(toneForState("anomaly")).toBe("warn");
|
|
15
|
+
expect(toneForState("confirmed")).toBe("warn");
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("widgetTone", () => {
|
|
20
|
+
it("is warn when any confirmed anomaly is present", () => {
|
|
21
|
+
expect(widgetTone({ confirmedCount: 1 })).toBe("warn");
|
|
22
|
+
expect(widgetTone({ confirmedCount: 5 })).toBe("warn");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("falls back to unknown when only suspicious rows exist", () => {
|
|
26
|
+
expect(widgetTone({ confirmedCount: 0 })).toBe("unknown");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("anomalyToneStyles", () => {
|
|
31
|
+
it("returns the warn triad classes", () => {
|
|
32
|
+
const styles = anomalyToneStyles("warn");
|
|
33
|
+
expect(styles.accent).toContain("bg-status-warn");
|
|
34
|
+
expect(styles.pill).toContain("text-status-warn");
|
|
35
|
+
expect(styles.dot).toBe("bg-status-warn");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns the unknown triad classes", () => {
|
|
39
|
+
const styles = anomalyToneStyles("unknown");
|
|
40
|
+
expect(styles.accent).toContain("bg-status-unknown");
|
|
41
|
+
expect(styles.pill).toContain("text-status-unknown");
|
|
42
|
+
expect(styles.dot).toBe("bg-status-unknown");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps anomaly states to the colorblind-safe status triad used across the
|
|
3
|
+
* design system. Confirmed anomalies read as `warn`; suspicious (not yet
|
|
4
|
+
* confirmed) ones read as `unknown`. Spelled out as full literal class strings
|
|
5
|
+
* (not interpolated) so Tailwind's JIT keeps them.
|
|
6
|
+
*/
|
|
7
|
+
export type AnomalyTone = "warn" | "unknown";
|
|
8
|
+
|
|
9
|
+
export interface AnomalyToneStyles {
|
|
10
|
+
pill: string;
|
|
11
|
+
dot: string;
|
|
12
|
+
accent: string;
|
|
13
|
+
text: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TONE_STYLES: Record<AnomalyTone, AnomalyToneStyles> = {
|
|
17
|
+
warn: {
|
|
18
|
+
pill: "bg-status-warn/10 text-status-warn",
|
|
19
|
+
dot: "bg-status-warn",
|
|
20
|
+
accent: "bg-status-warn",
|
|
21
|
+
text: "text-status-warn",
|
|
22
|
+
},
|
|
23
|
+
unknown: {
|
|
24
|
+
pill: "bg-status-unknown/10 text-status-unknown",
|
|
25
|
+
dot: "bg-status-unknown",
|
|
26
|
+
accent: "bg-status-unknown",
|
|
27
|
+
text: "text-status-unknown",
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** The tone for a single anomaly state string. */
|
|
32
|
+
export function toneForState(state: string): AnomalyTone {
|
|
33
|
+
return state === "suspicious" ? "unknown" : "warn";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolve the full class-set for a tone. */
|
|
37
|
+
export function anomalyToneStyles(tone: AnomalyTone): AnomalyToneStyles {
|
|
38
|
+
return TONE_STYLES[tone];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The accent stripe / overall card tone for the widget: `warn` when any
|
|
43
|
+
* confirmed anomaly is present, otherwise `unknown` (only suspicious rows).
|
|
44
|
+
*/
|
|
45
|
+
export function widgetTone({
|
|
46
|
+
confirmedCount,
|
|
47
|
+
}: {
|
|
48
|
+
confirmedCount: number;
|
|
49
|
+
}): AnomalyTone {
|
|
50
|
+
return confirmedCount > 0 ? "warn" : "unknown";
|
|
51
|
+
}
|