milestone 9: react spa admin ui, frontend ci and embedded dist

This commit is contained in:
2026-08-02 13:04:09 +02:00
parent 5253c47303
commit 617cc966a2
82 changed files with 11833 additions and 17 deletions
+261
View File
@@ -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>
);
}