Files
pilens/web/src/pages/Library.tsx
T

103 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useState } from "react";
import { api, fmtBytes, fmtTime, type Session, type Segment } from "../api";
import { Badge, Modal, go, toast, usePoll } from "../ui";
export default function Library() {
const [sessions, err, reload] = usePoll<Session[]>("/api/captures", 5000);
const [q, setQ] = useState("");
const [edit, setEdit] = useState<Session | null>(null);
const [del, setDel] = useState<Session | null>(null);
const [info, setInfo] = useState<{ s: Session; segs: Segment[]; verify?: any[] } | null>(null);
const [uploading, setUploading] = useState(false);
const file = useRef<HTMLInputElement>(null);
const list = (sessions ?? []).filter((s) => !q || (s.name + " " + s.tags + " " + s.description + " " + s.notes).toLowerCase().includes(q.toLowerCase()));
const upload = async (f: File) => {
setUploading(true);
try {
const s = await api<Session>(`/api/captures/import?name=${encodeURIComponent(f.name)}`, { raw: f });
toast(`Imported ${f.name}; indexing…`);
reload();
go("/analyse/" + s.id);
} catch (e) { toast((e as Error).message, true); }
setUploading(false);
};
return (
<div className="page">
<div className="page-head">
<div><h1>Capture Library</h1><p>Stored sessions. Original packet files are kept unmodified, with SHA-256 hashes.</p></div>
<div className="row">
<input placeholder="Search name, tags, notes" value={q} onChange={(e) => setQ(e.target.value)} />
<input ref={file} type="file" accept=".pcap,.pcapng,.cap" hidden onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} />
<button onClick={() => file.current?.click()} disabled={uploading}>{uploading ? "Uploading…" : "Import pcap/pcapng"}</button>
</div>
</div>
{err && <div className="banner bad"><div>{err}</div></div>}
<div className="card" style={{ padding: 0 }}>
<table className="data">
<thead><tr><th>Name</th><th>State</th><th>Started</th><th>Duration</th><th>Packets</th><th>Size</th><th>Source</th><th></th></tr></thead>
<tbody>
{list.map((s) => (
<tr key={s.id}>
<td><a href={"#/analyse/" + s.id}>{s.name}</a>{s.preserved && <> <Badge kind="ok">preserved</Badge></>}{s.tags && <div className="hint">{s.tags}</div>}</td>
<td><Badge kind={s.state === "running" ? "live" : s.state === "failed" ? "bad" : s.state === "importing" ? "warn" : "plain"}>{s.state}</Badge>{s.error && <div className="err hint" title={s.error}>{s.error.slice(0, 60)}</div>}</td>
<td className="hint">{fmtTime(s.started_at)}</td>
<td className="mono">{s.ended_at ? ((s.ended_at - s.started_at) / 1000).toFixed(0) + " s" : "–"}</td>
<td className="mono">{s.packets.toLocaleString()}</td>
<td className="mono">{fmtBytes(s.bytes)}</td>
<td className="mono">{s.kind === "import" ? "import" : (s.interfaces ?? []).join(",")}{s.capture_filter && <div className="hint" title="capture filter">{s.capture_filter}</div>}</td>
<td><div className="row" style={{ flexWrap: "nowrap" }}>
<button className="small" onClick={async () => setInfo({ s, segs: (await api(`/api/captures/${s.id}`)).segments ?? [] })}>Integrity</button>
<a className="btn small" href={`/api/captures/${s.id}/export`} download>Export</a>
<button className="small" onClick={() => setEdit({ ...s })}>Edit</button>
<button className="small danger" disabled={s.state === "running"} onClick={() => setDel(s)}>Delete</button>
</div></td>
</tr>
))}
{list.length === 0 && <tr><td colSpan={8} className="empty">No sessions.</td></tr>}
</tbody>
</table>
</div>
<Modal open={!!edit} title="Edit session" onClose={() => setEdit(null)} footer={<><button onClick={() => setEdit(null)}>Cancel</button>
<button className="primary" onClick={async () => { try { await api(`/api/captures/${edit!.id}`, { method: "PATCH", body: { Name: edit!.name, Description: edit!.description, Tags: edit!.tags, Notes: edit!.notes, Preserved: edit!.preserved } }); setEdit(null); reload(); } catch (e) { toast((e as Error).message, true); } }}>Save</button></>}>
{edit && <>
<label>Name<input value={edit.name} onChange={(e) => setEdit({ ...edit, name: e.target.value })} /></label>
<label>Description<input value={edit.description} onChange={(e) => setEdit({ ...edit, description: e.target.value })} /></label>
<label>Tags<input value={edit.tags} placeholder="comma separated" onChange={(e) => setEdit({ ...edit, tags: e.target.value })} /></label>
<label>Operator notes<textarea rows={5} value={edit.notes} onChange={(e) => setEdit({ ...edit, notes: e.target.value })} /></label>
<label className="inline"><input type="checkbox" checked={edit.preserved} onChange={(e) => setEdit({ ...edit, preserved: e.target.checked })} />Preserve: never delete automatically (retention or ring buffer)</label>
</>}
</Modal>
<Modal open={!!del} title="Delete capture session?" onClose={() => setDel(null)} footer={<><button onClick={() => setDel(null)}>Cancel</button>
<button className="danger" onClick={async () => { try { await api(`/api/captures/${del!.id}?confirm=${del!.id}`, { method: "DELETE" }); setDel(null); reload(); toast("Session deleted"); } catch (e) { toast((e as Error).message, true); } }}>Delete permanently</button></>}>
<p>This permanently deletes <b>{del?.name}</b>: {del?.packets.toLocaleString()} packets, {fmtBytes(del?.bytes ?? 0)} of original capture files, and all derived indexes. It cannot be undone.</p>
{del?.preserved && <div className="banner warn"><div>This session is marked <b>preserved</b>.</div></div>}
</Modal>
<Modal open={!!info} title="Integrity" onClose={() => setInfo(null)} footer={<>
<a className="btn" href={`/api/captures/${info?.s.id}/manifest`} download>Download manifest</a>
<button className="primary" onClick={async () => { const v = await api(`/api/captures/${info!.s.id}/verify`, { method: "POST" }); setInfo({ ...info!, verify: v }); }}>Verify hashes now</button></>}>
{info && <>
<dl className="kv">
<dt>Session</dt><dd className="mono">{info.s.id}</dd>
<dt>Clock synced</dt><dd>{String(info.s.meta?.clock_synchronised ?? "unknown")}</dd>
<dt>Software</dt><dd className="mono">PiLens {info.s.meta?.pilens_version} · {info.s.meta?.tshark_version}</dd>
{info.s.drops && <><dt>Drops</dt><dd className="mono" style={{ whiteSpace: "pre-wrap" }}>{info.s.drops}</dd></>}
</dl>
<table className="data"><thead><tr><th>#</th><th>File</th><th>Packets</th><th>SHA-256</th><th>Check</th></tr></thead><tbody>
{info.segs.map((g) => { const v = info.verify?.find((x) => x.seq === g.seq); return (
<tr key={g.seq}><td>{g.seq}</td><td className="mono">{g.file}{g.deleted && " (rotated out)"}</td><td className="mono">{g.count}</td>
<td className="mono" style={{ fontSize: ".72rem", wordBreak: "break-all" }}>{g.sha256 || "(still being written)"}</td>
<td>{v ? <Badge kind={v.status === "ok" ? "ok" : v.status === "mismatch" || v.status === "missing" ? "bad" : "warn"}>{v.status}</Badge> : ""}</td></tr>); })}
</tbody></table>
<p className="hint">Segment files are the original captured bytes. Packet summaries and dissections are derived on demand and are not evidence.</p>
</>}
</Modal>
</div>
);
}