Files
nxdns/web/src/features/queries/QueryLogPage.tsx
T
mokhtar 25455e5ae2
CI / test (push) Failing after 11s
CI / cross (push) Failing after 25s
CI / docker (push) Failing after 24s
CI / test-aarch64 (push) Failing after 2m22s
CI / frontend (push) Successful in 43s
milestone 16: behavioral fixes for silent failures, locks, counters and the query log
2026-08-07 01:54:40 +02:00

238 lines
7.9 KiB
TypeScript

import { useState, type FormEvent } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import * as api from "@/lib/api";
import { formatMicros, formatTime } from "@/lib/format";
import { queriesInfiniteQuery } from "@/lib/queries";
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 errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
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 base = useInfiniteQuery(queriesInfiniteQuery(applied));
const pages = base.data?.pages ?? [];
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
const filterActive = Object.keys(applied).length > 0;
// `base.hasNextPage` reads the query state, which is empty while placeholder
// data stands in for a filter change; derive the cursor from what is on
// screen so the button keeps its place instead of flashing "end of log".
const lastPage = pages[pages.length - 1];
const hasMore = lastPage !== undefined && lastPage.next_before !== null;
// A 401 is already redirecting via the cache-level handleUnauthorized.
const isUnauthorized = base.error instanceof api.ApiError && base.error.status === 401;
const moreError = base.isFetchNextPageError && !isUnauthorized ? errorMessage(base.error) : 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);
}
function clearFilters() {
setDomain("");
setClient("");
setBlocked("any");
setSince("");
setUntil("");
setApplied({});
}
function loadMore() {
if (!hasMore || base.isFetchingNextPage || base.isPlaceholderData) return;
void base.fetchNextPage();
}
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"}
{hasMore ? "" : " — end of log"}
</p>
{hasMore && (
<button
type="button"
onClick={loadMore}
disabled={base.isFetchingNextPage || base.isPlaceholderData}
className={buttonClass}
>
{base.isFetchingNextPage ? "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>
);
}