- 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
This commit is contained in:
1 parent
0ef27b0072
commit
56d9e10621
79 files changed
+13146
-118
No files matched your search
@@ -0,0 +1,315 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user