Add resilient dual-radio network failover
This commit is contained in:
+113
-8
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }));
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user