@checkstack/catalog-common 2.2.3 → 2.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 +86 -0
- package/package.json +7 -7
- package/src/access.ts +20 -0
- package/src/rpc-contract.test.ts +83 -0
- package/src/rpc-contract.ts +132 -5
- package/src/slots.ts +159 -0
- package/src/types.ts +21 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,91 @@
|
|
|
1
1
|
# @checkstack/catalog-common
|
|
2
2
|
|
|
3
|
+
## 2.3.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [13373ce]
|
|
8
|
+
- @checkstack/common@0.14.0
|
|
9
|
+
- @checkstack/auth-common@0.8.1
|
|
10
|
+
- @checkstack/frontend-api@0.7.1
|
|
11
|
+
- @checkstack/notification-common@1.3.1
|
|
12
|
+
|
|
13
|
+
## 2.3.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 9dcc848: Redesign the catalog into a group-first browse view and tabbed management tables, with inline health rollups.
|
|
18
|
+
|
|
19
|
+
- Browse view: the catalog home is a real read-only, scale-built experience - collapsible group sections (with member counts) plus a synthetic Ungrouped section, a shared toolbar (search, group/health/tag filters, density toggle), URL-backed view state (shareable deep links), polished empty states, and a manager-only "Manage catalog" link. Per-system status badges render through the existing `SystemStateBadgesSlot`; filtering is client-side over the loaded set.
|
|
20
|
+
- Management: redesigned as tabbed data tables (Systems / Groups / Environments) replacing the two-column drag-to-assign layout. Systems get multi-select + a bulk bar, inline health, and group + environment membership as removable chips with type-ahead pickers (portaled so they are never clipped); Groups get inline rename and member chips; Environments get a name / members / field-count table (CRUD gated by `catalog.environment.manage`). GitOps-locked rows stay read-only. Drag-and-drop (and `@dnd-kit` on this page) is removed; the management page also shares the browse toolbar.
|
|
21
|
+
- Inline health rollups: a new platform contract `CatalogBrowseHealthSlot` (`@checkstack/catalog-common`) - an additive optional slot catalog-frontend only consumes (a headless data boundary feeding group rollups + the health filter), with a catalog-owned `CatalogHealthStatus` vocabulary so catalog gains no health-plugin dependency. Group headers show a rollup pill derived from the reported status DATA (a system absent from the map is `"unknown"`, never healthy); all-healthy groups start collapsed. The health filter is wired on both toolbars and enables once a filler reports. healthcheck-frontend fills the slot by reusing dashboard-frontend's `SystemBadgeDataProvider`. When no health source is installed the slot is unfilled and the catalog stays fully functional.
|
|
22
|
+
|
|
23
|
+
This is a beta minor.
|
|
24
|
+
|
|
25
|
+
- 9dcc848: Redesign the dashboard as an extensible "needs attention" overview, and normalize system state badges.
|
|
26
|
+
|
|
27
|
+
The dashboard now surfaces ONLY systems that need attention (degraded, unhealthy, breaching/at-risk SLO, under an incident or active maintenance, anomalous, or with a dependency problem) and hides everything healthy. A compact header summarises fleet health and filters by severity; each problem renders as an elevated card with one row per issue that deep-links to where the issue originates. A calm "all clear" state shows when nothing needs attention, a live "recent activity" feed sits below, and a "View catalog" link replaces the duplicated system list.
|
|
28
|
+
|
|
29
|
+
New platform contract `SystemSignalsSlot` (`@checkstack/catalog-common`): a headless, render-once slot where any plugin bulk-fetches and reports structured `SystemSignal[]` per system via `onSignals(sourceId, map)`. The dashboard aggregates every source agnostic to which plugins contribute; each core reliability plugin (healthcheck, incident, SLO, maintenance, anomaly, dependency) ships a filler, and third-party plugins add new per-system state the same way with no dashboard change. Signals carry an `iconName` rendered via `DynamicIcon` so the contract stays React-free. The dashboard's old summary tiles and overview sheets are removed, so it no longer depends on those plugins' packages. The group "subscribe" control moved onto the catalog browse page's group headers.
|
|
30
|
+
|
|
31
|
+
System state badges are normalized into one icon-only `@checkstack/ui` `StatusBadge` primitive - a small tinted icon chip with the full label on hover/focus (and via `aria-label`). Each signal uses its feature's navbar icon (health = Activity, incident = AlertTriangle, SLO = Target, maintenance = Wrench, dependency = GitBranch; anomaly = ChartSpline). Badges self-sort by severity via CSS `order` (error -> warn -> info), tooltips are scoped to a named group, and in catalog browse rows the cluster moved to the right edge.
|
|
32
|
+
|
|
33
|
+
This is a beta minor.
|
|
34
|
+
|
|
35
|
+
- 9dcc848: Add environments as a first-class catalog primitive, with per-environment health-check fan-out, config templating, per-environment reactive health, and script run-context exposure.
|
|
36
|
+
|
|
37
|
+
- Catalog primitive: an environment is a sibling of groups - a named, instance-global record carrying free-form custom fields (baseUrl, region, tier, ...) that any system can belong to many-to-many. New `environments` + `systems_environments` tables, `EnvironmentSchema` + create/update schemas, `EntityService` environment CRUD and membership joins, RPC endpoints gated by a new `catalogAccess.environment` access rule, a GitOps `Environment` kind + `System.environments` extension, and frontend management (an `EnvironmentEditor`, an Environments management panel, and a per-system environment picker). The Environments card's Add/Edit/Delete affordances are gated on `catalogAccess.environment.manage`.
|
|
38
|
+
- Per-environment fan-out: run identity becomes `(systemId, configurationId, environmentId)`. Runs, aggregates, and state transitions gain a nullable `environmentId`. The health-check assignment gains an `environmentIds` selector with three modes (All / Specific / None; `null` and `[]` are distinct). The queue executor resolves the effective environment set via the catalog `resolveSystemEnvironments` read and executes one isolated run per environment.
|
|
39
|
+
- Config templating: a new `x-templatable` config-field marker renders a string field through the template engine at execute time, against `{ environment, check, system }`. A shared `renderTemplatableConfig` and a `renderTemplatePreview` helper (re-exported from `@checkstack/template-engine`) keep editor previews identical to the run-time render. The HTTP collector's `url`, `headers[].value`, and `body` are templatable, rendered per environment (the strategy client build moves inside the per-env loop); the `url`'s `.url()` validation moves post-render. Secrets resolve before templating; a field marked both secret and `x-templatable` is rejected at plugin load. `DynamicForm` shows a live "Preview" line, and the catalog `EnvironmentPreviewPicker` ("Preview as: <environment>") drives it in the collector editor (only when the schema has a templatable field).
|
|
40
|
+
- Script run-context: `CollectorRunContext` gains an optional `environment` field (`{ id, name, fields }`, metadata only). Shell collectors receive `CHECKSTACK_ENV_ID` / `_NAME` / `CHECKSTACK_ENV_<FIELD>` vars; inline TS collectors read `globalThis.context.environment`; the editor test panel mirrors both. The env-less path is unchanged.
|
|
41
|
+
- Per-environment reactive health (see BREAKING below), env-keyed read/write paths, env-qualified serialization locks, an optional `trigger.payload.environmentId`, per-environment isolation, and an `ENVIRONMENT_RESOLUTION_FAILED` signal when catalog resolution degrades to a single env-less run.
|
|
42
|
+
|
|
43
|
+
BREAKING CHANGES: the reactive `health` entity's id-shape and cardinality change. It now encodes two views: per-environment (id `"<systemId>::<environmentId>"`) and a system rollup (id `"<systemId>"`, the worst status across environments + env-less runs). The rollup PRESERVES the pre-existing system-level contract - dashboards, status badges, and automations referencing health by `systemId` keep working without re-authoring - but the entity's contract surface changed (new id-shape, higher cardinality, new payload field), so it is flagged breaking. `getBulkHealthState` parses env-qualified ids and keys results by the original id.
|
|
44
|
+
|
|
45
|
+
State and scale: membership and custom fields live only in catalog Postgres and are re-read every tick via the cross-plugin RPC; env-keyed health reads from shared `health_check_runs` / aggregates / transitions (compute-on-read). Every pod resolves the same effective set and the same per-environment health. No pod-local environment state.
|
|
46
|
+
|
|
47
|
+
Also: `unwrapSchema` in `zod-config.ts` loops instead of single-pass-stripping so multi-layer wrappers (`.optional().default()`) still resolve `x-templatable` meta. The env-less `{{ environment.* }}` run notice logs at `debug` (a legitimate recurring configuration), while the post-render HTTP `.url()` check still fails a genuinely-broken empty render with a clear "Rendered URL is invalid" error.
|
|
48
|
+
|
|
49
|
+
This is a beta minor.
|
|
50
|
+
|
|
51
|
+
- 9dcc848: Input-validation and error-mapping hardening found by a fuzzing pass against the built container.
|
|
52
|
+
|
|
53
|
+
- backend: a Postgres driver error caused by bad client input no longer surfaces as a `500`. The `/api` and `/rest` dispatchers now map the relevant SQLSTATE classes to the correct status - `22P02`/`22003`/`22001`/`22007` (malformed/out-of-range/over-long/bad-date value), `23502`/`23503`/`23514` (missing/dangling/check-failed) to `400`, and `23505` (unique violation) to `409` - and log them at `warn` (client mistake), not `error`. The client-facing message is generic so column/constraint names are never leaked; genuine unknown faults still log at `error` and 500. Previously a `where id = $1` with a non-uuid `$1` (or an over-long string, or a foreign-key miss in `addSystemToGroup`) reached the driver and 500'd, making routine probing look like a server outage and burying real 500s.
|
|
54
|
+
- slo-common: **fixes a stored cluster-wide DoS.** `windowDays` was accepted up to `2^53`, but the SLO engine derives window boundaries with `Date(now - windowDays * 86_400_000)` - a large value overflows past the max representable `Date` and yields `Invalid Date`. That objective committed fine, then every subsequent read of the system's objectives threw `RangeError: Invalid time value` during serialization (a 500 readable by anyone with SLO read access, on any pod). `windowDays` is now bounded to 1..3650 days at the contract, the GitOps `kind: SLO` spec, and the update path via a single shared `SloWindowDaysSchema`, so the poison row can never be created.
|
|
55
|
+
- slo-common + healthcheck-common: SLO `getDailySnapshots` and the healthcheck history endpoints (`getHistory`, `getDetailedHistory`, `getAggregatedHistory`, `getDetailedAggregatedHistory`, `getRunsForAnalysis`) declared their `startDate`/`endDate` params as `z.date()`, which a `/rest/...` string param can never satisfy - so those endpoints 400'd on the entire REST surface. They now use `z.coerce.date()`, accepting both the REST string shape and the native RPC `Date`.
|
|
56
|
+
- healthcheck-common: `intervalSeconds` was `z.number().min(1)` with no `.int()` and no upper bound, so a fractional or out-of-range value reached the DB and failed at insert (the column is a 32-bit int). It is now `.int().min(1).max(2_592_000)` (1 second .. 30 days), applied to both create and update (the update schema is the create partial).
|
|
57
|
+
- catalog-common: system/group/environment names were bare `z.string()` (environment was `.min(1)` only), so empty, whitespace-only, and 100KB+ names reached the DB - the huge ones surfaced as 500s when parameter binding blew up. Names are now `trim().min(1).max(200)` via a shared schema.
|
|
58
|
+
|
|
59
|
+
**BREAKING:** `getSystemContacts` is now `userType: "authenticated"` (was `"public"`). System contacts carry PII (user id, name, email); the public read leaked them to anonymous status-page visitors. Anonymous callers now receive `401` for this one endpoint; the system detail page already renders "No contacts assigned" for anonymous viewers, so the UI degrades gracefully. All other catalog reads remain public.
|
|
60
|
+
|
|
61
|
+
- catalog-frontend: the system detail page skips the `getSystemContacts` request entirely for anonymous viewers (it would now `401`) and falls back to the empty state.
|
|
62
|
+
|
|
63
|
+
This is a beta release: the breaking contact-visibility change ships as a minor bump per the beta versioning policy, not a major.
|
|
64
|
+
|
|
65
|
+
- 9dcc848: Align workspace dependency versions and migrate React Router to v7.
|
|
66
|
+
|
|
67
|
+
BREAKING CHANGES (React Router v7): All frontend packages now depend on `react-router-dom@^7.16.0`. Previously the workspace declared four divergent ranges (`^6.20.0`, `^6.22.0`, `^7.1.1`, `^7.14.2`), which resolved both `react-router@6` and `react-router@7` into a single bundle. Everything is now unified on v7. The public imports the app uses (`BrowserRouter`, `Routes`, `Route`, `Link`, `NavLink`, `MemoryRouter`, `useNavigate`, `useParams`, `useSearchParams`, `useLocation`) are unchanged between v6 and v7, so no source rewrites were required - but any out-of-tree plugin still on react-router v6 should upgrade to v7 (see the React Router v6 -> v7 upgrade guide) to share the host's single router instance via the import map.
|
|
68
|
+
|
|
69
|
+
Other unified ranges (no API change): `react` -> `^18.3.1`, the `@orpc/*` family (`contract`, `server`, `client`, `tanstack-query`, `openapi`, `zod`) -> `^1.14.4`, and `better-auth` -> `^1.6.13`.
|
|
70
|
+
|
|
71
|
+
Removed the pre-rename `@orpc/react-query` leftover from `@checkstack/frontend-api`; its `createRouterUtils` / `RouterUtils` / `ProcedureUtils` now come from `@orpc/tanstack-query` (the package already in use).
|
|
72
|
+
|
|
73
|
+
Stale in-range runtime deps pulled up to current published versions: `hono` `^4.12.23`, `@tanstack/react-query` (+devtools) `^5.100.14`, `date-fns` `^4.4.0`, `jose` `^6.2.3`, `tar` `^7.5.16`, `semver` `^7.8.1`, `@xyflow/react` `^12.11.0`.
|
|
74
|
+
|
|
75
|
+
### Patch Changes
|
|
76
|
+
|
|
77
|
+
- Updated dependencies [9dcc848]
|
|
78
|
+
- Updated dependencies [9dcc848]
|
|
79
|
+
- Updated dependencies [9dcc848]
|
|
80
|
+
- Updated dependencies [9dcc848]
|
|
81
|
+
- Updated dependencies [9dcc848]
|
|
82
|
+
- Updated dependencies [9dcc848]
|
|
83
|
+
- Updated dependencies [9dcc848]
|
|
84
|
+
- @checkstack/auth-common@0.8.0
|
|
85
|
+
- @checkstack/notification-common@1.3.0
|
|
86
|
+
- @checkstack/common@0.13.0
|
|
87
|
+
- @checkstack/frontend-api@0.7.0
|
|
88
|
+
|
|
3
89
|
## 2.2.3
|
|
4
90
|
|
|
5
91
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/catalog-common",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -9,17 +9,17 @@
|
|
|
9
9
|
}
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@checkstack/common": "0.
|
|
13
|
-
"@checkstack/auth-common": "0.
|
|
14
|
-
"@checkstack/frontend-api": "0.
|
|
15
|
-
"@checkstack/notification-common": "1.
|
|
16
|
-
"@orpc/contract": "^1.
|
|
12
|
+
"@checkstack/common": "0.13.0",
|
|
13
|
+
"@checkstack/auth-common": "0.8.0",
|
|
14
|
+
"@checkstack/frontend-api": "0.7.0",
|
|
15
|
+
"@checkstack/notification-common": "1.3.0",
|
|
16
|
+
"@orpc/contract": "^1.14.4",
|
|
17
17
|
"zod": "^4.2.1"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"typescript": "^5.7.2",
|
|
21
21
|
"@checkstack/tsconfig": "0.0.7",
|
|
22
|
-
"@checkstack/scripts": "0.
|
|
22
|
+
"@checkstack/scripts": "0.4.0"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
25
|
"typecheck": "tsgo -b",
|
package/src/access.ts
CHANGED
|
@@ -44,6 +44,24 @@ export const catalogAccess = {
|
|
|
44
44
|
},
|
|
45
45
|
}),
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Environment access (global, no team-based filtering).
|
|
49
|
+
*
|
|
50
|
+
* Environments are an instance-wide catalog primitive (a sibling of
|
|
51
|
+
* groups): a free-form set of custom fields that any system can belong
|
|
52
|
+
* to many-to-many.
|
|
53
|
+
*/
|
|
54
|
+
environment: accessPair("environment", {
|
|
55
|
+
read: {
|
|
56
|
+
description: "View environments",
|
|
57
|
+
isDefault: true,
|
|
58
|
+
isPublic: true,
|
|
59
|
+
},
|
|
60
|
+
manage: {
|
|
61
|
+
description: "Create, update, and delete environments",
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
|
|
47
65
|
/**
|
|
48
66
|
* View access (global, user-only).
|
|
49
67
|
*/
|
|
@@ -66,6 +84,8 @@ export const catalogAccessRules = [
|
|
|
66
84
|
catalogAccess.system.manage,
|
|
67
85
|
catalogAccess.group.read,
|
|
68
86
|
catalogAccess.group.manage,
|
|
87
|
+
catalogAccess.environment.read,
|
|
88
|
+
catalogAccess.environment.manage,
|
|
69
89
|
catalogAccess.view.read,
|
|
70
90
|
catalogAccess.view.manage,
|
|
71
91
|
];
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ZodType } from "zod";
|
|
3
|
+
import { catalogContract } from "./rpc-contract";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Contract-level guards for the fuzzing-pass findings:
|
|
7
|
+
* - `getSystemContacts` leaked PII (userId/userName/userEmail) to anonymous
|
|
8
|
+
* callers because it was `userType: "public"`. It must be `authenticated`.
|
|
9
|
+
* - System/Group names were bare `z.string()`, so empty, whitespace-only, and
|
|
10
|
+
* 100KB+ names reached the DB (the huge ones surfaced as 500s).
|
|
11
|
+
*
|
|
12
|
+
* `~orpc` is the contract-procedure internals (same accessor the sandbox-policy
|
|
13
|
+
* access test uses); `meta.userType` and `inputSchema` are stable fields on it.
|
|
14
|
+
*/
|
|
15
|
+
function metaFor(procName: keyof typeof catalogContract): {
|
|
16
|
+
userType?: string;
|
|
17
|
+
} {
|
|
18
|
+
const proc = catalogContract[procName] as unknown as Record<string, unknown>;
|
|
19
|
+
const orpc = proc["~orpc"] as { meta?: { userType?: string } };
|
|
20
|
+
return orpc.meta ?? {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function inputSchemaFor(procName: keyof typeof catalogContract): ZodType {
|
|
24
|
+
const proc = catalogContract[procName] as unknown as Record<string, unknown>;
|
|
25
|
+
const orpc = proc["~orpc"] as { inputSchema?: ZodType };
|
|
26
|
+
if (!orpc.inputSchema) throw new Error(`${String(procName)} has no input`);
|
|
27
|
+
return orpc.inputSchema;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("getSystemContacts is gated to authenticated callers (PII)", () => {
|
|
31
|
+
test("userType is authenticated, not public", () => {
|
|
32
|
+
expect(metaFor("getSystemContacts").userType).toBe("authenticated");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("anonymous-readable catalog reads stay public (no over-correction)", () => {
|
|
36
|
+
expect(metaFor("getEntities").userType).toBe("public");
|
|
37
|
+
expect(metaFor("getSystem").userType).toBe("public");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("catalog name validation", () => {
|
|
42
|
+
const cases: Array<keyof typeof catalogContract> = [
|
|
43
|
+
"createSystem",
|
|
44
|
+
"createGroup",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
for (const procName of cases) {
|
|
48
|
+
test(`${String(procName)} rejects an empty name`, () => {
|
|
49
|
+
expect(inputSchemaFor(procName).safeParse({ name: "" }).success).toBe(
|
|
50
|
+
false,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test(`${String(procName)} rejects a whitespace-only name`, () => {
|
|
55
|
+
expect(inputSchemaFor(procName).safeParse({ name: " " }).success).toBe(
|
|
56
|
+
false,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test(`${String(procName)} rejects a name over 200 chars`, () => {
|
|
61
|
+
expect(
|
|
62
|
+
inputSchemaFor(procName).safeParse({ name: "a".repeat(201) }).success,
|
|
63
|
+
).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test(`${String(procName)} trims a valid name`, () => {
|
|
67
|
+
const parsed = inputSchemaFor(procName).safeParse({ name: " ok " });
|
|
68
|
+
expect(parsed.success).toBe(true);
|
|
69
|
+
if (parsed.success) {
|
|
70
|
+
expect((parsed.data as { name: string }).name).toBe("ok");
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test("updateSystem rejects a whitespace-only name when provided", () => {
|
|
76
|
+
const schema = inputSchemaFor("updateSystem");
|
|
77
|
+
expect(
|
|
78
|
+
schema.safeParse({ id: "s1", data: { name: " " } }).success,
|
|
79
|
+
).toBe(false);
|
|
80
|
+
// ...but omitting the name (partial update) is still allowed.
|
|
81
|
+
expect(schema.safeParse({ id: "s1", data: {} }).success).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/rpc-contract.ts
CHANGED
|
@@ -8,12 +8,26 @@ import {
|
|
|
8
8
|
SystemContactSchema,
|
|
9
9
|
ContactTypeSchema,
|
|
10
10
|
SystemLinkSchema,
|
|
11
|
+
EnvironmentSchema,
|
|
12
|
+
CreateEnvironmentSchema,
|
|
13
|
+
UpdateEnvironmentSchema,
|
|
11
14
|
} from "./types";
|
|
12
15
|
import { catalogAccess } from "./access";
|
|
13
16
|
|
|
17
|
+
// Shared catalog display-name validation. Bare `z.string()` previously let
|
|
18
|
+
// empty, whitespace-only, and unbounded (100KB+) names through to the DB - the
|
|
19
|
+
// huge ones surfaced as 500s (parameter binding blew up), the empty ones as
|
|
20
|
+
// confusing rows. Trim first so " " collapses to "" and fails `.min(1)`; cap at
|
|
21
|
+
// 200 chars (well inside the `systems.name` unique btree index limit).
|
|
22
|
+
const NameSchema = z
|
|
23
|
+
.string()
|
|
24
|
+
.trim()
|
|
25
|
+
.min(1, "Name is required")
|
|
26
|
+
.max(200, "Name must be at most 200 characters");
|
|
27
|
+
|
|
14
28
|
// Input schemas that match the service layer expectations
|
|
15
29
|
const CreateSystemInputSchema = z.object({
|
|
16
|
-
name:
|
|
30
|
+
name: NameSchema,
|
|
17
31
|
description: z.string().optional(),
|
|
18
32
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
19
33
|
});
|
|
@@ -21,21 +35,21 @@ const CreateSystemInputSchema = z.object({
|
|
|
21
35
|
const UpdateSystemInputSchema = z.object({
|
|
22
36
|
id: z.string(),
|
|
23
37
|
data: z.object({
|
|
24
|
-
name:
|
|
38
|
+
name: NameSchema.optional(),
|
|
25
39
|
description: z.string().nullable().optional(),
|
|
26
40
|
metadata: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
27
41
|
}),
|
|
28
42
|
});
|
|
29
43
|
|
|
30
44
|
const CreateGroupInputSchema = z.object({
|
|
31
|
-
name:
|
|
45
|
+
name: NameSchema,
|
|
32
46
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
33
47
|
});
|
|
34
48
|
|
|
35
49
|
const UpdateGroupInputSchema = z.object({
|
|
36
50
|
id: z.string(),
|
|
37
51
|
data: z.object({
|
|
38
|
-
name:
|
|
52
|
+
name: NameSchema.optional(),
|
|
39
53
|
metadata: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
40
54
|
}),
|
|
41
55
|
});
|
|
@@ -133,9 +147,13 @@ export const catalogContract = {
|
|
|
133
147
|
// SYSTEM CONTACTS MANAGEMENT
|
|
134
148
|
// ==========================================================================
|
|
135
149
|
|
|
150
|
+
// Gated to authenticated callers: contacts carry PII (userId, userName,
|
|
151
|
+
// userEmail). A "public" read leaked those to anonymous status-page visitors.
|
|
152
|
+
// The detail-page UI renders "No contacts assigned" when this returns empty,
|
|
153
|
+
// so anonymous viewers degrade gracefully rather than erroring.
|
|
136
154
|
getSystemContacts: proc({
|
|
137
155
|
operationType: "query",
|
|
138
|
-
userType: "
|
|
156
|
+
userType: "authenticated",
|
|
139
157
|
access: [catalogAccess.system.read],
|
|
140
158
|
instanceAccess: { idParam: "systemId" },
|
|
141
159
|
})
|
|
@@ -267,6 +285,89 @@ export const catalogContract = {
|
|
|
267
285
|
)
|
|
268
286
|
.output(z.object({ success: z.boolean() })),
|
|
269
287
|
|
|
288
|
+
// ==========================================================================
|
|
289
|
+
// ENVIRONMENT MANAGEMENT
|
|
290
|
+
// Instance-wide catalog primitive: free-form custom fields, M:N with systems.
|
|
291
|
+
// ==========================================================================
|
|
292
|
+
|
|
293
|
+
listEnvironments: proc({
|
|
294
|
+
operationType: "query",
|
|
295
|
+
userType: "public",
|
|
296
|
+
access: [catalogAccess.environment.read],
|
|
297
|
+
}).output(z.array(EnvironmentSchema)),
|
|
298
|
+
|
|
299
|
+
getEnvironment: proc({
|
|
300
|
+
operationType: "query",
|
|
301
|
+
userType: "public",
|
|
302
|
+
access: [catalogAccess.environment.read],
|
|
303
|
+
})
|
|
304
|
+
.input(z.object({ environmentId: z.string() }))
|
|
305
|
+
.output(EnvironmentSchema.nullable()),
|
|
306
|
+
|
|
307
|
+
createEnvironment: proc({
|
|
308
|
+
operationType: "mutation",
|
|
309
|
+
userType: "authenticated",
|
|
310
|
+
access: [catalogAccess.environment.manage],
|
|
311
|
+
})
|
|
312
|
+
.input(CreateEnvironmentSchema)
|
|
313
|
+
.output(EnvironmentSchema),
|
|
314
|
+
|
|
315
|
+
updateEnvironment: proc({
|
|
316
|
+
operationType: "mutation",
|
|
317
|
+
userType: "authenticated",
|
|
318
|
+
access: [catalogAccess.environment.manage],
|
|
319
|
+
})
|
|
320
|
+
.route({ method: "PATCH" })
|
|
321
|
+
.input(
|
|
322
|
+
z.object({
|
|
323
|
+
environmentId: z.string(),
|
|
324
|
+
data: UpdateEnvironmentSchema,
|
|
325
|
+
}),
|
|
326
|
+
)
|
|
327
|
+
.output(EnvironmentSchema),
|
|
328
|
+
|
|
329
|
+
deleteEnvironment: proc({
|
|
330
|
+
operationType: "mutation",
|
|
331
|
+
userType: "authenticated",
|
|
332
|
+
access: [catalogAccess.environment.manage],
|
|
333
|
+
})
|
|
334
|
+
.route({ method: "DELETE" })
|
|
335
|
+
.input(z.object({ environmentId: z.string() }))
|
|
336
|
+
.output(z.object({ success: z.boolean() })),
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Desired-set assignment of a system's environments. Diffs the supplied
|
|
340
|
+
* set against current membership: adds missing links, prunes stale ones
|
|
341
|
+
* (mirrors the GitOps System->environments reconcile).
|
|
342
|
+
*/
|
|
343
|
+
setSystemEnvironments: proc({
|
|
344
|
+
operationType: "mutation",
|
|
345
|
+
userType: "authenticated",
|
|
346
|
+
access: [catalogAccess.environment.manage],
|
|
347
|
+
instanceAccess: { idParam: "systemId" },
|
|
348
|
+
})
|
|
349
|
+
.input(
|
|
350
|
+
z.object({
|
|
351
|
+
systemId: z.string(),
|
|
352
|
+
environmentIds: z.array(z.string()),
|
|
353
|
+
}),
|
|
354
|
+
)
|
|
355
|
+
.output(z.object({ success: z.boolean() })),
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Returns the environments a system currently belongs to (with custom
|
|
359
|
+
* fields). Used by host plugins to render the per-system environment
|
|
360
|
+
* picker. Server-side join — no client-side filtering needed.
|
|
361
|
+
*/
|
|
362
|
+
getSystemEnvironments: proc({
|
|
363
|
+
operationType: "query",
|
|
364
|
+
userType: "public",
|
|
365
|
+
access: [catalogAccess.environment.read],
|
|
366
|
+
instanceAccess: { idParam: "systemId" },
|
|
367
|
+
})
|
|
368
|
+
.input(z.object({ systemId: z.string() }))
|
|
369
|
+
.output(z.array(EnvironmentSchema)),
|
|
370
|
+
|
|
270
371
|
// ==========================================================================
|
|
271
372
|
// VIEW MANAGEMENT (userType: "user")
|
|
272
373
|
// ==========================================================================
|
|
@@ -301,6 +402,32 @@ export const catalogContract = {
|
|
|
301
402
|
})
|
|
302
403
|
.input(z.object({ systemId: z.string() }))
|
|
303
404
|
.output(z.object({ groupIds: z.array(z.string()) })),
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Service-grade read of a system's current environments (id + name +
|
|
408
|
+
* custom fields). Called by the healthcheck plugin at run time to resolve
|
|
409
|
+
* the effective fan-out set. Backend-to-backend only.
|
|
410
|
+
*/
|
|
411
|
+
resolveSystemEnvironments: proc({
|
|
412
|
+
operationType: "query",
|
|
413
|
+
userType: "service",
|
|
414
|
+
access: [],
|
|
415
|
+
})
|
|
416
|
+
.input(z.object({ systemId: z.string() }))
|
|
417
|
+
.output(z.array(EnvironmentSchema)),
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Service-grade resolve of an explicit set of environment ids (the
|
|
421
|
+
* explicit-subset fan-out case). Unknown ids are silently dropped.
|
|
422
|
+
* Backend-to-backend only.
|
|
423
|
+
*/
|
|
424
|
+
resolveEnvironments: proc({
|
|
425
|
+
operationType: "query",
|
|
426
|
+
userType: "service",
|
|
427
|
+
access: [],
|
|
428
|
+
})
|
|
429
|
+
.input(z.object({ environmentIds: z.array(z.string()) }))
|
|
430
|
+
.output(z.array(EnvironmentSchema)),
|
|
304
431
|
};
|
|
305
432
|
|
|
306
433
|
// Export contract type
|
package/src/slots.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createSlot } from "@checkstack/frontend-api";
|
|
2
|
+
import type { IconName } from "@checkstack/common";
|
|
2
3
|
import type { System } from "./types";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -73,6 +74,164 @@ export const SystemStateBadgesSlot = createSlot<{ system: System }>(
|
|
|
73
74
|
"plugin.catalog.system-state-badges"
|
|
74
75
|
);
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Catalog-owned health status vocabulary for the browse-view rollup. These are
|
|
79
|
+
* the only values a slot filler may report through {@link CatalogBrowseHealthSlot}.
|
|
80
|
+
*
|
|
81
|
+
* This is deliberately catalog's OWN enum, not an import of healthcheck's
|
|
82
|
+
* status enum: the catalog browse view must not depend on healthcheck. A filler
|
|
83
|
+
* (e.g. healthcheck-frontend) maps its own status into these values. `"unknown"`
|
|
84
|
+
* is never reported by a filler — it is the catalog-side default for a system
|
|
85
|
+
* the filler did not report a status for (no health source, or no checks wired).
|
|
86
|
+
*/
|
|
87
|
+
export type CatalogHealthStatus = "healthy" | "degraded" | "unhealthy";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The shape a {@link CatalogBrowseHealthSlot} filler reports back to the catalog
|
|
91
|
+
* browse view: a per-system-id status map. Systems absent from the map are
|
|
92
|
+
* treated as `"unknown"` by the rollup (NEVER as healthy).
|
|
93
|
+
*/
|
|
94
|
+
export type CatalogHealthStatuses = Record<string, CatalogHealthStatus>;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Context passed to a {@link CatalogBrowseHealthSlot} filler.
|
|
98
|
+
*
|
|
99
|
+
* - `systemIds` — every system id currently visible in the browse view, so the
|
|
100
|
+
* filler can bulk-fetch their statuses in one request.
|
|
101
|
+
* - `onStatuses` — the filler reports the resolved statuses here. The catalog
|
|
102
|
+
* browse view derives its group-level rollup (worst-of) and powers the health
|
|
103
|
+
* filter from this DATA, NOT from any rendered badge: healthy systems emit no
|
|
104
|
+
* badge, so "all healthy" can only be derived from the reported map.
|
|
105
|
+
*
|
|
106
|
+
* The filler renders nothing visible — it is a headless data boundary. Per-system
|
|
107
|
+
* badges continue to come from {@link SystemStateBadgesSlot}.
|
|
108
|
+
*/
|
|
109
|
+
export interface CatalogBrowseHealthSlotContext {
|
|
110
|
+
systemIds: string[];
|
|
111
|
+
onStatuses: (statuses: CatalogHealthStatuses) => void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Optional platform contract for surfacing bulk system-health data inline on the
|
|
116
|
+
* catalog browse view WITHOUT coupling catalog to any health provider.
|
|
117
|
+
*
|
|
118
|
+
* - catalog-frontend only CONSUMES this slot: it renders the slot once (a headless
|
|
119
|
+
* data boundary) and feeds the reported statuses into its group rollups + health
|
|
120
|
+
* filter. When the slot is unfilled, group headers show counts only and the
|
|
121
|
+
* health filter is disabled — catalog stays fully functional with no health
|
|
122
|
+
* source installed.
|
|
123
|
+
* - A plugin FILLS this slot to supply statuses (e.g. healthcheck-frontend wraps
|
|
124
|
+
* dashboard-frontend's existing SystemBadgeDataProvider and reports the
|
|
125
|
+
* bulk-fetched health via `onStatuses`). All cross-plugin coupling lives on the
|
|
126
|
+
* filler side; catalog gains no new dependency.
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* // In a health provider plugin
|
|
130
|
+
* import { CatalogBrowseHealthSlot } from "@checkstack/catalog-common";
|
|
131
|
+
*
|
|
132
|
+
* extensions: [{
|
|
133
|
+
* id: "my-plugin.catalog-browse-health",
|
|
134
|
+
* slotId: CatalogBrowseHealthSlot.id,
|
|
135
|
+
* component: ({ systemIds, onStatuses }) => (
|
|
136
|
+
* <MyBulkHealthReporter systemIds={systemIds} onStatuses={onStatuses} />
|
|
137
|
+
* ),
|
|
138
|
+
* }]
|
|
139
|
+
*/
|
|
140
|
+
export const CatalogBrowseHealthSlot =
|
|
141
|
+
createSlot<CatalogBrowseHealthSlotContext>(
|
|
142
|
+
"plugin.catalog.browse-health"
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Severity tone of a dashboard {@link SystemSignal}. Drives the signal's colour,
|
|
147
|
+
* its sort order in the overview (error before warn before info), and the
|
|
148
|
+
* severity counts shown in the overview header.
|
|
149
|
+
*/
|
|
150
|
+
export type SystemSignalTone = "error" | "warn" | "info";
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* One piece of "needs attention" state a plugin reports about a system for the
|
|
154
|
+
* dashboard overview. A single source may emit several signals for one system
|
|
155
|
+
* (e.g. two open incidents). The dashboard aggregates signals across ALL
|
|
156
|
+
* plugins to decide which systems to surface, how to sort them (worst tone
|
|
157
|
+
* first), what to count in the header, and renders each one as a deep-linking
|
|
158
|
+
* row pointing at the page the issue originates from.
|
|
159
|
+
*/
|
|
160
|
+
export interface SystemSignal {
|
|
161
|
+
/**
|
|
162
|
+
* Stable id of the contributing source, e.g. "incident" / "slo" /
|
|
163
|
+
* "healthcheck". Used to de-duplicate a source's contribution when it
|
|
164
|
+
* re-reports (see {@link SystemSignalsSlotContext.onSignals}).
|
|
165
|
+
*/
|
|
166
|
+
source: string;
|
|
167
|
+
/** Severity tone. */
|
|
168
|
+
tone: SystemSignalTone;
|
|
169
|
+
/** Short label, e.g. "Critical incident". */
|
|
170
|
+
label: string;
|
|
171
|
+
/** Optional longer context, e.g. the incident title or "2 of 3 checks failing". */
|
|
172
|
+
detail?: string;
|
|
173
|
+
/**
|
|
174
|
+
* Deep link (resolved route path) to where the issue originates. Omit when
|
|
175
|
+
* there is no more specific page than the system itself.
|
|
176
|
+
*/
|
|
177
|
+
href?: string;
|
|
178
|
+
/** ISO timestamp the signal started — shown as a "since" hint and used as a sort tie-break. */
|
|
179
|
+
since?: string;
|
|
180
|
+
/** Lucide icon name (PascalCase), rendered by `@checkstack/ui`'s `DynamicIcon`. */
|
|
181
|
+
iconName?: IconName;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The per-system-id signal map a {@link SystemSignalsSlot} filler reports for
|
|
186
|
+
* the systems it was handed. Systems absent from the map have no signal from
|
|
187
|
+
* that source (i.e. healthy as far as that source is concerned).
|
|
188
|
+
*/
|
|
189
|
+
export type SystemSignalsMap = Record<string, SystemSignal[]>;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Context passed to a {@link SystemSignalsSlot} filler.
|
|
193
|
+
*
|
|
194
|
+
* - `systemIds` — every system in the overview, so the filler can bulk-fetch
|
|
195
|
+
* their state in a single request (no N+1).
|
|
196
|
+
* - `onSignals` — the filler reports its resolved per-system signals here,
|
|
197
|
+
* tagged with its own stable `sourceId`. Re-reporting with the same
|
|
198
|
+
* `sourceId` REPLACES that source's previous contribution (so a source that
|
|
199
|
+
* reports an empty map clears its signals). The dashboard derives which
|
|
200
|
+
* systems need attention purely from this DATA — healthy systems are simply
|
|
201
|
+
* absent from every source's map.
|
|
202
|
+
*
|
|
203
|
+
* The filler renders nothing visible — it is a headless data boundary, exactly
|
|
204
|
+
* like {@link CatalogBrowseHealthSlot}.
|
|
205
|
+
*/
|
|
206
|
+
export interface SystemSignalsSlotContext {
|
|
207
|
+
systemIds: string[];
|
|
208
|
+
onSignals: (sourceId: string, signals: SystemSignalsMap) => void;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Extensible platform contract for the dashboard "needs attention" overview.
|
|
213
|
+
* Any plugin FILLS this slot to contribute per-system state signals; the
|
|
214
|
+
* dashboard CONSUMES it (rendering the slot once, headless) and aggregates
|
|
215
|
+
* every source's signals to surface, sort, count, and deep-link problem
|
|
216
|
+
* systems. A new plugin adds a whole new kind of state to the overview just by
|
|
217
|
+
* filling this slot — no dashboard change required.
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* // In your plugin
|
|
221
|
+
* import { SystemSignalsSlot } from "@checkstack/catalog-common";
|
|
222
|
+
*
|
|
223
|
+
* extensions: [{
|
|
224
|
+
* id: "my-plugin.system-signals",
|
|
225
|
+
* slotId: SystemSignalsSlot.id,
|
|
226
|
+
* component: ({ systemIds, onSignals }) => (
|
|
227
|
+
* <MyBulkSignalReporter systemIds={systemIds} onSignals={onSignals} />
|
|
228
|
+
* ),
|
|
229
|
+
* }]
|
|
230
|
+
*/
|
|
231
|
+
export const SystemSignalsSlot = createSlot<SystemSignalsSlotContext>(
|
|
232
|
+
"plugin.catalog.system-signals"
|
|
233
|
+
);
|
|
234
|
+
|
|
76
235
|
/**
|
|
77
236
|
* Slot for extending the System Editor dialog with additional sections.
|
|
78
237
|
* Only rendered when editing an existing system (not during creation).
|
package/src/types.ts
CHANGED
|
@@ -66,6 +66,27 @@ export const GroupSchema = z.object({
|
|
|
66
66
|
});
|
|
67
67
|
export type Group = z.infer<typeof GroupSchema>;
|
|
68
68
|
|
|
69
|
+
export const EnvironmentSchema = z.object({
|
|
70
|
+
id: z.string(),
|
|
71
|
+
name: z.string(),
|
|
72
|
+
description: z.string().nullable(),
|
|
73
|
+
systemIds: z.array(z.string()), // Required field from the service layer
|
|
74
|
+
metadata: z.record(z.string(), z.unknown()).nullable(),
|
|
75
|
+
createdAt: z.date(),
|
|
76
|
+
updatedAt: z.date(),
|
|
77
|
+
});
|
|
78
|
+
export type Environment = z.infer<typeof EnvironmentSchema>;
|
|
79
|
+
|
|
80
|
+
export const CreateEnvironmentSchema = z.object({
|
|
81
|
+
name: z.string().trim().min(1).max(200),
|
|
82
|
+
description: z.string().optional(),
|
|
83
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
84
|
+
});
|
|
85
|
+
export type CreateEnvironment = z.infer<typeof CreateEnvironmentSchema>;
|
|
86
|
+
|
|
87
|
+
export const UpdateEnvironmentSchema = CreateEnvironmentSchema.partial();
|
|
88
|
+
export type UpdateEnvironment = z.infer<typeof UpdateEnvironmentSchema>;
|
|
89
|
+
|
|
69
90
|
export const ViewSchema = z.object({
|
|
70
91
|
id: z.string(),
|
|
71
92
|
name: z.string(),
|