Files
PacketStation/frontend/src/screens/admin/AccessLogTab.tsx
T

179 lines
6.1 KiB
TypeScript
Raw Normal View History

// Admin: the access log for everyone, with filters and a CSV download (phase 9).
import { useEffect, useState, type FormEvent } from "react";
import { admin9Api, type AccessLogFilters, type AccessLogRow } from "../../api/admin";
import { errorText, fmtDetail, fmtTime } from "../../lib/format";
import { Notice, type Msg } from "./common";
const PAGE = 200;
/** A datetime-local value as an ISO time, or undefined. */
function iso(local: string): string | undefined {
if (!local) return undefined;
const d = new Date(local);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
interface Form {
since: string;
until: string;
q: string;
kind: string;
ok: "" | "true" | "false";
ip: string;
}
const EMPTY: Form = { since: "", until: "", q: "", kind: "", ok: "", ip: "" };
function toFilters(f: Form): AccessLogFilters {
const out: AccessLogFilters = {};
const since = iso(f.since);
const until = iso(f.until);
if (since) out.since = since;
if (until) out.until = until;
if (f.q.trim()) out.q = f.q.trim();
if (f.kind.trim()) out.kind = f.kind.trim();
if (f.ok) out.ok = f.ok;
if (f.ip.trim()) out.ip = f.ip.trim();
return out;
}
export function AccessLogTab() {
const [form, setForm] = useState<Form>(EMPTY);
const [filters, setFilters] = useState<AccessLogFilters>({});
const [rows, setRows] = useState<AccessLogRow[]>([]);
const [next, setNext] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [msg, setMsg] = useState<Msg>(null);
const fetchPage = async (f: AccessLogFilters, before: string | null) => {
setLoading(true);
setMsg(null);
try {
const r = await admin9Api.accessLog({ ...f, limit: PAGE, ...(before ? { before } : {}) });
setRows((cur) => (before ? [...cur, ...r.rows] : r.rows));
setNext(r.next_before);
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setLoading(false);
}
};
// The first page, once. Later pages come from the search and the load-more button.
useEffect(() => {
void fetchPage({}, null);
}, []);
const set = (patch: Partial<Form>) => setForm((cur) => ({ ...cur, ...patch }));
const search = (e: FormEvent) => {
e.preventDefault();
const f = toFilters(form);
setFilters(f);
void fetchPage(f, null);
};
const csvHref = admin9Api.accessLogCsvUrl(filters);
return (
<div className="stack">
<form className="panel stack" onSubmit={search} noValidate role="search" aria-label="Access log filters">
<div className="frow">
<label className="f">
From
<input className="in" type="datetime-local" value={form.since} onChange={(e) => set({ since: e.target.value })} />
</label>
<label className="f">
To
<input className="in" type="datetime-local" value={form.until} onChange={(e) => set({ until: e.target.value })} />
</label>
<label className="f">
Name or account
<input className="in" type="search" value={form.q} onChange={(e) => set({ q: e.target.value })} />
</label>
<label className="f">
Kind
<input className="in mono" placeholder="auth.signin, password, pin" value={form.kind} onChange={(e) => set({ kind: e.target.value })} />
</label>
<label className="f">
Result
<select className="in" aria-label="Result" value={form.ok} onChange={(e) => set({ ok: e.target.value === "true" ? "true" : e.target.value === "false" ? "false" : "" })}>
<option value="">All</option>
<option value="true">OK only</option>
<option value="false">Failed only</option>
</select>
</label>
<label className="f">
Address
<input className="in mono" value={form.ip} onChange={(e) => set({ ip: e.target.value })} />
</label>
</div>
<div className="chips">
<button className="btn pri" type="submit" disabled={loading}>
Search access log
</button>
<button
className="btn ghost"
type="button"
onClick={() => {
setForm(EMPTY);
setFilters({});
void fetchPage({}, null);
}}
>
Clear filters
</button>
<a className="btn" href={csvHref} download>
Download CSV
</a>
</div>
<div className="small muted">The CSV has the rows the last search matched, up to 50,000. The download is logged.</div>
</form>
<Notice msg={msg} />
<div className="tw">
<table aria-label="Access log">
<thead>
<tr>
<th>When</th>
<th>Who</th>
<th>Kind</th>
<th>Result</th>
<th>Address</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={`${r.source}-${r.id}`}>
<td className="small mono nowrap">{fmtTime(r.at)}</td>
<td>
{r.display_name ?? ""} {r.account ? <span className="mono small muted">{r.account}</span> : null}
</td>
<td className="mono small">{r.kind}</td>
<td>{r.ok ? <span className="pill st-ok">OK</span> : <span className="pill st-bad">Failed</span>}</td>
<td className="mono small">{r.ip ?? "-"}</td>
<td className="small">{fmtDetail(r.detail)}</td>
</tr>
))}
{!loading && rows.length === 0 ? (
<tr>
<td colSpan={6} className="muted">
Nothing matches.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{loading ? <div className="empty slim">Loading</div> : null}
{next && !loading ? (
<div>
<button className="btn sm" type="button" onClick={() => void fetchPage(filters, next)}>
Load older rows
</button>
</div>
) : null}
</div>
);
}