milestone 9: react spa admin ui, frontend ci and embedded dist
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import * as api from "@/lib/api";
|
||||
import { formatMicros, formatTime } from "@/lib/format";
|
||||
import { queriesQuery } from "@/lib/queries";
|
||||
import { handleUnauthorized } from "@/lib/queryClient";
|
||||
import type { QueriesFilter, QueryRow } from "@/lib/types";
|
||||
import { qtypeName } from "./qtype";
|
||||
|
||||
const inputClass =
|
||||
"mt-1 w-full rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
|
||||
const buttonClass =
|
||||
"rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
|
||||
|
||||
function datetimeLocalToUnix(value: string): number | undefined {
|
||||
if (value === "") return undefined;
|
||||
const ms = new Date(value).getTime();
|
||||
return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined;
|
||||
}
|
||||
|
||||
export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_reason"> }) {
|
||||
if (!row.blocked) return <span className="text-zinc-400">—</span>;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
Blocked
|
||||
</span>
|
||||
{row.block_reason !== "" && <span className="text-xs text-zinc-500">{row.block_reason}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function QueryCells({ row }: { row: Omit<QueryRow, "id"> }) {
|
||||
return (
|
||||
<>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-zinc-500">{formatTime(row.ts)}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs break-all">{row.domain}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{row.client_ip}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{qtypeName(row.qtype)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<BlockedCell row={row} />
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap tabular-nums">
|
||||
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
{row.cache_hit === null ? "—" : row.cache_hit ? "hit" : "miss"}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs break-all">{row.upstream === "" ? "—" : row.upstream}</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function QueryTableHead() {
|
||||
const th = "px-3 py-2 font-medium text-zinc-600 dark:text-zinc-400";
|
||||
return (
|
||||
<thead className="bg-zinc-50 text-left dark:bg-zinc-900">
|
||||
<tr>
|
||||
<th className={th}>Time</th>
|
||||
<th className={th}>Domain</th>
|
||||
<th className={th}>Client</th>
|
||||
<th className={th}>Type</th>
|
||||
<th className={th}>Status</th>
|
||||
<th className={th}>Response</th>
|
||||
<th className={th}>Cache</th>
|
||||
<th className={th}>Upstream</th>
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QueryLogPage() {
|
||||
const [domain, setDomain] = useState("");
|
||||
const [client, setClient] = useState("");
|
||||
const [blocked, setBlocked] = useState("any");
|
||||
const [since, setSince] = useState("");
|
||||
const [until, setUntil] = useState("");
|
||||
|
||||
const [applied, setApplied] = useState<QueriesFilter>({});
|
||||
const [extra, setExtra] = useState<QueryRow[]>([]);
|
||||
const [cursorOverride, setCursorOverride] = useState<number | null | undefined>(undefined);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [moreError, setMoreError] = useState<string | null>(null);
|
||||
const generation = useRef(0);
|
||||
|
||||
const base = useQuery({ ...queriesQuery(applied), placeholderData: keepPreviousData });
|
||||
|
||||
const rows: QueryRow[] = [...(base.data?.queries ?? []), ...extra];
|
||||
const nextBefore = cursorOverride !== undefined ? cursorOverride : (base.data?.next_before ?? null);
|
||||
const filterActive = Object.keys(applied).length > 0;
|
||||
|
||||
function resetAccumulation() {
|
||||
generation.current += 1;
|
||||
setExtra([]);
|
||||
setCursorOverride(undefined);
|
||||
setLoadingMore(false);
|
||||
setMoreError(null);
|
||||
}
|
||||
|
||||
function applyFilters(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const filter: QueriesFilter = {};
|
||||
if (domain.trim() !== "") filter.domain = domain.trim();
|
||||
if (client.trim() !== "") filter.client = client.trim();
|
||||
if (blocked === "blocked") filter.blocked = true;
|
||||
if (blocked === "allowed") filter.blocked = false;
|
||||
const sinceTs = datetimeLocalToUnix(since);
|
||||
if (sinceTs !== undefined) filter.since = sinceTs;
|
||||
const untilTs = datetimeLocalToUnix(until);
|
||||
if (untilTs !== undefined) filter.until = untilTs;
|
||||
setApplied(filter);
|
||||
resetAccumulation();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
setDomain("");
|
||||
setClient("");
|
||||
setBlocked("any");
|
||||
setSince("");
|
||||
setUntil("");
|
||||
setApplied({});
|
||||
resetAccumulation();
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (nextBefore === null || loadingMore || base.isPlaceholderData) return;
|
||||
const startedGeneration = generation.current;
|
||||
setLoadingMore(true);
|
||||
setMoreError(null);
|
||||
api.getQueries({ ...applied, before: nextBefore })
|
||||
.then((page) => {
|
||||
if (generation.current !== startedGeneration) return;
|
||||
setExtra((prev) => [...prev, ...page.queries]);
|
||||
setCursorOverride(page.next_before);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof api.ApiError && error.status === 401) {
|
||||
handleUnauthorized(error);
|
||||
return;
|
||||
}
|
||||
if (generation.current !== startedGeneration) return;
|
||||
setMoreError(error instanceof Error ? error.message : String(error));
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation.current !== startedGeneration) return;
|
||||
setLoadingMore(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 className="text-2xl font-semibold">Query Log</h1>
|
||||
|
||||
<form onSubmit={applyFilters} className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<label className="block text-sm">
|
||||
Domain contains
|
||||
<input
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Client (exact)
|
||||
<input
|
||||
type="text"
|
||||
value={client}
|
||||
onChange={(event) => setClient(event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Status
|
||||
<select value={blocked} onChange={(event) => setBlocked(event.target.value)} className={inputClass}>
|
||||
<option value="any">All</option>
|
||||
<option value="blocked">Blocked only</option>
|
||||
<option value="allowed">Allowed only</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Since
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={since}
|
||||
onChange={(event) => setSince(event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Until
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={until}
|
||||
onChange={(event) => setUntil(event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-5">
|
||||
<button type="submit" className={buttonClass}>
|
||||
Apply filters
|
||||
</button>
|
||||
<button type="button" onClick={clearFilters} className={buttonClass}>
|
||||
Clear
|
||||
</button>
|
||||
{base.isFetching && (
|
||||
<span className="text-sm text-zinc-500" role="status">
|
||||
Loading…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{base.data === undefined ? (
|
||||
<p className="mt-6 animate-pulse text-zinc-500" role="status">
|
||||
Loading query log…
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="mt-6 text-zinc-500">
|
||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-4 overflow-x-auto rounded border border-zinc-200 dark:border-zinc-800">
|
||||
<table className="w-full text-sm">
|
||||
<QueryTableHead />
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<QueryCells row={row} />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<p className="text-sm text-zinc-500">
|
||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||
{nextBefore === null ? " — end of log" : ""}
|
||||
</p>
|
||||
{nextBefore !== null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore || base.isPlaceholderData}
|
||||
className={buttonClass}
|
||||
>
|
||||
{loadingMore ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{moreError !== null && (
|
||||
<p role="alert" className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
Failed to load more: {moreError}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user