@checkstack/anomaly-frontend 0.2.2 → 0.3.1

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,316 @@
1
1
  # @checkstack/anomaly-frontend
2
2
 
3
+ ## 0.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 50e5f5f: Runtime plugin system: install + uninstall plugins from npm, GitHub releases
8
+ (including private GitHub Enterprise instances), or tarball uploads at
9
+ runtime, with multi-package bundles, dependency-derived compatibility checks,
10
+ multi-instance coordination via a Postgres artifact store, and
11
+ single-coordinator destructive cleanup.
12
+
13
+ Highlights:
14
+
15
+ - New `PluginSource` discriminated union and `PluginInstaller` /
16
+ `PluginInstallerRegistry` interfaces in `@checkstack/backend-api`. The
17
+ GitHub variant accepts an optional `apiBaseUrl` so deployments backed by
18
+ GitHub Enterprise can install from `https://ghe.example.com/api/v3`
19
+ instead of `api.github.com`.
20
+ - New `installPackageMetadataSchema` (Zod) in `@checkstack/common` validates
21
+ every plugin's `package.json` at install time. Required fields: `name`,
22
+ `version`, `description`, `author`, `license`, `checkstack.type`,
23
+ `checkstack.pluginId`. Optional: `checkstack.bundle`,
24
+ `checkstack.usageInstructions`, `checkstack.allowInstallScripts`.
25
+ - New `pluginManagerContract` in `@checkstack/pluginmanager-common` with
26
+ `list`, `previewInstall`, `install`, `previewUninstall`, `uninstall`, and
27
+ `events` procedures.
28
+ - New `@checkstack/pluginmanager-frontend` admin UI: installed-plugins list
29
+ with per-row uninstall (typed-confirmation modal, schema/configs/cascade
30
+ toggles), install page with NPM / Tarball Upload / GitHub Release tabs
31
+ (Catalog tab disabled — coming soon), and an events page surfacing the
32
+ install/uninstall audit log.
33
+ - New `bunx @checkstack/scripts plugin-pack` CLI for plugin authors —
34
+ per-package mode produces an npm-shaped tarball; `--bundle` mode produces
35
+ an outer tarball containing every sibling declared in
36
+ `package.json#checkstack.bundle`. Published to npm so external authors
37
+ can `bunx` it directly without a workspace checkout.
38
+ - Compatibility derived from `package.json#dependencies` ranges
39
+ (`semver.satisfies` against the platform's loaded `@checkstack/*`
40
+ versions) — no separate `compatibility` field.
41
+ - Multi-instance: originator persists artifacts + `plugins` rows + broadcasts
42
+ install/uninstall; receiving instances do in-process register/unregister
43
+ only. Destructive ops (drop schema, delete plugin_configs, delete
44
+ artifacts, delete `plugins` rows) run exactly once on the originator.
45
+ - Fresh-instance bootstrap: `loadPlugins()` hydrates any
46
+ `is_uninstallable=true` plugin missing from `node_modules` from the
47
+ artifact store before normal Phase 1 register.
48
+ - New schema: `plugin_artifacts` (tarball storage), `plugin_install_events`
49
+ (audit/error log). `plugins` extended with `version`, `metadata`,
50
+ `source`, `bundle_id`, `is_primary`. Local plugin sync now writes
51
+ `version` from each plugin's `package.json` so the admin UI shows real
52
+ versions instead of `—`.
53
+ - Tarball-upload endpoint (`POST /api/pluginmanager/upload-tarball`) for
54
+ the install UI; access-gated by `pluginmanager.plugin.manage`.
55
+ - Plugin Manager menu link added to the user menu (main grid, alongside
56
+ Profile / Notification Settings / etc.).
57
+
58
+ Cross-cutting changes:
59
+
60
+ - Backend request/response logging now flows through `rootLogger` (winston)
61
+ instead of `hono/logger`. 5xx responses include the response body inline
62
+ so swallowed early-return errors are visible in the log.
63
+ - The `/api/:pluginId/*` dispatcher now logs which core service is missing
64
+ or which `pluginId` had no metadata when it 500s.
65
+ - New `registerCorePluginMetadata` on `PluginManager` for core routers
66
+ (like the plugin manager itself) that need their metadata visible to the
67
+ RPC dispatcher without going through the full plugin lifecycle.
68
+ - ESLint: `unicorn/no-null` is now disabled globally. Drizzle distinguishes
69
+ between `null` (writes a real SQL NULL) and `undefined` (skip the column
70
+ on insert), so treating them as interchangeable produced latent bugs at
71
+ the persistence boundary. The bulk of the patch-bumped packages above
72
+ reflect lint-fix touches that landed when this rule was relaxed.
73
+ - Workspace-wide license normalization to `Elastic-2.0` (matches
74
+ `LICENSE.md`). Every `package.json` in the workspace now declares the
75
+ same SPDX identifier; the patch bumps capture this.
76
+
77
+ Plugin packages (every `plugins/*`): added a `pack` npm script
78
+ (`bunx @checkstack/scripts plugin-pack`), mirrored each plugin's
79
+ `pluginId` from `plugin-metadata.ts` into `package.json#checkstack.pluginId`
80
+ so install-time validation passes, stubbed any missing required metadata
81
+ fields (`description`, `author`, `license`), and added
82
+ `checkstack.bundle` to multi-package plugin primaries (telegram, rcon, ssh,
83
+ jira, queue-bullmq, queue-memory, cache-memory).
84
+
85
+ Breaking changes:
86
+
87
+ - The legacy single-method `PluginInstaller` interface (`install(packageName)`)
88
+ is removed. Callers must use `coreServices.pluginInstallerRegistry`.
89
+ - The old `pluginAdminContract` and `createPluginAdminRouter` are removed.
90
+ Replaced by `pluginManagerContract` in `@checkstack/pluginmanager-common`
91
+ and `createPluginManagerRouter` in `core/backend`.
92
+ - `@checkstack/test-utils-backend` no longer exports
93
+ `createMockPluginInstaller` / `MockPluginInstaller` (the legacy interface
94
+ it shimmed is gone).
95
+
96
+ Note: bumps are limited to `minor` (for packages with new public API
97
+ surface) and `patch` (for downstream consumers, license normalization,
98
+ and lint fixes). No `major` bumps despite the `PluginInstaller` removal —
99
+ the legacy interface had no third-party consumers in the wild before this
100
+ runtime plugin system landed, and the contract surface is the same shape
101
+ modulo the rename.
102
+
103
+ - Updated dependencies [50e5f5f]
104
+ - @checkstack/catalog-common@2.0.1
105
+ - @checkstack/common@0.8.0
106
+ - @checkstack/healthcheck-frontend@0.18.2
107
+ - @checkstack/notification-frontend@0.3.1
108
+ - @checkstack/signal-frontend@0.1.1
109
+ - @checkstack/ui@1.7.1
110
+ - @checkstack/anomaly-common@1.0.1
111
+ - @checkstack/frontend-api@0.4.2
112
+ - @checkstack/healthcheck-common@1.0.1
113
+ - @checkstack/notification-common@1.0.1
114
+
115
+ ## 0.3.0
116
+
117
+ ### Minor Changes
118
+
119
+ - 32d52c6: feat(anomaly): per-system and per-field notification mute
120
+
121
+ Anomaly notifications now flow through their own subscription group
122
+ (`anomaly.system.<systemId>`) instead of the shared catalog system group, so
123
+ users can opt out of anomaly noise without losing incident or healthcheck
124
+ alerts for the same system. On first deploy, existing subscribers of each
125
+ `catalog.system.<id>` group are seeded onto the new anomaly group so no one
126
+ silently stops getting alerts.
127
+
128
+ A new mute table (`anomaly_notification_mutes`) backs two granularities:
129
+
130
+ - **Per-field**: silence a single noisy metric on one system.
131
+ - **Per-system**: silence every anomaly for one system in one click.
132
+
133
+ The system anomaly widget now exposes a bell icon on each anomaly row plus a
134
+ `Mute all` toggle in the card header. Mutes are user-scoped and persist
135
+ across sessions.
136
+
137
+ Catalog gains a `systemCreated` hook so anomaly (and any future plugin) can
138
+ provision per-system state on creation rather than waiting for a restart.
139
+ The notification service gains a `bulkSubscribe` service-RPC used by the
140
+ one-time migration described above.
141
+
142
+ - 32d52c6: feat: unified notification-subscription manager dialog driven by spec registry
143
+
144
+ Replaces the bell-toggle UX (which only managed a single legacy
145
+ catalog group) with a modal that lists every notification type
146
+ registered against a target — system or group — and exposes both
147
+ per-type toggles and a bulk "Subscribe to all / Unsubscribe from all"
148
+ action. Both surfaces (system detail page header bell, dashboard group
149
+ header bell) now open the same `NotificationSubscriptionsManager`
150
+ component.
151
+
152
+ **Key change vs. the prior slot-based approach**: rows are now driven
153
+ by `notificationClient.listSubscriptionSpecs` — the backend's spec
154
+ registry is the single source of truth. Previously, a row only
155
+ appeared if a frontend plugin had remembered to register a
156
+ `createNotificationSubscriptionExtension`; this caused silent drift
157
+ (healthcheck and dependency registered backend specs without frontend
158
+ extensions, so the dialog counted them but never rendered rows). Now,
159
+ every spec the platform knows about renders a row using the spec's
160
+ `display` metadata (title, description, iconName resolved via
161
+ `DynamicIcon`).
162
+
163
+ **Sub-controls registry** (`@checkstack/notification-frontend`):
164
+ plugins that want sub-granularity (anomaly's per-field mute list,
165
+ future severity / channel filters) call
166
+ `registerSubscriptionSubControls(spec, Component)` at module load —
167
+ the manager looks the component up by `specId` when expanding a row.
168
+
169
+ **Removed (no compat)**:
170
+
171
+ - `createNotificationSubscriptionExtension` (replaced by the
172
+ spec-driven manager + the SubControls registry)
173
+ - `target.slot` field on `NotificationTarget` and the
174
+ `NotificationTargetInput.slot` parameter on
175
+ `defineNotificationTarget`
176
+ - `SystemNotificationSubscriptionsSlot` and
177
+ `GroupNotificationSubscriptionsSlot` from `@checkstack/catalog-common`
178
+ - `SystemNotificationsCard` from the system detail page's main column
179
+ - `SubscribeButton` wiring on dashboard group cards and the system
180
+ detail page header
181
+
182
+ **Migrated frontends**: anomaly (now registers `AnomalyFieldMuteList`
183
+ via the SubControls registry), incident, maintenance — all dropped
184
+ their `createNotificationSubscriptionExtension` calls. healthcheck and
185
+ dependency now show up automatically via the spec registry — no
186
+ frontend changes needed for them to render.
187
+
188
+ The trigger button reflects aggregate state — filled bell when at
189
+ least one spec is subscribed for the resource, ghost bell when none.
190
+
191
+ - 32d52c6: feat: notification target pattern + per-spec subscriptions
192
+
193
+ Replaces the all-or-nothing catalog system/group notification model with a
194
+ platform-level target pattern. Each notification-emitting plugin declares
195
+ _subscription specs_ against typed _target_ objects exported from the
196
+ target's owning plugin (catalog ships `catalogSystemTarget` and
197
+ `catalogGroupTarget`). Notification-backend handles every per-resource
198
+ group lifecycle, parent-edge inheritance, and legacy-subscription seeding
199
+ — plugins never author groupId helpers, lifecycle hooks, or migration
200
+ code again.
201
+
202
+ **Plugin-author surface area is now ~12 lines per emitter:**
203
+
204
+ ```ts
205
+ // <plugin>-common
206
+ const { defineSubscription } = createSubscriptionFactory(pluginMetadata);
207
+ export const fooSystemSubscription = defineSubscription({
208
+ localId: "system",
209
+ target: catalogSystemTarget,
210
+ display: { title: "Foo Alerts", description: "...", iconName: "Bell" },
211
+ });
212
+
213
+ // <plugin>-backend register()
214
+ env.registerSubscriptionSpecs([fooSystemSubscription]);
215
+ // ^ feeds the plugin loader's dependency sorter — each spec's
216
+ // target.ownerPlugin becomes an implicit init-order dep, so this
217
+ // plugin automatically waits for catalog (the target owner) to
218
+ // finish init + afterPluginsReady before its own runs.
219
+
220
+ // <plugin>-backend afterPluginsReady
221
+ await notificationClient.registerSubscriptionSpec(
222
+ specToRegistration(fooSystemSubscription)
223
+ );
224
+ // dispatch
225
+ await notificationClient.notifyForSubscription({
226
+ specId: fooSystemSubscription.specId,
227
+ resourceKeys: [systemId],
228
+ title,
229
+ body,
230
+ importance,
231
+ action,
232
+ collapseKey,
233
+ subjects,
234
+ });
235
+
236
+ // <plugin>-frontend
237
+ createNotificationSubscriptionExtension({ spec: fooSystemSubscription });
238
+ ```
239
+
240
+ **Migrated plugins**: anomaly, incident, maintenance, healthcheck,
241
+ dependency. Each lost its bespoke `notification-groups.ts`,
242
+ `bootstrap*NotificationGroups`, `ensure*Group`, and inheritance walk —
243
+ all of that is now centralized in notification-backend's
244
+ `subscription-engine`.
245
+
246
+ **Plugin loader change** (`@checkstack/backend-api`,
247
+ `@checkstack/backend`): the register-time API gains
248
+ `env.registerSubscriptionSpecs([...specs])`. The dependency sorter
249
+ walks `spec.target.ownerPlugin` for every declared spec and adds the
250
+ target owner as an init-order dependency of the emitting plugin. This
251
+ guarantees that catalog (the owner of the platform's `system` and
252
+ `group` targets) completes init + afterPluginsReady before any
253
+ emitting plugin tries to register its specs against the notification
254
+ service — no string-prefix heuristics, no manual `dependsOnPlugins`
255
+ list, no stub rows. Plugins that fail to declare their specs at
256
+ register time get a clear `Target type X is not registered. Did the
257
+ emitting plugin declare this spec via env.registerSubscriptionSpecs?`
258
+ error from the dispatcher.
259
+
260
+ **Removed** (no backwards compat):
261
+
262
+ - `catalogClient.notifySystemSubscribers` and
263
+ `catalogClient.notifyManySystemSubscribers`
264
+ - `notificationClient.notifyUsers` and `notificationClient.notifyGroups`
265
+ as direct dispatch primitives — replaced by spec-bound
266
+ `notifyForSubscription`
267
+ - catalog's `bootstrapNotificationGroups` (replaced by
268
+ `bootstrapNotificationTargets`)
269
+
270
+ **Enforcement**: the dispatcher rejects calls referencing unregistered
271
+ specIds, specs owned by other plugins, or resourceKeys that haven't been
272
+ pushed via `upsertNotificationResource`. Display metadata for any
273
+ groupId is recoverable via the spec registry, so audit lists render
274
+ correct labels even when an emitter's frontend isn't loaded.
275
+
276
+ **Per-field anomaly mute** keeps working — it now lives inside the
277
+ generic SubscriptionRow's optional `SubControls` panel
278
+ (`AnomalyFieldMuteList`), exposed through the catalog system detail
279
+ page's notifications card.
280
+
281
+ The catalog system detail page renders a "Notifications" card hosting
282
+ `SystemNotificationSubscriptionsSlot`. The matching group surface is
283
+ not yet rendered — group-level subscriptions are wired end-to-end on
284
+ the backend; a follow-up will add the host UI.
285
+
286
+ **Migration of existing subscribers**: target types declare a
287
+ `legacyGroupIdTemplate`; on first registration of each spec,
288
+ notification-backend reads subscribers from the legacy
289
+ `catalog.system.<id>` / `catalog.group.<id>` groups and seeds the new
290
+ spec groups exactly once per (spec × resource) pair, tracked in
291
+ `subscription_migrations`. Anomaly stays opt-in (its target also
292
+ declares the template, but the user-explicit nature of the original
293
+ opt-in flow means the seeding produces the same set of subscribers
294
+ they already had).
295
+
296
+ ### Patch Changes
297
+
298
+ - Updated dependencies [32d52c6]
299
+ - Updated dependencies [32d52c6]
300
+ - Updated dependencies [32d52c6]
301
+ - Updated dependencies [32d52c6]
302
+ - Updated dependencies [32d52c6]
303
+ - Updated dependencies [32d52c6]
304
+ - Updated dependencies [32d52c6]
305
+ - @checkstack/anomaly-common@1.0.0
306
+ - @checkstack/notification-common@1.0.0
307
+ - @checkstack/notification-frontend@0.3.0
308
+ - @checkstack/catalog-common@2.0.0
309
+ - @checkstack/healthcheck-common@1.0.0
310
+ - @checkstack/frontend-api@0.4.1
311
+ - @checkstack/ui@1.7.0
312
+ - @checkstack/healthcheck-frontend@0.18.1
313
+
3
314
  ## 0.2.2
