179 lines
6.1 KiB
TypeScript
179 lines
6.1 KiB
TypeScript
// 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>
|
||
|
|
);
|
||
|
|
}
|