Add tabbed settings and system shell

This commit is contained in:
2026-07-31 21:30:51 +02:00
parent d663979ebb
commit c36e5e811f
20 changed files with 698 additions and 29 deletions
+92 -11
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { lazy, Suspense, useCallback, useEffect, useState, type FormEvent } from "react";
import {
ArrowClockwise,
CheckCircle,
@@ -9,6 +9,7 @@ import {
Gear,
GitBranch,
DownloadSimple,
Power,
SignOut,
TerminalWindow,
WarningCircle,
@@ -29,6 +30,11 @@ import {
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() {
@@ -311,12 +317,15 @@ function portLabel(port: number): string {
}
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("");
@@ -372,6 +381,35 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
}
}
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;
@@ -426,7 +464,7 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
}
const revision = update?.installedRevision?.slice(0, 12) ?? "Unavailable";
const statusLabel = !update ? "Loading" : update.state === "current" ? "Up to date" : update.state === "success" ? "Updated" : update.state === "failed" ? "Needs attention" : running ? "Updating" : "Ready";
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",
@@ -442,11 +480,18 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
: null;
return (
<div className="settings-layout">
<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>Car network</h2><p>Keep the dashboard reachable through the built-in hotspot while preferring the AWUS1900 for upstream internet.</p></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">
@@ -456,7 +501,11 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
<p>{network?.message ?? "Reading network controller status..."}</p>
{probeSeconds !== null && <small>Offline hotspot recovery in at most {probeSeconds} seconds</small>}
</div>
<NetworkAdapter label="Built-in / car hotspot" adapter={network?.hotspot ?? null} />
<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>
@@ -500,17 +549,33 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
<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>}
<section className="settings-panel">
{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>Fetch, verify, build, and activate the newest release from the configured Git branch.</p></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>Last activity</dt><dd>{update && update.updatedAt !== new Date(0).toISOString() ? new Date(update.updatedAt).toLocaleString() : "Never"}</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">
@@ -522,10 +587,12 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
{updateError && <div className="form-error" role="alert"><WarningCircle size={20} />{updateError}</div>}
<div className="settings-actions">
<button className="primary-button" onClick={() => setConfirming(true)} disabled={!update?.supported || running || requesting}>
<DownloadSimple size={22} /> {running ? "Updating..." : "Update now"}
<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>
<button className="secondary-button" onClick={() => void refreshUpdate()} disabled={requesting}>Refresh status</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>
@@ -538,6 +605,9 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
<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()}>
@@ -573,6 +643,17 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
</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>
);
}