feat: add direct car telemetry management
This commit is contained in:
+1
-1
@@ -232,7 +232,7 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
|
||||
{page === "home" ? <>
|
||||
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}<button onClick={() => void refresh()}>Try again</button></div>}
|
||||
{!status ? <DashboardSkeleton /> : <StatusContent status={status} />}
|
||||
</> : page === "car" ? <CarPage /> : page === "adb" ? <AdbPage csrfToken={auth.csrfToken} /> : page === "network" ? <NetworkActivityPage /> : <SettingsPage csrfToken={auth.csrfToken} />}
|
||||
</> : page === "car" ? <CarPage csrfToken={auth.csrfToken} /> : page === "adb" ? <AdbPage csrfToken={auth.csrfToken} /> : page === "network" ? <NetworkActivityPage /> : <SettingsPage csrfToken={auth.csrfToken} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
+46
-3
@@ -10,16 +10,19 @@ import {
|
||||
Speedometer,
|
||||
SteeringWheel,
|
||||
Timer,
|
||||
Trash,
|
||||
Trophy,
|
||||
WarningCircle
|
||||
} from "@phosphor-icons/react";
|
||||
import {
|
||||
clearCarStatistics,
|
||||
getCarHistory,
|
||||
getCarStatistics,
|
||||
getCarTelemetry,
|
||||
type Availability,
|
||||
type CarStatistics,
|
||||
type CarTelemetry,
|
||||
type ClearTelemetryMode,
|
||||
type TelemetryHistoryPoint,
|
||||
type TelemetryPeriod
|
||||
} from "./api";
|
||||
@@ -32,12 +35,15 @@ const periods: Array<{ value: TelemetryPeriod; label: string }> = [
|
||||
{ value: "all", label: "All" }
|
||||
];
|
||||
|
||||
export function CarPage() {
|
||||
export function CarPage({ csrfToken }: { csrfToken: string }) {
|
||||
const [telemetry, setTelemetry] = useState<CarTelemetry | null>(null);
|
||||
const [statistics, setStatistics] = useState<CarStatistics | null>(null);
|
||||
const [history, setHistory] = useState<TelemetryHistoryPoint[]>([]);
|
||||
const [period, setPeriod] = useState<TelemetryPeriod>("24h");
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [clearDialog, setClearDialog] = useState(false);
|
||||
const [clearing, setClearing] = useState<ClearTelemetryMode | null>(null);
|
||||
|
||||
const refreshLive = useCallback(async () => {
|
||||
try {
|
||||
@@ -74,9 +80,27 @@ export function CarPage() {
|
||||
const angle = telemetry?.wheelAngleDegrees.available ? telemetry.wheelAngleDegrees.value : 0;
|
||||
const speedArc = Math.min(Math.max((speed ?? 0) / 180, 0), 1);
|
||||
|
||||
const clearStatistics = async (mode: ClearTelemetryMode) => {
|
||||
setClearing(mode);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
await clearCarStatistics(mode, csrfToken);
|
||||
setHistory([]);
|
||||
await refreshHistory();
|
||||
setClearDialog(false);
|
||||
setNotice(mode === "all" ? "All car statistics were cleared." : "History cleared. All-time records were kept.");
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Car statistics could not be cleared");
|
||||
} finally {
|
||||
setClearing(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="car-page">
|
||||
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}</div>}
|
||||
{notice && <div className="status-success" role="status">{notice}</div>}
|
||||
<section className="drive-stage">
|
||||
<div className="speed-cluster">
|
||||
<div className="speed-arc" style={{ "--speed": speedArc } as React.CSSProperties} />
|
||||
@@ -138,13 +162,32 @@ export function CarPage() {
|
||||
<section className="telemetry-storage">
|
||||
<Database size={26} />
|
||||
<div><strong>Bounded telemetry archive</strong><p>Raw samples: {statistics?.storage.rawSamples.toLocaleString() ?? "0"} of {statistics?.storage.rawSampleCap.toLocaleString() ?? "800,000"}. Hourly rollups: {statistics?.storage.hourlyRollups.toLocaleString() ?? "0"}.</p></div>
|
||||
<div><strong>{formatBytes(statistics?.storage.estimatedBytes ?? 0)}</strong><span>{statistics?.storage.rawRetentionDays ?? 90} days raw, {statistics?.storage.rollupRetentionYears ?? 10} years rolled up</span></div>
|
||||
<div className="telemetry-storage-summary"><strong>{formatBytes(statistics?.storage.estimatedBytes ?? 0)}</strong><span>{statistics?.storage.rawRetentionDays ?? 90} days raw, {statistics?.storage.rollupRetentionYears ?? 10} years rolled up</span></div>
|
||||
<button className="secondary-button telemetry-clear-button" onClick={() => setClearDialog(true)}><Trash size={20} />Clear statistics</button>
|
||||
</section>
|
||||
|
||||
<footer className="telemetry-footer">
|
||||
<span>Sources: SAIC vehicle service through MG Utility and live ADB properties</span>
|
||||
<span>Sources: factory SAIC vehicle service and live properties, queried directly over ADB</span>
|
||||
<span>{telemetry ? `Updated ${new Date(telemetry.collectedAt).toLocaleTimeString()}` : "Waiting for telemetry"}</span>
|
||||
</footer>
|
||||
|
||||
{clearDialog && <div className="modal-backdrop" onMouseDown={() => !clearing && setClearDialog(false)}>
|
||||
<div className="confirm-dialog telemetry-clear-dialog" role="dialog" aria-modal="true" aria-labelledby="clear-statistics-title" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<h2 id="clear-statistics-title">Clear car statistics</h2>
|
||||
<p>Choose whether to keep your long-term records. This cannot be undone.</p>
|
||||
<div className="clear-statistics-options">
|
||||
<button className="secondary-button" onClick={() => void clearStatistics("history")} disabled={Boolean(clearing)}>
|
||||
<span>{clearing === "history" ? "Clearing..." : "Clear data, keep all-time info"}</span>
|
||||
<small>Deletes graphs and stored history. Keeps peaks, tracked totals, charging sessions, and range learning.</small>
|
||||
</button>
|
||||
<button className="danger-button" onClick={() => void clearStatistics("all")} disabled={Boolean(clearing)}>
|
||||
<span>{clearing === "all" ? "Clearing..." : "Clear ALL data"}</span>
|
||||
<small>Deletes history and resets every peak, total, counter, and learned estimate.</small>
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-actions"><button className="secondary-button" onClick={() => setClearDialog(false)} disabled={Boolean(clearing)}>Cancel</button></div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -282,3 +282,9 @@ export async function getCarHistory(period: TelemetryPeriod): Promise<{ period:
|
||||
export async function getCarStatistics(): Promise<CarStatistics> {
|
||||
return parseResponse<CarStatistics>(await fetch("/api/car/statistics", { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
export type ClearTelemetryMode = "history" | "all";
|
||||
|
||||
export async function clearCarStatistics(mode: ClearTelemetryMode, csrfToken: string): Promise<void> {
|
||||
await post("/api/car/history/clear", { mode }, csrfToken);
|
||||
}
|
||||
|
||||
+11
-4
@@ -374,13 +374,19 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.chart-guide { stroke: #30372d; stroke-width: 1; vector-effect: non-scaling-stroke; }
|
||||
.chart-scale { display: flex; justify-content: space-between; gap: 12px; color: var(--muted); font: 0.66rem/1.3 ui-monospace, "Cascadia Code", monospace; }
|
||||
.chart-empty { min-height: 220px; display: grid !important; place-items: center; color: var(--muted); font-size: 0.78rem; text-align: center; }
|
||||
.telemetry-storage { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 15px; padding: 20px 23px; border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.telemetry-storage { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 15px; padding: 20px 23px; border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.telemetry-storage > svg { color: var(--accent); }
|
||||
.telemetry-storage > div { min-width: 0; }
|
||||
.telemetry-storage strong { display: block; margin-bottom: 5px; }
|
||||
.telemetry-storage p, .telemetry-storage span { margin: 0; color: var(--muted); font-size: 0.75rem; line-height: 1.4; }
|
||||
.telemetry-storage > div:last-child { text-align: right; }
|
||||
.telemetry-storage > div:last-child strong { font: 750 1.15rem/1 ui-monospace, "Cascadia Code", monospace; }
|
||||
.telemetry-storage-summary { text-align: right; }
|
||||
.telemetry-storage-summary strong { font: 750 1.15rem/1 ui-monospace, "Cascadia Code", monospace; }
|
||||
.telemetry-clear-button { min-height: 46px; padding: 0 17px; }
|
||||
.clear-statistics-options { display: grid; gap: 10px; margin-top: 22px; }
|
||||
.clear-statistics-options button { width: 100%; min-height: 82px; height: auto; display: grid; justify-items: start; gap: 5px; padding: 15px 18px; font-size: 0.82rem; text-align: left; white-space: normal; }
|
||||
.clear-statistics-options button small { color: var(--muted); font-size: 0.72rem; font-weight: 500; line-height: 1.4; }
|
||||
.clear-statistics-options .danger-button small { color: #e7b8b1; }
|
||||
.status-success { display: flex; align-items: center; min-height: 52px; padding: 12px 16px; color: var(--accent); background: rgba(185, 231, 105, 0.08); border: 1px solid rgba(185, 231, 105, 0.24); border-radius: 10px; }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.adb-command-grid { grid-template-columns: 1fr; }
|
||||
@@ -418,5 +424,6 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.history-panel { padding: 19px; }
|
||||
.history-chart svg { height: 180px; }
|
||||
.telemetry-storage { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.telemetry-storage > div:last-child { grid-column: 1 / -1; text-align: left; }
|
||||
.telemetry-storage-summary { grid-column: 1 / -1; text-align: left; }
|
||||
.telemetry-clear-button { grid-column: 1 / -1; width: 100%; padding: 0 17px; font-size: inherit; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user