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);
+15
View File
@@ -89,6 +89,17 @@ export type NetworkAdapterStatus = {
address: string | null;
};
export type NetworkActivity = {
supported: boolean;
collectedAt: string;
clients: Array<{ ipAddress: string; macAddress: string; hostname: string | null; leaseExpiresAt: string | null; state: string; connected: boolean }>;
dnsQueries: Array<{ timestamp: string; clientAddress: string; type: string; name: string }>;
flows: Array<{ protocol: "tcp" | "udp"; state: string; sourceAddress: string; sourcePort: number; destinationAddress: string; destinationPort: number; packets: number | null; bytes: number | null }>;
dnsAvailable: boolean;
flowsAvailable: boolean;
messages: string[];
};
type ErrorResponse = { error?: { code?: string; message?: string } };
export class ApiError extends Error {
@@ -135,3 +146,7 @@ export async function getUpdateStatus(): Promise<UpdateStatus> {
export async function getNetworkStatus(): Promise<NetworkStatus> {
return parseResponse<NetworkStatus>(await fetch("/api/network", { credentials: "same-origin" }));
}
export async function getNetworkActivity(): Promise<NetworkActivity> {
return parseResponse<NetworkActivity>(await fetch("/api/network/activity", { credentials: "same-origin" }));
}
+37
View File
@@ -103,6 +103,38 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.system-strip div { min-width: 0; display: grid; }
.system-strip strong { overflow: hidden; color: #d9dfd3; font-size: 0.88rem; text-overflow: ellipsis; white-space: nowrap; }
.activity-layout { display: grid; gap: 18px; }
.activity-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)) auto; align-items: center; gap: 1px; overflow: hidden; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); }
.activity-summary > div { min-height: 82px; display: grid; align-content: center; gap: 7px; padding: 15px 20px; background: var(--surface); }
.activity-summary span { color: var(--muted); font-size: 0.76rem; }
.activity-summary strong { font: 750 1.25rem/1 ui-monospace, "Cascadia Code", monospace; }
.activity-summary button { margin: 0 16px; }
.activity-message { display: flex; align-items: center; gap: 9px; padding: 11px 14px; color: #ffd5cf; background: #321d19; border: 1px solid #6b332a; border-radius: 9px; font-size: 0.8rem; }
.activity-panel { min-width: 0; padding: 21px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.activity-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
.activity-heading h2 { margin: 0 0 5px; font-size: 1.12rem; }
.activity-heading p { margin: 0; color: var(--muted); font-size: 0.78rem; }
.activity-heading > span { color: var(--accent); font: 0.72rem/1.4 ui-monospace, "Cascadia Code", monospace; text-transform: uppercase; }
.client-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.client-row { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 11px; min-height: 55px; padding: 9px 13px; background: #121610; border-radius: 8px; }
.client-row div { min-width: 0; display: grid; gap: 3px; }
.client-row strong, .client-row small, .client-row code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.client-row strong { font-size: 0.86rem; }
.client-row small, .client-row > span:last-child { color: var(--muted); font-size: 0.7rem; }
.client-row code { color: #cbd3c5; font-size: 0.72rem; }
.client-dot { width: 8px; height: 8px; border-radius: 50%; background: #777f72; }
.client-dot.online { background: var(--accent); box-shadow: 0 0 0 4px rgba(185, 231, 105, 0.09); }
.activity-columns { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 18px; }
.activity-table-wrap { max-height: 285px; overflow: auto; border: 1px solid var(--line); border-radius: 9px; }
.activity-table { width: 100%; border-collapse: collapse; font-size: 0.75rem; }
.activity-table th { position: sticky; top: 0; z-index: 1; padding: 10px 11px; color: var(--muted); background: #151914; font-size: 0.67rem; letter-spacing: 0.06em; text-align: left; text-transform: uppercase; }
.activity-table td { max-width: 280px; padding: 10px 11px; border-top: 1px solid var(--line); white-space: nowrap; }
.activity-table td.request-name { overflow: hidden; text-overflow: ellipsis; }
.activity-table td small { display: block; margin-top: 3px; color: var(--muted); }
.activity-table code { color: #d7ded2; font-size: 0.72rem; }
.activity-empty { min-height: 74px; display: grid; place-items: center; padding: 18px; color: var(--muted); font-size: 0.8rem; text-align: center; }
.activity-privacy { margin: 0; color: var(--muted); font-size: 0.72rem; text-align: right; }
.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; }
@@ -167,6 +199,9 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.settings-layout { grid-template-columns: 1fr; }
.network-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.hotspot-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.activity-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.activity-summary button { margin: 12px; }
.activity-columns { grid-template-columns: 1fr; }
}
@media (max-width: 767px) {
@@ -178,6 +213,7 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.sidebar { position: fixed; top: auto; bottom: 0; z-index: 10; width: 100%; height: 72px; flex-direction: row; padding: 8px 12px; border-top: 1px solid var(--line); border-right: 0; }
.brand-compact { display: none; }
.sidebar nav { display: flex; flex: 1; }
.sidebar nav .nav-item:disabled { display: none; }
.nav-item { width: 72px; min-height: 56px; }
.nav-item.logout { margin: 0 0 0 auto; }
.dashboard { padding: 26px 18px; }
@@ -192,6 +228,7 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.update-facts { grid-template-columns: 1fr; }
.network-overview { grid-template-columns: 1fr; }
.hotspot-fields { grid-template-columns: 1fr; }
.activity-summary, .client-grid { 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; }