4
315
 
5
316
  ### Patch Changes
package/package.json CHANGED
@@ -1,25 +1,28 @@
1
1
  {
2
2
  "name": "@checkstack/anomaly-frontend",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
+ "license": "Elastic-2.0",
4
5
  "type": "module",
5
6
  "main": "src/index.tsx",
6
7
  "checkstack": {
7
8
  "type": "frontend"
8
9
  },
9
10
  "scripts": {
10
- "typecheck": "tsc --noEmit",
11
+ "typecheck": "tsgo -b",
11
12
  "lint": "bun run lint:code",
12
13
  "lint:code": "eslint . --max-warnings 0"
13
14
  },
14
15
  "dependencies": {
15
- "@checkstack/anomaly-common": "0.2.0",
16
- "@checkstack/catalog-common": "1.5.2",
16
+ "@checkstack/anomaly-common": "1.0.0",
17
+ "@checkstack/catalog-common": "2.0.0",
17
18
  "@checkstack/common": "0.7.0",
18
- "@checkstack/frontend-api": "0.3.11",
19
- "@checkstack/healthcheck-common": "0.12.0",
20
- "@checkstack/healthcheck-frontend": "0.17.1",
21
- "@checkstack/signal-frontend": "0.0.16",
22
- "@checkstack/ui": "1.6.0",
19
+ "@checkstack/frontend-api": "0.4.1",
20
+ "@checkstack/healthcheck-common": "1.0.0",
21
+ "@checkstack/healthcheck-frontend": "0.18.1",
22
+ "@checkstack/notification-common": "1.0.0",
23
+ "@checkstack/notification-frontend": "0.3.0",
24
+ "@checkstack/signal-frontend": "0.1.0",
25
+ "@checkstack/ui": "1.7.0",
23
26
  "date-fns": "^4.1.0",
24
27
  "lucide-react": "^0.344.0",
25
28
  "react": "^18.2.0",
@@ -28,7 +31,7 @@
28
31
  },
29
32
  "devDependencies": {
30
33
  "@checkstack/scripts": "0.1.2",
31
- "@checkstack/tsconfig": "0.0.5",
34
+ "@checkstack/tsconfig": "0.0.6",
32
35
  "@types/react": "^18.2.0",
33
36
  "typescript": "^5.0.0"
34
37
  }
@@ -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
+ );
package/tsconfig.json CHANGED
@@ -5,5 +5,37 @@
5
5
  },
6
6
  "include": [
7
7
  "src"
8
+ ],
9
+ "references": [
10
+ {
11
+ "path": "../anomaly-common"
12
+ },
13
+ {
14
+ "path": "../catalog-common"
15
+ },
16
+ {
17
+ "path": "../common"
18
+ },
19
+ {
20
+ "path": "../frontend-api"
21
+ },
22
+ {
23
+ "path": "../healthcheck-common"
24
+ },
25
+ {
26
+ "path": "../healthcheck-frontend"
27
+ },
28
+ {
29
+ "path": "../notification-common"
30
+ },
31
+ {
32
+ "path": "../notification-frontend"
33
+ },
34
+ {
35
+ "path": "../signal-frontend"
36
+ },
37
+ {
38
+ "path": "../ui"
39
+ }
8
40
  ]
9
41
  }