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:
Ligbox Spec Hub 2026-06-25 12:33:56 +00:00
parent 935ceda4e0
commit af58565999
17 changed files with 732 additions and 135 deletions

View file

@ -1,6 +1,6 @@
# Backlog — Ligbox Ops Platform (VM122) # Backlog — Ligbox Ops Platform (VM122)
**Última atualização:** 2026-06-19 (Specs **026032** purge extra-auth) **Última atualização:** 2026-06-25 (Console v2 F1 · Spec 019)
**Projeto:** `ligbox-ops-platform` **Projeto:** `ligbox-ops-platform`
**VM122:** `ligbox-ops` · `10.10.10.122` · SSH WAN `:2522` **VM122:** `ligbox-ops` · `10.10.10.122` · SSH WAN `:2522`
**VM112:** Portal/Wizard — integração **API + webhooks** (fora do compose) **VM112:** Portal/Wizard — integração **API + webhooks** (fora do compose)
@ -92,6 +92,16 @@
--- ---
## Backlog futuro (P2P3)
| # | 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 ## Track A — Auditoria & Ops Dashboard
| ID | P | Item | Estado | | ID | P | Item | Estado |
@ -100,6 +110,7 @@
| **OPS-2** | P0 | `docker-compose.mvp.yml` | ✅ | | **OPS-2** | P0 | `docker-compose.mvp.yml` | ✅ |
| **OPS-3** | P0 | `tenant-registry` (VM112 = 1º nó) | ✅ | | **OPS-3** | P0 | `tenant-registry` (VM112 = 1º nó) | ✅ |
| **OPS-7** | P1 | VM123 finance stack (Spec 024) | ✅ | | **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-1** | P0 | Collectors: Carbonio, DNS, nginx | 🔄 parcial |
| **AUD-2** | P0 | UI `/ops/overview` + API scorecard | 🔄 parcial | | **AUD-2** | P0 | UI `/ops/overview` + API scorecard | 🔄 parcial |
| **AUD-3** | P1 | Scorecard por domínio (8 checks) | 🔄 | | **AUD-3** | P1 | Scorecard por domínio (8 checks) | 🔄 |

View file

@ -1,6 +1,6 @@
# VM123 — inventário Ops Console # VM123 — inventário Ops Console
**Data:** 2026-06-17 **Data:** 2026-06-25 (actualizado Console v2 F1)
**Host:** `vm123-finance` · `10.10.10.123` **Host:** `vm123-finance` · `10.10.10.123`
## Serviços existentes (não alterados) ## Serviços existentes (não alterados)
@ -23,7 +23,9 @@
| Container | `ligbox-ops-console-ui` | | Container | `ligbox-ops-console-ui` |
| Porta | `0.0.0.0:8100` (LAN — Traefik CT114) | | Porta | `0.0.0.0:8100` (LAN — Traefik CT114) |
| Health | `curl http://10.10.10.123:8100/health``ok` | | 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) | | Rede Docker | `ligbox-console` (bridge dedicada) |
| Imagem | `ligbox/ops-console:latest` | | Imagem | `ligbox/ops-console:latest` |

View file

@ -4557,6 +4557,21 @@ button.health-card {
.migration-gate-ready { color: #22c55e; } .migration-gate-ready { color: #22c55e; }
.migration-gate-blocked { color: #ef4444; } .migration-gate-blocked { color: #ef4444; }
.migration-gate-warning { color: #f59e0b; } .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-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%; } .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 { .ticket-card-aside {

View file

@ -275,10 +275,19 @@
</header> </header>
<section id="view-dashboard" class="view"> <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> <div id="dashboard-content"><p class="loading">Carregando…</p></div>
</section> </section>
<section id="view-overview" class="view"> <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> <div id="overview-content"><p class="loading">Carregando…</p></div>
</section> </section>
@ -291,6 +300,10 @@
</section> </section>
<section id="view-tickets" class="view"> <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"> <div class="toolbar">
<button type="button" class="filter-btn active" data-filter="all">Todos</button> <button type="button" class="filter-btn active" data-filter="all">Todos</button>
<button type="button" class="filter-btn" data-filter="active">Activos</button> <button type="button" class="filter-btn" data-filter="active">Activos</button>
@ -312,6 +325,10 @@
</section> </section>
<section id="view-events" class="view"> <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"> <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 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> <button type="button" class="filter-btn" data-events-tab="purges" id="events-tab-purges" hidden>Histórico de purges</button>

View file

@ -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`

View file

@ -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`

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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: 'L7L11 ú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>
)
}

View file

@ -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)
}

View file

@ -66,3 +66,91 @@ export const MOCK_AUDIT_TENANTS = [
{ id: 'wazuh', label: 'Wazuh SOC', kind: 'wazuh_soc', score: 8 }, { 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' }, { 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',
},
]

View file

@ -369,3 +369,138 @@ code {
.tenant-health-ok { color: var(--lb-sev-low); font-weight: 600; } .tenant-health-ok { color: var(--lb-sev-low); font-weight: 600; }
.tenant-health-warn { color: var(--lb-sev-med); 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; } .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;
}

View file

