Skip to content

Commit 4821519

Browse files
committed
feat(monitoramento): map-first workspace with attention, recent events, FR24 summary (#14)
Replace the contents of #view-dashboard (id stable per #13) with a map-first Monitoramento workspace. New loadMonitoramento() orchestrator composes a single Promise.all over /api/status, /api/events?limit=100, /api/fr24/status, and /api/fr24/clusters — no double-fetch. New openEvents(filter) tiny DOM helper routes through the existing tab activation; filter is set BEFORE the synthetic .click() so the existing loadReviews() reads the right value with no race. Manual sync / poll / test-email stay on Monitoramento per the issue's explicit guard until #19 moves them atomically. loadStatus() refactored: keeps phase / version / warnings / metrics, drops the embedded /api/events fetch, returns the status payload so the orchestrator can compose without a second round-trip. Existing callers at app.js:1058, 1071, 1147, 1330, 1486, 1512, 1535 are unchanged. Map is static SVG (no leaflet, no pan/zoom, no new dependencies). Equirectangular projection reused from fr24CoverageMinimap. Each event dot has <title> + aria-label carrying the review status text so color is never the only status signal. Color tokens map unreviewed / useful / uncertain / noise to --burgundy / --forest / --amber / --muted respectively — all existing tokens. init() now awaits loadMonitoramento() after loadStatus() so the post-login Monitoramento view is fully populated on first paint (no manual refresh required). #refresh and the language toggle dashboard branch both route to loadMonitoramento(). CSS uses only existing tokens. New VM test test_monitoramento_vm.mjs covers 32 checks across 12 scenarios including regression guards for the lat/lng projection bug and the 'Ver todos em Eventos' button wire (discovered during Sonnet review round 1). The 'monitoramento_fr24_summary' i18n key is added to the legitimately_same whitelist in test_i18n.py (brand name 'FR24' is identical in both languages). Scope per issue #14: no backend changes, no router, no new endpoints, no new pyproject deps, no consecutive_sync_failures read, no openSettingsSection dependency. Carries the lane's known visual polish gaps to #22.
1 parent f6782ad commit 4821519

7 files changed

Lines changed: 1079 additions & 35 deletions

File tree

app/i18n.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,32 @@
225225
"warn_no_areas_selected": "Nenhuma área protegida selecionada.",
226226
"warn_airplanes_limit": "Airplanes.live exigiria cerca de {n} requisições/dia; o app impõe o limite de 500.",
227227
"warn_earliest_stop": "Com o intervalo atual, a confirmação de pouso provável mais cedo é de aproximadamente {n} minutos.",
228-
# Error messages
228+
229+
# Monitoramento (issue #14)
230+
"monitoramento_title": "Monitoramento",
231+
"monitoramento_attention": "Atenção",
232+
"monitoramento_attention_detail": "aguardando revisão",
233+
"monitoramento_view_all_events": "Ver todos em Eventos",
234+
"monitoramento_recent_activity": "Atividade recente",
235+
"monitoramento_recent_empty": "Nenhum evento nas últimas horas",
236+
"monitoramento_fr24_summary": "FR24",
237+
"monitoramento_fr24_details_link": "Detalhes em FR24",
238+
"monitoramento_fr24_credits": "Créditos usados",
239+
"monitoramento_fr24_of_budget": "do orçamento",
240+
"monitoramento_fr24_state": "Estado",
241+
"monitoramento_fr24_state_active": "Ativo",
242+
"monitoramento_fr24_state_disabled": "Desativado",
243+
"monitoramento_fr24_state_paused": "Pausado",
244+
"monitoramento_empty": "Nenhum cluster configurado",
245+
"monitoramento_map_aria": "Mapa de áreas monitoradas e eventos recentes",
246+
"monitoramento_event_unreviewed": "Aguardando revisão",
247+
"monitoramento_event_useful": "Útil",
248+
"monitoramento_event_uncertain": "Incerto",
249+
"monitoramento_event_noise": "Ruído",
250+
"monitoramento_refresh": "Atualizar status",
251+
"monitoramento_actions": "Operações",
252+
"monitoramento_sync_failed": "Falha na sincronização de limites",
253+
"monitoramento_poll_failed": "Falha na consulta de voo",
229254
"err_sync_running": "Sincronização de limites já em andamento",
230255
"err_poll_running": "Ciclo de cobertura já em andamento",
231256
"err_poll_running_process": "Ciclo de cobertura já em andamento em outro processo",
@@ -345,6 +370,11 @@
345370
"fr24_active_clusters": "Clusters ativos",
346371
"fr24_budget_state": "Estado do orçamento",
347372
"fr24_credits_used": "Créditos usados",
373+
"fr24_budget_state_normal": "Saudável",
374+
"fr24_budget_state_warning": "Atenção",
375+
"fr24_budget_state_critical": "Crítico",
376+
"fr24_budget_state_hard_limit": "Limite rígido",
377+
"fr24_budget_state_exhausted": "Esgotado",
348378
"fr24_baseline": "Base (tudo vazio)",
349379
"fr24_projected": "Projeção do ciclo",
350380
"fr24_insufficient_data": "Dados insuficientes",
@@ -629,7 +659,7 @@
629659
"settings_prev": "Previous",
630660
"settings_next": "Next",
631661
"time_at": " at ",
632-
# Timezone labels
662+
633663
"tz_brasilia": "Brasília time (UTC-3)",
634664
"tz_manaus": "Manaus (UTC-4)",
635665
"tz_belem": "Belém (UTC-3)",
@@ -652,6 +682,31 @@
652682
"warn_no_areas_selected": "No protected areas are selected.",
653683
"warn_airplanes_limit": "Airplanes.live would require about {n} requests/day; the app enforces its 500-request daily ceiling.",
654684
"warn_earliest_stop": "With the current interval, the earliest probable-stop confirmation is roughly {n} minutes.",
685+
# Monitoramento (issue #14)
686+
"monitoramento_title": "Monitoring",
687+
"monitoramento_attention": "Attention",
688+
"monitoramento_attention_detail": "awaiting review",
689+
"monitoramento_view_all_events": "View all in Events",
690+
"monitoramento_recent_activity": "Recent activity",
691+
"monitoramento_recent_empty": "No events in the last few hours",
692+
"monitoramento_fr24_summary": "FR24",
693+
"monitoramento_fr24_details_link": "FR24 details",
694+
"monitoramento_fr24_credits": "Credits used",
695+
"monitoramento_fr24_of_budget": "of budget",
696+
"monitoramento_fr24_state": "State",
697+
"monitoramento_fr24_state_active": "Active",
698+
"monitoramento_fr24_state_disabled": "Disabled",
699+
"monitoramento_fr24_state_paused": "Paused",
700+
"monitoramento_empty": "No cluster configured",
701+
"monitoramento_map_aria": "Map of monitored areas and recent events",
702+
"monitoramento_event_unreviewed": "Awaiting review",
703+
"monitoramento_event_useful": "Useful",
704+
"monitoramento_event_uncertain": "Uncertain",
705+
"monitoramento_event_noise": "Noise",
706+
"monitoramento_refresh": "Refresh status",
707+
"monitoramento_actions": "Operations",
708+
"monitoramento_sync_failed": "Boundary sync failed",
709+
"monitoramento_poll_failed": "Flight query failed",
655710
# Error messages
656711
"err_sync_running": "Boundary sync already running",
657712
"err_poll_running": "Coverage cycle already running",
@@ -752,6 +807,12 @@
752807
"fr24_budget_state": "Budget state",
753808
"fr24_credits_used": "Credits used",
754809
"fr24_baseline": "All-empty baseline",
810+
811+
"fr24_budget_state_normal": "Normal",
812+
"fr24_budget_state_warning": "Warning",
813+
"fr24_budget_state_critical": "Critical",
814+
"fr24_budget_state_hard_limit": "Hard limit",
815+
"fr24_budget_state_exhausted": "Exhausted",
755816
"fr24_projected": "Projected this cycle",
756817
"fr24_insufficient_data": "Insufficient data",
757818
"fr24_retention_title": "Data retention",

app/static/app.js

Lines changed: 218 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -952,6 +952,12 @@ function handleHashRoute() {
952952
}
953953
}
954954
async function loadStatus() {
955+
// Lightweight global status loader (issue #14): keeps phase / version /
956+
// warnings / metrics, drops the embedded /api/events fetch and the
957+
// #dashboard-events table rendering. Existing callers at app.js:1058,
958+
// 1071, 1147, 1330, 1486, 1512, 1535 use this for global-status refresh
959+
// only; they continue to work unchanged. Returns the status payload so
960+
// the Monitoramento orchestrator can compose without a second round-trip.
955961
const container = $("#dashboard-events");
956962
return withLoading(null, container, async () => {
957963
const status = await api("/api/status");
@@ -966,13 +972,203 @@ async function loadStatus() {
966972
metric(t("metric_events"), status.events.total, `${status.events.review.useful || 0} ${t("metric_reviewed")}`),
967973
metric(t("metric_last_poll"), status.latest_poll?.success ? t("metric_healthy") : t("metric_not_ready"), status.latest_poll?.completed_at ? formatTime(status.latest_poll.completed_at) : t("metric_no_poll")),
968974
].join("");
969-
const recent = await api("/api/events?limit=20");
970-
$("#dashboard-events").innerHTML = recent.events.length
971-
? recent.events.map(eventRow).join("")
972-
: `<tr><td colspan="7" class="muted">${t("no_events")}</td></tr>`;
975+
return status;
973976
});
974977
}
975978

