Files
MultiRoombaRover/server/src/services/fleetReportService/index.js
T
2026-09-14 19:09:02 -04:00

153 lines
5.3 KiB
JavaScript

// Fleet Report Service
// Purpose: Owns the replaceable collection/report runtime and its stable browser API.
// Scope: Applies the complete fleetReports section without restarting the Node process.
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const logger = require('../../globals/logger').child('fleetReportService');
const { subscribeAll } = require('../eventBus');
const roverManager = require('../roverManager');
const { commandEvents } = require('../commandService');
const { odometerEvents } = require('../odometerService');
const { createStorage } = require('./storage');
const { createCollector } = require('./collector');
const { createReportBuilder } = require('./reportBuilder');
const { registerSocketGateway } = require('./socketGateway');
let runtime = null;
let storage = null;
function retentionDays(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : fallback;
}
function stopRuntime() {
if (!runtime) return;
runtime.unsubscribeEvents();
if (runtime.batteryEnabled) roverManager.managerEvents.off('sensor', runtime.collector.collectSensor);
commandEvents.off('observation', runtime.collector.collectCommand);
odometerEvents.off('update', runtime.collector.collectOdometer);
runtime.managerEventHandlers.forEach((handler, kind) => roverManager.managerEvents.off(kind, handler));
clearInterval(runtime.flushTimer);
clearInterval(runtime.retentionTimer);
runtime.collector.flushMinutes();
runtime = null;
}
function startRuntime(config = {}) {
stopRuntime();
if (!config.enabled) {
logger.info('Fleet reporting disabled by config');
return;
}
const batteryConfig = config.battery || {};
const retentionConfig = config.retention || {};
const maximumIntegrationGapMs = Math.max(
250,
(Number(batteryConfig.maximumIntegrationGapSeconds) || 5) * 1000,
);
const minimumCapacityTestDepthPercent = Math.max(
10,
Math.min(100, Number(batteryConfig.minimumCapacityTestDepthPercent) || 60),
);
const batteryEnabled = Boolean(batteryConfig.enabled);
// Keep one SQLite connection for the process lifetime. Configuration reloads
// replace collectors and timers, not the durable database they share.
if (!storage) {
storage = createStorage({ logger });
storage.open();
}
const collector = createCollector({
storage,
logger,
maximumIntegrationGapMs,
minimumCapacityTestDepthPercent,
});
const reportBuilder = createReportBuilder({ storage, collector, roverManager });
const unsubscribeEvents = subscribeAll(collector.collectEvent);
if (batteryEnabled) roverManager.managerEvents.on('sensor', collector.collectSensor);
commandEvents.on('observation', collector.collectCommand);
odometerEvents.on('update', collector.collectOdometer);
const managerEventKinds = ['rover', 'hostStats', 'driver', 'switch', 'lock', 'private', 'privateSafety'];
const managerEventHandlers = new Map(managerEventKinds.map((kind) => {
const handler = (event) => collector.collectManagerEvent(kind, event);
roverManager.managerEvents.on(kind, handler);
return [kind, handler];
}));
const flushTimer = setInterval(() => collector.flushMinutes(), 30 * 1000);
flushTimer.unref?.();
function pruneNow() {
const now = Date.now();
const detailedDays = retentionDays(retentionConfig.detailedDays, 0);
const minuteDays = retentionDays(retentionConfig.minuteSamplesDays, 0);
storage.prune({
detailedBefore: detailedDays === 0 ? 0 : now - detailedDays * 86400000,
minuteBefore: minuteDays === 0 ? 0 : now - minuteDays * 86400000,
});
}
pruneNow();
const retentionTimer = setInterval(pruneNow, 6 * 60 * 60 * 1000);
retentionTimer.unref?.();
runtime = {
batteryEnabled,
storage,
collector,
reportBuilder,
unsubscribeEvents,
managerEventHandlers,
flushTimer,
retentionTimer,
};
logger.info('Fleet reporting enabled', {
databaseAvailable: storage.getDiagnostics().available,
maximumIntegrationGapMs,
minimumCapacityTestDepthPercent,
batteryEnabled,
});
}
registerSocketGateway({ roverManager, getRuntime: () => runtime, logger });
startRuntime(loadConfig().fleetReports || {});
registerConfigurationHandler('fleetReports', startRuntime);
module.exports = {
get enabled() {
return Boolean(runtime);
},
getDailyReport({ since, until, roverIds } = {}) {
if (!runtime) return null;
const end = Number(until) || Date.now();
return runtime.reportBuilder.build({
since: Number(since) || end - 24 * 60 * 60 * 1000,
until: end,
roverIds: Array.isArray(roverIds) ? roverIds : undefined,
includeEvents: false,
});
},
get collector() {
return runtime?.collector || null;
},
get storage() {
return runtime?.storage || storage;
},
get reportBuilder() {
return runtime?.reportBuilder || null;
},
backupDatabase(destinationPath) {
/*
Backups include reporting history even when collection is currently
disabled. Lazily opening the existing store keeps this one operation
behind the report service's normal database ownership boundary.
*/
if (!storage) {
storage = createStorage({ logger });
storage.open();
}
return storage.backupDatabase(destinationPath);
},
stop: stopRuntime,
};