admin dashboard, metrics and telemetry
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
This commit is contained in:
ilbinek committed 2026-10-03 18:10:57 +02:00
1 parent 0ef27b0072
commit 56d9e10621
79 files changed
+13146 -118

No files matched your search

View File
Whitespace-only changes.
+13
View File
@@ -0,0 +1,13 @@
// Package web serves the admin dashboard (a React app built into web/dist).
//
// Build the UI before building the Go binary:
//
// npm --prefix web ci && npm --prefix web run build
//
// Without a build the binary still compiles and serves a placeholder page.
package web
import "embed"
//go:embed all:dist
var dist embed.FS
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<link rel="icon" type="image/svg+xml" href="/admin/ui/favicon.svg" />
<title>Crawler Admin</title>
<script src="/admin/ui/theme-init.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2534
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "reforger-crawler-admin",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"clean": "node -e \"const fs=require('fs');fs.rmSync('dist/assets',{recursive:true,force:true});fs.rmSync('dist/index.html',{force:true})\"",
"build": "npm run clean && tsc -b && vite build",
"typecheck": "tsc -b"
},
"dependencies": {
"@tanstack/react-query": "^5.80.0",
"lucide-react": "^0.511.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router": "^7.6.0",
"uplot": "^1.6.32"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.8",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.0",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3",
"vite": "^6.3.5"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#1a1a19"/><path d="M8 21l5-6 4 4 7-9" fill="none" stroke="#3987e5" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 241 B

+5
View File
@@ -0,0 +1,5 @@
// Applies the saved theme before first paint (external file: the CSP blocks inline scripts).
try {
var t = localStorage.getItem("rc-theme");
document.documentElement.setAttribute("data-theme", t === "light" ? "light" : "dark");
} catch (e) {}
+75
View File
@@ -0,0 +1,75 @@
package web
import (
"io/fs"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
const notBuiltHTML = `<!doctype html><html><head><meta charset="utf-8"><title>Admin UI not built</title>
<style>body{font-family:system-ui,sans-serif;background:#0b0d10;color:#c9d1d9;display:grid;place-items:center;height:100vh;margin:0}
code{background:#161b22;padding:2px 6px;border-radius:4px}</style></head>
<body><div><h1>Admin UI not built</h1><p>Run <code>npm --prefix web ci &amp;&amp; npm --prefix web run build</code> and rebuild the binary.</p>
<p>The JSON API is available under <code>/admin/api</code>.</p></div></body></html>`
const csp = "default-src 'self'; img-src 'self' https: data:; style-src 'self' 'unsafe-inline'; " +
"script-src 'self'; connect-src 'self'; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
func securityHeaders(c *gin.Context) {
c.Header("Content-Security-Policy", csp)
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Referrer-Policy", "no-referrer")
c.Header("X-Frame-Options", "DENY")
}
// Handler returns the SPA handler: real files are served from the embedded
// build, every other path gets index.html so client-side routes deep-link.
func Handler() gin.HandlerFunc {
sub, err := fs.Sub(dist, "dist")
if err != nil {
panic(err)
}
index, err := fs.ReadFile(sub, "index.html")
if err != nil {
index = []byte(notBuiltHTML)
}
files := http.FileServer(http.FS(sub))
return func(c *gin.Context) {
securityHeaders(c)
p := strings.TrimPrefix(c.Param("filepath"), "/")
if p != "" && p != "index.html" {
if st, err := fs.Stat(sub, p); err == nil && !st.IsDir() {
if strings.HasPrefix(p, "assets/") {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
} else {
c.Header("Cache-Control", "no-cache")
}
req := c.Request.Clone(c.Request.Context())
req.URL.Path = "/" + p
files.ServeHTTP(c.Writer, req)
return
}
// a missing hashed asset must not turn into an HTML page
if strings.HasPrefix(p, "assets/") {
c.Status(http.StatusNotFound)
return
}
}
// serve the bytes directly; FileServer would redirect /index.html to ./
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
}
}
// Register mounts the dashboard at /admin/ui and redirects /admin there.
func Register(r *gin.Engine) {
h := Handler()
r.GET("/admin/ui/*filepath", h)
r.HEAD("/admin/ui/*filepath", h)
redirect := func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/ui/") }
r.GET("/admin", redirect)
r.GET("/admin/ui", redirect)
}
+51
View File
@@ -0,0 +1,51 @@
package web
import (
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestSPA(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
Register(r)
get := func(path string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
return w
}
for _, p := range []string{"/admin/ui/", "/admin/ui/addons/123", "/admin/ui/index.html"} {
w := get(p)
if w.Code != http.StatusOK || !strings.Contains(w.Header().Get("Content-Type"), "text/html") {
t.Errorf("%s: got %d %s", p, w.Code, w.Header().Get("Content-Type"))
}
if w.Header().Get("Content-Security-Policy") == "" {
t.Errorf("%s: missing CSP", p)
}
}
if w := get("/admin/ui/assets/missing.js"); w.Code != http.StatusNotFound {
t.Errorf("missing asset: got %d, want 404", w.Code)
}
if w := get("/admin"); w.Code != http.StatusFound || w.Header().Get("Location") != "/admin/ui/" {
t.Errorf("/admin: got %d -> %s", w.Code, w.Header().Get("Location"))
}
// when the UI is built, hashed assets are served with long caching
sub, _ := fs.Sub(dist, "dist")
entries, _ := fs.ReadDir(sub, "assets")
for _, e := range entries {
w := get("/admin/ui/assets/" + e.Name())
if w.Code != http.StatusOK || !strings.Contains(w.Header().Get("Cache-Control"), "immutable") {
t.Errorf("asset %s: got %d cache %q", e.Name(), w.Code, w.Header().Get("Cache-Control"))
}
break
}
}
+160
View File
@@ -0,0 +1,160 @@
import { useEffect, type ReactNode } from "react";
import { NavLink, Navigate, Outlet, useLocation, useNavigate } from "react-router";
import { useIsFetching, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Activity,
Cpu,
LayoutDashboard,
LineChart,
ListOrdered,
LogOut,
Moon,
Package,
RefreshCw,
ScrollText,
Server,
ShieldCheck,
Sun,
} from "lucide-react";
import { api, ApiError, UNAUTHORIZED_EVENT } from "../lib/api";
import { RANGES, REFRESH, useSettings } from "../lib/settings";
import { Loading, Segmented, Select, cn } from "./ui";
const NAV: { to: string; label: string; icon: ReactNode }[] = [
{ to: "/", label: "Overview", icon: <LayoutDashboard className="size-4" /> },
{ to: "/traffic", label: "Traffic", icon: <Activity className="size-4" /> },
{ to: "/queue", label: "Indexing queue", icon: <ListOrdered className="size-4" /> },
{ to: "/addons", label: "Addons", icon: <Package className="size-4" /> },
{ to: "/whitelist", label: "Whitelist", icon: <ShieldCheck className="size-4" /> },
{ to: "/services", label: "Services", icon: <Server className="size-4" /> },
{ to: "/explorer", label: "Metrics explorer", icon: <LineChart className="size-4" /> },
{ to: "/events", label: "Events", icon: <ScrollText className="size-4" /> },
{ to: "/system", label: "System", icon: <Cpu className="size-4" /> },
];
/** Redirects to /login when the session is missing or expired. */
export function RequireAuth() {
const navigate = useNavigate();
const qc = useQueryClient();
const location = useLocation();
const me = useQuery({ queryKey: ["me"], queryFn: () => api<{ method: string }>("/auth/me"), retry: false, staleTime: 60_000 });
useEffect(() => {
const onUnauthorized = () => {
qc.clear();
navigate("/login", { replace: true, state: { from: location.pathname } });
};
window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
return () => window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
}, [navigate, qc, location.pathname]);
if (me.isLoading) return <div className="p-8"><Loading /></div>;
if (me.error instanceof ApiError && (me.error.status === 401 || me.error.status === 503)) {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
return <Shell />;
}
function Shell() {
const { theme, setTheme, range, setRange, refresh, setRefresh } = useSettings();
const fetching = useIsFetching();
const navigate = useNavigate();
const qc = useQueryClient();
const logout = async () => {
await api("/auth/logout", { method: "POST" }).catch(() => undefined);
qc.clear();
navigate("/login", { replace: true });
};
return (
<div className="flex min-h-screen">
<aside className="sticky top-0 hidden h-screen w-56 shrink-0 flex-col border-r border-line bg-surface md:flex">
<div className="flex items-center gap-2.5 px-4 py-4">
<div className="flex size-7 items-center justify-center rounded-lg bg-accent/15">
<svg viewBox="0 0 32 32" className="size-4"><path d="M6 22l6-7 5 5 9-11" fill="none" stroke="var(--accent)" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</div>
<div className="leading-tight">
<div className="text-[13px] font-semibold text-ink">Reforger Crawler</div>
<div className="text-[11px] text-muted">Admin</div>
</div>
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-2">
{NAV.map((n) => (
<NavLink
key={n.to}
to={n.to}
end={n.to === "/"}
className={({ isActive }) =>
cn(
"flex items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-[13px] transition-colors",
isActive ? "bg-surface-2 font-medium text-ink" : "text-ink-2 hover:bg-surface-2/60 hover:text-ink",
)
}
>
{n.icon}
{n.label}
</NavLink>
))}
</nav>
<div className="space-y-0.5 border-t border-line p-2">
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-[13px] text-ink-2 hover:bg-surface-2/60 hover:text-ink"
>
{theme === "dark" ? <Sun className="size-4" /> : <Moon className="size-4" />}
{theme === "dark" ? "Light mode" : "Dark mode"}
</button>
<button
onClick={logout}
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-[13px] text-ink-2 hover:bg-surface-2/60 hover:text-ink"
>
<LogOut className="size-4" />
Sign out
</button>
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<div className="sticky top-0 z-20 border-b border-line bg-page/85 backdrop-blur">
<div className="flex items-center gap-2 overflow-x-auto px-4 py-2 md:hidden">
{NAV.map((n) => (
<NavLink
key={n.to}
to={n.to}
end={n.to === "/"}
className={({ isActive }) =>
cn("flex shrink-0 items-center gap-1.5 rounded-lg px-2 py-1 text-xs", isActive ? "bg-surface-2 text-ink" : "text-ink-2")
}
>
{n.icon}
{n.label}
</NavLink>
))}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 px-4 py-2 md:px-6">
<RefreshCw className={cn("size-3.5 text-muted", fetching > 0 && "animate-spin text-accent")} aria-label={fetching ? "Refreshing" : "Idle"} />
<Select value={refresh} onChange={(e) => setRefresh(Number(e.target.value))} className="h-7.5 text-xs" aria-label="Auto refresh">
{REFRESH.map((r) => (
<option key={r.key} value={r.key}>
Refresh: {r.label}
</option>
))}
</Select>
<Segmented size="sm" options={RANGES.map((r) => ({ key: r.key, label: r.label }))} value={range} onChange={setRange} />
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="inline-flex size-7 items-center justify-center rounded-lg text-ink-2 hover:bg-surface-2 md:hidden"
aria-label="Toggle theme"
>
{theme === "dark" ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
</div>
</div>
<main className="mx-auto w-full max-w-[1400px] flex-1 px-4 py-6 md:px-6">
<Outlet />
</main>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { ReactNode } from "react";
import { compact } from "../../lib/format";
import { cn } from "../ui";
export interface BarItem {
key: string;
label: ReactNode;
value: number;
hint?: string;
onClick?: () => void;
}
/** Ranked list with a thin magnitude bar under each label. */
export function BarList({ items, format = compact, empty = "Nothing yet" }: { items: BarItem[]; format?: (v: number) => string; empty?: string }) {
if (items.length === 0) return <div className="py-6 text-center text-sm text-muted">{empty}</div>;
const max = Math.max(...items.map((i) => i.value), 1);
return (
<ul className="space-y-2.5">
{items.map((it) => (
<li
key={it.key}
title={it.hint}
onClick={it.onClick}
className={cn("group", it.onClick && "cursor-pointer")}
>
<div className="flex items-baseline justify-between gap-3 text-[13px]">
<span className={cn("min-w-0 truncate text-ink-2", it.onClick && "group-hover:text-ink")}>{it.label}</span>
<span className="tnum shrink-0 text-ink">{format(it.value)}</span>
</div>
<div className="mt-1 h-1 w-full rounded-full bg-surface-2">
<div className="h-1 rounded-full bg-accent" style={{ width: `${Math.max(2, (it.value / max) * 100)}%` }} />
</div>
</li>
))}
</ul>
);
}
+36
View File
@@ -0,0 +1,36 @@
/** Tiny trend line: de-emphasised line, latest value marked in the accent. */
export function Sparkline({ data, className }: { data: (number | null)[]; className?: string }) {
const vals = data.map((v) => (v === null || Number.isNaN(v) ? null : v));
const nums = vals.filter((v): v is number => v !== null);
if (nums.length < 2) return null;
const min = Math.min(...nums, 0);
const max = Math.max(...nums);
const W = 96;
const H = 32;
const pad = 4;
const x = (i: number) => (i / (vals.length - 1)) * (W - pad * 2) + pad;
const y = (v: number) => H - pad - (max === min ? 0.5 : (v - min) / (max - min)) * (H - pad * 2);
const segments: string[] = [];
let cur = "";
vals.forEach((v, i) => {
if (v === null) {
if (cur) segments.push(cur);
cur = "";
return;
}
cur += `${cur ? "L" : "M"}${x(i).toFixed(1)},${y(v).toFixed(1)}`;
});
if (cur) segments.push(cur);
let lastIdx = vals.length - 1;
while (lastIdx >= 0 && vals[lastIdx] === null) lastIdx--;
return (
<svg viewBox={`0 0 ${W} ${H}`} className={className} preserveAspectRatio="none" aria-hidden>
{segments.map((d, i) => (
<path key={i} d={d} fill="none" stroke="var(--muted)" strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
))}
{lastIdx >= 0 && <circle cx={x(lastIdx)} cy={y(vals[lastIdx] as number)} r={2.5} fill="var(--accent)" stroke="var(--surface)" strokeWidth={1.5} />}
</svg>
);
}
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useMemo, useRef, useState } from "react";
import uPlot from "uplot";
import { useSettings } from "../../lib/settings";
import type { QueryResult } from "../../lib/types";
import { compact } from "../../lib/format";
import { cn } from "../ui";
export interface ChartSeries {
label: string;
data: (number | null)[];
/** fixed categorical slot (1-8) so an entity keeps its colour across filters */
slot?: number;
}
export interface ChartData {
x: number[];
series: ChartSeries[];
}
/** Converts a metrics query result into chart data. */
export function fromQuery(
q: QueryResult | null | undefined,
label: (labels: Record<string, string>, i: number) => string = (l) => Object.values(l).join(" · ") || q?.name || "value",
scale = 1,
slotOf?: (labels: Record<string, string>) => number | undefined,
): ChartData {
if (!q || q.series.length === 0) return { x: [], series: [] };
const x = q.series[0].points.map((p) => p[0]);
return {
x,
series: q.series.map((s, i) => ({
label: label(s.labels, i),
slot: slotOf?.(s.labels),
data: s.points.map((p) => (p[1] === null ? null : p[1] * scale)),
})),
};
}
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
function seriesColor(i: number, slot?: number): string {
const n = slot ?? i + 1;
return cssVar(`--series-${((n - 1) % 8) + 1}`);
}
function fmtTick(ts: number, span: number): string {
const d = new Date(ts * 1000);
if (span >= 2 * 86400) return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short" });
return d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
}
function fmtTime(ts: number, span: number): string {
const d = new Date(ts * 1000);
if (span > 2 * 86400) return d.toLocaleString("en-GB", { month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" });
return d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
}
function tooltipPlugin(format: (v: number) => string, labels: () => ChartSeries[]): uPlot.Plugin {
let tip: HTMLDivElement;
return {
hooks: {
init: (u) => {
tip = document.createElement("div");
tip.className =
"pointer-events-none absolute z-10 hidden min-w-36 rounded-lg border border-line bg-surface px-2.5 py-2 text-xs text-ink shadow-lg";
u.over.appendChild(tip);
u.over.addEventListener("mouseleave", () => tip.classList.add("hidden"));
},
setCursor: (u) => {
const idx = u.cursor.idx;
if (idx === null || idx === undefined || u.cursor.left === undefined || u.cursor.left < 0) {
tip.classList.add("hidden");
return;
}
const ts = u.data[0][idx];
const span = (u.data[0][u.data[0].length - 1] ?? 0) - (u.data[0][0] ?? 0);
const rows = labels()
.map((s, i) => {
const v = u.data[i + 1][idx];
const color = (u.series[i + 1].stroke as () => string)?.() ?? "";
return `<div class="flex items-center justify-between gap-4 py-0.5"><span class="flex items-center gap-1.5 text-ink-2"><span style="background:${color}" class="inline-block h-0.5 w-3 rounded"></span>${escapeHtml(s.label)}</span><span class="tnum text-ink">${v === null || v === undefined ? "—" : format(v)}</span></div>`;
})
.join("");
tip.innerHTML = `<div class="mb-1 text-muted">${fmtTime(ts, span)}${span > 2 * 86400 ? "" : " · " + new Date(ts * 1000).toLocaleDateString("en-GB", { day: "2-digit", month: "short" })}</div>${rows}`;
tip.classList.remove("hidden");
const left = u.cursor.left;
const w = tip.offsetWidth;
const overW = u.over.clientWidth;
tip.style.left = `${left + 12 + w > overW ? left - w - 12 : left + 12}px`;
tip.style.top = `4px`;
},
},
};
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c] as string);
}
export function TimeChart({
data,
height = 220,
format = compact,
area,
bars,
yMin = 0,
className,
empty = "No data for this range yet",
}: {
data: ChartData;
height?: number;
format?: (v: number) => string;
area?: boolean;
bars?: boolean;
yMin?: number | null;
className?: string;
empty?: string;
}) {
const { theme } = useSettings();
const wrapRef = useRef<HTMLDivElement>(null);
const plotRef = useRef<uPlot | null>(null);
const seriesRef = useRef<ChartSeries[]>(data.series);
seriesRef.current = data.series;
const [width, setWidth] = useState(0);
const hasData = data.x.length > 0 && data.series.some((s) => s.data.some((v) => v !== null));
const shape = `${data.series.map((s) => `${s.label}#${s.slot ?? ""}`).join("|")}`;
const aligned = useMemo<uPlot.AlignedData>(() => [data.x, ...data.series.map((s) => s.data)] as uPlot.AlignedData, [data]);
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => setWidth(Math.floor(entries[0].contentRect.width)));
ro.observe(el);
return () => ro.disconnect();
}, []);
// (re)create when the theme or the set of series changes
useEffect(() => {
const el = wrapRef.current;
if (!el || !hasData || width === 0) return;
const muted = cssVar("--muted");
const grid = cssVar("--grid");
const font = "11px system-ui, -apple-system, Segoe UI, sans-serif";
const single = data.series.length === 1;
const opts: uPlot.Options = {
width,
height,
padding: [8, 14, 0, 0],
legend: { show: false },
cursor: { points: { size: 8, width: 2 }, drag: { x: false, y: false } },
scales: { x: { time: true }, y: { range: (_u, min, max) => [yMin === null ? min : Math.min(yMin, min), max <= 0 ? 1 : max * 1.08] } },
axes: [
{
stroke: muted,
font,
grid: { show: false },
ticks: { stroke: grid, width: 1, size: 4 },
space: 70,
values: (_u, splits) => {
const span = (splits[splits.length - 1] ?? 0) - (splits[0] ?? 0);
return splits.map((ts) => fmtTick(ts, span));
},
},
{
stroke: muted,
font,
size: 48,
space: 36,
grid: { stroke: grid, width: 1 },
ticks: { show: false },
values: (_u, vals) => vals.map((v) => format(v)),
},
],
series: [
{},
...data.series.map((s, i) => {
const color = seriesColor(i, s.slot);
return {
label: s.label,
stroke: color,
width: 2,
spanGaps: false,
// uPlot shows markers only when samples are far apart, so sparse data stays visible
points: { size: 7, width: 2, stroke: cssVar("--surface"), fill: color },
fill: bars ? color : area || single ? `color-mix(in srgb, ${color} 12%, transparent)` : undefined,
paths: bars ? uPlot.paths.bars!({ size: [0.6, 24], radius: 0.15 }) : undefined,
} as uPlot.Series;
}),
],
plugins: [tooltipPlugin(format, () => seriesRef.current)],
};
plotRef.current?.destroy();
plotRef.current = new uPlot(opts, aligned, el);
return () => {
plotRef.current?.destroy();
plotRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [theme, shape, hasData, height, bars, area, width === 0]);
useEffect(() => {
if (plotRef.current && width > 0) plotRef.current.setSize({ width, height });
}, [width, height]);
useEffect(() => {
plotRef.current?.setData(aligned);
}, [aligned]);
return (
<div className={cn("min-w-0", className)}>
<div ref={wrapRef} style={{ height }} className="relative w-full">
{!hasData && <div className="flex h-full items-center justify-center text-sm text-muted">{empty}</div>}
</div>
{hasData && data.series.length > 1 && <Legend series={data.series} />}
</div>
);
}
export function Legend({ series }: { series: ChartSeries[] }) {
return (
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2">
{series.map((s, i) => (
<span key={s.label + i} className="inline-flex items-center gap-1.5">
<span className="inline-block h-0.5 w-3 rounded" style={{ background: `var(--series-${(((s.slot ?? i + 1) - 1) % 8) + 1})` }} />
{s.label}
</span>
))}
</div>
);
}
+484
View File
@@ -0,0 +1,484 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ButtonHTMLAttributes,
type InputHTMLAttributes,
type ReactNode,
type SelectHTMLAttributes,
type TextareaHTMLAttributes,
} from "react";
import { AlertTriangle, Check, CheckCircle2, ChevronLeft, ChevronRight, Copy, Info, Loader2, X, XCircle } from "lucide-react";
import { Sparkline } from "./charts/Sparkline";
export function cn(...parts: (string | false | null | undefined)[]): string {
return parts.filter(Boolean).join(" ");
}
// ---- containers -------------------------------------------------------------
export function Card({
title,
subtitle,
actions,
children,
className,
bodyClass,
}: {
title?: ReactNode;
subtitle?: ReactNode;
actions?: ReactNode;
children: ReactNode;
className?: string;
bodyClass?: string;
}) {
return (
<section className={cn("rounded-xl border border-line bg-surface", className)}>
{(title || actions) && (
<header className="flex items-start justify-between gap-3 px-4 pt-3.5">
<div className="min-w-0">
{title && <h2 className="text-[13px] font-medium text-ink">{title}</h2>}
{subtitle && <p className="mt-0.5 text-xs text-muted">{subtitle}</p>}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</header>
)}
<div className={cn("p-4", bodyClass)}>{children}</div>
</section>
);
}
export function PageHeader({ title, description, actions }: { title: string; description?: ReactNode; actions?: ReactNode }) {
return (
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight text-ink">{title}</h1>
{description && <p className="mt-1 text-sm text-ink-2">{description}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
);
}
// ---- controls ---------------------------------------------------------------
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export function Button({
variant = "secondary",
size = "md",
icon,
loading,
className,
children,
...rest
}: ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: "sm" | "md";
icon?: ReactNode;
loading?: boolean;
}) {
const styles: Record<ButtonVariant, string> = {
primary: "bg-accent text-accent-ink hover:brightness-110 border-transparent",
secondary: "bg-surface-2 text-ink border-line hover:border-ink/25",
ghost: "bg-transparent text-ink-2 border-transparent hover:bg-surface-2 hover:text-ink",
danger: "bg-transparent text-critical border-critical/40 hover:bg-critical/10",
};
return (
<button
{...rest}
disabled={rest.disabled || loading}
className={cn(
"inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-lg border font-medium transition-colors",
"disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-accent",
size === "sm" ? "h-7 px-2.5 text-xs" : "h-8.5 px-3 text-[13px]",
styles[variant],
className,
)}
>
{loading ? <Loader2 className="size-3.5 animate-spin" /> : icon}
{children}
</button>
);
}
const fieldClass =
"h-8.5 rounded-lg border border-line bg-surface px-2.5 text-[13px] text-ink placeholder:text-muted " +
"focus:border-accent focus:outline-none";
export function Input({ className, ...rest }: InputHTMLAttributes<HTMLInputElement>) {
return <input {...rest} className={cn(fieldClass, className)} />;
}
export function Select({ className, children, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select {...rest} className={cn(fieldClass, "pr-7", className)}>
{children}
</select>
);
}
export function Textarea({ className, ...rest }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea {...rest} className={cn(fieldClass, "h-auto py-2 font-mono text-xs", className)} />;
}
export function Segmented<T extends string | number>({
options,
value,
onChange,
size = "md",
}: {
options: readonly { key: T; label: ReactNode }[];
value: T;
onChange: (v: T) => void;
size?: "sm" | "md";
}) {
return (
<div className="inline-flex rounded-lg border border-line bg-surface p-0.5">
{options.map((o) => (
<button
key={String(o.key)}
onClick={() => onChange(o.key)}
className={cn(
"rounded-md px-2.5 font-medium transition-colors",
size === "sm" ? "h-6 text-xs" : "h-7 text-[13px]",
o.key === value ? "bg-surface-2 text-ink shadow-sm" : "text-muted hover:text-ink",
)}
>
{o.label}
</button>
))}
</div>
);
}
export function Checkbox({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
return (
<input
type="checkbox"
aria-label={label}
checked={checked}
onChange={(e) => onChange(e.target.checked)}
onClick={(e) => e.stopPropagation()}
className="size-3.5 cursor-pointer accent-[var(--accent)]"
/>
);
}
// ---- status -----------------------------------------------------------------
export type Tone = "neutral" | "accent" | "good" | "warning" | "serious" | "critical";
const toneDot: Record<Tone, string> = {
neutral: "bg-muted",
accent: "bg-accent",
good: "bg-good",
warning: "bg-warning",
serious: "bg-serious",
critical: "bg-critical",
};
/** A status label: always a dot plus text, never colour alone. */
export function Badge({ tone = "neutral", children, title }: { tone?: Tone; children: ReactNode; title?: string }) {
return (
<span
title={title}
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border border-line bg-surface-2 px-2 py-0.5 text-[11px] font-medium text-ink-2"
>
<span className={cn("size-1.5 rounded-full", toneDot[tone])} />
{children}
</span>
);
}
export function StatusDot({ online }: { online: boolean }) {
return (
<span className="relative inline-flex size-2">
{online && <span className="absolute inline-flex size-full animate-ping rounded-full bg-good opacity-50" />}
<span className={cn("relative inline-flex size-2 rounded-full", online ? "bg-good" : "bg-critical")} />
</span>
);
}
// ---- figures ----------------------------------------------------------------
export function StatTile({
label,
value,
sub,
trend,
hero,
onClick,
}: {
label: string;
value: ReactNode;
sub?: ReactNode;
trend?: (number | null)[];
hero?: boolean;
onClick?: () => void;
}) {
return (
<div
onClick={onClick}
className={cn(
"flex min-w-0 flex-col justify-between rounded-xl border border-line bg-surface p-4",
onClick && "cursor-pointer transition-colors hover:border-ink/25",
)}
>
<div className="text-xs text-muted">{label}</div>
<div className="mt-2 flex items-end justify-between gap-3">
<div className="min-w-0">
<div className={cn("truncate font-semibold tracking-tight text-ink", hero ? "text-[40px] leading-none" : "text-2xl")}>{value}</div>
{sub && <div className="mt-1.5 truncate text-xs text-ink-2">{sub}</div>}
</div>
{trend && trend.length > 1 && <Sparkline data={trend} className="h-8 w-24 shrink-0" />}
</div>
</div>
);
}
/** Progress meter: unfilled track is a lighter step of the fill. */
export function Meter({ value, max, tone = "accent" }: { value: number; max: number; tone?: "accent" | "good" | "warning" | "critical" }) {
const p = max > 0 ? Math.min(100, (value / max) * 100) : 0;
const color = { accent: "var(--accent)", good: "var(--good)", warning: "var(--warning)", critical: "var(--critical)" }[tone];
return (
<div className="h-1.5 w-full overflow-hidden rounded-full" style={{ background: `color-mix(in srgb, ${color} 18%, transparent)` }}>
<div className="h-full rounded-full" style={{ width: `${p}%`, background: color }} />
</div>
);
}
// ---- feedback -----------------------------------------------------------------
export function Spinner({ className }: { className?: string }) {
return <Loader2 className={cn("size-4 animate-spin text-muted", className)} />;
}
export function Loading({ label = "Loading…" }: { label?: string }) {
return (
<div className="flex items-center gap-2 py-8 text-sm text-muted">
<Spinner /> {label}
</div>
);
}
export function Empty({ children }: { children: ReactNode }) {
return <div className="py-8 text-center text-sm text-muted">{children}</div>;
}
export function ErrorBox({ error }: { error: unknown }) {
const msg = error instanceof Error ? error.message : String(error);
return (
<div className="flex items-center gap-2 rounded-lg border border-critical/30 bg-critical/10 px-3 py-2 text-sm text-ink">
<XCircle className="size-4 shrink-0 text-critical" />
{msg}
</div>
);
}
export function CopyButton({ value, className }: { value: string; className?: string }) {
const [done, setDone] = useState(false);
return (
<button
title="Copy"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard?.writeText(value).then(() => {
setDone(true);
setTimeout(() => setDone(false), 1200);
});
}}
className={cn("inline-flex size-6 items-center justify-center rounded-md text-muted hover:bg-surface-2 hover:text-ink", className)}
>
{done ? <Check className="size-3.5 text-good" /> : <Copy className="size-3.5" />}
</button>
);
}
export function Pager({ total, limit, offset, onChange }: { total: number; limit: number; offset: number; onChange: (offset: number) => void }) {
const page = Math.floor(offset / limit) + 1;
const pages = Math.max(1, Math.ceil(total / limit));
return (
<div className="flex items-center justify-between gap-3 text-xs text-muted">
<span className="tnum">
{total === 0 ? "No results" : `${(offset + 1).toLocaleString()}–${Math.min(offset + limit, total).toLocaleString()} of ${total.toLocaleString()}`}
</span>
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost" disabled={page <= 1} onClick={() => onChange(Math.max(0, offset - limit))} icon={<ChevronLeft className="size-3.5" />} />
<span className="tnum px-1">
{page} / {pages}
</span>
<Button size="sm" variant="ghost" disabled={page >= pages} onClick={() => onChange(offset + limit)} icon={<ChevronRight className="size-3.5" />} />
</div>
</div>
);
}
// ---- table --------------------------------------------------------------------
export function Table({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={cn("-mx-4 overflow-x-auto", className)}>
<table className="w-full border-collapse text-[13px]">{children}</table>
</div>
);
}
export function Th({ children, className, onClick }: { children?: ReactNode; className?: string; onClick?: () => void }) {
return (
<th
onClick={onClick}
className={cn(
"border-b border-line px-4 py-2 text-left text-[11px] font-medium uppercase tracking-wide text-muted first:pl-4 whitespace-nowrap",
onClick && "cursor-pointer select-none hover:text-ink",
className,
)}
>
{children}
</th>
);
}
export function Td({ children, className, title }: { children?: ReactNode; className?: string; title?: string }) {
return (
<td title={title} className={cn("border-b border-line/60 px-4 py-2 align-middle text-ink-2", className)}>
{children}
</td>
);
}
// ---- toasts & confirm ---------------------------------------------------------
interface ToastItem {
id: number;
tone: "good" | "critical" | "neutral";
text: string;
}
interface Feedback {
toast: (text: string, tone?: ToastItem["tone"]) => void;
confirm: (opts: { title: string; body?: ReactNode; confirmLabel?: string; danger?: boolean; requireText?: string }) => Promise<boolean>;
}
const FeedbackCtx = createContext<Feedback | null>(null);
export function useFeedback(): Feedback {
const f = useContext(FeedbackCtx);
if (!f) throw new Error("FeedbackProvider missing");
return f;
}
interface ConfirmState {
title: string;
body?: ReactNode;
confirmLabel?: string;
danger?: boolean;
requireText?: string;
resolve: (ok: boolean) => void;
}
export function FeedbackProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null);
const [typed, setTyped] = useState("");
const dialogRef = useRef<HTMLDialogElement>(null);
const nextId = useRef(1);
const toast = useCallback((text: string, tone: ToastItem["tone"] = "good") => {
const id = nextId.current++;
setToasts((t) => [...t, { id, tone, text }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), tone === "critical" ? 6000 : 3500);
}, []);
const confirm = useCallback<Feedback["confirm"]>(
(opts) =>
new Promise<boolean>((resolve) => {
setTyped("");
setConfirmState({ ...opts, resolve });
}),
[],
);
useEffect(() => {
const d = dialogRef.current;
if (!d) return;
if (confirmState && !d.open) d.showModal();
if (!confirmState && d.open) d.close();
}, [confirmState]);
const close = (ok: boolean) => {
confirmState?.resolve(ok);
setConfirmState(null);
};
const blocked = !!confirmState?.requireText && typed !== confirmState.requireText;
return (
<FeedbackCtx.Provider value={{ toast, confirm }}>
{children}
<dialog
ref={dialogRef}
onCancel={(e) => {
e.preventDefault();
close(false);
}}
className="m-auto w-[min(440px,calc(100vw-32px))] rounded-xl border border-line bg-surface p-0 text-ink shadow-2xl"
>
{confirmState && (
<div className="p-5">
<div className="flex items-start gap-3">
{confirmState.danger ? (
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-critical" />
) : (
<Info className="mt-0.5 size-5 shrink-0 text-accent" />
)}
<div className="min-w-0">
<h3 className="font-medium">{confirmState.title}</h3>
{confirmState.body && <div className="mt-1.5 text-sm text-ink-2">{confirmState.body}</div>}
</div>
</div>
{confirmState.requireText && (
<div className="mt-4">
<label className="text-xs text-muted">
Type <span className="font-mono text-ink">{confirmState.requireText}</span> to confirm
</label>
<Input autoFocus className="mt-1 w-full font-mono" value={typed} onChange={(e) => setTyped(e.target.value)} />
</div>
)}
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => close(false)}>
Cancel
</Button>
<Button variant={confirmState.danger ? "danger" : "primary"} disabled={blocked} onClick={() => close(true)} autoFocus={!confirmState.requireText}>
{confirmState.confirmLabel ?? "Confirm"}
</Button>
</div>
</div>
)}
</dialog>
<div className="pointer-events-none fixed right-4 bottom-4 z-50 flex flex-col gap-2">
{toasts.map((t) => (
<div
key={t.id}
className="pointer-events-auto flex max-w-sm items-center gap-2 rounded-lg border border-line bg-surface px-3 py-2 text-sm text-ink shadow-lg"
>
{t.tone === "good" && <CheckCircle2 className="size-4 shrink-0 text-good" />}
{t.tone === "critical" && <XCircle className="size-4 shrink-0 text-critical" />}
{t.tone === "neutral" && <Info className="size-4 shrink-0 text-accent" />}
<span className="min-w-0 break-words">{t.text}</span>
<button className="ml-1 text-muted hover:text-ink" onClick={() => setToasts((x) => x.filter((y) => y.id !== t.id))}>
<X className="size-3.5" />
</button>
</div>
))}
</div>
</FeedbackCtx.Provider>
);
}
export function LevelBadge({ level }: { level: string }) {
const tone: Tone = level === "error" ? "critical" : level === "warn" ? "warning" : level === "debug" ? "neutral" : "accent";
return <Badge tone={tone}>{level}</Badge>;
}
+176
View File
@@ -0,0 +1,176 @@
@import "tailwindcss";
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
/* Colour tokens. Chart colours follow the validated reference palette
(categorical slots in fixed order, separate light/dark steps). */
:root {
color-scheme: light;
--page: #f9f9f7;
--surface: #fcfcfb;
--surface-2: #f2f2ef;
--ink: #0b0b0b;
--ink-2: #52514e;
--muted: #898781;
--line: rgba(11, 11, 11, 0.1);
--grid: #e1e0d9;
--axis: #c3c2b7;
--accent: #2a78d6;
--accent-ink: #ffffff;
--good: #0ca30c;
--good-ink: #006300;
--warning: #fab219;
--serious: #ec835a;
--critical: #d03b3b;
--series-1: #2a78d6;
--series-2: #eb6834;
--series-3: #1baf7a;
--series-4: #eda100;
--series-5: #e87ba4;
--series-6: #008300;
--series-7: #4a3aa7;
--series-8: #e34948;
}
:root[data-theme="dark"] {
color-scheme: dark;
--page: #0d0d0d;
--surface: #1a1a19;
--surface-2: #232321;
--ink: #ffffff;
--ink-2: #c3c2b7;
--muted: #898781;
--line: rgba(255, 255, 255, 0.1);
--grid: #2c2c2a;
--axis: #383835;
--accent: #3987e5;
--accent-ink: #ffffff;
--good-ink: #0ca30c;
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--series-4: #c98500;
--series-5: #d55181;
--series-6: #008300;
--series-7: #9085e9;
--series-8: #e66767;
}
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-surface-2: var(--surface-2);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-line: var(--line);
--color-grid: var(--grid);
--color-accent: var(--accent);
--color-accent-ink: var(--accent-ink);
--color-good: var(--good);
--color-good-ink: var(--good-ink);
--color-warning: var(--warning);
--color-serious: var(--serious);
--color-critical: var(--critical);
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace;
}
html,
body {
background: var(--page);
color: var(--ink);
}
body {
font-family: var(--font-sans);
font-size: 14px;
-webkit-font-smoothing: antialiased;
}
.tnum {
font-variant-numeric: tabular-nums;
}
/* uPlot base styles (trimmed from uplot.min.css) */
.uplot,
.uplot *,
.uplot *::before,
.uplot *::after {
box-sizing: border-box;
}
.uplot {
font-family: var(--font-sans);
line-height: 1.5;
width: min-content;
}
.u-wrap {
position: relative;
user-select: none;
}
.u-over,
.u-under {
position: absolute;
}
.u-under {
overflow: hidden;
}
.uplot canvas {
display: block;
position: relative;
width: 100%;
height: 100%;
}
.u-axis {
position: absolute;
}
.u-select {
background: color-mix(in srgb, var(--accent) 12%, transparent);
position: absolute;
pointer-events: none;
}
.u-cursor-x,
.u-cursor-y {
position: absolute;
left: 0;
top: 0;
pointer-events: none;
will-change: transform;
}
.u-cursor-x {
height: 100%;
border-right: 1px solid var(--axis);
}
.u-cursor-y {
width: 100%;
border-bottom: 0;
}
.u-cursor-pt {
position: absolute;
top: 0;
left: 0;
border-radius: 50%;
border: 2px solid var(--surface);
pointer-events: none;
will-change: transform;
background-clip: padding-box !important;
}
.u-hz .u-cursor-y {
display: none;
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--grid);
border-radius: 10px;
border: 2px solid var(--page);
}
+53
View File
@@ -0,0 +1,53 @@
// Thin fetch wrapper for the admin JSON API. Auth is the HttpOnly session
// cookie; every request carries the CSRF header the server requires.
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
export const UNAUTHORIZED_EVENT = "rc:unauthorized";
type Method = "GET" | "POST" | "DELETE";
export async function api<T>(path: string, opts: { method?: Method; body?: unknown } = {}): Promise<T> {
const headers: Record<string, string> = { "X-Admin-CSRF": "1", Accept: "application/json" };
if (opts.body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(`/admin/api${path}`, {
method: opts.method ?? "GET",
credentials: "same-origin",
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 204) return undefined as T;
let data: unknown = null;
const text = await res.text();
if (text) {
try {
data = JSON.parse(text);
} catch {
data = { error: text };
}
}
if (!res.ok) {
if (res.status === 401 && !path.startsWith("/auth/")) {
window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
}
const msg = (data as { error?: string } | null)?.error ?? `${res.status} ${res.statusText}`;
throw new ApiError(res.status, msg);
}
return data as T;
}
export function qs(params: Record<string, string | number | boolean | undefined | null>): string {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === "") continue;
p.set(k, String(v));
}
const s = p.toString();
return s ? `?${s}` : "";
}
+94
View File
@@ -0,0 +1,94 @@
const compactFmt = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 });
const intFmt = new Intl.NumberFormat("en");
export function compact(n: number | null | undefined): string {
if (n === null || n === undefined || Number.isNaN(n)) return "—";
if (Math.abs(n) < 1000) return intFmt.format(Math.round(n * 10) / 10);
return compactFmt.format(n);
}
export function int(n: number | null | undefined): string {
if (n === null || n === undefined || Number.isNaN(n)) return "—";
return intFmt.format(Math.round(n));
}
export function num(n: number | null | undefined, digits = 1): string {
if (n === null || n === undefined || Number.isNaN(n)) return "—";
return n.toLocaleString("en", { maximumFractionDigits: digits });
}
export function pct(part: number, total: number, digits = 1): string {
if (!total) return "—";
return `${((part / total) * 100).toFixed(digits)}%`;
}
export function bytes(n: number | null | undefined): string {
if (n === null || n === undefined || Number.isNaN(n)) return "—";
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let v = n;
let i = 0;
while (Math.abs(v) >= 1000 && i < units.length - 1) {
v /= 1000;
i++;
}
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
export function ms(n: number | null | undefined): string {
if (n === null || n === undefined || Number.isNaN(n)) return "—";
if (n < 1) return `${(n * 1000).toFixed(0)} µs`;
if (n < 1000) return `${n.toFixed(n < 10 ? 1 : 0)} ms`;
return `${(n / 1000).toFixed(2)} s`;
}
export function duration(sec: number | null | undefined): string {
if (sec === null || sec === undefined || Number.isNaN(sec)) return "—";
sec = Math.max(0, Math.round(sec));
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (d) return `${d}d ${h}h`;
if (h) return `${h}h ${m}m`;
if (m) return `${m}m ${s}s`;
return `${s}s`;
}
/** Parses an ISO string or unix seconds/ms; returns null for zero/empty times. */
export function toDate(v: string | number | null | undefined): Date | null {
if (v === null || v === undefined || v === "" || v === 0) return null;
const d = typeof v === "number" ? new Date(v > 1e12 ? v : v * 1000) : new Date(v);
if (Number.isNaN(d.getTime()) || d.getFullYear() < 2000) return null;
return d;
}
export function relative(v: string | number | null | undefined): string {
const d = toDate(v);
if (!d) return "—";
const diff = (Date.now() - d.getTime()) / 1000;
if (Math.abs(diff) < 45) return "just now";
const future = diff < 0;
const a = Math.abs(diff);
let out: string;
if (a < 3600) out = `${Math.round(a / 60)}m`;
else if (a < 86400) out = `${Math.round(a / 3600)}h`;
else if (a < 86400 * 60) out = `${Math.round(a / 86400)}d`;
else out = `${Math.round(a / (86400 * 30))}mo`;
return future ? `in ${out}` : `${out} ago`;
}
export function dateTime(v: string | number | null | undefined): string {
const d = toDate(v);
if (!d) return "—";
return d.toLocaleString("en-GB", { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
export function shortHash(h: string, n = 10): string {
return h.length > n * 2 ? `${h.slice(0, n)}…${h.slice(-4)}` : h;
}
/** Seconds for chart axes: keeps one decimal below a minute so ticks stay distinct. */
export function seconds(v: number): string {
if (v < 60) return `${num(v, v < 10 ? 1 : 0)}s`;
return duration(v);
}
+59
View File
@@ -0,0 +1,59 @@
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { api, qs } from "./api";
import { rangeSeconds, useRefetch, useSettings } from "./settings";
import type { QueryResult } from "./types";
export interface MetricParams {
service?: string;
name: string;
agg?: string;
groupBy?: string[];
match?: Record<string, string>;
step?: number;
/** override the global range (seconds) */
rangeSec?: number;
}
/** Fetches a metric time series for the global time range. */
export function useMetric(p: MetricParams, enabled = true) {
const { range } = useSettings();
const refetchInterval = useRefetch();
const span = p.rangeSec ?? rangeSeconds(range);
return useQuery({
queryKey: ["metric", p, span],
enabled,
refetchInterval,
placeholderData: keepPreviousData,
queryFn: () => {
const to = Math.floor(Date.now() / 1000);
return api<QueryResult>(
"/metrics/query" +
qs({
service: p.service ?? "main",
name: p.name,
agg: p.agg,
groupBy: p.groupBy?.join(","),
match: p.match ? JSON.stringify(p.match) : undefined,
step: p.step,
from: to - span,
to,
}),
);
},
});
}
/** Default step for a range so charts stay readable. */
export function stepFor(seconds: number): number {
if (seconds <= 3 * 3600) return 60;
if (seconds <= 12 * 3600) return 300;
if (seconds <= 48 * 3600) return 900;
if (seconds <= 8 * 86400) return 3600;
return 6 * 3600;
}
export function useRangeStep() {
const { range } = useSettings();
const seconds = rangeSeconds(range);
return { seconds, step: stepFor(seconds) };
}
+102
View File
@@ -0,0 +1,102 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
export type Theme = "dark" | "light";
export const RANGES = [
{ key: "1h", label: "1h", seconds: 3600 },
{ key: "6h", label: "6h", seconds: 6 * 3600 },
{ key: "24h", label: "24h", seconds: 86400 },
{ key: "7d", label: "7d", seconds: 7 * 86400 },
{ key: "30d", label: "30d", seconds: 30 * 86400 },
] as const;
export type RangeKey = (typeof RANGES)[number]["key"];
export const REFRESH = [
{ key: 0, label: "Off" },
{ key: 10, label: "10s" },
{ key: 30, label: "30s" },
{ key: 60, label: "1m" },
] as const;
interface Settings {
theme: Theme;
setTheme: (t: Theme) => void;
range: RangeKey;
setRange: (r: RangeKey) => void;
refresh: number;
setRefresh: (s: number) => void;
}
const Ctx = createContext<Settings | null>(null);
function load<T>(key: string, fallback: T, valid: (v: unknown) => boolean): T {
try {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
const v = JSON.parse(raw);
return valid(v) ? (v as T) : fallback;
} catch {
return fallback;
}
}
function save(key: string, v: unknown) {
try {
localStorage.setItem(key, JSON.stringify(v));
} catch {
/* storage unavailable */
}
}
export function SettingsProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() =>
document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark",
);
const [range, setRangeState] = useState<RangeKey>(() =>
load("rc-range", "24h", (v) => RANGES.some((r) => r.key === v)),
);
const [refresh, setRefreshState] = useState<number>(() =>
load("rc-refresh", 30, (v) => REFRESH.some((r) => r.key === v)),
);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
try {
localStorage.setItem("rc-theme", theme);
} catch {
/* ignore */
}
}, [theme]);
const setTheme = useCallback((t: Theme) => setThemeState(t), []);
const setRange = useCallback((r: RangeKey) => {
setRangeState(r);
save("rc-range", r);
}, []);
const setRefresh = useCallback((s: number) => {
setRefreshState(s);
save("rc-refresh", s);
}, []);
const value = useMemo(
() => ({ theme, setTheme, range, setRange, refresh, setRefresh }),
[theme, setTheme, range, setRange, refresh, setRefresh],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useSettings(): Settings {
const s = useContext(Ctx);
if (!s) throw new Error("SettingsProvider missing");
return s;
}
export function rangeSeconds(key: RangeKey): number {
return RANGES.find((r) => r.key === key)?.seconds ?? 86400;
}
/** Refetch interval in ms for TanStack Query (false when auto-refresh is off). */
export function useRefetch(): number | false {
const { refresh } = useSettings();
return refresh > 0 ? refresh * 1000 : false;
}
+289
View File
@@ -0,0 +1,289 @@
// Response shapes of the admin API (see admin/*.go).
export type Pt = [number, number | null];
export interface SeriesResult {
labels: Record<string, string>;
points: Pt[];
}
export interface QueryResult {
name: string;
kind: string;
agg: string;
resolution: number;
step: number;
from: number;
to: number;
series: SeriesResult[];
truncated: boolean;
}
export interface Total {
labels: Record<string, string>;
count: number;
sum: number;
min: number | null;
max: number | null;
avg: number | null;
last: number | null;
rate: number | null;
p50: number | null;
p95: number | null;
p99: number | null;
}
export interface MiniAddon {
id: string;
name: string;
author: string;
subscriberCount: number;
currentVersionSize: number;
lastIndexedAt?: string | null;
createdAt: string;
}
export interface TableInfo {
name: string;
rows: number;
deadRows: number;
sizeBytes: number;
}
export interface HeavyStats {
asOf: number;
filesLive: number;
filesSoftDeleted: number;
filesApprox: boolean;
distinctHashes: number;
distinctAsOf: number;
dbDriver: string;
dbSizeBytes: number;
tables: TableInfo[];
duplicatesAsOf: number;
error?: string;
}
export interface Overview {
asOf: number;
data: {
addons: {
total: number;
indexed: number;
queued: number;
indexing: number;
priority: number;
recheck: number;
blocked: number;
unlisted: number;
private: number;
totalSizeBytes: number;
added24h: number;
added7d: number;
indexed24h: number;
softDeleted: number;
};
types: { type: string; count: number }[];
whitelistCount: number;
dependencies: { edges: number; addonsWithDependencies: number; addonsDependedOn: number };
traffic: {
requests24h?: number;
websiteRequests24h?: number;
workerRequests24h?: number;
websiteP95Ms?: number | null;
websiteP50Ms?: number | null;
errors5xx24h: number;
visitorsToday: number;
botsToday: number;
};
activity: Record<string, number>;
services: { online: number; total: number };
topSubscribed: MiniAddon[];
largest: MiniAddon[];
recentlyAdded: MiniAddon[];
recentlyIndexed: MiniAddon[];
heavy: HeavyStats | null;
};
}
export interface AddonRow {
id: string;
name: string;
type: string;
author: string;
preview: string;
subscriberCount: number;
currentVersionNumber: string;
currentVersionSize: number;
unlisted: boolean;
private: boolean;
blocked: boolean;
toBeIndexed: boolean;
isBeingIndexed: boolean;
priorityIndexing: boolean;
recheck: boolean;
indexStartTime: string | null;
lastIndexedAt: string | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface Paged<T> {
items: T[];
total: number;
limit: number;
offset: number;
}
export interface RelatedAddon {
id: string;
name: string;
type: string;
author: string;
subscriberCount: number;
currentVersionNumber: string;
}
export interface AddonDetail {
addon: AddonRow;
summary: string;
currentVersionId: number;
fileCount: number;
deletedFileRows: number;
dependencies: { total: number; items: RelatedAddon[] | null; missing: { id: string; name: string; version: string }[] | null };
dependents: { total: number; items: RelatedAddon[] | null };
duplicates: { addonId: string; name: string; sharedFiles: number }[];
}
export interface FileRow {
id: number;
path: string;
hash: string;
version: string;
whitelisted: boolean;
otherAddons: number;
}
export interface QueueRow {
id: string;
name: string;
currentVersionSize: number;
subscriberCount: number;
priorityIndexing: boolean;
blocked: boolean;
indexStartTime: string | null;
updatedAt: string;
elapsedSec: number;
stuck: boolean;
}
export interface QueueData {
asOf: number;
counts: { total: number; queued: number; priority: number; indexing: number };
blockedQueued: number;
timeoutMinutes: number;
indexing: QueueRow[];
priority: QueueRow[];
next: QueueRow[];
throughput: {
perHour1h: number;
perHour6h: number;
indexed24h: number;
etaHours: number | null;
duration24h: Total | null;
};
}
export interface WhitelistRow {
id: number;
hash: string;
note: string;
createdAt: string;
files: number;
addons: number;
samplePath: string;
}
export interface DupHash {
hash: string;
addonCount: number;
fileCount: number;
samplePath: string;
whitelisted: boolean;
}
export interface ServiceInstance {
id: number;
service: string;
instance: string;
version: string;
status: string;
statusMessage: string;
startedAt: number;
lastSeen: number;
heartbeatIntervalSec: number;
remoteAddr: string;
online: boolean;
uptimeSec: number;
metaData?: Record<string, unknown>;
}
export interface ServiceEvent {
id: number;
service: string;
instance: string;
level: "debug" | "info" | "warn" | "error";
message: string;
ts: number;
fieldsData?: Record<string, unknown>;
}
export interface CatalogEntry {
service: string;
name: string;
kind: string;
help: string;
labels: Record<string, string[]>;
instances: string[];
series: number;
lastSeen: number;
}
export interface RouteStat {
method: string;
route: string;
group: string;
count: number;
errors: number;
clientErrors: number;
p50: number | null;
p95: number | null;
p99: number | null;
max: number | null;
avg: number | null;
}
export interface TrafficData {
asOf: number;
from: number;
to: number;
step: number;
requestsByGroup: QueryResult;
latency: { p50: QueryResult; p95: QueryResult; p99: QueryResult };
errors5xx: QueryResult | null;
errors4xx: QueryResult | null;
statusTotals: Total[] | null;
clientTotals: Total[] | null;
groupTotals: Total[] | null;
website: Total | null;
routes: RouteStat[];
visitorsDaily: QueryResult | null;
visitorsToday: { human: number; bot: number };
}
export interface TopEntry {
item: string;
count: number;
name?: string;
}
+67
View File
@@ -0,0 +1,67 @@
import { StrictMode, lazy, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import "./index.css";
import { SettingsProvider } from "./lib/settings";
import { FeedbackProvider, Loading } from "./components/ui";
import { RequireAuth } from "./components/Shell";
import { ApiError } from "./lib/api";
import Login from "./pages/Login";
import Overview from "./pages/Overview";
const Traffic = lazy(() => import("./pages/Traffic"));
const Queue = lazy(() => import("./pages/Queue"));
const Addons = lazy(() => import("./pages/Addons"));
const Whitelist = lazy(() => import("./pages/Whitelist"));
const Services = lazy(() => import("./pages/Services"));
const Explorer = lazy(() => import("./pages/Explorer"));
const Events = lazy(() => import("./pages/Events"));
const System = lazy(() => import("./pages/System"));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 10_000,
refetchOnWindowFocus: false,
retry: (count, err) => !(err instanceof ApiError && err.status < 500) && count < 2,
},
},
});
const page = (el: React.ReactNode) => <Suspense fallback={<Loading />}>{el}</Suspense>;
const router = createBrowserRouter(
[
{ path: "/login", element: <Login /> },
{
element: <RequireAuth />,
children: [
{ index: true, element: <Overview /> },
{ path: "traffic", element: page(<Traffic />) },
{ path: "queue", element: page(<Queue />) },
{ path: "addons", element: page(<Addons />) },
{ path: "addons/:id", element: page(<Addons />) },
{ path: "whitelist", element: page(<Whitelist />) },
{ path: "services", element: page(<Services />) },
{ path: "explorer", element: page(<Explorer />) },
{ path: "events", element: page(<Events />) },
{ path: "system", element: page(<System />) },
{ path: "*", element: <Overview /> },
],
},
],
{ basename: "/admin/ui" },
);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<SettingsProvider>
<QueryClientProvider client={queryClient}>
<FeedbackProvider>
<RouterProvider router={router} />
</FeedbackProvider>
</QueryClientProvider>
</SettingsProvider>
</StrictMode>,
);
+606
View File
@@ -0,0 +1,606 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { useNavigate, useParams, useSearchParams } from "react-router";
import { ExternalLink, RefreshCw, RotateCcw, Search, ShieldCheck, Star, StarOff, Trash2, Undo2, X } from "lucide-react";
import { api, qs } from "../lib/api";
import { useRefetch } from "../lib/settings";
import type { AddonDetail, AddonRow, FileRow, Overview, Paged } from "../lib/types";
import { bytes, compact, dateTime, int, relative, shortHash } from "../lib/format";
import {
Badge,
Button,
Card,
Checkbox,
CopyButton,
Empty,
ErrorBox,
Input,
Loading,
PageHeader,
Pager,
Segmented,
Select,
Table,
Td,
Th,
useFeedback,
type Tone,
} from "../components/ui";
const STATUSES = [
["all", "All"],
["indexed", "Indexed"],
["queued", "Queued"],
["indexing", "Indexing"],
["priority", "Priority"],
["recheck", "Recheck"],
["blocked", "Blocked"],
["unlisted", "Unlisted"],
["private", "Private"],
["deleted", "Deleted"],
] as const;
const SORTS = [
["subscribers", "Subscribers"],
["size", "Size"],
["name", "Name"],
["created", "Created"],
["updated", "Updated"],
["indexed", "Last indexed"],
["deleted", "Deleted at"],
] as const;
export function addonBadges(a: AddonRow): { tone: Tone; label: string }[] {
const out: { tone: Tone; label: string }[] = [];
if (a.deletedAt) out.push({ tone: "serious", label: "deleted" });
if (a.blocked) out.push({ tone: "critical", label: "blocked" });
if (a.isBeingIndexed) out.push({ tone: "good", label: "indexing" });
else if (a.toBeIndexed) out.push({ tone: "warning", label: "queued" });
if (a.priorityIndexing) out.push({ tone: "accent", label: "priority" });
if (a.unlisted) out.push({ tone: "neutral", label: "unlisted" });
if (a.private) out.push({ tone: "neutral", label: "private" });
return out;
}
const workshopUrl = (id: string) => `https://reforger.armaplatform.com/workshop/${id}`;
export default function Addons() {
const { id } = useParams();
const navigate = useNavigate();
const [sp, setSp] = useSearchParams();
const refetchInterval = useRefetch();
const qc = useQueryClient();
const { toast, confirm } = useFeedback();
const [selected, setSelected] = useState<Set<string>>(new Set());
const status = sp.get("status") ?? "all";
const search = sp.get("q") ?? "";
const type = sp.get("type") ?? "";
const sort = sp.get("sort") ?? (status === "deleted" ? "deleted" : "subscribers");
const order = sp.get("order") ?? "desc";
const offset = Number(sp.get("offset") ?? 0);
const limit = 50;
const [draft, setDraft] = useState(search);
useEffect(() => setDraft(search), [search]);
const set = (patch: Record<string, string | null>) => {
const next = new URLSearchParams(sp);
for (const [k, v] of Object.entries(patch)) {
if (v === null || v === "") next.delete(k);
else next.set(k, v);
}
if (!("offset" in patch)) next.delete("offset");
setSp(next, { replace: true });
setSelected(new Set());
};
const list = useQuery({
queryKey: ["addons", status, search, type, sort, order, offset],
queryFn: () => api<Paged<AddonRow>>("/addons" + qs({ status, q: search, type, sort, order, limit, offset })),
placeholderData: keepPreviousData,
refetchInterval,
});
const overview = useQuery({ queryKey: ["overview"], queryFn: () => api<Overview>("/overview"), staleTime: 60_000 });
const bulk = useMutation({
mutationFn: (body: { action: string; value?: boolean }) =>
api<{ updated: number }>("/bulk/addons", { method: "POST", body: { ids: [...selected], ...body } }),
onSuccess: (r, v) => {
toast(`${v.action}: ${r.updated} addon${r.updated === 1 ? "" : "s"} updated`);
setSelected(new Set());
qc.invalidateQueries({ queryKey: ["addons"] });
qc.invalidateQueries({ queryKey: ["overview"] });
},
onError: (e) => toast((e as Error).message, "critical"),
});
const items = list.data?.items ?? [];
const allSelected = items.length > 0 && items.every((a) => selected.has(a.id));
const query = sp.toString() ? `?${sp.toString()}` : "";
return (
<div className="space-y-4">
<PageHeader title="Addons" description="Search, inspect and manage every addon the crawler knows about" />
<Card bodyClass="p-0">
<div className="flex flex-wrap items-center gap-2 border-b border-line p-3">
<form
className="relative min-w-56 flex-1"
onSubmit={(e) => {
e.preventDefault();
set({ q: draft.trim() });
}}
>
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted" />
<Input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Search name, author or exact ID…" className="w-full pl-8" />
</form>
<Select value={status} onChange={(e) => set({ status: e.target.value, sort: null })}>
{STATUSES.map(([k, l]) => (
<option key={k} value={k}>
{l}
</option>
))}
</Select>
<Select value={type} onChange={(e) => set({ type: e.target.value })}>
<option value="">All types</option>
{(overview.data?.data.types ?? []).map((t) => (
<option key={t.type} value={t.type}>
{t.type || "(none)"}
</option>
))}
</Select>
<Select value={sort} onChange={(e) => set({ sort: e.target.value })}>
{SORTS.map(([k, l]) => (
<option key={k} value={k}>
Sort: {l}
</option>
))}
</Select>
<Segmented size="sm" options={[{ key: "desc", label: "Desc" }, { key: "asc", label: "Asc" }]} value={order} onChange={(v) => set({ order: v })} />
</div>
{selected.size > 0 && (
<div className="flex flex-wrap items-center gap-2 border-b border-line bg-surface-2/50 px-3 py-2 text-xs">
<span className="text-ink">{selected.size} selected</span>
<Button size="sm" icon={<Star className="size-3.5" />} onClick={() => bulk.mutate({ action: "priority", value: true })}>Prioritize</Button>
<Button size="sm" icon={<StarOff className="size-3.5" />} onClick={() => bulk.mutate({ action: "priority", value: false })}>Unprioritize</Button>
<Button size="sm" icon={<RefreshCw className="size-3.5" />} onClick={() => bulk.mutate({ action: "reindex", value: false })}>Re-index</Button>
{status === "deleted" ? (
<Button size="sm" icon={<Undo2 className="size-3.5" />} onClick={() => bulk.mutate({ action: "restore" })}>Restore</Button>
) : (
<Button
size="sm"
variant="danger"
icon={<Trash2 className="size-3.5" />}
onClick={async () => {
if (await confirm({ title: `Delete ${selected.size} addons?`, body: "They are soft-deleted with their files and can be restored later.", danger: true, confirmLabel: "Delete" }))
bulk.mutate({ action: "delete" });
}}
>
Delete
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>Clear</Button>
</div>
)}
{list.error ? (
<div className="p-4"><ErrorBox error={list.error} /></div>
) : list.isLoading ? (
<div className="px-4"><Loading /></div>
) : items.length === 0 ? (
<Empty>No addons match these filters</Empty>
) : (
<div className="px-4">
<Table>
<thead>
<tr>
<Th className="w-8">
<Checkbox
label="Select all"
checked={allSelected}
onChange={(v) => setSelected(v ? new Set(items.map((a) => a.id)) : new Set())}
/>
</Th>
<Th>Addon</Th>
<Th>State</Th>
<Th className="text-right">Subscribers</Th>
<Th className="text-right">Size</Th>
<Th>Version</Th>
<Th>{status === "deleted" ? "Deleted" : "Last indexed"}</Th>
</tr>
</thead>
<tbody>
{items.map((a) => (
<tr key={a.id} onClick={() => navigate(`/addons/${a.id}${query}`)} className="cursor-pointer hover:bg-surface-2/40">
<Td>
<Checkbox
label={`Select ${a.name}`}
checked={selected.has(a.id)}
onChange={(v) => {
const next = new Set(selected);
if (v) next.add(a.id);
else next.delete(a.id);
setSelected(next);
}}
/>
</Td>
<Td className="max-w-[340px]">
<div className="truncate text-ink" title={a.name}>{a.name || "(unnamed)"}</div>
<div className="truncate text-xs text-muted">
<span className="font-mono">{a.id}</span> · {a.author || "unknown"} · {a.type}
</div>
</Td>
<Td>
<div className="flex flex-wrap gap-1">
{addonBadges(a).map((b) => (
<Badge key={b.label} tone={b.tone}>{b.label}</Badge>
))}
</div>
</Td>
<Td className="tnum text-right">{compact(a.subscriberCount)}</Td>
<Td className="tnum text-right">{bytes(a.currentVersionSize)}</Td>
<Td className="font-mono text-xs">{a.currentVersionNumber}</Td>
<Td className="whitespace-nowrap">{relative(status === "deleted" ? a.deletedAt : a.lastIndexedAt)}</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={(o) => set({ offset: String(o) })} />
</div>
)}
</Card>
{id && <AddonDrawer id={id} onClose={() => navigate(`/addons${query}`)} />}
</div>
);
}
function AddonDrawer({ id, onClose }: { id: string; onClose: () => void }) {
const qc = useQueryClient();
const navigate = useNavigate();
const { toast, confirm } = useFeedback();
const [tab, setTab] = useState<"overview" | "files">("overview");
const detail = useQuery({ queryKey: ["addon", id], queryFn: () => api<AddonDetail>(`/addons/${encodeURIComponent(id)}`) });
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
useEffect(() => setTab("overview"), [id]);
const refresh = () => {
qc.invalidateQueries({ queryKey: ["addon", id] });
qc.invalidateQueries({ queryKey: ["addons"] });
qc.invalidateQueries({ queryKey: ["overview"] });
qc.invalidateQueries({ queryKey: ["queue"] });
};
const act = useMutation({
mutationFn: ({ action, value, method }: { action: string; value?: boolean; method?: "POST" | "DELETE" }) =>
method === "DELETE"
? api(`/addons/${encodeURIComponent(id)}${action ? `/${action}` : ""}${action === "purge" ? `?confirm=${encodeURIComponent(id)}` : ""}`, { method: "DELETE" })
: api(`/addons/${encodeURIComponent(id)}/${action}`, { method: "POST", body: { value: value ?? true } }),
onSuccess: (_r, v) => {
toast(v.action === "purge" ? "Addon purged" : "Saved");
if (v.action === "purge") onClose();
refresh();
},
onError: (e) => toast((e as Error).message, "critical"),
});
const a = detail.data?.addon;
return (
<div className="fixed inset-0 z-40 flex justify-end">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<aside className="relative flex h-full w-[min(760px,100vw)] flex-col border-l border-line bg-page shadow-2xl">
<header className="flex items-start justify-between gap-3 border-b border-line bg-surface px-5 py-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-lg font-semibold text-ink">{a?.name || id}</h2>
<a href={workshopUrl(id)} target="_blank" rel="noreferrer" className="text-muted hover:text-accent" title="Open on the workshop">
<ExternalLink className="size-4" />
</a>
</div>
<div className="mt-0.5 flex items-center gap-1 font-mono text-xs text-muted">
{id} <CopyButton value={id} />
</div>
{a && (
<div className="mt-2 flex flex-wrap gap-1">
{addonBadges(a).map((b) => <Badge key={b.label} tone={b.tone}>{b.label}</Badge>)}
{a.recheck && <Badge tone="warning">recheck</Badge>}
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={onClose} icon={<X className="size-4" />} aria-label="Close" />
</header>
{detail.isLoading ? (
<div className="px-5"><Loading /></div>
) : detail.error ? (
<div className="p-5"><ErrorBox error={detail.error} /></div>
) : (
a && (
<>
<div className="flex flex-wrap gap-2 border-b border-line bg-surface px-5 py-3">
{a.deletedAt ? (
<>
<Button size="sm" variant="primary" icon={<Undo2 className="size-3.5" />} onClick={() => act.mutate({ action: "restore" })}>
Restore
</Button>
<Button
size="sm"
variant="danger"
icon={<Trash2 className="size-3.5" />}
onClick={async () => {
if (
await confirm({
title: "Permanently purge this addon?",
body: "Removes the addon row, all its file rows and dependency edges. This cannot be undone.",
danger: true,
confirmLabel: "Purge",
requireText: id,
})
)
act.mutate({ action: "purge", method: "DELETE" });
}}
>
Purge permanently
</Button>
</>
) : (
<>
<Button
size="sm"
icon={a.priorityIndexing ? <StarOff className="size-3.5" /> : <Star className="size-3.5" />}
onClick={() => act.mutate({ action: "priority", value: !a.priorityIndexing })}
>
{a.priorityIndexing ? "Remove priority" : "Prioritize"}
</Button>
<Button size="sm" icon={<RefreshCw className="size-3.5" />} onClick={() => act.mutate({ action: "reindex", value: false })}>
Re-index
</Button>
{a.toBeIndexed && !a.isBeingIndexed && (
<Button size="sm" onClick={() => act.mutate({ action: "to-be-indexed", value: false })}>
Remove from queue
</Button>
)}
{a.isBeingIndexed && (
<Button size="sm" icon={<RotateCcw className="size-3.5" />} onClick={() => act.mutate({ action: "reset-indexing" })}>
Release claim
</Button>
)}
{a.recheck && (
<Button size="sm" onClick={() => act.mutate({ action: "recheck", value: false })}>
Clear recheck
</Button>
)}
<Button
size="sm"
variant="danger"
icon={<Trash2 className="size-3.5" />}
onClick={async () => {
if (await confirm({ title: "Delete this addon?", body: "Soft-deletes the addon and its files; it can be restored later.", danger: true, confirmLabel: "Delete" }))
act.mutate({ action: "", method: "DELETE" });
}}
>
Delete
</Button>
</>
)}
</div>
<div className="border-b border-line bg-surface px-5 py-2">
<Segmented
size="sm"
options={[
{ key: "overview", label: "Overview" },
{ key: "files", label: `Files (${int(detail.data!.fileCount)})` },
]}
value={tab}
onChange={setTab}
/>
</div>
<div className="flex-1 space-y-4 overflow-y-auto p-5">
{tab === "overview" ? (
<>
<Card>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-[13px] sm:grid-cols-3">
{[
["Author", a.author || "—"],
["Type", a.type || "—"],
["Subscribers", int(a.subscriberCount)],
["Version", a.currentVersionNumber || "—"],
["Size", bytes(a.currentVersionSize)],
["Files", `${int(detail.data!.fileCount)}${detail.data!.deletedFileRows ? ` (+${int(detail.data!.deletedFileRows)} old rows)` : ""}`],
["Added", dateTime(a.createdAt)],
["Updated", dateTime(a.updatedAt)],
["Last indexed", dateTime(a.lastIndexedAt)],
...(a.isBeingIndexed ? [["Claimed", relative(a.indexStartTime)]] : []),
...(a.deletedAt ? [["Deleted", dateTime(a.deletedAt)]] : []),
].map(([k, v]) => (
<div key={k}>
<dt className="text-xs text-muted">{k}</dt>
<dd className="mt-0.5 truncate text-ink">{v}</dd>
</div>
))}
</dl>
{detail.data!.summary && <p className="mt-4 border-t border-line pt-3 text-[13px] text-ink-2">{detail.data!.summary}</p>}
</Card>
<Card title="Shares files with" subtitle="Other addons containing identical files (whitelisted hashes excluded)">
{detail.data!.duplicates.length === 0 ? (
<div className="text-sm text-muted">No shared files</div>
) : (
<Table>
<tbody>
{detail.data!.duplicates.map((d) => (
<tr key={d.addonId} className="cursor-pointer hover:bg-surface-2/40" onClick={() => navigate(`/addons/${d.addonId}`)}>
<Td className="text-ink">
{d.name || d.addonId}
<div className="font-mono text-[11px] text-muted">{d.addonId}</div>
</Td>
<Td className="tnum text-right">{int(d.sharedFiles)} {d.sharedFiles === 1 ? "file" : "files"}</Td>
</tr>
))}
</tbody>
</Table>
)}
</Card>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Card title={`Depends on (${detail.data!.dependencies.total})`}>
<Related items={detail.data!.dependencies.items ?? []} onOpen={(x) => navigate(`/addons/${x}`)} />
{(detail.data!.dependencies.missing ?? []).length > 0 && (
<div className="mt-3 border-t border-line pt-3">
<div className="mb-1.5 text-xs text-muted">Not in the database</div>
{detail.data!.dependencies.missing!.map((m) => (
<div key={m.id} className="flex items-center justify-between gap-2 py-0.5 text-[13px]">
<span className="truncate text-ink-2">{m.name || m.id}</span>
<span className="font-mono text-[11px] text-muted">{m.id}</span>
</div>
))}
</div>
)}
</Card>
<Card title={`Used by (${detail.data!.dependents.total})`}>
<Related items={detail.data!.dependents.items ?? []} onOpen={(x) => navigate(`/addons/${x}`)} />
</Card>
</div>
</>
) : (
<Files id={id} />
)}
</div>
</>
)
)}
</aside>
</div>
);
}
function Related({ items, onOpen }: { items: { id: string; name: string; subscriberCount: number }[]; onOpen: (id: string) => void }) {
if (items.length === 0) return <div className="text-sm text-muted">None</div>;
return (
<ul className="space-y-1">
{items.map((x) => (
<li key={x.id}>
<button onClick={() => onOpen(x.id)} className="flex w-full items-center justify-between gap-2 rounded px-1 py-0.5 text-left text-[13px] hover:bg-surface-2">
<span className="truncate text-ink-2">{x.name || x.id}</span>
<span className="tnum shrink-0 text-xs text-muted">{compact(x.subscriberCount)}</span>
</button>
</li>
))}
</ul>
);
}
function Files({ id }: { id: string }) {
const [q, setQ] = useState("");
const [draft, setDraft] = useState("");
const [offset, setOffset] = useState(0);
const qc = useQueryClient();
const { toast, confirm } = useFeedback();
const limit = 100;
const files = useQuery({
queryKey: ["addon-files", id, q, offset],
queryFn: () => api<Paged<FileRow>>(`/addons/${encodeURIComponent(id)}/files` + qs({ q, offset, limit })),
placeholderData: keepPreviousData,
});
const whitelist = useMutation({
mutationFn: (f: FileRow) => api<{ added: number }>("/whitelist", { method: "POST", body: { hashes: [f.hash], note: f.path } }),
onSuccess: (r) => {
toast(r.added ? "Hash whitelisted" : "Already whitelisted");
qc.invalidateQueries({ queryKey: ["addon-files", id] });
qc.invalidateQueries({ queryKey: ["addon", id] });
},
onError: (e) => toast((e as Error).message, "critical"),
});
return (
<Card bodyClass="p-0">
<form
className="border-b border-line p-3"
onSubmit={(e) => {
e.preventDefault();
setOffset(0);
setQ(draft.trim());
}}
>
<Input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Filter by path or exact hash…" className="w-full" />
</form>
{files.isLoading ? (
<div className="px-4"><Loading /></div>
) : files.error ? (
<div className="p-4"><ErrorBox error={files.error} /></div>
) : (files.data?.items.length ?? 0) === 0 ? (
<Empty>No files</Empty>
) : (
<div className="px-4">
<Table>
<thead>
<tr>
<Th>Path</Th>
<Th>Hash</Th>
<Th className="text-right">Also in</Th>
<Th />
</tr>
</thead>
<tbody>
{files.data!.items.map((f) => (
<tr key={f.id}>
<Td className="max-w-[300px] truncate font-mono text-xs text-ink" title={f.path}>{f.path}</Td>
<Td className="whitespace-nowrap font-mono text-xs">
{shortHash(f.hash, 8)} <CopyButton value={f.hash} />
</Td>
<Td className="tnum text-right">{f.otherAddons ? `${int(f.otherAddons)} addons` : "—"}</Td>
<Td className="text-right">
{f.whitelisted ? (
<Badge tone="good">whitelisted</Badge>
) : (
<Button
size="sm"
variant="ghost"
icon={<ShieldCheck className="size-3.5" />}
onClick={async () => {
if (
await confirm({
title: "Whitelist this hash?",
body: (
<>
Files with this content will be ignored by duplicate detection for <b>every</b> addon.
<div className="mt-2 font-mono text-xs break-all text-muted">{f.path}</div>
</>
),
confirmLabel: "Whitelist",
})
)
whitelist.mutate(f);
}}
>
Whitelist
</Button>
)}
</Td>
</tr>
))}
</tbody>
</Table>
</div>
)}
{files.data && files.data.total > limit && (
<div className="border-t border-line px-4 py-2.5">
<Pager total={files.data.total} limit={limit} offset={offset} onChange={setOffset} />
</div>
)}
</Card>
);
}
+117
View File
@@ -0,0 +1,117 @@
import { useState } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { api, qs } from "../lib/api";
import { useRefetch } from "../lib/settings";
import type { ServiceEvent, ServiceInstance } from "../lib/types";
import { dateTime, relative } from "../lib/format";
import { Button, Card, Checkbox, Empty, ErrorBox, Input, LevelBadge, Loading, PageHeader, Select, Table, Td, Th } from "../components/ui";
export default function Events() {
const refetchInterval = useRefetch();
const [service, setService] = useState("");
const [level, setLevel] = useState("");
const [audit, setAudit] = useState(false);
const [search, setSearch] = useState("");
const [draft, setDraft] = useState("");
const services = useQuery({ queryKey: ["services"], queryFn: () => api<{ items: ServiceInstance[] }>("/services") });
const events = useInfiniteQuery({
queryKey: ["events", "log", service, level, audit, search],
initialPageParam: 0,
refetchInterval,
queryFn: ({ pageParam }) =>
api<{ items: ServiceEvent[]; next: number }>(
"/services/events" + qs({ service, level, audit: audit ? "1" : undefined, q: search, before: pageParam || undefined, limit: 100 }),
),
getNextPageParam: (last) => last.next || undefined,
});
const rows = events.data?.pages.flatMap((p) => p.items) ?? [];
const names = [...new Set((services.data?.items ?? []).map((s) => s.service))];
return (
<div className="space-y-4">
<PageHeader title="Events" description="Logs reported by services, status changes and the admin audit trail" />
<Card bodyClass="p-0">
<div className="flex flex-wrap items-center gap-2 border-b border-line p-3">
<form
className="min-w-56 flex-1"
onSubmit={(e) => {
e.preventDefault();
setSearch(draft.trim());
}}
>
<Input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Search messages…" className="w-full" />
</form>
<Select value={service} onChange={(e) => setService(e.target.value)}>
<option value="">All services</option>
{names.map((n) => <option key={n} value={n}>{n}</option>)}
</Select>
<Select value={level} onChange={(e) => setLevel(e.target.value)}>
<option value="">All levels</option>
<option value="error">Errors</option>
<option value="warn,error">Warnings & errors</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
</Select>
<label className="flex items-center gap-1.5 text-xs text-ink-2">
<Checkbox checked={audit} onChange={setAudit} label="Admin audit only" /> Admin audit only
</label>
</div>
{events.isLoading ? (
<div className="px-4"><Loading /></div>
) : events.error ? (
<div className="p-4"><ErrorBox error={events.error} /></div>
) : rows.length === 0 ? (
<Empty>No events</Empty>
) : (
<div className="px-4">
<Table>
<thead>
<tr>
<Th>Time</Th>
<Th>Level</Th>
<Th>Service</Th>
<Th>Message</Th>
</tr>
</thead>
<tbody>
{rows.map((e) => (
<tr key={e.id} className="align-top">
<Td className="whitespace-nowrap text-xs" title={relative(e.ts)}>{dateTime(e.ts)}</Td>
<Td><LevelBadge level={e.level} /></Td>
<Td className="whitespace-nowrap text-xs">
{e.service}
<div className="text-muted">{e.instance}</div>
</Td>
<Td className="text-ink">
{e.message}
{e.fieldsData && Object.keys(e.fieldsData).length > 0 && (
<div className="mt-0.5 flex flex-wrap gap-x-3 font-mono text-[11px] text-muted">
{Object.entries(e.fieldsData)
.filter(([k]) => k !== "kind")
.map(([k, v]) => (
<span key={k}>
{k}={typeof v === "object" ? JSON.stringify(v) : String(v)}
</span>
))}
</div>
)}
</Td>
</tr>
))}
</tbody>
</Table>
</div>
)}
{events.hasNextPage && (
<div className="border-t border-line p-3 text-center">
<Button size="sm" loading={events.isFetchingNextPage} onClick={() => events.fetchNextPage()}>
Load older events
</Button>
</div>
)}
</Card>
</div>
);
}
+232
View File
@@ -0,0 +1,232 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router";
import { X } from "lucide-react";
import { api } from "../lib/api";
import { stepFor, useMetric } from "../lib/queries";
import { rangeSeconds, useSettings } from "../lib/settings";
import type { CatalogEntry } from "../lib/types";
import { compact, num, relative } from "../lib/format";
import { Card, Empty, ErrorBox, Loading, PageHeader, Select, Table, Td, Th } from "../components/ui";
import { fromQuery, TimeChart } from "../components/charts/TimeChart";
const AGG_HELP: Record<string, string> = {
sum: "Total of values (counters: increase per interval)",
count: "Number of samples / observations",
rate: "Per second (counters: increase, others: observations)",
avg: "Average value",
min: "Minimum",
max: "Maximum",
last: "Latest value (summed over series)",
p50: "Median (histograms)",
p90: "90th percentile (histograms)",
p95: "95th percentile (histograms)",
p99: "99th percentile (histograms)",
};
const STEPS = [
{ key: 0, label: "Auto" },
{ key: 60, label: "1 min" },
{ key: 300, label: "5 min" },
{ key: 900, label: "15 min" },
{ key: 3600, label: "1 hour" },
{ key: 86400, label: "1 day" },
];
export default function Explorer() {
const [sp, setSp] = useSearchParams();
const { range } = useSettings();
const catalog = useQuery({ queryKey: ["catalog", "all"], queryFn: () => api<{ items: CatalogEntry[]; aggregations: string[] }>("/metrics/catalog") });
const service = sp.get("service") ?? "main";
const name = sp.get("name") ?? "";
const groupBy = (sp.get("groupBy") ?? "").split(",").filter(Boolean);
const match: Record<string, string> = useMemo(() => {
try {
return JSON.parse(sp.get("match") ?? "{}");
} catch {
return {};
}
}, [sp]);
const stepParam = Number(sp.get("step") ?? 0);
const update = (patch: Record<string, string | null>) => {
const next = new URLSearchParams(sp);
for (const [k, v] of Object.entries(patch)) {
if (v === null || v === "") next.delete(k);
else next.set(k, v);
}
setSp(next, { replace: true });
};
const services = useMemo(() => [...new Set((catalog.data?.items ?? []).map((c) => c.service))], [catalog.data]);
const metrics = (catalog.data?.items ?? []).filter((c) => c.service === service);
const entry = metrics.find((m) => m.name === name) ?? null;
const defaultAgg = entry ? (entry.kind === "counter" ? "sum" : entry.kind === "gauge" ? "last" : entry.kind === "histogram" ? "p95" : "avg") : "";
const agg = sp.get("agg") ?? defaultAgg;
const labelKeys = entry ? [...Object.keys(entry.labels), ...(entry.instances.length > 1 ? ["instance"] : [])] : [];
const step = stepParam || stepFor(rangeSeconds(range));
const q = useMetric({ service, name, agg, groupBy, match, step }, !!entry);
// colour only the 8 largest series (fixed slots, never cycled); the table lists all of them
const MAX_CHART_SERIES = 8;
const full = fromQuery(q.data, (l) => (Object.keys(l).length ? Object.entries(l).map(([k, v]) => `${k}=${v}`).join(", ") : name));
const ranked = full.series
.map((s) => ({ s, w: s.data.reduce<number>((a, v) => a + (v ?? 0), 0) }))
.sort((a, b) => b.w - a.w)
.slice(0, MAX_CHART_SERIES)
.map((x, i) => ({ ...x.s, slot: i + 1 }));
const data = { x: full.x, series: ranked };
const hidden = full.series.length - ranked.length;
const fmt = agg === "rate" ? (v: number) => num(v, 3) : compact;
const summary = (q.data?.series ?? []).map((s) => {
const vals = s.points.map((p) => p[1]).filter((v): v is number => v !== null);
return {
labels: s.labels,
last: vals.at(-1) ?? null,
min: vals.length ? Math.min(...vals) : null,
max: vals.length ? Math.max(...vals) : null,
avg: vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null,
};
});
return (
<div className="space-y-4">
<PageHeader title="Metrics explorer" description="Query any metric from the API or a reporting service. The URL keeps the query, so views can be bookmarked." />
<Card>
{catalog.isLoading ? (
<Loading />
) : catalog.error ? (
<ErrorBox error={catalog.error} />
) : (
<div className="flex flex-wrap items-end gap-3">
<Field label="Service">
<Select value={service} onChange={(e) => update({ service: e.target.value, name: null, agg: null, groupBy: null, match: null })}>
{services.map((s) => <option key={s} value={s}>{s}</option>)}
</Select>
</Field>
<Field label="Metric">
<Select value={name} onChange={(e) => update({ name: e.target.value, agg: null, groupBy: null, match: null })} className="min-w-64">
<option value="">Choose a metric…</option>
{metrics.map((m) => <option key={m.name} value={m.name}>{m.name} ({m.kind})</option>)}
</Select>
</Field>
{entry && (
<>
<Field label="Aggregation">
<Select value={agg} onChange={(e) => update({ agg: e.target.value })} title={AGG_HELP[agg]}>
{(catalog.data?.aggregations ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
</Select>
</Field>
<Field label="Interval">
<Select value={stepParam} onChange={(e) => update({ step: e.target.value === "0" ? null : e.target.value })}>
{STEPS.map((s) => <option key={s.key} value={s.key}>{s.label}</option>)}
</Select>
</Field>
{labelKeys.length > 0 && (
<Field label="Split by">
<div className="flex flex-wrap gap-1">
{labelKeys.map((k) => {
const on = groupBy.includes(k);
return (
<button
key={k}
onClick={() => update({ groupBy: (on ? groupBy.filter((g) => g !== k) : [...groupBy, k]).join(",") })}
className={`h-8.5 rounded-lg border px-2.5 text-xs ${on ? "border-accent bg-accent/15 text-ink" : "border-line text-ink-2 hover:text-ink"}`}
>
{k}
</button>
);
})}
</div>
</Field>
)}
</>
)}
</div>
)}
{entry && Object.keys(entry.labels).length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-line pt-3">
<span className="text-xs text-muted">Filter</span>
{Object.entries(entry.labels).map(([k, vals]) => (
<Select
key={k}
value={match[k] ?? ""}
onChange={(e) => {
const next = { ...match };
if (e.target.value) next[k] = e.target.value;
else delete next[k];
update({ match: Object.keys(next).length ? JSON.stringify(next) : null });
}}
className="h-7.5 text-xs"
>
<option value="">{k}: any</option>
{vals.map((v) => <option key={v} value={v}>{k}={v || "(empty)"}</option>)}
</Select>
))}
{Object.keys(match).length > 0 && (
<button className="inline-flex items-center gap-1 text-xs text-muted hover:text-ink" onClick={() => update({ match: null })}>
<X className="size-3" /> clear
</button>
)}
</div>
)}
</Card>
{!entry ? (
<Card><Empty>Pick a service and a metric to plot it</Empty></Card>
) : (
<>
<Card
title={<span className="font-mono">{name}</span>}
subtitle={`${entry.help || entry.kind} · ${agg}: ${AGG_HELP[agg] ?? ""}${q.data ? ` · ${q.data.resolution === 3600 ? "hourly" : "minute"} data, ${q.data.step / 60} min interval` : ""}`}
>
{q.error ? <ErrorBox error={q.error} /> : <TimeChart data={data} height={320} format={fmt} />}
{hidden > 0 && (
<p className="mt-2 text-xs text-muted">
Charting the {MAX_CHART_SERIES} largest of {full.series.length} series — use a filter or fewer split labels to see the rest.
</p>
)}
{q.data?.truncated && <p className="mt-2 text-xs text-muted">The query returned the first 50 series only.</p>}
</Card>
<Card title="Series" subtitle={`${entry.series} series · ${entry.instances.length} instance(s) · last sample ${relative(entry.lastSeen)}`}>
<Table>
<thead>
<tr>
<Th>Labels</Th>
<Th className="text-right">Last</Th>
<Th className="text-right">Min</Th>
<Th className="text-right">Avg</Th>
<Th className="text-right">Max</Th>
</tr>
</thead>
<tbody>
{summary.map((s, i) => (
<tr key={i}>
<Td className="font-mono text-xs text-ink">{Object.entries(s.labels).map(([k, v]) => `${k}=${v}`).join(", ") || name}</Td>
<Td className="tnum text-right">{s.last === null ? "—" : fmt(s.last)}</Td>
<Td className="tnum text-right">{s.min === null ? "—" : fmt(s.min)}</Td>
<Td className="tnum text-right">{s.avg === null ? "—" : fmt(s.avg)}</Td>
<Td className="tnum text-right">{s.max === null ? "—" : fmt(s.max)}</Td>
</tr>
))}
</tbody>
</Table>
</Card>
</>
)}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="flex flex-col gap-1">
<span className="text-xs text-muted">{label}</span>
{children}
</label>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useState, type FormEvent } from "react";
import { useLocation, useNavigate } from "react-router";
import { useQueryClient } from "@tanstack/react-query";
import { KeyRound } from "lucide-react";
import { api } from "../lib/api";
import { Button, ErrorBox, Input } from "../components/ui";
export default function Login() {
const [secret, setSecret] = useState("");
const [error, setError] = useState<unknown>(null);
const [busy, setBusy] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const qc = useQueryClient();
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
await api("/auth/login", { method: "POST", body: { secret } });
qc.clear();
const from = (location.state as { from?: string } | null)?.from;
navigate(from && from !== "/login" ? from : "/", { replace: true });
} catch (err) {
setError(err);
} finally {
setBusy(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center px-4">
<form onSubmit={submit} className="w-full max-w-sm rounded-2xl border border-line bg-surface p-6 shadow-sm">
<div className="mb-6 flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-accent/15">
<KeyRound className="size-5 text-accent" />
</div>
<div>
<h1 className="font-semibold text-ink">Reforger Crawler admin</h1>
<p className="text-xs text-muted">Sign in with the admin secret</p>
</div>
</div>
<label className="text-xs text-ink-2" htmlFor="secret">
Admin secret
</label>
<Input
id="secret"
type="password"
autoFocus
autoComplete="current-password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
className="mt-1 w-full"
/>
{error !== null && <div className="mt-3"><ErrorBox error={error} /></div>}
<Button type="submit" variant="primary" className="mt-4 w-full" loading={busy} disabled={!secret}>
Sign in
</Button>
<p className="mt-4 text-center text-[11px] text-muted">The session is kept in an HttpOnly cookie for 7 days.</p>
</form>
</div>
);
}
+209
View File
@@ -0,0 +1,209 @@
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { api } from "../lib/api";
import { useMetric, useRangeStep } from "../lib/queries";
import { useRefetch } from "../lib/settings";
import type { MiniAddon, Overview as OverviewT, ServiceEvent, TopEntry } from "../lib/types";
import { bytes, compact, int, ms, num, pct, relative } from "../lib/format";
import { Badge, Card, ErrorBox, LevelBadge, Loading, Meter, PageHeader, StatTile, Table, Td } from "../components/ui";
import { fromQuery, TimeChart } from "../components/charts/TimeChart";
import { BarList } from "../components/charts/BarList";
const GROUP_SLOT: Record<string, number> = { v1: 1, back: 2, admin: 3, metrics: 4, other: 5 };
const GROUP_LABEL: Record<string, string> = { v1: "Website API", back: "Workers", admin: "Admin", metrics: "Prometheus", other: "Other" };
function values(q: { series: { points: [number, number | null][] }[] } | undefined): (number | null)[] {
return q?.series[0]?.points.map((p) => p[1]) ?? [];
}
export default function Overview() {
const navigate = useNavigate();
const refetchInterval = useRefetch();
const { step } = useRangeStep();
const ov = useQuery({ queryKey: ["overview"], queryFn: () => api<OverviewT>("/overview"), refetchInterval, placeholderData: keepPreviousData });
const topAddons = useQuery({ queryKey: ["top", "addon"], queryFn: () => api<{ items: TopEntry[] }>("/top?kind=addon&days=1&limit=8"), refetchInterval });
const topSearch = useQuery({ queryKey: ["top", "search"], queryFn: () => api<{ items: TopEntry[] }>("/top?kind=search&days=1&limit=8"), refetchInterval });
const events = useQuery({ queryKey: ["events", "recent"], queryFn: () => api<{ items: ServiceEvent[] }>("/services/events?limit=6"), refetchInterval });
const requests = useMetric({ name: "http_request_duration_ms", agg: "rate", groupBy: ["group"], step });
const p95 = useMetric({ name: "http_request_duration_ms", agg: "p95", match: { group: "v1" }, step });
const queueDepth = useMetric({ name: "queue_depth", agg: "last", groupBy: ["kind"], step });
const indexed = useMetric({ name: "indexer_submissions_total", agg: "sum", groupBy: ["result"], step });
const addonsTotal = useMetric({ name: "addons_total", agg: "last", step });
const visitors = useMetric({ name: "visitors_unique", agg: "max", match: { kind: "human" }, step: 86400, rangeSec: 14 * 86400 });
if (ov.isLoading) return <Loading />;
if (ov.error) return <ErrorBox error={ov.error} />;
const d = ov.data!.data;
const a = d.addons;
const heavy = d.heavy;
const openAddon = (id: string) => navigate(`/addons/${id}`);
return (
<div className="space-y-4">
<PageHeader title="Overview" description={<>Snapshot of the crawler, its workers and the public website · updated {relative(ov.data!.asOf)}</>} />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatTile hero label="Addons tracked" value={int(a.total)} sub={`+${int(a.added24h)} today · +${int(a.added7d)} this week`} trend={values(addonsTotal.data)} onClick={() => navigate("/addons")} />
<div className="rounded-xl border border-line bg-surface p-4">
<div className="text-xs text-muted">Indexed</div>
<div className="mt-2 text-2xl font-semibold tracking-tight text-ink">{pct(a.indexed, a.total)}</div>
<div className="mt-1.5 text-xs text-ink-2">
{int(a.indexed)} of {int(a.total)} · {int(a.indexed24h)} in last 24h
</div>
<div className="mt-3">
<Meter value={a.indexed} max={a.total} />
</div>
</div>
<StatTile
label="Website visitors today"
value={int(d.traffic.visitorsToday)}
sub={`${int(d.traffic.botsToday)} bots / scripts · approx.`}
trend={values(visitors.data)}
onClick={() => navigate("/traffic")}
/>
<StatTile
label="Website latency p95 (24h)"
value={ms(d.traffic.websiteP95Ms)}
sub={`p50 ${ms(d.traffic.websiteP50Ms)} · ${compact(d.traffic.websiteRequests24h ?? 0)} requests`}
trend={values(p95.data)}
onClick={() => navigate("/traffic")}
/>
<StatTile label="Indexing queue" value={int(a.queued)} sub={`${int(a.priority)} priority · ${int(a.indexing)} in progress`} onClick={() => navigate("/queue")} />
<StatTile
label="Indexed files"
value={compact(heavy?.filesLive)}
sub={heavy ? `${compact(heavy.distinctHashes)} distinct hashes${heavy.filesApprox ? " · approx." : ""}` : "computing…"}
/>
<StatTile label="Server errors (24h)" value={int(d.traffic.errors5xx24h)} sub={`${compact(d.traffic.requests24h ?? 0)} requests in total`} onClick={() => navigate("/traffic")} />
<StatTile label="Services online" value={`${d.services.online} / ${d.services.total}`} sub="indexers, scrapers, API" onClick={() => navigate("/services")} />
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<Card title="Requests per minute" subtitle="All HTTP traffic by caller" className="xl:col-span-2">
<TimeChart
data={fromQuery(requests.data, (l) => GROUP_LABEL[l.group] ?? l.group, 60, (l) => GROUP_SLOT[l.group])}
format={(v) => num(v, 1)}
/>
</Card>
<Card title="Queue depth" subtitle="Addons waiting to be indexed">
<TimeChart
data={fromQuery(queueDepth.data, (l) => (l.kind === "priority" ? "Priority" : "All queued"), 1, (l) => (l.kind === "priority" ? 2 : 1))}
format={compact}
/>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<Card title="Indexing results" subtitle="Submissions from indexers per interval" className="xl:col-span-2">
<TimeChart data={fromQuery(indexed.data, (l) => l.result)} format={compact} />
</Card>
<Card title="Last 24 hours">
<dl className="space-y-2 text-[13px]">
{[
["New addons from scrapers", d.activity.scraperCreated24h],
["Updated addons", d.activity.scraperUpdated24h],
["Failed creates (soft-deleted IDs)", d.activity.scraperCreateFailed24h],
["Deleted by scrapers", d.activity.deleted24h],
["Indexed", d.activity.indexed24h],
["Indexing claims", d.activity.claims24h],
["Timed-out claims reset", d.activity.timeoutsReset24h],
["Dependency reports", d.activity.dependencyReports24h],
].map(([k, v]) => (
<div key={k as string} className="flex justify-between gap-3">
<dt className="text-ink-2">{k}</dt>
<dd className="tnum text-ink">{int(v as number)}</dd>
</div>
))}
</dl>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card title="Most viewed addons today">
<BarList
items={(topAddons.data?.items ?? []).map((t) => ({ key: t.item, label: t.name || t.item, value: t.count, onClick: () => openAddon(t.item) }))}
empty="No lookups yet today"
/>
</Card>
<Card title="Top searches today">
<BarList items={(topSearch.data?.items ?? []).map((t) => ({ key: t.item, label: t.item, value: t.count }))} empty="No searches yet today" />
</Card>
<Card title="Addon states">
<div className="space-y-2.5 text-[13px]">
{[
{ label: "Blocked on workshop", value: a.blocked, tone: "critical" as const, status: "blocked" },
{ label: "Soft-deleted", value: a.softDeleted, tone: "serious" as const, status: "deleted" },
{ label: "Unlisted", value: a.unlisted, tone: "neutral" as const, status: "unlisted" },
{ label: "Private", value: a.private, tone: "neutral" as const, status: "private" },
{ label: "Priority indexing", value: a.priority, tone: "accent" as const, status: "priority" },
{ label: "Marked for recheck", value: a.recheck, tone: "warning" as const, status: "recheck" },
].map((s) => (
<button key={s.label} onClick={() => navigate(`/addons?status=${s.status}`)} className="flex w-full items-center justify-between gap-3 text-left hover:text-ink">
<Badge tone={s.tone}>{s.label}</Badge>
<span className="tnum text-ink">{int(s.value)}</span>
</button>
))}
<div className="border-t border-line pt-2.5 text-xs text-ink-2">
{int(d.whitelistCount)} whitelisted hashes · {int(d.dependencies.edges)} dependency edges · {bytes(a.totalSizeBytes)} total size
</div>
</div>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<AddonList title="Most subscribed" items={d.topSubscribed} metric={(x) => compact(x.subscriberCount)} onOpen={openAddon} />
<AddonList title="Largest" items={d.largest} metric={(x) => bytes(x.currentVersionSize)} onOpen={openAddon} />
<AddonList title="Recently indexed" items={d.recentlyIndexed} metric={(x) => relative(x.lastIndexedAt)} onOpen={openAddon} />
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<Card title="Addon types">
<BarList items={d.types.map((t) => ({ key: t.type || "(none)", label: t.type || "(none)", value: t.count }))} />
</Card>
<Card title="Recent events" className="xl:col-span-2" actions={<button onClick={() => navigate("/events")} className="text-xs text-accent hover:underline">All events</button>}>
{(events.data?.items ?? []).length === 0 ? (
<div className="py-6 text-center text-sm text-muted">No events yet</div>
) : (
<Table>
<tbody>
{events.data!.items.map((e) => (
<tr key={e.id}>
<Td className="w-24"><LevelBadge level={e.level} /></Td>
<Td className="text-ink">{e.message}</Td>
<Td className="whitespace-nowrap">{e.service}</Td>
<Td className="whitespace-nowrap text-right text-muted">{relative(e.ts)}</Td>
</tr>
))}
</tbody>
</Table>
)}
</Card>
</div>
</div>
);
}
function AddonList({ title, items, metric, onOpen }: { title: string; items: MiniAddon[]; metric: (a: MiniAddon) => string; onOpen: (id: string) => void }) {
return (
<Card title={title}>
{items.length === 0 ? (
<div className="py-6 text-center text-sm text-muted">Nothing yet</div>
) : (
<ul className="-mx-2">
{items.map((x) => (
<li key={x.id}>
<button onClick={() => onOpen(x.id)} className="flex w-full items-center justify-between gap-3 rounded-lg px-2 py-1.5 text-left hover:bg-surface-2/60">
<span className="min-w-0">
<span className="block truncate text-[13px] text-ink" title={x.name}>{x.name || x.id}</span>
<span className="block truncate text-xs text-muted">{x.author || "unknown author"}</span>
</span>
<span className="tnum shrink-0 text-[13px] text-ink-2">{metric(x)}</span>
</button>
</li>
))}
</ul>
)}
</Card>
);
}
+208
View File
@@ -0,0 +1,208 @@
import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { RotateCcw, Star, StarOff } from "lucide-react";
import { api } from "../lib/api";
import { useMetric, useRangeStep } from "../lib/queries";
import { useRefetch } from "../lib/settings";
import type { QueueData, QueueRow } from "../lib/types";
import { bytes, compact, duration, int, num, relative, seconds } from "../lib/format";
import { Badge, Button, Card, ErrorBox, Loading, PageHeader, StatTile, Table, Td, Th, useFeedback } from "../components/ui";
import { fromQuery, TimeChart } from "../components/charts/TimeChart";
export default function Queue() {
const refetchInterval = useRefetch();
const { step } = useRangeStep();
const qc = useQueryClient();
const { toast, confirm } = useFeedback();
const navigate = useNavigate();
const q = useQuery({ queryKey: ["queue"], queryFn: () => api<QueueData>("/queue"), refetchInterval: refetchInterval || 15000, placeholderData: keepPreviousData });
const depth = useMetric({ name: "queue_depth", agg: "last", groupBy: ["kind"], step });
const claims = useMetric({ name: "indexer_claims_total", agg: "sum", groupBy: ["queue"], step });
const jobDuration = useMetric({ name: "indexer_job_duration_seconds", agg: "p50", step });
const resetStuck = useMutation({
mutationFn: () => api<{ reset: number }>("/queue/reset-stuck", { method: "POST", body: {} }),
onSuccess: (r) => {
toast(`Released ${r.reset} timed-out claim${r.reset === 1 ? "" : "s"}`);
qc.invalidateQueries({ queryKey: ["queue"] });
},
onError: (e) => toast(String((e as Error).message), "critical"),
});
const action = useMutation({
mutationFn: ({ id, act, value }: { id: string; act: string; value?: boolean }) =>
api(`/addons/${encodeURIComponent(id)}/${act}`, { method: "POST", body: { value: value ?? true } }),
onSuccess: () => {
toast("Updated");
qc.invalidateQueries({ queryKey: ["queue"] });
},
onError: (e) => toast(String((e as Error).message), "critical"),
});
if (q.isLoading) return <Loading />;
if (q.error) return <ErrorBox error={q.error} />;
const d = q.data!;
const stuck = d.indexing.filter((r) => r.stuck).length;
return (
<div className="space-y-4">
<PageHeader
title="Indexing queue"
description={<>Jobs are claimed priority-first, then by oldest update · claims time out after {d.timeoutMinutes} min · updated {relative(d.asOf)}</>}
actions={
<Button
icon={<RotateCcw className="size-3.5" />}
loading={resetStuck.isPending}
onClick={async () => {
if (await confirm({ title: "Release timed-out claims?", body: `Addons claimed more than ${d.timeoutMinutes} minutes ago go back to the queue.` }))
resetStuck.mutate();
}}
>
Release stuck claims
</Button>
}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatTile label="Waiting" value={int(d.counts.queued)} sub={`${int(d.counts.priority)} priority · ${int(d.blockedQueued)} blocked (never claimed)`} />
<StatTile label="In progress" value={int(d.counts.indexing)} sub={stuck ? `${stuck} past the timeout` : "none stuck"} />
<StatTile
label="Throughput"
value={`${num(d.throughput.perHour6h, 1)}/h`}
sub={`${num(d.throughput.perHour1h, 0)} last hour · ${int(d.throughput.indexed24h)} in 24h`}
/>
<StatTile
label="Queue drains in"
value={d.throughput.etaHours === null ? "—" : duration(d.throughput.etaHours * 3600)}
sub={d.throughput.duration24h ? `job p50 ${duration(d.throughput.duration24h.p50)} · p95 ${duration(d.throughput.duration24h.p95)}` : "at the 6h average rate"}
/>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<Card title="Queue depth">
<TimeChart data={fromQuery(depth.data, (l) => (l.kind === "priority" ? "Priority" : "All queued"), 1, (l) => (l.kind === "priority" ? 2 : 1))} />
</Card>
<Card title="Claims per interval" subtitle="What indexers got when asking for work">
<TimeChart data={fromQuery(claims.data, (l) => l.queue)} />
</Card>
<Card title="Job duration p50" subtitle="Claim to submitted result">
<TimeChart data={fromQuery(jobDuration.data, () => "p50")} format={seconds} />
</Card>
</div>
<QueueTable
title={`In progress (${d.indexing.length})`}
rows={d.indexing}
empty="No addon is being indexed right now"
extra={(r) => (
<>
<Td className="tnum whitespace-nowrap">{duration(r.elapsedSec)}</Td>
<Td>{r.stuck ? <Badge tone="critical">past timeout</Badge> : <Badge tone="good">running</Badge>}</Td>
</>
)}
extraHead={
<>
<Th>Elapsed</Th>
<Th>State</Th>
</>
}
actions={(r) => (
<Button size="sm" variant="ghost" onClick={() => action.mutate({ id: r.id, act: "reset-indexing" })}>
Release
</Button>
)}
onOpen={(id) => navigate(`/addons/${id}`)}
/>
<QueueTable
title={`Priority (${d.counts.priority})`}
rows={d.priority}
empty="No priority jobs. Mark addons as priority from the addon list."
actions={(r) => (
<Button size="sm" variant="ghost" icon={<StarOff className="size-3.5" />} onClick={() => action.mutate({ id: r.id, act: "priority", value: false })}>
Unprioritize
</Button>
)}
onOpen={(id) => navigate(`/addons/${id}`)}
/>
<QueueTable
title="Next up"
subtitle="The next jobs in claim order (an indexer may skip addons above its size limit)"
rows={d.next}
empty="The queue is empty"
actions={(r) =>
r.priorityIndexing ? null : (
<Button size="sm" variant="ghost" icon={<Star className="size-3.5" />} onClick={() => action.mutate({ id: r.id, act: "priority", value: true })}>
Prioritize
</Button>
)
}
onOpen={(id) => navigate(`/addons/${id}`)}
/>
</div>
);
}
function QueueTable({
title,
subtitle,
rows,
empty,
extra,
extraHead,
actions,
onOpen,
}: {
title: string;
subtitle?: string;
rows: QueueRow[];
empty: string;
extra?: (r: QueueRow) => React.ReactNode;
extraHead?: React.ReactNode;
actions?: (r: QueueRow) => React.ReactNode;
onOpen: (id: string) => void;
}) {
return (
<Card title={title} subtitle={subtitle}>
{rows.length === 0 ? (
<div className="py-4 text-sm text-muted">{empty}</div>
) : (
<Table>
<thead>
<tr>
<Th>Addon</Th>
<Th className="text-right">Size</Th>
<Th className="text-right">Subscribers</Th>
<Th>Waiting since</Th>
{extraHead}
<Th />
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="cursor-pointer hover:bg-surface-2/40" onClick={() => onOpen(r.id)}>
<Td className="text-ink">
<div className="flex items-center gap-2">
{r.name || r.id}
{r.priorityIndexing && <Badge tone="accent">priority</Badge>}
{r.blocked && <Badge tone="critical">blocked</Badge>}
</div>
<div className="font-mono text-[11px] text-muted">{r.id}</div>
</Td>
<Td className="tnum text-right">{bytes(r.currentVersionSize)}</Td>
<Td className="tnum text-right">{compact(r.subscriberCount)}</Td>
<Td className="whitespace-nowrap">{relative(r.updatedAt)}</Td>
{extra?.(r)}
<Td className="text-right" >
<span onClick={(e) => e.stopPropagation()}>{actions?.(r)}</span>
</Td>
</tr>
))}
</tbody>
</Table>
)}
</Card>
);
}
+212
View File
@@ -0,0 +1,212 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { LineChart, Trash2 } from "lucide-react";
import { api, qs } from "../lib/api";
import { useMetric, useRangeStep } from "../lib/queries";
import { useRefetch } from "../lib/settings";
import type { CatalogEntry, ServiceEvent, ServiceInstance } from "../lib/types";
import { compact, dateTime, duration, relative } from "../lib/format";
import { Badge, Button, Card, Empty, ErrorBox, LevelBadge, Loading, PageHeader, StatusDot, Table, Td, cn, useFeedback } from "../components/ui";
import { fromQuery, TimeChart } from "../components/charts/TimeChart";
const EXAMPLE = `POST /back/telemetry
X-SECRET-KEY: <SECRET_KEY>
Content-Type: application/json
{
"service": "indexer", // [a-z0-9_], not "main"
"instance": "indexer-1",
"version": "1.4.0",
"startedAt": 1727950000,
"heartbeatIntervalSec": 15, // offline after 3 missed beats
"status": "ok", // ok | degraded | error
"metrics": [
{"name": "jobs_total", "type": "counter", "value": 3, "labels": {"result": "ok"}},
{"name": "data_window_bytes", "type": "gauge", "value": 7.1e9},
{"name": "download_seconds", "type": "summary", "count": 3, "sum": 41.2, "min": 8.1, "max": 20.3}
],
"events": [{"level": "warn", "message": "data limit reached", "fields": {"limitGB": 50}}]
}`;
export default function Services() {
const refetchInterval = useRefetch();
const qc = useQueryClient();
const { toast, confirm } = useFeedback();
const [selected, setSelected] = useState<string | null>(null);
const services = useQuery({ queryKey: ["services"], queryFn: () => api<{ items: ServiceInstance[] }>("/services"), refetchInterval: refetchInterval || 30000 });
const forget = useMutation({
mutationFn: (id: number) => api(`/services/${id}`, { method: "DELETE" }),
onSuccess: () => {
toast("Instance removed");
qc.invalidateQueries({ queryKey: ["services"] });
},
});
if (services.isLoading) return <Loading />;
if (services.error) return <ErrorBox error={services.error} />;
const items = services.data!.items;
const names = [...new Set(items.map((s) => s.service))];
const current = selected ?? names.find((n) => n !== "main") ?? names[0] ?? null;
return (
<div className="space-y-4">
<PageHeader title="Services" description="Indexers, scrapers and the API report heartbeats, metrics and events here" />
{items.length === 0 ? (
<Card><Empty>No service has reported yet</Empty></Card>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{items.map((s) => (
<div
key={s.id}
onClick={() => setSelected(s.service)}
className={cn(
"cursor-pointer rounded-xl border bg-surface p-4 transition-colors",
current === s.service ? "border-accent/60" : "border-line hover:border-ink/25",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<StatusDot online={s.online} />
<span className="font-medium text-ink">{s.service}</span>
<span className="truncate text-xs text-muted">{s.instance}</span>
</div>
<div className="mt-2 flex flex-wrap gap-1">
<Badge tone={s.online ? "good" : "critical"}>{s.online ? "online" : "offline"}</Badge>
{s.status && s.status !== "ok" && <Badge tone={s.status === "error" ? "critical" : "warning"}>{s.status}</Badge>}
{s.version && <Badge>v{s.version}</Badge>}
</div>
</div>
{!s.online && s.service !== "main" && (
<Button
size="sm"
variant="ghost"
icon={<Trash2 className="size-3.5" />}
aria-label="Forget instance"
onClick={async (e) => {
e.stopPropagation();
if (await confirm({ title: `Forget ${s.service}/${s.instance}?`, body: "Its metric history stays; the instance will reappear if it reports again." }))
forget.mutate(s.id);
}}
/>
)}
</div>
{s.statusMessage && <div className="mt-2 text-xs text-ink-2">{s.statusMessage}</div>}
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div>
<dt className="text-muted">Last seen</dt>
<dd className="text-ink-2">{relative(s.lastSeen)}</dd>
</div>
<div>
<dt className="text-muted">Uptime</dt>
<dd className="text-ink-2">{s.online && s.uptimeSec ? duration(s.uptimeSec) : "—"}</dd>
</div>
<div>
<dt className="text-muted">Heartbeat</dt>
<dd className="text-ink-2">every {s.heartbeatIntervalSec}s</dd>
</div>
<div>
<dt className="text-muted">Address</dt>
<dd className="truncate text-ink-2">{s.remoteAddr || "local"}</dd>
</div>
{s.metaData &&
Object.entries(s.metaData).slice(0, 4).map(([k, v]) => (
<div key={k}>
<dt className="text-muted">{k}</dt>
<dd className="truncate text-ink-2">{String(v)}</dd>
</div>
))}
</dl>
</div>
))}
</div>
)}
{current && <ServiceDetail service={current} />}
<Card title="Sending telemetry" subtitle="Any process can report; a stdlib-only Go client lives in the telemetry/ module of this repo">
<pre className="overflow-x-auto rounded-lg bg-surface-2 p-3 font-mono text-xs leading-relaxed text-ink-2">{EXAMPLE}</pre>
<p className="mt-3 text-xs text-ink-2">
Counters carry the increase since the previous push, gauges the current value, summaries a pre-aggregated count/sum/min/max. Every push counts as a heartbeat.
</p>
</Card>
</div>
);
}
function ServiceDetail({ service }: { service: string }) {
const navigate = useNavigate();
const refetchInterval = useRefetch();
const catalog = useQuery({ queryKey: ["catalog", service], queryFn: () => api<{ items: CatalogEntry[] }>("/metrics/catalog" + qs({ service })) });
const events = useQuery({
queryKey: ["events", service],
queryFn: () => api<{ items: ServiceEvent[] }>("/services/events" + qs({ service, limit: 15 })),
refetchInterval,
});
const metrics = (catalog.data?.items ?? []).slice(0, service === "main" ? 12 : 24);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-ink">{service} metrics</h2>
<Button size="sm" icon={<LineChart className="size-3.5" />} onClick={() => navigate(`/explorer?service=${service}`)}>
Open in explorer
</Button>
</div>
{catalog.isLoading ? (
<Loading />
) : metrics.length === 0 ? (
<Card><Empty>This service has not sent any metrics yet</Empty></Card>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{metrics.map((m) => (
<MiniMetric key={m.name} entry={m} onOpen={() => navigate(`/explorer?service=${service}&name=${m.name}`)} />
))}
</div>
)}
<Card title="Recent events">
{(events.data?.items ?? []).length === 0 ? (
<div className="text-sm text-muted">No events</div>
) : (
<Table>
<tbody>
{events.data!.items.map((e) => (
<tr key={e.id}>
<Td className="w-20"><LevelBadge level={e.level} /></Td>
<Td className="text-ink">
{e.message}
{e.fieldsData && <div className="truncate font-mono text-[11px] text-muted">{JSON.stringify(e.fieldsData)}</div>}
</Td>
<Td className="whitespace-nowrap text-xs">{e.instance}</Td>
<Td className="whitespace-nowrap text-right text-xs text-muted" title={dateTime(e.ts)}>{relative(e.ts)}</Td>
</tr>
))}
</tbody>
</Table>
)}
</Card>
</div>
);
}
function aggFor(kind: string): string {
return kind === "counter" ? "sum" : kind === "gauge" ? "last" : kind === "histogram" ? "p95" : "avg";
}
function MiniMetric({ entry, onOpen }: { entry: CatalogEntry; onOpen: () => void }) {
const { step } = useRangeStep();
const groupBy = entry.instances.length > 1 ? ["instance"] : undefined;
const q = useMetric({ service: entry.service, name: entry.name, agg: aggFor(entry.kind), groupBy, step });
return (
<Card
title={<span className="font-mono text-xs">{entry.name}</span>}
subtitle={`${entry.kind} · ${aggFor(entry.kind)}${entry.help ? ` · ${entry.help}` : ""}`}
actions={<button className="text-xs text-accent hover:underline" onClick={onOpen}>Explore</button>}
>
<TimeChart data={fromQuery(q.data, (l) => l.instance ?? entry.name)} height={140} format={compact} />
</Card>
);
}
+248
View File
@@ -0,0 +1,248 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Database, Eraser, Gauge, RefreshCw } from "lucide-react";
import { api } from "../lib/api";
import { useRefetch } from "../lib/settings";
import type { HeavyStats } from "../lib/types";
import { bytes, dateTime, duration, int, num, relative } from "../lib/format";
import { Badge, Button, Card, ErrorBox, Input, Loading, PageHeader, StatTile, Table, Td, Th, useFeedback } from "../components/ui";
interface SystemData {
go: { version: string; os: string; arch: string; cpus: number; maxProcs: number; goroutines: number };
process: { pid: number; hostname: string; startedAt: number; uptimeSec: number };
memory: { heapAlloc: number; heapInuse: number; sys: number; numGC: number; pauseTotalNs: number; lastGC: number };
build: { commit?: string; time?: string; modified?: boolean; module?: string };
metrics: {
enabled: boolean;
lastFlush: number;
lastFlushMs: number;
lastFlushError: string;
lastRollup: number;
lastRetention: number;
rolledUntil: number;
openPoints: number;
liveSeries: number;
droppedPoints: number;
};
config: Record<string, unknown>;
dbPool?: { open: number; inUse: number; idle: number; maxOpen: number; waitCount: number; waitMs: number };
dbPingMs?: number;
dbPingError?: string;
db?: HeavyStats;
}
export default function System() {
const refetchInterval = useRefetch();
const qc = useQueryClient();
const { toast, confirm } = useFeedback();
const [days, setDays] = useState("7");
const sys = useQuery({ queryKey: ["system"], queryFn: () => api<SystemData>("/system"), refetchInterval });
const run = useMutation({
mutationFn: (body: { action: string; olderThanDays?: number }) => api("/system/maintenance", { method: "POST", body }),
onSuccess: (_r, v) => {
toast(`Started: ${v.action}`);
setTimeout(() => qc.invalidateQueries({ queryKey: ["system"] }), 1500);
},
onError: (e) => toast((e as Error).message, "critical"),
});
if (sys.isLoading) return <Loading />;
if (sys.error) return <ErrorBox error={sys.error} />;
const s = sys.data!;
const db = s.db;
return (
<div className="space-y-4">
<PageHeader title="System" description={<>{s.process.hostname} · pid {s.process.pid} · build {s.build.commit ? s.build.commit.slice(0, 10) : "dev"}{s.build.modified ? " (modified)" : ""}</>} />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatTile label="Uptime" value={duration(s.process.uptimeSec)} sub={`since ${dateTime(s.process.startedAt)}`} />
<StatTile label="Heap in use" value={bytes(s.memory.heapAlloc)} sub={`${bytes(s.memory.sys)} from OS · ${int(s.memory.numGC)} GCs`} />
<StatTile label="Goroutines" value={int(s.go.goroutines)} sub={`${s.go.version} · ${s.go.os}/${s.go.arch} · ${s.go.cpus} CPUs`} />
<StatTile
label="Database"
value={db ? bytes(db.dbSizeBytes) : "—"}
sub={`${db?.dbDriver ?? ""} · ping ${s.dbPingMs !== undefined ? `${num(s.dbPingMs, 1)} ms` : s.dbPingError ?? "—"}`}
/>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<Card
title="Tables"
subtitle={db ? `Updated ${relative(db.asOf)}${db.dbDriver === "postgres" ? " · row counts are estimates" : ""}` : "Computing…"}
className="xl:col-span-2"
actions={
<Button size="sm" icon={<RefreshCw className="size-3.5" />} onClick={() => run.mutate({ action: "refresh-stats" })}>
Refresh
</Button>
}
>
{db?.error && <div className="mb-3"><ErrorBox error={db.error} /></div>}
<Table>
<thead>
<tr>
<Th>Table</Th>
<Th className="text-right">Rows</Th>
<Th className="text-right">Dead rows</Th>
<Th className="text-right">Size</Th>
</tr>
</thead>
<tbody>
{(db?.tables ?? []).map((t) => (
<tr key={t.name}>
<Td className="font-mono text-xs text-ink">{t.name}</Td>
<Td className="tnum text-right">{int(t.rows)}</Td>
<Td className="tnum text-right">{t.deadRows ? int(t.deadRows) : "—"}</Td>
<Td className="tnum text-right">{t.sizeBytes ? bytes(t.sizeBytes) : "—"}</Td>
</tr>
))}
</tbody>
</Table>
{db && (
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-ink-2 sm:grid-cols-3">
<div>
<div className="text-muted">Live file rows</div>
<div className="text-ink">{int(db.filesLive)}{db.filesApprox ? " (est.)" : ""}</div>
</div>
<div>
<div className="text-muted">Soft-deleted file rows</div>
<div className="text-ink">{int(db.filesSoftDeleted)}</div>
</div>
<div>
<div className="text-muted">Distinct hashes</div>
<div className="text-ink">{int(db.distinctHashes)} <span className="text-muted">· {relative(db.distinctAsOf)}</span></div>
</div>
</div>
)}
</Card>
<Card title="Connection pool">
{s.dbPool ? (
<dl className="space-y-2 text-[13px]">
{[
["Open", s.dbPool.open],
["In use", s.dbPool.inUse],
["Idle", s.dbPool.idle],
["Max open", s.dbPool.maxOpen || "unlimited"],
["Waits", s.dbPool.waitCount],
["Wait time", `${int(s.dbPool.waitMs)} ms`],
].map(([k, v]) => (
<div key={k as string} className="flex justify-between">
<dt className="text-ink-2">{k}</dt>
<dd className="tnum text-ink">{typeof v === "number" ? int(v) : v}</dd>
</div>
))}
</dl>
) : (
<div className="text-sm text-muted">Unavailable</div>
)}
</Card>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<Card title="Metrics pipeline" subtitle="Minute buckets are flushed to the database every minute and rolled up hourly">
<dl className="space-y-2 text-[13px]">
<Row k="Status" v={s.metrics.enabled ? <Badge tone="good">enabled</Badge> : <Badge tone="warning">disabled</Badge>} />
<Row k="Last flush" v={`${relative(s.metrics.lastFlush)} · ${s.metrics.lastFlushMs} ms`} />
{s.metrics.lastFlushError && <Row k="Flush error" v={<span className="text-critical">{s.metrics.lastFlushError}</span>} />}
<Row k="Last rollup" v={relative(s.metrics.lastRollup)} />
<Row k="Rolled up until" v={dateTime(s.metrics.rolledUntil)} />
<Row k="Last retention run" v={relative(s.metrics.lastRetention)} />
<Row k="Unflushed buckets" v={int(s.metrics.openPoints)} />
<Row k="Series in memory" v={int(s.metrics.liveSeries)} />
<Row k="Dropped points" v={int(s.metrics.droppedPoints)} />
</dl>
</Card>
<Card title="Configuration" subtitle="Effective settings (secrets are never shown)">
<dl className="space-y-2 text-[13px]">
{Object.entries(s.config).map(([k, v]) => (
<Row
key={k}
k={k}
v={
typeof v === "boolean" ? (
<Badge tone={v ? "good" : k === "adminSecretStrong" || k === "secretKeySet" ? "critical" : "neutral"}>{v ? "yes" : "no"}</Badge>
) : Array.isArray(v) ? (
v.join(", ") || "—"
) : v === null || v === "" ? (
"—"
) : (
String(v)
)
}
/>
))}
</dl>
</Card>
<Card title="Maintenance">
<div className="space-y-4 text-[13px]">
<Action
icon={<Gauge className="size-4" />}
title="Flush metrics now"
text="Writes all buffered metric buckets to the database."
button={<Button size="sm" onClick={() => run.mutate({ action: "flush-metrics" })}>Flush</Button>}
/>
<Action
icon={<Database className="size-4" />}
title="Roll up & apply retention"
text="Builds hourly rollups and deletes data past the retention windows."
button={<Button size="sm" onClick={() => run.mutate({ action: "rollup" })}>Run</Button>}
/>
<Action
icon={<Eraser className="size-4" />}
title="Purge replaced file rows"
text={`Every re-index soft-deletes the previous file rows (${int(db?.filesSoftDeleted ?? 0)} now). Permanently remove rows replaced more than N days ago.`}
button={
<div className="flex items-center gap-1.5">
<Input value={days} onChange={(e) => setDays(e.target.value.replace(/\D/g, ""))} className="h-7 w-14 text-xs" aria-label="Days" />
<Button
size="sm"
variant="danger"
onClick={async () => {
if (
await confirm({
title: "Purge old file rows?",
body: `Permanently deletes soft-deleted addon_files rows older than ${days} days. Runs in the background in batches.`,
danger: true,
confirmLabel: "Purge",
})
)
run.mutate({ action: "purge-deleted-files", olderThanDays: Number(days) || 7 });
}}
>
Purge
</Button>
</div>
}
/>
</div>
</Card>
</div>
</div>
);
}
function Row({ k, v }: { k: string; v: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-3">
<dt className="truncate text-ink-2">{k}</dt>
<dd className="tnum shrink-0 text-right text-ink">{v}</dd>
</div>
);
}
function Action({ icon, title, text, button }: { icon: React.ReactNode; title: string; text: string; button: React.ReactNode }) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-muted">{icon}</div>
<div className="min-w-0 flex-1">
<div className="text-ink">{title}</div>
<div className="mt-0.5 text-xs text-ink-2">{text}</div>
</div>
<div className="shrink-0">{button}</div>
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { useMemo, useState } from "react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { api, qs } from "../lib/api";
import { rangeSeconds, useRefetch, useSettings } from "../lib/settings";
import type { RouteStat, TrafficData } from "../lib/types";
import { compact, int, ms, num, pct, relative } from "../lib/format";
import { Badge, Card, ErrorBox, Loading, PageHeader, Segmented, StatTile, Table, Td, Th } from "../components/ui";
import { fromQuery, TimeChart, type ChartData } from "../components/charts/TimeChart";
import { BarList } from "../components/charts/BarList";
const GROUP_SLOT: Record<string, number> = { v1: 1, back: 2, admin: 3, metrics: 4, other: 5 };
const GROUP_LABEL: Record<string, string> = { v1: "Website API", back: "Workers", admin: "Admin", metrics: "Prometheus", other: "Other" };
type SortKey = "count" | "errors" | "p50" | "p95" | "p99" | "max";
export default function Traffic() {
const { range } = useSettings();
const refetchInterval = useRefetch();
const [groupFilter, setGroupFilter] = useState<string>("all");
const [sort, setSort] = useState<SortKey>("count");
const q = useQuery({
queryKey: ["traffic", range],
refetchInterval,
placeholderData: keepPreviousData,
queryFn: () => {
const to = Math.floor(Date.now() / 1000);
return api<TrafficData>("/traffic" + qs({ from: to - rangeSeconds(range), to }));
},
});
const latency = useMemo<ChartData>(() => {
if (!q.data) return { x: [], series: [] };
const parts = (["p50", "p95", "p99"] as const).map((k) => fromQuery(q.data!.latency[k], () => k));
return { x: parts[0].x, series: parts.flatMap((p, i) => p.series.map((s) => ({ ...s, slot: i + 1 }))) };
}, [q.data]);
const errors = useMemo<ChartData>(() => {
if (!q.data) return { x: [], series: [] };
const e5 = fromQuery(q.data.errors5xx, (l) => `5xx · ${GROUP_LABEL[l.group] ?? l.group}`, 60);
const e4 = fromQuery(q.data.errors4xx, () => "4xx · Website API", 60);
const x = e5.x.length ? e5.x : e4.x;
return { x, series: [...e5.series, ...e4.series].map((s, i) => ({ ...s, slot: i + 1 })) };
}, [q.data]);
if (q.isLoading) return <Loading />;
if (q.error) return <ErrorBox error={q.error} />;
const t = q.data!;
const totalReq = (t.groupTotals ?? []).reduce((s, g) => s + g.count, 0);
const status = Object.fromEntries((t.statusTotals ?? []).map((s) => [s.labels.status, s.count]));
const web = t.website;
const visitors = fromQuery(t.visitorsDaily, (l) => (l.kind === "human" ? "Visitors" : "Bots & scripts"), 1, (l) => (l.kind === "human" ? 1 : 2));
const routes = t.routes
.filter((r) => groupFilter === "all" || r.group === groupFilter)
.sort((a, b) => ((b[sort] as number | null) ?? -1) - ((a[sort] as number | null) ?? -1));
return (
<div className="space-y-4">
<PageHeader title="Traffic & performance" description={<>Request rates, latency and errors for the API · updated {relative(t.asOf)}</>} />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatTile label="Requests in range" value={compact(totalReq)} sub={`${compact(web?.count ?? 0)} from the website API`} />
<StatTile label="Website latency p95" value={ms(web?.p95)} sub={`p50 ${ms(web?.p50)} · p99 ${ms(web?.p99)}`} />
<StatTile
label="Error rate"
value={pct(status["5xx"] ?? 0, totalReq, 2)}
sub={`${int(status["5xx"] ?? 0)} server · ${int(status["4xx"] ?? 0)} client errors`}
/>
<StatTile label="Visitors today" value={int(t.visitorsToday.human)} sub={`${int(t.visitorsToday.bot)} bots & scripts · approx.`} />
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<Card title="Requests per minute" subtitle="By caller">
<TimeChart data={fromQuery(t.requestsByGroup, (l) => GROUP_LABEL[l.group] ?? l.group, 60, (l) => GROUP_SLOT[l.group])} format={(v) => num(v, 1)} />
</Card>
<Card title="Website API latency" subtitle="Percentiles per interval, milliseconds">
<TimeChart data={latency} format={(v) => ms(v)} />
</Card>
<Card title="Errors per minute" subtitle="Server errors by caller, client errors on the website API">
<TimeChart data={errors} format={(v) => num(v, 2)} empty="No errors in this range" />
</Card>
<Card title="Unique visitors per day" subtitle="Last 30 days · salted hash of IP + user agent, approximate">
<TimeChart data={visitors} bars format={compact} />
</Card>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card title="Status codes">
<BarList items={(t.statusTotals ?? []).map((s) => ({ key: s.labels.status, label: s.labels.status, value: s.count }))} />
</Card>
<Card title="Callers">
<BarList items={(t.groupTotals ?? []).map((g) => ({ key: g.labels.group, label: GROUP_LABEL[g.labels.group] ?? g.labels.group, value: g.count }))} />
</Card>
<Card title="Website API clients" subtitle="Browser (Origin/Referer set) vs direct calls">
<BarList items={(t.clientTotals ?? []).map((g) => ({ key: g.labels.client, label: g.labels.client === "web" ? "Browser" : "Direct / scripts", value: g.count }))} />
</Card>
</div>
<Card
title="Routes"
subtitle="Latency in milliseconds over the selected range"
actions={
<Segmented
size="sm"
options={[
{ key: "all", label: "All" },
{ key: "v1", label: "Website" },
{ key: "back", label: "Workers" },
{ key: "admin", label: "Admin" },
]}
value={groupFilter}
onChange={setGroupFilter}
/>
}
>
<Table>
<thead>
<tr>
<Th>Route</Th>
<Th>Caller</Th>
{(["count", "errors", "p50", "p95", "p99", "max"] as SortKey[]).map((k) => (
<Th key={k} className="text-right" onClick={() => setSort(k)}>
{k === "count" ? "Requests" : k === "errors" ? "5xx" : k}
{sort === k ? " ↓" : ""}
</Th>
))}
</tr>
</thead>
<tbody>
{routes.map((r: RouteStat) => (
<tr key={`${r.method} ${r.route} ${r.group}`} className="hover:bg-surface-2/40">
<Td className="font-mono text-xs text-ink">
<span className="mr-2 inline-block w-12 text-muted">{r.method}</span>
{r.route}
</Td>
<Td><Badge tone={r.group === "v1" ? "accent" : "neutral"}>{GROUP_LABEL[r.group] ?? r.group}</Badge></Td>
<Td className="tnum text-right text-ink">{int(r.count)}</Td>
<Td className="tnum text-right">{r.errors ? <span className="text-critical">{int(r.errors)}</span> : "0"}</Td>
<Td className="tnum text-right">{ms(r.p50)}</Td>
<Td className="tnum text-right">{ms(r.p95)}</Td>
<Td className="tnum text-right">{ms(r.p99)}</Td>
<Td className="tnum text-right">{ms(r.max)}</Td>
</tr>
))}
{routes.length === 0 && (
<tr>
<Td className="py-8 text-center text-muted">No requests in this range</Td>
</tr>
)}
</tbody>
</Table>
</Card>
</div>
);
}
+315
View File
@@ -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>
);
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"noEmit": true,
"types": ["vite/client"],
"tsBuildInfoFile": "node_modules/.tmp/tsconfig.tsbuildinfo"
},
"include": ["src"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// The build is embedded into the Go binary (web/embed.go) and served at /admin/ui/.
// In dev, run the Go server on :8083 and `npm run dev`, then open
// http://localhost:5173/admin/ui/
export default defineConfig({
base: "/admin/ui/",
plugins: [react(), tailwindcss()],
build: {
outDir: "dist",
// keep dist/.gitkeep so go:embed always has a directory
emptyOutDir: false,
chunkSizeWarningLimit: 600,
},
server: {
proxy: {
"/admin/api": "http://localhost:8083",
},
},
});