disclosure-bureau/web/components/indexer-status.tsx

170 lines
5.4 KiB
TypeScript
Raw Normal View History

/**
* IndexerStatus sidebar widget for /admin/batch showing the next-step gap:
* how many docs/chunks need to be indexed into Postgres.
*/
"use client";
import { useEffect, useState } from "react";
interface IndexerPayload {
disk: { docs_on_disk: number; chunks_on_disk: number };
db: {
documents_count: number;
chunks_count: number;
chunks_with_embedding: number;
entities_count: number;
entity_mentions_count: number;
} | null;
db_error: string | null;
gap: {
docs_to_index: number;
chunks_to_index: number;
chunks_without_embedding: number;
ready_for_retrieval: boolean;
} | null;
}
export function IndexerStatus() {
const [data, setData] = useState<IndexerPayload | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
async function tick() {
try {
const r = await fetch("/api/admin/indexer");
if (!r.ok) return;
const j = (await r.json()) as IndexerPayload;
if (alive) {
setData(j);
setLoading(false);
}
} catch {
if (alive) setLoading(false);
}
}
tick();
const i = setInterval(tick, 60_000);
return () => {
alive = false;
clearInterval(i);
};
}, []);
if (loading && !data) {
return <div className="font-mono text-xs text-[#5a6678] animate-pulse">indexer status</div>;
}
if (!data) return null;
if (data.db_error) {
return (
<div className="border border-[rgba(255,107,107,0.30)] bg-[rgba(255,107,107,0.05)] rounded p-4 text-xs space-y-1">
<div className="text-[#ff6b6b] font-mono uppercase tracking-widest">database offline</div>
<code className="text-[#8896aa] text-[10px]">{data.db_error}</code>
<p className="text-[#8896aa]">
start Postgres + embed-service (compose), then apply migrations.
</p>
</div>
);
}
const db = data.db!;
const gap = data.gap!;
return (
<section className="space-y-3">
<h3 className="font-mono text-xs uppercase tracking-widest text-[#7fdbff]">
retrieval index Postgres + pgvector
</h3>
<div className="grid grid-cols-3 gap-2 text-xs font-mono">
<Card label="docs" lhs={data.disk.docs_on_disk} rhs={db.documents_count} gap={gap.docs_to_index} />
<Card
label="chunks"
lhs={data.disk.chunks_on_disk}
rhs={db.chunks_count}
gap={gap.chunks_to_index}
/>
<Card
label="embedded"
lhs={db.chunks_count}
rhs={db.chunks_with_embedding}
gap={gap.chunks_without_embedding}
/>
</div>
<div className="grid grid-cols-2 gap-2 text-xs font-mono">
<div className="border border-[rgba(0,255,156,0.15)] bg-[#0a121e] rounded p-2">
<div className="text-[9px] uppercase text-[#5a6678]">entidades canônicas</div>
<div className="text-[#a78bfa] mt-0.5">{db.entities_count.toLocaleString()}</div>
</div>
<div className="border border-[rgba(0,255,156,0.15)] bg-[#0a121e] rounded p-2">
<div className="text-[9px] uppercase text-[#5a6678]">entity_mentions</div>
<div className="text-[#7fdbff] mt-0.5">{db.entity_mentions_count.toLocaleString()}</div>
</div>
</div>
<div
className={`text-xs font-mono p-2 rounded ${
gap.ready_for_retrieval
? "border border-[rgba(0,255,156,0.30)] bg-[rgba(0,255,156,0.05)] text-[#00ff9c]"
: "border border-[rgba(255,165,0,0.30)] bg-[rgba(255,165,0,0.05)] text-[#ffa500]"
}`}
>
{gap.ready_for_retrieval
? "✓ hybrid_search está OPERACIONAL — agente já pode usar"
: "⚠ aguardando embeddings — execute scripts/30-index-chunks-to-db.py para ativar retrieval"}
</div>
{(gap.docs_to_index > 0 || gap.chunks_to_index > 0) && (
<details className="text-xs">
<summary className="cursor-pointer font-mono text-[#7fdbff] hover:text-[#00ff9c]">
próximos comandos
</summary>
<pre className="mt-2 p-2 bg-[#060a13] border border-[rgba(0,255,156,0.12)] rounded text-[10px] text-[#c8d4e6] overflow-x-auto">
{`# index chunks → Postgres + BGE-M3 embeddings
export DATABASE_URL='postgres://...'
export EMBED_SERVICE_URL='http://embed:8000'
python3 scripts/30-index-chunks-to-db.py --skip-existing
# materialize entity_mentions (links chunk entity)
python3 scripts/31-populate-entity-mentions.py`}
</pre>
</details>
)}
</section>
);
}
function Card({
label,
lhs,
rhs,
gap,
}: {
label: string;
lhs: number;
rhs: number;
gap: number;
}) {
const ok = gap === 0;
return (
<div
className={`border rounded p-2 ${
ok
? "border-[rgba(0,255,156,0.30)] bg-[rgba(0,255,156,0.04)]"
: "border-[rgba(255,165,0,0.30)] bg-[rgba(255,165,0,0.04)]"
}`}
>
<div className="text-[9px] uppercase text-[#5a6678]">{label}</div>
<div className="flex items-baseline gap-1 mt-0.5">
<span className="text-[#c8d4e6]">{lhs.toLocaleString()}</span>
<span className="text-[#5a6678]">/</span>
<span className={ok ? "text-[#00ff9c]" : "text-[#ffa500]"}>{rhs.toLocaleString()}</span>
</div>
{!ok && (
<div className="text-[9px] text-[#ffa500] mt-0.5">gap {gap.toLocaleString()}</div>
)}
</div>
);
}