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:
parent
33ccbcd879
commit
935ceda4e0
7 changed files with 298 additions and 54 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -31,8 +31,27 @@ export const MOCK_CHAMADOS = [
|
|||
]
|
||||
|
||||
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: 902, chamado_public_id: 'CH-2026-00042', source: 'onboard', event_type: 'onboarding.failed', domain: 'myvexx.com', severity: null, summary: 'DNS timeout' },
|
||||
{ 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', 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 = [
|
||||
|
|
|
|||
|
|
@ -143,6 +143,43 @@ a:hover { text-decoration: underline; }
|
|||
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 strong {
|
||||
|
|
|
|||
|
|
@ -1,27 +1,11 @@
|
|||
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 { 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 }) {
|
||||
return (
|
||||
<section className="wz-section">
|
||||
|
|
@ -103,12 +87,18 @@ function AuditTenantCards({ auditTenants }) {
|
|||
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)
|
||||
|
||||
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),
|
||||
|
|
@ -133,11 +123,11 @@ export default function ConsoleHome() {
|
|||
<>
|
||||
<h2 className="page-title">Console</h2>
|
||||
<p className="page-lead">
|
||||
Operações unificadas — chamados, discover, tenants de infraestrutura e saúde de domínios num só lugar.
|
||||
{usingMock ? ' (dados de demonstração — login API em breve)' : ''}
|
||||
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.' : ''}
|
||||
</p>
|
||||
|
||||
<AlertBar />
|
||||
<AlertBar counts={alertCounts} />
|
||||
|
||||
<FeatureSection title="Security operations">
|
||||
<FeatureCard
|
||||
|
|
@ -154,9 +144,9 @@ export default function ConsoleHome() {
|
|||
/>
|
||||
<FeatureCard
|
||||
icon="🛡️"
|
||||
title="Threat hunting"
|
||||
description="Alertas correlacionados por domínio e sessão de onboarding."
|
||||
to="/discover"
|
||||
title="Ver alertas médios"
|
||||
description="2 alertas L7–L11 nas últimas 24h — clique para lista filtrada."
|
||||
to="/discover?bucket=medium"
|
||||
/>
|
||||
</FeatureSection>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,36 +1,119 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { MOCK_EVENTS } from '../mock'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
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() {
|
||||
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 (
|
||||
<>
|
||||
<p><Link to="/">← Console</Link></p>
|
||||
<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">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MOCK_EVENTS.map((e) => (
|
||||
{filtered.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td>{e.id}</td>
|
||||
<td><span className={`badge badge-${e.source}`}>{e.source}</span></td>
|
||||
<td>{e.event_type}</td>
|
||||
<td>{e.domain}</td>
|
||||
<td>{e.severity ?? '—'}</td>
|
||||
<td>{fmtWhen(e.created_at)}</td>
|
||||
<td>
|
||||
{e.severity != null ? (
|
||||
<Link to={`/discover?bucket=${e.severity >= 15 ? 'critical' : e.severity >= 12 ? 'high' : e.severity >= 7 ? 'medium' : 'low'}`}>
|
||||
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>
|
||||
{e.chamado_public_id ? (
|
||||
<Link to={`/chamados/${e.chamado_public_id}`}>{e.chamado_public_id}</Link>
|
||||
) : '—'}
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue