From 65a3475c02175b9416e954ea0e57cf7696e77871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:52:11 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 1180 +++++++++++++++++++++++++++++++--- web/static/i18n/en-US.json | 84 ++- web/static/i18n/zh-CN.json | 73 ++- web/static/js/dashboard.js | 1215 +++++++++++++++++++++++++++++++++--- web/templates/index.html | 346 +++++++--- 5 files changed, 2645 insertions(+), 253 deletions(-) diff --git a/web/static/css/style.css b/web/static/css/style.css index 7fda6e54..4441cbbd 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -12760,7 +12760,7 @@ header { .dashboard-kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 20px; + gap: 16px; margin-bottom: 24px; } @@ -12775,12 +12775,16 @@ header { .dashboard-kpi-card { background: #fff; border-radius: 14px; - padding: 22px; + padding: 18px 20px; cursor: pointer; transition: transform 0.2s ease, box-shadow 0.25s ease; border: 1px solid rgba(0,0,0,0.06); box-shadow: 0 2px 8px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04); position: relative; + display: flex; + flex-direction: column; + gap: 6px; + min-height: 116px; } .dashboard-kpi-card:nth-child(1) { background: linear-gradient(145deg, #fff 0%, #f0f9ff 100%); } @@ -12794,11 +12798,33 @@ header { border-color: rgba(0, 102, 255, 0.2); } +.dashboard-kpi-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.dashboard-kpi-icon { + width: 28px; + height: 28px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.dashboard-kpi-icon-tasks { background: rgba(59, 130, 246, 0.1); color: #3b82f6; } +.dashboard-kpi-icon-vuln { background: rgba(239, 68, 68, 0.1); color: #ef4444; } +.dashboard-kpi-icon-calls { background: rgba(34, 197, 94, 0.1); color: #22c55e; } +.dashboard-kpi-icon-rate { background: rgba(20, 184, 166, 0.1); color: #14b8a6; } + .dashboard-kpi-value { font-size: 1.875rem; font-weight: 800; color: var(--text-primary); - line-height: 1.2; + line-height: 1.1; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; } @@ -12811,10 +12837,496 @@ header { .dashboard-kpi-label { font-size: 0.8125rem; color: var(--text-secondary); - margin-top: 8px; font-weight: 500; } +/* KPI 副标:徽章 + 文本,承载次级信息(严重漏洞数 / 待执行 / 工具数 / 健康度) */ +.dashboard-kpi-sub { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-height: 22px; + margin-top: 2px; +} + +.dashboard-kpi-sub-text { + font-size: 0.75rem; + color: var(--text-secondary); + font-weight: 500; +} + +.dashboard-kpi-sub-text.is-pending { color: #ca8a04; } +.dashboard-kpi-sub-text.is-running { color: #2563eb; } +.dashboard-kpi-sub-text.is-idle { color: #94a3b8; } +.dashboard-kpi-sub-text.is-success { color: #15803d; } +.dashboard-kpi-sub-text.is-warning { color: #ca8a04; } +.dashboard-kpi-sub-text.is-danger { color: #dc2626; } + +.dashboard-kpi-sub-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.6875rem; + font-weight: 600; + line-height: 1.4; + font-variant-numeric: tabular-nums; +} + +.dashboard-kpi-sub-badge[hidden] { display: none; } + +.dashboard-kpi-sub-badge-critical { + background: rgba(239, 68, 68, 0.12); + color: #b91c1c; +} + +.dashboard-kpi-sub-badge .dashboard-kpi-sub-badge-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + opacity: 0.85; +} + +/* 「上次更新」徽章 */ +.dashboard-last-updated { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + color: var(--text-secondary); + padding: 6px 10px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.03); + transition: background 0.2s, color 0.3s; + font-variant-numeric: tabular-nums; +} + +.dashboard-last-updated.is-flash { + background: rgba(34, 197, 94, 0.12); + color: #15803d; + animation: dashboard-last-flash 1.4s ease-out 1; +} + +@keyframes dashboard-last-flash { + 0% { background: rgba(34, 197, 94, 0.22); } + 100% { background: rgba(0, 0, 0, 0.03); } +} + +.dashboard-last-updated-icon { + flex-shrink: 0; +} + +.dashboard-last-updated-time { + font-weight: 600; + color: var(--text-primary); +} + +/* 关键提醒条 */ +.dashboard-alert-banner { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 18px; + border-radius: 14px; + margin-bottom: 18px; + background: linear-gradient(135deg, #fff5f5 0%, #fee2e2 100%); + border: 1px solid rgba(239, 68, 68, 0.25); + color: #7f1d1d; + box-shadow: 0 2px 8px rgba(239, 68, 68, 0.08); + animation: dashboard-alert-slide-in 0.3s ease-out; +} + +@keyframes dashboard-alert-slide-in { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +.dashboard-alert-banner[hidden] { display: none; } + +.dashboard-alert-banner.is-warning { + background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); + border-color: rgba(245, 158, 11, 0.3); + color: #78350f; +} + +.dashboard-alert-banner.is-danger { + background: linear-gradient(135deg, #fff5f5 0%, #fee2e2 100%); + border-color: rgba(239, 68, 68, 0.3); + color: #7f1d1d; +} + +.dashboard-alert-icon { + width: 36px; + height: 36px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.7); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.dashboard-alert-banner.is-danger .dashboard-alert-icon { color: #dc2626; } +.dashboard-alert-banner.is-warning .dashboard-alert-icon { color: #d97706; } + +.dashboard-alert-content { + flex: 1; + min-width: 0; +} + +.dashboard-alert-title { + font-size: 0.9375rem; + font-weight: 700; + line-height: 1.3; + margin-bottom: 2px; +} + +.dashboard-alert-desc { + font-size: 0.8125rem; + line-height: 1.45; + opacity: 0.92; +} + +.dashboard-alert-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.dashboard-alert-btn { + appearance: none; + border: 1px solid currentColor; + background: rgba(255, 255, 255, 0.85); + color: inherit; + font-size: 0.8125rem; + font-weight: 600; + padding: 6px 14px; + border-radius: 8px; + cursor: pointer; + transition: background 0.2s, transform 0.15s; + white-space: nowrap; +} + +.dashboard-alert-btn:hover { + background: #fff; + transform: translateY(-1px); +} + +.dashboard-alert-btn-secondary { + background: transparent; + border-color: rgba(0, 0, 0, 0.15); + color: inherit; + opacity: 0.85; +} + +/* 告警条「× 忽略」按钮:低权重,hover 时才显形 */ +.dashboard-alert-close { + appearance: none; + background: transparent; + border: none; + color: inherit; + opacity: 0.45; + cursor: pointer; + width: 26px; + height: 26px; + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: opacity 0.15s, background 0.15s; +} + +.dashboard-alert-close:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.06); +} + +/* 「上次更新」过期状态:变灰 + ⚠️ 图标显形 */ +.dashboard-last-updated.is-stale { + background: rgba(245, 158, 11, 0.08); + color: #92400e; + border-color: rgba(245, 158, 11, 0.25); +} + +.dashboard-last-updated-stale { + display: inline-flex; + align-items: center; + color: #d97706; + margin-left: 4px; +} + +.dashboard-last-updated-stale[hidden] { display: none; } + +/* 推荐操作 section:紧急 / 警告 / 配置 三类,颜色区分但不喧宾夺主 */ +.dashboard-section-recommend[hidden] { display: none; } + +.dashboard-section-hint { + font-size: 0.75rem; + color: var(--text-secondary, #6b7280); + font-weight: 400; + margin-left: auto; +} + +.dashboard-recommend-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 10px; + margin-top: 10px; +} + +.dashboard-recommend-item { + display: grid; + grid-template-columns: 36px 1fr auto; + align-items: center; + column-gap: 12px; + padding: 12px 14px; + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.06); + border-radius: 10px; + cursor: pointer; + transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s; + text-decoration: none; + color: inherit; +} + +.dashboard-recommend-item:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); + border-color: rgba(0, 0, 0, 0.12); +} + +.dashboard-recommend-item:focus-visible { + outline: 2px solid #6366f1; + outline-offset: 2px; +} + +.dashboard-recommend-icon { + width: 36px; + height: 36px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.dashboard-recommend-item.lvl-urgent .dashboard-recommend-icon { + background: #fee2e2; + color: #dc2626; +} +.dashboard-recommend-item.lvl-warning .dashboard-recommend-icon { + background: #fef3c7; + color: #d97706; +} +.dashboard-recommend-item.lvl-setup .dashboard-recommend-icon { + background: #dbeafe; + color: #2563eb; +} + +/* 紧急推荐:左侧加红条强调 */ +.dashboard-recommend-item.lvl-urgent { + border-left: 3px solid #ef4444; +} + +.dashboard-recommend-body { + min-width: 0; +} + +.dashboard-recommend-title { + font-size: 0.9375rem; + font-weight: 600; + color: var(--text-primary, #111827); + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; +} + +.dashboard-recommend-desc { + font-size: 0.75rem; + color: var(--text-secondary, #6b7280); + margin-top: 2px; + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; +} + +.dashboard-recommend-arrow { + color: var(--text-tertiary, #9ca3af); + font-size: 1.125rem; + font-weight: 400; + transition: transform 0.15s, color 0.15s; +} + +.dashboard-recommend-item:hover .dashboard-recommend-arrow { + color: #6366f1; + transform: translateX(2px); +} + +/* 最近事件 section:列表风格,按 p0/p1/p2 着色 */ +.dashboard-section-events[hidden] { display: none; } + +.dashboard-events-list { + display: flex; + flex-direction: column; + gap: 1px; + background: rgba(0, 0, 0, 0.04); + border-radius: 8px; + overflow: hidden; +} + +.dashboard-event-item { + display: grid; + grid-template-columns: 8px 1fr auto; + align-items: center; + column-gap: 12px; + padding: 10px 12px; + background: #fff; +} + +.dashboard-event-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #9ca3af; + flex-shrink: 0; +} + +.dashboard-event-item.lvl-p0 .dashboard-event-dot { background: #ef4444; } +.dashboard-event-item.lvl-p1 .dashboard-event-dot { background: #f59e0b; } +.dashboard-event-item.lvl-p2 .dashboard-event-dot { background: #6b7280; } + +.dashboard-event-body { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.dashboard-event-title { + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-primary, #111827); + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dashboard-event-msg { + font-size: 0.75rem; + color: var(--text-secondary, #6b7280); + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dashboard-event-time { + font-size: 0.6875rem; + color: var(--text-tertiary, #9ca3af); + font-variant-numeric: tabular-nums; + white-space: nowrap; + flex-shrink: 0; +} + +/* External MCP 健康度:能力总览中专门一行的 N/N + 状态徽章 */ +.dashboard-resource-icon-external { + background: #eef2ff; + color: #4f46e5; +} + +.dashboard-resource-health { + display: inline-flex; + align-items: center; + margin-left: 8px; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.6875rem; + font-weight: 600; + line-height: 1.4; + background: rgba(0, 0, 0, 0.05); + color: var(--text-secondary, #6b7280); +} + +.dashboard-resource-health.is-ok { + background: rgba(16, 185, 129, 0.1); + color: #047857; +} + +.dashboard-resource-health.is-warning { + background: rgba(245, 158, 11, 0.12); + color: #b45309; +} + +.dashboard-resource-health.is-danger { + background: rgba(239, 68, 68, 0.12); + color: #b91c1c; +} + +/* 升级版「最近漏洞」空状态:图标 + 标题 + 描述 + 行动按钮 */ +.dashboard-recent-vulns-empty.is-rich { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 28px 16px; + text-align: center; + gap: 8px; +} + +.dashboard-empty-icon { + color: #c7d2fe; + margin-bottom: 4px; +} + +.dashboard-empty-title { + font-size: 0.9375rem; + font-weight: 600; + color: var(--text-primary, #111827); +} + +.dashboard-empty-desc { + font-size: 0.8125rem; + color: var(--text-secondary, #6b7280); + line-height: 1.4; + max-width: 420px; +} + +.dashboard-empty-action { + appearance: none; + border: 1px solid #6366f1; + background: #6366f1; + color: #fff; + padding: 8px 16px; + border-radius: 8px; + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + margin-top: 8px; + transition: background 0.15s, transform 0.15s; +} + +.dashboard-empty-action:hover { + background: #4f46e5; + transform: translateY(-1px); +} + +@media (max-width: 720px) { + .dashboard-alert-banner { + flex-wrap: wrap; + } + .dashboard-alert-actions { + width: 100%; + } + .dashboard-recommend-list { + grid-template-columns: 1fr; + } +} + /* 两列主内容网格 */ .dashboard-grid { display: grid; @@ -12867,6 +13379,261 @@ header { letter-spacing: -0.01em; } +/* 章节标题 + 「查看全部」链接横排(标题不再单独占下边框) */ +.dashboard-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; + padding-bottom: 10px; + border-bottom: 2px solid #f1f5f9; +} + +.dashboard-section-header .dashboard-section-title { + margin: 0; + padding-bottom: 0; + border-bottom: none; +} + +.dashboard-section-link { + font-size: 0.8125rem; + color: var(--text-secondary); + cursor: pointer; + text-decoration: none; + font-weight: 500; + transition: color 0.2s, transform 0.15s; + flex-shrink: 0; + user-select: none; +} + +.dashboard-section-link:hover { + color: #0066ff; + transform: translateX(2px); +} + +/* 最近漏洞列表 */ +.dashboard-recent-vulns { + display: flex; + flex-direction: column; + gap: 4px; + min-height: 60px; +} + +.dashboard-recent-vulns-empty { + text-align: center; + color: var(--text-secondary); + padding: 28px 12px; + font-size: 0.875rem; + background: #fafbfc; + border-radius: 10px; + border: 1px dashed rgba(0, 0, 0, 0.08); +} + +.dashboard-recent-vuln-item { + display: grid; + grid-template-columns: 56px minmax(0, 1.6fr) minmax(0, 1fr) auto auto; + align-items: center; + column-gap: 14px; + padding: 12px 10px; + border-radius: 8px; + cursor: pointer; + transition: background 0.15s, transform 0.15s; + text-decoration: none; + color: inherit; + border-bottom: 1px solid #f3f4f6; +} + +.dashboard-recent-vuln-item:last-child { + border-bottom: none; +} + +.dashboard-recent-vuln-item:hover { + background: rgba(0, 102, 255, 0.04); +} + +.dashboard-recent-vuln-item:focus-visible { + outline: 2px solid rgba(0, 102, 255, 0.5); + outline-offset: 2px; +} + +.dashboard-recent-vuln-sev { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 3px 8px; + border-radius: 6px; + font-size: 0.75rem; + font-weight: 700; + line-height: 1.2; + width: fit-content; +} + +.dashboard-recent-vuln-sev.sev-critical { background: rgba(239, 68, 68, 0.12); color: #b91c1c; } +.dashboard-recent-vuln-sev.sev-high { background: rgba(249, 115, 22, 0.12); color: #c2410c; } +.dashboard-recent-vuln-sev.sev-medium { background: rgba(234, 179, 8, 0.15); color: #a16207; } +.dashboard-recent-vuln-sev.sev-low { background: rgba(20, 184, 166, 0.12); color: #0f766e; } +.dashboard-recent-vuln-sev.sev-info { background: rgba(59, 130, 246, 0.12); color: #1d4ed8; } + +.dashboard-recent-vuln-title { + font-weight: 600; + color: var(--text-primary); + font-size: 0.875rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-recent-vuln-target { + color: var(--text-secondary); + font-size: 0.8125rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; +} + +.dashboard-recent-vuln-time { + color: var(--text-secondary); + font-size: 0.75rem; + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +/* 状态药丸:和处置状态卡片用同一套语义色,但采用更克制的尺寸 */ +.dashboard-recent-vuln-status { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 9px; + border-radius: 999px; + font-size: 0.6875rem; + font-weight: 600; + line-height: 1.4; + white-space: nowrap; + border: 1px solid transparent; +} + +.dashboard-recent-vuln-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + opacity: 0.85; +} + +.dashboard-recent-vuln-status.st-open { + background: rgba(239, 68, 68, 0.08); + color: #b91c1c; + border-color: rgba(239, 68, 68, 0.18); +} + +.dashboard-recent-vuln-status.st-confirmed { + background: rgba(245, 158, 11, 0.10); + color: #b45309; + border-color: rgba(245, 158, 11, 0.20); +} + +.dashboard-recent-vuln-status.st-fixed { + background: rgba(34, 197, 94, 0.10); + color: #15803d; + border-color: rgba(34, 197, 94, 0.20); +} + +.dashboard-recent-vuln-status.st-fp { + background: rgba(148, 163, 184, 0.16); + color: #475569; + border-color: rgba(148, 163, 184, 0.25); +} + +@media (max-width: 720px) { + .dashboard-recent-vuln-item { + grid-template-columns: 56px minmax(0, 1fr) auto auto; + } + .dashboard-recent-vuln-target { display: none; } +} + +@media (max-width: 480px) { + .dashboard-recent-vuln-item { + grid-template-columns: 56px minmax(0, 1fr) auto; + } + .dashboard-recent-vuln-time { display: none; } +} + +/* 能力总览 - 侧栏列表 */ +.dashboard-resource-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.dashboard-resource-item { + display: grid; + grid-template-columns: 28px 1fr auto; + align-items: center; + gap: 12px; + padding: 10px 4px; + border-radius: 8px; + cursor: pointer; + text-decoration: none; + color: inherit; + transition: background 0.15s; + border-bottom: 1px solid #f3f4f6; +} + +/* External MCP 行可能在没配置时隐藏,display:grid 会覆盖 [hidden] 默认行为,需补一条 */ +.dashboard-resource-item[hidden] { display: none; } + +.dashboard-resource-item:last-child { + border-bottom: none; +} + +.dashboard-resource-item:hover { + background: rgba(0, 102, 255, 0.04); +} + +.dashboard-resource-item:focus-visible { + outline: 2px solid rgba(0, 102, 255, 0.5); + outline-offset: 2px; +} + +.dashboard-resource-icon { + width: 28px; + height: 28px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.dashboard-resource-icon-mcp { background: rgba(99, 102, 241, 0.1); color: #6366f1; } +.dashboard-resource-icon-skills { background: rgba(168, 85, 247, 0.1); color: #a855f7; } +.dashboard-resource-icon-knowledge { background: rgba(34, 197, 94, 0.1); color: #22c55e; } +.dashboard-resource-icon-roles { background: rgba(245, 158, 11, 0.1); color: #f59e0b; } +.dashboard-resource-icon-agents { background: rgba(20, 184, 166, 0.1); color: #14b8a6; } +/* WebShell 用偏暗的青蓝色,跟"终端 / shell"语义对得上,又跟其他几个色相区分 */ +.dashboard-resource-icon-webshell { background: rgba(8, 145, 178, 0.1); color: #0e7490; } + +.dashboard-resource-label { + font-size: 0.875rem; + color: var(--text-primary); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-resource-value { + font-size: 0.9375rem; + font-weight: 700; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + min-width: 1.5em; + text-align: right; +} + .dashboard-overview-list { display: flex; flex-direction: column; @@ -13184,91 +13951,358 @@ header { } } -.dashboard-chart-wrap { +/* 漏洞严重程度分布:半环形图(浅色风格) */ +.dashboard-severity-wrap { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(200px, 260px); + gap: 32px; + align-items: center; +} + +@media (max-width: 820px) { + .dashboard-severity-wrap { + grid-template-columns: minmax(0, 1fr); + gap: 20px; + } +} + +.dashboard-severity-chart { + position: relative; + width: 100%; + max-width: 480px; + margin: 0 auto; + aspect-ratio: 480 / 260; +} + +.dashboard-severity-donut { + width: 100%; + height: 100%; + display: block; + overflow: visible; +} + +.dashboard-severity-donut .donut-track { + fill: #f1f5f9; +} + +.dashboard-severity-donut .donut-segment { + /* 段与段之间用白色描边制造“切割线”效果,与参考图二一致; + 环回到黄金比例(厚度 50)后,描边也用回 4,切割线感更强 */ + stroke: #ffffff; + stroke-width: 4; + stroke-linejoin: round; + transition: opacity 0.2s ease; + cursor: default; +} + +.dashboard-severity-donut .donut-segment.is-empty { + display: none; +} + +.dashboard-severity-donut .donut-label-text { + font-size: 14px; + font-weight: 700; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; +} + +.dashboard-severity-donut .donut-label-text .donut-label-pct { + font-weight: 500; + font-size: 11px; + fill: var(--text-secondary, #6b7280); +} + +.dashboard-severity-donut .donut-label-text .donut-label-name { + font-size: 12px; + font-weight: 600; +} + +.dashboard-severity-donut .donut-label-text.label-critical { fill: #ef4444; } +.dashboard-severity-donut .donut-label-text.label-high { fill: #f97316; } +.dashboard-severity-donut .donut-label-text.label-medium { fill: #eab308; } +.dashboard-severity-donut .donut-label-text.label-low { fill: #14b8a6; } +.dashboard-severity-donut .donut-label-text.label-info { fill: #3b82f6; } + +/* 半环形配色:保持原有浅色基调(红→橙→黄→青→蓝) */ +.dashboard-severity-donut .donut-segment.seg-critical { fill: #f87171; } +.dashboard-severity-donut .donut-segment.seg-high { fill: #fb923c; } +.dashboard-severity-donut .donut-segment.seg-medium { fill: #facc15; } +.dashboard-severity-donut .donut-segment.seg-low { fill: #2dd4bf; } +.dashboard-severity-donut .donut-segment.seg-info { fill: #60a5fa; } + +.dashboard-severity-center { + position: absolute; + left: 50%; + /* cy 在 viewBox(0,0,480,260) 中是 215,约 83% 处; + 这里把中心文字放在内圈靠下、靠近直径线的位置,让数字看起来"坐"在半圆里。 */ + top: 76%; + transform: translate(-50%, -50%); + text-align: center; + pointer-events: none; + width: 60%; +} + +.dashboard-severity-center-value { + font-size: 2.75rem; + font-weight: 800; + line-height: 1; + color: var(--text-primary); + letter-spacing: -0.03em; + font-variant-numeric: tabular-nums; +} + +.dashboard-severity-center-label { + font-size: 0.8125rem; + color: var(--text-secondary); + margin-top: 8px; + letter-spacing: 0.04em; + font-weight: 500; +} + +@media (max-width: 720px) { + .dashboard-severity-center-value { font-size: 2.25rem; } + .dashboard-severity-center-label { font-size: 0.75rem; } +} + +.dashboard-severity-legend { display: flex; flex-direction: column; - gap: 20px; + gap: 2px; + align-self: center; } -.dashboard-stacked-bar { - display: flex; - width: 100%; - height: 28px; - border-radius: 14px; - overflow: hidden; - background: #f1f5f9; -} - -.dashboard-bar-seg { - height: 100%; - min-width: 2px; - transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1); - opacity: 0.92; - border-right: 1px solid rgba(241, 245, 249, 0.9); - box-sizing: border-box; -} - -.dashboard-bar-seg:first-child { - border-radius: 14px 0 0 14px; -} - -.dashboard-bar-seg:last-child { - border-right: none; - border-radius: 0 14px 14px 0; -} - -/* 严重→信息:柔和连续色阶,红→橙→黄→绿→蓝,无灰沉感 */ -.dashboard-bar-seg.seg-critical { background: linear-gradient(90deg, #f87171, #fca5a5); } -.dashboard-bar-seg.seg-high { background: linear-gradient(90deg, #fb923c, #fdba74); } -.dashboard-bar-seg.seg-medium { background: linear-gradient(90deg, #facc15, #fde047); } -.dashboard-bar-seg.seg-low { background: linear-gradient(90deg, #34d399, #6ee7b7); } -.dashboard-bar-seg.seg-info { background: linear-gradient(90deg, #60a5fa, #93c5fd); } - -.dashboard-legend { - display: flex; - flex-wrap: wrap; - gap: 16px 32px; +.dashboard-severity-legend-item { + display: grid; + grid-template-columns: 12px minmax(0, 1fr) 2.5em 3em; + column-gap: 14px; align-items: center; + padding: 10px 4px; + font-size: 0.9375rem; + border-bottom: 1px solid transparent; + transition: background 0.2s, border-color 0.2s; + border-radius: 4px; } -.dashboard-legend-item { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 0.875rem; - padding: 6px 12px; - background: #f8fafc; - border-radius: 20px; - transition: background 0.2s; +.dashboard-severity-legend-item:hover { + background: rgba(0, 0, 0, 0.025); } -.dashboard-legend-item:hover { - background: #f1f5f9; -} - -.dashboard-legend-dot { +.dashboard-severity-legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; - box-shadow: 0 0 0 2px rgba(255,255,255,0.8); } -/* 与条形图同色系,图例与条形视觉一致 */ -.dashboard-legend-dot.critical { background: #f87171; } -.dashboard-legend-dot.high { background: #fb923c; } -.dashboard-legend-dot.medium { background: #facc15; } -.dashboard-legend-dot.low { background: #34d399; } -.dashboard-legend-dot.info { background: #60a5fa; } +.dashboard-severity-legend-dot.critical { background: #f87171; } +.dashboard-severity-legend-dot.high { background: #fb923c; } +.dashboard-severity-legend-dot.medium { background: #facc15; } +.dashboard-severity-legend-dot.low { background: #2dd4bf; } +.dashboard-severity-legend-dot.info { background: #60a5fa; } -.dashboard-legend-label { +.dashboard-severity-legend-label { + color: var(--text-primary); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-severity-legend-value { + font-weight: 700; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.dashboard-severity-legend-pct { + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + text-align: right; +} + +/* 漏洞处置状态 + 修复进度(占据 donut 下方留白) */ +.dashboard-severity-status { + grid-column: 1 / -1; + padding-top: 18px; + border-top: 1px dashed rgba(0, 0, 0, 0.08); + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); + gap: 24px; + align-items: center; +} + +@media (max-width: 980px) { + .dashboard-severity-status { + grid-template-columns: minmax(0, 1fr); + gap: 16px; + } +} + +.dashboard-severity-status-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +@media (max-width: 480px) { + .dashboard-severity-status-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.dashboard-severity-status-cell { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + 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; + text-decoration: none; + color: inherit; + min-width: 0; +} + +.dashboard-severity-status-cell:hover { + transform: translateY(-1px); + border-color: rgba(0, 102, 255, 0.18); + background: #fff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04); +} + +.dashboard-severity-status-cell:focus-visible { + outline: 2px solid rgba(0, 102, 255, 0.5); + outline-offset: 2px; +} + +.dashboard-severity-status-icon { + width: 30px; + height: 30px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.dashboard-severity-status-cell.s-open .dashboard-severity-status-icon { background: rgba(239, 68, 68, 0.10); color: #dc2626; } +.dashboard-severity-status-cell.s-confirmed .dashboard-severity-status-icon { background: rgba(245, 158, 11, 0.12); color: #d97706; } +.dashboard-severity-status-cell.s-fixed .dashboard-severity-status-icon { background: rgba(34, 197, 94, 0.12); color: #16a34a; } +.dashboard-severity-status-cell.s-fp .dashboard-severity-status-icon { background: rgba(148, 163, 184, 0.18); color: #64748b; } + +.dashboard-severity-status-text { + display: flex; + flex-direction: column; + line-height: 1.2; + min-width: 0; +} + +.dashboard-severity-status-value { + font-size: 1.125rem; + font-weight: 800; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; +} + +.dashboard-severity-status-label { + font-size: 0.75rem; + color: var(--text-secondary); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dashboard-severity-progress { + display: flex; + flex-direction: column; + gap: 8px; +} + +.dashboard-severity-progress-meta { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.dashboard-severity-progress-title { + font-size: 0.8125rem; + color: var(--text-secondary); + font-weight: 600; +} + +.dashboard-severity-progress-value { + display: inline-flex; + align-items: baseline; + gap: 6px; +} + +.dashboard-severity-progress-value > span:first-child { + font-size: 1.125rem; + font-weight: 800; + color: #16a34a; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; +} + +.dashboard-severity-progress-detail { + font-size: 0.75rem; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.dashboard-severity-progress-track { + display: flex; + width: 100%; + height: 8px; + border-radius: 999px; + background: #f1f5f9; + overflow: hidden; +} + +.dashboard-severity-progress-fixed { + background: linear-gradient(90deg, #4ade80 0%, #16a34a 100%); + transition: width 0.4s ease; +} + +.dashboard-severity-progress-confirmed { + background: linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%); + transition: width 0.4s ease; +} + +.dashboard-severity-progress-legend { + display: flex; + flex-wrap: wrap; + gap: 12px; + font-size: 0.6875rem; color: var(--text-secondary); } -.dashboard-legend-value { - font-weight: 700; - color: var(--text-primary); - min-width: 1.5em; +.dashboard-severity-progress-legend-item { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.dashboard-severity-progress-legend-dot { + width: 8px; + height: 8px; + border-radius: 2px; + display: inline-block; +} + +.dashboard-severity-progress-legend-dot.legend-fixed { background: #16a34a; } +.dashboard-severity-progress-legend-dot.legend-confirmed { background: #f59e0b; } +.dashboard-severity-progress-legend-dot.legend-open { background: #f1f5f9; border: 1px solid #cbd5e1; } + +@media (prefers-reduced-motion: reduce) { + .dashboard-severity-status-cell { transition: none; } + .dashboard-severity-progress-fixed, + .dashboard-severity-progress-confirmed { transition: none; } } .dashboard-quick-links { @@ -13478,6 +14512,10 @@ header { overflow: hidden; } +/* HTML5 hidden 属性默认 display:none 会被 .dashboard-cta-block 的 display:flex 覆盖, + 补一条同特异性规则确保智能 CTA 隐藏逻辑生效 */ +.dashboard-cta-block[hidden] { display: none; } + .dashboard-cta-content { display: flex; align-items: center; @@ -13584,7 +14622,7 @@ header { .dashboard-tools-bar-fill { animation: none; } - .dashboard-bar-seg { + .dashboard-severity-donut .donut-segment { transition: none; } } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 54a765f4..78326120 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -20,7 +20,13 @@ "copied": "Copied", "copyFailed": "Copy failed", "view": "View", - "actions": "Actions" + "actions": "Actions", + "loadFailed": "Load failed", + "untitled": "Untitled", + "justNow": "Just now", + "minutesAgo": "{{n}} min ago", + "hoursAgo": "{{n}} h ago", + "daysAgo": "{{n}} d ago" }, "header": { "title": "CyberStrikeAI", @@ -88,6 +94,7 @@ "severityMedium": "Medium", "severityLow": "Low", "severityInfo": "Info", + "totalVulns": "Total vulnerabilities", "runOverview": "Run overview", "batchQueues": "Batch task queues", "pending": "Pending", @@ -114,7 +121,80 @@ "toUse": "To use", "active": "Active", "highFreq": "High frequency", - "noCallData": "No call data" + "noCallData": "No call data", + "lastUpdated": "Last updated", + "viewAll": "View all →", + "recentVulns": "Recent vulnerabilities", + "noVulnYet": "No vulnerabilities yet — start your first scan", + "capabilities": "Capabilities", + "mcpTools": "MCP tools", + "rolesLabel": "Roles", + "agentsLabel": "Agents", + "webshellLabel": "WebShell", + "pendingCountLabel": "{{count}} pending", + "highCountLabel": "High {{count}}", + "toolsCountLabel_one": "{{count}} tool", + "toolsCountLabel_other": "{{count}} tools", + "failedNCalls_one": "{{count}} failed", + "failedNCalls_other": "{{count}} failed", + "noCallYet": "No calls yet", + "allClear": "No new risks", + "allIdle": "System idle", + "executingNow": "Running", + "healthyStatus": "Healthy", + "normalStatus": "Mostly OK", + "degradedStatus": "Needs attention", + "alertTitle": "Heads up", + "alertWarningTitle": "Needs attention", + "alertDangerTitle": "Action required", + "alertCriticalReason_one": "{{count}} open critical vulnerability — please review immediately", + "alertCriticalReason_other": "{{count}} open critical vulnerabilities — please review immediately", + "alertFailedReason_one": "Tool success rate is low ({{count}} failed call) — check MCP monitor", + "alertFailedReason_other": "Tool success rate is low ({{count}} failed calls) — check MCP monitor", + "alertHitlReason_one": "{{count}} HITL request pending — Agent is waiting for your decision", + "alertHitlReason_other": "{{count}} HITL requests pending — Agent is waiting for your decision", + "alertMcpDownReason_one": "{{count}} External MCP server is down — related tools are unavailable", + "alertMcpDownReason_other": "{{count}} External MCP servers are down — related tools are unavailable", + "alertDismiss": "Dismiss (this session)", + "openHighCountLabel": "Open high {{count}}", + "allHandled": "All high severity handled", + "viewVulns": "View vulnerabilities", + "viewMonitor": "View monitor", + "viewHitl": "Approve", + "viewMcpManagement": "Manage MCP", + "statusOpen": "Open", + "statusConfirmed": "Confirmed", + "statusFixed": "Fixed", + "statusFalsePositive": "False positive", + "fixRate": "Fix rate", + "dataStale": "Data may be stale — please refresh", + "recommendedActions": "Recommended Actions", + "recommendedActionsHint": "Generated based on current state", + "recoFixCritical_one": "Fix {{count}} open critical vulnerability", + "recoFixCritical_other": "Fix {{count}} open critical vulnerabilities", + "recoFixCriticalDesc": "Critical-level vulnerabilities should be addressed first", + "recoApproveHitl_one": "Approve {{count}} HITL request", + "recoApproveHitl_other": "Approve {{count}} HITL requests", + "recoApproveHitlDesc": "Agent needs your decision to proceed", + "recoRestartMcp_one": "Check {{count}} stopped External MCP", + "recoRestartMcp_other": "Check {{count}} stopped External MCPs", + "recoRestartMcpDesc": "Related tools are unavailable until MCP recovers", + "recoCheckMonitor_one": "Investigate {{count}} failed tool call", + "recoCheckMonitor_other": "Investigate {{count}} failed tool calls", + "recoCheckMonitorDesc": "View failed request details in MCP monitor", + "recoSetupMcp": "Configure your first MCP tool", + "recoSetupMcpDesc": "Install MCP server before Agent can invoke specific capabilities", + "recoStartScan": "Start your first scan", + "recoStartScanDesc": "Describe your target in chat, AI will help execute", + "recentEvents": "Recent Events", + "eventUntitled": "Event", + "externalMcpServers": "External MCP", + "mcpAllRunning": "All running", + "mcpPartialDown_one": "{{count}} stopped", + "mcpPartialDown_other": "{{count}} stopped", + "mcpAllDown": "All stopped", + "noVulnDesc": "System looks safe — start a scan to discover potential issues", + "startScanBtn": "Go to chat to scan" }, "chat": { "newChat": "New chat", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 019bfcc9..335d22af 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -20,7 +20,13 @@ "copied": "已复制", "copyFailed": "复制失败", "view": "查看", - "actions": "操作" + "actions": "操作", + "loadFailed": "加载失败", + "untitled": "未命名", + "justNow": "刚刚", + "minutesAgo": "{{n}} 分钟前", + "hoursAgo": "{{n}} 小时前", + "daysAgo": "{{n}} 天前" }, "header": { "title": "CyberStrikeAI", @@ -88,6 +94,7 @@ "severityMedium": "中危", "severityLow": "低危", "severityInfo": "信息", + "totalVulns": "总漏洞数", "runOverview": "运行概览", "batchQueues": "批量任务队列", "pending": "待执行", @@ -114,7 +121,69 @@ "toUse": "待使用", "active": "活跃", "highFreq": "高频", - "noCallData": "暂无调用数据" + "noCallData": "暂无调用数据", + "lastUpdated": "上次更新", + "viewAll": "查看全部 →", + "recentVulns": "最近漏洞", + "noVulnYet": "暂无漏洞,开始你的第一次扫描吧", + "capabilities": "能力总览", + "mcpTools": "MCP 工具", + "rolesLabel": "角色", + "agentsLabel": "Agents", + "webshellLabel": "WebShell", + "pendingCountLabel": "{{count}} 待执行", + "highCountLabel": "高危 {{count}}", + "toolsCountLabel": "{{count}} 个工具", + "failedNCalls": "{{count}} 次失败", + "noCallYet": "暂无调用", + "allClear": "暂无新增风险", + "allIdle": "系统空闲", + "executingNow": "正在执行", + "healthyStatus": "运行平稳", + "normalStatus": "基本正常", + "degradedStatus": "需要关注", + "alertTitle": "需要关注", + "alertWarningTitle": "需要关注", + "alertDangerTitle": "需要立即处理", + "alertCriticalReason": "存在 {{count}} 个待处理的严重漏洞,建议立即处置", + "alertFailedReason": "工具调用成功率偏低({{count}} 次失败),请检查 MCP 监控", + "alertHitlReason": "有 {{count}} 个待审批的人机协同请求,Agent 正在等待你的决策", + "alertMcpDownReason": "External MCP 服务器有 {{count}} 个未运行,相关工具不可用", + "alertDismiss": "忽略此提醒(仅本次会话)", + "openHighCountLabel": "待处理高危 {{count}}", + "allHandled": "高严重度已全部处置", + "viewVulns": "查看漏洞", + "viewMonitor": "查看监控", + "viewHitl": "前往审批", + "viewMcpManagement": "管理 MCP", + "statusOpen": "待处理", + "statusConfirmed": "已确认", + "statusFixed": "已修复", + "statusFalsePositive": "误报", + "fixRate": "修复率", + "dataStale": "数据可能已过期,请手动刷新", + "recommendedActions": "推荐操作", + "recommendedActionsHint": "基于当前状态自动生成", + "recoFixCritical": "修复 {{count}} 个待处理严重漏洞", + "recoFixCriticalDesc": "严重等级的漏洞应优先处置", + "recoApproveHitl": "审批 {{count}} 个 HITL 请求", + "recoApproveHitlDesc": "Agent 正在等待你的决策才能继续", + "recoRestartMcp": "检查 {{count}} 个未运行的 External MCP", + "recoRestartMcpDesc": "相关工具在 MCP 服务恢复前不可用", + "recoCheckMonitor": "排查 {{count}} 次工具调用失败", + "recoCheckMonitorDesc": "在 MCP 监控中查看失败的请求详情", + "recoSetupMcp": "配置首个 MCP 工具", + "recoSetupMcpDesc": "安装 MCP 服务后 Agent 才能调用具体能力", + "recoStartScan": "开始第一次扫描", + "recoStartScanDesc": "在对话中描述目标,让 AI 协助执行", + "recentEvents": "最近事件", + "eventUntitled": "事件", + "externalMcpServers": "External MCP", + "mcpAllRunning": "全部运行", + "mcpPartialDown": "{{count}} 个未运行", + "mcpAllDown": "全部未运行", + "noVulnDesc": "系统目前安全,开始一次扫描可以发现潜在问题", + "startScanBtn": "前往对话发起扫描" }, "chat": { "newChat": "新对话", diff --git a/web/static/js/dashboard.js b/web/static/js/dashboard.js index 2c6b1d25..0ff94f97 100644 --- a/web/static/js/dashboard.js +++ b/web/static/js/dashboard.js @@ -1,81 +1,232 @@ -// 仪表盘页面:拉取运行中任务、漏洞统计、批量任务、工具与 Skills 统计并渲染 +// 仪表盘页面:拉取运行中任务、漏洞统计、批量任务、工具与 Skills 统计并渲染。 +// +// 工程基础设施: +// - dashboardState 集中保存运行时状态(in-flight controller / 自动轮询 timer / 上次更新时间 / +// 已被本会话忽略的告警条 reasons); +// - 每次 refreshDashboard 入口 abort 上一个 controller,把 signal 传给所有 apiFetch, +// 避免快速连点 / 自动轮询触发 race condition; +// - 自动轮询:startDashboardAutoRefresh() 每 60 秒拉一次;页面切走 / tab 隐藏时自动暂停, +// 再切回时立即补一次刷新(基于 lastUpdatedAt 避免无效请求); +// - 过期检测:updateLastUpdatedNow 记录时间戳;checkDashboardStale 每 30 秒检查, +// 超过 5 分钟未刷新则在「上次更新」徽章上加 .is-stale 类(变灰 + 显示 ⚠️)。 + +var DASHBOARD_POLL_INTERVAL_MS = 60 * 1000; +var DASHBOARD_STALE_THRESHOLD_MS = 5 * 60 * 1000; +var DASHBOARD_STALE_CHECK_INTERVAL_MS = 30 * 1000; + +var dashboardState = { + currentController: null, // 当前正在进行的 fetch 的 AbortController + pollTimer: null, // 自动轮询的 setInterval id + staleTimer: null, // 过期检查的 setInterval id + lastUpdatedAt: 0, // 上次成功刷新的时间戳(ms) + dismissedAlertKey: null, // 当前会话中被用户「×」掉的告警内容指纹(同样的 reasons 不再弹) + lastResources: null, // 上一轮关键资源快照,用于判断是否首次有数据 / 智能 CTA +}; async function refreshDashboard() { const runningEl = document.getElementById('dashboard-running-tasks'); const vulnTotalEl = document.getElementById('dashboard-vuln-total'); const severityIds = ['critical', 'high', 'medium', 'low', 'info']; - if (runningEl) runningEl.textContent = '…'; - if (vulnTotalEl) vulnTotalEl.textContent = '…'; - severityIds.forEach(s => { - const el = document.getElementById('dashboard-severity-' + s); - if (el) el.textContent = '0'; - const barEl = document.getElementById('dashboard-bar-' + s); - if (barEl) barEl.style.width = '0%'; - }); - setDashboardOverviewPlaceholder('…'); - setEl('dashboard-kpi-tools-calls', '…'); - setEl('dashboard-kpi-success-rate', '…'); - var chartPlaceholder = document.getElementById('dashboard-tools-pie-placeholder'); - if (chartPlaceholder) { chartPlaceholder.style.removeProperty('display'); chartPlaceholder.textContent = (typeof window.t === 'function' ? window.t('common.loading') : '加载中…'); } - var barChartEl = document.getElementById('dashboard-tools-bar-chart'); - if (barChartEl) { barChartEl.style.display = 'none'; barChartEl.innerHTML = ''; } + // severityTotalEl 在后续渲染逻辑中也被引用,必须在 loading 分支外声明 + const severityTotalEl = document.getElementById('dashboard-severity-total'); + + // 体验优化:自动轮询 / 已经有数据时,不再把界面闪成「…」占位, + // 直接在后台拉新数据并平滑替换;只有首次加载时才显示 loading 状态。 + var isInitialLoad = !dashboardState.lastUpdatedAt; + if (isInitialLoad) { + if (runningEl) runningEl.textContent = '…'; + if (vulnTotalEl) vulnTotalEl.textContent = '…'; + severityIds.forEach(s => { + const el = document.getElementById('dashboard-severity-' + s); + if (el) el.textContent = '0'; + const pctEl = document.getElementById('dashboard-severity-' + s + '-pct'); + if (pctEl) pctEl.textContent = '0%'; + }); + if (severityTotalEl) severityTotalEl.textContent = '0'; + renderSeverityDonut({}, 0); + renderVulnStatusPanel(null, 0); + setDashboardOverviewPlaceholder('…'); + setEl('dashboard-kpi-tools-calls', '…'); + setEl('dashboard-kpi-success-rate', '…'); + setKpiSubText('dashboard-kpi-tasks-sub-text', '…'); + setKpiSubText('dashboard-kpi-vuln-sub-text', '…'); + setKpiSubText('dashboard-kpi-tools-sub-text', '…'); + setKpiSubText('dashboard-kpi-rate-sub-text', '…'); + hideEl('dashboard-kpi-vuln-critical-badge'); + hideEl('dashboard-alert-banner'); + setRecentVulnsLoading(); + ['tools', 'skills', 'knowledge', 'roles', 'agents', 'webshell'].forEach(function (k) { + setEl('dashboard-resource-' + k, '…'); + }); + var chartPlaceholder = document.getElementById('dashboard-tools-pie-placeholder'); + if (chartPlaceholder) { chartPlaceholder.style.removeProperty('display'); chartPlaceholder.textContent = (typeof window.t === 'function' ? window.t('common.loading') : '加载中…'); } + var barChartEl = document.getElementById('dashboard-tools-bar-chart'); + if (barChartEl) { barChartEl.style.display = 'none'; barChartEl.innerHTML = ''; } + } if (typeof apiFetch === 'undefined') { if (runningEl) runningEl.textContent = '-'; if (vulnTotalEl) vulnTotalEl.textContent = '-'; setDashboardOverviewPlaceholder('-'); + setRecentVulnsError(); return; } + // 防 race:abort 上一个仍在进行中的请求,再创建新 controller + if (dashboardState.currentController) { + try { dashboardState.currentController.abort(); } catch (_) { /* ignore */ } + } + var controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; + dashboardState.currentController = controller; + var signal = controller ? controller.signal : undefined; + + // 统一封装:apiFetch + abort signal + 失败/取消都返回 null(不抛错), + // 让上层可以用解构赋值平铺读取所有结果,避免一处失败导致整个 Promise.all reject + var fetchJson = function (url) { + return apiFetch(url, { signal: signal }) + .then(function (r) { return r && r.ok ? r.json() : null; }) + .catch(function () { return null; }); + }; + try { - const [tasksRes, vulnRes, batchRes, monitorRes, knowledgeRes, skillsRes] = await Promise.all([ - apiFetch('/api/agent-loop/tasks').then(r => r.ok ? r.json() : null).catch(() => null), - apiFetch('/api/vulnerabilities/stats').then(r => r.ok ? r.json() : null).catch(() => null), - apiFetch('/api/batch-tasks?limit=500&page=1').then(r => r.ok ? r.json() : null).catch(() => null), - apiFetch('/api/monitor/stats').then(r => r.ok ? r.json() : null).catch(() => null), - apiFetch('/api/knowledge/stats').then(r => r.ok ? r.json() : null).catch(() => null), - apiFetch('/api/skills/stats').then(r => r.ok ? r.json() : null).catch(() => null) + // /api/vulnerabilities/stats 只给出 by_severity 与 by_status 两个独立维度, + // 无法得到「严重 × 待处理」的交叉计数。这里额外拉两次(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, + hitlPendingRes, notificationsRes, externalMcpStatsRes, + webshellRes + ] = await Promise.all([ + fetchJson('/api/agent-loop/tasks'), + fetchJson('/api/vulnerabilities/stats'), + fetchJson('/api/batch-tasks?limit=500&page=1'), + fetchJson('/api/monitor/stats'), + fetchJson('/api/knowledge/stats'), + fetchJson('/api/skills/stats'), + fetchJson('/api/vulnerabilities?limit=5&page=1'), + fetchJson('/api/roles'), + fetchJson('/api/multi-agent/markdown-agents'), + openVulnQuery('critical'), + openVulnQuery('high'), + // 拉取 MCP 工具的「配置总数」用于「能力总览」(区别于 monitor/stats 的「有调用记录」)。 + // 仅取 total 字段,page_size=1 减少传输;total 已涵盖内部 + 外部 MCP + 直接注册的工具。 + fetchJson('/api/config/tools?page=1&page_size=1'), + // HITL 待审批:用于「需要立即处理」告警条 + 推荐操作 + fetchJson('/api/hitl/pending'), + // 通知摘要:since=0 拿最新一批,limit 控制大小;用于「最近事件」内联展示 + fetchJson('/api/notifications/summary?since=0&limit=20&lang=' + encodeURIComponent((window.__locale || 'zh-CN'))), + // External MCP 健康度 + fetchJson('/api/external-mcp/stats'), + // WebShell 已建立的连接(pentest 落地后的 foothold,对运营场景非常关键) + fetchJson('/api/webshell/connections') ]); + // 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果 + if (signal && signal.aborted) return; + // 运行中任务:Agent 循环任务 + 批量队列「执行中」数量统一统计,避免顶部 KPI 与运行概览不一致 let agentRunningCount = null; if (tasksRes && Array.isArray(tasksRes.tasks)) { agentRunningCount = tasksRes.tasks.length; } let batchRunningCount = 0; + let batchPendingCount = 0; if (batchRes && Array.isArray(batchRes.queues)) { batchRes.queues.forEach(q => { - if ((q.status || '').toLowerCase() === 'running') batchRunningCount++; + const s = (q.status || '').toLowerCase(); + if (s === 'running') batchRunningCount++; + else if (s === 'pending' || s === 'paused') batchPendingCount++; }); } + const totalRunning = (agentRunningCount || 0) + batchRunningCount; if (runningEl) { if (agentRunningCount !== null) { - runningEl.textContent = String(agentRunningCount + batchRunningCount); + runningEl.textContent = String(totalRunning); } else if (batchRes && Array.isArray(batchRes.queues)) { runningEl.textContent = String(batchRunningCount); } else { runningEl.textContent = '-'; } } + // KPI 副标:N 待执行 / 全部空闲 + if (batchPendingCount > 0) { + setKpiSubBadge('dashboard-kpi-tasks-sub-text', + dt('dashboard.pendingCountLabel', { count: batchPendingCount }, batchPendingCount + ' 待执行'), + 'pending'); + } else if (totalRunning === 0) { + setKpiSubBadge('dashboard-kpi-tasks-sub-text', dt('dashboard.allIdle', null, '系统空闲'), 'idle'); + } else { + setKpiSubBadge('dashboard-kpi-tasks-sub-text', dt('dashboard.executingNow', null, '正在执行'), 'running'); + } + // 解析「待处理」口径的真实计数(专门拉的接口);若该接口失败则退回 by_severity + const pickOpenCount = function (res, fallback) { + if (res && typeof res.total === 'number') return res.total; + return fallback; + }; + + let criticalCount = 0; + let highCount = 0; + let openCriticalCount = 0; + let openHighCount = 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; + // 优先用专门拉的「待处理」计数;若专项接口失败,则退回 by_severity(宁可误报,不可漏报) + openCriticalCount = pickOpenCount(openCriticalRes, criticalCount); + openHighCount = pickOpenCount(openHighRes, highCount); + if (severityTotalEl) severityTotalEl.textContent = String(total); severityIds.forEach(sev => { const count = bySeverity[sev] || 0; const el = document.getElementById('dashboard-severity-' + sev); if (el) el.textContent = String(count); - const barEl = document.getElementById('dashboard-bar-' + sev); - if (barEl) barEl.style.width = total > 0 ? (count / total * 100) + '%' : '0%'; + const pctEl = document.getElementById('dashboard-severity-' + sev + '-pct'); + if (pctEl) { + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + pctEl.textContent = pct + '%'; + } }); + renderSeverityDonut(bySeverity, total); + renderVulnStatusPanel(vulnRes.by_status || {}, total); + + // 漏洞 KPI 副标:徽章/文案均使用「待处理」口径 + const critBadge = document.getElementById('dashboard-kpi-vuln-critical-badge'); + const critCountEl = document.getElementById('dashboard-kpi-vuln-critical-count'); + if (critCountEl) critCountEl.textContent = String(openCriticalCount); + if (critBadge) critBadge.hidden = openCriticalCount === 0; + const subTextEl = document.getElementById('dashboard-kpi-vuln-sub-text'); + if (subTextEl) { + if (total === 0) { + subTextEl.textContent = dt('dashboard.allClear', null, '暂无新增风险'); + } else if (openCriticalCount === 0 && openHighCount === 0) { + // 高严重度全部已处置 → 给正反馈 + subTextEl.textContent = dt('dashboard.allHandled', null, '高严重度已全部处置'); + } else if (openHighCount > 0) { + subTextEl.textContent = dt('dashboard.openHighCountLabel', { count: openHighCount }, '待处理高危 ' + openHighCount); + } else { + subTextEl.textContent = dt('dashboard.totalCount', { count: total }, '共 ' + total + ' 个'); + } + } } else { if (vulnTotalEl) vulnTotalEl.textContent = '-'; + if (severityTotalEl) severityTotalEl.textContent = '-'; severityIds.forEach(sev => { - const barEl = document.getElementById('dashboard-bar-' + sev); - if (barEl) barEl.style.width = '0%'; + const pctEl = document.getElementById('dashboard-severity-' + sev + '-pct'); + if (pctEl) pctEl.textContent = '-'; }); + renderSeverityDonut({}, 0); + renderVulnStatusPanel(null, 0); + hideEl('dashboard-kpi-vuln-critical-badge'); + setKpiSubText('dashboard-kpi-vuln-sub-text', '-'); } // 批量任务队列:按状态统计(优化版;running 与上方 batchRunningCount 一致) @@ -117,7 +268,8 @@ async function refreshDashboard() { updateProgressBar('dashboard-batch-progress-done', '0'); } - // 工具调用:monitor/stats 为 { toolName: { totalCalls, successCalls, failedCalls, ... } }(优化版) + // 工具调用:monitor/stats 为 { toolName: { totalCalls, successCalls, failedCalls, ... } } + let toolsCount = 0, toolsTotalCalls = 0, toolsSuccessRate = -1, toolsFailedCount = 0; if (monitorRes && typeof monitorRes === 'object') { const names = Object.keys(monitorRes); let totalCalls = 0, totalSuccess = 0, totalFailed = 0; @@ -130,92 +282,160 @@ async function refreshDashboard() { const f = v && (v.failedCalls ?? v.FailedCalls); if (typeof f === 'number') totalFailed += f; }); - setEl('dashboard-tools-count', String(names.length)); - setEl('dashboard-tools-calls', formatNumber(totalCalls)); - setEl('dashboard-kpi-tools-calls', String(totalCalls)); - var rateStr = totalCalls > 0 ? ((totalSuccess / totalCalls) * 100).toFixed(1) + '%' : '-'; - setEl('dashboard-kpi-success-rate', rateStr); - setEl('dashboard-tools-success-rate', rateStr !== '-' ? `成功率 ${rateStr}` : '-'); + toolsCount = names.length; + toolsTotalCalls = totalCalls; + toolsFailedCount = totalFailed; + setEl('dashboard-kpi-tools-calls', formatNumber(totalCalls)); + setKpiSubText('dashboard-kpi-tools-sub-text', + dt('dashboard.toolsCountLabel', { count: toolsCount }, toolsCount + ' 个工具')); + if (totalCalls > 0) { + toolsSuccessRate = (totalSuccess / totalCalls) * 100; + const rateStr = toolsSuccessRate.toFixed(1) + '%'; + setEl('dashboard-kpi-success-rate', rateStr); + setKpiRateBadge('dashboard-kpi-rate-sub-text', toolsSuccessRate, totalFailed); + } else { + setEl('dashboard-kpi-success-rate', '-'); + setKpiSubText('dashboard-kpi-rate-sub-text', dt('dashboard.noCallYet', null, '暂无调用')); + } renderDashboardToolsBar(monitorRes); } else { - setEl('dashboard-tools-count', '-'); - setEl('dashboard-tools-calls', '-'); setEl('dashboard-kpi-tools-calls', '-'); setEl('dashboard-kpi-success-rate', '-'); - setEl('dashboard-tools-success-rate', '-'); + setKpiSubText('dashboard-kpi-tools-sub-text', '-'); + setKpiSubText('dashboard-kpi-rate-sub-text', '-'); renderDashboardToolsBar(null); } - // 知识:{ enabled, total_categories, total_items, ... }(优化版) - const knowledgeItemsEl = document.getElementById('dashboard-knowledge-items'); - const knowledgeCategoriesEl = document.getElementById('dashboard-knowledge-categories'); - const knowledgeStatusEl = document.getElementById('dashboard-knowledge-status'); - if (knowledgeRes && typeof knowledgeRes === 'object') { - if (knowledgeRes.enabled === false) { - // 功能未启用:用状态标签展示,数值保持为 "-" - if (knowledgeStatusEl) knowledgeStatusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.notEnabled') : '未启用'); - if (knowledgeItemsEl) knowledgeItemsEl.textContent = '-'; - if (knowledgeCategoriesEl) knowledgeCategoriesEl.textContent = '-'; - } else { - const categories = knowledgeRes.total_categories ?? 0; - const items = knowledgeRes.total_items ?? 0; - if (knowledgeItemsEl) knowledgeItemsEl.textContent = formatNumber(items); - if (knowledgeCategoriesEl) knowledgeCategoriesEl.textContent = formatNumber(categories); - // 根据数据量给个轻量状态文案 - if (knowledgeStatusEl) { - if (items > 0 || categories > 0) { - knowledgeStatusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.enabled') : '已启用'); - } else { - knowledgeStatusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.toConfigure') : '待配置'); - } - } - } + // 「能力总览 → MCP 工具」用配置总数(包含未被调用过的工具);专项接口失败时回落到 monitor 的 names.length + if (toolsConfigRes && typeof toolsConfigRes.total === 'number') { + setEl('dashboard-resource-tools', formatNumber(toolsConfigRes.total)); + } else if (toolsCount > 0) { + setEl('dashboard-resource-tools', formatNumber(toolsCount)); } else { - if (knowledgeItemsEl) knowledgeItemsEl.textContent = '-'; - if (knowledgeCategoriesEl) knowledgeCategoriesEl.textContent = '-'; - if (knowledgeStatusEl) knowledgeStatusEl.textContent = '-'; + setEl('dashboard-resource-tools', '-'); } - // Skills:{ total_skills, total_calls, ... }(优化版) - if (skillsRes && typeof skillsRes === 'object') { - const totalSkills = skillsRes.total_skills ?? 0; - const totalCalls = skillsRes.total_calls ?? 0; - setEl('dashboard-skills-count', formatNumber(totalSkills)); - setEl('dashboard-skills-calls', formatNumber(totalCalls)); - - // 设置状态标签 - const statusEl = document.getElementById('dashboard-skills-status'); - if (statusEl) { - if (totalCalls === 0) { - statusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.toUse') : '待使用'); - statusEl.style.background = 'rgba(0, 0, 0, 0.05)'; - statusEl.style.color = 'var(--text-secondary)'; - } else if (totalCalls < 10) { - statusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.active') : '活跃'); - statusEl.style.background = 'rgba(16, 185, 129, 0.1)'; - statusEl.style.color = '#10b981'; - } else { - statusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.highFreq') : '高频'); - statusEl.style.background = 'rgba(59, 130, 246, 0.1)'; - statusEl.style.color = '#3b82f6'; - } + // 知识:填充能力总览中的「知识」一行 + if (knowledgeRes && typeof knowledgeRes === 'object') { + if (knowledgeRes.enabled === false) { + setEl('dashboard-resource-knowledge', dt('dashboard.notEnabled', null, '未启用')); + } else { + const items = knowledgeRes.total_items ?? 0; + setEl('dashboard-resource-knowledge', formatNumber(items)); } } else { - setEl('dashboard-skills-count', '-'); - setEl('dashboard-skills-calls', '-'); - const statusEl = document.getElementById('dashboard-skills-status'); - if (statusEl) statusEl.textContent = '-'; + setEl('dashboard-resource-knowledge', '-'); } + + // Skills:填充能力总览中的「Skills」一行 + if (skillsRes && typeof skillsRes === 'object') { + const totalSkills = skillsRes.total_skills ?? 0; + setEl('dashboard-resource-skills', formatNumber(totalSkills)); + } else { + setEl('dashboard-resource-skills', '-'); + } + + // 角色 / Agents + if (rolesRes) { + // /api/roles 返回 { roles: [...] } 或者数组本身 + const roles = Array.isArray(rolesRes) ? rolesRes : (rolesRes.roles || []); + setEl('dashboard-resource-roles', formatNumber(Array.isArray(roles) ? roles.length : 0)); + } else { + setEl('dashboard-resource-roles', '-'); + } + if (agentsRes) { + // /api/multi-agent/markdown-agents 返回 { agents: [...] } + const agents = Array.isArray(agentsRes) ? agentsRes : (agentsRes.agents || []); + setEl('dashboard-resource-agents', formatNumber(Array.isArray(agents) ? agents.length : 0)); + } else { + setEl('dashboard-resource-agents', '-'); + } + // WebShell 已建立的连接:/api/webshell/connections 直接返回数组(不带包裹), + // 兼容一下 { connections: [...] } 形式以防后续接口变更 + var webshellList = null; + if (Array.isArray(webshellRes)) webshellList = webshellRes; + else if (webshellRes && Array.isArray(webshellRes.connections)) webshellList = webshellRes.connections; + var webshellCount = webshellList ? webshellList.length : null; + if (webshellCount !== null) { + setEl('dashboard-resource-webshell', formatNumber(webshellCount)); + } else { + setEl('dashboard-resource-webshell', '-'); + } + + // 最近漏洞列表 + renderRecentVulns(recentVulnsRes); + + // External MCP 健康度(同时拿到 down 数喂给 alert banner / 推荐操作) + var externalMcpDown = renderExternalMcpHealth(externalMcpStatsRes); + + // HITL 待审批数量(喂给 alert banner / 推荐操作) + var hitlPending = getHitlPendingCount(hitlPendingRes); + + // 「最近事件」内联展示(来自通知摘要,过滤掉已经被仪表盘其他位置覆盖的类型) + renderRecentEvents(notificationsRes); + + // 关键提醒条:把所有可能的告警源(漏洞/HITL/失败率/MCP健康)合并展示 + renderDashboardAlertBanner({ + criticalCount: openCriticalCount, + hitlPending: hitlPending, + failedTools: toolsFailedCount, + successRate: toolsSuccessRate, + externalMcpDown: externalMcpDown + }); + + // 智能 CTA:有数据时隐藏「开始你的安全之旅」 + var batchTotalCount = (batchRes && Array.isArray(batchRes.queues)) ? batchRes.queues.length : 0; + var toolsConfiguredCount = (toolsConfigRes && typeof toolsConfigRes.total === 'number') + ? toolsConfigRes.total : 0; + updateSmartCTA({ + totalRunning: totalRunning, + totalVulns: (vulnRes && typeof vulnRes.total === 'number') ? vulnRes.total : 0, + totalCalls: toolsTotalCalls, + toolsConfigured: toolsConfiguredCount, + batchTotal: batchTotalCount + }); + + // 「推荐操作」:基于全量当前状态智能生成 + renderRecommendedActions({ + openCriticalCount: openCriticalCount, + hitlPending: hitlPending, + externalMcpDown: externalMcpDown, + successRate: toolsSuccessRate, + failedTools: toolsFailedCount, + toolsConfigured: toolsConfiguredCount, + totalVulns: (vulnRes && typeof vulnRes.total === 'number') ? vulnRes.total : 0, + totalRunning: totalRunning + }); + + // 更新「上次更新」时间 + updateLastUpdatedNow(); } catch (e) { + // AbortError 是预期内(被新一次刷新主动取消),不视为错误 + if (e && (e.name === 'AbortError' || (signal && signal.aborted))) return; console.warn('仪表盘拉取统计失败', e); if (runningEl) runningEl.textContent = '-'; if (vulnTotalEl) vulnTotalEl.textContent = '-'; setDashboardOverviewPlaceholder('-'); setEl('dashboard-kpi-success-rate', '-'); setEl('dashboard-kpi-tools-calls', '-'); + setKpiSubText('dashboard-kpi-tasks-sub-text', '-'); + setKpiSubText('dashboard-kpi-vuln-sub-text', '-'); + setKpiSubText('dashboard-kpi-tools-sub-text', '-'); + setKpiSubText('dashboard-kpi-rate-sub-text', '-'); + ['tools', 'skills', 'knowledge', 'roles', 'agents', 'webshell'].forEach(function (k) { + setEl('dashboard-resource-' + k, '-'); + }); + setRecentVulnsError(); renderDashboardToolsBar(null); var ph = document.getElementById('dashboard-tools-pie-placeholder'); if (ph) { ph.style.removeProperty('display'); ph.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据'); } + } finally { + if (dashboardState.currentController === controller) { + dashboardState.currentController = null; + } + // 第一次 refreshDashboard(无论成功与否)完成后即开启自动轮询 + 过期检查; + // 重复调用是幂等的(内部判断 timer 是否已存在)。 + startDashboardAutoRefresh(); } } @@ -224,16 +444,587 @@ function setEl(id, text) { if (el) el.textContent = text; } -function setDashboardOverviewPlaceholder(t) { - ['dashboard-batch-pending', 'dashboard-batch-running', 'dashboard-batch-done', 'dashboard-batch-total', - 'dashboard-tools-count', 'dashboard-tools-calls', 'dashboard-tools-success-rate', - 'dashboard-skills-count', 'dashboard-skills-calls', 'dashboard-skills-status', - 'dashboard-knowledge-items', 'dashboard-knowledge-categories', 'dashboard-knowledge-status'].forEach(id => setEl(id, t)); +function hideEl(id) { + const el = document.getElementById(id); + if (el) el.hidden = true; +} + +function showEl(id) { + const el = document.getElementById(id); + if (el) el.hidden = false; +} + +function setDashboardOverviewPlaceholder(text) { + ['dashboard-batch-pending', 'dashboard-batch-running', 'dashboard-batch-done', 'dashboard-batch-total'].forEach(id => setEl(id, text)); updateProgressBar('dashboard-batch-progress-pending', '0'); updateProgressBar('dashboard-batch-progress-running', '0'); updateProgressBar('dashboard-batch-progress-done', '0'); } +// 翻译辅助;找不到时回退到 fallback 字符串。 +// 命名为 dt 而非 t,避免覆盖 i18n.js 暴露的 window.t(同名函数声明在脚本顶层会写入 window) +function dt(key, opts, fallback) { + if (typeof window.t === 'function') { + const v = window.t(key, opts); + if (v && v !== key) return v; + } + return fallback != null ? fallback : key; +} + +// KPI 卡片副标:纯文本 +function setKpiSubText(id, text) { + const el = document.getElementById(id); + if (!el) return; + el.textContent = text; + el.classList.remove('is-pending', 'is-running', 'is-idle', 'is-warning', 'is-success', 'is-danger'); +} + +// KPI 卡片副标:带状态色(pending / running / idle / warning / success / danger) +function setKpiSubBadge(id, text, kind) { + const el = document.getElementById(id); + if (!el) return; + el.textContent = text; + el.classList.remove('is-pending', 'is-running', 'is-idle', 'is-warning', 'is-success', 'is-danger'); + if (kind) el.classList.add('is-' + kind); +} + +// 工具成功率徽章着色 +function setKpiRateBadge(id, rate, failedCount) { + const el = document.getElementById(id); + if (!el) return; + el.classList.remove('is-pending', 'is-running', 'is-idle', 'is-warning', 'is-success', 'is-danger'); + if (rate >= 95) { + el.textContent = dt('dashboard.healthyStatus', null, '运行平稳'); + el.classList.add('is-success'); + } else if (rate >= 80) { + el.textContent = dt('dashboard.normalStatus', null, '基本正常') + (failedCount > 0 ? ' · ' + dt('dashboard.failedNCalls', { count: failedCount }, failedCount + ' 失败') : ''); + el.classList.add('is-warning'); + } else { + el.textContent = dt('dashboard.degradedStatus', null, '需要关注') + (failedCount > 0 ? ' · ' + dt('dashboard.failedNCalls', { count: failedCount }, failedCount + ' 失败') : ''); + el.classList.add('is-danger'); + } +} + +// 关键提醒条:根据严重情况渲染或隐藏。 +// - level: danger(红) > warning(橙) > info(蓝),按 reasons 自动取最高级 +// - 用户点 × 后,把当前 reasons 指纹存入 sessionStorage,本会话内再出现完全相同的内容会自动跳过 +// - 当 reasons 集合发生变化(如又新增一类问题),指纹失效,banner 重新弹出,避免「忽略后永远不再提醒」 +function renderDashboardAlertBanner(stats) { + const banner = document.getElementById('dashboard-alert-banner'); + const titleEl = document.getElementById('dashboard-alert-title'); + const descEl = document.getElementById('dashboard-alert-desc'); + const actsEl = document.getElementById('dashboard-alert-actions'); + if (!banner || !titleEl || !descEl || !actsEl) return; + + const reasons = []; + // 用 reasonKeys 算指纹(不含本地化字符串,切语言后不会让用户重新看到) + const reasonKeys = []; + let level = 'info'; // info | warning | danger + + if (stats.criticalCount > 0) { + reasons.push(dt('dashboard.alertCriticalReason', { count: stats.criticalCount }, + '存在 ' + stats.criticalCount + ' 个待处理的严重漏洞,建议立即处置')); + reasonKeys.push('crit:' + stats.criticalCount); + level = 'danger'; + } + if (stats.hitlPending > 0) { + // HITL 待审批是阻塞 Agent 流程的,独立成一条;不影响 level(除非已经是 info 升 warning) + reasons.push(dt('dashboard.alertHitlReason', { count: stats.hitlPending }, + '有 ' + stats.hitlPending + ' 个待审批的人机协同请求,Agent 正在等待你的决策')); + reasonKeys.push('hitl:' + stats.hitlPending); + if (level === 'info') level = 'warning'; + } + if (stats.successRate >= 0 && stats.successRate < 80 && stats.failedTools > 0) { + reasons.push(dt('dashboard.alertFailedReason', { count: stats.failedTools }, + '工具调用成功率偏低(' + stats.failedTools + ' 次失败),请检查 MCP 监控')); + reasonKeys.push('rate:' + Math.round(stats.successRate) + ':' + stats.failedTools); + if (level === 'info') level = 'warning'; + } + if (stats.externalMcpDown > 0) { + // External MCP 异常服务器数 > 0:影响工具可用性 + reasons.push(dt('dashboard.alertMcpDownReason', { count: stats.externalMcpDown }, + 'External MCP 服务器有 ' + stats.externalMcpDown + ' 个未运行,相关工具不可用')); + reasonKeys.push('mcp:' + stats.externalMcpDown); + if (level === 'info') level = 'warning'; + } + + if (reasons.length === 0) { + banner.hidden = true; + banner.classList.remove('is-warning', 'is-danger', 'is-info'); + dashboardState.dismissedAlertKey = null; + return; + } + + var fingerprint = level + '|' + reasonKeys.join(','); + dashboardState.dismissedAlertKey = fingerprint; + + // 检查是否被本会话忽略过同样的内容 + var dismissed = null; + try { dismissed = sessionStorage.getItem('dashboard.dismissedAlert'); } catch (_) {} + if (dismissed === fingerprint) { + banner.hidden = true; + return; + } + + banner.hidden = false; + banner.classList.remove('is-warning', 'is-danger', 'is-info'); + banner.classList.add('is-' + level); + + if (level === 'danger') { + titleEl.textContent = dt('dashboard.alertDangerTitle', null, '需要立即处理'); + } else if (level === 'warning') { + titleEl.textContent = dt('dashboard.alertWarningTitle', null, '需要关注'); + } else { + titleEl.textContent = dt('dashboard.alertTitle', null, '提醒'); + } + + descEl.textContent = reasons.join(';'); + + actsEl.innerHTML = ''; + if (stats.criticalCount > 0) { + const btn = document.createElement('button'); + btn.className = 'dashboard-alert-btn'; + btn.textContent = dt('dashboard.viewVulns', null, '查看漏洞'); + btn.onclick = function () { try { switchPage('vulnerabilities'); } catch (e) {} }; + actsEl.appendChild(btn); + } + if (stats.hitlPending > 0) { + const btn = document.createElement('button'); + btn.className = 'dashboard-alert-btn dashboard-alert-btn-secondary'; + btn.textContent = dt('dashboard.viewHitl', null, '前往审批'); + btn.onclick = function () { try { switchPage('hitl'); } catch (e) {} }; + actsEl.appendChild(btn); + } + if (stats.successRate >= 0 && stats.successRate < 80) { + const btn = document.createElement('button'); + btn.className = 'dashboard-alert-btn dashboard-alert-btn-secondary'; + btn.textContent = dt('dashboard.viewMonitor', null, '查看监控'); + btn.onclick = function () { try { switchPage('mcp-monitor'); } catch (e) {} }; + actsEl.appendChild(btn); + } + if (stats.externalMcpDown > 0) { + const btn = document.createElement('button'); + btn.className = 'dashboard-alert-btn dashboard-alert-btn-secondary'; + btn.textContent = dt('dashboard.viewMcpManagement', null, '管理 MCP'); + btn.onclick = function () { try { switchPage('mcp-management'); } catch (e) {} }; + actsEl.appendChild(btn); + } +} + +// External MCP 健康度:从 /api/external-mcp/stats 解析出 running / total / down, +// 决定是否在「能力总览」第 6 行显示,并把 down 数返回给 alert banner 驱动告警。 +function renderExternalMcpHealth(stats) { + var row = document.getElementById('dashboard-resource-external-mcp-row'); + var textEl = document.getElementById('dashboard-resource-external-mcp-text'); + var healthEl = document.getElementById('dashboard-resource-external-mcp-health'); + if (!row || !textEl) return 0; + + if (!stats || typeof stats !== 'object') { + row.hidden = true; + return 0; + } + // 兼容多种返回字段:{ total, running, stopped/error };常见命名都尝试一下 + var total = Number(stats.total ?? stats.Total ?? 0) || 0; + var running = Number(stats.running ?? stats.Running ?? 0) || 0; + if (total === 0) { + row.hidden = true; + return 0; + } + var down = Math.max(0, total - running); + row.hidden = false; + textEl.textContent = formatNumber(running) + ' / ' + formatNumber(total); + if (healthEl) { + healthEl.classList.remove('is-ok', 'is-warning', 'is-danger'); + if (down === 0) { + healthEl.classList.add('is-ok'); + healthEl.textContent = dt('dashboard.mcpAllRunning', null, '全部运行'); + } else if (down < total) { + healthEl.classList.add('is-warning'); + healthEl.textContent = dt('dashboard.mcpPartialDown', { count: down }, + down + ' 个未运行'); + } else { + healthEl.classList.add('is-danger'); + healthEl.textContent = dt('dashboard.mcpAllDown', null, '全部未运行'); + } + healthEl.hidden = false; + } + return down; +} + +// HITL 待审批数量:返回 pending 项数;同时可在能力总览或 KPI 副标里使用 +function getHitlPendingCount(res) { + if (!res) return 0; + if (Array.isArray(res.items)) return res.items.length; + if (typeof res.total === 'number') return res.total; + if (Array.isArray(res)) return res.length; + return 0; +} + +// 「最近事件」内联展示:取通知摘要里最重要的前 N 条 +// 设计原则: +// - 不重复 alert banner / KPI 已经表达过的信息(漏洞、HITL 等会被过滤掉避免冗余) +// - 只显示 p0/p1 优先级,p2 作为兜底(当 p0/p1 不够时) +// - 整个 section 在没有可显示内容时整个隐藏,避免空模块占地方 +function renderRecentEvents(notifRes) { + var section = document.getElementById('dashboard-section-events'); + var listEl = document.getElementById('dashboard-events-list'); + if (!section || !listEl) return; + + var items = (notifRes && Array.isArray(notifRes.items)) ? notifRes.items : []; + // 过滤:只看有意义的事件,去掉 actionable 已处理的、以及类型已经在仪表盘其他位置覆盖的 + var coveredTypes = { 'vulnerability_created': true, 'hitl_pending': true }; + var filtered = items.filter(function (it) { + if (!it || !it.type) return false; + if (coveredTypes[it.type]) return false; + return true; + }); + + // 按 level 排序:p0 > p1 > p2,再按时间倒序 + var levelOrder = { p0: 0, p1: 1, p2: 2 }; + filtered.sort(function (a, b) { + var la = levelOrder[a.level] != null ? levelOrder[a.level] : 9; + var lb = levelOrder[b.level] != null ? levelOrder[b.level] : 9; + if (la !== lb) return la - lb; + var ta = a.createdAt || a.created_at || 0; + var tb = b.createdAt || b.created_at || 0; + return new Date(tb).getTime() - new Date(ta).getTime(); + }); + + var top = filtered.slice(0, 3); + if (top.length === 0) { + section.hidden = true; + listEl.innerHTML = ''; + return; + } + section.hidden = false; + + listEl.innerHTML = top.map(function (it) { + var level = it.level || 'p2'; + var title = esc(it.title || it.message || dt('dashboard.eventUntitled', null, '事件')); + var msg = esc(it.message || it.summary || ''); + var when = esc(timeAgoStr(it.createdAt || it.created_at)); + return ( + '
' + + '' + + '
' + + '
' + title + '
' + + (msg && msg !== title ? '
' + msg + '
' : '') + + '
' + + '' + when + '' + + '
' + ); + }).join(''); +} + +// 推荐操作:基于当前数据状态智能生成「下一步该做什么」。 +// 设计原则:每条都必须可点击直达对应页面,按优先级(紧急 > 维护 > 配置)排序, +// 同一时间只显示最重要的 3-5 条;没有可推荐时整个 section 隐藏。 +function renderRecommendedActions(state) { + var section = document.getElementById('dashboard-section-recommend'); + var listEl = document.getElementById('dashboard-recommend-list'); + if (!section || !listEl) return; + + var actions = []; + + // 紧急类:未处理严重漏洞 + if (state.openCriticalCount > 0) { + actions.push({ + level: 'urgent', + icon: '', + title: dt('dashboard.recoFixCritical', { count: state.openCriticalCount }, + '修复 ' + state.openCriticalCount + ' 个待处理严重漏洞'), + desc: dt('dashboard.recoFixCriticalDesc', null, '严重等级的漏洞应优先处置'), + page: 'vulnerabilities' + }); + } + // 紧急类:HITL 待审批 + if (state.hitlPending > 0) { + actions.push({ + level: 'urgent', + icon: '', + title: dt('dashboard.recoApproveHitl', { count: state.hitlPending }, + '审批 ' + state.hitlPending + ' 个 HITL 请求'), + desc: dt('dashboard.recoApproveHitlDesc', null, 'Agent 正在等待你的决策才能继续'), + page: 'hitl' + }); + } + // 维护类:External MCP 异常 + if (state.externalMcpDown > 0) { + actions.push({ + level: 'warning', + icon: '', + title: dt('dashboard.recoRestartMcp', { count: state.externalMcpDown }, + '检查 ' + state.externalMcpDown + ' 个未运行的 External MCP'), + desc: dt('dashboard.recoRestartMcpDesc', null, '相关工具在 MCP 服务恢复前不可用'), + page: 'mcp-management' + }); + } + // 维护类:高失败率 + if (state.successRate >= 0 && state.successRate < 80 && state.failedTools > 0) { + actions.push({ + level: 'warning', + icon: '', + title: dt('dashboard.recoCheckMonitor', { count: state.failedTools }, + '排查 ' + state.failedTools + ' 次工具调用失败'), + desc: dt('dashboard.recoCheckMonitorDesc', null, '在 MCP 监控中查看失败的请求详情'), + page: 'mcp-monitor' + }); + } + // 配置类:第一次运行场景 + if (state.toolsConfigured === 0) { + actions.push({ + level: 'setup', + icon: '', + title: dt('dashboard.recoSetupMcp', null, '配置首个 MCP 工具'), + desc: dt('dashboard.recoSetupMcpDesc', null, '安装 MCP 服务后 Agent 才能调用具体能力'), + page: 'mcp-management' + }); + } + if (state.totalVulns === 0 && state.totalRunning === 0 && state.toolsConfigured > 0) { + actions.push({ + level: 'setup', + icon: '', + title: dt('dashboard.recoStartScan', null, '开始第一次扫描'), + desc: dt('dashboard.recoStartScanDesc', null, '在对话中描述目标,让 AI 协助执行'), + page: 'chat' + }); + } + + if (actions.length === 0) { + section.hidden = true; + listEl.innerHTML = ''; + return; + } + section.hidden = false; + listEl.innerHTML = actions.slice(0, 5).map(function (a) { + return ( + '' + + '' + + '
' + + '
' + esc(a.title) + '
' + + '
' + esc(a.desc) + '
' + + '
' + + '' + + '
' + ); + }).join(''); + + // 委托点击/键盘到推荐项 → switchPage + Array.from(listEl.querySelectorAll('.dashboard-recommend-item')).forEach(function (el) { + var page = el.getAttribute('data-page'); + el.onclick = function () { try { switchPage(page); } catch (_) {} }; + el.onkeydown = function (e) { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); } + }; + }); +} + +// 智能 CTA:用户已经有任何数据(任务运行 / 漏洞 / 工具调用 / 配置过 MCP)就把 +// 「开始你的安全之旅」的 CTA 隐藏,只在真正空白的全新环境保留它当引导 +function updateSmartCTA(state) { + var cta = document.getElementById('dashboard-cta-block'); + if (!cta) return; + var hasData = ( + (state.totalRunning || 0) > 0 || + (state.totalVulns || 0) > 0 || + (state.totalCalls || 0) > 0 || + (state.toolsConfigured || 0) > 0 || + (state.batchTotal || 0) > 0 + ); + cta.hidden = hasData; +} + +// 「上次更新」时间显示;同时记录 lastUpdatedAt 给 stale 检查使用,并清掉 stale 状态 +function updateLastUpdatedNow() { + dashboardState.lastUpdatedAt = Date.now(); + const el = document.getElementById('dashboard-last-updated-time'); + if (!el) return; + const d = new Date(); + const pad = function (n) { return n < 10 ? '0' + n : String(n); }; + el.textContent = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); + const wrap = document.getElementById('dashboard-last-updated'); + if (wrap) { + wrap.classList.remove('is-stale'); + wrap.classList.remove('is-flash'); + // trigger reflow then add class for the flash animation + void wrap.offsetWidth; + wrap.classList.add('is-flash'); + } + const stale = document.getElementById('dashboard-last-updated-stale'); + if (stale) stale.hidden = true; +} + +// 数据过期检查:超过 DASHBOARD_STALE_THRESHOLD_MS 未刷新,给徽章加 .is-stale 类, +// 显示 ⚠️ 图标提示用户「这块数据可能已经过期,请手动刷新或检查网络」 +function checkDashboardStale() { + if (!dashboardState.lastUpdatedAt) return; + var ageMs = Date.now() - dashboardState.lastUpdatedAt; + var wrap = document.getElementById('dashboard-last-updated'); + var stale = document.getElementById('dashboard-last-updated-stale'); + if (!wrap) return; + if (ageMs > DASHBOARD_STALE_THRESHOLD_MS) { + wrap.classList.add('is-stale'); + if (stale) stale.hidden = false; + } else { + wrap.classList.remove('is-stale'); + if (stale) stale.hidden = true; + } +} + +// 自动轮询:仪表盘活跃 + tab 可见时每 60 秒静默刷新一次。 +// 切走 / tab 隐藏时 setInterval 仍在跑,但 tick 内会检查并跳过实际刷新; +// 重新可见时基于 lastUpdatedAt 判断是否需要立即补刷一次(>= 间隔的一半就刷)。 +function startDashboardAutoRefresh() { + if (dashboardState.pollTimer) return; + dashboardState.pollTimer = setInterval(function () { + try { + var page = document.getElementById('page-dashboard'); + if (!page || !page.classList.contains('active')) return; + if (typeof document !== 'undefined' && document.hidden) return; + refreshDashboard(); + } catch (e) { + console.warn('auto refresh tick failed', e); + } + }, DASHBOARD_POLL_INTERVAL_MS); + + if (!dashboardState.staleTimer) { + dashboardState.staleTimer = setInterval(checkDashboardStale, DASHBOARD_STALE_CHECK_INTERVAL_MS); + } +} + +function stopDashboardAutoRefresh() { + if (dashboardState.pollTimer) { + clearInterval(dashboardState.pollTimer); + dashboardState.pollTimer = null; + } + if (dashboardState.staleTimer) { + clearInterval(dashboardState.staleTimer); + dashboardState.staleTimer = null; + } +} + +// 严重度配色及中文标签 +var SEVERITY_LABELS_FALLBACK = { + critical: '严重', high: '高危', medium: '中危', low: '低危', info: '信息' +}; + +function severityShortLabel(id) { + const key = 'dashboard.severity' + id.charAt(0).toUpperCase() + id.slice(1); + return t(key, null, SEVERITY_LABELS_FALLBACK[id] || id); +} + +// 友好的相对时间:"5 分钟前" / "2 小时前" / "昨天" / "3 天前" +function timeAgoStr(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d.getTime())) return ''; + const diffSec = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000)); + if (diffSec < 60) return dt('common.justNow', null, '刚刚'); + const min = Math.floor(diffSec / 60); + if (min < 60) return dt('common.minutesAgo', { n: min }, min + ' 分钟前'); + const hr = Math.floor(min / 60); + if (hr < 24) return dt('common.hoursAgo', { n: hr }, hr + ' 小时前'); + const day = Math.floor(hr / 24); + if (day < 7) return dt('common.daysAgo', { n: day }, day + ' 天前'); + // 超过一周显示日期 + return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); +} + +// 最近漏洞列表 +function setRecentVulnsLoading() { + const wrap = document.getElementById('dashboard-recent-vulns'); + const empty = document.getElementById('dashboard-recent-vulns-empty'); + if (!wrap) return; + Array.from(wrap.querySelectorAll('.dashboard-recent-vuln-item')).forEach(function (n) { n.remove(); }); + if (empty) { + empty.hidden = false; + empty.classList.remove('is-rich'); + empty.textContent = dt('common.loading', null, '加载中…'); + } +} + +function setRecentVulnsError() { + const wrap = document.getElementById('dashboard-recent-vulns'); + const empty = document.getElementById('dashboard-recent-vulns-empty'); + if (!wrap) return; + Array.from(wrap.querySelectorAll('.dashboard-recent-vuln-item')).forEach(function (n) { n.remove(); }); + if (empty) { + empty.hidden = false; + empty.classList.remove('is-rich'); + empty.textContent = dt('common.loadFailed', null, '加载失败'); + } +} + +function renderRecentVulns(res) { + const wrap = document.getElementById('dashboard-recent-vulns'); + const empty = document.getElementById('dashboard-recent-vulns-empty'); + if (!wrap) return; + + Array.from(wrap.querySelectorAll('.dashboard-recent-vuln-item')).forEach(function (n) { n.remove(); }); + + const list = res && Array.isArray(res.vulnerabilities) ? res.vulnerabilities : []; + if (list.length === 0) { + if (empty) { + empty.hidden = false; + // 升级版空状态:图标 + 标题 + 描述 + 行动按钮,比纯文本更易引导用户下一步 + empty.classList.add('is-rich'); + empty.innerHTML = ( + '' + + '
' + esc(dt('dashboard.noVulnYet', null, '暂无漏洞')) + '
' + + '
' + esc(dt('dashboard.noVulnDesc', null, '系统目前安全,开始一次扫描可以发现潜在问题')) + '
' + + '' + ); + var btn = empty.querySelector('[data-action="scan"]'); + if (btn) btn.onclick = function () { try { switchPage('chat'); } catch (_) {} }; + } + return; + } + if (empty) { + empty.hidden = true; + empty.classList.remove('is-rich'); + } + + list.slice(0, 5).forEach(function (v) { + const sev = (v.severity || 'info').toLowerCase(); + const status = (v.status || 'open').toLowerCase(); + const item = document.createElement('a'); + item.className = 'dashboard-recent-vuln-item'; + item.setAttribute('role', 'button'); + item.tabIndex = 0; + item.onclick = function () { try { switchPage('vulnerabilities'); } catch (e) {} }; + item.onkeydown = function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); item.click(); } }; + + const severityBadge = '' + esc(severityShortLabel(sev)) + ''; + const title = '' + esc(v.title || dt('common.untitled', null, '无标题')) + ''; + const target = v.target ? ('' + esc(v.target) + '') : ''; + const statusPill = '' + esc(statusShortLabel(status)) + ''; + const time = '' + esc(timeAgoStr(v.created_at)) + ''; + + item.innerHTML = severityBadge + title + target + statusPill + time; + wrap.appendChild(item); + }); +} + +// 漏洞状态映射:把 status 字符串规整到 4 类(避免脏数据) +function statusKey(s) { + s = String(s || '').toLowerCase(); + if (s === 'fixed' || s === 'closed' || s === 'resolved') return 'fixed'; + if (s === 'confirmed') return 'confirmed'; + if (s === 'false_positive' || s === 'false-positive' || s === 'fp') return 'fp'; + return 'open'; +} + +function statusShortLabel(s) { + const k = statusKey(s); + if (k === 'fixed') return dt('dashboard.statusFixed', null, '已修复'); + if (k === 'confirmed') return dt('dashboard.statusConfirmed', null, '已确认'); + if (k === 'fp') return dt('dashboard.statusFalsePositive', null, '误报'); + return dt('dashboard.statusOpen', null, '待处理'); +} + // 格式化数字,添加千位分隔符 function formatNumber(num) { if (typeof num !== 'number' || isNaN(num)) return '-'; @@ -265,6 +1056,43 @@ function esc(s) { return s.replace(/&/g, '&').replace(/ 0 ? (fixed / t) * 100 : 0; + var rateStr = t > 0 ? rate.toFixed(rate >= 100 ? 0 : 1) + '%' : '-'; + setEl('dashboard-fix-rate', rateStr); + + var detailEl = document.getElementById('dashboard-fix-detail'); + if (detailEl) { + detailEl.textContent = '(' + formatNumber(fixed) + ' / ' + formatNumber(t) + ')'; + } + + var fixedPct = t > 0 ? (fixed / t) * 100 : 0; + var confirmedPct = t > 0 ? (confirmed / t) * 100 : 0; + var fixedBar = document.getElementById('dashboard-fix-progress-fixed'); + var confirmedBar = document.getElementById('dashboard-fix-progress-confirmed'); + if (fixedBar) fixedBar.style.width = fixedPct.toFixed(2) + '%'; + if (confirmedBar) confirmedBar.style.width = confirmedPct.toFixed(2) + '%'; +} + function renderDashboardToolsBar(monitorRes) { const placeholder = document.getElementById('dashboard-tools-pie-placeholder'); const barChartEl = document.getElementById('dashboard-tools-bar-chart'); @@ -363,3 +1191,194 @@ function dashboardBarTooltipOnOut(ev) { dashboardBarTooltipTimer = null; if (dashboardBarTooltipEl) dashboardBarTooltipEl.style.display = 'none'; } + +// 漏洞严重程度分布:半环形(donut)渲染 +// 几何参数固定,便于配合 viewBox 0 0 560 320 的 SVG 容器 +// 段间分隔由 CSS 的白色 stroke 完成,不再使用 gapRad +var SEVERITY_DONUT_CFG = { + // viewBox 0 0 480 260:整体保持紧凑,但环厚回到「黄金比例」附近, + // 让弧带本身有视觉分量,又不像最早那版那样占太多空间。 + // 原则:rInner / rOuter ≈ 0.70,ring thickness ≈ rOuter * 0.30。 + cx: 240, + cy: 215, + rOuter: 165, + rInner: 115, // 环厚 = 50(介于原 90 和上一版 35 之间,自然且有质感) + labelOffset: 14, + gapRad: 0 +}; + +var SEVERITY_DEFAULT_LABELS = { + critical: '严重', + high: '高危', + medium: '中危', + low: '低危', + info: '信息' +}; + +function severityLabel(id) { + var key = 'dashboard.severity' + id.charAt(0).toUpperCase() + id.slice(1); + if (typeof window.t === 'function') { + var v = window.t(key); + if (v && v !== key) return v; + } + return SEVERITY_DEFAULT_LABELS[id] || id; +} + +function renderSeverityDonut(bySeverity, total) { + var trackEl = document.getElementById('dashboard-severity-donut-track'); + var segmentsEl = document.getElementById('dashboard-severity-donut-segments'); + var labelsEl = document.getElementById('dashboard-severity-donut-labels'); + if (!trackEl || !segmentsEl || !labelsEl) return; + + var cfg = SEVERITY_DONUT_CFG; + + // 背景轨迹(完整半环)只渲染一次 + if (!trackEl.hasChildNodes()) { + trackEl.innerHTML = ''; + } + + var ids = ['critical', 'high', 'medium', 'low', 'info']; + var severities = ids.map(function (id) { + return { id: id, value: (bySeverity && typeof bySeverity[id] === 'number') ? bySeverity[id] : 0 }; + }); + var visible = severities.filter(function (s) { return s.value > 0; }); + + if (!total || total <= 0 || visible.length === 0) { + segmentsEl.innerHTML = ''; + labelsEl.innerHTML = ''; + return; + } + + // 弧长按 value/total 计算;若严重度求和 < total(存在未分级),右侧会保留背景轨迹的空白 + var sumVisible = visible.reduce(function (s, seg) { return s + seg.value; }, 0); + var coverage = sumVisible / total; // 半环被实际段覆盖的比例 + var visibleCount = visible.length; + var totalGapRad = cfg.gapRad * Math.max(0, visibleCount - 1); + // 半环可用的总弧度 = π * coverage(按比例填充),再扣除段间间隙 + var arcsTotalRad = Math.max(0, Math.PI * coverage - totalGapRad); + + var segmentsHtml = ''; + var labelsHtml = ''; + var cumRad = 0; + + visible.forEach(function (seg, i) { + var arcFraction = seg.value / sumVisible; + var segRad = arcsTotalRad * arcFraction; + var angleStart = Math.PI - cumRad; + var angleEnd = angleStart - segRad; + + var path = arcSegmentPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner, angleStart, angleEnd); + segmentsHtml += ''; + + // 仅当占比 >= 5% 时显示外置标签,避免小段标签互相重叠 + var pctOfTotal = (seg.value / total) * 100; + if (pctOfTotal >= 5) { + var midAngle = (angleStart + angleEnd) / 2; + var labelR = cfg.rOuter + cfg.labelOffset; + var sinMid = Math.sin(midAngle); + var cosMid = Math.cos(midAngle); + var lx = cfg.cx + labelR * cosMid; + // 顶部区域标签整体向上抬一些,避免与外弧贴住;侧边标签则不调整 + var topLift = sinMid > 0.4 ? Math.round((sinMid - 0.3) * 10) : 0; + var ly = cfg.cy - labelR * sinMid - topLift; + + var anchor = 'middle'; + if (cosMid < -0.15) anchor = 'end'; + else if (cosMid > 0.15) anchor = 'start'; + + var pctText = Math.round(pctOfTotal) + '%'; + var name = esc(severityLabel(seg.id)); + + // 两行:第一行 "数量 (百分比)"(弧色),第二行 "严重度名称"(同色但稍小) + labelsHtml += ''; + labelsHtml += '' + seg.value + ' (' + pctText + ')'; + labelsHtml += '' + name + ''; + labelsHtml += ''; + } + + cumRad += segRad; + if (i < visibleCount - 1) cumRad += cfg.gapRad; + }); + + segmentsEl.innerHTML = segmentsHtml; + labelsEl.innerHTML = labelsHtml; +} + +// SVG 半环(背景轨迹)路径 +function halfRingPath(cx, cy, rOuter, rInner) { + var x1Outer = cx - rOuter; + var y1Outer = cy; + var x2Outer = cx + rOuter; + var y2Outer = cy; + var x1Inner = cx - rInner; + var y1Inner = cy; + var x2Inner = cx + rInner; + var y2Inner = cy; + return 'M ' + x1Outer + ' ' + y1Outer + + ' A ' + rOuter + ' ' + rOuter + ' 0 0 1 ' + x2Outer + ' ' + y2Outer + + ' L ' + x2Inner + ' ' + y2Inner + + ' A ' + rInner + ' ' + rInner + ' 0 0 0 ' + x1Inner + ' ' + y1Inner + ' Z'; +} + +// 单段弧形(angleStart > angleEnd,逆时针角度递减,视觉上沿半环顶部顺时针推进) +function arcSegmentPath(cx, cy, rOuter, rInner, angleStart, angleEnd) { + var x1Outer = cx + rOuter * Math.cos(angleStart); + var y1Outer = cy - rOuter * Math.sin(angleStart); + var x2Outer = cx + rOuter * Math.cos(angleEnd); + var y2Outer = cy - rOuter * Math.sin(angleEnd); + var x1Inner = cx + rInner * Math.cos(angleStart); + var y1Inner = cy - rInner * Math.sin(angleStart); + var x2Inner = cx + rInner * Math.cos(angleEnd); + var y2Inner = cy - rInner * Math.sin(angleEnd); + + var largeArc = (angleStart - angleEnd) > Math.PI ? 1 : 0; + + return 'M ' + x1Outer.toFixed(2) + ' ' + y1Outer.toFixed(2) + + ' A ' + rOuter + ' ' + rOuter + ' 0 ' + largeArc + ' 1 ' + x2Outer.toFixed(2) + ' ' + y2Outer.toFixed(2) + + ' L ' + x2Inner.toFixed(2) + ' ' + y2Inner.toFixed(2) + + ' A ' + rInner + ' ' + rInner + ' 0 ' + largeArc + ' 0 ' + x1Inner.toFixed(2) + ' ' + y1Inner.toFixed(2) + ' Z'; +} + +// 语言切换后,仪表盘上由 JS 动态渲染的部分(KPI 副标、告警条、半环图标签、 +// 状态卡、最近漏洞列表、能力总览徽章等)不会被 applyTranslations 自动重绘, +// 需要主动重新拉取数据并以新语言重新渲染;与 tasks/vulnerability 等其他页面保持一致。 +document.addEventListener('languagechange', function () { + try { + var dashboardPage = document.getElementById('page-dashboard'); + if (!dashboardPage || !dashboardPage.classList.contains('active')) { + return; + } + if (typeof refreshDashboard === 'function') { + refreshDashboard(); + } + } catch (e) { + console.warn('languagechange dashboard refresh failed', e); + } +}); + +// 页面可见性:从其他 tab 切回时,如果距离上次刷新已经过半个轮询周期,立刻补刷一次; +// 避免后台标签页停留几小时回来时数据还是旧的,又不至于每次切回都打接口。 +document.addEventListener('visibilitychange', function () { + if (document.hidden) return; + var page = document.getElementById('page-dashboard'); + if (!page || !page.classList.contains('active')) return; + var ageMs = Date.now() - (dashboardState.lastUpdatedAt || 0); + if (ageMs >= DASHBOARD_POLL_INTERVAL_MS / 2) { + try { refreshDashboard(); } catch (_) { /* ignore */ } + } else { + // 不需要重新拉数据,但也跑一次 stale 检查更新徽章状态 + checkDashboardStale(); + } +}); + +// 关闭告警条按钮:把当前 reasons 指纹存入 sessionStorage,本会话不再弹同样的内容 +document.addEventListener('click', function (ev) { + var btn = ev.target && ev.target.closest && ev.target.closest('#dashboard-alert-close'); + if (!btn) return; + ev.preventDefault(); + var key = dashboardState.dismissedAlertKey || ''; + try { sessionStorage.setItem('dashboard.dismissedAlert', key); } catch (_) {} + var banner = document.getElementById('dashboard-alert-banner'); + if (banner) banner.hidden = true; +}); + diff --git a/web/templates/index.html b/web/templates/index.html index 2f04ae72..58385b28 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -305,41 +305,207 @@
- + + +
-
-
运行中任务
-
-
漏洞总数
-
-
工具调用次数
-
-
工具执行成功率
+
+
+
运行中任务
+ +
+
-
+
+ - +
+
+
+
+
漏洞总数
+ +
+
-
+
+ + 暂无新增风险 +
+
+
+
+
工具调用次数
+ +
+
-
+
+ - +
+
+
+
+
工具执行成功率
+ +
+
-
+
+ 运行平稳 +
+
-

漏洞严重程度分布

-
-
- - - - - +
+

漏洞严重程度分布

+ 查看全部 → +
+
+
+ +
+
0
+
总漏洞数
+
-
-
严重0
-
高危0
-
中危0
-
低危0
-
信息0
+
+
+ + 严重 + 0 + 0% +
+
+ + 高危 + 0 + 0% +
+
+ + 中危 + 0 + 0% +
+
+ + 低危 + 0 + 0% +
+
+ + 信息 + 0 + 0% +
+
+
+ +
+
+
+ +
+ 0 + 待处理 +
+
+
+ +
+ 0 + 已确认 +
+
+
+ +
+ 0 + 已修复 +
+
+
+ +
+ 0 + 误报 +
+
+
+
+
+ 修复率 + + 0% + (0 / 0) + +
+ +
+ 已修复 + 已确认 + 待处理 +
+
+
+

最近漏洞

+ 查看全部 → +
+
+
暂无漏洞,开始你的第一次扫描吧
+
+
-

运行概览

+
+

批量任务队列

+ 查看全部 → +
@@ -374,80 +540,100 @@
-
- -
-
- 工具调用 - - -
-
- - - 次调用 - · - - - 个工具 -
-
-
-
- -
-
- 知识 - - -
-
- - - 项知识 - · - - - 个分类 -
-
-
-
- -
-
- Skills - - -
-
- - - 次调用 - · - - - 个 Skill -
-
-
-
-

快捷入口

-
-
+ +