herdr-remote-relay 0.2.4 → 0.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote-relay",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Standalone relay server and web terminal for Herdr Remote",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/metrics.js CHANGED
@@ -12,6 +12,29 @@ function bytesPerSecond(current, previous, elapsedMs) {
12
12
  return Math.max(0, (current - previous) * 1000 / elapsedMs);
13
13
  }
14
14
 
15
+ /**
16
+ * How many people are attached, rather than how many sockets are open.
17
+ *
18
+ * A phone and a laptop belonging to one person are two connections but one
19
+ * paired device each, and one person opening three browser windows is three
20
+ * connections and one device — so the distinct paired device is the closest
21
+ * thing the relay has to a user. Connections that predate device pairing carry
22
+ * no `deviceId`; they fall back to their own connection id so they still count
23
+ * once instead of collapsing into a single anonymous user.
24
+ */
25
+ function countActiveUsers(clients = []) {
26
+ const identities = new Set();
27
+ for (const client of clients) {
28
+ if (!client) continue;
29
+ identities.add(
30
+ typeof client.deviceId === 'string' && client.deviceId
31
+ ? `device:${client.deviceId}`
32
+ : `client:${client.id}`
33
+ );
34
+ }
35
+ return identities.size;
36
+ }
37
+
15
38
  class RelayMetrics {
16
39
  constructor({ version = '0.1.0', protocolVersion = 1 } = {}) {
17
40
  this.version = version;
@@ -143,7 +166,13 @@ class RelayMetrics {
143
166
  };
144
167
  }
145
168
 
146
- snapshot({ clients = [], hosts = [], ptys = [], sample = true, scopeHostId = null } = {}) {
169
+ /**
170
+ * `activeUserCount` is passed in rather than derived from `clients`: the
171
+ * public `/api/status` rows deliberately omit `deviceId`, so only the caller
172
+ * still holding the full connection records can count devices correctly.
173
+ * Omitting it falls back to counting whatever the given rows identify.
174
+ */
175
+ snapshot({ clients = [], hosts = [], ptys = [], sample = true, scopeHostId = null, activeUserCount = null } = {}) {
147
176
  const now = Date.now();
148
177
  const elapsedMs = now - this.lastSample.at;
149
178
  const throughput = {
@@ -181,6 +210,7 @@ class RelayMetrics {
181
210
  clientCount: clients.length,
182
211
  hostCount: hosts.length,
183
212
  ptyCount: ptys.length,
213
+ activeUserCount: Number.isFinite(activeUserCount) ? activeUserCount : countActiveUsers(clients),
184
214
  throughput: scopedThroughput,
185
215
  cpu: {
186
216
  load1m: finite(load[0]),
@@ -208,4 +238,4 @@ class RelayMetrics {
208
238
  }
209
239
  }
210
240
 
211
- module.exports = { RelayMetrics };
241
+ module.exports = { RelayMetrics, countActiveUsers };
@@ -9,7 +9,7 @@ const { URL } = require('node:url');
9
9
  const { WebSocketServer, WebSocket } = require('ws');
10
10
  const { loadRelayConfig, defaultStateDir, PACKAGE_ROOT } = require('./relay-config');
11
11
  const { AuthStore } = require('./auth-store');
12
- const { RelayMetrics } = require('./metrics');
12
+ const { RelayMetrics, countActiveUsers } = require('./metrics');
13
13
  const { unpackStreamFrame, packStreamFrame, sanitizeTerminalPalette } = require('./stream-frame');
14
14
  const { ensureDir } = require('./state');
15
15
 
@@ -1203,6 +1203,15 @@ class RelayServer {
1203
1203
  bytesSent: client.bytesSent,
1204
1204
  ip: client.ip,
1205
1205
  }));
1206
+ // The roster is read once and used twice: the operator response carries it
1207
+ // whole, and every response carries the per-host tally derived from it. A
1208
+ // public caller learns how many devices a workstation has paired, never
1209
+ // which ones.
1210
+ const pairedDevices = this.auth.listDevices();
1211
+ const pairedPerHost = new Map();
1212
+ for (const device of pairedDevices) {
1213
+ pairedPerHost.set(device.hostId, (pairedPerHost.get(device.hostId) || 0) + 1);
1214
+ }
1206
1215
  const hosts = scopedHosts.map((host) => ({
1207
1216
  id: host.id,
1208
1217
  hostname: host.hostname,
@@ -1211,6 +1220,12 @@ class RelayServer {
1211
1220
  status: host.reconnecting ? 'reconnecting' : host.clients.size ? 'busy' : 'online',
1212
1221
  connectedAt: host.connectedAt,
1213
1222
  activePtyCount: host.ptys.length,
1223
+ // Distinct devices attached to this workstation, not open sockets: a
1224
+ // phone with two tabs open is one device on the operator's board.
1225
+ connectedDeviceCount: countActiveUsers(
1226
+ [...host.clients].map((id) => this.clients.get(id)).filter(Boolean)
1227
+ ),
1228
+ pairedDeviceCount: pairedPerHost.get(host.id) || 0,
1214
1229
  load: host.load,
1215
1230
  }));
1216
1231
  // The workstation counts one PTY per stream and cannot know how many
@@ -1225,9 +1240,19 @@ class RelayServer {
1225
1240
  // The paired-device roster identifies people's hardware, so it is served to
1226
1241
  // the relay operator only — never on /api/status, which any paired device
1227
1242
  // may read.
1228
- const devices = includeDevices ? this.auth.listDevices() : undefined;
1243
+ const devices = includeDevices ? pairedDevices : undefined;
1229
1244
  return {
1230
- ...this.metrics.snapshot({ clients, hosts, ptys, sample, scopeHostId }),
1245
+ ...this.metrics.snapshot({
1246
+ clients,
1247
+ hosts,
1248
+ ptys,
1249
+ sample,
1250
+ scopeHostId,
1251
+ // Counted from the unredacted records: the mapped rows above only carry
1252
+ // `deviceId` for the operator, and a public caller must still see the
1253
+ // same number of users the operator does.
1254
+ activeUserCount: countActiveUsers(scopedClients),
1255
+ }),
1231
1256
  ...(devices ? { devices } : {}),
1232
1257
  relayMode: this.relayMode,
1233
1258
  isRemoteRelay: this.relayMode === 'remote',