@checkstack/satellite-backend 0.2.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 ADDED
@@ -0,0 +1,44 @@
1
+ # @checkstack/satellite-backend
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 26d8bae: Distributed satellite health checks and Assignment IDE page
8
+
9
+ **Satellite System**
10
+
11
+ - New `satellite-backend`, `satellite-common`, `satellite-frontend`, and `satellite` agent packages for distributed health check execution
12
+ - WebSocket-based satellite connectivity with authentication, heartbeats, and live configuration push
13
+ - Satellite management UI with create dialog, status badges, and list page
14
+
15
+ **Live Configuration Updates**
16
+
17
+ - Added `assignmentChanged` hook to `healthcheck-backend` for cross-plugin communication
18
+ - `satellite-backend` subscribes to assignment changes and pushes config updates to connected satellites in real-time
19
+
20
+ **Assignment IDE Page**
21
+
22
+ - Replaced the 1028-line modal-based `SystemHealthCheckAssignment` component with a full-page IDE layout
23
+ - New modular components: `AssignmentTree`, `GeneralPanel`, `ThresholdsPanel`, `RetentionPanel`, `ExecutionPanel`
24
+ - Added unassign capability and sorted assignment lists for stable ordering
25
+
26
+ **Shared IDE Primitives**
27
+
28
+ - Extracted `IDETreeNode`, `IDETreeSection`, `IDEStatusBar`, `IDELayout` to `@checkstack/ui` for cross-plugin reuse
29
+ - Migrated existing health check IDE editor to use shared primitives
30
+
31
+ **Infrastructure**
32
+
33
+ - Added `Dockerfile.satellite` for containerized satellite deployment
34
+ - WebSocket route registry in `@checkstack/backend` and `@checkstack/backend-api`
35
+
36
+ ### Patch Changes
37
+
38
+ - Updated dependencies [26d8bae]
39
+ - Updated dependencies [26d8bae]
40
+ - @checkstack/healthcheck-common@0.11.0
41
+ - @checkstack/healthcheck-backend@0.13.0
42
+ - @checkstack/satellite-common@0.2.0
43
+ - @checkstack/backend-api@0.12.0
44
+ - @checkstack/queue-api@0.2.13
@@ -0,0 +1,10 @@
1
+ CREATE TABLE "satellites" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "name" text NOT NULL,
4
+ "region" text NOT NULL,
5
+ "tags" jsonb DEFAULT '{}'::jsonb NOT NULL,
6
+ "token_hash" text NOT NULL,
7
+ "last_heartbeat_at" timestamp,
8
+ "version" text,
9
+ "created_at" timestamp DEFAULT now() NOT NULL
10
+ );
@@ -0,0 +1,83 @@
1
+ {
2
+ "id": "8c89df94-7c41-44ec-9197-02edea2f23f2",
3
+ "prevId": "00000000-0000-0000-0000-000000000000",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.satellites": {
8
+ "name": "satellites",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "uuid",
14
+ "primaryKey": true,
15
+ "notNull": true,
16
+ "default": "gen_random_uuid()"
17
+ },
18
+ "name": {
19
+ "name": "name",
20
+ "type": "text",
21
+ "primaryKey": false,
22
+ "notNull": true
23
+ },
24
+ "region": {
25
+ "name": "region",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": true
29
+ },
30
+ "tags": {
31
+ "name": "tags",
32
+ "type": "jsonb",
33
+ "primaryKey": false,
34
+ "notNull": true,
35
+ "default": "'{}'::jsonb"
36
+ },
37
+ "token_hash": {
38
+ "name": "token_hash",
39
+ "type": "text",
40
+ "primaryKey": false,
41
+ "notNull": true
42
+ },
43
+ "last_heartbeat_at": {
44
+ "name": "last_heartbeat_at",
45
+ "type": "timestamp",
46
+ "primaryKey": false,
47
+ "notNull": false
48
+ },
49
+ "version": {
50
+ "name": "version",
51
+ "type": "text",
52
+ "primaryKey": false,
53
+ "notNull": false
54
+ },
55
+ "created_at": {
56
+ "name": "created_at",
57
+ "type": "timestamp",
58
+ "primaryKey": false,
59
+ "notNull": true,
60
+ "default": "now()"
61
+ }
62
+ },
63
+ "indexes": {},
64
+ "foreignKeys": {},
65
+ "compositePrimaryKeys": {},
66
+ "uniqueConstraints": {},
67
+ "policies": {},
68
+ "checkConstraints": {},
69
+ "isRLSEnabled": false
70
+ }
71
+ },
72
+ "enums": {},
73
+ "schemas": {},
74
+ "sequences": {},
75
+ "roles": {},
76
+ "policies": {},
77
+ "views": {},
78
+ "_meta": {
79
+ "columns": {},
80
+ "schemas": {},
81
+ "tables": {}
82
+ }
83
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1776590977685,
9
+ "tag": "0000_melted_gargoyle",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ export default defineConfig({
4
+ schema: "./src/schema.ts",
5
+ out: "./drizzle",
6
+ dialect: "postgresql",
7
+ });
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@checkstack/satellite-backend",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "checkstack": {
7
+ "type": "backend"
8
+ },
9
+ "scripts": {
10
+ "typecheck": "tsc --noEmit",
11
+ "generate": "drizzle-kit generate",
12
+ "lint": "bun run lint:code",
13
+ "lint:code": "eslint . --max-warnings 0"
14
+ },
15
+ "dependencies": {
16
+ "@checkstack/backend-api": "0.11.1",
17
+ "@checkstack/satellite-common": "0.1.0",
18
+ "@checkstack/healthcheck-common": "0.10.1",
19
+ "@checkstack/signal-common": "0.1.9",
20
+ "@checkstack/healthcheck-backend": "0.12.1",
21
+ "@checkstack/common": "0.6.5",
22
+ "@checkstack/queue-api": "0.2.12",
23
+ "drizzle-orm": "^0.45.0",
24
+ "zod": "^4.2.1",
25
+ "@orpc/server": "^1.13.2"
26
+ },
27
+ "devDependencies": {
28
+ "@checkstack/drizzle-helper": "0.0.4",
29
+ "@checkstack/scripts": "0.1.2",
30
+ "@checkstack/test-utils-backend": "0.1.18",
31
+ "@checkstack/tsconfig": "0.0.5",
32
+ "@types/bun": "^1.0.0",
33
+ "typescript": "^5.0.0"
34
+ }
35
+ }
@@ -0,0 +1,31 @@
1
+ import type { SatelliteAssignment } from "@checkstack/satellite-common";
2
+
3
+ /**
4
+ * Builds the full configuration payload for a satellite.
5
+ * Queries systemHealthChecks for associations that reference this satellite's ID,
6
+ * then joins with healthCheckConfigurations to build the full assignment list.
7
+ */
8
+ export class ConfigRelay {
9
+ constructor(
10
+ /**
11
+ * The healthcheck database connection.
12
+ * This is passed in from the healthcheck-backend's schema context.
13
+ */
14
+ private getHealthcheckDb: () => Promise<{
15
+ getAssignmentsForSatellite: (
16
+ satelliteId: string,
17
+ ) => Promise<SatelliteAssignment[]>;
18
+ }>,
19
+ ) {}
20
+
21
+ /**
22
+ * Get all health check assignments for a specific satellite.
23
+ * Returns the full configuration payload needed for the satellite to execute checks.
24
+ */
25
+ async getAssignmentsForSatellite(
26
+ satelliteId: string,
27
+ ): Promise<SatelliteAssignment[]> {
28
+ const db = await this.getHealthcheckDb();
29
+ return db.getAssignmentsForSatellite(satelliteId);
30
+ }
31
+ }
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import { HeartbeatMonitor } from "./heartbeat-monitor";
3
+ import {
4
+ createMockLogger,
5
+ createMockSignalService,
6
+ type MockSignalService,
7
+ } from "@checkstack/test-utils-backend";
8
+ import { SATELLITE_STATUS_CHANGED } from "@checkstack/satellite-common";
9
+ import type { SatelliteService } from "./service";
10
+ import type { SatelliteWithStatus } from "@checkstack/satellite-common";
11
+
12
+ const createMockSatelliteService = (
13
+ satellites: SatelliteWithStatus[],
14
+ ): SatelliteService =>
15
+ ({
16
+ listSatellites: mock(async () => satellites),
17
+ }) as unknown as SatelliteService;
18
+
19
+ describe("HeartbeatMonitor", () => {
20
+ let signalService: MockSignalService;
21
+ let logger: ReturnType<typeof createMockLogger>;
22
+
23
+ beforeEach(() => {
24
+ signalService = createMockSignalService();
25
+ logger = createMockLogger();
26
+ });
27
+
28
+ it("should not broadcast signals on first check (initialization)", async () => {
29
+ const service = createMockSatelliteService([
30
+ {
31
+ id: "sat-1",
32
+ name: "eu-west",
33
+ region: "eu-west-1",
34
+ tags: {},
35
+ status: "online",
36
+ createdAt: new Date(),
37
+ },
38
+ ]);
39
+
40
+ const monitor = new HeartbeatMonitor(service, signalService, logger);
41
+ await monitor.checkHeartbeats();
42
+
43
+ // First check should NOT broadcast — it only initializes state
44
+ expect(signalService.getRecordedSignals()).toHaveLength(0);
45
+ });
46
+
47
+ it("should broadcast when a satellite goes offline", async () => {
48
+ const satellites: SatelliteWithStatus[] = [
49
+ {
50
+ id: "sat-1",
51
+ name: "eu-west",
52
+ region: "eu-west-1",
53
+ tags: {},
54
+ status: "online",
55
+ createdAt: new Date(),
56
+ },
57
+ ];
58
+ const service = createMockSatelliteService(satellites);
59
+ const monitor = new HeartbeatMonitor(service, signalService, logger);
60
+
61
+ // First check: initialize state
62
+ await monitor.checkHeartbeats();
63
+
64
+ // Simulate satellite going offline
65
+ satellites[0] = { ...satellites[0], status: "offline" };
66
+ await monitor.checkHeartbeats();
67
+
68
+ expect(
69
+ signalService.wasSignalEmitted(SATELLITE_STATUS_CHANGED.id),
70
+ ).toBe(true);
71
+
72
+ const recorded = signalService.getRecordedSignalsById(
73
+ SATELLITE_STATUS_CHANGED.id,
74
+ );
75
+ expect(recorded).toHaveLength(1);
76
+ expect(recorded[0].payload).toEqual({
77
+ satelliteId: "sat-1",
78
+ status: "offline",
79
+ name: "eu-west",
80
+ region: "eu-west-1",
81
+ });
82
+ });
83
+
84
+ it("should broadcast when a satellite comes back online", async () => {
85
+ const satellites: SatelliteWithStatus[] = [
86
+ {
87
+ id: "sat-1",
88
+ name: "eu-west",
89
+ region: "eu-west-1",
90
+ tags: {},
91
+ status: "offline",
92
+ createdAt: new Date(),
93
+ },
94
+ ];
95
+ const service = createMockSatelliteService(satellites);
96
+ const monitor = new HeartbeatMonitor(service, signalService, logger);
97
+
98
+ // First check: initialize state
99
+ await monitor.checkHeartbeats();
100
+
101
+ // Simulate satellite coming online
102
+ satellites[0] = { ...satellites[0], status: "online" };
103
+ await monitor.checkHeartbeats();
104
+
105
+ const recorded = signalService.getRecordedSignalsById(
106
+ SATELLITE_STATUS_CHANGED.id,
107
+ );
108
+ expect(recorded).toHaveLength(1);
109
+ expect(recorded[0].payload).toEqual({
110
+ satelliteId: "sat-1",
111
+ status: "online",
112
+ name: "eu-west",
113
+ region: "eu-west-1",
114
+ });
115
+ });
116
+
117
+ it("should not broadcast if status stays the same", async () => {
118
+ const satellites: SatelliteWithStatus[] = [
119
+ {
120
+ id: "sat-1",
121
+ name: "eu-west",
122
+ region: "eu-west-1",
123
+ tags: {},
124
+ status: "online",
125
+ createdAt: new Date(),
126
+ },
127
+ ];
128
+ const service = createMockSatelliteService(satellites);
129
+ const monitor = new HeartbeatMonitor(service, signalService, logger);
130
+
131
+ // First check: initialize
132
+ await monitor.checkHeartbeats();
133
+ // Second check: same status
134
+ await monitor.checkHeartbeats();
135
+
136
+ expect(signalService.getRecordedSignals()).toHaveLength(0);
137
+ });
138
+
139
+ it("should clean up tracked state for deleted satellites", async () => {
140
+ const satellites: SatelliteWithStatus[] = [
141
+ {
142
+ id: "sat-1",
143
+ name: "eu-west",
144
+ region: "eu-west-1",
145
+ tags: {},
146
+ status: "online",
147
+ createdAt: new Date(),
148
+ },
149
+ ];
150
+ const service = createMockSatelliteService(satellites);
151
+ const monitor = new HeartbeatMonitor(service, signalService, logger);
152
+
153
+ // First check
154
+ await monitor.checkHeartbeats();
155
+
156
+ // Satellite removed (empty list returned)
157
+ satellites.length = 0;
158
+ await monitor.checkHeartbeats();
159
+
160
+ // Adding it back should not trigger a transition
161
+ // (it's treated as a new satellite on first check)
162
+ satellites.push({
163
+ id: "sat-1",
164
+ name: "eu-west",
165
+ region: "eu-west-1",
166
+ tags: {},
167
+ status: "online",
168
+ createdAt: new Date(),
169
+ });
170
+ await monitor.checkHeartbeats();
171
+
172
+ // No transition signal should have been emitted for the re-add
173
+ expect(signalService.getRecordedSignals()).toHaveLength(0);
174
+ });
175
+ });
@@ -0,0 +1,70 @@
1
+ import type { Logger } from "@checkstack/backend-api";
2
+ import type { SignalService } from "@checkstack/signal-common";
3
+ import type { SatelliteService } from "./service";
4
+ import {
5
+ SATELLITE_STATUS_CHANGED,
6
+ OFFLINE_THRESHOLD_MS,
7
+ } from "@checkstack/satellite-common";
8
+
9
+ /**
10
+ * Monitors satellite heartbeats and broadcasts status change signals.
11
+ * Tracks previous state in-memory to detect transitions (online → offline, offline → online).
12
+ */
13
+ export class HeartbeatMonitor {
14
+ /**
15
+ * In-memory tracking of each satellite's last known status.
16
+ * Used to detect transitions and avoid redundant signal broadcasts.
17
+ */
18
+ private previousStatuses = new Map<string, "online" | "offline">();
19
+
20
+ constructor(
21
+ private service: SatelliteService,
22
+ private signalService: SignalService,
23
+ private logger: Logger,
24
+ ) {}
25
+
26
+ /**
27
+ * Check all satellites and broadcast status change signals for any transitions.
28
+ * Called periodically by a recurring queue job.
29
+ */
30
+ async checkHeartbeats(): Promise<void> {
31
+ const allSatellites = await this.service.listSatellites();
32
+
33
+ for (const satellite of allSatellites) {
34
+ const previousStatus = this.previousStatuses.get(satellite.id);
35
+ const currentStatus = satellite.status;
36
+
37
+ // Detect transition
38
+ if (previousStatus !== undefined && previousStatus !== currentStatus) {
39
+ this.logger.info(
40
+ `Satellite ${satellite.name} (${satellite.region}) status: ${previousStatus} → ${currentStatus}`,
41
+ );
42
+
43
+ await this.signalService.broadcast(SATELLITE_STATUS_CHANGED, {
44
+ satelliteId: satellite.id,
45
+ status: currentStatus,
46
+ name: satellite.name,
47
+ region: satellite.region,
48
+ });
49
+ }
50
+
51
+ this.previousStatuses.set(satellite.id, currentStatus);
52
+ }
53
+
54
+ // Clean up tracked satellites that no longer exist
55
+ const currentIds = new Set(allSatellites.map((s) => s.id));
56
+ for (const trackedId of this.previousStatuses.keys()) {
57
+ if (!currentIds.has(trackedId)) {
58
+ this.previousStatuses.delete(trackedId);
59
+ }
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Get the offline threshold in milliseconds.
65
+ * Exposed for testing.
66
+ */
67
+ static get OFFLINE_THRESHOLD_MS(): number {
68
+ return OFFLINE_THRESHOLD_MS;
69
+ }
70
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { createHook } from "@checkstack/backend-api";
2
+
3
+ /**
4
+ * Satellite hooks for cross-plugin communication.
5
+ * Other plugins (e.g., healthcheck-backend) can subscribe to clean up
6
+ * when a satellite is removed.
7
+ */
8
+ export const satelliteHooks = {
9
+ /**
10
+ * Emitted when a satellite is deleted.
11
+ * Healthcheck-backend subscribes to scrub this satellite's ID
12
+ * from all systemHealthChecks.satelliteIds arrays.
13
+ */
14
+ satelliteRemoved: createHook<{
15
+ satelliteId: string;
16
+ }>("satellite.removed"),
17
+ } as const;
package/src/index.ts ADDED
@@ -0,0 +1,159 @@
1
+ import * as schema from "./schema";
2
+ import type { SafeDatabase } from "@checkstack/backend-api";
3
+ import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
4
+ import {
5
+ satelliteAccessRules,
6
+ satelliteContract,
7
+ pluginMetadata,
8
+ HEARTBEAT_INTERVAL_MS,
9
+ } from "@checkstack/satellite-common";
10
+ import { HealthCheckApi } from "@checkstack/healthcheck-common";
11
+ import { healthCheckHooks } from "@checkstack/healthcheck-backend";
12
+ import { SatelliteService } from "./service";
13
+ import { createSatelliteRouter } from "./router";
14
+ import { HeartbeatMonitor } from "./heartbeat-monitor";
15
+ import { SatelliteWsHandler } from "./satellite-ws-handler";
16
+ import { ConfigRelay } from "./config-relay";
17
+
18
+ // Queue and job constants
19
+ const HEARTBEAT_QUEUE = "satellite-heartbeat";
20
+ const HEARTBEAT_JOB_ID = "satellite-heartbeat-check";
21
+ const HEARTBEAT_WORKER_GROUP = "satellite-heartbeat-worker";
22
+
23
+ export default createBackendPlugin({
24
+ metadata: pluginMetadata,
25
+ register(env) {
26
+ env.registerAccessRules(satelliteAccessRules);
27
+
28
+ env.registerInit({
29
+ schema,
30
+ deps: {
31
+ logger: coreServices.logger,
32
+ rpc: coreServices.rpc,
33
+ rpcClient: coreServices.rpcClient,
34
+ signalService: coreServices.signalService,
35
+ queueManager: coreServices.queueManager,
36
+ wsRegistry: coreServices.wsRegistry,
37
+ },
38
+ init: async ({ logger, database, rpc, signalService }) => {
39
+ logger.debug("🛰️ Initializing Satellite Backend...");
40
+
41
+ const service = new SatelliteService(
42
+ database as SafeDatabase<typeof schema>,
43
+ );
44
+
45
+ const router = createSatelliteRouter({
46
+ service,
47
+ signalService,
48
+ logger,
49
+ });
50
+ rpc.registerRouter(router, satelliteContract);
51
+
52
+ logger.debug("✅ Satellite Backend initialized.");
53
+ },
54
+ afterPluginsReady: async ({
55
+ database,
56
+ queueManager,
57
+ logger,
58
+ signalService,
59
+ wsRegistry,
60
+ rpcClient,
61
+ onHook,
62
+ }) => {
63
+ const service = new SatelliteService(
64
+ database as SafeDatabase<typeof schema>,
65
+ );
66
+
67
+ // Wire ConfigRelay via RPC loopback to healthcheck-backend
68
+ const configRelay = new ConfigRelay(async () => {
69
+ const hcClient = rpcClient.forPlugin(HealthCheckApi);
70
+ return {
71
+ getAssignmentsForSatellite: async (satelliteId: string) => {
72
+ return hcClient.getAssignmentsForSatellite({ satelliteId });
73
+ },
74
+ };
75
+ });
76
+
77
+ // Wire result handler — ingests satellite results into healthcheck-backend
78
+ const wsHandler = new SatelliteWsHandler(
79
+ service,
80
+ configRelay,
81
+ {
82
+ handleResult: async ({ satelliteId, sourceLabel, result }) => {
83
+ const hcClient = rpcClient.forPlugin(HealthCheckApi);
84
+ await hcClient.ingestSatelliteResult({
85
+ configId: result.configId,
86
+ systemId: result.systemId,
87
+ status: result.status,
88
+ latencyMs: result.latencyMs,
89
+ result: result.result,
90
+ executedAt: result.executedAt,
91
+ sourceId: satelliteId,
92
+ sourceLabel,
93
+ });
94
+ logger.debug(
95
+ `Ingested result from satellite ${satelliteId} (${sourceLabel}): ` +
96
+ `config=${result.configId} status=${result.status}`,
97
+ );
98
+ },
99
+ },
100
+ logger,
101
+ );
102
+
103
+ // Register satellite WebSocket endpoint via the scoped WS registry
104
+ // pluginId "satellite" is auto-prefixed → available at /api/ws/satellite
105
+ wsRegistry.register("/", wsHandler);
106
+ logger.debug("✅ Satellite WebSocket endpoint registered at /api/ws/satellite");
107
+
108
+ // Setup heartbeat monitor
109
+ const heartbeatMonitor = new HeartbeatMonitor(
110
+ service,
111
+ signalService,
112
+ logger,
113
+ );
114
+
115
+ const queue = queueManager.getQueue<Record<string, never>>(
116
+ HEARTBEAT_QUEUE,
117
+ );
118
+
119
+ // Subscribe to heartbeat check jobs
120
+ await queue.consume(
121
+ async () => {
122
+ await heartbeatMonitor.checkHeartbeats();
123
+ },
124
+ {
125
+ consumerGroup: HEARTBEAT_WORKER_GROUP,
126
+ maxRetries: 0,
127
+ },
128
+ );
129
+
130
+ // Schedule heartbeat check at the same interval as the heartbeat itself
131
+ const intervalSeconds = Math.round(HEARTBEAT_INTERVAL_MS / 1000);
132
+ await queue.scheduleRecurring(
133
+ {},
134
+ {
135
+ jobId: HEARTBEAT_JOB_ID,
136
+ intervalSeconds,
137
+ },
138
+ );
139
+
140
+ logger.debug(
141
+ `✅ Satellite heartbeat monitor scheduled (every ${intervalSeconds}s).`,
142
+ );
143
+
144
+ // Subscribe to assignment changes to push config to connected satellites
145
+ onHook(
146
+ healthCheckHooks.assignmentChanged,
147
+ async () => {
148
+ await wsHandler.pushConfigUpdateToAll();
149
+ },
150
+ );
151
+
152
+ logger.debug("✅ Satellite Backend afterPluginsReady complete.");
153
+ },
154
+ });
155
+ },
156
+ });
157
+
158
+ // Re-export hooks for other plugins to use
159
+ export { satelliteHooks } from "./hooks";