disclosure-bureau/web/components/stats-dashboard.tsx

272 lines
9.2 KiB
TypeScript

/**
* StatsDashboard — corpus-wide analytics rendered from /api/admin/stats.
*/
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
interface FsStats {
documents_total: number;
documents_rebuilt_v2: number;
pages_total: number;
chunks_on_disk: number;
redactions_total: number;
collections: Record<string, number>;
document_class: Record<string, number>;
content_classification: Record<string, number>;
entity_counts: Record<string, number>;
entities_total: number;
}
interface DbStats {
ok: boolean;
error?: string;
core?: { docs: number; chunks: number; entities: number; mentions: number };
chunk_types?: Array<{ type: string; count: number }>;
classifications?: Array<{ classification: string | null; count: number }>;
top_docs_by_chunks?: Array<{ doc_id: string; count: number }>;
ufo_anomaly_types?: Array<{ anomaly_type: string | null; count: number }>;
cryptid_count?: number;
embedded_count?: number;
}
const CLASS_COLOR: Record<string, string> = {
people: "#ff6ec7",
organizations: "#ff8a4d",
locations: "#3fde6a",
events: "#ffa500",
"uap-objects": "#ff3344",
vehicles: "#5b9bd5",
operations: "#9b5de5",
concepts: "#06d6a0",
};
export function StatsDashboard() {
const [fs, setFs] = useState<FsStats | null>(null);
const [db, setDb] = useState<DbStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/admin/stats")
.then((r) => r.json())
.then((j: { fs: FsStats; db: DbStats }) => {
setFs(j.fs);
setDb(j.db);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
if (loading) {
return <div className="font-mono text-xs text-[#5a6678] animate-pulse">carregando</div>;
}
if (!fs) {
return <div className="font-mono text-xs text-[#ff6b6b]">stats indisponível</div>;
}
return (
<div className="space-y-10">
{/* Top-line counters */}
<section className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Stat label="documentos" value={fs.documents_total.toLocaleString()} accent="#00ff9c" />
<Stat label="páginas" value={fs.pages_total.toLocaleString()} accent="#7fdbff" />
<Stat label="entidades" value={fs.entities_total.toLocaleString()} accent="#a78bfa" />
<Stat
label="docs indexados"
value={`${fs.documents_rebuilt_v2}/${fs.documents_total}`}
accent="#ffa500"
/>
<Stat label="chunks no disco" value={fs.chunks_on_disk.toLocaleString()} accent="#3fde6a" />
<Stat
label="redactions detectadas"
value={fs.redactions_total.toLocaleString()}
accent="#ff6b6b"
/>
{db?.ok && db.core && (
<>
<Stat
label="chunks na DB"
value={db.core.chunks.toLocaleString()}
accent={db.core.chunks > 0 ? "#00ff9c" : "#5a6678"}
/>
<Stat
label="entity_mentions"
value={db.core.mentions.toLocaleString()}
accent={db.core.mentions > 0 ? "#7fdbff" : "#5a6678"}
/>
</>
)}
</section>
{/* Entities by class */}
<section>
<h2 className="font-mono text-sm text-[#7fdbff] uppercase tracking-widest mb-3 border-l-2 border-[#7fdbff] pl-3">
entidades por classe
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(fs.entity_counts).map(([cls, n]) => {
const color = CLASS_COLOR[cls] ?? "#7fdbff";
return (
<Link
key={cls}
href={`/e/${cls}`}
className="block p-3 border rounded hover:scale-[1.02] transition"
style={{ borderColor: `${color}40`, background: `${color}08` }}
>
<div className="font-mono text-[10px] uppercase tracking-widest" style={{ color }}>
{cls}
</div>
<div className="font-mono text-xl mt-1" style={{ color }}>
{n.toLocaleString()}
</div>
</Link>
);
})}
</div>
</section>
{/* Collections breakdown */}
<section className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Histogram
title="collections"
color="#00ff9c"
data={Object.entries(fs.collections).sort((a, b) => b[1] - a[1])}
/>
<Histogram
title="document_class"
color="#7fdbff"
data={Object.entries(fs.document_class).sort((a, b) => b[1] - a[1])}
/>
</section>
{/* Content classification */}
<section>
<h2 className="font-mono text-sm text-[#7fdbff] uppercase tracking-widest mb-3 border-l-2 border-[#7fdbff] pl-3">
conteúdo das páginas
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-2">
{Object.entries(fs.content_classification)
.sort((a, b) => b[1] - a[1])
.map(([tag, n]) => (
<div
key={tag}
className="px-3 py-2 border border-[rgba(127,219,255,0.20)] rounded font-mono text-xs"
>
<div className="text-[10px] text-[#5a6678]">{tag}</div>
<div className="text-[#c8d4e6] mt-0.5">{n.toLocaleString()}</div>
</div>
))}
</div>
</section>
{/* DB-derived */}
{db?.ok ? (
<>
{(db.ufo_anomaly_types?.length ?? 0) > 0 && (
<section>
<h2 className="font-mono text-sm text-[#7fdbff] uppercase tracking-widest mb-3 border-l-2 border-[#ff3344] pl-3">
🛸 anomalias UFO detectadas · {db.ufo_anomaly_types?.reduce((s, r) => s + r.count, 0)} chunks
</h2>
<Histogram
title=""
color="#ff3344"
data={(db.ufo_anomaly_types ?? []).map((r) => [r.anomaly_type ?? "(sem tipo)", r.count])}
/>
</section>
)}
{(db.top_docs_by_chunks?.length ?? 0) > 0 && (
<section>
<h2 className="font-mono text-sm text-[#7fdbff] uppercase tracking-widest mb-3 border-l-2 border-[#ffa500] pl-3">
top 10 docs por chunks
</h2>
<ul className="space-y-1">
{db.top_docs_by_chunks!.map((d) => (
<li key={d.doc_id} className="flex items-center gap-2 text-xs font-mono">
<Link
href={`/d/${d.doc_id}`}
className="text-[#7fdbff] hover:text-[#00ff9c] truncate flex-1"
>
{d.doc_id}
</Link>
<span className="text-[#00ff9c]">{d.count.toLocaleString()} chunks</span>
</li>
))}
</ul>
</section>
)}
{(db.chunk_types?.length ?? 0) > 0 && (
<section>
<h2 className="font-mono text-sm text-[#7fdbff] uppercase tracking-widest mb-3 border-l-2 border-[#a78bfa] pl-3">
tipos de chunk
</h2>
<Histogram
title=""
color="#a78bfa"
data={(db.chunk_types ?? []).map((r) => [r.type, r.count])}
/>
</section>
)}
</>
) : (
<div className="text-xs font-mono text-[#5a6678] border border-[rgba(255,165,0,0.30)] bg-[rgba(255,165,0,0.05)] rounded p-3">
DB stats indisponíveis rode o indexer:{" "}
<code className="text-[#ffa500]">python3 scripts/30-index-chunks-to-db.py</code>
{db?.error && (
<div className="mt-1 text-[#8896aa]">erro: {db.error}</div>
)}
</div>
)}
</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>
);
}
function Histogram({
title,
color,
data,
}: {
title: string;
color: string;
data: [string, number][];
}) {
if (data.length === 0) return null;
const max = Math.max(...data.map(([, n]) => n));
return (
<div>
{title && (
<h3 className="font-mono text-xs uppercase tracking-widest text-[#5a6678] mb-2">{title}</h3>
)}
<ul className="space-y-1">
{data.map(([k, v]) => (
<li key={k} className="flex items-center gap-2 font-mono text-xs">
<span className="text-[#c8d4e6] truncate flex-1" title={k}>
{k}
</span>
<span className="text-[#5a6678] tabular-nums w-12 text-right">
{v.toLocaleString()}
</span>
<div
className="h-2 rounded"
style={{
width: `${(v / max) * 100}%`,
maxWidth: 200,
background: color,
opacity: 0.6,
}}
/>
</li>
))}
</ul>
</div>
);
}