Files
reforger_crawler_main/web/src/pages/Whitelist.tsx
T
ilbinek 56d9e10621
Cross Compile Go / build (push) Canceled after 0s
admin dashboard, metrics and telemetry
- embedded React admin UI at /admin/ui (go:embed), login with ADMIN_SECRET
  via signed HttpOnly session cookie, X-ADMIN-KEY still works for scripts
- admin API: overview stats, indexing queue, addon management (priority,
  reindex, delete/restore/purge, bulk), whitelist CRUD, duplicate hashes,
  traffic, metrics explorer, services, events/audit log, system info
- built-in metrics: request latency per route, visitors, top addons and
  searches, indexer/scraper counters; minute buckets in the DB with hourly
  rollups and retention; optional Prometheus /metrics (METRICS_TOKEN)
- POST /back/telemetry for indexers/scrapers (metrics, events, heartbeat)
  and a stdlib-only Go client in telemetry/
- auth hardening: empty secrets no longer allow access, constant-time
  compare, CORS not applied to /admin and /back, TRUSTED_PROXIES setting
- Dockerfile builds the UI and no longer bakes config.yaml into the image;
  CI builds the UI, runs tests and builds arm64 with CGO
2026-10-03 18:10:57 +02:00

316 lines
13 KiB
TypeScript

