272 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
}
|