slopreporting

This commit is contained in:
legop3
2026-07-25 15:54:38 -04:00
parent 3a26b4871a
commit 8642fbac3c
21 changed files with 707 additions and 555 deletions
+36 -80
View File
@@ -1,6 +1,6 @@
// Fleet Reports Card
// Purpose: Shows a dense current fleet summary at the bottom of the Activities tab.
// Scope: Self-gates from session.features and links to the dedicated read-only fullscreen report.
// Purpose: Shows every rover's key battery and efficiency metrics at the bottom of Activities.
// Scope: Self-gates through session.features and links to the all-rovers fullscreen report.
import { Link } from 'react-router-dom';
import { useState } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
@@ -8,34 +8,19 @@ import { isFeatureEnabled } from '../../lib/features.js';
import useFleetReport from '../../hooks/useFleetReport.js';
import CardFrame from '../CardFrame/index.jsx';
function formatMah(value) {
return Number.isFinite(Number(value)) ? `${Math.round(Number(value))} mAh` : '--';
function value(number, digits = 1) {
return Number.isFinite(Number(number))
? Number(number).toLocaleString(undefined, { maximumFractionDigits: digits })
: '--';
}
function formatDuration(ms) {
const minutes = Math.round((Number(ms) || 0) / 60000);
if (minutes < 60) return `${minutes}m`;
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
}
function formatDistance(mm) {
const value = Number(mm) || 0;
return value >= 1000000 ? `${(value / 1000000).toFixed(2)} km` : `${(value / 1000).toFixed(1)} m`;
}
function Metric({ label, value }) {
return (
<div className="surface flex min-w-0 items-center justify-between gap-1 px-1 py-0.5 text-sm">
<span className="text-slate-400">{label}</span>
<span className="truncate text-right font-medium text-slate-100">{value}</span>
</div>
);
function distance(millimeters) {
return Number(millimeters) >= 1e6
? `${value(Number(millimeters) / 1e6, 2)} km`
: `${value(Number(millimeters) / 1000, 1)} m`;
}
function EnabledFleetReportsCard() {
// State gives each request a stable range endpoint while allowing the user
// to advance the 24-hour window explicitly without calling time APIs during
// render.
const [now, setNow] = useState(() => Date.now());
const { report, loading, error } = useFleetReport({
since: now - 24 * 60 * 60 * 1000,
@@ -43,60 +28,30 @@ function EnabledFleetReportsCard() {
compact: true,
includeEvents: false,
});
const actions = (
<div className="flex items-center gap-0.5">
<button type="button" className="button-dark px-1 py-0.25 text-[0.75rem]" onClick={() => setNow(Date.now())}>Refresh</button>
<Link className="button-dark px-1 py-0.25 text-[0.75rem]" to="/reports">Open full report</Link>
</div>
);
return (
<CardFrame title="Fleet report" meta="Last 24 hours" actions={actions} bodyClassName="space-y-0.5 p-0.5 text-sm">
{loading && !report ? <p className="text-slate-400">Loading fleet report</p> : null}
<CardFrame
title="Fleet battery and efficiency"
meta="Last 24 hours"
actions={<div className="flex gap-0.5"><button type="button" className="button-dark px-1 py-0.25 text-[0.75rem]" onClick={() => setNow(Date.now())}>Refresh</button><Link className="button-dark px-1 py-0.25 text-[0.75rem]" to="/reports">Open full report</Link></div>}
bodyClassName="space-y-0.5 p-0.5 text-sm"
>
{loading && !report ? <p className="text-slate-400">Loading fleet metrics</p> : null}
{error ? <p className="text-red-300">{error}</p> : null}
{report ? (
<>
<div className="grid grid-cols-2 gap-0.5 md:grid-cols-4">
<Metric label="Online" value={`${report.totals.onlineRoverCount}/${report.totals.roverCount}`} />
<Metric label="Coverage" value={formatDuration(report.totals.coverageMs)} />
<Metric label="Discharged" value={formatMah(report.totals.dischargedMah)} />
<Metric label="Charged" value={formatMah(report.totals.chargedMah)} />
<Metric label="Sensor samples" value={report.totals.sampleCount.toLocaleString()} />
<Metric label="Telemetry gaps" value={report.totals.telemetryGapCount.toLocaleString()} />
<Metric label="Distance" value={formatDistance(report.totals.distanceMm)} />
<Metric label="Overcurrent episodes" value={report.totals.overcurrentEpisodeCount.toLocaleString()} />
<Metric label="Warnings" value={report.totals.warningFindingCount.toLocaleString()} />
<Metric label="Critical" value={report.totals.criticalFindingCount.toLocaleString()} />
</div>
{report.findings.length ? (
<div className="surface space-y-0.5 px-1 py-0.5">
<p className="text-xs font-semibold text-slate-200">Needs attention</p>
{report.findings.slice(0, 5).map((finding) => (
<div key={finding.key} className="flex items-start justify-between gap-1 text-xs">
<span className="text-slate-200">{finding.roverId ? `${finding.roverId}: ` : ''}{finding.title}</span>
<span className={finding.severity === 'critical' ? 'text-red-300' : finding.severity === 'warning' ? 'text-amber-300' : 'text-slate-400'}>
{finding.severity}
</span>
</div>
))}
</div>
) : <p className="surface px-1 py-0.5 text-xs text-emerald-300">No report findings in this range.</p>}
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="text-slate-400"><tr><th>Rover</th><th>State</th><th>Samples</th><th>Used</th><th>Temp max</th><th>Gaps</th></tr></thead>
<tbody>
{report.rovers.map((rover) => (
<tr key={rover.roverId} className="border-t border-neutral-700/70 text-slate-200">
<td>{rover.name}</td><td>{rover.online ? 'online' : 'offline'}</td>
<td>{rover.sampleCount.toLocaleString()}</td><td>{formatMah(rover.dischargedMah)}</td>
<td>{rover.maximumTemperatureC == null ? '--' : `${rover.maximumTemperatureC}°C`}</td><td>{rover.gapCount}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
<div className="overflow-x-auto">
<table className="w-full whitespace-nowrap text-left text-xs">
<thead className="text-slate-400"><tr><th>Rover</th><th>State</th><th>Health</th><th>Confidence</th><th>Wh/km</th><th>Distance</th><th>Used</th><th>Temperature</th></tr></thead>
<tbody>{report.rovers.map((rover) => (
<tr key={rover.roverId} className="border-t border-neutral-700/70 text-slate-200">
<td>{rover.name}</td><td>{rover.online ? 'online' : 'offline'}</td>
<td>{rover.batteryHealth.capacityRetentionPercent == null ? '--' : `${value(rover.batteryHealth.capacityRetentionPercent)}%`}</td>
<td>{rover.batteryHealth.confidence}</td><td>{value(rover.overallWhPerKm)}</td>
<td>{distance(rover.distanceMm)}</td><td>{value(rover.dischargedWh, 2)} Wh</td>
<td>{value(rover.maximumTemperatureC)} °C</td>
</tr>
))}</tbody>
</table>
</div>
) : null}
</CardFrame>
);
@@ -104,9 +59,10 @@ function EnabledFleetReportsCard() {
export default function FleetReportsCard() {
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'fleetReports'));
// The outer component owns the optional feature gate, while the enabled
// child owns data hooks. This avoids opening report socket requests at all
// when the server has disabled the feature and still lets layout stacks stay
// completely unaware of feature branching.
/*
Keeping the hook inside the enabled child ensures a disabled optional
feature creates no socket traffic and leaves the Activities layout unaware
of reporting internals.
*/
return enabled ? <EnabledFleetReportsCard /> : null;
}