import { useState } from "react";
import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { Plus, Search, ShieldCheck, Trash2 } from "lucide-react";
import { api, qs } from "../lib/api";
import type { DupHash, Paged, WhitelistRow } from "../lib/types";
import { int, relative, shortHash } from "../lib/format";
import {
Badge,
Button,
Card,
Checkbox,
CopyButton,
Empty,
ErrorBox,
Input,
Loading,
PageHeader,
Pager,
Segmented,
Table,
Td,
Textarea,
Th,
useFeedback,
} from "../components/ui";
interface HashUsage {
hash: string;
whitelisted: boolean;
total: number;
items: { addonId: string; name: string; path: string; version: string }[];
}
export default function Whitelist() {
const [tab, setTab] = useState<"list" | "candidates">("list");
const [inspect, setInspect] = useState<string | null>(null);
return (
<div className="space-y-4">
<PageHeader
title="Whitelisted hashes"
description="Files whose hash is whitelisted are ignored by duplicate detection for every addon — use it for shared engine or template files."
actions={
<Segmented
options={[
{ key: "list", label: "Whitelist" },
{ key: "candidates", label: "Most shared hashes" },
]}
value={tab}
onChange={setTab}
/>
}
/>
{tab === "list" ? <List onInspect={setInspect} /> : <Candidates onInspect={setInspect} />}
{inspect && <UsageDialog hash={inspect} onClose={() => setInspect(null)} />}
</div>
);
}
function List({ onInspect }: { onInspect: (h: string) => void }) {
const qc = useQueryClient();
const { toast, confirm } = useFeedback();
const [q, setQ] = useState("");
const [draft, setDraft] = useState("");
const [offset, setOffset] = useState(0);
const [text, setText] = useState("");
const [note, setNote] = useState("");
const limit = 50;
const list = useQuery({
queryKey: ["whitelist", q, offset],
queryFn: () => api<Paged<WhitelistRow>>("/whitelist" + qs({ q, offset, limit })),
placeholderData: keepPreviousData,
});
const add = useMutation({
mutationFn: () => api<{ added: number; skipped: { hash: string; reason: string }[] }>("/whitelist", { method: "POST", body: { text, note } }),
onSuccess: (r) => {
const reasons = r.skipped.length ? ` · ${r.skipped.length} skipped (${[...new Set(r.skipped.map((s) => s.reason))].join(", ")})` : "";
toast(`${r.added} hash${r.added === 1 ? "" : "es"} added${reasons}`, r.added ? "good" : "neutral");
if (r.added) {
setText("");
setNote("");
}
qc.invalidateQueries({ queryKey: ["whitelist"] });
qc.invalidateQueries({ queryKey: ["dups"] });
},
onError: (e) => toast((e as Error).message, "critical"),
});
const remove = useMutation({
mutationFn: (id: number) => api(`/whitelist/${id}`, { method: "DELETE" }),
onSuccess: () => {
toast("Removed from whitelist");
qc.invalidateQueries({ queryKey: ["whitelist"] });
},
onError: (e) => toast((e as Error).message, "critical"),
});
const count = text.split(/[\s,;]+/).filter(Boolean).length;
return (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<Card title="Add hashes" subtitle="SHA-512 hex, one per line (or separated by commas/spaces)" className="xl:order-2">
<Textarea rows={8} value={text} onChange={(e) => setText(e.target.value)} placeholder="9f86d081884c7d659a2feaa0c55ad015…" className="w-full" />
<Input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Note (optional) — e.g. vanilla UI layout" className="mt-2 w-full" />
<Button variant="primary" className="mt-3 w-full" icon={<Plus className="size-4" />} disabled={!count} loading={add.isPending} onClick={() => add.mutate()}>
Add {count ? int(count) : ""} hash{count === 1 ? "" : "es"}
</Button>
</Card>
<Card bodyClass="p-0" className="xl:col-span-2">
<form
className="relative border-b border-line p-3"
onSubmit={(e) => {
e.preventDefault();
setOffset(0);
setQ(draft.trim());
}}
>
<Search className="pointer-events-none absolute top-1/2 left-5.5 size-3.5 -translate-y-1/2 text-muted" />
<Input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Search by hash prefix or note…" className="w-full pl-8" />
</form>
{list.isLoading ? (
<div className="px-4"><Loading /></div>
) : list.error ? (
<div className="p-4"><ErrorBox error={list.error} /></div>
) : list.data!.items.length === 0 ? (
<Empty>{q ? "No matching hashes" : "No whitelisted hashes yet"}</Empty>
) : (
<div className="px-4">
<Table>
<thead>
<tr>
<Th>Hash</Th>
<Th>Example file</Th>
<Th className="text-right">Used by</Th>
<Th>Added</Th>
<Th />
</tr>
</thead>
<tbody>
{list.data!.items.map((w) => (
<tr key={w.id} className="hover:bg-surface-2/40">
<Td className="whitespace-nowrap">
<button className="font-mono text-xs text-ink hover:text-accent" onClick={() => onInspect(w.hash)}>
{shortHash(w.hash, 10)}
</button>
<CopyButton value={w.hash} />
{w.note && <div className="max-w-[240px] truncate text-xs text-muted" title={w.note}>{w.note}</div>}
</Td>
<Td className="max-w-[260px] truncate font-mono text-xs" title={w.samplePath}>{w.samplePath || "—"}</Td>
<Td className="tnum whitespace-nowrap text-right">
{int(w.addons)} addons
<div className="text-xs text-muted">{int(w.files)} files</div>
</Td>
<Td className="whitespace-nowrap">{relative(w.createdAt)}</Td>
<Td className="text-right">
<Button
size="sm"
variant="ghost"
icon={<Trash2 className="size-3.5" />}
aria-label="Remove"
onClick={async () => {
if (await confirm({ title: "Remove from whitelist?", body: "Files with this hash will count as duplicates again.", danger: true, confirmLabel: "Remove" }))
remove.mutate(w.id);
}}
/>
</Td>
</tr>
))}
</tbody>
</Table>
</div>
)}
{list.data && (
<div className="border-t border-line px-4 py-2.5">
<Pager total={list.data.total} limit={limit} offset={offset} onChange={setOffset} />
</div>
)}
</Card>
</div>
);
}
function Candidates({ onInspect }: { onInspect: (h: string) => void }) {
const qc = useQueryClient();
const { toast } = useFeedback();
const [hide, setHide] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set());
const dups = useQuery({
queryKey: ["dups", hide],
queryFn: () => api<{ asOf: number; items: DupHash[]; pending?: boolean }>(`/overview/duplicates${hide ? "?hideWhitelisted=1" : ""}`),
});
const add = useMutation({
mutationFn: (hashes: string[]) => api<{ added: number }>("/whitelist", { method: "POST", body: { hashes, note: "from most shared hashes" } }),
onSuccess: (r) => {
toast(`${r.added} hash${r.added === 1 ? "" : "es"} whitelisted`);
setSelected(new Set());
qc.invalidateQueries({ queryKey: ["dups"] });
qc.invalidateQueries({ queryKey: ["whitelist"] });
},
onError: (e) => toast((e as Error).message, "critical"),
});
if (dups.isLoading) return <Loading />;
if (dups.error) return <ErrorBox error={dups.error} />;
const items = dups.data!.items;
return (
<Card
title="Hashes shared by the most addons"
subtitle={dups.data!.pending ? "Statistics are being computed…" : `Computed ${relative(dups.data!.asOf)} · refreshed hourly`}
actions={
<>
<label className="flex items-center gap-1.5 text-xs text-ink-2">
<Checkbox checked={hide} onChange={setHide} label="Hide whitelisted" /> Hide whitelisted
</label>
<Button size="sm" variant="primary" icon={<ShieldCheck className="size-3.5" />} disabled={selected.size === 0} loading={add.isPending} onClick={() => add.mutate([...selected])}>
Whitelist {selected.size || ""}
</Button>
</>
}
>
{items.length === 0 ? (
<Empty>No shared hashes</Empty>
) : (
<Table>
<thead>
<tr>
<Th className="w-8" />
<Th>Hash</Th>
<Th>Example file</Th>
<Th className="text-right">Addons</Th>
<Th className="text-right">Files</Th>
<Th />
</tr>
</thead>
<tbody>
{items.map((d) => (
<tr key={d.hash} className="hover:bg-surface-2/40">
<Td>
{!d.whitelisted && (
<Checkbox
label="Select"
checked={selected.has(d.hash)}
onChange={(v) => {
const next = new Set(selected);
if (v) next.add(d.hash);
else next.delete(d.hash);
setSelected(next);
}}
/>
)}
</Td>
<Td className="whitespace-nowrap">
<button className="font-mono text-xs text-ink hover:text-accent" onClick={() => onInspect(d.hash)}>
{shortHash(d.hash, 10)}
</button>
<CopyButton value={d.hash} />
</Td>
<Td className="max-w-[340px] truncate font-mono text-xs" title={d.samplePath}>{d.samplePath}</Td>
<Td className="tnum text-right text-ink">{int(d.addonCount)}</Td>
<Td className="tnum text-right">{int(d.fileCount)}</Td>
<Td className="text-right">{d.whitelisted && <Badge tone="good">whitelisted</Badge>}</Td>
</tr>
))}
</tbody>
</Table>
)}
</Card>
);
}
function UsageDialog({ hash, onClose }: { hash: string; onClose: () => void }) {
const navigate = useNavigate();
const usage = useQuery({ queryKey: ["hash", hash], queryFn: () => api<HashUsage>(`/hashes/${hash}`) });
return (
<div className="fixed inset-0 z-40 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative max-h-[80vh] w-[min(720px,100%)] overflow-hidden rounded-xl border border-line bg-surface shadow-2xl">
<div className="flex items-start justify-between gap-3 border-b border-line px-5 py-4">
<div className="min-w-0">
<h3 className="font-medium text-ink">Files with this hash</h3>
<div className="mt-1 font-mono text-[11px] break-all text-muted">{hash}</div>
</div>
<Button size="sm" variant="ghost" onClick={onClose}>Close</Button>
</div>
<div className="max-h-[60vh] overflow-y-auto px-5 py-3">
{usage.isLoading ? (
<Loading />
) : usage.error ? (
<ErrorBox error={usage.error} />
) : (
<>
<div className="mb-2 flex items-center gap-2 text-xs text-ink-2">
{int(usage.data!.total)} files {usage.data!.whitelisted ? <Badge tone="good">whitelisted</Badge> : <Badge>not whitelisted</Badge>}
</div>
<Table>
<tbody>
{usage.data!.items.map((u, i) => (
<tr key={u.addonId + i} className="cursor-pointer hover:bg-surface-2/40" onClick={() => navigate(`/addons/${u.addonId}`)}>
<Td className="text-ink">{u.name || u.addonId}</Td>
<Td className="max-w-[320px] truncate font-mono text-xs" title={u.path}>{u.path}</Td>
</tr>
))}
</tbody>
</Table>
</>
)}
</div>
</div>
</div>
);
}