@checkstack/anomaly-frontend 0.5.18 → 0.6.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,136 @@
1
1
  # @checkstack/anomaly-frontend
2
2
 
3
+ ## 0.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 0cac684: Align the health-check run-history gates end to end. The history surfaces had a
8
+ three-way drift: the route allowed `configuration.read`, the page required
9
+ manage capability, and the procedures required the standalone
10
+ `healthcheck.details` rule - so global read-rule holders reached a page that
11
+ denied them, and team-scoped managers passed the page gate but got 403s from
12
+ every data call.
13
+
14
+ Detailed run history is now a MANAGER surface everywhere, with system owners
15
+ included: access requires global `configuration.manage`, a team manage grant
16
+ on the CONFIGURATION, or manage access to the SYSTEM - a system's owning team
17
+ sees every run of that system, whoever owns the configuration.
18
+
19
+ - Routes, pages, drawer links, and the anomaly/health signals gate on the
20
+ manage capability (with `catalog.system` as the parent type); the drawer and
21
+ chart hook check the caller's grant on the specific configuration OR system.
22
+ - All three history procedures (`getDetailedHistory`,
23
+ `getDetailedAggregatedHistory`, `getRunById`) are authorized in the handler
24
+ via a shared fail-closed module (`history-access.ts`) - the triple-OR is not
25
+ expressible with the declarative instanceAccess modes. `getRunById`
26
+ authorizes against the fetched run's own configuration/system, and answers
27
+ `undefined` for unauthorized callers so run ids don't leak existence.
28
+ - The feed (`getDetailedHistory`) scopes team callers to runs of their
29
+ configurations UNION runs of their systems, with correct pagination totals.
30
+
31
+ BREAKING CHANGES:
32
+
33
+ - The standalone `healthcheck.details` access rule is REMOVED. Roles that held
34
+ `details` without `configuration.manage` lose access to detailed run data;
35
+ grant them the manage rule (or a team grant on the configuration/system)
36
+ instead. Stale role rows referencing the removed rule are inert.
37
+ - `getDetailedAggregatedHistory` is `authenticated` (was `public`); anonymous
38
+ callers could never pass its access rule anyway.
39
+
40
+ - Updated dependencies [0cac684]
41
+ - @checkstack/healthcheck-common@1.11.0
42
+ - @checkstack/healthcheck-frontend@0.28.0
43
+ - @checkstack/notification-frontend@0.7.1
44
+
45
+ ## 0.6.0
46
+
47
+ ### Minor Changes
48
+
49
+ - 5236e41: Scope anomaly rows by (check, environment), completing the deferred follow-up
50
+ from the per-environment work in #375 (which env-scoped only baselines).
51
+
52
+ Previously the `anomalies` table was cross-environment: the inline spike
53
+ detector and the drift evaluator located and created the open row by
54
+ `(systemId, configurationId, fieldPath, kind)` with no environment predicate.
55
+ When a `(system, configuration)` assignment fanned out to multiple environments,
56
+ a healthy value in environment A shared one row with an anomaly in environment B,
57
+ so one env could mask (or merge with) another.
58
+
59
+ - **Schema.** New nullable `anomalies.environment_id` column (migration
60
+ `0007_uneven_trauma.sql`, a single `ADD COLUMN`). No unique constraint is
61
+ added: the table intentionally allows multiple rows per identity tuple (a
62
+ `recovered` historical row plus a fresh active row), so uniqueness would break
63
+ the state machine.
64
+ - **Detection.** The spike detector (from the `checkCompleted` hook) and the
65
+ drift evaluator (from the analyzer's per-environment loop) now locate/create
66
+ the open row by `(systemId, configurationId, environmentId, fieldPath, kind)`,
67
+ matching `environment_id = <id>` when present or `IS NULL` for the env-less
68
+ slice - mirroring the per-environment baseline lookup.
69
+ - **Reads.** `getAnomalies` gains an optional `environmentId` tristate filter
70
+ (`undefined` = all envs, `null` = env-less slice, string = that env), and both
71
+ `AnomalyDto` and `getActiveSignalAnomalies` surface `environmentId`. The
72
+ system-detail widget renders an environment pill on env-scoped anomaly rows.
73
+ - **Notifications.** An env-scoped anomaly appends its environment id to the
74
+ collapse key, so two failing environments render as two independent cards
75
+ instead of collapsing into one. The env-less slice keeps the pre-feature
76
+ two-segment key. Mutes stay env-agnostic (per system / per field).
77
+
78
+ BREAKING (semantics, not types; BETA so minor only):
79
+
80
+ - **Anomaly row identity now includes `environmentId`.** For a fanned-out check,
81
+ an anomaly in one environment is a distinct row from another environment. Any
82
+ code that assumed a single anomaly row per `(system, config, field, kind)`
83
+ must account for the environment dimension.
84
+ - **`AnomalyDto` and `getActiveSignalAnomalies` rows carry a new
85
+ `environmentId: string | null` field**, and `getAnomalies` accepts a new
86
+ optional `environmentId` filter. Additive on the wire; consumers that reject
87
+ unknown fields should be updated.
88
+ - **Upgrade behaviour.** Existing rows backfill to `null` (the env-less slice)
89
+ and stay until they recover; the next detection tick opens fresh
90
+ per-environment rows for fanned-out checks. This mirrors how #375 handled
91
+ baselines.
92
+
93
+ State and scale: the anomaly state lives entirely in the shared `anomalies`
94
+ Postgres table. `environmentId` is just another column on the row, so every pod
95
+ reads the same per-`(system, config, env, field, kind)` state - no pod-local
96
+ state, and reads return the same answer on every pod. The baseline cache key
97
+ already carries the env segment (#375), so there is no cross-env cache shadowing.
98
+
99
+ ### Patch Changes
100
+
101
+ - Updated dependencies [52c55bf]
102
+ - Updated dependencies [5236e41]
103
+ - Updated dependencies [d1b71b6]
104
+ - Updated dependencies [0d912a3]
105
+ - Updated dependencies [a07b375]
106
+ - Updated dependencies [d9f4654]
107
+ - Updated dependencies [b45be8e]
108
+ - Updated dependencies [d9f4654]
109
+ - Updated dependencies [3420d24]
110
+ - Updated dependencies [dea02f0]
111
+ - Updated dependencies [21e0d88]
112
+ - Updated dependencies [52c55bf]
113
+ - Updated dependencies [935d34e]
114
+ - Updated dependencies [e430fbe]
115
+ - Updated dependencies [eab80e3]
116
+ - Updated dependencies [259b93c]
117
+ - Updated dependencies [53666a7]
118
+ - Updated dependencies [b3b547c]
119
+ - Updated dependencies [d2d49cf]
120
+ - Updated dependencies [0d912a3]
121
+ - Updated dependencies [0d912a3]
122
+ - Updated dependencies [692fa18]
123
+ - @checkstack/anomaly-common@1.6.0
124
+ - @checkstack/healthcheck-common@1.10.0
125
+ - @checkstack/healthcheck-frontend@0.27.0
126
+ - @checkstack/notification-common@1.5.0
127
+ - @checkstack/notification-frontend@0.7.0
128
+ - @checkstack/ui@1.22.0
129
+ - @checkstack/frontend-api@0.13.0
130
+ - @checkstack/common@0.19.0
131
+ - @checkstack/catalog-common@2.6.0
132
+ - @checkstack/signal-frontend@0.3.2
133
+
3
134
  ## 0.5.18
4
135
 
5
136
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/anomaly-frontend",
3
- "version": "0.5.18",
3
+ "version": "0.6.1",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -16,16 +16,16 @@
16
16
  "lint:code": "eslint . --max-warnings 0"
17
17
  },
18
18
  "dependencies": {
19
- "@checkstack/anomaly-common": "1.5.4",
20
- "@checkstack/catalog-common": "2.5.0",
21
- "@checkstack/common": "0.18.0",
22
- "@checkstack/frontend-api": "0.12.1",
23
- "@checkstack/healthcheck-common": "1.9.0",
24
- "@checkstack/healthcheck-frontend": "0.26.1",
25
- "@checkstack/notification-common": "1.4.2",
26
- "@checkstack/notification-frontend": "0.6.4",
27
- "@checkstack/signal-frontend": "0.3.1",
28
- "@checkstack/ui": "1.21.0",
19
+ "@checkstack/anomaly-common": "1.6.0",
20
+ "@checkstack/catalog-common": "2.6.0",
21
+ "@checkstack/common": "0.19.0",
22
+ "@checkstack/frontend-api": "0.13.0",
23
+ "@checkstack/healthcheck-common": "1.11.0",
24
+ "@checkstack/healthcheck-frontend": "0.28.0",
25
+ "@checkstack/notification-common": "1.5.0",
26
+ "@checkstack/notification-frontend": "0.7.1",
27
+ "@checkstack/signal-frontend": "0.3.2",
28
+ "@checkstack/ui": "1.22.0",
29
29
  "date-fns": "^4.4.0",
30
30
  "lucide-react": "^1.17.0",
31
31
  "react": "19.2.7",
@@ -33,7 +33,7 @@
33
33
  "zod": "^4.2.1"
34
34
  },
35
35
  "devDependencies": {
36
- "@checkstack/scripts": "0.6.5",
36
+ "@checkstack/scripts": "0.7.0",
37
37
  "@checkstack/tsconfig": "0.0.7",
38
38
  "@types/react": "^19.0.0",
39
39
  "typescript": "^5.0.0"
@@ -47,6 +47,7 @@ export const AnomalySignalsFiller: React.FC<Props> = ({
47
47
  .map((a) => ({
48
48
  systemId: a.systemId,
49
49
  configurationId: a.configurationId,
50
+ environmentId: a.environmentId,
50
51
  fieldPath: a.fieldPath,
51
52
  startedAt: a.startedAt,
52
53
  state: a.state,
@@ -59,8 +60,10 @@ export const AnomalySignalsFiller: React.FC<Props> = ({
59
60
  systemId: row.systemId,
60
61
  configurationId: row.configurationId,
61
62
  }),
62
- // The history detail page is gated; render as text for users without it.
63
- accessRule: healthCheckAccess.details,
63
+ // The history detail page is a manager surface; render as text for
64
+ // users without global healthcheck manage (a signal's accessRule is a
65
+ // global check - team-scoped managers reach history via their pages).
66
+ accessRule: healthCheckAccess.configuration.manage,
64
67
  });
65
68
  }, [confirmed, suspicious, systemIds]);
66
69
 
@@ -5,7 +5,7 @@ import {
5
5
  accessApiRef,
6
6
  type SlotContext,
7
7
  } from "@checkstack/frontend-api";
8
- import { SystemDetailsSlot } from "@checkstack/catalog-common";
8
+ import { SystemDetailsSlot, CatalogApi } from "@checkstack/catalog-common";
9
9
  import {
10
10
  AnomalyApi,
11
11
  anomalyAccess,
@@ -33,6 +33,7 @@ import {
33
33
  Bell,
34
34
  BellOff,
35
35
  EyeOff,
36
+ Layers,
36
37
  } from "lucide-react";
37
38
  import { formatDistanceToNow } from "date-fns";
38
39
  import { Link } from "react-router-dom";
@@ -123,6 +124,7 @@ function humanizeCollectorSource(strategyId: string, collectorId: string): strin
123
124
  function AnomalyRow({
124
125
  anomaly,
125
126
  systemId,
127
+ environmentLabel,
126
128
  isMuted,
127
129
  onToggleMute,
128
130
  isToggling,
@@ -133,6 +135,12 @@ function AnomalyRow({
133
135
  }: {
134
136
  anomaly: AnomalyDto;
135
137
  systemId: string;
138
+ /**
139
+ * Resolved environment label for an env-scoped anomaly (rendered as a pill
140
+ * next to the field name). `undefined` for the env-less slice / cross-env
141
+ * feed - no pill, matching the pre-per-env single-row look.
142
+ */
143
+ environmentLabel?: string;
136
144
  isMuted: boolean;
137
145
  onToggleMute: (fieldPath: string, isMuted: boolean) => void;
138
146
  isToggling: boolean;
@@ -198,6 +206,12 @@ function AnomalyRow({
198
206
  {parsed.source}
199
207
  </span>
200
208
  ) : undefined}
209
+ {environmentLabel ? (
210
+ <span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-border/60 bg-surface-inset px-1.5 py-0.5 text-[10px] text-muted-foreground">
211
+ <Layers className="h-2.5 w-2.5" />
212
+ {environmentLabel}
213
+ </span>
214
+ ) : undefined}
201
215
  {isMuted && (
202
216
  <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">
203
217
  <BellOff className="h-2.5 w-2.5" />
@@ -299,6 +313,7 @@ function AnomalyRow({
299
313
 
300
314
  export const SystemAnomalyWidget: React.FC<Props> = ({ system }) => {
301
315
  const anomalyClient = usePluginClient(AnomalyApi);
316
+ const catalogClient = usePluginClient(CatalogApi);
302
317
  const toast = useToast();
303
318
  const accessApi = useApi(accessApiRef);
304
319
  // Muting notifications is a per-user preference any LOGGED-IN user may set
@@ -329,6 +344,19 @@ export const SystemAnomalyWidget: React.FC<Props> = ({ system }) => {
329
344
  { staleTime: 30_000 },
330
345
  );
331
346
 
347
+ // Resolve env names for env-scoped anomaly rows (two envs of the same field
348
+ // now surface as two rows). Same query the healthcheck overview/drawer issue,
349
+ // so the cache entry is shared when those surfaces mount alongside this one.
350
+ const { data: systemEnvironments = [] } =
351
+ catalogClient.getSystemEnvironments.useQuery(
352
+ { systemId: system.id },
353
+ { staleTime: 30_000 },
354
+ );
355
+ const envNameById = React.useMemo(
356
+ () => new Map(systemEnvironments.map((e) => [e.id, e.name])),
357
+ [systemEnvironments],
358
+ );
359
+
332
360
  const mutedFields = React.useMemo(
333
361
  () => new Set(mutes.map((m) => m.fieldPath)),
334
362
  [mutes],
@@ -502,6 +530,12 @@ export const SystemAnomalyWidget: React.FC<Props> = ({ system }) => {
502
530
  key={anomaly.id}
503
531
  anomaly={anomaly}
504
532
  systemId={system.id}
533
+ environmentLabel={
534
+ anomaly.environmentId
535
+ ? (envNameById.get(anomaly.environmentId) ??
536
+ anomaly.environmentId)
537
+ : undefined
538
+ }
505
539
  isMuted={isSystemMuted || mutedFields.has(anomaly.fieldPath)}
506
540
  onToggleMute={handleToggleMute}
507
541
  isToggling={isToggling}