Add live hotspot network diagnostics
This commit is contained in:
+90
-4
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user