@camstack/server 1.1.23 → 1.1.25

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.
@@ -1,15 +1,38 @@
1
1
  "use strict";
2
+ /**
3
+ * Health endpoints — fast probe surface for monitoring (k8s, uptime,
4
+ * external watchdogs).
5
+ *
6
+ * Routes:
7
+ * - GET /health → hub self-health (in-memory registry ONLY, no RPC)
8
+ * - GET /health/agents → online agent node IDs (in-memory registry ONLY, no RPC)
9
+ * - GET /health/agents/:nodeId → forward to agent's `$agent.health`
10
+ * - GET /health/cluster → hub + every online agent in one shot
11
+ *
12
+ * `/health` and `/health/agents` are served purely from the hub's in-memory
13
+ * Moleculer node registry (`listNodeLiveness`) and NEVER fan out to agent
14
+ * nodes — a frozen/ghost agent cannot slow them. This is what makes `/health`
15
+ * safe as the admin-ui's 4s hub-liveness probe (#26): the old path went
16
+ * through `listNodes()` which RPCs `$agent.status`/`$process.list` at 5s/node.
17
+ * `/health/agents/:nodeId` and `/health/cluster` are the (intentional)
18
+ * forwarding surfaces backed by the `$agent.health` Moleculer action, so
19
+ * monitors talking to the hub and monitors talking directly to an agent
20
+ * (`http://<agent>:4444/health`) see identical payloads.
21
+ */
2
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.buildHubHealth = buildHubHealth;
3
24
  exports.registerHealthRoutes = registerHealthRoutes;
4
25
  const AGENT_HEALTH_TIMEOUT_MS = 3_000;
5
26
  function nowIso() {
6
27
  return new Date().toISOString();
7
28
  }