979+
// ---- Monitoramento (issue #14) -------------------------------------------
980+
// Orchestrator + renderers for the redesigned dashboard surface.
981+
// `loadMonitoramento()` composes loadStatus() with the events, FR24 status,
982+
// and FR24 clusters endpoints. `openEvents(filter)` is the tiny cross-section
983+
// helper used by the attention card and "Ver todos em Eventos".
984+
985+
async function loadMonitoramento() {
986+
const container = $("#view-dashboard");
987+
return withLoading(null, container, async () => {
988+
const [status, eventsPayload, fr24Status, fr24Clusters] = await Promise.all([
989+
loadStatus(),
990+
api("/api/events?limit=100").catch(() => ({ events: [] })),
991+
api("/api/fr24/status").catch(() => ({})),
992+
api("/api/fr24/clusters").catch(() => ({ clusters: [] })),
993+
]);
994+
renderMonitoramentoMap(eventsPayload.events || [], fr24Clusters.clusters || [], fr24Status);
995+
renderMonitoramentoRecent(eventsPayload.events || []);
996+
renderMonitoramentoAttention(status);
997+
renderMonitoramentoFr24(fr24Status);
998+
});
999+
}
1000+
1001+
function openEvents(filter = "") {
1002+
// Set the filter BEFORE the synthetic .click() so by the time the existing
1003+
// tab-activation handler at app.js:1573 reads #review-filter.value inside
1004+
// loadReviews(), the filter is already in place. The handler invokes
1005+
// loadReviews() itself; we do not call it again.
1006+
const select = $("#review-filter");
1007+
if (select) select.value = filter;
1008+
appState.reviewsOffset = 0;
1009+
const tab = $("#tab-events");
1010+
if (tab) tab.click();
1011+
}
1012+
1013+
function renderMonitoramentoMap(events, clusters, _fr24Status) {
1014+
const map = $("#monitoramento-map");
1015+
const empty = $("#monitoramento-map-empty");
1016+
if (!map) return;
1017+
const recent = (events || []).filter(
1018+
(e) => Number.isFinite(e.latitude) && Number.isFinite(e.longitude),
1019+
);
1020+
if (!clusters.length && !recent.length) {
1021+
map.innerHTML = "";
1022+
if (empty) {
1023+
empty.hidden = false;
1024+
empty.textContent = t("monitoramento_empty");
1025+
}
1026+
return;
1027+
}
1028+
if (empty) empty.hidden = true;
1029+
const bbox = monitoramentoUnionBbox(clusters, recent);
1030+
const { west, east, south, north } = bbox;
1031+
const lonSpan = east - west || 0.0001;
1032+
const latSpan = north - south || 0.0001;
1033+
const width = 520;
1034+
const kx = Math.cos(((north + south) / 2) * (Math.PI / 180)) || 1;
1035+
const height = Math.max(140, Math.min(360, Math.round(width * (latSpan / (lonSpan * kx)))));
1036+
const pad = 8;
1037+
const px = (lon) => pad + ((lon - west) / lonSpan) * (width - 2 * pad);
1038+
const py = (lat) => pad + ((north - lat) / latSpan) * (height - 2 * pad);
1039+
const ringPath = (ring) =>
1040+
ring.map(([lon, lat], i) => `${i ? "L" : "M"}${px(lon).toFixed(2)} ${py(lat).toFixed(2)}`).join("") + "Z";
1041+
const polys = (clusters || []).flatMap((cluster) => {
1042+
const fc = cluster.coverage_geojson;
1043+
if (!fc || !Array.isArray(fc.features)) return [];
1044+
return fc.features
1045+
.filter((f) => f.properties && f.properties.role === "area")
1046+
.map((f) => {
1047+
const path = f.geometry?.type === "Polygon"
1048+
? f.geometry.coordinates.map(ringPath).join("")
1049+
: f.geometry?.type === "MultiPolygon"
1050+
? f.geometry.coordinates.map((poly) => poly.map(ringPath).join("")).join("")
1051+
: "";
1052+
if (!path) return "";
1053+
return `<path class="minimap-area" d="${path}"><title>${escapeHtml(f.properties.name || "")}</title></path>`;
1054+
});
1055+
});
1056+
const boundsRects = (clusters || []).map((cluster) => {
1057+
const b = fr24ClusterNumericBounds(cluster);
1058+
if (!b) return "";
1059+
const x = px(b.west).toFixed(2);
1060+
const y = py(b.north).toFixed(2);
1061+
const w = (px(b.east) - px(b.west)).toFixed(2);
1062+
const h = (py(b.south) - py(b.north)).toFixed(2);
1063+
return `<rect class="minimap-bounds" x="${x}" y="${y}" width="${w}" height="${h}"><title>${escapeHtml(cluster.name || "")}</title></rect>`;
1064+
});
1065+
const statusLabels = {
1066+
unreviewed: t("monitoramento_event_unreviewed"),
1067+
useful: t("monitoramento_event_useful"),
1068+
uncertain: t("monitoramento_event_uncertain"),
1069+
noise: t("monitoramento_event_noise"),
1070+
};
1071+
const dots = recent.map((event) => {
1072+
const cx = px(event.longitude).toFixed(2);
1073+
const cy = py(event.latitude).toFixed(2);
1074+
const label = statusLabels[event.review_status] || event.review_status || "";
1075+
const title = `${formatTime(event.occurred_at)} · ${label}`;
1076+
return `<a href="#/events/${encodeURIComponent(event.id)}" class="event-dot event-dot-${escapeHtml(event.review_status || "unreviewed")}" aria-label="${escapeHtml(title)}"><title>${escapeHtml(title)}</title><circle cx="${cx}" cy="${cy}" r="4"/></a>`;
1077+
}).join("");
1078+
map.innerHTML = `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(t("monitoramento_map_aria"))}"><g class="monitoramento-areas">${polys.join("")}</g><g class="monitoramento-bounds">${boundsRects.join("")}</g><g class="monitoramento-events">${dots}</g></svg>`;
1079+
}
1080+
1081+
function monitoramentoUnionBbox(clusters, events) {
1082+
let west = Infinity, east = -Infinity, south = Infinity, north = -Infinity;
1083+
for (const cluster of clusters || []) {
1084+
const b = fr24ClusterNumericBounds(cluster);
1085+
if (!b) continue;
1086+
if (b.west < west) west = b.west;
1087+
if (b.east > east) east = b.east;
1088+
if (b.south < south) south = b.south;
1089+
if (b.north > north) north = b.north;
1090+
}
1091+
for (const event of events || []) {
1092+
if (!Number.isFinite(event.latitude) || !Number.isFinite(event.longitude)) continue;
1093+
if (event.longitude < west) west = event.longitude;
1094+
if (event.longitude > east) east = event.longitude;
1095+
if (event.latitude < south) south = event.latitude;
1096+
if (event.latitude > north) north = event.latitude;
1097+
}
1098+
if (!Number.isFinite(west)) {
1099+
return { west: -75, east: -34, south: -35, north: 6 };
1100+
}
1101+
const lonPad = (east - west) * 0.05 || 0.5;
1102+
const latPad = (north - south) * 0.05 || 0.5;
1103+
return {
1104+
west: west - lonPad,
1105+
east: east + lonPad,
1106+
south: south - latPad,
1107+
north: north + latPad,
1108+
};
1109+
}
1110+
1111+
function renderMonitoramentoRecent(events) {
1112+
const tbody = $("#monitoramento-recent-events");
1113+
if (!tbody) return;
1114+
const top5 = (events || []).slice(0, 5);
1115+
tbody.innerHTML = top5.length
1116+
? top5.map(eventRow).join("")
1117+
: `<tr><td colspan="7" class="muted">${escapeHtml(t("monitoramento_recent_empty"))}</td></tr>`;
1118+
}
1119+
1120+
function renderMonitoramentoAttention(status) {
1121+
const countEl = $("#monitoramento-attention-count");
1122+
const count = status?.events?.review?.unreviewed ?? 0;
1123+
if (countEl) countEl.textContent = String(count);
1124+
const trigger = $("#monitoramento-attention-btn");
1125+
if (trigger) trigger.onclick = () => openEvents("unreviewed");
1126+
}
1127+
1128+
function renderMonitoramentoFr24(fr24Status) {
1129+
const tiles = $("#monitoramento-fr24-tiles");
1130+
const blockers = $("#monitoramento-fr24-blockers");
1131+
if (!tiles || !blockers) return;
1132+
// /api/fr24/status returns credits_used_this_cycle, operating_budget,
1133+
// budget_state, projected_end_of_cycle_credits, blockers, enabled.
1134+
// See app/main.py:924 fr24_status().
1135+
const used = fr24Status?.credits_used_this_cycle ?? 0;
1136+
const budget = fr24Status?.operating_budget ?? 0;
1137+
const pct = budget > 0 ? Math.round((used / budget) * 100) : 0;
1138+
const rawState = fr24Status?.budget_state;
1139+
const stateKey = rawState
1140+
? `fr24_budget_state_${String(rawState).toLowerCase()}`
1141+
: "fr24_budget_state_active";
1142+
const stateLabel = t(stateKey) !== stateKey ? t(stateKey) : (rawState || "—");
1143+
const projected = fr24Status?.projected_end_of_cycle_credits;
1144+
const projectedNote = projected === null || projected === undefined
1145+
? (fr24Status?.billing_cycle_id || "")
1146+
: `${fr24Status?.billing_cycle_id || ""} · proj. ${Math.round(projected)}`;
1147+
tiles.innerHTML = [
1148+
metric(t("monitoramento_fr24_credits"), String(used), `${pct}% ${t("monitoramento_fr24_of_budget")}`),
1149+
metric(t("monitoramento_fr24_state"), stateLabel, projectedNote),
1150+
].join("");
1151+
// Blockers list — the four known codes (flag_disabled, missing_api_key,
1152+
// no_enabled_clusters, budget_exhausted_paused) all have i18n keys at
1153+
// app/i18n.py:336-339. translateFr24Blocker handles both the known set
1154+
// and any future unknown code by falling back to the raw string.
1155+
const codes = fr24Status?.blockers || [];
1156+
blockers.innerHTML = codes.length
1157+
? `<ul>${codes.map((c) => `<li>${escapeHtml(translateFr24Blocker(c))}</li>`).join("")}</ul>`
1158+
: "";
1159+
}
1160+
1161+
// Blocker translator — same keys as the FR24 admin surface at app.js:579
1162+
// (fr24_blocker_flag_disabled / missing_api_key / no_enabled_clusters /
1163+
// budget_exhausted_paused). Falls back to the raw code for unknown values.
1164+
function translateFr24Blocker(code) {
1165+
if (!code) return "";
1166+
const key = `fr24_blocker_${String(code).toLowerCase()}`;
1167+
const translated = t(key);
1168+
if (translated !== key) return translated;
1169+
return String(code);
1170+
}
1171+
9761172
function areaFeedback(message) {
9771173
const box = $("#area-error");
9781174
if (!box) return;
@@ -1509,7 +1705,9 @@ async function init() {
15091705
showApp();
15101706
// Settings first: formatTime in loadStatus needs appState.timezone set.
15111707
await loadSettings();
1512-
await loadStatus();
1708+
// loadMonitoramento() composes loadStatus() as its status building
1709+
// block, so a second loadStatus() here would be redundant.
1710+
await loadMonitoramento();
15131711
handleHashRoute();
15141712
try { fr24WizardInit(); } catch {}
15151713
try { initSettingsStepper(); } catch {}
@@ -1555,7 +1753,8 @@ $("#lang-toggle").addEventListener("click", async () => {
15551753
const activeTab = $(".tab.active");
15561754
if (activeTab) {
15571755
const view = activeTab.dataset.view;
1558-
if (view === "dashboard") await loadStatus();
1756+
if (view === "dashboard") await loadMonitoramento();
1757+
15591758
if (view === "areas") await loadAreas();
15601759
if (view === "events") await loadReviews();
15611760
if (view === "settings") await loadSettings();
@@ -1598,6 +1797,7 @@ $$(".tab").forEach((tab) => {
15981797
const c = containerMap[tab.dataset.view];
15991798
if (c) try { c.setAttribute("aria-busy", "true"); } catch {}
16001799
try {
1800+
if (tab.dataset.view === "dashboard") await loadMonitoramento();
16011801
if (tab.dataset.view === "areas") await loadAreas();
16021802
if (tab.dataset.view === "events") await loadReviews();
16031803
if (tab.dataset.view === "settings") await loadSettings();
@@ -1627,12 +1827,23 @@ $$(".tab").forEach((tab) => {
16271827
$("#sync-now").addEventListener("click", (event) => runAction(event.currentTarget, t("action_syncing"), "/api/boundaries/sync"));
16281828
$("#poll-now").addEventListener("click", (event) => runAction(event.currentTarget, t("action_polling"), "/api/poll"));
16291829
$("#test-email").addEventListener("click", (event) => runAction(event.currentTarget, t("action_testing_email"), "/api/email/test"));
1630-
$("#refresh").addEventListener("click", (event) => withLoading(event.currentTarget, $("#dashboard-events"), loadStatus));
1830+
$("#refresh").addEventListener("click", (event) => withLoading(event.currentTarget, $("#view-dashboard"), loadMonitoramento));
16311831
$("#area-filter").addEventListener("click", (event) => {
16321832
appState.areaFilter = { search: $("#area-search").value, category: $("#area-category").value, selected: $("#area-selected").value };
16331833
appState.areasOffset = 0;
16341834
withLoading(event.currentTarget, $("#areas-body"), loadAreas);
16351835
});
1836+
// Ver todos em Eventos button on Monitoramento (issue #14).
1837+
$("#monitoramento-view-all-events")?.addEventListener("click", (event) => {
1838+
event.preventDefault();
1839+
openEvents("");
1840+
});
1841+
// it still exists (issue #18 will rewire to openSettingsSection("fr24")).
1842+
$("#monitoramento-fr24-details")?.addEventListener("click", (event) => {
1843+
event.preventDefault();
1844+
const tab = $("#tab-fr24");
1845+
if (tab) tab.click();
1846+
});
16361847
const debouncedAreaSearch = debounce(() => {
16371848
try {
16381849
const val = $("#area-search") ? $("#area-search").value : "";

0 commit comments

Comments
 (0)