rename web/ to admin/, along with the web-named build and cli identifiers
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { groupsQuery, lookupQuery } from "@/lib/queries";
|
||||
import type { Group, LookupResult } from "@/lib/types";
|
||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||
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)";
|
||||
|
||||
interface Submitted {
|
||||
domain: string;
|
||||
groupId: number;
|
||||
}
|
||||
|
||||
interface Verdict {
|
||||
label: string;
|
||||
tone: "local" | "blocked" | "forwarded" | "allowed";
|
||||
description: string;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
intro: {
|
||||
marginTop: "0.5rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
form: {
|
||||
marginTop: "1.5rem",
|
||||
display: "flex",
|
||||
maxWidth: "42rem",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-end",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
domainField: {
|
||||
minWidth: "14rem",
|
||||
flexGrow: 1,
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
note: {
|
||||
marginTop: "1.5rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
error: {
|
||||
marginTop: "1.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.danger,
|
||||
},
|
||||
card: {
|
||||
marginTop: "1.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
},
|
||||
banner: {
|
||||
borderStartStartRadius: "0.25rem",
|
||||
borderStartEndRadius: "0.25rem",
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.75rem",
|
||||
},
|
||||
/** Four verdicts need four tints; only "blocked" maps onto a token role. */
|
||||
local: {
|
||||
backgroundColor: { default: "oklch(93.2% 0.032 255.585)", [DARK]: "oklch(28.2% 0.091 267.935)" },
|
||||
color: { default: "oklch(42.4% 0.199 265.638)", [DARK]: "oklch(80.9% 0.105 251.813)" },
|
||||
},
|
||||
blocked: {
|
||||
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
|
||||
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
|
||||
},
|
||||
forwarded: {
|
||||
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
|
||||
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
|
||||
},
|
||||
allowed: {
|
||||
backgroundColor: { default: "oklch(96.2% 0.044 156.743)", [DARK]: "oklch(26.6% 0.065 152.934)" },
|
||||
color: { default: "oklch(44.8% 0.119 151.328)", [DARK]: "oklch(87.1% 0.15 154.449)" },
|
||||
},
|
||||
verdictLabel: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
verdictDescription: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
details: {
|
||||
paddingInline: "1rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/** `divide-y`: a hairline between rows, so the first row carries none. */
|
||||
detailRow: {
|
||||
display: "flex",
|
||||
gap: "1rem",
|
||||
paddingBlock: "0.5rem",
|
||||
borderTopWidth: { default: 1, ":first-child": 0 },
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
detailTerm: {
|
||||
width: "10rem",
|
||||
flexShrink: 0,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
detailValue: {
|
||||
minWidth: 0,
|
||||
overflowWrap: "break-word",
|
||||
},
|
||||
sourceLink: {
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
});
|
||||
|
||||
function toneStyle(tone: Verdict["tone"]) {
|
||||
if (tone === "local") return styles.local;
|
||||
if (tone === "blocked") return styles.blocked;
|
||||
return tone === "forwarded" ? styles.forwarded : styles.allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header priority follows the pipeline order the lookup handler documents
|
||||
* (PLAN §6): local records answer first, then the block decision, then
|
||||
* forward zones, then plain forwarding to the upstream pool.
|
||||
*/
|
||||
export function verdictOf(result: LookupResult): Verdict {
|
||||
if (result.local_records) {
|
||||
return {
|
||||
label: "Local answer",
|
||||
tone: "local",
|
||||
description: "A local record answers this name directly.",
|
||||
};
|
||||
}
|
||||
if (result.blocked) {
|
||||
return {
|
||||
label: "Blocked",
|
||||
tone: "blocked",
|
||||
description: "Queries for this name get a blocked response.",
|
||||
};
|
||||
}
|
||||
if (result.forward_zone !== null) {
|
||||
return {
|
||||
label: "Forwarded",
|
||||
tone: "forwarded",
|
||||
description: `Queries go to the resolver for zone ${result.forward_zone}.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "Allowed",
|
||||
tone: "allowed",
|
||||
description: "Queries resolve through the upstream pool.",
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status === 503) {
|
||||
return "No filter snapshot is loaded yet — the server is starting or degraded. Try again shortly.";
|
||||
}
|
||||
if (error.status === 429) {
|
||||
return error.retryAfter !== undefined
|
||||
? `Rate limited. Try again in ${error.retryAfter}s.`
|
||||
: "Rate limited. Try again shortly.";
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
return "Could not reach the server.";
|
||||
}
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div {...stylex.props(styles.detailRow)}>
|
||||
<dt {...stylex.props(styles.detailTerm)}>{label}</dt>
|
||||
<dd {...stylex.props(styles.detailValue)}>{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[] }) {
|
||||
const verdict = verdictOf(result);
|
||||
const groupName = groups.find((group) => group.id === result.group_id)?.name ?? `#${result.group_id}`;
|
||||
|
||||
return (
|
||||
<div {...stylex.props(styles.card)}>
|
||||
<div {...stylex.props(styles.banner, toneStyle(verdict.tone))}>
|
||||
<h2 {...stylex.props(styles.verdictLabel)}>{verdict.label}</h2>
|
||||
<p {...stylex.props(styles.verdictDescription)}>{verdict.description}</p>
|
||||
</div>
|
||||
<dl {...stylex.props(styles.details)}>
|
||||
<DetailRow label="Domain">
|
||||
<span {...stylex.props(shared.mono)}>{result.domain}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Group">{groupName}</DetailRow>
|
||||
<DetailRow label="Local record">{result.local_records ? "Yes" : "No"}</DetailRow>
|
||||
<DetailRow label="Forward zone">
|
||||
{result.forward_zone !== null ? (
|
||||
<span {...stylex.props(shared.mono)}>{result.forward_zone}</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Blocked">{result.blocked ? "Yes" : "No"}</DetailRow>
|
||||
<DetailRow label="Reason">
|
||||
<span {...stylex.props(shared.mono)}>{result.reason}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Matched pattern">
|
||||
{result.matched !== "" ? <span {...stylex.props(shared.mono)}>{result.matched}</span> : "—"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Blocklist source">
|
||||
{result.source_url !== null ? (
|
||||
<a
|
||||
href={result.source_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
{...stylex.props(styles.sourceLink, shared.focusRing)}
|
||||
>
|
||||
{result.source_url}
|
||||
</a>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Safe search rewrite">
|
||||
{result.safe_search_rewrite !== null ? (
|
||||
<span {...stylex.props(shared.mono)}>{result.safe_search_rewrite}</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LookupPage() {
|
||||
const groups = useSuspenseQuery(groupsQuery()).data;
|
||||
const preselectedGroupId = defaultGroupId(groups);
|
||||
|
||||
const [domain, setDomain] = useState("");
|
||||
const [groupId, setGroupId] = useState(preselectedGroupId);
|
||||
const [submitted, setSubmitted] = useState<Submitted | null>(null);
|
||||
|
||||
const lookup = useQuery({
|
||||
...lookupQuery(submitted?.domain ?? "", submitted?.groupId),
|
||||
enabled: submitted !== null,
|
||||
});
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const trimmed = domain.trim();
|
||||
if (trimmed === "") return;
|
||||
if (submitted !== null && submitted.domain === trimmed && submitted.groupId === groupId) {
|
||||
void lookup.refetch();
|
||||
return;
|
||||
}
|
||||
setSubmitted({ domain: trimmed, groupId });
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Lookup</h1>
|
||||
<p {...stylex.props(styles.intro)}>
|
||||
What the pipeline would do with a domain: local records, forward zones, block decision, safe search.
|
||||
</p>
|
||||
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
|
||||
<div {...stylex.props(styles.domainField)}>
|
||||
<label htmlFor="lookup-domain" {...stylex.props(styles.fieldLabel)}>
|
||||
Domain
|
||||
</label>
|
||||
<input
|
||||
id="lookup-domain"
|
||||
required
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
placeholder="ads.example.com"
|
||||
{...stylex.props(shared.input, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
label="Group"
|
||||
value={String(groupId)}
|
||||
onChange={(value) => setGroupId(Number(value))}
|
||||
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={lookup.isFetching}
|
||||
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||
>
|
||||
Look up
|
||||
</button>
|
||||
</form>
|
||||
{lookup.isFetching && <p {...stylex.props(styles.note)}>Looking up…</p>}
|
||||
{!lookup.isFetching && lookup.isError && (
|
||||
<p role="alert" {...stylex.props(styles.error)}>
|
||||
{errorMessage(lookup.error)}
|
||||
</p>
|
||||
)}
|
||||
{!lookup.isFetching && lookup.data !== undefined && !lookup.isError && (
|
||||
<VerdictCard result={lookup.data} groups={groups} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user