disclosure-bureau/web/components/entity-attributes.tsx
Luiz Gustavo 55cac8a395
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 1m30s
CI / Scripts — Python smoke (push) Failing after 32s
CI / Web — npm audit (push) Failing after 37s
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 18:18:42 -03:00

164 lines
5.2 KiB
TypeScript

/**
* EntityAttributes — renders an entity's descriptive content and structured
* attributes straight from its wiki frontmatter. The generated entity files
* carry their real content in YAML fields (narrative_summary_*, maneuver_notes,
* shape, color, roles, countries, …) while the markdown body holds only empty
* "## Description" headings — so the page must surface the frontmatter.
*/
type FM = Record<string, unknown>;
const ATTR_LABELS: Record<string, string> = {
event_class: "Tipo de evento",
date_start: "Início",
date_end: "Fim",
date_confidence: "Confiança da data",
primary_location_names: "Locais",
primary_location_geo_classes: "Classe do local",
geo_class: "Classe geográfica",
countries: "Países",
regions_or_states: "Regiões / estados",
org_class: "Tipo de organização",
person_class: "Tipo de pessoa",
affiliations: "Afiliações",
roles: "Funções / papéis",
shape: "Forma",
color: "Cor",
medium: "Meio",
size_estimate_m: "Tamanho estimado (m)",
altitude_ft: "Altitude (ft)",
speed_kts: "Velocidade (kt)",
};
// Order in which attributes are shown (only those present render).
const ATTR_ORDER = [
"event_class",
"person_class",
"org_class",
"shape",
"color",
"medium",
"size_estimate_m",
"altitude_ft",
"speed_kts",
"date_start",
"date_end",
"date_confidence",
"geo_class",
"countries",
"regions_or_states",
"primary_location_names",
"primary_location_geo_classes",
"affiliations",
"roles",
];
function clean(v: unknown): string | null {
const s = typeof v === "string" ? v.trim() : "";
return s && s.toLowerCase() !== "null" ? s : null;
}
// Placeholder values that carry no real attribute information — hidden from the
// ATRIBUTOS grid (but never from the free-text description).
const EMPTY_TOKENS = new Set([
"null",
"none",
"n/a",
"na",
"unknown",
"unidentified",
"undetermined",
"unspecified",
"not specified",
"not stated",
"not applicable",
]);
function isEmptyToken(s: string): boolean {
return EMPTY_TOKENS.has(s.trim().toLowerCase());
}
function fmtValue(v: unknown): string | null {
if (v == null) return null;
if (Array.isArray(v)) {
const items = v
.map((x) => (typeof x === "string" ? x.trim() : String(x)))
.filter((x) => x && !x.startsWith("[[") && !isEmptyToken(x));
return items.length ? items.join(", ") : null;
}
if (typeof v === "number") return String(v);
const s = clean(v);
return s && !isEmptyToken(s) ? s : null;
}
export function EntityAttributes({ fm }: { fm: FM }) {
const ptText = clean(fm.narrative_summary_pt_br) ?? clean(fm.description_pt_br);
const enText = clean(fm.narrative_summary_en) ?? clean(fm.description_en);
const notes = clean(fm.maneuver_notes); // source-language only (uap_object)
const attrs = ATTR_ORDER.map((k) => [k, fmtValue(fm[k])] as const).filter(
([, v]) => v !== null,
);
const hasDescription = Boolean(ptText || enText || notes);
if (!hasDescription && attrs.length === 0) return null;
return (
<section className="mb-10">
{hasDescription && (
<>
{ptText && (
<div className="mb-4">
<h2 className="font-mono text-sm text-[#7fdbff] uppercase tracking-widest mb-2 border-l-2 border-[#7fdbff] pl-3">
Descrição (PT-BR)
</h2>
<p className="text-[15px] leading-relaxed text-[#c8d4e6]">{ptText}</p>
</div>
)}
{enText && (
<div className="mb-4">
<h2 className="font-mono text-sm text-[#7fdbff] uppercase tracking-widest mb-2 border-l-2 border-[#7fdbff] pl-3">
Description (EN)
</h2>
<p className="text-[15px] leading-relaxed text-[#c8d4e6]">{enText}</p>
</div>
)}
{notes && !ptText && !enText && (
<div className="mb-4">
<h2 className="font-mono text-sm text-[#7fdbff] uppercase tracking-widest mb-2 border-l-2 border-[#7fdbff] pl-3">
Descrição · Description
</h2>
<p className="text-[15px] leading-relaxed text-[#c8d4e6]">{notes}</p>
</div>
)}
{notes && (ptText || enText) && (
<div className="mb-4">
<h3 className="font-mono text-[11px] text-[#8896aa] uppercase tracking-widest mb-1">
Notas de manobra / aparência
</h3>
<p className="text-sm leading-relaxed text-[#8896aa]">{notes}</p>
</div>
)}
</>
)}
{attrs.length > 0 && (
<div className="mt-2">
<h3 className="font-mono text-[11px] text-[#8896aa] uppercase tracking-widest mb-2">
Atributos
</h3>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2">
{attrs.map(([k, v]) => (
<div key={k} className="flex items-baseline gap-2 border-b border-[rgba(127,219,255,0.10)] pb-1.5">
<dt className="font-mono text-[11px] text-[#5a6678] uppercase tracking-wide shrink-0 min-w-[42%]">
{ATTR_LABELS[k] ?? k}
</dt>
<dd className="text-sm text-[#c8d4e6]">{v}</dd>
</div>
))}
</dl>
</div>
)}
</section>
);
}