252 lines
8.9 KiB
TypeScript
252 lines
8.9 KiB
TypeScript
/**
|
|
* BatchMonitor — client component that polls /api/admin/batch every 30s
|
|
* and renders progress bar + stats + recent docs.
|
|
*/
|
|
"use client";
|
|
import { useEffect, useState } from "react";
|
|
|
|
interface Stats {
|
|
total_cost_usd: number;
|
|
total_chunks: number;
|
|
total_images: number;
|
|
total_pages_processed: number;
|
|
avg_seconds_per_doc: number | null;
|
|
avg_chunks_per_doc: number | null;
|
|
throughput_docs_per_hour: number | null;
|
|
eta_minutes: number | null;
|
|
}
|
|
|
|
interface RecentDoc {
|
|
doc_id: string;
|
|
pages?: number;
|
|
chunks?: number;
|
|
cost_usd?: number | null;
|
|
wall_s?: number;
|
|
success?: boolean;
|
|
finished_at?: string;
|
|
}
|
|
|
|
interface BatchPayload {
|
|
status: string;
|
|
queue_total: number;
|
|
completed: number;
|
|
successes: number;
|
|
failures: number;
|
|
progress_pct: number;
|
|
quota_state?: "ok" | "throttled";
|
|
quota_resume_eta_minutes?: number | null;
|
|
latest_quota_at?: string | null;
|
|
stats: Stats | null;
|
|
recent_docs?: RecentDoc[];
|
|
failed_docs?: Array<{ doc_id: string; timed_out?: boolean; returncode?: number }>;
|
|
}
|
|
|
|
export function BatchMonitor() {
|
|
const [data, setData] = useState<BatchPayload | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [lastFetched, setLastFetched] = useState<number>(0);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
async function tick() {
|
|
try {
|
|
const r = await fetch("/api/admin/batch");
|
|
if (!r.ok) return;
|
|
const j = (await r.json()) as BatchPayload;
|
|
if (alive) {
|
|
setData(j);
|
|
setLastFetched(Date.now());
|
|
setLoading(false);
|
|
}
|
|
} catch {
|
|
if (alive) setLoading(false);
|
|
}
|
|
}
|
|
tick();
|
|
const interval = setInterval(tick, 30_000);
|
|
return () => {
|
|
alive = false;
|
|
clearInterval(interval);
|
|
};
|
|
}, []);
|
|
|
|
if (loading && !data) {
|
|
return (
|
|
<div className="font-mono text-xs text-[#5a6678] animate-pulse">carregando progresso…</div>
|
|
);
|
|
}
|
|
if (!data || data.status === "no_log") {
|
|
return (
|
|
<div className="border border-[rgba(255,107,107,0.30)] bg-[rgba(255,107,107,0.05)] rounded p-4 text-xs">
|
|
<p className="text-[#ff6b6b] font-mono">no progress.jsonl found</p>
|
|
<p className="text-[#8896aa] mt-1">
|
|
run <code>python3 scripts/28-batch-rebuild-all.py</code> to start a rebuild
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const stats = data.stats;
|
|
const eta = stats?.eta_minutes;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Quota throttle banner */}
|
|
{data.quota_state === "throttled" && (
|
|
<div className="border border-[rgba(255,165,0,0.40)] bg-[rgba(255,165,0,0.08)] rounded p-3 font-mono text-xs flex items-center gap-3">
|
|
<span className="text-2xl">💸</span>
|
|
<div className="flex-1">
|
|
<div className="text-[#ffa500] font-bold mb-0.5">
|
|
Anthropic quota throttled — janela rolling 5h
|
|
</div>
|
|
<div className="text-[#c8d4e6]">
|
|
último 429:{" "}
|
|
{data.latest_quota_at ? new Date(data.latest_quota_at).toLocaleString("pt-BR") : "?"} ·
|
|
<span className="text-[#ffa500] ml-1 font-bold">
|
|
reset estimado em{" "}
|
|
{data.quota_resume_eta_minutes && data.quota_resume_eta_minutes >= 60
|
|
? `${(data.quota_resume_eta_minutes / 60).toFixed(1)}h`
|
|
: `${data.quota_resume_eta_minutes ?? "?"}min`}
|
|
</span>
|
|
</div>
|
|
<div className="text-[#8896aa] mt-1">
|
|
<code className="text-[#ffa500]">python3 scripts/28-batch-rebuild-all.py --workers 2</code>{" "}
|
|
quando o reset acontecer (script é idempotente, skipa docs prontos)
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Progress bar */}
|
|
<section>
|
|
<div className="flex items-baseline justify-between mb-2">
|
|
<h2 className="font-mono text-xs uppercase tracking-widest text-[#7fdbff]">
|
|
{data.completed} of {data.queue_total} docs · {data.progress_pct}%
|
|
</h2>
|
|
<span className="font-mono text-[10px] text-[#5a6678]">
|
|
atualizado {Math.round((Date.now() - lastFetched) / 1000)}s atrás
|
|
</span>
|
|
</div>
|
|
<div className="w-full h-3 bg-[#0a121e] border border-[rgba(0,255,156,0.20)] rounded overflow-hidden">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-[#00ff9c] to-[#7fdbff] transition-all duration-700"
|
|
style={{ width: `${data.progress_pct}%` }}
|
|
/>
|
|
</div>
|
|
<div className="mt-2 flex items-center gap-3 font-mono text-[11px]">
|
|
<span className="text-[#00ff9c]">✓ {data.successes}</span>
|
|
<span className="text-[#ff6b6b]">✗ {data.failures}</span>
|
|
<span className="text-[#8896aa] ml-auto">
|
|
{eta != null && eta > 0
|
|
? `ETA ~${eta < 60 ? `${eta}min` : `${(eta / 60).toFixed(1)}h`}`
|
|
: "—"}
|
|
</span>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Stats grid */}
|
|
{stats && (
|
|
<section className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<Stat label="custo total" value={`$${stats.total_cost_usd.toFixed(2)}`} accent="#00ff9c" />
|
|
<Stat label="chunks" value={stats.total_chunks.toLocaleString()} accent="#7fdbff" />
|
|
<Stat label="imagens cropadas" value={stats.total_images.toLocaleString()} accent="#a78bfa" />
|
|
<Stat
|
|
label="páginas processadas"
|
|
value={stats.total_pages_processed.toLocaleString()}
|
|
accent="#ffa500"
|
|
/>
|
|
<Stat
|
|
label="avg s/doc"
|
|
value={stats.avg_seconds_per_doc != null ? `${stats.avg_seconds_per_doc}s` : "—"}
|
|
/>
|
|
<Stat
|
|
label="avg chunks/doc"
|
|
value={stats.avg_chunks_per_doc != null ? String(stats.avg_chunks_per_doc) : "—"}
|
|
/>
|
|
<Stat
|
|
label="throughput"
|
|
value={
|
|
stats.throughput_docs_per_hour
|
|
? `${stats.throughput_docs_per_hour.toFixed(1)} docs/h`
|
|
: "—"
|
|
}
|
|
/>
|
|
<Stat
|
|
label="custo médio/doc"
|
|
value={
|
|
data.successes
|
|
? `$${(stats.total_cost_usd / data.successes).toFixed(2)}`
|
|
: "—"
|
|
}
|
|
/>
|
|
</section>
|
|
)}
|
|
|
|
{/* Recent docs */}
|
|
<section>
|
|
<h3 className="font-mono text-xs uppercase tracking-widest text-[#7fdbff] mb-2">
|
|
últimos 20 documentos
|
|
</h3>
|
|
<ul className="space-y-1 font-mono text-xs">
|
|
{(data.recent_docs ?? []).map((d, i) => (
|
|
<li
|
|
key={i}
|
|
className={`flex items-center gap-2 px-2 py-1 rounded border ${
|
|
d.success
|
|
? "border-[rgba(0,255,156,0.15)] bg-[rgba(0,255,156,0.03)]"
|
|
: "border-[rgba(255,107,107,0.30)] bg-[rgba(255,107,107,0.05)]"
|
|
}`}
|
|
>
|
|
<span className={d.success ? "text-[#00ff9c]" : "text-[#ff6b6b]"}>
|
|
{d.success ? "✓" : "✗"}
|
|
</span>
|
|
<span className="text-[#c8d4e6] truncate flex-1">{d.doc_id}</span>
|
|
<span className="text-[#8896aa]">{d.pages ?? 0} pg</span>
|
|
<span className="text-[#8896aa]">{d.chunks ?? 0} ch</span>
|
|
<span className="text-[#00ff9c]">${(d.cost_usd ?? 0).toFixed(2)}</span>
|
|
<span className="text-[#5a6678]">{d.wall_s ?? 0}s</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
|
|
{/* Failures */}
|
|
{(data.failed_docs?.length ?? 0) > 0 && (
|
|
<section>
|
|
<h3 className="font-mono text-xs uppercase tracking-widest text-[#ff6b6b] mb-2">
|
|
falhas — {data.failed_docs?.length ?? 0}
|
|
</h3>
|
|
<ul className="space-y-1 font-mono text-xs">
|
|
{(data.failed_docs ?? []).map((d, i) => (
|
|
<li
|
|
key={i}
|
|
className="flex items-center gap-2 px-2 py-1 rounded border border-[rgba(255,107,107,0.30)] bg-[rgba(255,107,107,0.05)]"
|
|
>
|
|
<span className="text-[#ff6b6b]">✗</span>
|
|
<span className="text-[#c8d4e6] truncate flex-1">{d.doc_id}</span>
|
|
{d.timed_out && <span className="text-[#ff6b6b]">timeout</span>}
|
|
{!d.timed_out && d.returncode != null && (
|
|
<span className="text-[#5a6678]">rc={d.returncode}</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Stat({ label, value, accent }: { label: string; value: string; accent?: string }) {
|
|
return (
|
|
<div className="border border-[rgba(0,255,156,0.15)] bg-[#0a121e] rounded p-3">
|
|
<div className="font-mono text-[9px] uppercase tracking-widest text-[#5a6678]">{label}</div>
|
|
<div
|
|
className="font-mono text-lg mt-1"
|
|
style={{ color: accent ?? "#c8d4e6" }}
|
|
>
|
|
{value}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|