Console: barra de alertas clicável com drill-down no Discover.

Cada contador de severidade liga a /discover?bucket= com agente, regra, domínio e chamado; contagens calculadas a partir dos eventos Wazuh 24h.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ligbox Spec Hub 2026-06-25 11:55:31 +00:00
parent 33ccbcd879
commit 935ceda4e0
7 changed files with 298 additions and 54 deletions

View file

@ -0,0 +1,39 @@
import { Link } from 'react-router-dom'
import { SEVERITY_BUCKETS } from '../lib/severity'
export default function AlertBar({ counts, linkable = true }) {
return (
<div className="wz-alerts-bar" role="region" aria-label="Alertas últimas 24 horas">
{SEVERITY_BUCKETS.map(({ key, label, hint, className }) => {
const val = counts[key] ?? 0
const inner = (
<>
<strong>{val}</strong>
<span>{label}</span>
<span className="wz-alert-hint">{hint}</span>
{linkable && val > 0 ? (
<span className="wz-alert-cta">Ver alertas </span>
) : null}
</>
)
if (linkable && val > 0) {
return (
<Link
key={key}
to={`/discover?bucket=${key}`}
className={`wz-alert-stat wz-alert-stat--link ${className}`}
title={`Ver ${val} alerta(s) — ${hint}`}
>
{inner}
</Link>
)
}
return (
<div key={key} className={`wz-alert-stat ${className}`} title={hint}>
{inner}
</div>
)
})}
</div>
)
}

View file

@ -0,0 +1,32 @@
import { MOCK_DISCOVER_EVENTS } from '../mock'
import { apiGet } from './api'
function normalizeApiEvent(row) {
const payload = row.payload || {}
const data = payload.data || {}
const level = row.severity ?? data.level ?? payload.level
return {
id: row.id,
source: row.source || 'wazuh',
event_type: row.event_type,
domain: row.domain || payload.domain || data.agent || '—',
severity: level != null ? Number(level) : null,
summary: data.description || payload.summary || row.event_type,
agent: data.agent || payload.agent || '—',
rule_id: data.rule_id || payload.rule_id,
chamado_public_id: payload.chamado_public_id || null,
created_at: row.created_at,
wazuh_link: 'https://wazuh.itecnologys.com/',
}
}
export async function loadDiscoverEvents() {
try {
const data = await apiGet('/api/v1/webhooks/events?source=wazuh')
const rows = (data.events || []).map(normalizeApiEvent)
if (rows.length) return { events: rows, fromApi: true }
} catch {
/* sem login JWT — mock */
}
return { events: MOCK_DISCOVER_EVENTS, fromApi: false }
}

View file

@ -0,0 +1,44 @@
/** Buckets Wazuh — mesma escala da home Console */
export const SEVERITY_BUCKETS = [
{ key: 'critical', label: 'Critical severity', hint: 'Rule level 15 or higher', className: 'wz-sev-critical', min: 15, max: 99 },
{ key: 'high', label: 'High severity', hint: 'Rule level 12 to 14', className: 'wz-sev-high', min: 12, max: 14 },
{ key: 'medium', label: 'Medium severity', hint: 'Rule level 7 to 11', className: 'wz-sev-med', min: 7, max: 11 },
{ key: 'low', label: 'Low severity', hint: 'Rule level 0 to 6', className: 'wz-sev-low', min: 0, max: 6 },
]
export function bucketForLevel(level) {
const n = Number(level)
if (Number.isNaN(n)) return null
return SEVERITY_BUCKETS.find((b) => n >= b.min && n <= b.max)?.key ?? null
}
export function aggregateWazuh24h(events) {
const counts = Object.fromEntries(SEVERITY_BUCKETS.map((b) => [b.key, 0]))
const now = Date.now()
const windowMs = 24 * 60 * 60 * 1000
for (const ev of events || []) {
if (ev.source !== 'wazuh') continue
const ts = ev.created_at ? new Date(ev.created_at).getTime() : now
if (now - ts > windowMs) continue
const bucket = bucketForLevel(ev.severity)
if (bucket) counts[bucket] += 1
}
return counts
}
export function filterByBucket(events, bucketKey) {
if (!bucketKey) return events
const now = Date.now()
const windowMs = 24 * 60 * 60 * 1000
return (events || []).filter((ev) => {
if (bucketForLevel(ev.severity) !== bucketKey) return false
if (ev.source !== 'wazuh') return false
const ts = ev.created_at ? new Date(ev.created_at).getTime() : now
return now - ts <= windowMs
})
}
export function bucketLabel(bucketKey) {
return SEVERITY_BUCKETS.find((b) => b.key === bucketKey)?.label ?? bucketKey
}

View file

