From e0753fd03eee0c636ac3469b1096a67601d823b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Fri, 1 May 2026 01:28:19 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 223 ++++++++++++++++++++++++++++++++++++- web/static/i18n/en-US.json | 9 ++ web/static/i18n/zh-CN.json | 9 ++ web/static/js/dashboard.js | 100 ++++++++++++++++- web/templates/index.html | 34 ++++++ 5 files changed, 369 insertions(+), 6 deletions(-) diff --git a/web/static/css/style.css b/web/static/css/style.css index cd492209..0b6f2423 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -14180,19 +14180,236 @@ header { } } -/* 漏洞严重程度分布:半环形图(浅色风格) */ +/* 漏洞严重程度分布:半环形图(浅色风格) + 三列布局:[风险概览卡(结论)] [donut(分布图)] [legend(明细)] + —— 左列的「风险概览」填补原来 donut 左侧的留白,把"多危险 / 还有几个紧急项 / 多久前更新"这类 + 结论性信息前置,与右列的分类明细互补。 */ .dashboard-severity-wrap { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(200px, 260px); - gap: 32px; + grid-template-columns: minmax(160px, 180px) minmax(0, 1fr) minmax(200px, 260px); + gap: 24px; align-items: center; } +@media (max-width: 1100px) { + .dashboard-severity-wrap { + grid-template-columns: minmax(0, 1fr) minmax(200px, 260px); + gap: 24px; + } + .dashboard-severity-insights { + grid-column: 1 / -1; + flex-direction: row; + gap: 16px; + } + .dashboard-severity-insights > * { + flex: 1 1 0; + min-width: 0; + } +} + @media (max-width: 820px) { .dashboard-severity-wrap { grid-template-columns: minmax(0, 1fr); gap: 20px; } + .dashboard-severity-insights { + flex-direction: column; + gap: 12px; + } +} + +/* 风险概览卡:竖向堆叠三块小模块(风险等级/待处理/最新时间) */ +.dashboard-severity-insights { + display: flex; + flex-direction: column; + gap: 14px; + align-self: stretch; + justify-content: center; + padding: 4px 0; +} + +/* —— 风险等级模块 —— */ +.dashboard-severity-insight-risk { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 14px; + border-radius: 12px; + background: #fafbfc; + border: 1px solid rgba(0, 0, 0, 0.05); + transition: background 0.2s, border-color 0.2s; +} + +.dashboard-severity-insight-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.dashboard-severity-insight-label { + font-size: 0.75rem; + color: var(--text-secondary); + font-weight: 500; + letter-spacing: 0.02em; + white-space: nowrap; +} + +.dashboard-severity-insight-risk-badge { + font-size: 0.8125rem; + font-weight: 700; + padding: 2px 8px; + border-radius: 999px; + letter-spacing: 0.02em; + line-height: 1.4; + white-space: nowrap; + flex-shrink: 0; +} + +/* 风险等级着色:安全/低/中/高/极高 */ +.dashboard-severity-insight-risk[data-level="safe"] { border-color: rgba(34, 197, 94, 0.20); background: rgba(34, 197, 94, 0.04); } +.dashboard-severity-insight-risk[data-level="safe"] .dashboard-severity-insight-risk-badge { background: rgba(34, 197, 94, 0.12); color: #16a34a; } +.dashboard-severity-insight-risk[data-level="low"] { border-color: rgba(59, 130, 246, 0.22); background: rgba(59, 130, 246, 0.04); } +.dashboard-severity-insight-risk[data-level="low"] .dashboard-severity-insight-risk-badge { background: rgba(59, 130, 246, 0.12); color: #2563eb; } +.dashboard-severity-insight-risk[data-level="medium"] { border-color: rgba(234, 179, 8, 0.25); background: rgba(234, 179, 8, 0.05); } +.dashboard-severity-insight-risk[data-level="medium"] .dashboard-severity-insight-risk-badge { background: rgba(234, 179, 8, 0.15); color: #b45309; } +.dashboard-severity-insight-risk[data-level="high"] { border-color: rgba(249, 115, 22, 0.28); background: rgba(249, 115, 22, 0.05); } +.dashboard-severity-insight-risk[data-level="high"] .dashboard-severity-insight-risk-badge { background: rgba(249, 115, 22, 0.15); color: #c2410c; } +.dashboard-severity-insight-risk[data-level="severe"] { border-color: rgba(239, 68, 68, 0.30); background: rgba(239, 68, 68, 0.06); } +.dashboard-severity-insight-risk[data-level="severe"] .dashboard-severity-insight-risk-badge { background: rgba(239, 68, 68, 0.15); color: #dc2626; } + +/* 风险分进度条 */ +.dashboard-severity-insight-score-track { + width: 100%; + height: 5px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.06); + overflow: hidden; +} + +.dashboard-severity-insight-score-fill { + height: 100%; + border-radius: 999px; + transition: width 0.4s ease, background 0.2s; + background: #94a3b8; +} + +.dashboard-severity-insight-risk[data-level="safe"] .dashboard-severity-insight-score-fill { background: linear-gradient(90deg, #4ade80, #16a34a); } +.dashboard-severity-insight-risk[data-level="low"] .dashboard-severity-insight-score-fill { background: linear-gradient(90deg, #60a5fa, #2563eb); } +.dashboard-severity-insight-risk[data-level="medium"] .dashboard-severity-insight-score-fill { background: linear-gradient(90deg, #facc15, #ca8a04); } +.dashboard-severity-insight-risk[data-level="high"] .dashboard-severity-insight-score-fill { background: linear-gradient(90deg, #fb923c, #ea580c); } +.dashboard-severity-insight-risk[data-level="severe"] .dashboard-severity-insight-score-fill { background: linear-gradient(90deg, #f87171, #dc2626); } + +.dashboard-severity-insight-score-meta { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.dashboard-severity-insight-score-label { + font-size: 0.6875rem; + color: var(--text-secondary); +} + +.dashboard-severity-insight-score-value { + font-size: 0.9375rem; + font-weight: 800; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; +} + +/* —— 待处理紧急项:分组(标题 + 两个小徽章) —— */ +.dashboard-severity-insight-urgent-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.dashboard-severity-insight-urgent { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.dashboard-severity-insight-urgent-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding: 10px 6px; + border-radius: 10px; + background: #fafbfc; + border: 1px solid rgba(0, 0, 0, 0.05); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, transform 0.15s, box-shadow 0.15s; + text-decoration: none; + color: inherit; + min-width: 0; +} + +.dashboard-severity-insight-urgent-item:hover { + transform: translateY(-1px); + background: #fff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04); +} + +.dashboard-severity-insight-urgent-item:focus-visible { + outline: 2px solid rgba(0, 102, 255, 0.5); + outline-offset: 2px; +} + +.dashboard-severity-insight-urgent-item.u-critical { border-color: rgba(239, 68, 68, 0.22); } +.dashboard-severity-insight-urgent-item.u-critical:hover { border-color: rgba(239, 68, 68, 0.40); } +.dashboard-severity-insight-urgent-item.u-high { border-color: rgba(249, 115, 22, 0.22); } +.dashboard-severity-insight-urgent-item.u-high:hover { border-color: rgba(249, 115, 22, 0.40); } + +.dashboard-severity-insight-urgent-value { + font-size: 1.25rem; + font-weight: 800; + line-height: 1.1; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; +} + +.dashboard-severity-insight-urgent-item.u-critical .dashboard-severity-insight-urgent-value { color: #dc2626; } +.dashboard-severity-insight-urgent-item.u-high .dashboard-severity-insight-urgent-value { color: #ea580c; } + +/* 当数量为 0 时,数值变灰,避免在无紧急项时仍然引人注目 */ +.dashboard-severity-insight-urgent-item.is-zero .dashboard-severity-insight-urgent-value { + color: var(--text-secondary); + opacity: 0.7; +} + +.dashboard-severity-insight-urgent-label { + font-size: 0.6875rem; + color: var(--text-secondary); + font-weight: 500; + letter-spacing: 0.02em; + white-space: nowrap; +} + +/* —— 最近发现 —— */ +.dashboard-severity-insight-latest { + display: flex; + flex-direction: column; + gap: 2px; + padding: 2px 4px; +} + +.dashboard-severity-insight-time { + font-size: 0.875rem; + font-weight: 700; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +.dashboard-severity-insight-time.is-empty { + color: var(--text-secondary); + font-weight: 500; + opacity: 0.75; } .dashboard-severity-chart { diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 3e4e4613..39606d84 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -95,6 +95,15 @@ "severityLow": "Low", "severityInfo": "Info", "totalVulns": "Total vulnerabilities", + "riskLevel": "Risk level", + "riskScore": "Weighted risk score", + "riskSafe": "Safe", + "riskLow": "Low", + "riskMedium": "Medium", + "riskHigh": "High", + "riskSevere": "Severe", + "latestFound": "Latest found", + "noneYet": "None yet", "runOverview": "Run overview", "batchQueues": "Batch task queues", "pending": "Pending", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 3e687094..a5f579b5 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -95,6 +95,15 @@ "severityLow": "低危", "severityInfo": "信息", "totalVulns": "总漏洞数", + "riskLevel": "风险等级", + "riskScore": "加权风险分", + "riskSafe": "安全", + "riskLow": "低", + "riskMedium": "中", + "riskHigh": "高", + "riskSevere": "极高", + "latestFound": "最近发现", + "noneYet": "暂无", "runOverview": "运行概览", "batchQueues": "批量任务队列", "pending": "待执行", diff --git a/web/static/js/dashboard.js b/web/static/js/dashboard.js index 9b461d91..93d5c660 100644 --- a/web/static/js/dashboard.js +++ b/web/static/js/dashboard.js @@ -46,6 +46,7 @@ async function refreshDashboard() { if (severityTotalEl) severityTotalEl.textContent = '0'; renderSeverityDonut({}, 0); renderVulnStatusPanel(null, 0); + renderSeverityInsights(null, 0, null); setDashboardOverviewPlaceholder('…'); setEl('dashboard-kpi-tools-calls', '…'); setEl('dashboard-kpi-success-rate', '…'); @@ -91,15 +92,16 @@ async function refreshDashboard() { try { // /api/vulnerabilities/stats 只给出 by_severity 与 by_status 两个独立维度, - // 无法得到「严重 × 待处理」的交叉计数。这里额外拉两次(limit=1,仅取 total), - // 用真实的「待处理严重 / 待处理高危」数量驱动告警条与 KPI 副标,避免修复后仍报警。 + // 无法得到「严重 × 待处理」的交叉计数。这里按四档各拉一次(limit=1,仅取 total), + // 用真实的「待处理 × 各严重度」数量驱动告警条 / KPI 副标 / 风险概览卡的加权分, + // 避免「全部修复后风险等级仍显示极高」这类语义冲突。 var openVulnQuery = function (sev) { return fetchJson('/api/vulnerabilities?severity=' + sev + '&status=open&limit=1'); }; const [ tasksRes, vulnRes, batchRes, monitorRes, knowledgeRes, skillsRes, recentVulnsRes, rolesRes, agentsRes, - openCriticalRes, openHighRes, toolsConfigRes, + openCriticalRes, openHighRes, openMediumRes, openLowRes, toolsConfigRes, hitlPendingRes, notificationsRes, externalMcpStatsRes, webshellRes ] = await Promise.all([ @@ -114,6 +116,9 @@ async function refreshDashboard() { fetchJson('/api/multi-agent/markdown-agents'), openVulnQuery('critical'), openVulnQuery('high'), + // 中/低危的「待处理」计数:用于风险概览卡的加权风险分,使其反映"当前未处理风险" + openVulnQuery('medium'), + openVulnQuery('low'), // 拉取 MCP 工具的「配置总数」用于「能力总览」(区别于 monitor/stats 的「有调用记录」)。 // 仅取 total 字段,page_size=1 减少传输;total 已涵盖内部 + 外部 MCP + 直接注册的工具。 fetchJson('/api/config/tools?page=1&page_size=1'), @@ -173,17 +178,25 @@ async function refreshDashboard() { let criticalCount = 0; let highCount = 0; + let mediumCount = 0; + let lowCount = 0; let openCriticalCount = 0; let openHighCount = 0; + let openMediumCount = 0; + let openLowCount = 0; if (vulnRes && typeof vulnRes.total === 'number') { if (vulnTotalEl) vulnTotalEl.textContent = String(vulnRes.total); const bySeverity = vulnRes.by_severity || {}; const total = vulnRes.total || 0; criticalCount = bySeverity.critical || 0; highCount = bySeverity.high || 0; + mediumCount = bySeverity.medium || 0; + lowCount = bySeverity.low || 0; // 优先用专门拉的「待处理」计数;若专项接口失败,则退回 by_severity(宁可误报,不可漏报) openCriticalCount = pickOpenCount(openCriticalRes, criticalCount); openHighCount = pickOpenCount(openHighRes, highCount); + openMediumCount = pickOpenCount(openMediumRes, mediumCount); + openLowCount = pickOpenCount(openLowRes, lowCount); if (severityTotalEl) severityTotalEl.textContent = String(total); severityIds.forEach(sev => { const count = bySeverity[sev] || 0; @@ -197,6 +210,11 @@ async function refreshDashboard() { }); renderSeverityDonut(bySeverity, total); renderVulnStatusPanel(vulnRes.by_status || {}, total); + renderSeverityInsights( + { critical: openCriticalCount, high: openHighCount, medium: openMediumCount, low: openLowCount }, + openCriticalCount + openHighCount + openMediumCount + openLowCount, + recentVulnsRes + ); // 漏洞 KPI 副标:徽章/文案均使用「待处理」口径 const critBadge = document.getElementById('dashboard-kpi-vuln-critical-badge'); @@ -225,6 +243,7 @@ async function refreshDashboard() { }); renderSeverityDonut({}, 0); renderVulnStatusPanel(null, 0); + renderSeverityInsights(null, 0, null); hideEl('dashboard-kpi-vuln-critical-badge'); setKpiSubText('dashboard-kpi-vuln-sub-text', '-'); } @@ -1133,6 +1152,81 @@ function renderVulnStatusPanel(byStatus, total) { if (confirmedBar) confirmedBar.style.width = confirmedPct.toFixed(2) + '%'; } +// 风险概览卡:基于「待处理(open)」口径的严重度分布计算加权风险分 + 紧急徽章 +// +// 为什么用 open 口径而不是全量: +// 如果用全量,全部漏洞修复后 by_severity 不变,风险分仍然居高, +// 但紧急徽章(待严重/待高危)已经归零——视觉上会出现「极高 + 0 待处理」的语义冲突。 +// 改成 open 口径后,修复即卸掉风险,风险等级与紧急计数完全同步。 +// +// bySeverityOpen: { critical, high, medium, low }(只统计 status=open 的漏洞;info 不计入) +// totalOpen: 待处理漏洞总数(= critical + high + medium + low),仅用于"全无待处理 → safe"判断 +// recentVulnsRes: /api/vulnerabilities?limit=5 响应(用于"最近发现"时间,口径是全量,与处置状态无关) +function renderSeverityInsights(bySeverityOpen, totalOpen, recentVulnsRes) { + var riskBox = document.querySelector('.dashboard-severity-insight-risk'); + var levelEl = document.getElementById('dashboard-severity-risk-level'); + var fillEl = document.getElementById('dashboard-severity-risk-fill'); + var scoreEl = document.getElementById('dashboard-severity-risk-score'); + var urgentCriticalEl = document.getElementById('dashboard-severity-urgent-critical'); + var urgentHighEl = document.getElementById('dashboard-severity-urgent-high'); + var urgentCriticalCell = urgentCriticalEl ? urgentCriticalEl.closest('.dashboard-severity-insight-urgent-item') : null; + var urgentHighCell = urgentHighEl ? urgentHighEl.closest('.dashboard-severity-insight-urgent-item') : null; + var latestEl = document.getElementById('dashboard-severity-latest-time'); + + var sev = bySeverityOpen && typeof bySeverityOpen === 'object' ? bySeverityOpen : {}; + var c = Number(sev.critical || 0) || 0; + var h = Number(sev.high || 0) || 0; + var m = Number(sev.medium || 0) || 0; + var l = Number(sev.low || 0) || 0; + + // 加权分:严重 ×10、高危 ×5、中危 ×2、低危 ×0.5;信息忽略 + // 阈值设计偏"保守":1 个待处理严重就进"中",2 个进"高",≥4 个进"极高" + var score = c * 10 + h * 5 + m * 2 + l * 0.5; + var level, levelKey, levelFallback; + var t = Number(totalOpen || 0) || 0; + if (t === 0 || score === 0) { + level = 'safe'; levelKey = 'dashboard.riskSafe'; levelFallback = '安全'; + } else if (score <= 3) { + level = 'low'; levelKey = 'dashboard.riskLow'; levelFallback = '低'; + } else if (score <= 10) { + level = 'medium'; levelKey = 'dashboard.riskMedium'; levelFallback = '中'; + } else if (score <= 30) { + level = 'high'; levelKey = 'dashboard.riskHigh'; levelFallback = '高'; + } else { + level = 'severe'; levelKey = 'dashboard.riskSevere'; levelFallback = '极高'; + } + + if (riskBox) riskBox.setAttribute('data-level', level); + if (levelEl) levelEl.textContent = dt(levelKey, null, levelFallback); + // 进度条用 0-100 线性映射:>=100 直接满格 + var pct = Math.max(0, Math.min(100, score)); + if (fillEl) fillEl.style.width = pct.toFixed(1) + '%'; + if (scoreEl) { + // 分数保留一位小数(低危 0.5 权重可能出现非整数);整数直接显示 + var displayScore = Math.round(score) === score ? String(score) : score.toFixed(1); + scoreEl.textContent = score >= 100 ? displayScore + '+' : displayScore; + } + + // 紧急徽章直接复用 open 口径的 critical / high(与加权分完全同源,不会出现"风险极高 + 0 待处理"的矛盾) + if (urgentCriticalEl) urgentCriticalEl.textContent = formatNumber(c); + if (urgentHighEl) urgentHighEl.textContent = formatNumber(h); + if (urgentCriticalCell) urgentCriticalCell.classList.toggle('is-zero', c === 0); + if (urgentHighCell) urgentHighCell.classList.toggle('is-zero', h === 0); + + if (latestEl) { + var list = recentVulnsRes && Array.isArray(recentVulnsRes.vulnerabilities) ? recentVulnsRes.vulnerabilities : []; + var latestIso = list.length > 0 ? list[0].created_at : null; + var timeStr = latestIso ? timeAgoStr(latestIso) : ''; + if (timeStr) { + latestEl.textContent = timeStr; + latestEl.classList.remove('is-empty'); + } else { + latestEl.textContent = dt('dashboard.noneYet', null, '暂无'); + latestEl.classList.add('is-empty'); + } + } +} + function renderDashboardToolsBar(monitorRes) { const placeholder = document.getElementById('dashboard-tools-pie-placeholder'); const barChartEl = document.getElementById('dashboard-tools-bar-chart'); diff --git a/web/templates/index.html b/web/templates/index.html index 99ebdf0a..1a8133fe 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -388,6 +388,40 @@ 查看全部 →
+ +