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>
164 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|