@ -31,8 +31,27 @@ export const MOCK_CHAMADOS = [
] ]
export const MOCK_EVENTS = [ export const MOCK_EVENTS = [
{ id: 901, chamado_public_id: 'CH-2026-00042', source: 'wazuh', event_type: 'wazuh.alert', domain: 'myvexx.com', severity: 12, summary: 'SSH brute force' }, { id: 901, chamado_public_id: 'CH-2026-00042', source: 'wazuh', event_type: 'wazuh.alert', domain: 'myvexx.com', severity: 12, summary: 'SSH brute force', agent: 'vm112-mail', rule_id: '5712', created_at: new Date(Date.now() - 3600000).toISOString() },
{ id: 902, chamado_public_id: 'CH-2026-00042', source: 'onboard', event_type: 'onboarding.failed', domain: 'myvexx.com', severity: null, summary: 'DNS timeout' }, { id: 902, chamado_public_id: 'CH-2026-00042', source: 'onboard', event_type: 'onboarding.failed', domain: 'myvexx.com', severity: null, summary: 'DNS timeout', agent: '—', rule_id: null, created_at: new Date(Date.now() - 3000000).toISOString() },
]
/** Alertas Wazuh últimas 24h — alimentam barra + Discover filtrável */
export const MOCK_DISCOVER_EVENTS = [
{ id: 1001, source: 'wazuh', event_type: 'wazuh.alert', domain: 'myvexx.com', severity: 9, summary: 'Integrity checksum changed', agent: 'vm112-mail', rule_id: '550', chamado_public_id: null, created_at: new Date(Date.now() - 2 * 3600000).toISOString() },
{ id: 1002, source: 'wazuh', event_type: 'wazuh.alert', domain: 'ligbox.com.br', severity: 8, summary: 'Multiple authentication failures', agent: 'vm123-finance', rule_id: '5710', chamado_public_id: null, created_at: new Date(Date.now() - 5 * 3600000).toISOString() },
{ id: 1003, source: 'wazuh', event_type: 'wazuh.alert', domain: '—', severity: 5, summary: 'sshd: authentication failure', agent: 'vm122-desk', rule_id: '5712', chamado_public_id: null, created_at: new Date(Date.now() - 1 * 3600000).toISOString() },
{ id: 1004, source: 'wazuh', event_type: 'wazuh.alert', domain: '—', severity: 4, summary: 'PAM: User login failed', agent: 'vm122-desk', rule_id: '5503', chamado_public_id: null, created_at: new Date(Date.now() - 3 * 3600000).toISOString() },
{ id: 1005, source: 'wazuh', event_type: 'wazuh.alert', domain: '—', severity: 3, summary: 'New Yum package installed', agent: 'vm104-wazuh', rule_id: '23504', chamado_public_id: null, created_at: new Date(Date.now() - 6 * 3600000).toISOString() },
{ id: 1006, source: 'wazuh', event_type: 'wazuh.alert', domain: '—', severity: 3, summary: 'Listened ports status changed', agent: 'traefik-proxy', rule_id: '533', chamado_public_id: null, created_at: new Date(Date.now() - 8 * 3600000).toISOString() },
{ id: 1007, source: 'wazuh', event_type: 'wazuh.alert', domain: '—', severity: 2, summary: 'syslog: cron session opened', agent: 'vm112-mail', rule_id: '2833', chamado_public_id: null, created_at: new Date(Date.now() - 10 * 3600000).toISOString() },
{ id: 1008, source: 'wazuh', event_type: 'wazuh.alert', domain: '—', severity: 2, summary: 'File added to the system', agent: 'vm123-finance', rule_id: '554', chamado_public_id: null, created_at: new Date(Date.now() - 12 * 3600000).toISOString() },
{ id: 1009, source: 'wazuh', event_type: 'wazuh.alert', domain: '—', severity: 2, summary: 'Dpkg (Debian Package) changed', agent: 'vm122-desk', rule_id: '2902', chamado_public_id: null, created_at: new Date(Date.now() - 14 * 3600000).toISOString() },
{ id: 1010, source: 'wazuh', event_type: 'wazuh.alert', domain: '—', severity: 1, summary: 'Login session opened', agent: 'vm104-wazuh', rule_id: '5501', chamado_public_id: null, created_at: new Date(Date.now() - 16 * 3600000).toISOString() },
{ id: 1011, source: 'wazuh', event_type: 'wazuh.alert', domain: '—', severity: 1, summary: 'PAM: Login session closed', agent: 'traefik-proxy', rule_id: '5502', chamado_public_id: null, created_at: new Date(Date.now() - 18 * 3600000).toISOString() },
{ id: 1012, source: 'wazuh', event_type: 'wazuh.alert', domain: '—', severity: 1, summary: 'sshd: Accepted publickey', agent: 'vm112-mail', rule_id: '5715', chamado_public_id: null, created_at: new Date(Date.now() - 20 * 3600000).toISOString() },
{ id: 1013, source: 'wazuh', event_type: 'wazuh.alert', domain: '—', severity: 0, summary: 'Agent started', agent: 'vm122-desk', rule_id: '503', chamado_public_id: null, created_at: new Date(Date.now() - 22 * 3600000).toISOString() },
{ id: 1014, source: 'wazuh', event_type: 'wazuh.alert', domain: '—', severity: 0, summary: 'Agent keepalive', agent: 'vm123-finance', rule_id: '504', chamado_public_id: null, created_at: new Date(Date.now() - 23 * 3600000).toISOString() },
{ id: 902, chamado_public_id: 'CH-2026-00042', source: 'onboard', event_type: 'onboarding.failed', domain: 'myvexx.com', severity: null, summary: 'DNS timeout', agent: '—', rule_id: null, created_at: new Date(Date.now() - 3000000).toISOString() },
] ]
export const MOCK_TENANTS = [ export const MOCK_TENANTS = [

View file

@ -143,6 +143,43 @@ a:hover { text-decoration: underline; }
text-align: center; text-align: center;
} }
.wz-alert-stat--link {
text-decoration: none;
color: inherit;
transition: background 0.15s;
cursor: pointer;
}
.wz-alert-stat--link:hover {
background: var(--lb-accent-soft);
text-decoration: none;
}
.wz-alert-hint {
display: block;
font-size: 0.65rem;
color: var(--lb-text-muted);
text-transform: none;
margin-top: 0.2rem;
}
.wz-alert-cta {
display: block;
font-size: 0.72rem;
color: var(--lb-accent);
margin-top: 0.45rem;
font-weight: 600;
}
.discover-filter-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.wz-alert-stat:last-child { border-right: none; } .wz-alert-stat:last-child { border-right: none; }
.wz-alert-stat strong { .wz-alert-stat strong {

View file

@ -1,27 +1,11 @@
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 { loadDiscoverEvents } from '../lib/events'
import { aggregateWazuh24h } from '../lib/severity'
import { MOCK_AUDIT_TENANTS, MOCK_CHAMADOS, MOCK_TENANTS } from '../mock' import { MOCK_AUDIT_TENANTS, MOCK_CHAMADOS, MOCK_TENANTS } from '../mock'
import { apiGet } from '../lib/api' import { apiGet } from '../lib/api'
function AlertBar() {
return (
<div className="wz-alerts-bar" role="region" aria-label="Alertas últimas 24 horas">
{[
['0', 'Critical severity', 'wz-sev-critical', 'Rule level 15 or higher'],
['0', 'High severity', 'wz-sev-high', 'Rule level 12 to 14'],
['2', 'Medium severity', 'wz-sev-med', 'Rule level 7 to 11'],
['12', 'Low severity', 'wz-sev-low', 'Rule level 0 to 6'],
].map(([val, label, cls, hint]) => (
<div key={label} className={`wz-alert-stat ${cls}`}>
<strong>{val}</strong>
<span>{label}</span>
<span style={{ display: 'block', fontSize: '0.65rem', marginTop: '0.2rem', textTransform: 'none' }}>{hint}</span>
</div>
))}
</div>
)
}
function FeatureSection({ title, children }) { function FeatureSection({ title, children }) {
return ( return (
<section className="wz-section"> <section className="wz-section">
@ -103,12 +87,18 @@ function AuditTenantCards({ auditTenants }) {
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 [audit, setAudit] = useState(MOCK_AUDIT_TENANTS)
const [alertCounts, setAlertCounts] = useState({ critical: 0, high: 0, medium: 0, low: 0 })
const [usingMock, setUsingMock] = useState(true) const [usingMock, setUsingMock] = useState(true)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
try { try {
const { events, fromApi } = await loadDiscoverEvents()
if (!cancelled) {
setAlertCounts(aggregateWazuh24h(events))
if (fromApi) setUsingMock(false)
}
const [tData, aData] = await Promise.all([ const [tData, aData] = await Promise.all([
apiGet('/api/v1/tenants').catch(() => null), apiGet('/api/v1/tenants').catch(() => null),
apiGet('/api/v1/audit/overview').catch(() => null), apiGet('/api/v1/audit/overview').catch(() => null),
@ -133,11 +123,11 @@ export default function ConsoleHome() {
<> <>
<h2 className="page-title">Console</h2> <h2 className="page-title">Console</h2>
<p className="page-lead"> <p className="page-lead">
Operações unificadas chamados, discover, tenants de infraestrutura e saúde de domínios num lugar. 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 — login API em breve)' : ''} {usingMock ? ' Dados de demonstração até login API.' : ''}
</p> </p>
<AlertBar /> <AlertBar counts={alertCounts} />
<FeatureSection title="Security operations"> <FeatureSection title="Security operations">
<FeatureCard <FeatureCard
@ -154,9 +144,9 @@ export default function ConsoleHome() {
/> />
<FeatureCard <FeatureCard
icon="🛡️" icon="🛡️"
title="Threat hunting" title="Ver alertas médios"
description="Alertas correlacionados por domínio e sessão de onboarding." description="2 alertas L7L11 nas últimas 24h — clique para lista filtrada."
to="/discover" to="/discover?bucket=medium"
/> />
</FeatureSection> </FeatureSection>

View file

@ -1,36 +1,119 @@
import { Link } from 'react-router-dom' import { useEffect, useMemo, useState } from 'react'
import { MOCK_EVENTS } from '../mock' import { Link, useSearchParams } from 'react-router-dom'
import { loadDiscoverEvents } from '../lib/events'
import { bucketLabel, filterByBucket } from '../lib/severity'
function fmtWhen(iso) {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' })
} catch {
return iso
}
}
export default function Discover() { export default function Discover() {
const [searchParams, setSearchParams] = useSearchParams()
const bucket = searchParams.get('bucket')
const [events, setEvents] = useState([])
const [fromApi, setFromApi] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
const { events: rows, fromApi: api } = await loadDiscoverEvents()
if (!cancelled) {
setEvents(rows)
setFromApi(api)
setLoading(false)
}
})()
return () => { cancelled = true }
}, [])
const filtered = useMemo(() => {
if (!bucket) return events
return filterByBucket(events, bucket)
}, [events, bucket])
const clearFilter = () => setSearchParams({})
return ( return (
<> <>
<p><Link to="/"> Console</Link></p>
<h2 className="page-title">Discover</h2> <h2 className="page-title">Discover</h2>
<p className="page-lead">Feed unificado clique num evento para abrir o hub CH-* ( 2 cliques).</p> {bucket ? (
<div className="discover-filter-banner card">
<div>
<strong>Filtro activo:</strong> {bucketLabel(bucket)} {filtered.length} alerta(s) nas últimas 24h
</div>
<button type="button" className="btn" onClick={clearFilter}>Limpar filtro</button>
</div>
) : (
<p className="page-lead">
Feed unificado Wazuh + onboard. Cada linha mostra <strong>agente</strong>, <strong>regra</strong> e ligação ao chamado.
{fromApi ? '' : ' (demonstração — login API para dados live)'}
</p>
)}
{loading ? (
<p className="page-lead">Carregando eventos</p>
) : filtered.length === 0 ? (
<div className="card">
<p style={{ margin: 0 }}>Nenhum alerta neste filtro nas últimas 24 horas.</p>
</div>
) : (
<div className="card table-wrap"> <div className="card table-wrap">
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>ID</th><th>Origem</th><th>Evento</th><th>Domínio</th><th>Sev</th><th>Chamado</th> <th>Quando</th>
<th>Sev</th>
<th>Agente</th>
<th>Regra</th>
<th>Descrição</th>
<th>Domínio</th>
<th>Chamado</th>
<th>Wazuh</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{MOCK_EVENTS.map((e) => ( {filtered.map((e) => (
<tr key={e.id}> <tr key={e.id}>
<td>{e.id}</td> <td>{fmtWhen(e.created_at)}</td>
<td><span className={`badge badge-${e.source}`}>{e.source}</span></td> <td>
<td>{e.event_type}</td> {e.severity != null ? (
<td>{e.domain}</td> <Link to={`/discover?bucket=${e.severity >= 15 ? 'critical' : e.severity >= 12 ? 'high' : e.severity >= 7 ? 'medium' : 'low'}`}>
<td>{e.severity ?? '—'}</td> L{e.severity}
</Link>
) : '—'}
</td>
<td><code>{e.agent || '—'}</code></td>
<td>{e.rule_id ? <code>{e.rule_id}</code> : '—'}</td>
<td>{e.summary || e.event_type}</td>
<td>{e.domain || '—'}</td>
<td> <td>
{e.chamado_public_id ? ( {e.chamado_public_id ? (
<Link to={`/chamados/${e.chamado_public_id}`}>{e.chamado_public_id}</Link> <Link to={`/chamados/${e.chamado_public_id}`}>{e.chamado_public_id}</Link>
) : '—'} ) : '—'}
</td> </td>
<td>
{e.source === 'wazuh' ? (
<a href="https://wazuh.itecnologys.com/" target="_blank" rel="noreferrer noreferrer" className="btn" style={{ padding: '0.2rem 0.5rem', fontSize: '0.75rem' }}>
Abrir
</a>
) : (
<span className={`badge badge-${e.source}`}>{e.source}</span>
)}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
)}
</> </>
) )
} }