// 仪表盘页面:拉取运行中任务、漏洞统计、批量任务、工具与 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']; // 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 { // /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 => { 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(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 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 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 一致) if (batchRes && Array.isArray(batchRes.queues)) { const queues = batchRes.queues; let pending = 0, running = batchRunningCount, done = 0; queues.forEach(q => { const s = (q.status || '').toLowerCase(); if (s === 'pending' || s === 'paused') pending++; else if (s === 'running') { /* already counted into batchRunningCount */ } else if (s === 'completed' || s === 'cancelled') done++; }); const total = pending + running + done; setEl('dashboard-batch-pending', String(pending)); setEl('dashboard-batch-running', String(running)); setEl('dashboard-batch-done', String(done)); setEl('dashboard-batch-total', total > 0 ? (typeof window.t === 'function' ? window.t('dashboard.totalCount', { count: total }) : `共 ${total} 个`) : (typeof window.t === 'function' ? window.t('dashboard.noTasks') : '暂无任务')); // 更新进度条 if (total > 0) { const pendingPct = (pending / total * 100).toFixed(1); const runningPct = (running / total * 100).toFixed(1); const donePct = (done / total * 100).toFixed(1); updateProgressBar('dashboard-batch-progress-pending', pendingPct); updateProgressBar('dashboard-batch-progress-running', runningPct); updateProgressBar('dashboard-batch-progress-done', donePct); } else { updateProgressBar('dashboard-batch-progress-pending', '0'); updateProgressBar('dashboard-batch-progress-running', '0'); updateProgressBar('dashboard-batch-progress-done', '0'); } } else { setEl('dashboard-batch-pending', '-'); setEl('dashboard-batch-running', '-'); setEl('dashboard-batch-done', '-'); setEl('dashboard-batch-total', '-'); updateProgressBar('dashboard-batch-progress-pending', '0'); updateProgressBar('dashboard-batch-progress-running', '0'); updateProgressBar('dashboard-batch-progress-done', '0'); } // 工具调用: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; names.forEach(k => { const v = monitorRes[k]; const n = v && (v.totalCalls ?? v.TotalCalls); if (typeof n === 'number') totalCalls += n; const s = v && (v.successCalls ?? v.SuccessCalls); if (typeof s === 'number') totalSuccess += s; const f = v && (v.failedCalls ?? v.FailedCalls); if (typeof f === 'number') totalFailed += f; }); 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-kpi-tools-calls', '-'); setEl('dashboard-kpi-success-rate', '-'); setKpiSubText('dashboard-kpi-tools-sub-text', '-'); setKpiSubText('dashboard-kpi-rate-sub-text', '-'); renderDashboardToolsBar(null); } // 「能力总览 → 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 { setEl('dashboard-resource-tools', '-'); } // 知识:填充能力总览中的「知识」一行 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-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(); } } function setEl(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; } 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'); } } // sessionStorage:告警条「×」忽略记录 + 最近一次**实际展示过**的 reason 片段(不含 level), // 用于在「问题从多变少」(如审完 HITL 后只剩严重漏洞)时,避免误用更早对「仅子集」的忽略。 var DASH_SESSION_ALERT_DISMISSED = 'dashboard.dismissedAlert'; var DASH_SESSION_ALERT_LAST_REASONS = 'dashboard.alertLastReasons'; function dashboardAlertReasonKeySetFromJoined(s) { if (!s || typeof s !== 'string') return new Set(); return new Set(s.split(',').map(function (x) { return x.trim(); }).filter(Boolean)); } /** 当前 reason 片段相对上次展示的片段是否为真子集(用于清除过时的忽略) */ function dashboardAlertCurrentIsStrictSubsetOfLastShown(currentReasonJoined, lastReasonJoined) { var cur = dashboardAlertReasonKeySetFromJoined(currentReasonJoined); var last = dashboardAlertReasonKeySetFromJoined(lastReasonJoined); if (cur.size === 0 || last.size === 0) return false; if (cur.size >= last.size) return false; var ok = true; cur.forEach(function (k) { if (!last.has(k)) ok = false; }); return ok; } // 关键提醒条:根据严重情况渲染或隐藏。 // - level: danger(红) > warning(橙) > info(蓝),按 reasons 自动取最高级 // - 用户点 × 后,把当前 reasons 指纹存入 sessionStorage,本会话内再出现完全相同的内容会自动跳过 // - 当 reasons 集合发生变化(如又新增一类问题),指纹失效,banner 重新弹出,避免「忽略后永远不再提醒」 // - 若曾展示过「更多类问题」的组合,之后仅部分问题消失,即使指纹与早年忽略相同,也会清除忽略并继续提醒(见 dashboard.alertLastReasons) 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; try { sessionStorage.removeItem(DASH_SESSION_ALERT_LAST_REASONS); } catch (_) {} return; } var fingerprint = level + '|' + reasonKeys.join(','); var reasonPartJoined = reasonKeys.join(','); // 检查是否被本会话忽略过同样的内容;若当前仅为「上次曾展示组合」的真子集,则清除忽略(最佳实践:部分处置后仍提醒剩余项) var dismissed = null; try { dismissed = sessionStorage.getItem(DASH_SESSION_ALERT_DISMISSED); } catch (_) {} var lastShownReasons = ''; try { lastShownReasons = sessionStorage.getItem(DASH_SESSION_ALERT_LAST_REASONS) || ''; } catch (_) {} if (dismissed === fingerprint && dashboardAlertCurrentIsStrictSubsetOfLastShown(reasonPartJoined, lastShownReasons)) { try { sessionStorage.removeItem(DASH_SESSION_ALERT_DISMISSED); dismissed = null; } catch (_) { /* ignore */ } } dashboardState.dismissedAlertKey = fingerprint; 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); } try { sessionStorage.setItem(DASH_SESSION_ALERT_LAST_REASONS, reasonPartJoined); } catch (_) {} } // 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 已表达的「新漏洞」通知(vulnerability_created 仍过滤) // - HITL 待审批在推荐操作等处也会提示,但仍在此展示时间线,便于与任务完成等并列查看 // - 整个 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 : []; // 过滤:去掉新漏洞类型(与「最近漏洞」等板块避免重复);HITL 不再过滤 var coveredTypes = { 'vulnerability_created': 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.ts || a.createdAt || a.created_at || 0; var tb = b.ts || 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 || it.desc || ''); var whenRaw = timeAgoStr(it.ts || it.createdAt || it.created_at); var when = esc(whenRaw || '—'); return ( '