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);
|
||||
|
||||
@@ -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" }));
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user