the vitest "something prevents Vite server from exiting" warning was @stylexjs/unplugin, not our code: configureServer starts a 150ms polling interval for HMR and clears it from server.httpServer's close event, which vitest resolves to null in middleware mode. drop that one hook under VITEST and keep the transform. textMuted was swept rather than judged. measured against their grounds, the 39 flat text-zinc-500 sites went 4.12:1 -> 7.56:1 in dark mode, repairing a WCAG AA failure, so that stands; the 4 already-adaptive sites went 7.40:1 -> 4.62:1 in light mode for nothing, so a textSecondary token restores their original zinc-600/zinc-400 pair. append the symbol families to the reset's font stack: Select renders U+25BE and Segoe UI does not carry it. correct two acceptance criteria that were literally false: both greps match only comments.
371 lines
11 KiB
TypeScript
371 lines
11 KiB
TypeScript
import { useState, type FormEvent } from "react";
|
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
import * as stylex from "@stylexjs/stylex";
|
|
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";
|
|
import Select from "@/ui/Select";
|
|
import { styles as shared } from "@/ui/styles";
|
|
import { colors } from "@/ui/tokens.stylex";
|
|
|
|
const DARK = "@media (prefers-color-scheme: dark)";
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ value: "any", label: "All" },
|
|
{ value: "blocked", label: "Blocked only" },
|
|
{ value: "allowed", label: "Allowed only" },
|
|
];
|
|
|
|
const styles = stylex.create({
|
|
heading: {
|
|
fontSize: "1.5rem",
|
|
lineHeight: "2rem",
|
|
fontWeight: 600,
|
|
},
|
|
/** One column on a phone, two from `sm`, five from `lg`, as before. */
|
|
filterGrid: {
|
|
marginTop: "1rem",
|
|
display: "grid",
|
|
gap: "0.75rem",
|
|
gridTemplateColumns: {
|
|
default: "repeat(1, minmax(0, 1fr))",
|
|
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
|
|
"@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))",
|
|
},
|
|
},
|
|
filterLabel: {
|
|
display: "block",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
},
|
|
filterInput: {
|
|
marginTop: "0.25rem",
|
|
width: "100%",
|
|
},
|
|
buttonRow: {
|
|
display: "flex",
|
|
alignItems: "flex-end",
|
|
gap: "0.5rem",
|
|
gridColumn: {
|
|
default: null,
|
|
"@media (min-width: 640px)": "span 2 / span 2",
|
|
"@media (min-width: 1024px)": "span 5 / span 5",
|
|
},
|
|
},
|
|
toolbarButton: {
|
|
fontWeight: 500,
|
|
},
|
|
note: {
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.textMuted,
|
|
},
|
|
empty: {
|
|
marginTop: "1.5rem",
|
|
color: colors.textMuted,
|
|
},
|
|
tableWrap: {
|
|
marginTop: "1rem",
|
|
overflowX: "auto",
|
|
borderRadius: "0.25rem",
|
|
borderWidth: 1,
|
|
borderStyle: "solid",
|
|
borderColor: colors.border,
|
|
},
|
|
table: {
|
|
width: "100%",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
},
|
|
/** The header tint is a shade off the ground in each scheme, not a token role. */
|
|
head: {
|
|
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
|
textAlign: "left",
|
|
},
|
|
th: {
|
|
paddingInline: "0.75rem",
|
|
paddingBlock: "0.5rem",
|
|
fontWeight: 500,
|
|
color: colors.textSecondary,
|
|
},
|
|
/** `divide-y`: a hairline between rows, so the first row carries none. */
|
|
row: {
|
|
borderTopWidth: { default: 1, ":first-child": 0 },
|
|
borderTopStyle: "solid",
|
|
borderTopColor: colors.border,
|
|
},
|
|
cell: {
|
|
paddingInline: "0.75rem",
|
|
paddingBlock: "0.5rem",
|
|
},
|
|
nowrap: {
|
|
whiteSpace: "nowrap",
|
|
},
|
|
breakAll: {
|
|
wordBreak: "break-all",
|
|
},
|
|
small: {
|
|
fontSize: "0.75rem",
|
|
lineHeight: "1rem",
|
|
},
|
|
muted: {
|
|
color: colors.textMuted,
|
|
},
|
|
blockedWrap: {
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: "0.375rem",
|
|
},
|
|
blockedBadge: {
|
|
borderRadius: "0.25rem",
|
|
paddingInline: "0.375rem",
|
|
paddingBlock: "0.125rem",
|
|
fontSize: "0.75rem",
|
|
lineHeight: "1rem",
|
|
fontWeight: 500,
|
|
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
|
|
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
|
|
},
|
|
footer: {
|
|
marginTop: "0.75rem",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.75rem",
|
|
},
|
|
moreError: {
|
|
marginTop: "0.5rem",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.dangerText,
|
|
},
|
|
});
|
|
|
|
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 {...stylex.props(styles.muted)}>—</span>;
|
|
return (
|
|
<span {...stylex.props(styles.blockedWrap)}>
|
|
<span {...stylex.props(styles.blockedBadge)}>Blocked</span>
|
|
{row.block_reason !== "" && <span {...stylex.props(styles.small, styles.muted)}>{row.block_reason}</span>}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function QueryCells({ row }: { row: Omit<QueryRow, "id"> }) {
|
|
return (
|
|
<>
|
|
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
|
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>{row.domain}</td>
|
|
<td {...stylex.props(styles.cell, styles.small, styles.nowrap, shared.mono)}>{row.client_ip}</td>
|
|
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
|
|
<td {...stylex.props(styles.cell)}>
|
|
<BlockedCell row={row} />
|
|
</td>
|
|
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
|
|
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
|
|
</td>
|
|
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
|
{row.cache_hit === null ? "—" : row.cache_hit ? "hit" : "miss"}
|
|
</td>
|
|
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
|
|
{row.upstream === "" ? "—" : row.upstream}
|
|
</td>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function QueryTableHead() {
|
|
return (
|
|
<thead {...stylex.props(styles.head)}>
|
|
<tr>
|
|
<th {...stylex.props(styles.th)}>Time</th>
|
|
<th {...stylex.props(styles.th)}>Domain</th>
|
|
<th {...stylex.props(styles.th)}>Client</th>
|
|
<th {...stylex.props(styles.th)}>Type</th>
|
|
<th {...stylex.props(styles.th)}>Status</th>
|
|
<th {...stylex.props(styles.th)}>Response</th>
|
|
<th {...stylex.props(styles.th)}>Cache</th>
|
|
<th {...stylex.props(styles.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 {...stylex.props(styles.heading)}>Query Log</h1>
|
|
|
|
<form onSubmit={applyFilters} {...stylex.props(styles.filterGrid)}>
|
|
<label {...stylex.props(styles.filterLabel)}>
|
|
Domain contains
|
|
<input
|
|
type="text"
|
|
value={domain}
|
|
onChange={(event) => setDomain(event.target.value)}
|
|
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
|
/>
|
|
</label>
|
|
<label {...stylex.props(styles.filterLabel)}>
|
|
Client (exact)
|
|
<input
|
|
type="text"
|
|
value={client}
|
|
onChange={(event) => setClient(event.target.value)}
|
|
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
|
/>
|
|
</label>
|
|
<Select
|
|
variant="compactField"
|
|
label="Status"
|
|
value={blocked}
|
|
onChange={setBlocked}
|
|
options={STATUS_OPTIONS}
|
|
/>
|
|
<label {...stylex.props(styles.filterLabel)}>
|
|
Since
|
|
<input
|
|
type="datetime-local"
|
|
value={since}
|
|
onChange={(event) => setSince(event.target.value)}
|
|
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
|
/>
|
|
</label>
|
|
<label {...stylex.props(styles.filterLabel)}>
|
|
Until
|
|
<input
|
|
type="datetime-local"
|
|
value={until}
|
|
onChange={(event) => setUntil(event.target.value)}
|
|
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
|
/>
|
|
</label>
|
|
<div {...stylex.props(styles.buttonRow)}>
|
|
<button type="submit" {...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}>
|
|
Apply filters
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={clearFilters}
|
|
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
|
>
|
|
Clear
|
|
</button>
|
|
{base.isFetching && (
|
|
<span {...stylex.props(styles.note)} role="status">
|
|
Loading…
|
|
</span>
|
|
)}
|
|
</div>
|
|
</form>
|
|
|
|
{base.data === undefined ? (
|
|
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
|
Loading query log…
|
|
</p>
|
|
) : rows.length === 0 ? (
|
|
<p {...stylex.props(styles.empty)}>
|
|
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
|
</p>
|
|
) : (
|
|
<>
|
|
<div {...stylex.props(styles.tableWrap)}>
|
|
<table {...stylex.props(styles.table)}>
|
|
<QueryTableHead />
|
|
<tbody>
|
|
{rows.map((row) => (
|
|
<tr key={row.id} {...stylex.props(styles.row)}>
|
|
<QueryCells row={row} />
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div {...stylex.props(styles.footer)}>
|
|
<p {...stylex.props(styles.note)}>
|
|
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
|
{hasMore ? "" : " — end of log"}
|
|
</p>
|
|
{hasMore && (
|
|
<button
|
|
type="button"
|
|
onClick={loadMore}
|
|
disabled={base.isFetchingNextPage || base.isPlaceholderData}
|
|
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
|
>
|
|
{base.isFetchingNextPage ? "Loading…" : "Load more"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{moreError !== null && (
|
|
<p role="alert" {...stylex.props(styles.moreError)}>
|
|
Failed to load more: {moreError}
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|