Cross Compile Go / build (push) Canceled after 0s
- 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
316 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|