@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 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.2.1",
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.1.0",
16
- "@checkstack/catalog-common": "1.5.1",
17
- "@checkstack/common": "0.6.5",
18
- "@checkstack/frontend-api": "0.3.10",
19
- "@checkstack/healthcheck-common": "0.11.0",
20
- "@checkstack/healthcheck-frontend": "0.16.5",
21
- "@checkstack/signal-frontend": "0.0.15",
22
- "@checkstack/ui": "1.5.1",
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 { AnomalyApi, type AnomalyDto } from "@checkstack/anomaly-common";
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 { Activity, AlertTriangle, HelpCircle, TrendingUp, TrendingDown, ArrowRight, LineChart } from "lucide-react";
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({ anomaly, systemId }: { anomaly: AnomalyDto; systemId: string }) {
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 { SystemStateBadgesSlot, SystemDetailsSlot } from "@checkstack/catalog-common";
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
+ );