mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
slopreporting
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Fullscreen Fleet Reports Application
|
||||
// Purpose: Presents deep read-only operational history using the same global cards, surfaces, theme, and spacing as the driver page.
|
||||
// Scope: Owns report-range controls, dense evidence tables, chart selection, CSV export, and route-level feature handling.
|
||||
// Fullscreen Fleet Energy Report
|
||||
// Purpose: Shows every rover together in one dense, read-only battery-health and efficiency workspace.
|
||||
// Scope: Uses existing CardFrame, surface, button, page-theme, and spacing globals; intentionally contains no charts.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Papa from 'papaparse';
|
||||
@@ -13,7 +13,6 @@ import useFleetReport from '../hooks/useFleetReport.js';
|
||||
import { isFeatureEnabled } from '../lib/features.js';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { DEFAULT_PAGE_THEME_KEY, getPageThemeClass, themeGapClass } from '../themes/index.js';
|
||||
import FleetTimeSeriesChart from './FleetTimeSeriesChart.jsx';
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ label: '24 hours', ms: 24 * 60 * 60 * 1000 },
|
||||
@@ -23,39 +22,43 @@ const RANGE_OPTIONS = [
|
||||
{ label: '1 year', ms: 365 * 24 * 60 * 60 * 1000 },
|
||||
];
|
||||
|
||||
const METRICS = [
|
||||
['dischargedMah', 'Discharged mAh'],
|
||||
['chargedMah', 'Charged mAh'],
|
||||
['avgVoltageMv', 'Average voltage'],
|
||||
['avgCurrentMa', 'Average current'],
|
||||
['avgTemperatureC', 'Average temperature'],
|
||||
];
|
||||
|
||||
function number(value, digits = 2) {
|
||||
return value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value))
|
||||
return value !== null && value !== undefined && Number.isFinite(Number(value))
|
||||
? Number(value).toLocaleString(undefined, { maximumFractionDigits: digits })
|
||||
: '--';
|
||||
}
|
||||
|
||||
function timestamp(value) {
|
||||
return value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value))
|
||||
? new Date(Number(value)).toLocaleString()
|
||||
: '--';
|
||||
return Number.isFinite(Number(value)) ? new Date(Number(value)).toLocaleString() : '--';
|
||||
}
|
||||
|
||||
function duration(value) {
|
||||
const minutes = Math.round((Number(value) || 0) / 60000);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
if (minutes < 1440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
||||
return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`;
|
||||
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
||||
}
|
||||
|
||||
function distance(value) {
|
||||
const mm = Number(value) || 0;
|
||||
return mm >= 1000000 ? `${number(mm / 1000000)} km` : `${number(mm / 1000)} m`;
|
||||
const millimeters = Number(value) || 0;
|
||||
return millimeters >= 1e6 ? `${number(millimeters / 1e6)} km` : `${number(millimeters / 1000, 1)} m`;
|
||||
}
|
||||
|
||||
function Metric({ label, value, detail = null }) {
|
||||
function exportCsv(rows) {
|
||||
// Papa Parse owns quoting and escaping so new metric columns cannot silently
|
||||
// corrupt exports when rover names or confidence explanations contain commas.
|
||||
const csv = Papa.unparse(rows.map((rover) => ({
|
||||
...rover,
|
||||
batteryHealth: JSON.stringify(rover.batteryHealth),
|
||||
})));
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = 'fleet-battery-efficiency.csv';
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function Metric({ label, value, detail }) {
|
||||
return (
|
||||
<div className="surface min-w-0 px-1 py-0.5">
|
||||
<div className="text-[0.68rem] text-slate-400">{label}</div>
|
||||
@@ -65,23 +68,13 @@ function Metric({ label, value, detail = null }) {
|
||||
);
|
||||
}
|
||||
|
||||
function exportCsv(name, rows) {
|
||||
// Papa Parse owns CSV quoting and nested-value escaping. JSON-stringifying
|
||||
// object cells preserves evidence without maintaining a fragile custom CSV
|
||||
// serializer for arbitrary structured event payloads.
|
||||
const normalized = rows.map((row) => Object.fromEntries(
|
||||
Object.entries(row).map(([key, value]) => [key, value && typeof value === 'object' ? JSON.stringify(value) : value]),
|
||||
));
|
||||
const csv = Papa.unparse(normalized);
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = name;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
function SortHeading({ field, sort, onSort, children }) {
|
||||
// Sorting is an explicit table interaction rather than hidden column state;
|
||||
// the arrow tells users which fleet comparison currently controls row order.
|
||||
return <th><button type="button" className="whitespace-nowrap text-left" onClick={() => onSort(field)}>{children}{sort.key === field ? (sort.descending ? ' ↓' : ' ↑') : ''}</button></th>;
|
||||
}
|
||||
|
||||
function BatteryRegistryPanel({ report, onSaved }) {
|
||||
function BatteryRegistry({ report, refresh }) {
|
||||
const socket = useSocket();
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const canEdit = role === 'admin' || role === 'lockdown';
|
||||
@@ -94,48 +87,43 @@ function BatteryRegistryPanel({ report, onSaved }) {
|
||||
});
|
||||
const [status, setStatus] = useState('');
|
||||
|
||||
const submit = (event) => {
|
||||
function submit(event) {
|
||||
event.preventDefault();
|
||||
setStatus('Saving…');
|
||||
socket.emit('fleetReports:replaceBattery', {
|
||||
roverId: draft.roverId,
|
||||
chemistry: draft.chemistry,
|
||||
...draft,
|
||||
ratedCapacityMah: Number(draft.ratedCapacityMah),
|
||||
installedAt: new Date(`${draft.installedDate}T12:00:00`).getTime(),
|
||||
notes: draft.notes,
|
||||
}, (response = {}) => {
|
||||
if (response.error) {
|
||||
setStatus(response.error);
|
||||
return;
|
||||
}
|
||||
setStatus(`Registered ${response.battery?.batteryKey || 'battery'}`);
|
||||
onSaved();
|
||||
setStatus(response.error || `Registered ${response.battery?.batteryKey || 'battery'}`);
|
||||
if (!response.error) refresh();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<CardFrame title="Physical battery registry" meta={`${report.batteryRegistry.length} historical records`} bodyClassName="space-y-0.5 p-0.5">
|
||||
<CardFrame title="Battery identity and baselines" meta={`${report.batteryRegistry.length} records`} bodyClassName="space-y-0.5 p-0.5">
|
||||
<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>Battery identity</th><th>Chemistry</th><th>Rated capacity mAh</th><th>Installed</th><th>Retired</th><th>Learned baseline mAh</th><th>Notes</th></tr></thead>
|
||||
<thead className="text-slate-400"><tr><th>Rover</th><th>Battery</th><th>Chemistry</th><th>Rated mAh</th><th>Installed</th><th>Retired</th><th>Learned baseline mAh</th><th>Notes</th></tr></thead>
|
||||
<tbody>{report.batteryRegistry.map((battery) => (
|
||||
<tr key={battery.batteryKey} className="border-t border-neutral-700/70 text-slate-200">
|
||||
<td>{battery.roverId}</td><td>{battery.batteryKey}</td><td>{battery.chemistry || '--'}</td><td>{number(battery.ratedCapacityMah, 0)}</td>
|
||||
<td>{timestamp(battery.installedAt)}</td><td>{timestamp(battery.retiredAt)}</td><td>{number(battery.healthyBaselineMah)}</td><td>{battery.notes || '--'}</td>
|
||||
<td>{battery.roverId}</td><td>{battery.batteryKey}</td><td>{battery.chemistry || '--'}</td>
|
||||
<td>{number(battery.ratedCapacityMah, 0)}</td><td>{timestamp(battery.installedAt)}</td>
|
||||
<td>{timestamp(battery.retiredAt)}</td><td>{number(battery.healthyBaselineMah, 0)}</td><td>{battery.notes || '--'}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{canEdit ? (
|
||||
<form className="surface grid gap-0.5 p-1 text-xs md:grid-cols-2 xl:grid-cols-6" onSubmit={submit}>
|
||||
<label className="space-y-0.5"><span className="text-slate-400">Rover</span><select className="w-full bg-neutral-800 p-0.5" value={draft.roverId} onChange={(event) => setDraft((current) => ({ ...current, roverId: event.target.value }))}>{report.rovers.map((rover) => <option key={rover.roverId} value={rover.roverId}>{rover.name}</option>)}</select></label>
|
||||
<label className="space-y-0.5"><span className="text-slate-400">Chemistry</span><select className="w-full bg-neutral-800 p-0.5" value={draft.chemistry} onChange={(event) => setDraft((current) => ({ ...current, chemistry: event.target.value }))}><option value="unknown">Unknown</option><option value="NiMH">NiMH</option><option value="Li-ion">Li-ion</option></select></label>
|
||||
<label className="space-y-0.5"><span className="text-slate-400">Rated capacity mAh</span><input required min="1" max="65535" type="number" className="w-full bg-neutral-800 p-0.5" value={draft.ratedCapacityMah} onChange={(event) => setDraft((current) => ({ ...current, ratedCapacityMah: event.target.value }))} /></label>
|
||||
<label className="space-y-0.5"><span className="text-slate-400">Installation date</span><input required type="date" className="w-full bg-neutral-800 p-0.5" value={draft.installedDate} onChange={(event) => setDraft((current) => ({ ...current, installedDate: event.target.value }))} /></label>
|
||||
<label className="space-y-0.5"><span className="text-slate-400">Notes</span><input className="w-full bg-neutral-800 p-0.5" value={draft.notes} onChange={(event) => setDraft((current) => ({ ...current, notes: event.target.value }))} /></label>
|
||||
<div className="flex items-end gap-0.5"><button type="submit" className="button-dark px-1 py-0.5">Install/replace battery</button><span className="text-slate-400">{status}</span></div>
|
||||
<label><span className="text-slate-400">Rover</span><select className="w-full bg-neutral-800 p-0.5" value={draft.roverId} onChange={(event) => setDraft((value) => ({ ...value, roverId: event.target.value }))}>{report.rovers.map((rover) => <option key={rover.roverId} value={rover.roverId}>{rover.name}</option>)}</select></label>
|
||||
<label><span className="text-slate-400">Chemistry</span><select className="w-full bg-neutral-800 p-0.5" value={draft.chemistry} onChange={(event) => setDraft((value) => ({ ...value, chemistry: event.target.value }))}><option value="unknown">Unknown</option><option value="NiMH">NiMH</option><option value="Li-ion">Li-ion</option></select></label>
|
||||
<label><span className="text-slate-400">Rated capacity mAh</span><input required min="1" max="65535" type="number" className="w-full bg-neutral-800 p-0.5" value={draft.ratedCapacityMah} onChange={(event) => setDraft((value) => ({ ...value, ratedCapacityMah: event.target.value }))} /></label>
|
||||
<label><span className="text-slate-400">Installed</span><input required type="date" className="w-full bg-neutral-800 p-0.5" value={draft.installedDate} onChange={(event) => setDraft((value) => ({ ...value, installedDate: event.target.value }))} /></label>
|
||||
<label><span className="text-slate-400">Notes</span><input className="w-full bg-neutral-800 p-0.5" value={draft.notes} onChange={(event) => setDraft((value) => ({ ...value, notes: event.target.value }))} /></label>
|
||||
<div className="flex items-end gap-0.5"><button type="submit" className="button-dark px-1 py-0.5">Install or replace</button><span className="text-slate-400">{status}</span></div>
|
||||
</form>
|
||||
) : <p className="surface px-1 py-0.5 text-xs text-slate-400">Battery history is read-only. Admin access is required to register a physical replacement.</p>}
|
||||
) : null}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -143,200 +131,125 @@ function BatteryRegistryPanel({ report, onSaved }) {
|
||||
function FullReportContent() {
|
||||
const [rangeMs, setRangeMs] = useState(RANGE_OPTIONS[1].ms);
|
||||
const [rangeEnd, setRangeEnd] = useState(() => Date.now());
|
||||
const [metric, setMetric] = useState('dischargedMah');
|
||||
const [selectedRovers, setSelectedRovers] = useState([]);
|
||||
const since = rangeEnd - rangeMs;
|
||||
const [query, setQuery] = useState('');
|
||||
const [sort, setSort] = useState({ key: 'name', descending: false });
|
||||
const { report, loading, error, refresh } = useFleetReport({
|
||||
since,
|
||||
since: rangeEnd - rangeMs,
|
||||
until: rangeEnd,
|
||||
compact: false,
|
||||
includeEvents: true,
|
||||
roverIds: selectedRovers.length ? selectedRovers : null,
|
||||
includeEvents: false,
|
||||
});
|
||||
|
||||
const chartRoverIds = useMemo(() => report?.rovers.map((rover) => rover.roverId) || [], [report]);
|
||||
const toggleRover = (roverId) => {
|
||||
setSelectedRovers((current) => current.includes(roverId)
|
||||
? current.filter((id) => id !== roverId)
|
||||
: [...current, roverId]);
|
||||
};
|
||||
const rows = useMemo(() => {
|
||||
const filtered = (report?.rovers || []).filter((rover) =>
|
||||
`${rover.name} ${rover.roverId}`.toLowerCase().includes(query.trim().toLowerCase()),
|
||||
);
|
||||
return [...filtered].sort((left, right) => {
|
||||
const leftValue = sort.key.startsWith('batteryHealth.')
|
||||
? left.batteryHealth?.[sort.key.slice('batteryHealth.'.length)]
|
||||
: left[sort.key];
|
||||
const rightValue = sort.key.startsWith('batteryHealth.')
|
||||
? right.batteryHealth?.[sort.key.slice('batteryHealth.'.length)]
|
||||
: right[sort.key];
|
||||
const result = typeof leftValue === 'string'
|
||||
? leftValue.localeCompare(String(rightValue ?? ''))
|
||||
: (Number(leftValue) || 0) - (Number(rightValue) || 0);
|
||||
return sort.descending ? -result : result;
|
||||
});
|
||||
}, [query, report, sort]);
|
||||
|
||||
function sortBy(key) {
|
||||
setSort((current) => ({ key, descending: current.key === key ? !current.descending : false }));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardFrame
|
||||
title="Fleet reports"
|
||||
meta={report ? `Generated ${timestamp(report.generatedAt)}` : 'Read-only operational history'}
|
||||
actions={<Link className="button-dark px-1 py-0.25 text-[0.75rem]" to="/">Back to rover page</Link>}
|
||||
bodyClassName="space-y-0.5 p-0.5 text-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
{RANGE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.label}
|
||||
type="button"
|
||||
className={`button-dark px-1 py-0.5 text-xs ${rangeMs === option.ms ? 'text-cyan-200' : ''}`}
|
||||
onClick={() => { setRangeMs(option.ms); setRangeEnd(Date.now()); }}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="button-dark px-1 py-0.5 text-xs" onClick={() => setRangeEnd(Date.now())}>Refresh</button>
|
||||
<span className="text-xs text-slate-400">{timestamp(since)} through {timestamp(rangeEnd)}</span>
|
||||
<CardFrame title="Fleet battery health and efficiency" meta={report ? timestamp(report.generatedAt) : 'Loading'} bodyClassName="space-y-0.5 p-0.5">
|
||||
<div className="surface flex flex-wrap items-end gap-0.5 p-0.5 text-xs">
|
||||
<label><span className="mr-0.5 text-slate-400">Range</span><select className="bg-neutral-800 p-0.5" value={rangeMs} onChange={(event) => setRangeMs(Number(event.target.value))}>{RANGE_OPTIONS.map((option) => <option key={option.ms} value={option.ms}>{option.label}</option>)}</select></label>
|
||||
<label><span className="mr-0.5 text-slate-400">Find rover</span><input className="bg-neutral-800 p-0.5" value={query} onChange={(event) => setQuery(event.target.value)} /></label>
|
||||
<button type="button" className="button-dark px-1 py-0.5" onClick={() => { setRangeEnd(Date.now()); refresh(); }}>Refresh</button>
|
||||
<Link className="button-dark px-1 py-0.5" to="/">Back</Link>
|
||||
</div>
|
||||
{report?.rovers.length ? (
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{report.rovers.map((rover) => (
|
||||
<button
|
||||
key={rover.roverId}
|
||||
type="button"
|
||||
className={`button-dark px-1 py-0.5 text-xs ${selectedRovers.includes(rover.roverId) ? 'text-cyan-200' : ''}`}
|
||||
onClick={() => toggleRover(rover.roverId)}
|
||||
>
|
||||
{selectedRovers.includes(rover.roverId) ? '✓ ' : ''}{rover.name}
|
||||
</button>
|
||||
))}
|
||||
{selectedRovers.length ? <button type="button" className="button-dark px-1 py-0.5 text-xs" onClick={() => setSelectedRovers([])}>Clear rover filter</button> : null}
|
||||
{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 xl:grid-cols-8">
|
||||
<Metric label="Rovers online" value={`${report.totals.onlineRoverCount}/${report.totals.roverCount}`} />
|
||||
<Metric label="Distance" value={distance(report.totals.distanceMm)} />
|
||||
<Metric label="Energy used" value={`${number(report.totals.dischargedWh)} Wh`} />
|
||||
<Metric label="Fleet Wh/km" value={report.totals.overallWhPerKm == null ? '--' : number(report.totals.overallWhPerKm)} />
|
||||
<Metric label="Moving Wh/km" value={report.totals.movingWhPerKm == null ? '--' : number(report.totals.movingWhPerKm)} />
|
||||
<Metric label="Moving energy" value={`${number(report.totals.movingDischargedWh)} Wh`} />
|
||||
<Metric label="Stationary energy" value={`${number(report.totals.stationaryDischargedWh)} Wh`} />
|
||||
<Metric label="Attention" value={number(report.totals.attentionCount, 0)} />
|
||||
</div>
|
||||
) : null}
|
||||
</CardFrame>
|
||||
|
||||
{loading && !report ? <CardFrame title="Loading" bodyClassName="p-1 text-sm text-slate-400">Loading detailed fleet evidence…</CardFrame> : null}
|
||||
{error ? <CardFrame title="Report unavailable" bodyClassName="p-1 text-sm text-red-300">{error}</CardFrame> : null}
|
||||
|
||||
{report ? (
|
||||
<>
|
||||
<CardFrame title="Exact totals" meta={`${report.totals.sampleCount.toLocaleString()} sensor samples`} bodyClassName="grid grid-cols-2 gap-0.5 p-0.5 md:grid-cols-4 xl:grid-cols-6">
|
||||
<Metric label="Rovers" value={`${report.totals.onlineRoverCount}/${report.totals.roverCount} online`} />
|
||||
<Metric label="Telemetry coverage" value={duration(report.totals.coverageMs)} />
|
||||
<Metric label="Telemetry gaps" value={number(report.totals.telemetryGapCount, 0)} />
|
||||
<Metric label="Commands" value={number(report.totals.commandCount, 0)} detail={`${number(report.totals.driveCommandCount, 0)} drive/motor`} />
|
||||
<Metric label="Rejected commands" value={number(report.totals.rejectedCommandCount, 0)} />
|
||||
<Metric label="Distance" value={distance(report.totals.distanceMm)} />
|
||||
<Metric label="Overcurrent episodes" value={number(report.totals.overcurrentEpisodeCount, 0)} />
|
||||
<Metric label="Bumps" value={number(report.totals.bumpCount, 0)} />
|
||||
<Metric label="Cliff episodes" value={number(report.totals.cliffCount, 0)} />
|
||||
<Metric label="Wheel drops" value={number(report.totals.wheelDropCount, 0)} />
|
||||
<Metric label="Virtual walls" value={number(report.totals.virtualWallCount, 0)} />
|
||||
<Metric label="Charged" value={`${number(report.totals.chargedMah)} mAh`} />
|
||||
<Metric label="Discharged" value={`${number(report.totals.dischargedMah)} mAh`} />
|
||||
<Metric label="Battery sessions" value={number(report.totals.batterySessionCount, 0)} />
|
||||
<Metric label="Warnings" value={number(report.totals.warningFindingCount, 0)} />
|
||||
<Metric label="Critical" value={number(report.totals.criticalFindingCount, 0)} />
|
||||
</CardFrame>
|
||||
|
||||
<CardFrame title="Needs attention" meta={report.findings.length} bodyClassName="space-y-0.5 p-0.5 text-xs">
|
||||
{report.findings.length ? report.findings.map((finding) => (
|
||||
<details key={finding.key} className="surface px-1 py-0.5">
|
||||
<summary className="cursor-pointer text-slate-100">
|
||||
<span className={finding.severity === 'critical' ? 'text-red-300' : finding.severity === 'warning' ? 'text-amber-300' : 'text-slate-400'}>{finding.severity}</span>
|
||||
{' · '}{finding.roverId ? `${finding.roverId} · ` : ''}{finding.title} · confidence {finding.confidence}
|
||||
</summary>
|
||||
<pre className="mt-0.5 overflow-x-auto whitespace-pre-wrap text-[0.68rem] text-slate-300">{JSON.stringify(finding.evidence, null, 2)}</pre>
|
||||
</details>
|
||||
)) : <p className="surface px-1 py-0.5 text-emerald-300">No findings in the selected range.</p>}
|
||||
</CardFrame>
|
||||
|
||||
<CardFrame
|
||||
title="Time series"
|
||||
meta={`${report.minutes.length.toLocaleString()} minute rows`}
|
||||
actions={(
|
||||
<select className="surface px-1 py-0.25 text-xs text-slate-100" value={metric} onChange={(event) => setMetric(event.target.value)}>
|
||||
{METRICS.map(([key, label]) => <option key={key} value={key}>{label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
bodyClassName="p-0.5"
|
||||
>
|
||||
<FleetTimeSeriesChart minutes={report.minutes} roverIds={chartRoverIds} metric={metric} />
|
||||
</CardFrame>
|
||||
|
||||
<CardFrame
|
||||
title="Rover comparison"
|
||||
meta={report.rovers.length}
|
||||
actions={<button type="button" className="button-dark px-1 py-0.25 text-xs" onClick={() => exportCsv('fleet-rovers.csv', report.rovers)}>Export CSV</button>}
|
||||
title="All rovers"
|
||||
meta={`${rows.length} visible`}
|
||||
actions={<button type="button" className="button-dark px-1 py-0.25 text-xs" onClick={() => exportCsv(rows)}>Export CSV</button>}
|
||||
bodyClassName="overflow-x-auto p-0.5"
|
||||
>
|
||||
<table className="w-full whitespace-nowrap text-left text-xs">
|
||||
<thead className="text-slate-400"><tr><th>Rover</th><th>Online</th><th>Samples</th><th>Coverage</th><th>Gaps</th><th>Distance</th><th>Commands</th><th>Drive/motor</th><th>Rejected</th><th>Overcurrents</th><th>Bumps</th><th>Cliffs</th><th>Wheel drops</th><th>Virtual walls</th><th>Charged mAh</th><th>Discharged mAh</th><th>Voltage avg/min/max mV</th><th>Temperature avg/min/max °C</th><th>Charge now mAh</th><th>Reported capacity mAh</th><th>Last sample</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 ? 'yes' : 'no'}</td><td>{number(rover.sampleCount, 0)}</td><td>{duration(rover.coverageMs)}</td><td>{number(rover.gapCount, 0)}</td><td>{distance(rover.distanceMm)}</td><td>{number(rover.commandCount, 0)}</td><td>{number(rover.driveCommandCount, 0)}</td><td>{number(rover.rejectedCommandCount, 0)}</td><td>{number(rover.overcurrentEpisodeCount, 0)}</td><td>{number(rover.bumpCount, 0)}</td><td>{number(rover.cliffCount, 0)}</td><td>{number(rover.wheelDropCount, 0)}</td><td>{number(rover.virtualWallCount, 0)}</td>
|
||||
<td>{number(rover.chargedMah)}</td><td>{number(rover.dischargedMah)}</td>
|
||||
<td>{number(rover.averageVoltageMv, 0)} / {number(rover.minimumVoltageMv, 0)} / {number(rover.maximumVoltageMv, 0)}</td>
|
||||
<td>{number(rover.averageTemperatureC)} / {number(rover.minimumTemperatureC)} / {number(rover.maximumTemperatureC)}</td>
|
||||
<td>{number(rover.latestChargeMah, 0)}</td><td>{number(rover.reportedCapacityMah, 0)}</td><td>{timestamp(rover.lastSampleAt)}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
<thead className="sticky top-0 bg-neutral-900 text-slate-400"><tr>
|
||||
<SortHeading field="name" sort={sort} onSort={sortBy}>Rover</SortHeading><SortHeading field="online" sort={sort} onSort={sortBy}>State</SortHeading>
|
||||
<SortHeading field="batteryHealth.capacityRetentionPercent" sort={sort} onSort={sortBy}>Health</SortHeading><SortHeading field="batteryHealth.measuredUsableMah" sort={sort} onSort={sortBy}>Usable Ah</SortHeading>
|
||||
<SortHeading field="batteryHealth.measuredUsableWh" sort={sort} onSort={sortBy}>Usable Wh</SortHeading><SortHeading field="batteryHealth.confidence" sort={sort} onSort={sortBy}>Confidence</SortHeading>
|
||||
<SortHeading field="overallWhPerKm" sort={sort} onSort={sortBy}>Wh/km</SortHeading><SortHeading field="movingWhPerKm" sort={sort} onSort={sortBy}>Moving Wh/km</SortHeading>
|
||||
<SortHeading field="distanceMm" sort={sort} onSort={sortBy}>Distance</SortHeading><SortHeading field="dischargedWh" sort={sort} onSort={sortBy}>Used Wh</SortHeading>
|
||||
<SortHeading field="movingDischargedWh" sort={sort} onSort={sortBy}>Moving Wh</SortHeading><SortHeading field="stationaryDischargedWh" sort={sort} onSort={sortBy}>Stationary Wh</SortHeading>
|
||||
<SortHeading field="movingMs" sort={sort} onSort={sortBy}>Moving time</SortHeading><SortHeading field="averageSpeedMmPerSecond" sort={sort} onSort={sortBy}>Average speed</SortHeading>
|
||||
<SortHeading field="maximumSpeedMmPerSecond" sort={sort} onSort={sortBy}>Maximum speed</SortHeading><SortHeading field="latestChargeMah" sort={sort} onSort={sortBy}>Charge</SortHeading>
|
||||
<SortHeading field="averageVoltageMv" sort={sort} onSort={sortBy}>Voltage</SortHeading><SortHeading field="averageCurrentMa" sort={sort} onSort={sortBy}>Current</SortHeading>
|
||||
<SortHeading field="maximumTemperatureC" sort={sort} onSort={sortBy}>Temperature</SortHeading><SortHeading field="lastSampleAt" sort={sort} onSort={sortBy}>Last data</SortHeading>
|
||||
</tr></thead>
|
||||
<tbody>{rows.map((rover) => {
|
||||
const health = rover.batteryHealth;
|
||||
return (
|
||||
<tr key={rover.roverId} className="border-t border-neutral-700/70 text-slate-200">
|
||||
<td className="sticky left-0 bg-neutral-900 font-semibold">{rover.name}</td><td>{rover.online ? 'online' : 'offline'}</td>
|
||||
<td>{health.capacityRetentionPercent == null ? '--' : `${number(health.capacityRetentionPercent, 1)}%`}</td>
|
||||
<td>{health.measuredUsableMah == null ? '--' : number(health.measuredUsableMah / 1000, 3)}</td>
|
||||
<td>{number(health.measuredUsableWh)}</td><td title={health.confidenceReason}>{health.confidence}</td>
|
||||
<td>{rover.overallWhPerKm == null ? `need ${distance(rover.efficiencyDistanceRequiredMm)}` : number(rover.overallWhPerKm)}</td>
|
||||
<td>{number(rover.movingWhPerKm)}</td><td>{distance(rover.distanceMm)}</td><td>{number(rover.dischargedWh)}</td>
|
||||
<td>{number(rover.movingDischargedWh)}</td><td>{number(rover.stationaryDischargedWh)}</td><td>{duration(rover.movingMs)}</td>
|
||||
<td>{rover.averageSpeedMmPerSecond == null ? '--' : `${number(rover.averageSpeedMmPerSecond)} mm/s`}</td>
|
||||
<td>{rover.maximumSpeedMmPerSecond == null ? '--' : `${number(rover.maximumSpeedMmPerSecond)} mm/s`}</td>
|
||||
<td>{number(rover.latestChargeMah, 0)} mAh</td><td>{number(rover.averageVoltageMv, 0)} mV</td>
|
||||
<td>{number(rover.averageCurrentMa, 0)} mA</td><td>{number(rover.maximumTemperatureC, 1)} °C</td><td>{timestamp(rover.lastSampleAt)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}</tbody>
|
||||
</table>
|
||||
</CardFrame>
|
||||
|
||||
<CardFrame
|
||||
title="Battery sessions"
|
||||
meta={report.batterySessions.length}
|
||||
actions={<button type="button" className="button-dark px-1 py-0.25 text-xs" onClick={() => exportCsv('fleet-battery-sessions.csv', report.batterySessions)}>Export CSV</button>}
|
||||
bodyClassName="overflow-x-auto p-0.5"
|
||||
>
|
||||
<CardFrame title="Battery-health evidence" meta="All rovers" bodyClassName="overflow-x-auto p-0.5">
|
||||
<table className="w-full whitespace-nowrap text-left text-xs">
|
||||
<thead className="text-slate-400"><tr><th>Rover</th><th>Kind</th><th>Started</th><th>Ended</th><th>Duration</th><th>Start/end charge</th><th>Charged mAh</th><th>Discharged mAh</th><th>Voltage min/max</th><th>Temperature min/max</th><th>Samples</th><th>Gaps</th><th>Confidence</th><th>Qualification</th></tr></thead>
|
||||
<tbody>{report.batterySessions.map((session) => (
|
||||
<tr key={session.id} className="border-t border-neutral-700/70 text-slate-200">
|
||||
<td>{session.roverId}</td><td>{session.kind}</td><td>{timestamp(session.startedAt)}</td><td>{timestamp(session.endedAt)}</td><td>{duration((session.endedAt || report.generatedAt) - session.startedAt)}</td>
|
||||
<td>{number(session.startChargeMah, 0)} / {number(session.endChargeMah, 0)} mAh</td><td>{number(session.chargedMah)}</td><td>{number(session.dischargedMah)}</td>
|
||||
<td>{number(session.minVoltageMv, 0)} / {number(session.maxVoltageMv, 0)} mV</td><td>{number(session.minTemperatureC)} / {number(session.maxTemperatureC)} °C</td>
|
||||
<td>{number(session.sampleCount, 0)}</td><td>{number(session.gapCount, 0)}</td><td>{session.confidence}</td><td>{session.qualificationReason}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
<thead className="text-slate-400"><tr><th>Rover</th><th>Battery</th><th>Reference mAh</th><th>Estimated usable mAh</th><th>Observed range floor</th><th>Retention</th><th>Observations</th><th>Average depth</th><th>Throughput</th><th>Latest evidence</th><th>Confidence basis</th></tr></thead>
|
||||
<tbody>{rows.map((rover) => {
|
||||
const health = rover.batteryHealth;
|
||||
return <tr key={rover.roverId} className="border-t border-neutral-700/70 text-slate-200"><td>{rover.name}</td><td>{health.batteryKey}</td><td>{number(health.referenceMah, 0)}</td><td>{number(health.measuredUsableMah, 0)}</td><td>{number(health.observedUsableFloorMah, 0)}</td><td>{health.capacityRetentionPercent == null ? '--' : `${number(health.capacityRetentionPercent, 1)}%`}</td><td>{number(health.observationCount, 0)}</td><td>{number(health.averageObservationDepthPercent, 1)}%</td><td>{number(health.dischargedThroughputMah, 0)} mAh</td><td>{timestamp(health.latestObservationAt)}</td><td>{health.confidenceReason}</td></tr>;
|
||||
})}</tbody>
|
||||
</table>
|
||||
</CardFrame>
|
||||
|
||||
<CardFrame title="Battery health calculations" meta={report.batteryHealth.length} bodyClassName="overflow-x-auto p-0.5">
|
||||
<table className="w-full whitespace-nowrap text-left text-xs">
|
||||
<thead className="text-slate-400"><tr><th>Rover</th><th>Battery identity</th><th>Qualified tests</th><th>Baseline tests</th><th>Learned baseline mAh</th><th>Latest usable mAh</th><th>Capacity retained</th><th>Discharged throughput mAh</th><th>Equivalent full cycles</th><th>Latest qualified test</th><th>Confidence</th><th>Confidence reason</th></tr></thead>
|
||||
<tbody>{report.batteryHealth.map((health) => (
|
||||
<tr key={health.roverId} className="border-t border-neutral-700/70 text-slate-200">
|
||||
<td>{health.roverId}</td><td>{health.batteryKey}</td><td>{number(health.qualifiedTestCount, 0)}</td><td>{number(health.baselineTestCount, 0)}</td>
|
||||
<td>{number(health.baselineMah)}</td><td>{number(health.measuredUsableMah)}</td><td>{health.capacityRetentionPercent == null ? '--' : `${number(health.capacityRetentionPercent)}%`}</td>
|
||||
<td>{number(health.dischargedThroughputMah)}</td><td>{number(health.equivalentFullCycles, 3)}</td><td>{timestamp(health.latestQualifiedTestAt)}</td><td>{health.confidence}</td><td>{health.confidenceReason}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</CardFrame>
|
||||
{report.attention.length ? (
|
||||
<CardFrame title="Attention" meta={report.attention.length} bodyClassName="p-0.5 text-xs">
|
||||
<table className="w-full text-left"><thead className="text-slate-400"><tr><th>Rover</th><th>Severity</th><th>Reason</th></tr></thead><tbody>{report.attention.map((item) => <tr key={item.key} className="border-t border-neutral-700/70"><td>{item.roverId}</td><td>{item.severity}</td><td>{item.title}</td></tr>)}</tbody></table>
|
||||
</CardFrame>
|
||||
) : null}
|
||||
|
||||
<BatteryRegistryPanel report={report} onSaved={refresh} />
|
||||
|
||||
<CardFrame
|
||||
title="Structured event history"
|
||||
meta={`${report.events.length} returned`}
|
||||
actions={<button type="button" className="button-dark px-1 py-0.25 text-xs" onClick={() => exportCsv('fleet-events.csv', report.events)}>Export CSV</button>}
|
||||
bodyClassName="space-y-0.5 p-0.5"
|
||||
>
|
||||
{report.events.map((event) => (
|
||||
<details key={event.id} className="surface px-1 py-0.5 text-xs">
|
||||
<summary className="cursor-pointer text-slate-200">{timestamp(event.ts)} · {event.severity} · {event.source} · {event.type}{event.roverId ? ` · ${event.roverId}` : ''}</summary>
|
||||
<pre className="mt-0.5 overflow-x-auto whitespace-pre-wrap text-[0.68rem] text-slate-300">{JSON.stringify(event.payload, null, 2)}</pre>
|
||||
</details>
|
||||
))}
|
||||
</CardFrame>
|
||||
|
||||
<CardFrame title="Daily report and Discord delivery history" meta={report.dailyReportHistory.length} bodyClassName="overflow-x-auto p-0.5">
|
||||
<table className="w-full whitespace-nowrap text-left text-xs">
|
||||
<thead className="text-slate-400"><tr><th>Report day</th><th>Generated</th><th>Stored bytes</th><th>Discord delivered</th><th>Delivery error</th></tr></thead>
|
||||
<tbody>{report.dailyReportHistory.map((daily) => (
|
||||
<tr key={daily.reportDate} className="border-t border-neutral-700/70 text-slate-200">
|
||||
<td>{daily.reportDate}</td><td>{timestamp(daily.generatedAt)}</td><td>{number(daily.reportBytes, 0)}</td><td>{timestamp(daily.discordDeliveredAt)}</td><td>{daily.discordError || '--'}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</CardFrame>
|
||||
|
||||
<CardFrame title="Collector and storage diagnostics" bodyClassName="grid gap-0.5 p-0.5 md:grid-cols-2">
|
||||
<pre className="surface overflow-x-auto whitespace-pre-wrap p-1 text-[0.68rem] text-slate-300">{JSON.stringify(report.diagnostics.collector, null, 2)}</pre>
|
||||
<pre className="surface overflow-x-auto whitespace-pre-wrap p-1 text-[0.68rem] text-slate-300">{JSON.stringify(report.diagnostics.storage, null, 2)}</pre>
|
||||
</CardFrame>
|
||||
<BatteryRegistry report={report} refresh={refresh} />
|
||||
|
||||
<CardFrame title="Methodology" bodyClassName="space-y-0.5 p-1 text-xs text-slate-300">
|
||||
<p>Battery throughput integrates OI packet 23 signed battery current: delta mAh = current mA × elapsed milliseconds / 3,600,000. Positive current is charged throughput; negative current is discharged throughput.</p>
|
||||
<p>Intervals exceeding the configured maximum gap are excluded rather than interpreted as zero. Packet 25 charge movement is retained as independent evidence. Packet 26 is displayed as the rover-reported fixed reference and is not treated as measured battery health.</p>
|
||||
<p>A high-confidence capacity test requires a qualified full endpoint, continuous telemetry, configured minimum discharge depth, and a low endpoint. Partial or interrupted sessions remain visible with lower confidence and an explicit qualification reason.</p>
|
||||
<p>Energy is integrated per sensor interval from OI voltage and signed current. Existing odometer distance and speed are consumed directly; reporting does not calculate a competing speed value.</p>
|
||||
<p>Capacity combines partial discharge current with packet 25 charge movement. Every usable observation contributes; deeper observations increase confidence without labeling cycles good or bad. Packet 26 is only a fixed reference.</p>
|
||||
<p>Wh/km appears after 25 m of selected-range odometer travel. Existing historical rows predate exact power integration, so their Wh fields remain zero rather than being backfilled with false precision.</p>
|
||||
</CardFrame>
|
||||
</>
|
||||
) : null}
|
||||
@@ -348,9 +261,8 @@ export default function FleetReportsApp() {
|
||||
useUserIdentitySync({ identitySurface: 'passive' });
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'fleetReports'));
|
||||
const { value: pageSettings } = useSettingsNamespace('page', { backgroundTheme: DEFAULT_PAGE_THEME_KEY });
|
||||
const pageBackgroundClass = getPageThemeClass(pageSettings?.backgroundTheme);
|
||||
return (
|
||||
<div className={`${pageBackgroundClass} min-h-screen text-slate-100`}>
|
||||
<div className={`${getPageThemeClass(pageSettings?.backgroundTheme)} min-h-screen text-slate-100`}>
|
||||
<SocketConnectionPill />
|
||||
<main className={`mx-auto flex min-h-screen w-full max-w-[120rem] flex-col ${themeGapClass} p-1`}>
|
||||
{enabled ? <FullReportContent /> : (
|
||||
|
||||
Reference in New Issue
Block a user