238 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|