- embedded React admin UI at /admin/ui (go:embed), login with ADMIN_SECRET via signed HttpOnly session cookie, X-ADMIN-KEY still works for scripts - admin API: overview stats, indexing queue, addon management (priority, reindex, delete/restore/purge, bulk), whitelist CRUD, duplicate hashes, traffic, metrics explorer, services, events/audit log, system info - built-in metrics: request latency per route, visitors, top addons and searches, indexer/scraper counters; minute buckets in the DB with hourly rollups and retention; optional Prometheus /metrics (METRICS_TOKEN) - POST /back/telemetry for indexers/scrapers (metrics, events, heartbeat) and a stdlib-only Go client in telemetry/ - auth hardening: empty secrets no longer allow access, constant-time compare, CORS not applied to /admin and /back, TRUSTED_PROXIES setting - Dockerfile builds the UI and no longer bakes config.yaml into the image; CI builds the UI, runs tests and builds arm64 with CGO
This commit is contained in:
1 parent
0ef27b0072
commit
56d9e10621
79 files changed
+13146
-118
No files matched your search
Vendored
Whitespace-only changes.
@@ -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
|
||||
@@ -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>
|
||||
Generated
+2534
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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
@@ -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 && 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}` : "";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user