| … | … | @@ -134,11 +134,15 @@ function probeNode (relay) { |
| 134 | 134 | 'TS=$(systemctl show hiverelay -p TasksCurrent --value 2>/dev/null || echo \'?\')', |
| 135 | 135 | 'ENV=$(systemctl show hiverelay -p Environment --value 2>/dev/null | tr \'\' \';\' | tr \' \' \';\')', |
| 136 | 136 | 'CFG=$(cat ~/hiverelay/config.json 2>/dev/null || cat ~/.hiverelay/config.json 2>/dev/null || echo \'{}\')', |
| 137 | // Capability doc is what the relay ACTUALLY advertises. config.json is |
| 138 | // what someone wrote; the dubai incident is the standing reminder that |
| 139 | // those differ. Diversity must be read from the advertised document. |
| 140 | 'CAP=$(curl -fsS --max-time 4 http://127.0.0.1:9100/.well-known/hiverelay.json 2>/dev/null || echo \'{}\')', |
| 137 | 141 | 'STATUS=$(curl -fsS --max-time 8 http://127.0.0.1:9100/status 2>/dev/null || echo \'{}\')', |
| 138 | 142 | 'HEALTH=$(curl -fsS --max-time 4 http://127.0.0.1:9100/health 2>/dev/null || echo \'\')', |
| 139 | 143 | 'WD=$(systemctl is-enabled hiverelay-health-watchdog.timer 2>/dev/null || echo missing)', |
| 140 | 144 | 'HO=0; echo "$HEALTH" | grep -q \'"ok"[[:space:]]*:[[:space:]]*true\' && HO=1', |
| 141 | | 'printf \'{"version":"%s","diskPct":%s,"diskGB":%s,"swapMB":%s,"ramUsedPct":%s,"loadAvg":%s,"uptime":"%s","restarts":%s,"memCurrentMB":%s,"tasks":%s,"env":"%s","watchdog":"%s","healthOk":%s,"config":%s,"status":%s}\\n\' "$V" "${D:-0}" "${DG:-0}" "${SW:-0}" "${RM:-0}" "${LA:-0}" "$UP" "${RT:-0}" "${MC:-0}" "${TS:-0}" "$ENV" "$WD" "$HO" "$CFG" "$STATUS"' |
| 145 | 'printf \'{"version":"%s","diskPct":%s,"diskGB":%s,"swapMB":%s,"ramUsedPct":%s,"loadAvg":%s,"uptime":"%s","restarts":%s,"memCurrentMB":%s,"tasks":%s,"env":"%s","watchdog":"%s","healthOk":%s,"config":%s,"status":%s,"cap":%s}\\n\' "$V" "${D:-0}" "${DG:-0}" "${SW:-0}" "${RM:-0}" "${LA:-0}" "$UP" "${RT:-0}" "${MC:-0}" "${TS:-0}" "$ENV" "$WD" "$HO" "$CFG" "$STATUS" "$CAP"' |
| 142 | 146 | ].join('\n') |
| 143 | 147 | |
| 144 | 148 | // `ip` comes from the inventory, and POST /api/relay appends to that |
| … | … | @@ -191,8 +195,17 @@ function probeNode (relay) { |
| 191 | 195 | |
| 192 | 196 | const features = extractFeatures(config, env, st) |
| 193 | 197 | |
| 198 | // Advertised identity. `operator` is who owns it (the trust axis); |
| 199 | // `failureDomain` is what dies with it (the scheduling axis). They |
| 200 | // were one field until they were split, which is why a single-owner |
| 201 | // fleet used to report as many independent operators. |
| 202 | const cap = d.cap || {} |
| 203 | |
| 194 | 204 | resolve({ |
| 195 | 205 | ...relay, |
| 206 | operator: cap.operator || null, |
| 207 | failureDomain: cap.failureDomain || null, |
| 208 | advertisedRegion: cap.region || null, |
| 196 | 209 | status, |
| 197 | 210 | reachable: true, |
| 198 | 211 | version: d.version || 'none', |
| … | … | @@ -379,6 +392,7 @@ const server = http.createServer(async (req, res) => { |
| 379 | 392 | return sendJson(res, 200, { |
| 380 | 393 | nodes: fleetState, |
| 381 | 394 | summary: computeSummary(), |
| 395 | diversity: computeDiversity(), |
| 382 | 396 | lastProbe, |
| 383 | 397 | channels, |
| 384 | 398 | interval: INTERVAL_S, |
| … | … | @@ -658,6 +672,46 @@ function serveStatic (res, filePath) { |
| 658 | 672 | } catch { sendJson(res, 404, { error: 'not found' }) } |
| 659 | 673 | } |
| 660 | 674 | |
| 675 | // Diversity, as advertised rather than as configured. |
| 676 | // |
| 677 | // The point of this block is to make concentration impossible to miss. A fleet |
| 678 | // can be spread across many hosts and regions and still be ONE operator — and |
| 679 | // a quorum drawn from it is only as independent as that number. Reporting |
| 680 | // relays-as-operators is exactly the accounting error that let a single-owner |
| 681 | // fleet satisfy a minOperators floor of five. |
| 682 | function computeDiversity () { |
| 683 | const online = fleetState.filter(n => n.reachable) |
| 684 | const operators = new Set() |
| 685 | const domains = new Set() |
| 686 | const regions = new Set() |
| 687 | let undeclared = 0 |
| 688 | |
| 689 | for (const n of online) { |
| 690 | if (n.operator) operators.add(n.operator) |
| 691 | else undeclared++ |
| 692 | // Fall back to the relay name for spreading only — never for operator. |
| 693 | domains.add(n.failureDomain || n.name) |
| 694 | regions.add(n.advertisedRegion || n.region || 'Unknown') |
| 695 | } |
| 696 | |
| 697 | return { |
| 698 | relays: online.length, |
| 699 | operators: operators.size, |
| 700 | operatorNames: [...operators].sort(), |
| 701 | // Undeclared is UNKNOWN, not independent. Surfaced separately so it can |
| 702 | // never be quietly counted toward an independence claim. |
| 703 | operatorsUndeclared: undeclared, |
| 704 | failureDomains: domains.size, |
| 705 | regions: regions.size, |
| 706 | regionNames: [...regions].sort(), |
| 707 | // The honest headline: many boxes under one owner is not many operators. |
| 708 | singleOperator: operators.size <= 1 && online.length > 1, |
| 709 | concentrationNote: operators.size <= 1 && online.length > 1 |
| 710 | ? `${online.length} relays, ${operators.size || 0} declared operator — spread across ${domains.size} failure domains, but not independent` |
| 711 | : null |
| 712 | } |
| 713 | } |
| 714 | |
| 661 | 715 | function computeSummary () { |
| 662 | 716 | const nodes = fleetState |
| 663 | 717 | const totalRamGB = nodes.reduce((s, n) => s + (n.ramGB || 0), 0) |