Files
MultiRoombaRover/server/src/services/fleetReportService/socketGateway.js
T
2026-09-14 17:56:53 -04:00

103 lines
4.7 KiB
JavaScript

// Fleet Report Socket Gateway
// Purpose: Exposes read-only, visibility-filtered fleet history to browser clients.
// Scope: Owns query validation and existing private/lockdown access boundaries; it performs no collection or analysis.
const io = require('../../globals/io');
const crypto = require('crypto');
const { isAdmin, isLockdownAdmin } = require('../roleService');
const MAX_RANGE_MS = 366 * 24 * 60 * 60 * 1000;
function normalizeRange(payload = {}) {
const now = Date.now();
const until = Number.isFinite(Number(payload.until)) ? Number(payload.until) : now;
const requestedSince = Number.isFinite(Number(payload.since))
? Number(payload.since)
: until - 24 * 60 * 60 * 1000;
const since = Math.max(0, Math.max(requestedSince, until - MAX_RANGE_MS));
return { since, until: Math.max(since + 1, until) };
}
function registerSocketGateway({ roverManager, getRuntime, logger }) {
io.on('connection', (socket) => {
socket.on('fleetReports:get', (payload = {}, cb = () => {}) => {
try {
const runtime = getRuntime();
if (!runtime) throw new Error('Fleet reports are disabled');
const { reportBuilder } = runtime;
const { since, until } = normalizeRange(payload);
// getRosterForSocket is the canonical live private-rover visibility
// resolver. Historical queries use precisely those currently visible
// rover IDs so fleet totals cannot indirectly disclose a private rover.
const visibleRoverIds = roverManager.getRosterForSocket(socket).map((rover) => String(rover.id));
const requestedIds = Array.isArray(payload.roverIds)
? payload.roverIds.map(String).filter((id) => visibleRoverIds.includes(id))
: visibleRoverIds;
const report = reportBuilder.build({
since,
until,
roverIds: requestedIds,
includeEvents: payload.includeEvents !== false,
eventLimit: payload.eventLimit,
});
// Lockdown-only events are deliberately removed after query assembly.
// They are global rather than rover-scoped, so rover filtering alone is
// insufficient to preserve the pre-existing lockdown privacy boundary.
if (!isLockdownAdmin(socket)) {
report.events = report.events.filter((event) => event.visibility !== 'lockdown');
report.totals.eventCountReturned = report.events.length;
}
if (payload.compact === true) {
// The Activities card now consumes the same all-rovers metric rows
// as fullscreen. The builder no longer attaches minute/session
// evidence by default, so compacting only removes archival metadata.
report.events = [];
report.dailyReportHistory = [];
}
cb({ ok: true, report });
} catch (err) {
logger.warn('Fleet report query failed', { socketId: socket.id, error: err.message });
cb({ error: 'Fleet report query failed' });
}
});
socket.on('fleetReports:replaceBattery', (payload = {}, cb = () => {}) => {
try {
const runtime = getRuntime();
if (!runtime) throw new Error('Fleet reports are disabled');
const { storage, collector } = runtime;
if (!isAdmin(socket)) throw new Error('Admin access required');
const roverId = String(payload.roverId || '').trim();
if (!roverId || !roverManager.rovers.has(roverId)) throw new Error('Known online rover required');
const ratedCapacityMah = Number(payload.ratedCapacityMah);
if (!Number.isFinite(ratedCapacityMah) || ratedCapacityMah <= 0 || ratedCapacityMah > 65535) {
throw new Error('Rated capacity must be between 1 and 65535 mAh');
}
const installedAt = Number.isFinite(Number(payload.installedAt)) ? Number(payload.installedAt) : Date.now();
const entry = storage.replaceBattery({
roverId,
batteryKey: `battery:${roverId}:${installedAt}:${crypto.randomUUID().slice(0, 8)}`,
chemistry: String(payload.chemistry || '').trim() || null,
ratedCapacityMah: Math.round(ratedCapacityMah),
installedAt,
notes: String(payload.notes || '').trim() || null,
});
if (!entry) throw new Error('Battery registry write failed');
collector.refreshBatteryIdentity(roverId);
collector.collectEvent({
source: 'fleetReportService',
type: 'battery.replaced',
payload: { roverId, battery: entry },
});
cb({ ok: true, battery: entry });
} catch (err) {
logger.warn('Fleet battery replacement rejected', { socketId: socket.id, error: err.message });
cb({ error: err.message });
}
});
});
}
module.exports = {
registerSocketGateway,
};