mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
157 lines
6.3 KiB
JavaScript
157 lines
6.3 KiB
JavaScript
// Discord Fleet Daily Reports
|
|
// Purpose: Schedules and delivers completed-day fleet summaries to the existing admin alert channel.
|
|
// Scope: Discord owns timing/formatting/delivery; the fleet service owns evidence, analysis, and durable delivery state.
|
|
const { DateTime } = require('luxon');
|
|
const { EmbedBuilder } = require('discord.js');
|
|
|
|
function parseSendTime(value) {
|
|
const match = /^(\d{1,2}):(\d{2})$/.exec(String(value || '').trim());
|
|
if (!match) return { hour: 8, minute: 0 };
|
|
return {
|
|
hour: Math.max(0, Math.min(23, Number(match[1]))),
|
|
minute: Math.max(0, Math.min(59, Number(match[2]))),
|
|
};
|
|
}
|
|
|
|
function nextRunAt({ zone, hour, minute }) {
|
|
const now = DateTime.now().setZone(zone);
|
|
let next = now.set({ hour, minute, second: 0, millisecond: 0 });
|
|
if (next <= now) next = next.plus({ days: 1 });
|
|
return next;
|
|
}
|
|
|
|
function formatNumber(value, digits = 1) {
|
|
return Number(value || 0).toLocaleString(undefined, { maximumFractionDigits: digits });
|
|
}
|
|
|
|
function createFleetDailyReports({ logger, discordConfig, fleetConfig, fleetReportService, roverManager, sendToChannel }) {
|
|
let timer = null;
|
|
const reportConfig = fleetConfig?.discord || {};
|
|
const enabled = Boolean(reportConfig.enabled);
|
|
/*
|
|
Keep the configured choice separate from runtime availability. An operator
|
|
can enable Discord delivery while the parent fleet collector is unhealthy
|
|
or disabled; that dependency prevents work but does not rewrite the meaning
|
|
of this switch.
|
|
*/
|
|
const fleetReportsAvailable = Boolean(fleetReportService?.enabled);
|
|
const channelId = discordConfig?.channels?.adminAlerts;
|
|
const zone = String(reportConfig.timezone || 'America/New_York');
|
|
const { hour, minute } = parseSendTime(reportConfig.sendAt);
|
|
|
|
function publicRoverIds() {
|
|
// Discord's shared admin-alert channel does not provide a per-viewer socket
|
|
// against which private-rover grants can be checked. Excluding private
|
|
// rovers here preserves the existing privacy boundary instead of assuming
|
|
// every channel reader has every private grant.
|
|
return roverManager.getRoster()
|
|
.filter((rover) => !rover?.private?.enabled)
|
|
.map((rover) => String(rover.id));
|
|
}
|
|
|
|
function completedDayRange() {
|
|
const end = DateTime.now().setZone(zone).startOf('day');
|
|
const start = end.minus({ days: 1 });
|
|
return {
|
|
reportDate: start.toISODate(),
|
|
since: start.toMillis(),
|
|
until: end.toMillis(),
|
|
};
|
|
}
|
|
|
|
function buildEmbed(reportDate, report) {
|
|
const totals = report.totals;
|
|
const attention = report.attention
|
|
.filter((item) => item.severity !== 'notice')
|
|
.slice(0, 12)
|
|
.map((item) => `• ${item.roverId}: ${item.title}`)
|
|
.join('\n') || 'No material battery or efficiency changes need attention.';
|
|
const roverLines = report.rovers.map((rover) => {
|
|
const health = rover.batteryHealth || {};
|
|
const efficiency = rover.overallWhPerKm == null
|
|
? `efficiency pending (${formatNumber(rover.distanceMm / 1000, 0)} m)`
|
|
: `${formatNumber(rover.overallWhPerKm)} Wh/km`;
|
|
const capacity = health.measuredUsableMah == null
|
|
? `health collecting (${health.confidence || 'low'} confidence)`
|
|
: `${formatNumber(health.measuredUsableMah / 1000, 2)} Ah usable · ${formatNumber(health.capacityRetentionPercent)}% retained · ${health.confidence} confidence`;
|
|
return `• ${rover.name}: ${formatNumber(rover.distanceMm / 1e6, 2)} km · ${formatNumber(rover.dischargedWh, 2)} Wh · ${efficiency}\n Battery: ${capacity}`;
|
|
}
|
|
).join('\n') || 'No public rover telemetry.';
|
|
return new EmbedBuilder()
|
|
.setTitle(`Daily fleet report — ${reportDate}`)
|
|
.setColor(totals.attentionCount ? 0xf0b651 : 0x4caf50)
|
|
.addFields(
|
|
{
|
|
name: 'Fleet energy',
|
|
value: `${formatNumber(totals.distanceMm / 1e6, 2)} km · ${formatNumber(totals.dischargedWh, 2)} Wh · ${totals.overallWhPerKm == null ? 'efficiency pending' : `${formatNumber(totals.overallWhPerKm)} Wh/km`} · ${formatNumber(totals.stationaryDischargedWh, 2)} stationary Wh`,
|
|
},
|
|
{ name: 'Needs attention', value: attention.slice(0, 1024) },
|
|
{ name: 'Rovers', value: roverLines.slice(0, 1024) },
|
|
)
|
|
.setFooter({ text: 'The server reports page contains the complete all-rovers metric table.' });
|
|
}
|
|
|
|
async function deliverPreviousDay() {
|
|
if (!enabled || !fleetReportsAvailable || !channelId) return;
|
|
const range = completedDayRange();
|
|
const existing = fleetReportService.storage.getDailyReport(range.reportDate);
|
|
if (existing?.discordDeliveredAt) return;
|
|
const report = fleetReportService.getDailyReport({
|
|
since: range.since,
|
|
until: range.until,
|
|
roverIds: publicRoverIds(),
|
|
});
|
|
if (!report) return;
|
|
/*
|
|
Daily storage retains the exact metric report used for delivery, but the
|
|
Discord message intentionally has no raw JSON attachment. Admins need
|
|
actionable fleet comparisons here; the complete read-only evidence stays
|
|
on the reports page without turning routine events into notification noise.
|
|
*/
|
|
fleetReportService.storage.saveDailyReport(range.reportDate, report);
|
|
const sent = await sendToChannel(
|
|
channelId,
|
|
`Daily fleet report for ${range.reportDate}`,
|
|
{ embeds: [buildEmbed(range.reportDate, report)] },
|
|
{ parse: [] },
|
|
);
|
|
if (sent) {
|
|
fleetReportService.storage.markDailyReportDelivery(range.reportDate, { deliveredAt: Date.now(), error: null });
|
|
} else {
|
|
fleetReportService.storage.markDailyReportDelivery(range.reportDate, { error: 'Discord delivery returned no message' });
|
|
}
|
|
}
|
|
|
|
function scheduleNext() {
|
|
if (!enabled || !fleetReportsAvailable || !channelId) return;
|
|
const next = nextRunAt({ zone, hour, minute });
|
|
const delay = Math.max(1000, next.toMillis() - Date.now());
|
|
timer = setTimeout(async () => {
|
|
try {
|
|
await deliverPreviousDay();
|
|
} catch (err) {
|
|
logger.warn('Daily fleet report delivery failed', { error: err.message });
|
|
} finally {
|
|
scheduleNext();
|
|
}
|
|
}, delay);
|
|
timer.unref?.();
|
|
logger.info('Scheduled daily fleet report', { nextRunAt: next.toISO(), channelId });
|
|
}
|
|
|
|
function start() {
|
|
scheduleNext();
|
|
}
|
|
|
|
function stop() {
|
|
if (timer) clearTimeout(timer);
|
|
timer = null;
|
|
}
|
|
|
|
return { start, stop, deliverPreviousDay };
|
|
}
|
|
|
|
module.exports = {
|
|
createFleetDailyReports,
|
|
};
|