mirror of
https://github.com/FunnyWolf/agentic-soc-platform.git
synced 2026-08-22 13:12:56 +02:00
Improve dashboard big-screen UX
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -31,14 +31,14 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
gap: 16px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.dashboard-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
padding: 22px;
|
||||
border: 1px solid rgba(22, 119, 255, 0.38);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
@@ -69,6 +69,10 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-hero-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -84,11 +88,48 @@
|
||||
.dashboard-title {
|
||||
margin: 0;
|
||||
color: #f5faff;
|
||||
font-size: 30px;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.dashboard-command-summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 760px;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--posture-color, #69b1ff) 45%, transparent);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--posture-color, #69b1ff) 18%, transparent), rgba(6, 21, 40, 0.48)),
|
||||
rgba(6, 21, 40, 0.46);
|
||||
box-shadow: 0 0 22px color-mix(in srgb, var(--posture-color, #69b1ff) 16%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-command-state {
|
||||
flex: none;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
color: #061528;
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
background: var(--posture-color, #69b1ff);
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--posture-color, #69b1ff) 42%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-command-copy {
|
||||
overflow: hidden;
|
||||
color: rgba(245, 250, 255, 0.86);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-hero-actions {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
@@ -155,6 +196,60 @@
|
||||
background: rgba(250, 173, 20, 0.08);
|
||||
}
|
||||
|
||||
.dashboard-command-strip {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr repeat(4, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.dashboard-command-pill {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--signal-color, #69b1ff) 35%, rgba(255, 255, 255, 0.08));
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--signal-color, #69b1ff) 14%, transparent), rgba(255, 255, 255, 0.035)),
|
||||
rgba(6, 21, 40, 0.62);
|
||||
}
|
||||
|
||||
.dashboard-command-pill::before {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 3px;
|
||||
content: '';
|
||||
background: var(--signal-color, #69b1ff);
|
||||
box-shadow: 0 0 18px var(--signal-color, #69b1ff);
|
||||
}
|
||||
|
||||
.dashboard-command-pill span {
|
||||
color: rgba(214, 234, 255, 0.54);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dashboard-command-pill strong {
|
||||
overflow: hidden;
|
||||
color: #f5faff;
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 0 16px color-mix(in srgb, var(--signal-color, #69b1ff) 26%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-command-pill.emphasis strong {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@@ -208,6 +303,7 @@
|
||||
|
||||
.dashboard-card,
|
||||
.dashboard-metric-card {
|
||||
position: relative;
|
||||
border: 1px solid rgba(22, 119, 255, 0.22);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
@@ -240,12 +336,25 @@
|
||||
}
|
||||
|
||||
.dashboard-risk-body {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(170px, 0.85fr) minmax(180px, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dashboard-risk-body::before {
|
||||
position: absolute;
|
||||
inset: 8px auto auto 8px;
|
||||
width: 74px;
|
||||
height: 74px;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, color-mix(in srgb, var(--risk-color, #69b1ff) 22%, transparent), transparent 68%);
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.dashboard-risk-gauge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -260,12 +369,31 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-risk-value-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-risk-value {
|
||||
color: #f5faff;
|
||||
color: var(--risk-color, #f5faff);
|
||||
font-size: 54px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 24px rgba(22, 119, 255, 0.42);
|
||||
text-shadow: 0 0 24px color-mix(in srgb, var(--risk-color, #69b1ff) 44%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-risk-level {
|
||||
margin-bottom: 7px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--risk-color, #69b1ff) 52%, transparent);
|
||||
border-radius: 999px;
|
||||
color: var(--risk-color, #69b1ff);
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.1em;
|
||||
background: color-mix(in srgb, var(--risk-color, #69b1ff) 13%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-risk-label {
|
||||
@@ -275,6 +403,42 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dashboard-risk-summary {
|
||||
color: rgba(245, 250, 255, 0.76);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.dashboard-risk-band {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
margin: 2px 0 4px;
|
||||
}
|
||||
|
||||
.dashboard-risk-band span {
|
||||
overflow: hidden;
|
||||
padding: 5px 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 7px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.dashboard-risk-band span.active {
|
||||
border-color: color-mix(in srgb, var(--risk-color, #69b1ff) 54%, transparent);
|
||||
color: #f5faff;
|
||||
background: color-mix(in srgb, var(--risk-color, #69b1ff) 20%, transparent);
|
||||
box-shadow: 0 0 14px color-mix(in srgb, var(--risk-color, #69b1ff) 20%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-risk-meta {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -303,6 +467,17 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-metric-card.priority {
|
||||
border-color: color-mix(in srgb, var(--metric-color, #1677ff) 42%, rgba(255, 255, 255, 0.08));
|
||||
background:
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--metric-color, #1677ff) 16%, transparent), rgba(13, 20, 33, 0.94) 48%),
|
||||
rgba(20, 20, 20, 0.92);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06),
|
||||
0 18px 38px rgba(0, 0, 0, 0.22),
|
||||
0 0 22px color-mix(in srgb, var(--metric-color, #1677ff) 14%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-metric-card::before {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
@@ -322,6 +497,36 @@
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
}
|
||||
|
||||
.dashboard-metric-card.priority .ant-statistic-content {
|
||||
text-shadow: 0 0 18px color-mix(in srgb, var(--metric-color, #1677ff) 28%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-metric-category {
|
||||
margin-bottom: 7px;
|
||||
color: var(--metric-color, #69b1ff);
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dashboard-metric-card.tone-critical {
|
||||
border-color: rgba(255, 77, 79, 0.3);
|
||||
}
|
||||
|
||||
.dashboard-metric-card.tone-warning {
|
||||
border-color: rgba(250, 140, 22, 0.3);
|
||||
}
|
||||
|
||||
.dashboard-metric-card.tone-success {
|
||||
border-color: rgba(82, 196, 26, 0.26);
|
||||
}
|
||||
|
||||
.dashboard-metric-card.tone-intel {
|
||||
border-color: rgba(114, 46, 209, 0.26);
|
||||
}
|
||||
|
||||
.dashboard-metric-sub {
|
||||
margin-top: 8px;
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
@@ -336,6 +541,10 @@
|
||||
min-height: 190px;
|
||||
}
|
||||
|
||||
.dashboard-trend-card .dashboard-panel-body {
|
||||
min-height: 230px;
|
||||
}
|
||||
|
||||
.dashboard-mini-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -545,6 +754,51 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-radar-wrap {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-radar-legend {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dashboard-radar-legend span {
|
||||
display: grid;
|
||||
grid-template-columns: 8px auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 5px 7px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.055);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.dashboard-radar-legend i {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px currentColor;
|
||||
}
|
||||
|
||||
.dashboard-radar-legend strong {
|
||||
color: rgba(245, 250, 255, 0.82);
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dashboard-radar-legend em {
|
||||
overflow: hidden;
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-mitre-matrix {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -655,26 +909,78 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-event-stream::before {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
bottom: 14px;
|
||||
left: 72px;
|
||||
width: 1px;
|
||||
content: '';
|
||||
background: linear-gradient(to bottom, transparent, rgba(105, 177, 255, 0.42), transparent);
|
||||
}
|
||||
|
||||
.dashboard-event {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 11px 12px 11px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
grid-template-columns: 62px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--event-color, #1677ff) 22%, rgba(255, 255, 255, 0.08));
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--event-color, #1677ff) 10%, transparent), rgba(255, 255, 255, 0.035)),
|
||||
rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.dashboard-event::before {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
bottom: 14px;
|
||||
top: 50%;
|
||||
left: 67px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
content: '';
|
||||
border: 2px solid rgba(6, 21, 40, 0.94);
|
||||
border-radius: 999px;
|
||||
background: var(--event-color, #1677ff);
|
||||
box-shadow: 0 0 16px var(--event-color, #1677ff);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.dashboard-event::after {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
content: '';
|
||||
border-radius: 999px;
|
||||
background: var(--event-color, #1677ff);
|
||||
box-shadow: 0 0 14px var(--event-color, #1677ff);
|
||||
background: var(--severity-color, #ff4d4f);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.dashboard-event-time {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
justify-items: end;
|
||||
padding-right: 14px;
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
}
|
||||
|
||||
.dashboard-event-time span {
|
||||
color: #f5faff;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dashboard-event-time small {
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dashboard-event-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-event-title {
|
||||
@@ -691,6 +997,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-event-title .ant-badge-status-text {
|
||||
color: rgba(245, 250, 255, 0.7);
|
||||
}
|
||||
|
||||
.dashboard-event-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -699,6 +1009,12 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dashboard-event-meta span:first-child {
|
||||
color: var(--event-color, #69b1ff);
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.dashboard-posture-grid {
|
||||
grid-template-columns: minmax(300px, 0.9fr) minmax(520px, 1.8fr);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useEffect, useMemo, useState, type ReactNode} from 'react'
|
||||
import {useCallback, useEffect, useMemo, useState, type CSSProperties, type ReactNode} from 'react'
|
||||
import {Area, Gauge, Radar as RadarChart} from '@ant-design/charts'
|
||||
import {Alert, Badge, Button, Card, Progress, Segmented, Skeleton, Space, Statistic, Tag, Tooltip, Typography} from 'antd'
|
||||
import {InfoCircleOutlined, ReloadOutlined} from '@ant-design/icons'
|
||||
@@ -16,6 +16,16 @@ const windowOptions: { label: string; value: DashboardWindow }[] = [
|
||||
]
|
||||
const chartTheme = 'classicDark' as const
|
||||
const lucideIconProps = { size: 16, strokeWidth: 2 }
|
||||
const riskBands = ['STABLE', 'GUARDED', 'ELEVATED', 'CRITICAL'] as const
|
||||
|
||||
type RiskBand = typeof riskBands[number]
|
||||
type MetricTone = 'critical' | 'warning' | 'signal' | 'success' | 'intel' | 'neutral'
|
||||
|
||||
interface RiskPostureLevel {
|
||||
band: RiskBand
|
||||
color: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
function hasValues(data: DashboardLabelValue[]) {
|
||||
return data.some((item) => item.value > 0)
|
||||
@@ -53,6 +63,83 @@ function severityColor(severity: string) {
|
||||
return severityColors[severity] || '#1677ff'
|
||||
}
|
||||
|
||||
function getRiskPostureLevel(value: number | null | undefined): RiskPostureLevel {
|
||||
const score = value ?? 0
|
||||
if (score >= 80) {
|
||||
return { band: 'CRITICAL', color: '#ff4d4f', summary: 'Critical pressure requires command attention.' }
|
||||
}
|
||||
if (score >= 55) {
|
||||
return { band: 'ELEVATED', color: '#fa8c16', summary: 'Elevated risk pressure across active signals.' }
|
||||
}
|
||||
if (score >= 35) {
|
||||
return { band: 'GUARDED', color: '#fadb14', summary: 'Guarded posture with monitored signal pressure.' }
|
||||
}
|
||||
return { band: 'STABLE', color: '#52c41a', summary: 'Stable posture with no dominant pressure.' }
|
||||
}
|
||||
|
||||
function commandNarrative(data: DashboardOverview | null) {
|
||||
if (!data) return 'Waiting for telemetry handshake.'
|
||||
const {summary} = data
|
||||
const posture = getRiskPostureLevel(summary.active_risk_index)
|
||||
const leadSignal = summary.open_critical_cases > 0
|
||||
? `${formatNumber(summary.open_critical_cases)} critical case${summary.open_critical_cases === 1 ? '' : 's'} open`
|
||||
: summary.critical_high_alerts > 0
|
||||
? `${formatNumber(summary.critical_high_alerts)} critical/high alert${summary.critical_high_alerts === 1 ? '' : 's'} observed`
|
||||
: 'critical signal quiet'
|
||||
const automation = summary.failed_playbooks > 0
|
||||
? `${formatNumber(summary.failed_playbooks)} failed playbook${summary.failed_playbooks === 1 ? '' : 's'}`
|
||||
: `${formatPercent(summary.automation_success_rate)} automation success`
|
||||
return `${posture.band} posture · ${leadSignal} · ${automation}`
|
||||
}
|
||||
|
||||
function automationTone(summary: DashboardOverview['summary'] | undefined): MetricTone {
|
||||
if (!summary) return 'neutral'
|
||||
return summary.failed_playbooks > 0 ? 'critical' : 'success'
|
||||
}
|
||||
|
||||
function eventTimeParts(timestamp: string) {
|
||||
const value = new Date(timestamp)
|
||||
return {
|
||||
time: value.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
date: value.toLocaleDateString([], { month: 'short', day: '2-digit' }),
|
||||
}
|
||||
}
|
||||
|
||||
function compactMitreTacticLabel(label: string) {
|
||||
const normalized = label.trim()
|
||||
const labels: Record<string, string> = {
|
||||
Reconnaissance: 'Recon',
|
||||
'Resource Development': 'Resource',
|
||||
'Initial Access': 'Initial',
|
||||
Execution: 'Exec',
|
||||
Persistence: 'Persist',
|
||||
'Privilege Escalation': 'Priv Esc',
|
||||
'Defense Evasion': 'Defense',
|
||||
'Credential Access': 'Cred',
|
||||
Discovery: 'Discover',
|
||||
'Lateral Movement': 'Lateral',
|
||||
Collection: 'Collect',
|
||||
'Command and Control': 'C2',
|
||||
'Command & Control': 'C2',
|
||||
Exfiltration: 'Exfil',
|
||||
Impact: 'Impact',
|
||||
}
|
||||
if (labels[normalized]) return labels[normalized]
|
||||
if (normalized.length <= 10) return normalized
|
||||
return normalized
|
||||
.replace(/\band\b/gi, '&')
|
||||
.split(/\s+/)
|
||||
.map((part) => part.slice(0, 4))
|
||||
.slice(0, 2)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function eventAccentColor(item: DashboardHighlight) {
|
||||
if (item.kind === 'case') return statusColor(item.status)
|
||||
if (item.status === 'Resolved' || item.status === 'Closed') return '#52c41a'
|
||||
return '#b37feb'
|
||||
}
|
||||
|
||||
function generatedAtLabel(value: string | undefined) {
|
||||
if (!value) return 'Waiting for telemetry'
|
||||
return `Last refresh ${new Date(value).toLocaleString()}`
|
||||
@@ -88,6 +175,9 @@ function MetricCard({
|
||||
tooltip,
|
||||
accent = '#1677ff',
|
||||
sub,
|
||||
category,
|
||||
tone = 'signal',
|
||||
priority = false,
|
||||
}: {
|
||||
title: string
|
||||
value: ReactNode
|
||||
@@ -95,6 +185,9 @@ function MetricCard({
|
||||
tooltip?: string
|
||||
accent?: string
|
||||
sub?: ReactNode
|
||||
category?: string
|
||||
tone?: MetricTone
|
||||
priority?: boolean
|
||||
}) {
|
||||
const statisticTitle = (
|
||||
<Space size={6}>
|
||||
@@ -108,7 +201,8 @@ function MetricCard({
|
||||
)
|
||||
|
||||
return (
|
||||
<Card className="dashboard-metric-card" style={{ '--metric-color': accent } as React.CSSProperties} styles={{ body: { padding: 16 } }}>
|
||||
<Card className={`dashboard-metric-card tone-${tone}${priority ? ' priority' : ''}`} style={{ '--metric-color': accent } as CSSProperties} styles={{ body: { padding: 16 } }}>
|
||||
{category && <div className="dashboard-metric-category">{category}</div>}
|
||||
<Statistic title={statisticTitle} value={value as string | number} suffix={suffix} styles={{ content: { color: '#f5faff' } }} />
|
||||
{sub && <div className="dashboard-metric-sub">{sub}</div>}
|
||||
</Card>
|
||||
@@ -122,6 +216,8 @@ function MeanTimeCard({ title, metric, tooltip, accent }: { title: string; metri
|
||||
value={formatDuration(metric.seconds)}
|
||||
tooltip={tooltip}
|
||||
accent={accent}
|
||||
category="Response Clock"
|
||||
tone="signal"
|
||||
sub={`${metric.sample_count} valid sample${metric.sample_count === 1 ? '' : 's'}`}
|
||||
/>
|
||||
)
|
||||
@@ -130,29 +226,30 @@ function MeanTimeCard({ title, metric, tooltip, accent }: { title: string; metri
|
||||
function RiskPostureCard({ data, loading }: { data: DashboardOverview | null; loading: boolean }) {
|
||||
const riskIndex = data?.summary.active_risk_index ?? 0
|
||||
const clampedRiskIndex = Math.min(100, Math.max(0, riskIndex))
|
||||
const riskLevel = getRiskPostureLevel(riskIndex)
|
||||
const gaugeConfig = useMemo(() => ({
|
||||
data: {
|
||||
percent: clampedRiskIndex / 100,
|
||||
thresholds: [0.55, 0.8, 1],
|
||||
thresholds: [0.35, 0.55, 0.8, 1],
|
||||
},
|
||||
height: 190,
|
||||
theme: chartTheme,
|
||||
scale: {
|
||||
color: {
|
||||
range: ['#52c41a', '#fadb14', '#fa8c16'],
|
||||
range: ['#52c41a', '#13c2c2', '#fa8c16', '#ff4d4f'],
|
||||
},
|
||||
},
|
||||
style: {
|
||||
arcLineWidth: 9,
|
||||
pointerLineWidth: 3,
|
||||
pointerLineCap: 'round',
|
||||
pointerStroke: '#69b1ff',
|
||||
pointerStroke: riskLevel.color,
|
||||
pinR: 7,
|
||||
pinFill: '#061528',
|
||||
pinStroke: '#69b1ff',
|
||||
pinStroke: riskLevel.color,
|
||||
textContent: () => '',
|
||||
},
|
||||
}), [clampedRiskIndex])
|
||||
}), [clampedRiskIndex, riskLevel.color])
|
||||
|
||||
return (
|
||||
<CyberCard
|
||||
@@ -167,13 +264,22 @@ function RiskPostureCard({ data, loading }: { data: DashboardOverview | null; lo
|
||||
{loading && !data ? (
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
) : (
|
||||
<div className="dashboard-risk-body">
|
||||
<div className="dashboard-risk-body" style={{ '--risk-color': riskLevel.color } as CSSProperties}>
|
||||
<div className="dashboard-risk-gauge">
|
||||
<Gauge {...gaugeConfig} />
|
||||
</div>
|
||||
<div className="dashboard-risk-score">
|
||||
<div className="dashboard-risk-value">{riskIndex}</div>
|
||||
<div className="dashboard-risk-value-row">
|
||||
<div className="dashboard-risk-value">{riskIndex}</div>
|
||||
<span className="dashboard-risk-level">{riskLevel.band}</span>
|
||||
</div>
|
||||
<div className="dashboard-risk-label">weighted pressure / 100</div>
|
||||
<div className="dashboard-risk-summary">{riskLevel.summary}</div>
|
||||
<div className="dashboard-risk-band">
|
||||
{riskBands.map((band) => (
|
||||
<span className={band === riskLevel.band ? 'active' : ''} key={band}>{band}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="dashboard-risk-meta">
|
||||
<div className="dashboard-chip"><span>Open cases</span><span>{formatNumber(data?.summary.open_cases)}</span></div>
|
||||
<div className="dashboard-chip"><span>Critical cases</span><span>{formatNumber(data?.summary.open_critical_cases)}</span></div>
|
||||
@@ -198,7 +304,7 @@ function statusColor(status: string) {
|
||||
}
|
||||
|
||||
function signalColor(index: number) {
|
||||
return ['#69b1ff', '#13c2c2', '#b37feb', '#f759ab', '#faad14', '#52c41a'][index % 6]
|
||||
return ['#69b1ff', '#13c2c2', '#b37feb', '#597ef7', '#36cfc9', '#9254de'][index % 6]
|
||||
}
|
||||
|
||||
function ChartPanel({ title, children, compact = false, className = '' }: { title: ReactNode; children: ReactNode; compact?: boolean; className?: string }) {
|
||||
@@ -214,7 +320,7 @@ function AlertTrend({ data }: { data: DashboardLabelValue[] }) {
|
||||
data,
|
||||
xField: 'label',
|
||||
yField: 'value',
|
||||
height: 150,
|
||||
height: 190,
|
||||
autoFit: true,
|
||||
smooth: true,
|
||||
colorField: 'value',
|
||||
@@ -226,7 +332,7 @@ function AlertTrend({ data }: { data: DashboardLabelValue[] }) {
|
||||
}
|
||||
|
||||
function SeverityPressure({ data }: { data: DashboardLabelValue[] }) {
|
||||
const visibleData = data.filter((item) => item.label !== 'Info')
|
||||
const visibleData = data.filter((item) => !['Info', 'Informational'].includes(item.label))
|
||||
const maxValue = Math.max(...visibleData.map((item) => item.value), 0)
|
||||
const total = visibleData.reduce((sum, item) => sum + item.value, 0)
|
||||
|
||||
@@ -358,12 +464,17 @@ function ThreatKeywordCloud({ data }: { data: DashboardKeyword[] }) {
|
||||
function SecurityDomainRadar({ data }: { data: DashboardLabelValue[] }) {
|
||||
const visibleData = data.filter((item) => item.value > 0)
|
||||
if (!hasValues(visibleData)) return <CyberEmpty title="No Domain Signals" />
|
||||
const maxValue = Math.max(...visibleData.map((item) => item.value), 1)
|
||||
const radarData = visibleData.map((item) => ({
|
||||
...item,
|
||||
fullLabel: item.label,
|
||||
label: compactMitreTacticLabel(item.label),
|
||||
}))
|
||||
const maxValue = Math.max(...radarData.map((item) => item.value), 1)
|
||||
const config = {
|
||||
data: visibleData,
|
||||
data: radarData,
|
||||
xField: 'label',
|
||||
yField: 'value',
|
||||
height: 260,
|
||||
height: 220,
|
||||
autoFit: true,
|
||||
theme: chartTheme,
|
||||
coordinateType: 'polar' as const,
|
||||
@@ -395,7 +506,20 @@ function SecurityDomainRadar({ data }: { data: DashboardLabelValue[] }) {
|
||||
},
|
||||
}
|
||||
|
||||
return <RadarChart {...config} />
|
||||
return (
|
||||
<div className="dashboard-radar-wrap">
|
||||
<RadarChart {...config} />
|
||||
<div className="dashboard-radar-legend">
|
||||
{radarData.map((item, index) => (
|
||||
<span key={`${item.fullLabel}-${index}`} title={`${item.fullLabel}: ${item.value}`}>
|
||||
<i style={{ background: signalColor(index), color: signalColor(index) }} />
|
||||
<strong>{item.label}</strong>
|
||||
<em>{item.fullLabel}</em>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MitreSeverityMatrix({ data }: { data: DashboardMitreSeverityCell[] }) {
|
||||
@@ -514,21 +638,43 @@ function HighlightStream({ highlights }: { highlights: DashboardHighlight[] }) {
|
||||
|
||||
return (
|
||||
<div className="dashboard-event-stream">
|
||||
{highlights.map((item) => (
|
||||
<div className="dashboard-event" key={`${item.kind}-${item.id}`} style={{ '--event-color': severityColor(item.severity) } as React.CSSProperties}>
|
||||
<div className="dashboard-event-title">
|
||||
<strong title={item.title}>{item.title || item.readable_id}</strong>
|
||||
<Badge color={severityColor(item.severity)} text={item.severity || 'Unknown'} />
|
||||
{highlights.map((item) => {
|
||||
const timestamp = eventTimeParts(item.timestamp)
|
||||
const eventAccent = eventAccentColor(item)
|
||||
return (
|
||||
<div
|
||||
className="dashboard-event"
|
||||
key={`${item.kind}-${item.id}`}
|
||||
style={{ '--event-color': eventAccent, '--severity-color': severityColor(item.severity) } as CSSProperties}
|
||||
>
|
||||
<div className="dashboard-event-time">
|
||||
<span>{timestamp.time}</span>
|
||||
<small>{timestamp.date}</small>
|
||||
</div>
|
||||
<div className="dashboard-event-body">
|
||||
<div className="dashboard-event-title">
|
||||
<strong title={item.title}>{item.title || item.readable_id}</strong>
|
||||
<Badge color={severityColor(item.severity)} text={item.severity || 'Unknown'} />
|
||||
</div>
|
||||
<div className="dashboard-event-meta">
|
||||
<span>{item.kind.toUpperCase()}</span>
|
||||
<span>{item.readable_id}</span>
|
||||
<span>{item.status || 'Unknown'}</span>
|
||||
<span>{item.subtitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-event-meta">
|
||||
<span>{item.kind.toUpperCase()}</span>
|
||||
<span>{item.readable_id}</span>
|
||||
<span>{item.status || 'Unknown'}</span>
|
||||
<span>{item.subtitle}</span>
|
||||
<span>{new Date(item.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSignalPill({ label, value, color, emphasis = false }: { label: string; value: ReactNode; color: string; emphasis?: boolean }) {
|
||||
return (
|
||||
<div className={`dashboard-command-pill${emphasis ? ' emphasis' : ''}`} style={{ '--signal-color': color } as CSSProperties}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -558,15 +704,20 @@ export default function Dashboard() {
|
||||
}, [loadOverview, selectedWindow])
|
||||
|
||||
const summary = data?.summary
|
||||
const riskLevel = getRiskPostureLevel(summary?.active_risk_index)
|
||||
|
||||
return (
|
||||
<div className="dashboard-page">
|
||||
<div className="dashboard-shell">
|
||||
<section className="dashboard-hero">
|
||||
<div className="dashboard-hero-content">
|
||||
<div>
|
||||
<div className="dashboard-hero-copy">
|
||||
<div className="dashboard-eyebrow"><RadioTower size={14} /> Cyber Command Center</div>
|
||||
<h1 className="dashboard-title">Security Posture Dashboard</h1>
|
||||
<div className="dashboard-command-summary" style={{ '--posture-color': riskLevel.color } as CSSProperties}>
|
||||
<span className="dashboard-command-state">{riskLevel.band}</span>
|
||||
<span className="dashboard-command-copy">{commandNarrative(data)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-hero-actions">
|
||||
{error && <Alert className="dashboard-soft-alert" type="warning" title={error} showIcon />}
|
||||
@@ -586,18 +737,25 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-command-strip">
|
||||
<CommandSignalPill label="Risk Index" value={`${formatNumber(summary?.active_risk_index)} / 100`} color={riskLevel.color} emphasis />
|
||||
<CommandSignalPill label="Critical Cases" value={formatNumber(summary?.open_critical_cases)} color="#ff4d4f" />
|
||||
<CommandSignalPill label="Critical/High Alerts" value={formatNumber(summary?.critical_high_alerts)} color="#fa8c16" />
|
||||
<CommandSignalPill label="Automation" value={formatPercent(summary?.automation_success_rate)} color={summary?.failed_playbooks ? '#ff4d4f' : '#52c41a'} />
|
||||
<CommandSignalPill label="Window" value={selectedWindow.toUpperCase()} color="#69b1ff" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="dashboard-posture-grid">
|
||||
<RiskPostureCard data={data} loading={loading} />
|
||||
<div className="dashboard-posture-stack">
|
||||
<div className="dashboard-grid posture-kpis">
|
||||
<MetricCard title="Open Critical Cases" value={formatNumber(summary?.open_critical_cases)} accent="#ff4d4f" sub={`${formatNumber(summary?.open_cases)} open cases`} />
|
||||
<MetricCard title="Cases In Window" value={formatNumber(summary?.total_cases)} accent="#1677ff" sub={`${selectedWindow} operating window`} />
|
||||
<MetricCard title="Critical / High Alerts" value={formatNumber(summary?.critical_high_alerts)} accent="#fa8c16" sub={`${formatNumber(summary?.total_alerts)} alerts observed`} />
|
||||
<MetricCard title="Artifacts In Scope" value={formatNumber(summary?.total_artifacts)} accent="#722ed1" sub="Distinct entities linked to alerts" />
|
||||
<MetricCard title="Automation Success" value={formatPercent(summary?.automation_success_rate)} accent="#52c41a" sub={`${formatNumber(summary?.running_playbooks)} running · ${formatNumber(summary?.failed_playbooks)} failed`} />
|
||||
<MetricCard title="Knowledge Signals" value={formatNumber(summary?.total_knowledge)} accent="#13c2c2" sub={`${formatNumber(summary?.total_enrichments)} enrichments`} />
|
||||
<MetricCard title="Open Critical Cases" value={formatNumber(summary?.open_critical_cases)} accent="#ff4d4f" category="Primary Risk" tone="critical" priority sub={`${formatNumber(summary?.open_cases)} open cases`} />
|
||||
<MetricCard title="Critical / High Alerts" value={formatNumber(summary?.critical_high_alerts)} accent="#fa8c16" category="Primary Risk" tone="warning" priority sub={`${formatNumber(summary?.total_alerts)} alerts observed`} />
|
||||
<MetricCard title="Automation Success" value={formatPercent(summary?.automation_success_rate)} accent={summary?.failed_playbooks ? '#ff4d4f' : '#52c41a'} category="Response Capacity" tone={automationTone(summary)} sub={`${formatNumber(summary?.running_playbooks)} running · ${formatNumber(summary?.failed_playbooks)} failed`} />
|
||||
<MetricCard title="Cases In Window" value={formatNumber(summary?.total_cases)} accent="#1677ff" category="Operating Volume" tone="signal" sub={`${selectedWindow} operating window`} />
|
||||
<MetricCard title="Artifacts In Scope" value={formatNumber(summary?.total_artifacts)} accent="#722ed1" category="Entity Surface" tone="intel" sub="Distinct entities linked to alerts" />
|
||||
<MetricCard title="Knowledge Signals" value={formatNumber(summary?.total_knowledge)} accent="#13c2c2" category="Intel Depth" tone="intel" sub={`${formatNumber(summary?.total_enrichments)} enrichments`} />
|
||||
</div>
|
||||
<div className="dashboard-grid posture-mean-times">
|
||||
<MeanTimeCard
|
||||
@@ -635,7 +793,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid operations">
|
||||
<ChartPanel title={<><ChartSpline {...lucideIconProps} /> Alert Telemetry Trend</>} compact>
|
||||
<ChartPanel title={<><ChartSpline {...lucideIconProps} /> Alert Telemetry Trend</>} className="dashboard-trend-card">
|
||||
{loading && !data ? <Skeleton active paragraph={{ rows: 6 }} /> : <AlertTrend data={data?.alert_trend || []} />}
|
||||
</ChartPanel>
|
||||
<ChartPanel title={<><Workflow {...lucideIconProps} /> Automation State</>}>
|
||||
@@ -650,8 +808,8 @@ export default function Dashboard() {
|
||||
<ChartPanel title={<><TextSearch {...lucideIconProps} /> Threat Keyword Cloud</>} className="dashboard-keyword-card">
|
||||
{loading && !data ? <Skeleton active paragraph={{ rows: 6 }} /> : <ThreatKeywordCloud data={data?.threat_keywords || []} />}
|
||||
</ChartPanel>
|
||||
<ChartPanel title={<><RadarIcon {...lucideIconProps} /> Security Domain Radar</>}>
|
||||
{loading && !data ? <Skeleton active paragraph={{ rows: 6 }} /> : <SecurityDomainRadar data={data?.product_category_distribution || []} />}
|
||||
<ChartPanel title={<><RadarIcon {...lucideIconProps} /> MITRE Tactic Radar</>}>
|
||||
{loading && !data ? <Skeleton active paragraph={{ rows: 6 }} /> : <SecurityDomainRadar data={data?.mitre_tactics || []} />}
|
||||
</ChartPanel>
|
||||
<ChartPanel title={<><Target {...lucideIconProps} /> MITRE Severity Matrix</>} className="dashboard-mitre-card">
|
||||
{loading && !data ? <Skeleton active paragraph={{ rows: 6 }} /> : <MitreSeverityMatrix data={data?.mitre_severity_heatmap || []} />}
|
||||
|
||||
Reference in New Issue
Block a user