rename web/ to admin/, along with the web-named build and cli identifiers
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user