@@ -952,6 +952,12 @@ function handleHashRoute() {
952952 }
953953}
954954async 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+
9761172function 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+ } ) ;
16361847const debouncedAreaSearch = debounce ( ( ) => {
16371848 try {
16381849 const val = $ ( "#area-search" ) ? $ ( "#area-search" ) . value : "" ;
0 commit comments