Add tabbed settings and system shell
This commit is contained in:
+92
-11
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user