761 lines
38 KiB
TypeScript
761 lines
38 KiB
TypeScript
import { lazy, Suspense, useCallback, useEffect, useState, type FormEvent } from "react";
|
|
import {
|
|
ArrowClockwise,
|
|
AndroidLogo,
|
|
CarProfile,
|
|
CheckCircle,
|
|
Cpu,
|
|
Gauge,
|
|
HardDrives,
|
|
House,
|
|
Gear,
|
|
GitBranch,
|
|
DownloadSimple,
|
|
Power,
|
|
SignOut,
|
|
TerminalWindow,
|
|
WarningCircle,
|
|
WifiHigh
|
|
} from "@phosphor-icons/react";
|
|
import { AdbPage } from "./AdbPage";
|
|
import { CarPage } from "./CarPage";
|
|
import {
|
|
ApiError,
|
|
getAuthState,
|
|
getStatus,
|
|
getNetworkStatus,
|
|
getNetworkActivity,
|
|
getUpdateStatus,
|
|
post,
|
|
type AuthState,
|
|
type SystemStatus,
|
|
type NetworkStatus,
|
|
type NetworkActivity,
|
|
type UpdateStatus
|
|
} from "./api";
|
|
|
|
const ShellTerminal = lazy(async () => {
|
|
const module = await import("./ShellTerminal");
|
|
return { default: module.ShellTerminal };
|
|
});
|
|
|
|
type Screen = "loading" | "setup" | "login" | "dashboard" | "fatal";
|
|
|
|
export function App() {
|
|
const [screen, setScreen] = useState<Screen>("loading");
|
|
const [auth, setAuth] = useState<AuthState | null>(null);
|
|
const [fatalError, setFatalError] = useState("");
|
|
|
|
const refreshAuth = useCallback(async () => {
|
|
try {
|
|
const state = await getAuthState();
|
|
setAuth(state);
|
|
setScreen(state.setupRequired ? "setup" : state.user ? "dashboard" : "login");
|
|
} catch (error) {
|
|
setFatalError(error instanceof Error ? error.message : "The companion service is unreachable");
|
|
setScreen("fatal");
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => void refreshAuth(), [refreshAuth]);
|
|
|
|
if (screen === "loading") return <BootScreen />;
|
|
if (screen === "fatal") return <ConnectionError message={fatalError} retry={() => void refreshAuth()} />;
|
|
if (!auth) return null;
|
|
if (screen === "setup") return <AccountScreen mode="setup" csrfToken={auth.csrfToken} onComplete={refreshAuth} />;
|
|
if (screen === "login") return <AccountScreen mode="login" csrfToken={auth.csrfToken} onComplete={refreshAuth} />;
|
|
return <Dashboard auth={auth} onLoggedOut={refreshAuth} />;
|
|
}
|
|
|
|
function BootScreen() {
|
|
return (
|
|
<main className="center-screen" aria-live="polite">
|
|
<div className="boot-mark"><Gauge size={44} weight="duotone" /></div>
|
|
<p className="eyebrow">Pi Car Companion</p>
|
|
<h1>Connecting to your Pi</h1>
|
|
<div className="loading-line" aria-hidden="true"><span /></div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function ConnectionError({ message, retry }: { message: string; retry: () => void }) {
|
|
return (
|
|
<main className="center-screen error-screen">
|
|
<WarningCircle size={52} weight="duotone" aria-hidden="true" />
|
|
<p className="eyebrow">Connection unavailable</p>
|
|
<h1>The Pi did not respond</h1>
|
|
<p className="screen-copy">{message}. Check power and network access, then reconnect.</p>
|
|
<button className="primary-button" onClick={retry}><ArrowClockwise size={22} /> Reconnect</button>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function AccountScreen({
|
|
mode,
|
|
csrfToken,
|
|
onComplete
|
|
}: {
|
|
mode: "setup" | "login";
|
|
csrfToken: string;
|
|
onComplete: () => Promise<void>;
|
|
}) {
|
|
const [username, setUsername] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [error, setError] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const setup = mode === "setup";
|
|
|
|
async function submit(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
setError("");
|
|
setSubmitting(true);
|
|
try {
|
|
if (setup) await post("/api/auth/setup", { username, password }, csrfToken);
|
|
await post("/api/auth/login", { username, password }, csrfToken);
|
|
await onComplete();
|
|
} catch (caught) {
|
|
setError(caught instanceof ApiError ? caught.message : "The request could not be completed");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main className="account-layout">
|
|
<section className="account-context">
|
|
<div className="brand"><Gauge size={32} weight="duotone" /><span>Pi Car Companion</span></div>
|
|
<div>
|
|
<p className="eyebrow">{setup ? "First run" : "Local access"}</p>
|
|
<h1>{setup ? "Secure your companion." : "Welcome back."}</h1>
|
|
<p>{setup ? "Create the only initial administrator. There are no default credentials." : "Sign in to view live Pi status and controlled actions."}</p>
|
|
</div>
|
|
<p className="local-note"><WifiHigh size={22} /> Credentials stay on this Pi.</p>
|
|
</section>
|
|
<section className="account-form-wrap">
|
|
<form className="account-form" onSubmit={(event) => void submit(event)}>
|
|
<h2>{setup ? "Create administrator" : "Sign in"}</h2>
|
|
<label>
|
|
<span>Username</span>
|
|
<input autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} minLength={3} maxLength={48} required autoFocus />
|
|
</label>
|
|
<label>
|
|
<span>Password</span>
|
|
<input
|
|
type="password"
|
|
autoComplete={setup ? "new-password" : "current-password"}
|
|
value={password}
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
minLength={setup ? 8 : 1}
|
|
maxLength={256}
|
|
pattern={setup ? "(?=.*[A-Z])(?=.*[0-9]).{8,}" : undefined}
|
|
title={setup ? "Use at least 8 characters, one uppercase letter, and one number." : undefined}
|
|
required
|
|
/>
|
|
{setup && <small>Use at least 8 characters, one uppercase letter, and one number.</small>}
|
|
</label>
|
|
{error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>}
|
|
<button className="primary-button" type="submit" disabled={submitting}>
|
|
{submitting ? "Working..." : setup ? "Create account" : "Sign in"}
|
|
</button>
|
|
</form>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () => Promise<void> }) {
|
|
const [page, setPage] = useState<"home" | "car" | "adb" | "network" | "settings">("home");
|
|
const [status, setStatus] = useState<SystemStatus | null>(null);
|
|
const [error, setError] = useState("");
|
|
const [connection, setConnection] = useState<"connecting" | "live" | "offline">("connecting");
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
const refresh = useCallback(async () => {
|
|
setRefreshing(true);
|
|
try {
|
|
setStatus(await getStatus());
|
|
setError("");
|
|
} catch (caught) {
|
|
setError(caught instanceof Error ? caught.message : "Status could not be loaded");
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
const events = new EventSource("/api/events");
|
|
events.addEventListener("open", () => setConnection("live"));
|
|
events.addEventListener("status", (event) => {
|
|
setStatus(JSON.parse((event as MessageEvent<string>).data) as SystemStatus);
|
|
setConnection("live");
|
|
setError("");
|
|
});
|
|
events.addEventListener("error", () => setConnection("offline"));
|
|
return () => events.close();
|
|
}, [refresh]);
|
|
|
|
async function logout() {
|
|
await post("/api/auth/logout", {}, auth.csrfToken);
|
|
await onLoggedOut();
|
|
}
|
|
|
|
return (
|
|
<div className="app-shell">
|
|
<aside className="sidebar">
|
|
<div className="brand brand-compact"><Gauge size={30} weight="duotone" /><span>Pi Companion</span></div>
|
|
<nav aria-label="Primary navigation">
|
|
<button className={`nav-item ${page === "home" ? "active" : ""}`} onClick={() => setPage("home")}><House size={24} weight={page === "home" ? "fill" : "regular"} /><span>Home</span></button>
|
|
<button className={`nav-item ${page === "car" ? "active" : ""}`} onClick={() => setPage("car")}><CarProfile size={24} weight={page === "car" ? "fill" : "regular"} /><span>Car</span></button>
|
|
<button className={`nav-item ${page === "adb" ? "active" : ""}`} onClick={() => setPage("adb")}><AndroidLogo size={24} weight={page === "adb" ? "fill" : "regular"} /><span>ADB</span></button>
|
|
<button className={`nav-item ${page === "network" ? "active" : ""}`} onClick={() => setPage("network")}><WifiHigh size={24} weight={page === "network" ? "fill" : "regular"} /><span>Network</span></button>
|
|
<button className={`nav-item ${page === "settings" ? "active" : ""}`} onClick={() => setPage("settings")}><Gear size={24} weight={page === "settings" ? "fill" : "regular"} /><span>Settings</span></button>
|
|
</nav>
|
|
<button className="nav-item logout" onClick={() => void logout()}><SignOut size={24} /><span>Sign out</span></button>
|
|
</aside>
|
|
<main className="dashboard">
|
|
<header className="dashboard-header">
|
|
<div>
|
|
<p className="eyebrow">{page === "home" ? "System overview" : page === "car" ? "Live vehicle signals" : page === "adb" ? "Android device bridge" : page === "network" ? "Live diagnostics" : "Companion settings"}</p>
|
|
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : page === "car" ? "Car" : page === "adb" ? "ADB" : page === "network" ? "Network activity" : "Settings"}</h1>
|
|
</div>
|
|
<div className="header-actions">
|
|
<div className={`connection-state ${connection}`}>
|
|
<span aria-hidden="true" /> {connection === "live" ? "Live" : connection === "connecting" ? "Connecting" : "Reconnecting"}
|
|
</div>
|
|
{page === "home" && <button className="secondary-button" onClick={() => void refresh()} disabled={refreshing}>
|
|
<ArrowClockwise size={22} className={refreshing ? "spin" : ""} /> Refresh
|
|
</button>}
|
|
</div>
|
|
</header>
|
|
|
|
{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} />}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NetworkActivityPage() {
|
|
const [activity, setActivity] = useState<NetworkActivity | null>(null);
|
|
const [error, setError] = useState("");
|
|
const [paused, setPaused] = useState(false);
|
|
|
|
const refresh = useCallback(async () => {
|
|
try {
|
|
setActivity(await getNetworkActivity());
|
|
setError("");
|
|
} catch (caught) {
|
|
setError(caught instanceof Error ? caught.message : "Network activity is unavailable");
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
if (paused) return;
|
|
const interval = window.setInterval(() => void refresh(), 2_000);
|
|
return () => window.clearInterval(interval);
|
|
}, [paused, refresh]);
|
|
|
|
const connected = activity?.clients.filter((client) => client.connected).length ?? 0;
|
|
return (
|
|
<div className="activity-layout">
|
|
<section className="activity-summary">
|
|
<div><span>Connected clients</span><strong>{connected}</strong></div>
|
|
<div><span>Recent DNS queries</span><strong>{activity?.dnsQueries.length ?? 0}</strong></div>
|
|
<div><span>Active flows</span><strong>{activity?.flows.length ?? 0}</strong></div>
|
|
<div><span>Snapshot</span><strong>{activity?.supported ? new Date(activity.collectedAt).toLocaleTimeString() : "Unavailable"}</strong></div>
|
|
<button className="secondary-button text-button" onClick={() => setPaused((value) => !value)}>{paused ? "Resume live view" : "Pause live view"}</button>
|
|
</section>
|
|
|
|
{error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>}
|
|
{activity?.messages.map((message) => <div className="activity-message" key={message}><WarningCircle size={18} />{message}</div>)}
|
|
|
|
<section className="activity-clients activity-panel">
|
|
<div className="activity-heading"><div><h2>Hotspot clients</h2><p>DHCP leases and current neighbor reachability.</p></div></div>
|
|
<div className="client-grid">
|
|
{activity?.clients.length ? activity.clients.map((client) => (
|
|
<div className="client-row" key={`${client.ipAddress}-${client.macAddress}`}>
|
|
<span className={`client-dot ${client.connected ? "online" : ""}`} aria-hidden="true" />
|
|
<div><strong>{client.hostname ?? client.ipAddress}</strong><small>{client.hostname ? client.ipAddress : client.macAddress}</small></div>
|
|
<code>{client.hostname ? client.macAddress : client.state}</code>
|
|
<span>{client.connected ? "Connected" : client.state}</span>
|
|
</div>
|
|
)) : <ActivityEmpty text="No hotspot clients observed." />}
|
|
</div>
|
|
</section>
|
|
|
|
<div className="activity-columns">
|
|
<section className="activity-panel">
|
|
<div className="activity-heading"><div><h2>DNS requests</h2><p>Query metadata from clients during the last 15 minutes.</p></div><span>{activity?.dnsAvailable ? "Live" : "Unavailable"}</span></div>
|
|
<div className="activity-table-wrap">
|
|
<table className="activity-table"><thead><tr><th>Time</th><th>Client</th><th>Type</th><th>Name</th></tr></thead><tbody>
|
|
{activity?.dnsQueries.map((query, index) => <tr key={`${query.timestamp}-${query.clientAddress}-${query.name}-${index}`}><td>{new Date(query.timestamp).toLocaleTimeString()}</td><td><code>{query.clientAddress}</code></td><td>{query.type}</td><td className="request-name">{query.name}</td></tr>)}
|
|
</tbody></table>
|
|
{!activity?.dnsQueries.length && <ActivityEmpty text="No DNS requests captured yet." />}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="activity-panel">
|
|
<div className="activity-heading"><div><h2>Active connections</h2><p>Destination metadata only; encrypted content remains private.</p></div><span>{activity?.flowsAvailable ? "Live" : "Unavailable"}</span></div>
|
|
<div className="activity-table-wrap">
|
|
<table className="activity-table"><thead><tr><th>Client</th><th>Protocol</th><th>Destination</th><th>State</th></tr></thead><tbody>
|
|
{activity?.flows.map((flow, index) => <tr key={`${flow.sourceAddress}-${flow.sourcePort}-${flow.destinationAddress}-${flow.destinationPort}-${index}`}><td><code>{flow.sourceAddress}</code></td><td>{flow.protocol.toUpperCase()}</td><td><code>{flow.destinationAddress}:{flow.destinationPort}</code><small>{portLabel(flow.destinationPort)}</small></td><td>{flow.state}</td></tr>)}
|
|
</tbody></table>
|
|
{!activity?.flows.length && <ActivityEmpty text="No active client connections observed." />}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
<p className="activity-privacy">This page records bounded connection metadata only. It does not capture payloads, passwords, cookies, or HTTPS paths.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActivityEmpty({ text }: { text: string }) {
|
|
return <div className="activity-empty">{text}</div>;
|
|
}
|
|
|
|
function portLabel(port: number): string {
|
|
return ({ 53: "DNS", 80: "HTTP", 123: "NTP", 443: "HTTPS", 853: "DNS over TLS" } as Record<number, string>)[port] ?? "";
|
|
}
|
|
|
|
function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
|
const [tab, setTab] = useState<"network" | "system" | "shell">("network");
|
|
const [update, setUpdate] = useState<UpdateStatus | null>(null);
|
|
const [network, setNetwork] = useState<NetworkStatus | null>(null);
|
|
const [updateError, setUpdateError] = useState("");
|
|
const [networkError, setNetworkError] = useState("");
|
|
const [systemNotice, setSystemNotice] = useState("");
|
|
const [requesting, setRequesting] = useState(false);
|
|
const [confirming, setConfirming] = useState(false);
|
|
const [powerAction, setPowerAction] = useState<"reboot" | "poweroff" | null>(null);
|
|
const [networkAction, setNetworkAction] = useState<"retry-upstream" | "restore-hotspot" | null>(null);
|
|
const [hotspotSsid, setHotspotSsid] = useState("MG4-Companion");
|
|
const [hotspotPassword, setHotspotPassword] = useState("");
|
|
const [hotspotPasswordAgain, setHotspotPasswordAgain] = useState("");
|
|
const [ssidTouched, setSsidTouched] = useState(false);
|
|
const [confirmHotspot, setConfirmHotspot] = useState(false);
|
|
const [networkNotice, setNetworkNotice] = useState("");
|
|
const running = update?.state === "checking" || update?.state === "building";
|
|
|
|
const refreshUpdate = useCallback(async () => {
|
|
try {
|
|
setUpdate(await getUpdateStatus());
|
|
setUpdateError("");
|
|
} catch (caught) {
|
|
setUpdateError(caught instanceof Error ? caught.message : "Update status is unavailable");
|
|
}
|
|
}, []);
|
|
|
|
const refreshNetwork = useCallback(async () => {
|
|
try {
|
|
setNetwork(await getNetworkStatus());
|
|
setNetworkError("");
|
|
} catch (caught) {
|
|
setNetworkError(caught instanceof Error ? caught.message : "Network status is unavailable");
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void refreshUpdate();
|
|
void refreshNetwork();
|
|
const interval = window.setInterval(() => {
|
|
void refreshUpdate();
|
|
void refreshNetwork();
|
|
}, 3_000);
|
|
return () => window.clearInterval(interval);
|
|
}, [refreshNetwork, refreshUpdate]);
|
|
|
|
useEffect(() => {
|
|
if (!ssidTouched && network?.hotspotSsid) setHotspotSsid(network.hotspotSsid);
|
|
}, [network?.hotspotSsid, ssidTouched]);
|
|
|
|
async function startUpdate() {
|
|
setConfirming(false);
|
|
setRequesting(true);
|
|
setUpdateError("");
|
|
try {
|
|
await post("/api/system/update", {}, csrfToken);
|
|
await refreshUpdate();
|
|
} catch (caught) {
|
|
setUpdateError(caught instanceof ApiError ? caught.message : "The update could not be started");
|
|
} finally {
|
|
setRequesting(false);
|
|
}
|
|
}
|
|
|
|
async function checkForUpdates() {
|
|
setRequesting(true);
|
|
setUpdateError("");
|
|
try {
|
|
await post("/api/system/update/check", {}, csrfToken);
|
|
window.setTimeout(() => void refreshUpdate(), 700);
|
|
} catch (caught) {
|
|
setUpdateError(caught instanceof ApiError ? caught.message : "The update check could not be started");
|
|
} finally {
|
|
setRequesting(false);
|
|
}
|
|
}
|
|
|
|
async function requestPowerAction() {
|
|
if (!powerAction) return;
|
|
const action = powerAction;
|
|
setPowerAction(null);
|
|
setRequesting(true);
|
|
setUpdateError("");
|
|
try {
|
|
await post("/api/system/power", { action }, csrfToken);
|
|
setSystemNotice(action === "reboot" ? "Reboot scheduled. The dashboard will disconnect briefly." : "Power off scheduled. The Pi will shut down safely.");
|
|
} catch (caught) {
|
|
setUpdateError(caught instanceof ApiError ? caught.message : "The power action could not be scheduled");
|
|
} finally {
|
|
setRequesting(false);
|
|
}
|
|
}
|
|
|
|
async function requestNetworkAction() {
|
|
if (!networkAction) return;
|
|
const action = networkAction;
|
|
setNetworkAction(null);
|
|
setRequesting(true);
|
|
setNetworkError("");
|
|
try {
|
|
await post("/api/network/actions", { action }, csrfToken);
|
|
await refreshNetwork();
|
|
} catch (caught) {
|
|
setNetworkError(caught instanceof ApiError ? caught.message : "The network action could not be requested");
|
|
} finally {
|
|
setRequesting(false);
|
|
}
|
|
}
|
|
|
|
function prepareHotspotConfiguration(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
setNetworkError("");
|
|
setNetworkNotice("");
|
|
if (!/^[a-zA-Z0-9_. -]{1,32}$/.test(hotspotSsid.trim())) {
|
|
setNetworkError("Use 1-32 letters, numbers, spaces, dots, hyphens, or underscores for the hotspot name");
|
|
return;
|
|
}
|
|
if (hotspotPassword.length < 8 || hotspotPassword.length > 63 || !/^[\x20-\x7e]+$/.test(hotspotPassword)) {
|
|
setNetworkError("Use 8-63 printable characters for the hotspot password");
|
|
return;
|
|
}
|
|
if (hotspotPassword !== hotspotPasswordAgain) {
|
|
setNetworkError("The hotspot passwords do not match");
|
|
return;
|
|
}
|
|
setConfirmHotspot(true);
|
|
}
|
|
|
|
async function configureHotspot() {
|
|
setConfirmHotspot(false);
|
|
setRequesting(true);
|
|
setNetworkError("");
|
|
setNetworkNotice("");
|
|
try {
|
|
await post("/api/network/configuration", { ssid: hotspotSsid.trim(), password: hotspotPassword }, csrfToken);
|
|
setHotspotPassword("");
|
|
setHotspotPasswordAgain("");
|
|
setNetworkNotice("Hotspot configuration accepted. Reconnect using the new name and password if this page disconnects.");
|
|
window.setTimeout(() => void refreshNetwork(), 2_000);
|
|
} catch (caught) {
|
|
setNetworkError(caught instanceof ApiError ? caught.message : "The hotspot could not be configured");
|
|
} finally {
|
|
setRequesting(false);
|
|
}
|
|
}
|
|
|
|
const revision = update?.installedRevision?.slice(0, 12) ?? "Unavailable";
|
|
const statusLabel = !update ? "Loading" : update.state === "current" ? "Up to date" : update.state === "available" ? "Update available" : update.state === "success" ? "Updated" : update.state === "failed" ? "Needs attention" : running ? (update.state === "checking" ? "Checking" : "Updating") : "Ready";
|
|
const modeLabels: Record<NonNullable<NetworkStatus>["mode"], string> = {
|
|
disabled: "Not configured",
|
|
starting: "Starting",
|
|
dual_radio: "Hotspot + internet",
|
|
built_in_upstream: "Built-in upstream",
|
|
probing_built_in: "Looking for internet",
|
|
offline_hotspot: "Offline hotspot",
|
|
degraded: "Needs attention",
|
|
error: "Recovery failed"
|
|
};
|
|
const probeSeconds = network?.probeDeadline
|
|
? Math.max(0, Math.ceil((Date.parse(network.probeDeadline) - Date.now()) / 1000))
|
|
: null;
|
|
|
|
return (
|
|
<div className="settings-page">
|
|
<nav className="settings-tabs" aria-label="Settings categories" role="tablist">
|
|
<button role="tab" aria-selected={tab === "network"} className={tab === "network" ? "active" : ""} onClick={() => setTab("network")}><WifiHigh size={20} />Network</button>
|
|
<button role="tab" aria-selected={tab === "system"} className={tab === "system" ? "active" : ""} onClick={() => setTab("system")}><Gear size={20} />System</button>
|
|
<button role="tab" aria-selected={tab === "shell"} className={tab === "shell" ? "active" : ""} onClick={() => setTab("shell")}><TerminalWindow size={20} />Shell</button>
|
|
</nav>
|
|
|
|
{tab === "network" && <div className="settings-layout" role="tabpanel">
|
|
<section className="settings-panel network-panel">
|
|
<div className="settings-title">
|
|
<div className="settings-icon"><WifiHigh size={30} weight="duotone" /></div>
|
|
<div><h2>Network</h2><p>The built-in radio hosts the car network while the AWUS1900 provides upstream internet whenever it is available.</p></div>
|
|
</div>
|
|
|
|
<div className="network-overview">
|
|
<div className={`network-mode mode-${network?.mode ?? "starting"}`}>
|
|
<span>Current mode</span>
|
|
<strong>{network ? modeLabels[network.mode] : "Loading"}</strong>
|
|
<p>{network?.message ?? "Reading network controller status..."}</p>
|
|
{probeSeconds !== null && <small>Offline hotspot recovery in at most {probeSeconds} seconds</small>}
|
|
</div>
|
|
<div className="network-adapter">
|
|
<span>Hotspot</span>
|
|
<strong>{network?.hotspotSsid ?? (network ? "Not active" : "Loading")}</strong>
|
|
<small>{network?.hotspot.address ?? network?.hotspot.interface ?? "Waiting for status"}</small>
|
|
</div>
|
|
<NetworkAdapter label="AWUS1900 / upstream" adapter={network?.upstream ?? null} />
|
|
<div className="network-adapter">
|
|
<span>Internet check</span>
|
|
<strong>{network?.connectivity ?? "unknown"}</strong>
|
|
<small>Reported by NetworkManager</small>
|
|
</div>
|
|
</div>
|
|
|
|
{networkError && <div className="form-error" role="alert"><WarningCircle size={20} />{networkError}</div>}
|
|
{networkNotice && <div className="network-notice" role="status"><CheckCircle size={20} />{networkNotice}</div>}
|
|
{network?.lastFailure && <p className="network-failure"><WarningCircle size={18} /> Last issue: {network.lastFailure}</p>}
|
|
<div className="settings-actions">
|
|
<button className="primary-button" onClick={() => setNetworkAction("retry-upstream")} disabled={!network?.enabled || requesting || network.mode === "probing_built_in"}>
|
|
<ArrowClockwise size={22} /> Try upstream again
|
|
</button>
|
|
<button className="secondary-button text-button" onClick={() => setNetworkAction("restore-hotspot")} disabled={!network?.enabled || requesting || network.mode === "offline_hotspot" || network.mode === "dual_radio"}>
|
|
<WifiHigh size={22} /> Restore hotspot
|
|
</button>
|
|
</div>
|
|
|
|
<form className="hotspot-form" onSubmit={prepareHotspotConfiguration}>
|
|
<div className="hotspot-form-heading">
|
|
<div><h3>Hotspot credentials</h3><p>Changing these settings may disconnect devices currently using the car hotspot.</p></div>
|
|
{network?.hotspotSsid && <span>Current: {network.hotspotSsid}</span>}
|
|
</div>
|
|
<div className="hotspot-fields">
|
|
<label>
|
|
<span>Network name</span>
|
|
<input value={hotspotSsid} onChange={(event) => { setHotspotSsid(event.target.value); setSsidTouched(true); }} maxLength={32} autoComplete="off" required />
|
|
</label>
|
|
<label>
|
|
<span>New password</span>
|
|
<input type="password" value={hotspotPassword} onChange={(event) => setHotspotPassword(event.target.value)} minLength={8} maxLength={63} autoComplete="new-password" required />
|
|
</label>
|
|
<label>
|
|
<span>Repeat password</span>
|
|
<input type="password" value={hotspotPasswordAgain} onChange={(event) => setHotspotPasswordAgain(event.target.value)} minLength={8} maxLength={63} autoComplete="new-password" required />
|
|
</label>
|
|
<button className="secondary-button text-button" type="submit" disabled={requesting}>Save hotspot</button>
|
|
</div>
|
|
<p className="settings-note">The password is sent only to the local Pi configuration service and is stored by NetworkManager as a derived WPA key.</p>
|
|
</form>
|
|
</section>
|
|
</div>}
|
|
|
|
{tab === "system" && <div className="system-settings-layout" role="tabpanel">
|
|
<section className="settings-panel power-panel">
|
|
<div className="settings-title">
|
|
<div className="settings-icon"><Power size={30} weight="duotone" /></div>
|
|
<div><h2>General system</h2><p>Restart or safely shut down the Raspberry Pi.</p></div>
|
|
</div>
|
|
{systemNotice && <div className="network-notice" role="status"><CheckCircle size={20} />{systemNotice}</div>}
|
|
<div className="power-actions">
|
|
<button className="secondary-button text-button" onClick={() => setPowerAction("reboot")} disabled={requesting}><ArrowClockwise size={22} />Reboot Pi</button>
|
|
<button className="danger-button" onClick={() => setPowerAction("poweroff")} disabled={requesting}><Power size={22} />Power off</button>
|
|
</div>
|
|
<p className="settings-note">Both actions use systemd for a clean shutdown. Power can be removed only after the Pi has stopped.</p>
|
|
</section>
|
|
|
|
<section className="settings-panel update-panel">
|
|
<div className="settings-title">
|
|
<div className="settings-icon"><DownloadSimple size={30} weight="duotone" /></div>
|
|
<div><h2>Software update</h2><p>Check the configured Git branch, then verify and activate a newer release when you choose.</p></div>
|
|
</div>
|
|
|
|
<dl className="update-facts">
|
|
<div><dt>Status</dt><dd className={`update-state ${update?.state ?? "idle"}`}>{statusLabel}</dd></div>
|
|
<div><dt>Installed revision</dt><dd><GitBranch size={18} /> <code>{revision}</code></dd></div>
|
|
<div><dt>Available revision</dt><dd><GitBranch size={18} /> <code>{update?.toRevision?.slice(0, 12) ?? "Not checked"}</code></dd></div>
|
|
<div><dt>Last check</dt><dd>{update && update.updatedAt !== new Date(0).toISOString() ? new Date(update.updatedAt).toLocaleString() : "Never"}</dd></div>
|
|
</dl>
|
|
|
|
<div className="update-message" aria-live="polite">
|
|
{running && <ArrowClockwise size={22} className="spin" />}
|
|
{!running && update?.state === "failed" && <WarningCircle size={22} />}
|
|
{!running && update?.state !== "failed" && <CheckCircle size={22} />}
|
|
<span>{update?.message ?? "Reading update status..."}</span>
|
|
</div>
|
|
{updateError && <div className="form-error" role="alert"><WarningCircle size={20} />{updateError}</div>}
|
|
|
|
<div className="settings-actions">
|
|
<button className="secondary-button text-button" onClick={() => void checkForUpdates()} disabled={!update?.supported || running || requesting}>
|
|
<ArrowClockwise size={22} /> {update?.state === "checking" ? "Checking..." : "Check for updates"}
|
|
</button>
|
|
<button className="primary-button" onClick={() => setConfirming(true)} disabled={!update?.supported || running || requesting}>
|
|
<DownloadSimple size={22} /> {update?.state === "building" ? "Updating..." : "Install update"}
|
|
</button>
|
|
</div>
|
|
<p className="settings-note">The current release stays active unless the new version passes installation, lint, type checks, tests, and production builds.</p>
|
|
</section>
|
|
|
|
<section className="settings-panel compact-panel">
|
|
<h2>Service behavior</h2>
|
|
<dl className="behavior-list">
|
|
<div><dt>Start on boot</dt><dd>{update?.supported ? "Enabled by systemd" : "Available after install"}</dd></div>
|
|
<div><dt>Failure recovery</dt><dd>{update?.supported ? "Automatic restart" : "Available after install"}</dd></div>
|
|
<div><dt>Update channel</dt><dd>{update?.supported ? "Configured Git branch" : "Not configured"}</dd></div>
|
|
</dl>
|
|
</section>
|
|
</div>}
|
|
|
|
{tab === "shell" && <div role="tabpanel"><Suspense fallback={<div className="shell-loading">Loading terminal…</div>}><ShellTerminal csrfToken={csrfToken} /></Suspense></div>}
|
|
|
|
{confirming && <div className="modal-backdrop" role="presentation" onMouseDown={() => setConfirming(false)}>
|
|
<div className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="update-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
|
|
<h2 id="update-dialog-title">Install the latest version?</h2>
|
|
<p>The dashboard may disconnect briefly when the service restarts. The previous version remains active if verification fails.</p>
|
|
<div className="dialog-actions">
|
|
<button className="secondary-button" onClick={() => setConfirming(false)}>Cancel</button>
|
|
<button className="primary-button" onClick={() => void startUpdate()}>Install update</button>
|
|
</div>
|
|
</div>
|
|
</div>}
|
|
|
|
{networkAction && <div className="modal-backdrop" role="presentation" onMouseDown={() => setNetworkAction(null)}>
|
|
<div className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="network-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
|
|
<h2 id="network-dialog-title">{networkAction === "retry-upstream" ? "Try upstream networking?" : "Restore the car hotspot?"}</h2>
|
|
<p>{networkAction === "retry-upstream"
|
|
? "If the AWUS1900 cannot connect, the built-in hotspot will disappear for up to 60 seconds while saved upstream networks are tried. It will be restored automatically after failure."
|
|
: "The built-in adapter will leave its upstream network and restore the car hotspot. This dashboard connection may briefly disconnect."}</p>
|
|
<div className="dialog-actions">
|
|
<button className="secondary-button" onClick={() => setNetworkAction(null)}>Cancel</button>
|
|
<button className="primary-button" onClick={() => void requestNetworkAction()}>Continue</button>
|
|
</div>
|
|
</div>
|
|
</div>}
|
|
|
|
{confirmHotspot && <div className="modal-backdrop" role="presentation" onMouseDown={() => setConfirmHotspot(false)}>
|
|
<div className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="hotspot-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
|
|
<h2 id="hotspot-dialog-title">Apply new hotspot credentials?</h2>
|
|
<p>The hotspot will become <strong>{hotspotSsid.trim()}</strong>. Connected devices may disconnect and must reconnect with the new password.</p>
|
|
<div className="dialog-actions">
|
|
<button className="secondary-button" onClick={() => setConfirmHotspot(false)}>Cancel</button>
|
|
<button className="primary-button" onClick={() => void configureHotspot()}>Apply settings</button>
|
|
</div>
|
|
</div>
|
|
</div>}
|
|
|
|
{powerAction && <div className="modal-backdrop" role="presentation" onMouseDown={() => setPowerAction(null)}>
|
|
<div className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="power-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
|
|
<h2 id="power-dialog-title">{powerAction === "reboot" ? "Reboot the Pi?" : "Power off the Pi?"}</h2>
|
|
<p>{powerAction === "reboot" ? "The dashboard and hotspot will disconnect while the Pi restarts, then return automatically." : "The dashboard and hotspot will disconnect. Physical access may be required to turn the Pi back on."}</p>
|
|
<div className="dialog-actions">
|
|
<button className="secondary-button text-button" onClick={() => setPowerAction(null)}>Cancel</button>
|
|
<button className={powerAction === "poweroff" ? "danger-button" : "primary-button"} onClick={() => void requestPowerAction()}>{powerAction === "reboot" ? "Reboot Pi" : "Power off"}</button>
|
|
</div>
|
|
</div>
|
|
</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NetworkAdapter({ label, adapter }: { label: string; adapter: NetworkStatus["hotspot"] | null }) {
|
|
return (
|
|
<div className="network-adapter">
|
|
<span>{label}</span>
|
|
<strong>{!adapter ? "Loading" : !adapter.available ? "Unavailable" : adapter.connected ? adapter.connection ?? "Connected" : "Disconnected"}</strong>
|
|
<small>{adapter?.address ?? adapter?.interface ?? "Waiting for status"}</small>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusContent({ status }: { status: SystemStatus }) {
|
|
const memoryPercent = percent(status.memory.usedBytes, status.memory.totalBytes);
|
|
const diskPercent = status.disk.available ? percent(status.disk.value.usedBytes, status.disk.value.totalBytes) : null;
|
|
const primaryAddress = status.network.interfaces[0]?.address;
|
|
const temperature = status.cpu.temperatureCelsius.available
|
|
? `${status.cpu.temperatureCelsius.value.toFixed(1)}°C`
|
|
: "Unavailable";
|
|
const headUnitLabel = status.headUnit.available
|
|
? status.headUnit.value.identity.model.available
|
|
? status.headUnit.value.identity.model.value
|
|
: "Connected"
|
|
: status.headUnit.state === "unauthorized"
|
|
? "Authorization required"
|
|
: status.headUnit.state === "ambiguous"
|
|
? "Multiple devices"
|
|
: status.headUnit.state === "disabled"
|
|
? "Not configured"
|
|
: status.headUnit.state === "offline"
|
|
? "Offline"
|
|
: "Unavailable";
|
|
|
|
return (
|
|
<div className="status-layout">
|
|
<section className="health-summary">
|
|
<div className="summary-heading">
|
|
<div className="health-icon"><CheckCircle size={38} weight="fill" /></div>
|
|
<div><p>Companion service</p><h2>Running normally</h2></div>
|
|
</div>
|
|
<dl className="summary-details">
|
|
<div><dt>Host uptime</dt><dd>{formatUptime(status.uptimeSeconds)}</dd></div>
|
|
<div><dt>Service uptime</dt><dd>{formatUptime(status.service.processUptimeSeconds)}</dd></div>
|
|
<div><dt>Last update</dt><dd>{new Date(status.collectedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}</dd></div>
|
|
</dl>
|
|
</section>
|
|
|
|
<section className="metrics-grid" aria-label="System metrics">
|
|
<Metric icon={<Cpu size={28} />} label="CPU temperature" value={temperature} note={`Load ${status.cpu.loadAverage[0]?.toFixed(2) ?? "Unavailable"}`} />
|
|
<Metric icon={<Gauge size={28} />} label="Memory used" value={`${memoryPercent}%`} note={`${formatBytes(status.memory.availableBytes)} available`} meter={memoryPercent} />
|
|
<Metric icon={<HardDrives size={28} />} label="Disk used" value={diskPercent === null ? "Unavailable" : `${diskPercent}%`} note={status.disk.available ? `${formatBytes(status.disk.value.availableBytes)} available` : status.disk.reason} meter={diskPercent ?? undefined} />
|
|
<Metric icon={<WifiHigh size={28} />} label="Local network" value={primaryAddress ?? "Unavailable"} note={status.network.interfaces[0]?.name ?? status.network.interfaceReason ?? "No active interface"} />
|
|
</section>
|
|
|
|
<section className="system-strip">
|
|
<div><span>Operating system</span><strong>{status.operatingSystem}</strong></div>
|
|
<div><span>Processor</span><strong>{status.cpu.model}</strong></div>
|
|
<div><span>Wi-Fi detail</span><strong>{status.network.wifi.available ? status.network.wifi.value : status.network.wifi.reason}</strong></div>
|
|
<div><span>ADB connection</span><strong title={status.headUnit.available ? "MG4 head unit connected" : status.headUnit.reason}>{headUnitLabel}</strong></div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Metric({ icon, label, value, note, meter }: { icon: React.ReactNode; label: string; value: string; note: string; meter?: number | undefined }) {
|
|
return (
|
|
<article className="metric">
|
|
<div className="metric-label">{icon}<span>{label}</span></div>
|
|
<strong className="metric-value">{value}</strong>
|
|
<p>{note}</p>
|
|
{meter !== undefined && <div className="meter" aria-label={`${label}: ${meter}%`}><span style={{ width: `${meter}%` }} /></div>}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function DashboardSkeleton() {
|
|
return <div className="skeleton-grid" aria-label="Loading system status"><div /><div /><div /><div /><div /></div>;
|
|
}
|
|
|
|
function percent(used: number, total: number): number {
|
|
return total > 0 ? Math.round((used / total) * 100) : 0;
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
let value = bytes;
|
|
let unit = 0;
|
|
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
|
|
return `${value.toFixed(unit < 2 ? 0 : 1)} ${units[unit]}`;
|
|
}
|
|
|
|
function formatUptime(seconds: number): string {
|
|
const days = Math.floor(seconds / 86_400);
|
|
const hours = Math.floor((seconds % 86_400) / 3_600);
|
|
const minutes = Math.floor((seconds % 3_600) / 60);
|
|
return days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
}
|