diff --git a/projects/ops-desk/frontend/assets/agentic-ops.js b/projects/ops-desk/frontend/assets/agentic-ops.js index 334f6ac..062294c 100644 --- a/projects/ops-desk/frontend/assets/agentic-ops.js +++ b/projects/ops-desk/frontend/assets/agentic-ops.js @@ -273,6 +273,16 @@ return { list, openAgents, counts, filtered, bySev, sel }; } + function incidentsFingerprint(data) { + return (data.incidents?.incidents || []) + .map((i) => `${i.id}:${i.status}:${i.severity}:${i.last_seen_at || ''}`) + .join('|'); + } + + function timelineFingerprint(timeline) { + return JSON.stringify(timeline?.ticks || []); + } + function agentStatus(id, openAgents) { if (openAgents.has(id)) return { cls: 'ao-squad-status--alert', label: 'alerta' }; if (state.selectedAgent === id) return { cls: 'ao-squad-status--active', label: 'activo' }; @@ -691,18 +701,36 @@ patchHtml(el.querySelector('#ao-pane-activity'), activityFeedHtml()); } - function patchDashboard(el, data) { + function patchDashboard(el, data, opts = {}) { + const { soft = false } = opts; const view = deriveView(data); const open = data.overview.incidents_open || {}; const dot = el.querySelector('#ao-live-dot'); if (dot) dot.className = `ao-live-dot ${liveDotClass(open)}`.trim(); patchHtml(el.querySelector('#ao-stat-chips'), chipsHtml(data.overview, open)); - patchHtml(el.querySelector('#ao-subnav'), subnavHtml()); - patchHtml(el.querySelector('#ao-squad-grid'), squadHtml(data, view)); - patchHtml(el.querySelector('#ao-kanban'), kanbanHtml(view)); - patchHtml(el.querySelector('#ao-timeline'), timelineHtml(data.timeline)); - patchHtml(el.querySelector('#ao-task-board'), taskBoardHtml(data)); + + const incFp = incidentsFingerprint(data); + const tickFp = timelineFingerprint(data.timeline); + const boardChanged = !soft || incFp !== state._aoIncidentsFp; + const timelineChanged = !soft || tickFp !== state._aoTimelineFp; + + if (boardChanged) { + patchHtml(el.querySelector('#ao-subnav'), subnavHtml()); + patchHtml(el.querySelector('#ao-squad-grid'), squadHtml(data, view)); + patchHtml(el.querySelector('#ao-kanban'), kanbanHtml(view)); + state._aoIncidentsFp = incFp; + } + + if (timelineChanged) { + patchHtml(el.querySelector('#ao-timeline'), timelineHtml(data.timeline)); + state._aoTimelineFp = tickFp; + } + + if (!soft) { + patchHtml(el.querySelector('#ao-task-board'), taskBoardHtml(data)); + patchSubViewPanes(el, data); + } const boardLabel = el.querySelector('#ao-board-label'); if (boardLabel) { @@ -711,8 +739,9 @@ : 'Mission Board'; } - patchSubViewPanes(el, data); - updateSubViews(el); + if (!soft) { + updateSubViews(el); + } updateMainTabs(el); updateCommsChrome(el, view.sel); updateMobileTabs(el); @@ -1215,7 +1244,7 @@ bindEventsOnce(el); } - const view = patchDashboard(el, data); + const view = patchDashboard(el, data, { soft }); if (saved) restoreInputs(el, saved); if (state.threadId && state.chatModalOpen) { diff --git a/projects/ops-desk/frontend/assets/app.js b/projects/ops-desk/frontend/assets/app.js index 5e0d8f1..29b585d 100644 --- a/projects/ops-desk/frontend/assets/app.js +++ b/projects/ops-desk/frontend/assets/app.js @@ -286,21 +286,38 @@ function reschedulePoll() { async function loadHealth() { const el = document.getElementById('global-health'); + if (!el) return null; try { - const h = await api('/health'); - el.className = 'status-pill ok'; - el.innerHTML = ' API online'; - return h; + await api('/health'); + const html = ' API online'; + if (!el.classList.contains('ok') || el.innerHTML !== html) { + el.className = 'status-pill ok'; + el.innerHTML = html; + } + return true; } catch { - el.className = 'status-pill err'; - el.innerHTML = ' API offline'; + const html = ' API offline'; + if (!el.classList.contains('err') || el.innerHTML !== html) { + el.className = 'status-pill err'; + el.innerHTML = html; + } return null; } } -async function renderDashboard() { +/** Evita flash "Carregando…" quando o poll refresca uma vista já montada */ +function viewHasContent(el, selector) { + return Boolean(el?.querySelector(selector)); +} + +async function renderDashboard(options = {}) { + const { poll = false } = options; const box = document.getElementById('dashboard-content'); - box.innerHTML = '
Carregando…
'; + if (!viewHasContent(box, '.dashboard-top')) { + box.innerHTML = 'Carregando…
'; + } else if (poll) { + box.classList.add('view--refreshing'); + } try { const leadsPromise = canReadLeads() ? api('/v1/crm/leads').catch(() => ({ leads: [], total: 0 })) @@ -425,10 +442,10 @@ async function renderDashboard() { }); } catch (e) { box.innerHTML = `Erro: ${esc(e.message)}
`; + } finally { + box?.classList.remove('view--refreshing'); } } - -function sourceBadge(src) { if (src === 'desk-registration') return 'desk'; if (src === 'wazuh') return 'wazuh'; if (src === 'vm112-onboard') return 'onboard'; @@ -1713,9 +1730,14 @@ async function openUserAccessModal() { } } -async function renderOverview() { +async function renderOverview(options = {}) { + const { poll = false } = options; const el = document.getElementById('overview-content'); - el.innerHTML = 'Carregando overview…
'; + if (!viewHasContent(el, '.health-grid')) { + el.innerHTML = 'Carregando overview…
'; + } else if (poll) { + el.classList.add('view--refreshing'); + } try { const secPromise = window.DeskModules?.isEnabled('wizard-security') ? api('/v1/security/summary?window_hours=24').catch(() => null) @@ -1766,6 +1788,8 @@ async function renderOverview() { }); } catch (e) { el.innerHTML = `Erro: ${esc(e.message)}
`; + } finally { + el?.classList.remove('view--refreshing'); } } @@ -1883,13 +1907,18 @@ async function renderOverviewHome(options = {}) { el.innerHTML = 'Módulo Serviços não carregado.
'; } -async function renderLeads() { +async function renderLeads(options = {}) { + const { poll = false } = options; const el = document.getElementById('leads-content'); if (!canReadLeads()) { el.innerHTML = 'Sem permissão para ver leads
'; return; } - el.innerHTML = 'Carregando leads…
'; + if (!viewHasContent(el, '.lead-grid')) { + el.innerHTML = 'Carregando leads…
'; + } else if (poll) { + el.classList.add('view--refreshing'); + } try { const data = await api('/v1/crm/leads'); const leads = data.leads || []; @@ -1916,6 +1945,8 @@ async function renderLeads() { }); } catch (e) { el.innerHTML = `Erro: ${esc(e.message)}
`; + } finally { + el?.classList.remove('view--refreshing'); } } @@ -2111,7 +2142,8 @@ async function updateTicketStatus(status) { await renderTickets(); } -async function renderEvents() { +async function renderEvents(options = {}) { + const { poll = false } = options; syncEventsToolbar(); if (state.eventsTab === 'purges') { await renderPurgeHistory(); @@ -2126,7 +2158,11 @@ async function renderEvents() { return; } const el = document.getElementById('events-content'); - el.innerHTML = 'Carregando eventos…
'; + if (!viewHasContent(el, '.table-wrap')) { + el.innerHTML = 'Carregando eventos…
'; + } else if (poll) { + el.classList.add('view--refreshing'); + } try { const srcQ = state.eventSourceFilter !== 'all' ? `?source=${state.eventSourceFilter}` : ''; const data = await api(`/v1/webhooks/events${srcQ}`); @@ -2151,6 +2187,8 @@ async function renderEvents() { `; } catch (e) { el.innerHTML = `Erro: ${esc(e.message)}
`; + } finally { + el?.classList.remove('view--refreshing'); } } @@ -4110,9 +4148,14 @@ function openInfraProcessDetail(procId) { } } -async function renderInfra() { +async function renderInfra(options = {}) { + const { poll = false } = options; const el = document.getElementById('infra-content'); - el.innerHTML = 'Verificando stack…
'; + if (!viewHasContent(el, '.infra-vm-section')) { + el.innerHTML = 'Verificando stack…
'; + } else if (poll) { + el.classList.add('view--refreshing'); + } try { const [stack, integrations, health, vm123Health, purgeMeta] = await Promise.all([ api('/v1/infra/stack/status'), @@ -4169,10 +4212,12 @@ async function renderInfra() { ${sections} ${integrationsCard ? `Erro: ${esc(e.message)}
`; + } finally { + el?.classList.remove('view--refreshing'); } } @@ -4284,20 +4329,25 @@ async function refresh(options = {}) { if (poll && state.view === 'account') { return; } - if (state.view === 'dashboard') await renderDashboard(); + // Agentic Ops tem poll próprio — evita duplo refresh e flash "Carregando…" + if (poll && state.view === 'agentic-ops') { + return; + } + const p = poll ? { poll: true } : {}; + if (state.view === 'dashboard') await renderDashboard(p); if (state.view === 'email-migration' && window.DeskEmailMigration?.renderPage) await window.DeskEmailMigration.renderPage(); - if (state.view === 'overview') await renderOverview(); + if (state.view === 'overview') await renderOverview(p); if (state.view === 'overview-home') await renderOverviewHome({ poll }); - if (state.view === 'leads') await renderLeads(); + if (state.view === 'leads') await renderLeads(p); if (state.view === 'tickets') { if (poll && window.TicketsWorkspace?._pageReady) await TicketsWorkspace.softRefresh(); else await renderTickets({ poll: false }); } - if (state.view === 'events') await renderEvents(); + if (state.view === 'events') await renderEvents(p); if (state.view === 'tenants') await renderTenants(); - if (state.view === 'infra') await renderInfra(); + if (state.view === 'infra') await renderInfra(p); if (state.view === 'infra2') await renderInfra2({ poll }); - if (state.view === 'agentic-ops' && window.renderAgenticOps) await window.renderAgenticOps(); + if (state.view === 'agentic-ops' && window.renderAgenticOps) await window.renderAgenticOps(p); if (state.view === 'messages') await renderMessages(); if (state.view === 'admin') await renderAdmin(); if (state.view === 'access-matrix' && window.renderAccessMatrix) await window.renderAccessMatrix(); diff --git a/projects/ops-desk/frontend/assets/styles.css b/projects/ops-desk/frontend/assets/styles.css index d7bb23c..923a844 100644 --- a/projects/ops-desk/frontend/assets/styles.css +++ b/projects/ops-desk/frontend/assets/styles.css @@ -1302,6 +1302,13 @@ th { color: var(--muted); font-weight: 600; font-size: 0.75rem; text-transform: .hidden { display: none !important; } .loading { color: var(--muted); font-size: 0.9rem; padding: 1rem; } +/* Poll suave — sem flash "Carregando…" */ +.view--refreshing { + opacity: 0.94; + transition: opacity 0.25s ease; + pointer-events: none; +} + .infra-card { display: flex; align-items: center; diff --git a/projects/ops-desk/frontend/index.html b/projects/ops-desk/frontend/index.html index 5b7fbe0..d54d6e3 100644 --- a/projects/ops-desk/frontend/index.html +++ b/projects/ops-desk/frontend/index.html @@ -4,7 +4,7 @@