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>
);
}
+26
View File
@@ -66,6 +66,28 @@ export type UpdateStatus = {
supported: boolean;
};
export type NetworkStatus = {
supported: boolean;
enabled: boolean;
mode: "disabled" | "starting" | "dual_radio" | "built_in_upstream" | "probing_built_in" | "offline_hotspot" | "degraded" | "error";
message: string;
updatedAt: string;
lastTransitionAt: string;
probeDeadline: string | null;
lastFailure: string | null;
connectivity: "full" | "limited" | "portal" | "none" | "unknown";
hotspot: NetworkAdapterStatus;
upstream: NetworkAdapterStatus;
};
export type NetworkAdapterStatus = {
interface: string;
available: boolean;
connected: boolean;
connection: string | null;
address: string | null;
};
type ErrorResponse = { error?: { code?: string; message?: string } };
export class ApiError extends Error {
@@ -108,3 +130,7 @@ export async function getStatus(): Promise<SystemStatus> {
export async function getUpdateStatus(): Promise<UpdateStatus> {
return parseResponse<UpdateStatus>(await fetch("/api/system/update", { credentials: "same-origin" }));
}
export async function getNetworkStatus(): Promise<NetworkStatus> {
return parseResponse<NetworkStatus>(await fetch("/api/network", { credentials: "same-origin" }));
}
+14
View File
@@ -105,6 +105,18 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.settings-layout { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.7fr); gap: 18px; align-items: start; }
.settings-panel { padding: 28px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.network-panel { grid-column: 1 / -1; }
.network-overview { display: grid; grid-template-columns: 1.3fr repeat(3, minmax(0, 1fr)); gap: 1px; margin: 28px 0 20px; overflow: hidden; border: 1px solid var(--line); border-radius: 11px; background: var(--line); }
.network-mode, .network-adapter { min-width: 0; padding: 19px; background: #121610; }
.network-mode span, .network-adapter span { display: block; margin-bottom: 10px; color: var(--muted); font-size: 0.78rem; }
.network-mode strong, .network-adapter strong { display: block; overflow: hidden; font-size: 1.05rem; text-overflow: ellipsis; white-space: nowrap; }
.network-mode p { min-height: 2.7em; margin: 9px 0 0; color: var(--muted); font-size: 0.82rem; line-height: 1.35; }
.network-mode small, .network-adapter small { display: block; overflow: hidden; margin-top: 9px; color: #899183; font: 0.72rem/1.3 ui-monospace, "Cascadia Code", monospace; text-overflow: ellipsis; white-space: nowrap; }
.mode-dual_radio strong, .mode-offline_hotspot strong { color: var(--accent); }
.mode-error strong, .mode-degraded strong { color: var(--danger); }
.network-failure { display: flex; align-items: flex-start; gap: 8px; margin: 0 0 16px; color: #ffc8bf; font-size: 0.82rem; line-height: 1.45; }
.network-failure svg { flex: none; }
.text-button { width: auto; padding: 0 20px; font-size: inherit; }
.settings-title { display: flex; align-items: flex-start; gap: 18px; }
.settings-title h2, .compact-panel h2 { margin-bottom: 8px; font-size: 1.45rem; }
.settings-title p { max-width: 620px; margin: 0; color: var(--muted); line-height: 1.5; }
@@ -144,6 +156,7 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.health-summary { grid-template-columns: 1fr; gap: 24px; }
.metrics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.settings-layout { grid-template-columns: 1fr; }
.network-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 767px) {
@@ -167,6 +180,7 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.summary-details div { padding: 0; border: 0; }
.metrics-grid, .system-strip { grid-template-columns: 1fr; }
.update-facts { grid-template-columns: 1fr; }
.network-overview { grid-template-columns: 1fr; }
.settings-actions, .dialog-actions { flex-direction: column; }
.settings-actions button, .dialog-actions button { width: 100%; }
.skeleton-grid { grid-template-columns: 1fr; }