8
29
  async function buildHubHealth(deps, proc = process) {
9
- const nodes = await deps.agentRegistry.listNodes();
30
+ // Fan-out-free: in-memory registry snapshot only, never a per-node RPC (#26).
31
+ const nodes = deps.agentRegistry.listNodeLiveness();
10
32
  const remote = nodes.filter((n) => !n.isHub);
11
- const online = remote.filter((n) => n.isOnline !== false).length;
33
+ const offlineNodes = remote.filter((n) => !n.isOnline);
12
34
  const total = remote.length;
35
+ const online = total - offlineNodes.length;
13
36
  const memUsage = proc.memoryUsage();
14
37
  const totalMem = memUsage.heapTotal + memUsage.external + memUsage.arrayBuffers;
15
38
  const memoryPercent = totalMem > 0 ? Math.round((memUsage.heapUsed / totalMem) * 100) : 0;
@@ -19,7 +42,14 @@ async function buildHubHealth(deps, proc = process) {
19
42
  version: deps.hubVersion,
20
43
  uptimeSeconds: Math.round(proc.uptime()),
21
44
  pid: proc.pid,
22
- agents: { total, online, offline: total - online },
45
+ agents: {
46
+ total,
47
+ online,
48
+ offline: offlineNodes.length,
49
+ // Named so health consumers (admin-ui connection banner) can say
50
+ // WHICH node is degraded, not only how many.
51
+ offlineIds: offlineNodes.map((n) => n.id),
52
+ },
23
53
  cpuPercent: 0,
24
54
  memoryPercent,
25
55
  checkedAt: nowIso(),
@@ -46,10 +76,8 @@ function registerHealthRoutes(fastify, deps) {
46
76
  return health;
47
77
  });
48
78
  fastify.get('/health/agents', async () => {
49
- const nodes = await deps.agentRegistry.listNodes();
50
- return {
51
- agents: nodes.filter((n) => !n.isHub && n.isOnline !== false).map((n) => n.info.id),
52
- };
79
+ const nodes = deps.agentRegistry.listNodeLiveness();
80
+ return { agents: nodes.filter((n) => !n.isHub && n.isOnline).map((n) => n.id) };
53
81
  });
54
82
  fastify.get('/health/agents/:nodeId', async (req, reply) => {
55
83
  const { nodeId } = req.params;
@@ -64,9 +92,10 @@ function registerHealthRoutes(fastify, deps) {
64
92
  });
65
93
  fastify.get('/health/cluster', async () => {
66
94
  const hub = await buildHubHealth(deps);
67
- const nodes = await deps.agentRegistry.listNodes();
68
- const remote = nodes.filter((n) => !n.isHub && n.isOnline !== false);
69
- const agents = await Promise.all(remote.map((n) => fetchAgentHealth(deps, n.info.id)));
95
+ // Enumeration only (fan-out-free); the $agent.health fan-out below is intentional.
96
+ const nodes = deps.agentRegistry.listNodeLiveness();
97
+ const remote = nodes.filter((n) => !n.isHub && n.isOnline);
98
+ const agents = await Promise.all(remote.map((n) => fetchAgentHealth(deps, n.id)));
70
99
  const ok = hub.ok && agents.every((a) => a.ok);
71
100
  return { ok, hub, agents, checkedAt: nowIso() };
72
101
  });
@@ -55,6 +55,21 @@ function requireDeviceScoped(registry, capName) {
55
55
  }
56
56
  const native = registry.getNativeProvider(capName, deviceId);
57
57
  if (!native) {
58
+ // No native provider. For `getStatus`, the device may still have a
59
+ // SYNTHESIZED status from device-links (battery/consumables wired
60
+ // from sibling sensors). Consult the resolver with a null base
61
+ // (the synthesize path) before failing — it returns null when the
62
+ // device has no links for this cap, preserving today's error.
63
+ if (prop === 'getStatus') {
64
+ const deviceManager = registry.getSingleton('device-manager');
65
+ const synthesized = await deviceManager?.resolveLinkedStatus?.({
66
+ deviceId,
67
+ cap: String(capName),
68
+ baseStatus: null,
69
+ });
70
+ if (synthesized != null)
71
+ return synthesized;
72
+ }
58
73
  throw new server_1.TRPCError({
59
74
  code: 'PRECONDITION_FAILED',
60
75
  message: `Capability "${String(capName)}" not registered for device ${deviceId}`,
@@ -2337,6 +2337,33 @@ function createCapRouter_deviceManager(getProvider, createRemoteProxy) {
2337
2337
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2338
2338
  return p.setDeviceLinks(methodInput);
2339
2339
  }),
2340
+ setDisplay: trpc_middleware_js_1.adminProcedure
2341
+ .input(types_38.deviceManagerCapability.methods.setDisplay.input.loose())
2342
+ .output(types_38.deviceManagerCapability.methods.setDisplay.output)
2343
+ .mutation(async ({ input, ctx }) => {
2344
+ const { nodeId, ...methodInput } = input;
2345
+ const p = resolveProvider('device-manager', nodeId, () => getProvider(ctx), createRemoteProxy);
2346
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2347
+ return p.setDisplay(methodInput);
2348
+ }),
2349
+ getRoleDisplayDefaults: trpc_middleware_js_1.protectedProcedure
2350
+ .input(types_38.deviceManagerCapability.methods.getRoleDisplayDefaults.input.loose())
2351
+ .output(types_38.deviceManagerCapability.methods.getRoleDisplayDefaults.output)
2352
+ .query(async ({ input, ctx }) => {
2353
+ const { nodeId, ...methodInput } = input;
2354
+ const p = resolveProvider('device-manager', nodeId, () => getProvider(ctx), createRemoteProxy);
2355
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2356
+ return p.getRoleDisplayDefaults(methodInput);
2357
+ }),
2358
+ setRoleDisplayDefaults: trpc_middleware_js_1.adminProcedure
2359
+ .input(types_38.deviceManagerCapability.methods.setRoleDisplayDefaults.input.loose())
2360
+ .output(types_38.deviceManagerCapability.methods.setRoleDisplayDefaults.output)
2361
+ .mutation(async ({ input, ctx }) => {
2362
+ const { nodeId, ...methodInput } = input;
2363
+ const p = resolveProvider('device-manager', nodeId, () => getProvider(ctx), createRemoteProxy);
2364
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2365
+ return p.setRoleDisplayDefaults(methodInput);
2366
+ }),
2340
2367
  getWireableFields: trpc_middleware_js_1.protectedProcedure
2341
2368
  .input(types_38.deviceManagerCapability.methods.getWireableFields.input.loose())
2342
2369
  .output(types_38.deviceManagerCapability.methods.getWireableFields.output)
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.AgentRegistryService = void 0;
37
+ exports.toNodeLiveness = toNodeLiveness;
37
38
  const node_crypto_1 = require("node:crypto");
38
39
  const os = __importStar(require("node:os"));
39
40
  const system_1 = require("@camstack/system");
@@ -70,6 +71,16 @@ const AGENT_BOOTSTRAP_PACKAGES = new Set([
70
71
  '@camstack/system',
71
72
  '@camstack/addon-agent-ui',
72
73
  ]);
74
+ /**
75
+ * Map raw Moleculer registry nodes to the health-surface liveness rows.
76
+ * Child runner ids (`hub/foo`, `agent/bar`) are processes, not cluster
77
+ * nodes — excluded, same rule as `listNodes()` / `classifyNode`.
78
+ */
79
+ function toNodeLiveness(nodes) {
80
+ return nodes
81
+ .filter((node) => !node.id.includes('/'))
82
+ .map((node) => ({ id: node.id, isHub: node.id === 'hub', isOnline: node.available }));
83
+ }
73
84
  class AgentRegistryService {
74
85
  eventBus;
75
86
  moleculer;
@@ -339,6 +350,19 @@ class AgentRegistryService {
339
350
  updateAgentName(nodeId, name) {
340
351
  console.log(`[agent-registry] Agent renamed: "${nodeId}" → "${name}"`);
341
352
  }
353
+ /**
354
+ * Fan-out-free liveness snapshot for the `/health` surface. Reads ONLY
355
+ * the hub's in-memory Moleculer node registry — never a per-node RPC —
356
+ * so a frozen/ghost agent can never slow the hub-liveness probe.
357
+ * (`listNodes()` fans `$agent.status`/`$process.list` out at 5s/node;
358
+ * the admin-ui probe aborts `/health` at 4s, which turned one frozen
359
+ * node into a false RED "Server unreachable" banner — #26.)
360
+ * `onlyAvailable: false` keeps recently-offline nodes listed with
361
+ * `available:false`, which is what populates `agents.offlineIds`.
362
+ */
363
+ listNodeLiveness() {
364
+ return toNodeLiveness(this.moleculer.broker.registry.getNodeList({ onlyAvailable: false }));
365
+ }
342
366
  async listNodes() {
343
367
  // Get child processes for hub via $process.list
344
368
  let hubProcesses = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.1.23",
3
+ "version": "1.1.25",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",