disclosure-bureau/web/components/anomaly-highlights.tsx

136 lines
4.8 KiB
TypeScript
Raw Permalink Normal View History

W0+W1+W1.2: security hardening, observability, autocomplete, glitchtip, forgejo CI W0 — security hardening (5 fixes verified live on disclosure.top) - middleware: gate /api/admin/* same as /admin/* (F1) - imgproxy: tighten LOCAL_FILESYSTEM_ROOT from / to /var/lib/storage (F2) - studio: real basic-auth label (bcrypt hash, middleware reference) (F3) - relations: ENABLE ROW LEVEL SECURITY + public SELECT policy (F4) - migration 0003: fold is_searchable + hybrid_search update into canonical (TD#2) W1 — observability + resilience + autocomplete - studio: HOSTNAME=0.0.0.0 so Next.js binds on loopback for healthcheck - compose: PG_POOL_MAX=20, CLAUDE_CODE_OAUTH_TOKEN gated by separate env - claude-code.ts: subprocess timeout configurable (CLAUDE_CODE_TIMEOUT_MS) - openrouter.ts: retry with exponential backoff + Retry-After + in-memory circuit breaker (promotes FALLBACK after CB_THRESHOLD failures) - lib/logger.ts: pino logger (NDJSON prod / pretty dev) + withRequest helper - middleware: mints correlation_id, stamps x-correlation-id response header, emits structured http_request log per /api/* call - messages/route.ts: switch to structured logger - 60_meili_index.py: push documents + chunks into Meilisearch - /api/search/autocomplete: parallel meili search (docs + chunks), 5-8ms p50 - search-autocomplete.tsx: debounced dropdown wired into search-panel W1.2 — Glitchtip + Forgejo self-hosted - compose: glitchtip-redis + glitchtip-web + glitchtip-worker (v4.2) - compose: forgejo + forgejo-runner (server v9, runner v6) with group_add=988 - @sentry/nextjs SDK wired (instrumentation.ts + sentry.{client,server}.config.ts) - /api/admin/throw smoke endpoint (gated by W0-F1 middleware) - Synthetic event ingestion verified at glitchtip.disclosure.top - forgejo.disclosure.top up, repo discadmin/disclosure-bureau created, runner registered (labels: ubuntu-latest, docker) - .forgejo/workflows/ci.yml: typecheck + lint + build + npm audit + python syntax + compose validation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:18:42 +00:00
/**
* AnomalyHighlights prominent UAP / cryptid anomaly panel for the document
* page. The clean reading version is the default body, but the investigative
* "destaque" of every flagged passage must stay visible regardless of which
* view (reading or scan) is active. Identical type+rationale flags are grouped
* and each group links to the per-page scan where the anomaly was detected.
*/
import Link from "next/link";
export interface AnomalyFlag {
chunk_id: string;
page: number;
type: string | null;
rationale: string | null;
}
function clean(v: string | null): string | null {
const s = typeof v === "string" ? v.trim() : "";
return s && s.toLowerCase() !== "null" ? s : null;
}
interface Group {
type: string | null;
rationale: string | null; // shown only when the group has a single flag
count: number;
pages: number[];
}
// Group by anomaly type so the panel stays a scannable "destaque" overview.
// Per-passage rationale is kept only when a type has exactly one flag; the full
// per-chunk rationale remains available in the "trechos · scan original" view.
function groupFlags(flags: AnomalyFlag[]): Group[] {
const m = new Map<string, Group>();
for (const f of flags) {
const type = clean(f.type);
const rationale = clean(f.rationale);
const key = type ?? "anomalia";
const g = m.get(key) ?? { type, rationale, count: 0, pages: [] };
g.count += 1;
g.rationale = g.count === 1 ? rationale : null;
if (!g.pages.includes(f.page)) g.pages.push(f.page);
m.set(key, g);
}
return Array.from(m.values())
.map((g) => ({ ...g, pages: g.pages.sort((a, b) => a - b) }))
.sort((a, b) => b.count - a.count || a.pages[0] - b.pages[0]);
}
function pad(p: number): string {
return String(p).padStart(3, "0");
}
function PageChips({ docId, pages }: { docId: string; pages: number[] }) {
const shown = pages.slice(0, 14);
const extra = pages.length - shown.length;
return (
<span className="inline-flex flex-wrap gap-1 align-middle">
{shown.map((p) => (
<Link
key={p}
href={`/d/${docId}/p${pad(p)}`}
className="font-mono text-[10px] px-1.5 py-0.5 border border-[rgba(127,219,255,0.30)] text-[#7fdbff] rounded hover:border-[#00ff9c] hover:text-[#00ff9c]"
>
p{p}
</Link>
))}
{extra > 0 && <span className="font-mono text-[10px] text-[#5a6678]">+{extra}</span>}
</span>
);
}
export function AnomalyHighlights({
docId,
ufo,
cryptid,
}: {
docId: string;
ufo: AnomalyFlag[];
cryptid: AnomalyFlag[];
}) {
if (ufo.length === 0 && cryptid.length === 0) return null;
const ufoGroups = groupFlags(ufo);
const cryptidGroups = groupFlags(cryptid);
return (
<section className="mb-6 border border-[rgba(0,255,156,0.40)] bg-[rgba(0,255,156,0.05)] rounded p-4">
{ufo.length > 0 && (
<>
<h2 className="font-mono text-sm text-[#00ff9c] mb-3 flex items-center gap-2">
🛸 Anomalias UAP destacadas
<span className="text-[#5a6678]">
({ufo.length} {ufo.length === 1 ? "trecho" : "trechos"} · {ufoGroups.length}{" "}
{ufoGroups.length === 1 ? "tipo" : "tipos"})
</span>
</h2>
<ul className="space-y-2.5">
{ufoGroups.map((g, i) => (
<li key={i} className="text-sm text-[#c8d4e6] leading-relaxed">
<span className="font-mono text-[#00ff9c]">🛸 {g.type ?? "anomalia"}</span>
{g.count > 1 && (
<span className="font-mono text-[10px] text-[#5a6678]"> ×{g.count}</span>
)}
{g.rationale && <span className="text-[#c8d4e6]"> {g.rationale}</span>}{" "}
<PageChips docId={docId} pages={g.pages} />
</li>
))}
</ul>
</>
)}
{cryptid.length > 0 && (
<div className={ufo.length > 0 ? "mt-4 pt-4 border-t border-[rgba(155,93,229,0.25)]" : ""}>
<h2 className="font-mono text-sm text-[#9b5de5] mb-3 flex items-center gap-2">
👁 Anomalias cryptid destacadas
<span className="text-[#5a6678]">
({cryptid.length} {cryptid.length === 1 ? "trecho" : "trechos"})
</span>
</h2>
<ul className="space-y-2.5">
{cryptidGroups.map((g, i) => (
<li key={i} className="text-sm text-[#c8d4e6] leading-relaxed">
<span className="font-mono text-[#9b5de5]">👁 {g.type ?? "anomalia"}</span>
{g.count > 1 && (
<span className="font-mono text-[10px] text-[#5a6678]"> ×{g.count}</span>
)}
{g.rationale && <span className="text-[#c8d4e6]"> {g.rationale}</span>}{" "}
<PageChips docId={docId} pages={g.pages} />
</li>
))}
</ul>
</div>
)}
</section>
);
}