Add resilient dual-radio network failover

This commit is contained in:
2026-07-31 20:24:34 +02:00
parent 0a0dae144c
commit 6940cfb3a8
19 changed files with 857 additions and 11 deletions
+113 -8
View File
@@ -18,10 +18,12 @@ import {
ApiError,
getAuthState,
getStatus,
getNetworkStatus,
getUpdateStatus,
post,
type AuthState,
type SystemStatus,
type NetworkStatus,
type UpdateStatus
} from "./api";
@@ -224,35 +226,67 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
function SettingsPage({ csrfToken }: { csrfToken: string }) {
const [update, setUpdate] = useState<UpdateStatus | null>(null);
const [error, setError] = useState("");
const [network, setNetwork] = useState<NetworkStatus | null>(null);
const [updateError, setUpdateError] = useState("");
const [networkError, setNetworkError] = useState("");
const [requesting, setRequesting] = useState(false);
const [confirming, setConfirming] = useState(false);
const [networkAction, setNetworkAction] = useState<"retry-upstream" | "restore-hotspot" | null>(null);
const running = update?.state === "checking" || update?.state === "building";
const refreshUpdate = useCallback(async () => {
try {
setUpdate(await getUpdateStatus());
setError("");
setUpdateError("");
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Update status is unavailable");
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();
const interval = window.setInterval(() => void refreshUpdate(), 3_000);
void refreshNetwork();
const interval = window.setInterval(() => {
void refreshUpdate();
void refreshNetwork();
}, 3_000);
return () => window.clearInterval(interval);
}, [refreshUpdate]);
}, [refreshNetwork, refreshUpdate]);
async function startUpdate() {
setConfirming(false);
setRequesting(true);
setError("");
setUpdateError("");
try {
await post("/api/system/update", {}, csrfToken);
await refreshUpdate();
} catch (caught) {
setError(caught instanceof ApiError ? caught.message : "The update could not be started");
setUpdateError(caught instanceof ApiError ? caught.message : "The update could not be started");
} 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);
}
@@ -260,9 +294,57 @@ 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 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-layout">
<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>
<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>
<NetworkAdapter label="Built-in / car hotspot" adapter={network?.hotspot ?? null} />
<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>}
{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?.supported || 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?.supported || requesting || network.mode === "offline_hotspot" || network.mode === "dual_radio"}>
<WifiHigh size={22} /> Restore hotspot
</button>
</div>
{!network?.supported && <p className="settings-note">Run the network setup helper on the Pi after creating a saved AWUS upstream profile. Automatic switching stays disabled until then.</p>}
</section>
<section className="settings-panel">
<div className="settings-title">
<div className="settings-icon"><DownloadSimple size={30} weight="duotone" /></div>
@@ -281,7 +363,7 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
{!running && update?.state !== "failed" && <CheckCircle size={22} />}
<span>{update?.message ?? "Reading update status..."}</span>
</div>
{error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>}
{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}>
@@ -311,6 +393,29 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
</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>}
</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>
);
}