Cross Compile Go / build (push) Canceled after 0s
- embedded React admin UI at /admin/ui (go:embed), login with ADMIN_SECRET via signed HttpOnly session cookie, X-ADMIN-KEY still works for scripts - admin API: overview stats, indexing queue, addon management (priority, reindex, delete/restore/purge, bulk), whitelist CRUD, duplicate hashes, traffic, metrics explorer, services, events/audit log, system info - built-in metrics: request latency per route, visitors, top addons and searches, indexer/scraper counters; minute buckets in the DB with hourly rollups and retention; optional Prometheus /metrics (METRICS_TOKEN) - POST /back/telemetry for indexers/scrapers (metrics, events, heartbeat) and a stdlib-only Go client in telemetry/ - auth hardening: empty secrets no longer allow access, constant-time compare, CORS not applied to /admin and /back, TRUSTED_PROXIES setting - Dockerfile builds the UI and no longer bakes config.yaml into the image; CI builds the UI, runs tests and builds arm64 with CGO
209 lines
8.1 KiB
TypeScript
209 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|