feat(console): v2 home Fase 1 — KPI, saúde, incidentes e rollback
Unifica Overview Desk na home do Console com KPI bar, health cards, domínios com atenção e strip Agentic; banners de migração no Desk; documentação e rollback CONSOLE-V2 para VM123. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
935ceda4e0
commit
af58565999
17 changed files with 732 additions and 135 deletions
13
BACKLOG.md
13
BACKLOG.md
|
|
@ -1,6 +1,6 @@
|
|||
# Backlog — Ligbox Ops Platform (VM122)
|
||||
|
||||
**Última atualização:** 2026-06-19 (Specs **026–032** purge extra-auth)
|
||||
**Última atualização:** 2026-06-25 (Console v2 F1 · Spec 019)
|
||||
**Projeto:** `ligbox-ops-platform`
|
||||
**VM122:** `ligbox-ops` · `10.10.10.122` · SSH WAN `:2522`
|
||||
**VM112:** Portal/Wizard — integração **API + webhooks** (fora do compose)
|
||||
|
|
@ -92,6 +92,16 @@
|
|||
|
||||
---
|
||||
|
||||
## Backlog futuro (P2–P3)
|
||||
|
||||
| # | Feature | Prioridade | Estado | Notas |
|
||||
|---|---------|------------|--------|-------|
|
||||
| **035** | `ligbox-watchman` | **P0** | 📋 | Vigilância contínua + alertas ntfy — incidente Desk 502 |
|
||||
| **034** | `nextcloud-carbonio-vm112` | P2 | 📋 | Integração Nextcloud — Roger adiou Fase 0 |
|
||||
| **036** | `backstage-techdocs-speckit-companion` | **P3** | 📋 | Backstage+TechDocs CT130 + Spec Kit Companion LAPTOP — spec escrita, **sem implementação** |
|
||||
|
||||
---
|
||||
|
||||
## Track A — Auditoria & Ops Dashboard
|
||||
|
||||
| ID | P | Item | Estado |
|
||||
|
|
@ -100,6 +110,7 @@
|
|||
| **OPS-2** | P0 | `docker-compose.mvp.yml` | ✅ |
|
||||
| **OPS-3** | P0 | `tenant-registry` (VM112 = 1º nó) | ✅ |
|
||||
| **OPS-7** | P1 | VM123 finance stack (Spec 024) | ✅ |
|
||||
| **OPS-8** | P1 | Console v2 F1 — home unificada `console.ligbox.com.br` (Spec 019) | ✅ 2026-06-25 |
|
||||
| **AUD-1** | P0 | Collectors: Carbonio, DNS, nginx | 🔄 parcial |
|
||||
| **AUD-2** | P0 | UI `/ops/overview` + API scorecard | 🔄 parcial |
|
||||
| **AUD-3** | P1 | Scorecard por domínio (8 checks) | 🔄 |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# VM123 — inventário Ops Console
|
||||
|
||||
**Data:** 2026-06-17
|
||||
**Data:** 2026-06-25 (actualizado Console v2 F1)
|
||||
**Host:** `vm123-finance` · `10.10.10.123`
|
||||
|
||||
## Serviços existentes (não alterados)
|
||||
|
|
@ -23,7 +23,9 @@
|
|||
| Container | `ligbox-ops-console-ui` |
|
||||
| Porta | `0.0.0.0:8100` (LAN — Traefik CT114) |
|
||||
| Health | `curl http://10.10.10.123:8100/health` → `ok` |
|
||||
| URL pública | `https://console.ligbox.com.br` → **✅ G1 2026-06-25** |
|
||||
| URL pública | `https://console.ligbox.com.br` → **✅ G1 + v2 F1 2026-06-25** |
|
||||
| Versão UI | Console v2 Fase 1 — KPI, Saúde, Incidentes, Domínios |
|
||||
| Rollback | `specs/019-.../deploy/CONSOLE-V2-ROLLBACK.md` |
|
||||
| Rede Docker | `ligbox-console` (bridge dedicada) |
|
||||
| Imagem | `ligbox/ops-console:latest` |
|
||||
|
||||
|
|
|
|||
|
|
@ -4557,6 +4557,21 @@ button.health-card {
|
|||
.migration-gate-ready { color: #22c55e; }
|
||||
.migration-gate-blocked { color: #ef4444; }
|
||||
.migration-gate-warning { color: #f59e0b; }
|
||||
|
||||
/* Console v2 migration banners (Desk → console.ligbox.com.br) */
|
||||
.console-v2-banner {
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.65rem 1rem;
|
||||
background: linear-gradient(90deg, #e6f4ff 0%, #f0f7ff 100%);
|
||||
border: 1px solid #91caff;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: #1a1c21;
|
||||
}
|
||||
.console-v2-banner a {
|
||||
color: #006bb4;
|
||||
font-weight: 600;
|
||||
}
|
||||
.billing-modal-backdrop { position: fixed; inset: 0; background: #0009; z-index: 900; display: flex; align-items: center; justify-content: center; }
|
||||
.billing-modal { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; max-width: 480px; width: 90%; }
|
||||
.ticket-card-aside {
|
||||
|
|
|
|||
|
|
@ -275,10 +275,19 @@
|
|||
</header>
|
||||
|
||||
<section id="view-dashboard" class="view">
|
||||
<div class="console-v2-banner" role="note">
|
||||
<strong>Console v2</strong> — KPIs e ranking técnico migram para
|
||||
<a href="https://console.ligbox.com.br" target="_blank" rel="noopener noreferrer">console.ligbox.com.br</a>.
|
||||
Este painel permanece até cutover Fase 4.
|
||||
</div>
|
||||
<div id="dashboard-content"><p class="loading">Carregando…</p></div>
|
||||
</section>
|
||||
|
||||
<section id="view-overview" class="view">
|
||||
<div class="console-v2-banner" role="note">
|
||||
<strong>Saúde migrada</strong> — scorecard e domínios com atenção estão no
|
||||
<a href="https://console.ligbox.com.br" target="_blank" rel="noopener noreferrer">Console unificado</a>.
|
||||
</div>
|
||||
<div id="overview-content"><p class="loading">Carregando…</p></div>
|
||||
</section>
|
||||
|
||||
|
|
@ -291,6 +300,10 @@
|
|||
</section>
|
||||
|
||||
<section id="view-tickets" class="view">
|
||||
<div class="console-v2-banner" role="note">
|
||||
<strong>Chamados CH-*</strong> — investigação unificada em
|
||||
<a href="https://console.ligbox.com.br/chamados" target="_blank" rel="noopener noreferrer">console.ligbox.com.br/chamados</a>.
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="filter-btn active" data-filter="all">Todos</button>
|
||||
<button type="button" class="filter-btn" data-filter="active">Activos</button>
|
||||
|
|
@ -312,6 +325,10 @@
|
|||
</section>
|
||||
|
||||
<section id="view-events" class="view">
|
||||
<div class="console-v2-banner" role="note">
|
||||
<strong>Discover</strong> — feed de eventos Wazuh/onboard em
|
||||
<a href="https://console.ligbox.com.br/discover" target="_blank" rel="noopener noreferrer">console.ligbox.com.br/discover</a>.
|
||||
</div>
|
||||
<div class="toolbar" id="events-toolbar">
|
||||
<button type="button" class="filter-btn active" data-events-tab="webhooks">Webhooks</button>
|
||||
<button type="button" class="filter-btn" data-events-tab="purges" id="events-tab-purges" hidden>Histórico de purges</button>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
# Console v2 Fase 1 — Execução 2026-06-25
|
||||
|
||||
**Responsável:** Cursor Agent · **Aprovado por:** Roger
|
||||
|
||||
## Entregas
|
||||
|
||||
1. Home unificada em `console.ligbox.com.br/` (KPI → Alertas → Saúde → Incidentes → Domínios → Infra → Chamados)
|
||||
2. Componentes React: `KpiBar`, `HealthCards`, `DomainAttention`, `IncidentsStrip`, `ShortcutsRow`
|
||||
3. Loader `lib/console-home.js` com fallback mock + API JWT quando disponível
|
||||
4. Desk VM122: banners migração Dashboard/Overview/Eventos/Tickets
|
||||
5. Documentação + rollback `CONSOLE-V2-ROLLBACK.md`
|
||||
|
||||
## Backups pré-deploy
|
||||
|
||||
- VM123: `frontend.bak-v2-*`, tag Docker `ligbox/ops-console:pre-v2-YYYYMMDD`
|
||||
- Git: commit anterior documentado no rollback
|
||||
|
||||
## Validação
|
||||
|
||||
```bash
|
||||
curl -sS https://console.ligbox.com.br/health
|
||||
curl -sS https://console.ligbox.com.br/ | head -20
|
||||
```
|
||||
|
||||
## Pendente Fase 2+
|
||||
|
||||
- Rota `/ops` alias (opcional)
|
||||
- Assist panel live (T052)
|
||||
- API chamados real sem mock
|
||||
- Cutover 301 desk → console
|
||||
- Mission board `/ops/agentic`
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
# Console v2 — Rollback (Fase 1)
|
||||
|
||||
**Data:** 2026-06-25
|
||||
**Spec:** 019 · **Exec:** CONSOLE-V2-F1-20260625
|
||||
**Ambiente:** VM123 (`10.10.10.123`) · CT114 Traefik · `console.ligbox.com.br`
|
||||
|
||||
---
|
||||
|
||||
## O que mudou (Fase 1)
|
||||
|
||||
| Área | Alteração |
|
||||
|------|-----------|
|
||||
| Console home | KPI bar, Saúde plataforma (ex-Overview), Domínios com atenção, Incidentes Agentic, Atalhos |
|
||||
| Desk VM122 | Banners de migração em Dashboard, Overview, Eventos, Tickets |
|
||||
| Repo | `specs/019-.../deploy/frontend/src/*` + docs |
|
||||
|
||||
**Não alterado nesta fase:** CT114 Traefik, DNS, `/etc/network/interfaces`, VM112, rota `/ops` (home continua em `/`).
|
||||
|
||||
---
|
||||
|
||||
## Pré-requisitos rollback
|
||||
|
||||
Backups criados **antes** do deploy v2:
|
||||
|
||||
```bash
|
||||
ssh root@10.10.10.123
|
||||
ls -la /opt/ligbox-ops-console/frontend.bak-v2-*
|
||||
docker images | grep ops-console
|
||||
```
|
||||
|
||||
Git no CT130:
|
||||
|
||||
```bash
|
||||
cd /opt/ligbox-spec-hub/repos/ligbox-ops-platform
|
||||
git log --oneline -5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback rápido — só frontend VM123
|
||||
|
||||
```bash
|
||||
ssh root@10.10.10.123
|
||||
cd /opt/ligbox-ops-console
|
||||
BACKUP=$(ls -td frontend.bak-v2-* 2>/dev/null | head -1)
|
||||
if [ -n "$BACKUP" ]; then
|
||||
rm -rf frontend
|
||||
cp -a "$BACKUP" frontend
|
||||
fi
|
||||
docker compose build --no-cache && docker compose up -d
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8100/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback imagem Docker
|
||||
|
||||
```bash
|
||||
docker tag ligbox/ops-console:pre-v2-YYYYMMDD ligbox/ops-console:latest
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback código (CT130)
|
||||
|
||||
```bash
|
||||
cd /opt/ligbox-spec-hub/repos/ligbox-ops-platform
|
||||
git revert <commit-v2> --no-edit
|
||||
./portal/refresh-spec-driver.sh
|
||||
rsync -a specs/019-ops-console-active-operations/ \
|
||||
/opt/ligbox-spec-hub/obsidian-vault/ligbox-ops-platform/specs/019-ops-console-active-operations/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Referências
|
||||
|
||||
- G1 Traefik: `deploy/traefik/G1-ROLLBACK.md`
|
||||
- Inventário: `docs/network/VM123_INVENTARIO.md`
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function DomainAttention({ rows }) {
|
||||
if (!rows?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card table-wrap">
|
||||
<h3 style={{ marginTop: 0, fontSize: '0.95rem' }}>Domínios com atenção</h3>
|
||||
<p style={{ fontSize: '0.82rem', color: 'var(--lb-text-secondary)', marginTop: 0 }}>
|
||||
Issues do antigo Overview Desk — clique para abrir chamado ou investigar.
|
||||
</p>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domínio</th><th>Tenant</th><th>Check</th><th>Mensagem</th><th>Acção</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={`${r.tenant_id}-${r.domain}-${r.check_id}`}>
|
||||
<td><code>{r.domain}</code></td>
|
||||
<td>{r.tenant_name}</td>
|
||||
<td><code>{r.check_id}</code></td>
|
||||
<td>{r.message}</td>
|
||||
<td>
|
||||
<Link to="/chamados" className="btn" style={{ padding: '0.2rem 0.5rem', fontSize: '0.75rem' }}>
|
||||
Chamados
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
function statusClass(status) {
|
||||
if (status === 'healthy') return 'health-ok'
|
||||
if (status === 'degraded') return 'health-warn'
|
||||
if (status === 'critical') return 'health-bad'
|
||||
return 'health-unknown'
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
if (status === 'healthy') return 'Saudável'
|
||||
if (status === 'degraded') return 'Degradado'
|
||||
if (status === 'critical') return 'Crítico'
|
||||
return 'Desconhecido'
|
||||
}
|
||||
|
||||
export default function HealthCards({ tenants }) {
|
||||
if (!tenants?.length) {
|
||||
return (
|
||||
<div className="card">
|
||||
<p style={{ margin: 0, color: 'var(--lb-text-secondary)' }}>Nenhum tenant auditado. Aguarde ciclo de auditoria ou complete onboarding.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="health-grid">
|
||||
{tenants.map((t) => {
|
||||
const pass = t.score?.pass ?? 0
|
||||
const total = t.score?.total ?? 8
|
||||
const warn = t.score?.warn ?? 0
|
||||
const fail = t.score?.fail ?? 0
|
||||
const cls = statusClass(t.status)
|
||||
return (
|
||||
<div key={t.tenant_id || t.name} className={`health-card health-card--${cls}`}>
|
||||
<div className="health-card-head">
|
||||
<div>
|
||||
<h3>{t.name}</h3>
|
||||
<p className="health-card-meta">
|
||||
<code>{t.ip}</code>
|
||||
{' · '}
|
||||
<strong>{t.domains_count ?? 0}</strong> domínio(s)
|
||||
</p>
|
||||
</div>
|
||||
<span className={`health-badge health-badge--${cls}`}>{statusLabel(t.status)}</span>
|
||||
</div>
|
||||
<p className="health-score-line">
|
||||
{pass}/{total} pass · {warn} warn · {fail} fail
|
||||
</p>
|
||||
{t.kind === 'wazuh_soc' && t.alerts_count != null ? (
|
||||
<p className="health-card-meta">Alertas Wazuh: {t.alerts_count} · max L{t.max_level ?? '—'}</p>
|
||||
) : null}
|
||||
{t.top_issues?.length ? (
|
||||
<ul className="health-issue-list">
|
||||
{t.top_issues.slice(0, 3).map((i) => (
|
||||
<li key={`${i.domain}-${i.check_id}`}>
|
||||
<code>{i.domain}</code> · {i.check_id} — {i.message || i.status}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="health-card-meta">Sem issues críticas no último audit</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function IncidentsStrip({ incidents }) {
|
||||
if (!incidents?.length) return null
|
||||
|
||||
return (
|
||||
<section className="wz-section">
|
||||
<h2 className="wz-section-title">Incidentes abertos (Agentic Ops)</h2>
|
||||
<div className="incident-list">
|
||||
{incidents.map((inc) => (
|
||||
<Link
|
||||
key={inc.id || inc.scenario_id}
|
||||
to="/chamados"
|
||||
className={`incident-card incident-card--${inc.severity || 'warn'}`}
|
||||
>
|
||||
<div>
|
||||
<strong>{inc.title}</strong>
|
||||
<p>{inc.scenario_id} · {inc.severity} · visto {inc.occurrence_count ?? 1}×</p>
|
||||
</div>
|
||||
<span className="incident-cta">Investigar →</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<p className="page-lead" style={{ marginBottom: 0, fontSize: '0.8rem' }}>
|
||||
Mission board completo: <a href="https://desk.ligbox.com.br" target="_blank" rel="noreferrer">desk.ligbox.com.br</a> (migra para /ops/agentic)
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
|
||||
const KPI_CONFIG = [
|
||||
{ key: 'tickets_open', label: 'CH / tickets abertos', to: '/chamados', variant: 'blue' },
|
||||
{ key: 'tickets_escalated', label: 'Escalados', to: '/chamados', variant: 'amber' },
|
||||
{ key: 'wazuh_high', label: 'Wazuh L12+', to: '/discover?bucket=high', variant: 'red', compute: (s, alerts) => (alerts?.high || 0) + (alerts?.critical || 0) },
|
||||
{ key: 'webhook_events', label: 'Eventos 48h', to: '/discover', variant: 'slate', compute: (s) => s.webhook_events ?? '—' },
|
||||
]
|
||||
|
||||
export default function KpiBar({ summary, alertCounts }) {
|
||||
return (
|
||||
<div className="kpi-bar" role="region" aria-label="Indicadores operacionais">
|
||||
{KPI_CONFIG.map(({ key, label, to, variant, compute }) => {
|
||||
const value = compute ? compute(summary, alertCounts) : (summary?.[key] ?? '—')
|
||||
return (
|
||||
<Link key={key} to={to} className={`kpi-card kpi-card--${variant}`} title={label}>
|
||||
<span className="kpi-value">{value}</span>
|
||||
<span className="kpi-label">{label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
|
||||
const SHORTCUTS = [
|
||||
{ to: '/discover', icon: '🔍', title: 'Discover', desc: 'Feed unificado de eventos' },
|
||||
{ to: '/chamados', icon: '📋', title: 'Chamados', desc: 'Hub CH-* investigação' },
|
||||
{ to: '/discover?bucket=medium', icon: '🛡️', title: 'Alertas médios', desc: 'L7–L11 últimas 24h' },
|
||||
]
|
||||
|
||||
export default function ShortcutsRow() {
|
||||
return (
|
||||
<section className="wz-section">
|
||||
<h2 className="wz-section-title">Atalhos</h2>
|
||||
<div className="wz-card-grid">
|
||||
{SHORTCUTS.map((s) => (
|
||||
<Link key={s.to} to={s.to} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<div className="wz-feature-card">
|
||||
<div className="wz-feature-icon">{s.icon}</div>
|
||||
<div>
|
||||
<h3>{s.title}</h3>
|
||||
<p>{s.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import {
|
||||
MOCK_AUDIT_OVERVIEW,
|
||||
MOCK_CHAMADOS,
|
||||
MOCK_INCIDENTS,
|
||||
MOCK_SUMMARY,
|
||||
} from '../mock'
|
||||
import { apiGet } from './api'
|
||||
import { loadDiscoverEvents } from './events'
|
||||
import { aggregateWazuh24h } from './severity'
|
||||
|
||||
export async function loadConsoleHomeData() {
|
||||
let fromApi = false
|
||||
const result = {
|
||||
summary: { ...MOCK_SUMMARY },
|
||||
audit: { tenants: [...MOCK_AUDIT_OVERVIEW.tenants] },
|
||||
incidents: [...MOCK_INCIDENTS],
|
||||
chamados: [...MOCK_CHAMADOS],
|
||||
alertCounts: { critical: 0, high: 0, medium: 0, low: 0 },
|
||||
fromApi: false,
|
||||
}
|
||||
|
||||
const { events, fromApi: eventsApi } = await loadDiscoverEvents()
|
||||
result.alertCounts = aggregateWazuh24h(events)
|
||||
if (eventsApi) fromApi = true
|
||||
|
||||
try {
|
||||
const summary = await apiGet('/api/v1/desk/summary')
|
||||
if (summary) {
|
||||
result.summary = summary
|
||||
if (summary.recent_tickets?.length) {
|
||||
result.chamados = summary.recent_tickets.map((t) => ({
|
||||
public_id: t.chamado_public_id || `TK-${t.id}`,
|
||||
status: t.status,
|
||||
domain: t.domain || '—',
|
||||
max_severity: t.severity,
|
||||
}))
|
||||
}
|
||||
fromApi = true
|
||||
}
|
||||
} catch { /* mock */ }
|
||||
|
||||
try {
|
||||
const audit = await apiGet('/api/v1/audit/overview')
|
||||
if (audit?.tenants?.length) {
|
||||
result.audit = audit
|
||||
fromApi = true
|
||||
}
|
||||
} catch { /* mock */ }
|
||||
|
||||
try {
|
||||
const inc = await apiGet('/api/v1/agents/incidents?status=open&limit=8')
|
||||
if (inc?.incidents?.length) {
|
||||
result.incidents = inc.incidents
|
||||
fromApi = true
|
||||
}
|
||||
} catch { /* mock */ }
|
||||
|
||||
result.fromApi = fromApi
|
||||
return result
|
||||
}
|
||||
|
||||
/** Flatten top_issues from all audit tenants for domain attention list */
|
||||
export function collectDomainAttention(tenants, limit = 12) {
|
||||
const rows = []
|
||||
for (const t of tenants || []) {
|
||||
for (const issue of t.top_issues || []) {
|
||||
rows.push({
|
||||
tenant_id: t.tenant_id,
|
||||
tenant_name: t.name,
|
||||
domain: issue.domain,
|
||||
check_id: issue.check_id,
|
||||
message: issue.message || issue.status,
|
||||
status: issue.status,
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows.slice(0, limit)
|
||||
}
|
||||
|
|
@ -66,3 +66,91 @@ export const MOCK_AUDIT_TENANTS = [
|
|||
{ id: 'wazuh', label: 'Wazuh SOC', kind: 'wazuh_soc', score: 8 },
|
||||
{ id: 'demo', label: 'cenario-demo.ops.ligbox.com.br', kind: 'domain', score: 6, domain: 'cenario-demo.ops.ligbox.com.br' },
|
||||
]
|
||||
|
||||
export const MOCK_SUMMARY = {
|
||||
tickets_open: 4,
|
||||
tickets_escalated: 1,
|
||||
tickets_assisting: 2,
|
||||
tickets_closed: 18,
|
||||
webhook_events: 142,
|
||||
wazuh_events: 89,
|
||||
tenants: 4,
|
||||
billing_pending: 2,
|
||||
billing_active: 11,
|
||||
}
|
||||
|
||||
/** Formato /api/v1/audit/overview — espelha VM122 audit_store */
|
||||
export const MOCK_AUDIT_OVERVIEW = {
|
||||
generated_at: '2026-06-25T12:00:00Z',
|
||||
tenants: [
|
||||
{
|
||||
tenant_id: 1,
|
||||
name: 'VM112 Onboard / Carbonio',
|
||||
ip: '10.10.10.112',
|
||||
status: 'degraded',
|
||||
score: { pass: 7, warn: 1, fail: 0, total: 8 },
|
||||
domains_count: 3,
|
||||
last_audit_at: '2026-06-25T08:30:00Z',
|
||||
top_issues: [
|
||||
{ domain: 'myvexx.com', check_id: 'dmarc', message: 'DMARC policy none', status: 'warn' },
|
||||
{ domain: 'diarissima.com', check_id: 'tls_webmail', message: 'cert expira em 12d', status: 'warn' },
|
||||
],
|
||||
},
|
||||
{
|
||||
tenant_id: 2,
|
||||
name: 'VM122 Ligbox Ops',
|
||||
ip: '10.10.10.122',
|
||||
status: 'healthy',
|
||||
score: { pass: 8, warn: 0, fail: 0, total: 8 },
|
||||
domains_count: 2,
|
||||
last_audit_at: '2026-06-25T09:00:00Z',
|
||||
top_issues: [],
|
||||
},
|
||||
{
|
||||
tenant_id: 3,
|
||||
name: 'VM104 Wazuh SOC',
|
||||
ip: '10.10.10.104',
|
||||
kind: 'wazuh_soc',
|
||||
status: 'healthy',
|
||||
score: { pass: 8, warn: 0, fail: 0, total: 8 },
|
||||
alerts_count: 14,
|
||||
max_level: 9,
|
||||
domains_count: 0,
|
||||
top_issues: [],
|
||||
},
|
||||
{
|
||||
tenant_id: 4,
|
||||
name: 'cenario-demo',
|
||||
ip: '10.10.10.112',
|
||||
status: 'critical',
|
||||
score: { pass: 4, warn: 2, fail: 2, total: 8 },
|
||||
domains_count: 1,
|
||||
last_audit_at: '2026-06-24T22:00:00Z',
|
||||
top_issues: [
|
||||
{ domain: 'cenario-demo.ops.ligbox.com.br', check_id: 'mx', message: 'MX não resolve', status: 'fail' },
|
||||
{ domain: 'cenario-demo.ops.ligbox.com.br', check_id: 'spf', message: 'SPF missing', status: 'fail' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const MOCK_INCIDENTS = [
|
||||
{
|
||||
id: 1,
|
||||
scenario_id: 'desk.api.health',
|
||||
title: 'desk.api.health — HTTP 502 upstream',
|
||||
severity: 'critical',
|
||||
status: 'open',
|
||||
occurrence_count: 3,
|
||||
last_seen_at: '2026-06-25T11:40:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
scenario_id: 'onboard.dns.stale',
|
||||
title: 'Onboard DNS apply stale > 2h',
|
||||
severity: 'warn',
|
||||
status: 'open',
|
||||
occurrence_count: 1,
|
||||
last_seen_at: '2026-06-25T10:15:00Z',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -369,3 +369,138 @@ code {
|
|||
.tenant-health-ok { color: var(--lb-sev-low); font-weight: 600; }
|
||||
.tenant-health-warn { color: var(--lb-sev-med); font-weight: 600; }
|
||||
.tenant-health-bad { color: var(--lb-sev-high); font-weight: 600; }
|
||||
|
||||
/* Console v2 — KPI bar */
|
||||
.kpi-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
@media (max-width: 900px) { .kpi-bar { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
.kpi-card {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--lb-bg-card);
|
||||
border: 1px solid var(--lb-border);
|
||||
border-radius: var(--lb-radius);
|
||||
padding: 0.9rem 1rem;
|
||||
box-shadow: var(--lb-shadow);
|
||||
transition: border-color 0.15s, transform 0.1s;
|
||||
}
|
||||
.kpi-card:hover {
|
||||
border-color: var(--lb-accent);
|
||||
text-decoration: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.kpi-value {
|
||||
display: block;
|
||||
font-size: 1.65rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: var(--lb-text-heading);
|
||||
}
|
||||
.kpi-label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--lb-text-muted);
|
||||
margin-top: 0.35rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.kpi-card--red .kpi-value { color: var(--lb-sev-high); }
|
||||
.kpi-card--amber .kpi-value { color: var(--lb-sev-med); }
|
||||
.kpi-card--blue .kpi-value { color: var(--lb-accent); }
|
||||
|
||||
/* Health cards (Overview port) */
|
||||
.health-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.health-card {
|
||||
background: var(--lb-bg-card);
|
||||
border: 1px solid var(--lb-border);
|
||||
border-radius: var(--lb-radius);
|
||||
padding: 1rem 1.1rem;
|
||||
box-shadow: var(--lb-shadow);
|
||||
border-left: 4px solid var(--lb-border);
|
||||
}
|
||||
.health-card--health-ok { border-left-color: var(--lb-sev-low); }
|
||||
.health-card--health-warn { border-left-color: var(--lb-sev-med); }
|
||||
.health-card--health-bad { border-left-color: var(--lb-sev-high); }
|
||||
.health-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.health-card h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.health-card-meta {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--lb-text-secondary);
|
||||
}
|
||||
.health-score-line {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.health-badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
padding: 0.2rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.health-badge--health-ok { background: #e8f5e9; color: #2e7d32; }
|
||||
.health-badge--health-warn { background: #fff8e1; color: #f57c00; }
|
||||
.health-badge--health-bad { background: #fce8e6; color: var(--lb-sev-high); }
|
||||
.health-badge--health-unknown { background: var(--lb-bg-subtle); color: var(--lb-text-muted); }
|
||||
.health-issue-list {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--lb-text-secondary);
|
||||
}
|
||||
|
||||
/* Agentic incidents strip */
|
||||
.incident-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.incident-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--lb-bg-card);
|
||||
border: 1px solid var(--lb-border);
|
||||
border-radius: var(--lb-radius);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.incident-card:hover { border-color: var(--lb-accent); text-decoration: none; }
|
||||
.incident-card p {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--lb-text-secondary);
|
||||
}
|
||||
.incident-card--critical { border-left: 4px solid var(--lb-sev-critical); }
|
||||
.incident-card--warn { border-left: 4px solid var(--lb-sev-med); }
|
||||
.incident-cta {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--lb-accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +1,21 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import AlertBar from '../components/AlertBar'
|
||||
import { loadDiscoverEvents } from '../lib/events'
|
||||
import { aggregateWazuh24h } from '../lib/severity'
|
||||
import { MOCK_AUDIT_TENANTS, MOCK_CHAMADOS, MOCK_TENANTS } from '../mock'
|
||||
import DomainAttention from '../components/DomainAttention'
|
||||
import HealthCards from '../components/HealthCards'
|
||||
import IncidentsStrip from '../components/IncidentsStrip'
|
||||
import KpiBar from '../components/KpiBar'
|
||||
import ShortcutsRow from '../components/ShortcutsRow'
|
||||
import { collectDomainAttention, loadConsoleHomeData } from '../lib/console-home'
|
||||
import { MOCK_TENANTS } from '../mock'
|
||||
import { apiGet } from '../lib/api'
|
||||
|
||||
function FeatureSection({ title, children }) {
|
||||
return (
|
||||
<section className="wz-section">
|
||||
<h2 className="wz-section-title">{title}</h2>
|
||||
<div className="wz-card-grid">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureCard({ icon, title, description, to }) {
|
||||
const inner = (
|
||||
<div className="wz-feature-card">
|
||||
<div className="wz-feature-icon" aria-hidden="true">{icon}</div>
|
||||
<div>
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return to ? <Link to={to} style={{ textDecoration: 'none', color: 'inherit' }}>{inner}</Link> : inner
|
||||
}
|
||||
|
||||
function InfraTable({ tenants }) {
|
||||
return (
|
||||
<div className="card table-wrap">
|
||||
<h3 style={{ marginTop: 0, fontSize: '0.95rem' }}>Nós de infraestrutura</h3>
|
||||
<p style={{ fontSize: '0.82rem', color: 'var(--lb-text-secondary)', marginTop: 0 }}>
|
||||
Registo de VMs e serviços Ligbox (antigo menu Tenants).
|
||||
Registo de VMs e serviços Ligbox (antigo menu Tenants no Desk).
|
||||
</p>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
|
|
@ -57,131 +39,69 @@ function InfraTable({ tenants }) {
|
|||
)
|
||||
}
|
||||
|
||||
function AuditTenantCards({ auditTenants }) {
|
||||
if (!auditTenants.length) return null
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0, fontSize: '0.95rem' }}>Saúde por tenant / domínio</h3>
|
||||
<div className="wz-card-grid" style={{ marginTop: '0.75rem' }}>
|
||||
{auditTenants.map((t) => {
|
||||
const score = t.score ?? t.health_score
|
||||
const cls = score >= 8 ? 'tenant-health-ok' : score >= 5 ? 'tenant-health-warn' : 'tenant-health-bad'
|
||||
return (
|
||||
<div key={t.id || t.domain} className="wz-feature-card">
|
||||
<div className="wz-feature-icon">📧</div>
|
||||
<div>
|
||||
<h3>{t.label || t.domain || t.name}</h3>
|
||||
<p>
|
||||
{t.kind ? <span className="badge badge-onboard">{t.kind}</span> : null}{' '}
|
||||
<span className={cls}>{score != null ? `${score}/8 checks` : t.status || 'monitorizar'}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ConsoleHome() {
|
||||
const [infra, setInfra] = useState(MOCK_TENANTS)
|
||||
const [audit, setAudit] = useState(MOCK_AUDIT_TENANTS)
|
||||
const [alertCounts, setAlertCounts] = useState({ critical: 0, high: 0, medium: 0, low: 0 })
|
||||
const [usingMock, setUsingMock] = useState(true)
|
||||
const [home, setHome] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const { events, fromApi } = await loadDiscoverEvents()
|
||||
if (!cancelled) {
|
||||
setAlertCounts(aggregateWazuh24h(events))
|
||||
if (fromApi) setUsingMock(false)
|
||||
}
|
||||
const [tData, aData] = await Promise.all([
|
||||
apiGet('/api/v1/tenants').catch(() => null),
|
||||
apiGet('/api/v1/audit/overview').catch(() => null),
|
||||
])
|
||||
const data = await loadConsoleHomeData()
|
||||
if (cancelled) return
|
||||
if (tData?.tenants?.length) {
|
||||
setInfra(tData.tenants)
|
||||
setUsingMock(false)
|
||||
}
|
||||
if (aData?.tenants?.length) {
|
||||
setAudit(aData.tenants)
|
||||
setUsingMock(false)
|
||||
}
|
||||
} catch {
|
||||
/* mock fallback */
|
||||
}
|
||||
setHome(data)
|
||||
setLoading(false)
|
||||
try {
|
||||
const tData = await apiGet('/api/v1/tenants')
|
||||
if (!cancelled && tData?.tenants?.length) setInfra(tData.tenants)
|
||||
} catch { /* mock infra */ }
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const auditTenants = home?.audit?.tenants || []
|
||||
const domainRows = collectDomainAttention(auditTenants)
|
||||
const usingMock = home ? !home.fromApi : true
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="page-title">Console</h2>
|
||||
<p className="page-lead">
|
||||
Operações unificadas — clique nos números de alerta para ver <strong>quem</strong> gerou cada evento (agente, regra, domínio).
|
||||
{usingMock ? ' Dados de demonstração até login API.' : ''}
|
||||
Home unificada — KPIs, saúde da plataforma, alertas Wazuh 24h e infraestrutura.
|
||||
{usingMock ? ' Dados de demonstração até login API (JWT Desk).' : ' Dados live da API ops.'}
|
||||
</p>
|
||||
|
||||
<AlertBar counts={alertCounts} />
|
||||
{loading ? <p className="status-pill">A carregar indicadores…</p> : null}
|
||||
|
||||
<FeatureSection title="Security operations">
|
||||
<FeatureCard
|
||||
icon="🔍"
|
||||
title="Discover"
|
||||
description="Feed unificado de eventos Wazuh, onboard e suporte — investigação em até 2 cliques."
|
||||
to="/discover"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="📋"
|
||||
title="Chamados CH-*"
|
||||
description="Hub de investigação — timeline, observables, assist wizard e runbooks."
|
||||
to="/chamados"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="🛡️"
|
||||
title="Ver alertas médios"
|
||||
description="2 alertas L7–L11 nas últimas 24h — clique para lista filtrada."
|
||||
to="/discover?bucket=medium"
|
||||
/>
|
||||
</FeatureSection>
|
||||
{home ? (
|
||||
<>
|
||||
<KpiBar summary={home.summary} alertCounts={home.alertCounts} />
|
||||
<AlertBar counts={home.alertCounts} />
|
||||
|
||||
<FeatureSection title="Platform tenants">
|
||||
<FeatureCard
|
||||
icon="🖥️"
|
||||
title="Infraestrutura Ligbox"
|
||||
description="VM112 onboard, VM122 ops, VM104 Wazuh — registo de nós da plataforma."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="📊"
|
||||
title="Scorecard domínios"
|
||||
description="DNS, TLS, Carbonio e webmail — visão consolidada por tenant de oferta."
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="⚙️"
|
||||
title="Provisionamento"
|
||||
description="Estado MOSP, purge e orquestração VM112 (Desk Serviços)."
|
||||
/>
|
||||
</FeatureSection>
|
||||
<section className="wz-section">
|
||||
<h2 className="wz-section-title">Saúde da plataforma</h2>
|
||||
<HealthCards tenants={auditTenants} />
|
||||
</section>
|
||||
|
||||
<IncidentsStrip incidents={home.incidents} />
|
||||
<DomainAttention rows={domainRows} />
|
||||
<ShortcutsRow />
|
||||
<InfraTable tenants={infra} />
|
||||
<AuditTenantCards auditTenants={audit} />
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0, fontSize: '0.95rem' }}>Chamados recentes</h3>
|
||||
<ul className="timeline">
|
||||
{MOCK_CHAMADOS.map((c) => (
|
||||
{home.chamados.map((c) => (
|
||||
<li key={c.public_id}>
|
||||
<Link to={`/chamados/${c.public_id}`}>{c.public_id}</Link> — {c.domain}{' '}
|
||||
{c.max_severity ? <span className="badge badge-sev-high">L{c.max_severity}</span> : null}
|
||||
{c.status ? <span className="badge badge-onboard">{c.status}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,11 +33,34 @@ O **Chamado `CH-*` é o hub de investigação** — equivalente ao «caso» TheH
|
|||
|
||||
---
|
||||
|
||||
## Módulos sidebar (Console VM123)
|
||||
## Módulos sidebar (Console VM123) — **v2 Fase 1 (2026-06-25)**
|
||||
|
||||
| # | Módulo | Função |
|
||||
|---|--------|--------|
|
||||
| 1 | **Console** | Home unificada: KPIs, alertas 24h, saúde plataforma, incidentes Agentic, domínios, infra, chamados |
|
||||
| 2 | **Discover** | Feed eventos; alertas clicáveis por severidade |
|
||||
| 3 | **Chamados** | Lista + hub `CH-*` |
|
||||
|
||||
**Deprecado no Desk (banners Fase 4 leve):** Overview, Dashboard KPIs, Eventos, Tickets → links para Console.
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Console (home /) │
|
||||
│ KPI bar → Alertas 24h → Saúde tenants │
|
||||
│ → Incidentes Agentic → Domínios atenção │
|
||||
│ → Infra (ex-Tenants) → Chamados recentes│
|
||||
└──────────────┬──────────────────────────┘
|
||||
│
|
||||
Discover / Chamados / Hub CH-*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Módulos sidebar (legado — pré-v2)
|
||||
|
||||
| # | Módulo | Inspiração Wazuh | Função |
|
||||
|---|--------|------------------|--------|
|
||||
| 1 | **Overview** | Threat Hunting overview | Entrada rápida; não substitui o hub |
|
||||
| 1 | **Overview** | Threat Hunting overview | ~~Entrada rápida~~ → fundido em Console |
|
||||
| 2 | **Discover** | Explore → Discover | Investigação transversal; sempre liga a `CH-*` |
|
||||
| 3 | **Chamados** | — (Ligbox) | Lista de hubs activos; fila de trabalho |
|
||||
| 4 | **Hub `CH-*`** | Alert detail + case | **Ecrã principal de investigação** (rota `/chamados/CH-…`) |
|
||||
|
|
|
|||
|
|
@ -54,6 +54,17 @@
|
|||
- [x] T040 Deploy Docker VM123 — **ligbox-ops-console-ui UP :8100**
|
||||
- [x] T041 Traefik console.ligbox.com.br **(G1 2026-06-25 — HTTPS 200 via CT114)**
|
||||
|
||||
## F4 — Console v2 home (2026-06-25)
|
||||
|
||||
- [x] T047 KPI bar (desk/summary + alertas Wazuh)
|
||||
- [x] T048 Saúde plataforma — port Overview (`HealthCards`)
|
||||
- [x] T049 Domínios com atenção (`DomainAttention`)
|
||||
- [x] T050 Incidentes Agentic strip (mock + API fallback)
|
||||
- [x] T051 Desk banners migração Overview/Eventos/Tickets/Dashboard
|
||||
- [x] T052 Rollback doc `deploy/CONSOLE-V2-ROLLBACK.md`
|
||||
- [ ] T053 Rota `/ops` alias + redirect desk cutover
|
||||
- [ ] T054 Assist panel live no hub CH-*
|
||||
|
||||
---
|
||||
|
||||
## F4 — Runbooks + aprovações
|
||||
|
|
|
|||
Loading…
Reference in a new issue