Web UI, packaging, systemd units, installer scripts, namespace integration tests

React/TS analyser (virtualised packet list, protocol tree, verified hex mapping),
setup wizard, network/devices/health/library/settings pages. Installer with
backup and rollback, uninstall/repair/diagnose. Docker+netns integration suite
covering DHCP, DNS, NAT, isolation, IPv6 blocking, capture and export.
This commit is contained in:
Andy Dixon
2026-10-09 17:56:02 +01:00
parent 31e39bdbef
commit 01b568aaf7
38 changed files with 4897 additions and 1 deletions
+86
View File
@@ -0,0 +1,86 @@
// Thin API client. All state-changing requests carry the per-session CSRF token.
let csrf = "";
export const setCSRF = (t: string) => { csrf = t; };
export class ApiError extends Error {
constructor(public status: number, message: string, public body?: any) { super(message); }
}
export async function api<T = any>(path: string, opts: { method?: string; body?: any; raw?: BodyInit; signal?: AbortSignal } = {}): Promise<T> {
const headers: Record<string, string> = { "X-Requested-With": "pilens" };
const method = opts.method ?? (opts.body !== undefined || opts.raw ? "POST" : "GET");
if (method !== "GET") headers["X-CSRF-Token"] = csrf;
let body: BodyInit | undefined = opts.raw;
if (opts.body !== undefined) { headers["Content-Type"] = "application/json"; body = JSON.stringify(opts.body); }
const r = await fetch(path, { method, headers, body, credentials: "same-origin", signal: opts.signal });
const text = await r.text();
let data: any = undefined;
try { data = text ? JSON.parse(text) : undefined; } catch { data = text; }
if (!r.ok) {
if (r.status === 401 && path !== "/api/auth/login") window.dispatchEvent(new Event("pilens:unauthorised"));
throw new ApiError(r.status, (data && data.error) || r.statusText, data);
}
return data as T;
}
// ---- types mirroring the Go API ----
export interface Row { n: number; t: number; s: string; d: string; p: string; l: number; i: string; sp?: number; dp?: number; st?: number; tf?: string; ttl?: number; vl?: number; c?: string }
export interface Page { total: number; first: number; offset: number; rows: Row[]; nums?: number[]; filtered: boolean }
export interface TreeNode { label: string; field?: string; show?: string; pos: number; size: number; verified: boolean; children?: TreeNode[] }
export interface Detail { row: Row; bytes: string; tree: TreeNode[] | null; segment: number; note?: string }
export interface Session {
id: string; name: string; description: string; kind: "live" | "import"; state: string; interfaces: string[]; capture_filter: string;
started_at: number; ended_at: number; packets: number; bytes: number; drops: string; error: string; preserved: boolean;
tags: string; notes: string; ring_segments: number; meta: any; paused: boolean; ingest_lag: number;
}
export interface Segment { seq: number; file: string; format: string; base: number; count: number; bytes: number; sha256: string; complete: boolean; deleted: boolean }
export interface Iface { name: string; mac: string; addrs: string[] | null; up: boolean; carrier: boolean; wireless: boolean; driver: string; default_gateway?: string; virtual: boolean; speed_mbps?: number }
export interface Network {
management: { interfaces: string[]; allowed_subnets: string[]; port: number };
uplink: string;
ap: { enabled: boolean; interface: string; ssid: string; passphrase?: string; security: string; band: string; channel: number; hidden: boolean; country: string; client_isolation: boolean };
lan: { address: string; dhcp_start: string; dhcp_end: string; lease_minutes: number; upstream_dns: string[] | null; reservations: { mac: string; ip: string; hostname?: string }[] | null; dns_overrides: { name: string; ip: string }[] | null };
forwarding: { mode: string; allow_cidrs: string[] | null };
}
export interface ApplyResult { applied: boolean; rolled_back: boolean; steps: string[]; error?: string }
export interface Limits { max_bytes: number; reserve_bytes: number; segment_bytes: number; import_bytes: number }
// ---- live events (SSE) ----
type Handler = (type: string, data: any) => void;
const handlers = new Set<Handler>();
let es: EventSource | null = null;
export function connectEvents() {
if (es) return;
es = new EventSource("/api/events");
for (const t of ["hello", "packets", "capture", "resync", "ping"]) {
es.addEventListener(t, (e) => {
let d: any = {};
try { d = JSON.parse((e as MessageEvent).data); } catch { /* ignore */ }
handlers.forEach((h) => h(t, d));
});
}
es.onerror = () => handlers.forEach((h) => h("disconnected", {})); // EventSource reconnects by itself
}
export function disconnectEvents() { es?.close(); es = null; }
export function onEvent(h: Handler) { handlers.add(h); return () => { handlers.delete(h); }; }
// ---- formatting ----
export const fmtBytes = (n: number) => {
if (!isFinite(n)) return "–";
const u = ["B", "KiB", "MiB", "GiB", "TiB"]; let i = 0;
while (Math.abs(n) >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
};
export const fmtTime = (ms: number) => ms ? new Date(ms).toLocaleString() : "–";
export const fmtDur = (s: number) => {
if (!isFinite(s) || s < 0) return "–";
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = Math.floor(s % 60);
return h ? `${h}h ${m}m` : m ? `${m}m ${x}s` : `${x}s`;
};