@ -1,39 +1,21 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import AlertBar from '../components/AlertBar' import AlertBar from '../components/AlertBar'
import { loadDiscoverEvents } from '../lib/events' import DomainAttention from '../components/DomainAttention'
import { aggregateWazuh24h } from '../lib/severity' import HealthCards from '../components/HealthCards'
import { MOCK_AUDIT_TENANTS, MOCK_CHAMADOS, MOCK_TENANTS } from '../mock' 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' 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 }) { function InfraTable({ tenants }) {
return ( return (
<div className="card table-wrap"> <div className="card table-wrap">
<h3 style={{ marginTop: 0, fontSize: '0.95rem' }}>Nós de infraestrutura</h3> <h3 style={{ marginTop: 0, fontSize: '0.95rem' }}>Nós de infraestrutura</h3>
<p style={{ fontSize: '0.82rem', color: 'var(--lb-text-secondary)', marginTop: 0 }}> <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> </p>
<table className="data-table"> <table className="data-table">
<thead> <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() { export default function ConsoleHome() {
const [infra, setInfra] = useState(MOCK_TENANTS) const [infra, setInfra] = useState(MOCK_TENANTS)
const [audit, setAudit] = useState(MOCK_AUDIT_TENANTS) const [home, setHome] = useState(null)
const [alertCounts, setAlertCounts] = useState({ critical: 0, high: 0, medium: 0, low: 0 }) const [loading, setLoading] = useState(true)
const [usingMock, setUsingMock] = useState(true)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
const data = await loadConsoleHomeData()
if (cancelled) return
setHome(data)
setLoading(false)
try { try {
const { events, fromApi } = await loadDiscoverEvents() const tData = await apiGet('/api/v1/tenants')
if (!cancelled) { if (!cancelled && tData?.tenants?.length) setInfra(tData.tenants)
setAlertCounts(aggregateWazuh24h(events)) } catch { /* mock infra */ }
if (fromApi) setUsingMock(false)
}
const [tData, aData] = await Promise.all([
apiGet('/api/v1/tenants').catch(() => null),
apiGet('/api/v1/audit/overview').catch(() => null),
])
if (cancelled) return
if (tData?.tenants?.length) {
setInfra(tData.tenants)
setUsingMock(false)
}
if (aData?.tenants?.length) {
setAudit(aData.tenants)
setUsingMock(false)
}
} catch {
/* mock fallback */
}
})() })()
return () => { cancelled = true } return () => { cancelled = true }
}, []) }, [])
const auditTenants = home?.audit?.tenants || []
const domainRows = collectDomainAttention(auditTenants)
const usingMock = home ? !home.fromApi : true
return ( return (
<> <>
<h2 className="page-title">Console</h2> <h2 className="page-title">Console</h2>
<p className="page-lead"> <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). Home unificada KPIs, saúde da plataforma, alertas Wazuh 24h e infraestrutura.
{usingMock ? ' Dados de demonstração até login API.' : ''} {usingMock ? ' Dados de demonstração até login API (JWT Desk).' : ' Dados live da API ops.'}
</p> </p>
<AlertBar counts={alertCounts} /> {loading ? <p className="status-pill">A carregar indicadores</p> : null}
<FeatureSection title="Security operations"> {home ? (
<FeatureCard <>
icon="🔍" <KpiBar summary={home.summary} alertCounts={home.alertCounts} />
title="Discover" <AlertBar counts={home.alertCounts} />
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 L7L11 nas últimas 24h — clique para lista filtrada."
to="/discover?bucket=medium"
/>
</FeatureSection>
<FeatureSection title="Platform tenants"> <section className="wz-section">
<FeatureCard <h2 className="wz-section-title">Saúde da plataforma</h2>
icon="🖥️" <HealthCards tenants={auditTenants} />
title="Infraestrutura Ligbox" </section>
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>
<InfraTable tenants={infra} /> <IncidentsStrip incidents={home.incidents} />
<AuditTenantCards auditTenants={audit} /> <DomainAttention rows={domainRows} />
<ShortcutsRow />
<InfraTable tenants={infra} />
<div className="card"> <div className="card">
<h3 style={{ marginTop: 0, fontSize: '0.95rem' }}>Chamados recentes</h3> <h3 style={{ marginTop: 0, fontSize: '0.95rem' }}>Chamados recentes</h3>
<ul className="timeline"> <ul className="timeline">
{MOCK_CHAMADOS.map((c) => ( {home.chamados.map((c) => (
<li key={c.public_id}> <li key={c.public_id}>
<Link to={`/chamados/${c.public_id}`}>{c.public_id}</Link> {c.domain}{' '} <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.max_severity ? <span className="badge badge-sev-high">L{c.max_severity}</span> : null}
</li> {c.status ? <span className="badge badge-onboard">{c.status}</span> : null}
))} </li>
</ul> ))}
</div> </ul>
</div>
</>
) : null}
</> </>
) )
} }

View file

@ -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 | | # | 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-*` | | 2 | **Discover** | Explore → Discover | Investigação transversal; sempre liga a `CH-*` |
| 3 | **Chamados** | — (Ligbox) | Lista de hubs activos; fila de trabalho | | 3 | **Chamados** | — (Ligbox) | Lista de hubs activos; fila de trabalho |
| 4 | **Hub `CH-*`** | Alert detail + case | **Ecrã principal de investigação** (rota `/chamados/CH-…`) | | 4 | **Hub `CH-*`** | Alert detail + case | **Ecrã principal de investigação** (rota `/chamados/CH-…`) |

View file

@ -54,6 +54,17 @@
- [x] T040 Deploy Docker VM123 — **ligbox-ops-console-ui UP :8100** - [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)** - [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 ## F4 — Runbooks + aprovações