@checkstack/anomaly-frontend 0.2.1 → 0.3.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 +214 -0
- package/package.json +11 -9
- package/src/components/AnomalyFieldMuteList.tsx +140 -0
- package/src/components/SystemAnomalyWidget.tsx +132 -9
- package/src/plugin.tsx +20 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,219 @@
|
|
|
1
1
|
# @checkstack/anomaly-frontend
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 32d52c6: feat(anomaly): per-system and per-field notification mute
|
|
8
|
+
|
|
9
|
+
Anomaly notifications now flow through their own subscription group
|
|
10
|
+
(`anomaly.system.<systemId>`) instead of the shared catalog system group, so
|
|
11
|
+
users can opt out of anomaly noise without losing incident or healthcheck
|
|
12
|
+
alerts for the same system. On first deploy, existing subscribers of each
|
|
13
|
+
`catalog.system.<id>` group are seeded onto the new anomaly group so no one
|
|
14
|
+
silently stops getting alerts.
|
|
15
|
+
|
|
16
|
+
A new mute table (`anomaly_notification_mutes`) backs two granularities:
|
|
17
|
+
|
|
18
|
+
- **Per-field**: silence a single noisy metric on one system.
|
|
19
|
+
- **Per-system**: silence every anomaly for one system in one click.
|
|
20
|
+
|
|
21
|
+
The system anomaly widget now exposes a bell icon on each anomaly row plus a
|
|
22
|
+
`Mute all` toggle in the card header. Mutes are user-scoped and persist
|
|
23
|
+
across sessions.
|
|
24
|
+
|
|
25
|
+
Catalog gains a `systemCreated` hook so anomaly (and any future plugin) can
|
|
26
|
+
provision per-system state on creation rather than waiting for a restart.
|
|
27
|
+
The notification service gains a `bulkSubscribe` service-RPC used by the
|
|
28
|
+
one-time migration described above.
|
|
29
|
+
|
|
30
|
+
- 32d52c6: feat: unified notification-subscription manager dialog driven by spec registry
|
|
31
|
+
|
|
32
|
+
Replaces the bell-toggle UX (which only managed a single legacy
|
|
33
|
+
catalog group) with a modal that lists every notification type
|
|
34
|
+
registered against a target — system or group — and exposes both
|
|
35
|
+
per-type toggles and a bulk "Subscribe to all / Unsubscribe from all"
|
|
36
|
+
action. Both surfaces (system detail page header bell, dashboard group
|
|
37
|
+
header bell) now open the same `NotificationSubscriptionsManager`
|
|
38
|
+
component.
|
|
39
|
+
|
|
40
|
+
**Key change vs. the prior slot-based approach**: rows are now driven
|
|
41
|
+
by `notificationClient.listSubscriptionSpecs` — the backend's spec
|
|
42
|
+
registry is the single source of truth. Previously, a row only
|
|
43
|
+
appeared if a frontend plugin had remembered to register a
|
|
44
|
+
`createNotificationSubscriptionExtension`; this caused silent drift
|
|
45
|
+
(healthcheck and dependency registered backend specs without frontend
|
|
46
|
+
extensions, so the dialog counted them but never rendered rows). Now,
|
|
47
|
+
every spec the platform knows about renders a row using the spec's
|
|
48
|
+
`display` metadata (title, description, iconName resolved via
|
|
49
|
+
`DynamicIcon`).
|
|
50
|
+
|
|
51
|
+
**Sub-controls registry** (`@checkstack/notification-frontend`):
|
|
52
|
+
plugins that want sub-granularity (anomaly's per-field mute list,
|
|
53
|
+
future severity / channel filters) call
|
|
54
|
+
`registerSubscriptionSubControls(spec, Component)` at module load —
|
|
55
|
+
the manager looks the component up by `specId` when expanding a row.
|
|
56
|
+
|
|
57
|
+
**Removed (no compat)**:
|
|
58
|
+
|
|
59
|
+
- `createNotificationSubscriptionExtension` (replaced by the
|
|
60
|
+
spec-driven manager + the SubControls registry)
|
|
61
|
+
- `target.slot` field on `NotificationTarget` and the
|
|
62
|
+
`NotificationTargetInput.slot` parameter on
|
|
63
|
+
`defineNotificationTarget`
|
|
64
|
+
- `SystemNotificationSubscriptionsSlot` and
|
|
65
|
+
`GroupNotificationSubscriptionsSlot` from `@checkstack/catalog-common`
|
|
66
|
+
- `SystemNotificationsCard` from the system detail page's main column
|
|
67
|
+
- `SubscribeButton` wiring on dashboard group cards and the system
|
|
68
|
+
detail page header
|
|
69
|
+
|
|
70
|
+
**Migrated frontends**: anomaly (now registers `AnomalyFieldMuteList`
|
|
71
|
+
via the SubControls registry), incident, maintenance — all dropped
|
|
72
|
+
their `createNotificationSubscriptionExtension` calls. healthcheck and
|
|
73
|
+
dependency now show up automatically via the spec registry — no
|
|
74
|
+
frontend changes needed for them to render.
|
|
75
|
+
|
|
76
|
+
The trigger button reflects aggregate state — filled bell when at
|
|
77
|
+
least one spec is subscribed for the resource, ghost bell when none.
|
|
78
|
+
|
|
79
|
+
- 32d52c6: feat: notification target pattern + per-spec subscriptions
|
|
80
|
+
|
|
81
|
+
Replaces the all-or-nothing catalog system/group notification model with a
|
|
82
|
+
platform-level target pattern. Each notification-emitting plugin declares
|
|
83
|
+
_subscription specs_ against typed _target_ objects exported from the
|
|
84
|
+
target's owning plugin (catalog ships `catalogSystemTarget` and
|
|
85
|
+
`catalogGroupTarget`). Notification-backend handles every per-resource
|
|
86
|
+
group lifecycle, parent-edge inheritance, and legacy-subscription seeding
|
|
87
|
+
— plugins never author groupId helpers, lifecycle hooks, or migration
|
|
88
|
+
code again.
|
|
89
|
+
|
|
90
|
+
**Plugin-author surface area is now ~12 lines per emitter:**
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
// <plugin>-common
|
|
94
|
+
const { defineSubscription } = createSubscriptionFactory(pluginMetadata);
|
|
95
|
+
export const fooSystemSubscription = defineSubscription({
|
|
96
|
+
localId: "system",
|
|
97
|
+
target: catalogSystemTarget,
|
|
98
|
+
display: { title: "Foo Alerts", description: "...", iconName: "Bell" },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// <plugin>-backend register()
|
|
102
|
+
env.registerSubscriptionSpecs([fooSystemSubscription]);
|
|
103
|
+
// ^ feeds the plugin loader's dependency sorter — each spec's
|
|
104
|
+
// target.ownerPlugin becomes an implicit init-order dep, so this
|
|
105
|
+
// plugin automatically waits for catalog (the target owner) to
|
|
106
|
+
// finish init + afterPluginsReady before its own runs.
|
|
107
|
+
|
|
108
|
+
// <plugin>-backend afterPluginsReady
|
|
109
|
+
await notificationClient.registerSubscriptionSpec(
|
|
110
|
+
specToRegistration(fooSystemSubscription)
|
|
111
|
+
);
|
|
112
|
+
// dispatch
|
|
113
|
+
await notificationClient.notifyForSubscription({
|
|
114
|
+
specId: fooSystemSubscription.specId,
|
|
115
|
+
resourceKeys: [systemId],
|
|
116
|
+
title,
|
|
117
|
+
body,
|
|
118
|
+
importance,
|
|
119
|
+
action,
|
|
120
|
+
collapseKey,
|
|
121
|
+
subjects,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// <plugin>-frontend
|
|
125
|
+
createNotificationSubscriptionExtension({ spec: fooSystemSubscription });
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Migrated plugins**: anomaly, incident, maintenance, healthcheck,
|
|
129
|
+
dependency. Each lost its bespoke `notification-groups.ts`,
|
|
130
|
+
`bootstrap*NotificationGroups`, `ensure*Group`, and inheritance walk —
|
|
131
|
+
all of that is now centralized in notification-backend's
|
|
132
|
+
`subscription-engine`.
|
|
133
|
+
|
|
134
|
+
**Plugin loader change** (`@checkstack/backend-api`,
|
|
135
|
+
`@checkstack/backend`): the register-time API gains
|
|
136
|
+
`env.registerSubscriptionSpecs([...specs])`. The dependency sorter
|
|
137
|
+
walks `spec.target.ownerPlugin` for every declared spec and adds the
|
|
138
|
+
target owner as an init-order dependency of the emitting plugin. This
|
|
139
|
+
guarantees that catalog (the owner of the platform's `system` and
|
|
140
|
+
`group` targets) completes init + afterPluginsReady before any
|
|
141
|
+
emitting plugin tries to register its specs against the notification
|
|
142
|
+
service — no string-prefix heuristics, no manual `dependsOnPlugins`
|
|
143
|
+
list, no stub rows. Plugins that fail to declare their specs at
|
|
144
|
+
register time get a clear `Target type X is not registered. Did the
|
|
145
|
+
emitting plugin declare this spec via env.registerSubscriptionSpecs?`
|
|
146
|
+
error from the dispatcher.
|
|
147
|
+
|
|
148
|
+
**Removed** (no backwards compat):
|
|
149
|
+
|
|
150
|
+
- `catalogClient.notifySystemSubscribers` and
|
|
151
|
+
`catalogClient.notifyManySystemSubscribers`
|
|
152
|
+
- `notificationClient.notifyUsers` and `notificationClient.notifyGroups`
|
|
153
|
+
as direct dispatch primitives — replaced by spec-bound
|
|
154
|
+
`notifyForSubscription`
|
|
155
|
+
- catalog's `bootstrapNotificationGroups` (replaced by
|
|
156
|
+
`bootstrapNotificationTargets`)
|
|
157
|
+
|
|
158
|
+
**Enforcement**: the dispatcher rejects calls referencing unregistered
|
|
159
|
+
specIds, specs owned by other plugins, or resourceKeys that haven't been
|
|
160
|
+
pushed via `upsertNotificationResource`. Display metadata for any
|
|
161
|
+
groupId is recoverable via the spec registry, so audit lists render
|
|
162
|
+
correct labels even when an emitter's frontend isn't loaded.
|
|
163
|
+
|
|
164
|
+
**Per-field anomaly mute** keeps working — it now lives inside the
|
|
165
|
+
generic SubscriptionRow's optional `SubControls` panel
|
|
166
|
+
(`AnomalyFieldMuteList`), exposed through the catalog system detail
|
|
167
|
+
page's notifications card.
|
|
168
|
+
|
|
169
|
+
The catalog system detail page renders a "Notifications" card hosting
|
|
170
|
+
`SystemNotificationSubscriptionsSlot`. The matching group surface is
|
|
171
|
+
not yet rendered — group-level subscriptions are wired end-to-end on
|
|
172
|
+
the backend; a follow-up will add the host UI.
|
|
173
|
+
|
|
174
|
+
**Migration of existing subscribers**: target types declare a
|
|
175
|
+
`legacyGroupIdTemplate`; on first registration of each spec,
|
|
176
|
+
notification-backend reads subscribers from the legacy
|
|
177
|
+
`catalog.system.<id>` / `catalog.group.<id>` groups and seeds the new
|
|
178
|
+
spec groups exactly once per (spec × resource) pair, tracked in
|
|
179
|
+
`subscription_migrations`. Anomaly stays opt-in (its target also
|
|
180
|
+
declares the template, but the user-explicit nature of the original
|
|
181
|
+
opt-in flow means the seeding produces the same set of subscribers
|
|
182
|
+
they already had).
|
|
183
|
+
|
|
184
|
+
### Patch Changes
|
|
185
|
+
|
|
186
|
+
- Updated dependencies [32d52c6]
|
|
187
|
+
- Updated dependencies [32d52c6]
|
|
188
|
+
- Updated dependencies [32d52c6]
|
|
189
|
+
- Updated dependencies [32d52c6]
|
|
190
|
+
- Updated dependencies [32d52c6]
|
|
191
|
+
- Updated dependencies [32d52c6]
|
|
192
|
+
- Updated dependencies [32d52c6]
|
|
193
|
+
- @checkstack/anomaly-common@1.0.0
|
|
194
|
+
- @checkstack/notification-common@1.0.0
|
|
195
|
+
- @checkstack/notification-frontend@0.3.0
|
|
196
|
+
- @checkstack/catalog-common@2.0.0
|
|
197
|
+
- @checkstack/healthcheck-common@1.0.0
|
|
198
|
+
- @checkstack/frontend-api@0.4.1
|
|
199
|
+
- @checkstack/ui@1.7.0
|
|
200
|
+
- @checkstack/healthcheck-frontend@0.18.1
|
|
201
|
+
|
|
202
|
+
## 0.2.2
|
|
203
|
+
|
|
204
|
+
### Patch Changes
|
|
205
|
+
|
|
206
|
+
- Updated dependencies [a914b31]
|
|
207
|
+
- Updated dependencies [ac1e5d4]
|
|
208
|
+
- Updated dependencies [208ad71]
|
|
209
|
+
- @checkstack/healthcheck-frontend@0.18.0
|
|
210
|
+
- @checkstack/signal-frontend@0.1.0
|
|
211
|
+
- @checkstack/frontend-api@0.4.0
|
|
212
|
+
- @checkstack/anomaly-common@0.3.0
|
|
213
|
+
- @checkstack/healthcheck-common@0.13.0
|
|
214
|
+
- @checkstack/catalog-common@1.5.3
|
|
215
|
+
- @checkstack/ui@1.6.1
|
|
216
|
+
|
|
3
217
|
## 0.2.1
|
|
4
218
|
|
|
5
219
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/anomaly-frontend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.tsx",
|
|
6
6
|
"checkstack": {
|
|
@@ -12,14 +12,16 @@
|
|
|
12
12
|
"lint:code": "eslint . --max-warnings 0"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@checkstack/anomaly-common": "0.
|
|
16
|
-
"@checkstack/catalog-common": "1.5.
|
|
17
|
-
"@checkstack/common": "0.
|
|
18
|
-
"@checkstack/frontend-api": "0.
|
|
19
|
-
"@checkstack/healthcheck-common": "0.
|
|
20
|
-
"@checkstack/healthcheck-frontend": "0.
|
|
21
|
-
"@checkstack/
|
|
22
|
-
"@checkstack/
|
|
15
|
+
"@checkstack/anomaly-common": "0.3.0",
|
|
16
|
+
"@checkstack/catalog-common": "1.5.3",
|
|
17
|
+
"@checkstack/common": "0.7.0",
|
|
18
|
+
"@checkstack/frontend-api": "0.4.0",
|
|
19
|
+
"@checkstack/healthcheck-common": "0.13.0",
|
|
20
|
+
"@checkstack/healthcheck-frontend": "0.18.0",
|
|
21
|
+
"@checkstack/notification-common": "0.3.0",
|
|
22
|
+
"@checkstack/notification-frontend": "0.2.36",
|
|
23
|
+
"@checkstack/signal-frontend": "0.1.0",
|
|
24
|
+
"@checkstack/ui": "1.6.1",
|
|
23
25
|
"date-fns": "^4.1.0",
|
|
24
26
|
"lucide-react": "^0.344.0",
|
|
25
27
|
"react": "^18.2.0",
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Bell, BellOff } from "lucide-react";
|
|
3
|
+
import { Button, useToast } from "@checkstack/ui";
|
|
4
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
5
|
+
import { AnomalyApi } from "@checkstack/anomaly-common";
|
|
6
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sub-controls panel rendered inside the generic SubscriptionRow when a
|
|
10
|
+
* user is reachable for `anomaly.system`. Lists the system's currently
|
|
11
|
+
* tracked anomaly fields with per-field mute toggles. Mutes survive
|
|
12
|
+
* unsubscribe (they're a separate per-(systemId, fieldPath) record).
|
|
13
|
+
*
|
|
14
|
+
* The list is built from existing baselines + active anomalies — fields
|
|
15
|
+
* the user has never seen activity on aren't shown to avoid an
|
|
16
|
+
* unbounded picker. A user who needs to mute proactively can do it from
|
|
17
|
+
* the inline bell button on each anomaly row in the system widget.
|
|
18
|
+
*/
|
|
19
|
+
export const AnomalyFieldMuteList: React.FC<{
|
|
20
|
+
resource: { systemId: string };
|
|
21
|
+
}> = ({ resource }) => {
|
|
22
|
+
const { systemId } = resource;
|
|
23
|
+
const anomalyClient = usePluginClient(AnomalyApi);
|
|
24
|
+
const toast = useToast();
|
|
25
|
+
|
|
26
|
+
const { data: anomalies = [] } = anomalyClient.getAnomalies.useQuery(
|
|
27
|
+
{ systemId, limit: 50 },
|
|
28
|
+
{ staleTime: 30_000 },
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const { data: mutes = [], refetch: refetchMutes } =
|
|
32
|
+
anomalyClient.listAnomalyNotificationMutes.useQuery(
|
|
33
|
+
{ systemId },
|
|
34
|
+
{ staleTime: 30_000 },
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const mutedFields = React.useMemo(
|
|
38
|
+
() => new Set(mutes.map((m) => m.fieldPath)),
|
|
39
|
+
[mutes],
|
|
40
|
+
);
|
|
41
|
+
const isSystemMuted = mutedFields.has("");
|
|
42
|
+
|
|
43
|
+
const muteMutation = anomalyClient.muteAnomalyNotification.useMutation({
|
|
44
|
+
onSuccess: () => void refetchMutes(),
|
|
45
|
+
onError: (error) =>
|
|
46
|
+
toast.error(extractErrorMessage(error, "Failed to mute notifications")),
|
|
47
|
+
});
|
|
48
|
+
const unmuteMutation = anomalyClient.unmuteAnomalyNotification.useMutation({
|
|
49
|
+
onSuccess: () => void refetchMutes(),
|
|
50
|
+
onError: (error) =>
|
|
51
|
+
toast.error(extractErrorMessage(error, "Failed to unmute notifications")),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const isPending = muteMutation.isPending || unmuteMutation.isPending;
|
|
55
|
+
|
|
56
|
+
// Distinct fieldPaths from anomalies so the list reflects what the user
|
|
57
|
+
// would actually be paged about. Plus any explicitly muted fields,
|
|
58
|
+
// because the user should still see and be able to unmute them.
|
|
59
|
+
const fieldPaths = React.useMemo(() => {
|
|
60
|
+
const set = new Set<string>();
|
|
61
|
+
for (const a of anomalies) set.add(a.fieldPath);
|
|
62
|
+
for (const fp of mutedFields) if (fp !== "") set.add(fp);
|
|
63
|
+
return [...set].toSorted();
|
|
64
|
+
}, [anomalies, mutedFields]);
|
|
65
|
+
|
|
66
|
+
const handleToggle = (fieldPath: string, currentlyMuted: boolean) => {
|
|
67
|
+
if (currentlyMuted) {
|
|
68
|
+
unmuteMutation.mutate({ systemId, fieldPath });
|
|
69
|
+
} else {
|
|
70
|
+
muteMutation.mutate({ systemId, fieldPath });
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="space-y-3">
|
|
76
|
+
<div className="flex items-center justify-between">
|
|
77
|
+
<span className="text-xs text-muted-foreground">
|
|
78
|
+
Mute the entire system or just specific fields. Mutes persist
|
|
79
|
+
across re-subscribes.
|
|
80
|
+
</span>
|
|
81
|
+
<Button
|
|
82
|
+
type="button"
|
|
83
|
+
variant={isSystemMuted ? "outline" : "primary"}
|
|
84
|
+
size="sm"
|
|
85
|
+
className="h-6 px-2 text-[11px] gap-1"
|
|
86
|
+
disabled={isPending}
|
|
87
|
+
onClick={() => handleToggle("", isSystemMuted)}
|
|
88
|
+
>
|
|
89
|
+
{isSystemMuted ? (
|
|
90
|
+
<>
|
|
91
|
+
<BellOff className="h-3 w-3" /> System muted
|
|
92
|
+
</>
|
|
93
|
+
) : (
|
|
94
|
+
<>
|
|
95
|
+
<Bell className="h-3 w-3" /> Mute system
|
|
96
|
+
</>
|
|
97
|
+
)}
|
|
98
|
+
</Button>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{fieldPaths.length === 0 ? (
|
|
102
|
+
<div className="text-xs text-muted-foreground italic">
|
|
103
|
+
No tracked fields yet — mute will become available once anomalies
|
|
104
|
+
are observed.
|
|
105
|
+
</div>
|
|
106
|
+
) : (
|
|
107
|
+
<div className="flex flex-col divide-y rounded-md border">
|
|
108
|
+
{fieldPaths.map((fp) => {
|
|
109
|
+
const muted = mutedFields.has(fp) || isSystemMuted;
|
|
110
|
+
return (
|
|
111
|
+
<div
|
|
112
|
+
key={fp}
|
|
113
|
+
className="flex items-center justify-between px-3 py-1.5 text-xs"
|
|
114
|
+
>
|
|
115
|
+
<span className="font-mono truncate">{fp}</span>
|
|
116
|
+
<Button
|
|
117
|
+
type="button"
|
|
118
|
+
variant="ghost"
|
|
119
|
+
size="icon"
|
|
120
|
+
className="h-6 w-6"
|
|
121
|
+
disabled={isPending || isSystemMuted}
|
|
122
|
+
title={muted ? "Unmute this field" : "Mute this field"}
|
|
123
|
+
onClick={() =>
|
|
124
|
+
handleToggle(fp, mutedFields.has(fp))
|
|
125
|
+
}
|
|
126
|
+
>
|
|
127
|
+
{muted ? (
|
|
128
|
+
<BellOff className="h-3 w-3" />
|
|
129
|
+
) : (
|
|
130
|
+
<Bell className="h-3 w-3" />
|
|
131
|
+
)}
|
|
132
|
+
</Button>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
})}
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
};
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { usePluginClient, type SlotContext } from "@checkstack/frontend-api";
|
|
3
3
|
import { SystemDetailsSlot } from "@checkstack/catalog-common";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
AnomalyApi,
|
|
6
|
+
type AnomalyDto,
|
|
7
|
+
} from "@checkstack/anomaly-common";
|
|
5
8
|
import { healthcheckRoutes } from "@checkstack/healthcheck-common";
|
|
6
9
|
import { resolveRoute } from "@checkstack/common";
|
|
7
10
|
import {
|
|
@@ -10,8 +13,20 @@ import {
|
|
|
10
13
|
CardTitle,
|
|
11
14
|
CardContent,
|
|
12
15
|
Badge,
|
|
16
|
+
Button,
|
|
17
|
+
useToast,
|
|
13
18
|
} from "@checkstack/ui";
|
|
14
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
Activity,
|
|
21
|
+
AlertTriangle,
|
|
22
|
+
HelpCircle,
|
|
23
|
+
TrendingUp,
|
|
24
|
+
TrendingDown,
|
|
25
|
+
ArrowRight,
|
|
26
|
+
LineChart,
|
|
27
|
+
Bell,
|
|
28
|
+
BellOff,
|
|
29
|
+
} from "lucide-react";
|
|
15
30
|
import { formatDistanceToNow } from "date-fns";
|
|
16
31
|
import { Link } from "react-router-dom";
|
|
17
32
|
|
|
@@ -75,14 +90,12 @@ function humanizeFieldName(name: string): string {
|
|
|
75
90
|
.join(" ");
|
|
76
91
|
}
|
|
77
92
|
|
|
78
|
-
/** Convert strategy + collector into a readable source label */
|
|
79
93
|
function humanizeCollectorSource(strategyId: string, collectorId: string): string {
|
|
80
|
-
// Strip "healthcheck-" prefix if present
|
|
81
94
|
const cleanStrategy = strategyId.replace(/^healthcheck-/, "").toUpperCase();
|
|
82
95
|
|
|
83
96
|
if (collectorId) {
|
|
84
97
|
const cleanCollector = collectorId
|
|
85
|
-
.split("
|
|
98
|
+
.split("-")
|
|
86
99
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
87
100
|
.join(" ");
|
|
88
101
|
return `${cleanStrategy} · ${cleanCollector}`;
|
|
@@ -95,7 +108,19 @@ function humanizeCollectorSource(strategyId: string, collectorId: string): strin
|
|
|
95
108
|
// Anomaly Row Component
|
|
96
109
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
97
110
|
|
|
98
|
-
function AnomalyRow({
|
|
111
|
+
function AnomalyRow({
|
|
112
|
+
anomaly,
|
|
113
|
+
systemId,
|
|
114
|
+
isMuted,
|
|
115
|
+
onToggleMute,
|
|
116
|
+
isToggling,
|
|
117
|
+
}: {
|
|
118
|
+
anomaly: AnomalyDto;
|
|
119
|
+
systemId: string;
|
|
120
|
+
isMuted: boolean;
|
|
121
|
+
onToggleMute: (fieldPath: string, isMuted: boolean) => void;
|
|
122
|
+
isToggling: boolean;
|
|
123
|
+
}) {
|
|
99
124
|
const isSuspicious = anomaly.state === "suspicious";
|
|
100
125
|
const isDrift = anomaly.kind === "drift";
|
|
101
126
|
const parsed = parseFieldPath(anomaly.fieldPath);
|
|
@@ -133,16 +158,22 @@ function AnomalyRow({ anomaly, systemId }: { anomaly: AnomalyDto; systemId: stri
|
|
|
133
158
|
<span className="text-sm font-medium truncate">
|
|
134
159
|
{parsed.label}
|
|
135
160
|
</span>
|
|
136
|
-
{parsed.source
|
|
161
|
+
{parsed.source ? (
|
|
137
162
|
<span className="text-[10px] text-muted-foreground bg-muted/80 px-1.5 py-0.5 rounded font-medium shrink-0">
|
|
138
163
|
{parsed.source}
|
|
139
164
|
</span>
|
|
140
|
-
)}
|
|
165
|
+
) : undefined}
|
|
141
166
|
{isDrift && (
|
|
142
167
|
<span className="text-[10px] text-muted-foreground bg-muted/40 px-1.5 py-0.5 rounded font-medium shrink-0">
|
|
143
168
|
drift
|
|
144
169
|
</span>
|
|
145
170
|
)}
|
|
171
|
+
{isMuted && (
|
|
172
|
+
<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">
|
|
173
|
+
<BellOff className="h-2.5 w-2.5" />
|
|
174
|
+
muted
|
|
175
|
+
</span>
|
|
176
|
+
)}
|
|
146
177
|
</div>
|
|
147
178
|
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground">
|
|
148
179
|
{isDrift ? (
|
|
@@ -168,7 +199,7 @@ function AnomalyRow({ anomaly, systemId }: { anomaly: AnomalyDto; systemId: stri
|
|
|
168
199
|
</div>
|
|
169
200
|
</div>
|
|
170
201
|
|
|
171
|
-
{/* Deviation badge + arrow */}
|
|
202
|
+
{/* Deviation badge + mute toggle + arrow */}
|
|
172
203
|
<div className="flex items-center gap-2 shrink-0">
|
|
173
204
|
<Badge
|
|
174
205
|
variant={isSuspicious ? "outline" : "warning"}
|
|
@@ -183,6 +214,29 @@ function AnomalyRow({ anomaly, systemId }: { anomaly: AnomalyDto; systemId: stri
|
|
|
183
214
|
)}
|
|
184
215
|
{deviationValue}σ
|
|
185
216
|
</Badge>
|
|
217
|
+
<Button
|
|
218
|
+
type="button"
|
|
219
|
+
variant="ghost"
|
|
220
|
+
size="icon"
|
|
221
|
+
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
|
222
|
+
disabled={isToggling}
|
|
223
|
+
title={
|
|
224
|
+
isMuted
|
|
225
|
+
? "Unmute notifications for this field"
|
|
226
|
+
: "Mute notifications for this field"
|
|
227
|
+
}
|
|
228
|
+
onClick={(event) => {
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
event.stopPropagation();
|
|
231
|
+
onToggleMute(anomaly.fieldPath, isMuted);
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
{isMuted ? (
|
|
235
|
+
<BellOff className="h-3.5 w-3.5" />
|
|
236
|
+
) : (
|
|
237
|
+
<Bell className="h-3.5 w-3.5" />
|
|
238
|
+
)}
|
|
239
|
+
</Button>
|
|
186
240
|
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
|
187
241
|
</div>
|
|
188
242
|
</Link>
|
|
@@ -195,6 +249,7 @@ function AnomalyRow({ anomaly, systemId }: { anomaly: AnomalyDto; systemId: stri
|
|
|
195
249
|
|
|
196
250
|
export const SystemAnomalyWidget: React.FC<Props> = ({ system }) => {
|
|
197
251
|
const anomalyClient = usePluginClient(AnomalyApi);
|
|
252
|
+
const toast = useToast();
|
|
198
253
|
|
|
199
254
|
// Fetch only active anomalies — exclude recovered ones.
|
|
200
255
|
// Two queries with React Query deduplication: confirmed anomalies + suspicious.
|
|
@@ -210,6 +265,44 @@ export const SystemAnomalyWidget: React.FC<Props> = ({ system }) => {
|
|
|
210
265
|
{ staleTime: 30_000 },
|
|
211
266
|
);
|
|
212
267
|
|
|
268
|
+
const { data: mutes = [], refetch: refetchMutes } =
|
|
269
|
+
anomalyClient.listAnomalyNotificationMutes.useQuery(
|
|
270
|
+
{ systemId: system.id },
|
|
271
|
+
{ staleTime: 30_000 },
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const mutedFields = React.useMemo(
|
|
275
|
+
() => new Set(mutes.map((m) => m.fieldPath)),
|
|
276
|
+
[mutes],
|
|
277
|
+
);
|
|
278
|
+
const isSystemMuted = mutedFields.has("");
|
|
279
|
+
|
|
280
|
+
const muteMutation = anomalyClient.muteAnomalyNotification.useMutation({
|
|
281
|
+
onSuccess: () => {
|
|
282
|
+
void refetchMutes();
|
|
283
|
+
},
|
|
284
|
+
onError: () => {
|
|
285
|
+
toast.error("Failed to mute notifications");
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const unmuteMutation = anomalyClient.unmuteAnomalyNotification.useMutation({
|
|
290
|
+
onSuccess: () => {
|
|
291
|
+
void refetchMutes();
|
|
292
|
+
},
|
|
293
|
+
onError: () => {
|
|
294
|
+
toast.error("Failed to unmute notifications");
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const handleToggleMute = (fieldPath: string, currentlyMuted: boolean) => {
|
|
299
|
+
if (currentlyMuted) {
|
|
300
|
+
unmuteMutation.mutate({ systemId: system.id, fieldPath });
|
|
301
|
+
} else {
|
|
302
|
+
muteMutation.mutate({ systemId: system.id, fieldPath });
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
213
306
|
const isLoading = loadingConfirmed || loadingSuspicious;
|
|
214
307
|
|
|
215
308
|
// Confirmed anomalies first, then suspicious
|
|
@@ -240,6 +333,8 @@ export const SystemAnomalyWidget: React.FC<Props> = ({ system }) => {
|
|
|
240
333
|
const confirmedCount = confirmedAnomalies.length;
|
|
241
334
|
const suspiciousCount = suspiciousAnomalies.length;
|
|
242
335
|
|
|
336
|
+
const isToggling = muteMutation.isPending || unmuteMutation.isPending;
|
|
337
|
+
|
|
243
338
|
return (
|
|
244
339
|
<Card>
|
|
245
340
|
<CardHeader>
|
|
@@ -264,6 +359,31 @@ export const SystemAnomalyWidget: React.FC<Props> = ({ system }) => {
|
|
|
264
359
|
{suspiciousCount}
|
|
265
360
|
</Badge>
|
|
266
361
|
)}
|
|
362
|
+
<Button
|
|
363
|
+
type="button"
|
|
364
|
+
variant="ghost"
|
|
365
|
+
size="sm"
|
|
366
|
+
className="h-6 px-2 text-[11px] gap-1"
|
|
367
|
+
disabled={isToggling}
|
|
368
|
+
title={
|
|
369
|
+
isSystemMuted
|
|
370
|
+
? "Resume anomaly notifications for this system"
|
|
371
|
+
: "Stop receiving any anomaly notifications for this system"
|
|
372
|
+
}
|
|
373
|
+
onClick={() => handleToggleMute("", isSystemMuted)}
|
|
374
|
+
>
|
|
375
|
+
{isSystemMuted ? (
|
|
376
|
+
<>
|
|
377
|
+
<BellOff className="h-3 w-3" />
|
|
378
|
+
Muted
|
|
379
|
+
</>
|
|
380
|
+
) : (
|
|
381
|
+
<>
|
|
382
|
+
<Bell className="h-3 w-3" />
|
|
383
|
+
Mute all
|
|
384
|
+
</>
|
|
385
|
+
)}
|
|
386
|
+
</Button>
|
|
267
387
|
</div>
|
|
268
388
|
</div>
|
|
269
389
|
</CardHeader>
|
|
@@ -274,6 +394,9 @@ export const SystemAnomalyWidget: React.FC<Props> = ({ system }) => {
|
|
|
274
394
|
key={anomaly.id}
|
|
275
395
|
anomaly={anomaly}
|
|
276
396
|
systemId={system.id}
|
|
397
|
+
isMuted={isSystemMuted || mutedFields.has(anomaly.fieldPath)}
|
|
398
|
+
onToggleMute={handleToggleMute}
|
|
399
|
+
isToggling={isToggling}
|
|
277
400
|
/>
|
|
278
401
|
))}
|
|
279
402
|
</div>
|
package/src/plugin.tsx
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { definePluginMetadata } from "@checkstack/common";
|
|
2
2
|
import { FrontendPlugin, createSlotExtension } from "@checkstack/frontend-api";
|
|
3
|
-
import {
|
|
4
|
-
AssignmentIDENodeSlot,
|
|
5
|
-
AssignmentIDEPanelSlot,
|
|
3
|
+
import {
|
|
4
|
+
AssignmentIDENodeSlot,
|
|
5
|
+
AssignmentIDEPanelSlot,
|
|
6
6
|
HealthCheckConfigIDENodeSlot,
|
|
7
7
|
HealthCheckConfigIDEPanelSlot,
|
|
8
8
|
type AssignmentIDEContext,
|
|
9
9
|
type HealthCheckConfigIDEContext
|
|
10
10
|
} from "@checkstack/healthcheck-frontend";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
SystemStateBadgesSlot,
|
|
13
|
+
SystemDetailsSlot,
|
|
14
|
+
} from "@checkstack/catalog-common";
|
|
15
|
+
import { registerSubscriptionSubControls } from "@checkstack/notification-frontend";
|
|
16
|
+
import { anomalySystemSubscription } from "@checkstack/anomaly-common";
|
|
12
17
|
import { AnomalyConfigPanel } from "./components/AnomalyConfigPanel";
|
|
13
18
|
import { AnomalyTemplatePanel } from "./components/AnomalyTemplatePanel";
|
|
14
19
|
import { SystemAnomalyBadge } from "./components/SystemAnomalyBadge";
|
|
15
20
|
import { SystemAnomalyWidget } from "./components/SystemAnomalyWidget";
|
|
21
|
+
import { AnomalyFieldMuteList } from "./components/AnomalyFieldMuteList";
|
|
16
22
|
import { IDETreeNode } from "@checkstack/ui";
|
|
17
23
|
import { Activity } from "lucide-react";
|
|
18
24
|
|
|
@@ -76,3 +82,13 @@ export const plugin: FrontendPlugin = {
|
|
|
76
82
|
}),
|
|
77
83
|
],
|
|
78
84
|
};
|
|
85
|
+
|
|
86
|
+
// Sub-control panel for the per-system anomaly subscription — registered
|
|
87
|
+
// once at module load so the notification dialog renders the per-field
|
|
88
|
+
// mute list inline below the row when the user expands it. The dialog
|
|
89
|
+
// itself is driven by the backend spec registry; no slot extension to
|
|
90
|
+
// declare here.
|
|
91
|
+
registerSubscriptionSubControls(
|
|
92
|
+
anomalySystemSubscription,
|
|
93
|
+
AnomalyFieldMuteList,
|
|
94
|
+
);
|