Add live hotspot network diagnostics

This commit is contained in:
2026-07-31 21:01:02 +02:00
parent d1239d66af
commit 37e2cb154e
19 changed files with 490 additions and 11 deletions
+90 -4
View File
@@ -19,11 +19,13 @@ import {
getAuthState,
getStatus,
getNetworkStatus,
getNetworkActivity,
getUpdateStatus,
post,
type AuthState,
type SystemStatus,
type NetworkStatus,
type NetworkActivity,
type UpdateStatus
} from "./api";
@@ -152,7 +154,7 @@ function AccountScreen({
}
function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () => Promise<void> }) {
const [page, setPage] = useState<"home" | "settings">("home");
const [page, setPage] = useState<"home" | "network" | "settings">("home");
const [status, setStatus] = useState<SystemStatus | null>(null);
const [error, setError] = useState("");
const [connection, setConnection] = useState<"connecting" | "live" | "offline">("connecting");
@@ -194,6 +196,7 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
<div className="brand brand-compact"><Gauge size={30} weight="duotone" /><span>Pi Companion</span></div>
<nav aria-label="Primary navigation">
<button className={`nav-item ${page === "home" ? "active" : ""}`} onClick={() => setPage("home")}><House size={24} weight={page === "home" ? "fill" : "regular"} /><span>Home</span></button>
<button className={`nav-item ${page === "network" ? "active" : ""}`} onClick={() => setPage("network")}><WifiHigh size={24} weight={page === "network" ? "fill" : "regular"} /><span>Network</span></button>
<button className="nav-item" disabled><TerminalWindow size={24} /><span>Jobs</span><small>Soon</small></button>
<button className={`nav-item ${page === "settings" ? "active" : ""}`} onClick={() => setPage("settings")}><Gear size={24} weight={page === "settings" ? "fill" : "regular"} /><span>Settings</span></button>
</nav>
@@ -202,8 +205,8 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
<main className="dashboard">
<header className="dashboard-header">
<div>
<p className="eyebrow">{page === "home" ? "System overview" : "Companion settings"}</p>
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : "Settings"}</h1>
<p className="eyebrow">{page === "home" ? "System overview" : page === "network" ? "Live diagnostics" : "Companion settings"}</p>
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : page === "network" ? "Network activity" : "Settings"}</h1>
</div>
<div className="header-actions">
<div className={`connection-state ${connection}`}>
@@ -218,12 +221,95 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
{page === "home" ? <>
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}<button onClick={() => void refresh()}>Try again</button></div>}
{!status ? <DashboardSkeleton /> : <StatusContent status={status} />}
</> : <SettingsPage csrfToken={auth.csrfToken} />}
</> : page === "network" ? <NetworkActivityPage /> : <SettingsPage csrfToken={auth.csrfToken} />}
</main>
</div>
);
}
function NetworkActivityPage() {
const [activity, setActivity] = useState<NetworkActivity | null>(null);
const [error, setError] = useState("");
const [paused, setPaused] = useState(false);
const refresh = useCallback(async () => {
try {
setActivity(await getNetworkActivity());
setError("");
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Network activity is unavailable");
}
}, []);
useEffect(() => {
void refresh();
if (paused) return;
const interval = window.setInterval(() => void refresh(), 2_000);
return () => window.clearInterval(interval);
}, [paused, refresh]);
const connected = activity?.clients.filter((client) => client.connected).length ?? 0;
return (
<div className="activity-layout">
<section className="activity-summary">
<div><span>Connected clients</span><strong>{connected}</strong></div>
<div><span>Recent DNS queries</span><strong>{activity?.dnsQueries.length ?? 0}</strong></div>
<div><span>Active flows</span><strong>{activity?.flows.length ?? 0}</strong></div>
<div><span>Snapshot</span><strong>{activity?.supported ? new Date(activity.collectedAt).toLocaleTimeString() : "Unavailable"}</strong></div>
<button className="secondary-button text-button" onClick={() => setPaused((value) => !value)}>{paused ? "Resume live view" : "Pause live view"}</button>
</section>
{error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>}
{activity?.messages.map((message) => <div className="activity-message" key={message}><WarningCircle size={18} />{message}</div>)}
<section className="activity-clients activity-panel">
<div className="activity-heading"><div><h2>Hotspot clients</h2><p>DHCP leases and current neighbor reachability.</p></div></div>
<div className="client-grid">
{activity?.clients.length ? activity.clients.map((client) => (
<div className="client-row" key={`${client.ipAddress}-${client.macAddress}`}>
<span className={`client-dot ${client.connected ? "online" : ""}`} aria-hidden="true" />
<div><strong>{client.hostname ?? client.ipAddress}</strong><small>{client.hostname ? client.ipAddress : client.macAddress}</small></div>
<code>{client.hostname ? client.macAddress : client.state}</code>
<span>{client.connected ? "Connected" : client.state}</span>
</div>
)) : <ActivityEmpty text="No hotspot clients observed." />}
</div>
</section>
<div className="activity-columns">
<section className="activity-panel">
<div className="activity-heading"><div><h2>DNS requests</h2><p>Query metadata from clients during the last 15 minutes.</p></div><span>{activity?.dnsAvailable ? "Live" : "Unavailable"}</span></div>
<div className="activity-table-wrap">
<table className="activity-table"><thead><tr><th>Time</th><th>Client</th><th>Type</th><th>Name</th></tr></thead><tbody>
{activity?.dnsQueries.map((query, index) => <tr key={`${query.timestamp}-${query.clientAddress}-${query.name}-${index}`}><td>{new Date(query.timestamp).toLocaleTimeString()}</td><td><code>{query.clientAddress}</code></td><td>{query.type}</td><td className="request-name">{query.name}</td></tr>)}
</tbody></table>
{!activity?.dnsQueries.length && <ActivityEmpty text="No DNS requests captured yet." />}
</div>
</section>
<section className="activity-panel">
<div className="activity-heading"><div><h2>Active connections</h2><p>Destination metadata only; encrypted content remains private.</p></div><span>{activity?.flowsAvailable ? "Live" : "Unavailable"}</span></div>
<div className="activity-table-wrap">
<table className="activity-table"><thead><tr><th>Client</th><th>Protocol</th><th>Destination</th><th>State</th></tr></thead><tbody>
{activity?.flows.map((flow, index) => <tr key={`${flow.sourceAddress}-${flow.sourcePort}-${flow.destinationAddress}-${flow.destinationPort}-${index}`}><td><code>{flow.sourceAddress}</code></td><td>{flow.protocol.toUpperCase()}</td><td><code>{flow.destinationAddress}:{flow.destinationPort}</code><small>{portLabel(flow.destinationPort)}</small></td><td>{flow.state}</td></tr>)}
</tbody></table>
{!activity?.flows.length && <ActivityEmpty text="No active client connections observed." />}
</div>
</section>
</div>
<p className="activity-privacy">This page records bounded connection metadata only. It does not capture payloads, passwords, cookies, or HTTPS paths.</p>
</div>
);
}
function ActivityEmpty({ text }: { text: string }) {
return <div className="activity-empty">{text}</div>;
}
function portLabel(port: number): string {
return ({ 53: "DNS", 80: "HTTP", 123: "NTP", 443: "HTTPS", 853: "DNS over TLS" } as Record<number, string>)[port] ?? "";
}
function SettingsPage({ csrfToken }: { csrfToken: string }) {
const [update, setUpdate] = useState<UpdateStatus | null>(null);
const [network, setNetwork] = useState<NetworkStatus | null>(null);