diff --git a/web/static/css/style.css b/web/static/css/style.css index b3edd1ed..d0f073a1 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -24536,17 +24536,30 @@ button.chat-files-dropdown-item:hover:not(:disabled) { flex-direction: column; overflow: hidden; min-height: 0; - padding-bottom: 16px; + padding-bottom: 0; } #project-panel-graph .projects-graph-toolbar { flex: 0 0 auto; } #project-panel-graph .project-fact-graph-layout { - flex: 1 1 auto; + flex: 1 1 0; min-height: 0; + max-height: 100%; + overflow: hidden; +} +#project-panel-graph .project-fact-graph-container { + min-height: 0; + height: 100%; } #project-panel-graph .project-fact-graph-footer { flex: 0 0 auto; + flex-shrink: 0; + position: relative; + z-index: 20; + margin: 0; + padding: 10px 0 12px; + background: #fff; + border-top: 1px solid #eef2f7; } .projects-graph-toolbar-row { align-items: flex-end; @@ -24607,7 +24620,27 @@ button.chat-files-dropdown-item:hover:not(:disabled) { flex-wrap: wrap; align-items: center; justify-content: flex-end; - gap: 6px 12px; + gap: 8px 14px; +} +.projects-graph-legend-group { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; +} +.projects-graph-legend-heading { + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #94a3b8; +} +.projects-graph-legend-divider { + display: inline-block; + width: 1px; + height: 18px; + background: #e2e8f0; + flex: 0 0 auto; } .projects-graph-legend-item { display: inline-flex; @@ -24616,27 +24649,40 @@ button.chat-files-dropdown-item:hover:not(:disabled) { font-size: 0.75rem; color: #64748b; } -.projects-graph-legend-item i { +.projects-graph-legend-item--edge i { display: inline-block; width: 22px; height: 0; border-top: 2.5px solid var(--legend-color, #cbd5e1); border-radius: 2px; } -.projects-graph-legend-item--dashed i { +.projects-graph-legend-item--edge.projects-graph-legend-item--dashed i { border-top-style: dashed; opacity: 0.7; } +.projects-graph-legend-item--node i { + display: inline-block; + width: 14px; + height: 14px; + border: 1.5px solid var(--legend-color, #cbd5e1); + border-radius: 4px; + background: linear-gradient(135deg, #ffffff 0%, var(--legend-bg, #f8fafc) 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9); +} +.projects-graph-legend-item--node-dashed i { + border-style: dashed; + opacity: 0.85; +} .project-fact-graph-layout { position: relative; display: flex; - min-height: 480px; + min-height: 0; align-items: stretch; } .project-fact-graph-container { flex: 1 1 auto; width: 100%; - min-height: 480px; + min-height: 240px; border: 1px solid var(--border-color, #e2e8f0); border-radius: 14px; background-color: #f8fafc; @@ -24781,8 +24827,8 @@ button.chat-files-dropdown-item:hover:not(:disabled) { border: 1px solid #e2e8f0; } .project-fact-graph-node-category--target { color: #4338ca; background: #eef2ff; border-color: #c7d2fe; } -.project-fact-graph-node-category--finding, -.project-fact-graph-node-category--vulnerability { color: #be123c; background: #fff1f2; border-color: #fecdd3; } +.project-fact-graph-node-category--finding { color: #be123c; background: #fff1f2; border-color: #fecdd3; } +.project-fact-graph-node-category--vulnerability { color: #7e22ce; background: #f5f3ff; border-color: #ddd6fe; } .project-fact-graph-node-category--exploit, .project-fact-graph-node-category--poc { color: #c2410c; background: #ffedd5; border-color: #fdba74; } .project-fact-graph-node-category--chain { color: #6d28d9; background: #f5f3ff; border-color: #ddd6fe; } @@ -24831,6 +24877,13 @@ button.chat-files-dropdown-item:hover:not(:disabled) { min-width: 0; color: #475569; } +.project-fact-graph-node-vuln-hint { + display: block; + width: 100%; + font-size: 0.75rem; + line-height: 1.45; + color: #64748b; +} .project-fact-graph-edges-wrap { flex: 1 1 auto; min-height: 0; @@ -24991,6 +25044,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) { gap: 8px 12px; margin: 10px 0 0; flex: 0 0 auto; + flex-shrink: 0; } .project-fact-graph-stats { display: flex; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index bb94a4c6..04bc4b35 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -280,6 +280,14 @@ "graphStats": "Nodes: {{nodes}} | Edges: {{edges}}", "graphStatsNodes": "Nodes", "graphStatsEdges": "Edges", + "graphLegendNodes": "Nodes", + "graphLegendEdges": "Edges", + "graphLegendNodeTarget": "TARGET", + "graphLegendNodeInfra": "INFRA", + "graphLegendNodeFinding": "FINDING", + "graphLegendNodeVuln": "VULN", + "graphLegendNodeExploit": "EXPLOIT", + "graphLegendNodeMissing": "MISSING", "graphLegendDiscovered": "discovered_on", "graphLegendLeads": "leads_to", "graphLegendExploits": "exploits", @@ -310,6 +318,8 @@ "graphEdgeDeleteFailed": "Failed to delete edge", "graphEdgeDeleteSuccess": "Edge deleted", "graphDeleteEdge": "Delete", + "viewVulnerability": "View vulnerability", + "graphVulnSidebarHint": "Linked vulnerability node. Use the button below to open it in Vulnerability Management.", "promoteAttackChain": "Promote chain", "promoteAttackChainTitle": "Promote conversation attack chain to project facts", "confirmPromoteAttackChain": "Promote this conversation's attack chain into the project? Facts and edges will be created or updated.", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index f697869f..d767c40a 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -268,6 +268,14 @@ "graphStats": "节点: {{nodes}} | 边: {{edges}}", "graphStatsNodes": "节点", "graphStatsEdges": "边", + "graphLegendNodes": "节点", + "graphLegendEdges": "连线", + "graphLegendNodeTarget": "TARGET · 目标", + "graphLegendNodeInfra": "INFRA · 基础设施", + "graphLegendNodeFinding": "FINDING · 发现", + "graphLegendNodeVuln": "VULN · 漏洞", + "graphLegendNodeExploit": "EXPLOIT · 利用", + "graphLegendNodeMissing": "MISSING · 缺失", "graphLegendDiscovered": "discovered_on", "graphLegendLeads": "leads_to", "graphLegendExploits": "exploits", @@ -298,6 +306,8 @@ "graphEdgeDeleteFailed": "删除边失败", "graphEdgeDeleteSuccess": "边已删除", "graphDeleteEdge": "删边", + "viewVulnerability": "查看漏洞", + "graphVulnSidebarHint": "关联漏洞节点,点击下方按钮在漏洞管理中查看详情。", "promoteAttackChain": "沉淀攻击链", "promoteAttackChainTitle": "将对话攻击链沉淀为项目事实与边", "confirmPromoteAttackChain": "将该对话的攻击链沉淀到本项目?会创建/更新事实与关系边。", diff --git a/web/static/js/fact-graph.js b/web/static/js/fact-graph.js index 200b2639..a787debe 100644 --- a/web/static/js/fact-graph.js +++ b/web/static/js/fact-graph.js @@ -9,6 +9,7 @@ let _graphData = null; let _onNodeSelect = null; let _onEdgeSelect = null; + let _resizeObs = null; const EDGE_COLORS = { discovered_on: '#4F46E5', @@ -30,25 +31,29 @@ const CARD_MIN_W = 300; const CARD_TARGET_W = 360; const CARD_MIN_H = 88; - const CARD_MAX_H = 152; + const CARD_MAX_H = 176; const CARD_HEADER_FS = 11; const CARD_HEADER_LH = 16; + const CARD_KEY_FS = 10; + const CARD_KEY_LH = 14; const CARD_SUMMARY_FS = 13; const CARD_SUMMARY_LH = 18; const CARD_SECTION_GAP = 6; const CARD_FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif'; + const CARD_KEY_FONT = + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace'; function nodeTheme(type) { switch (type) { case 'target': return { typeLabel: '目标', typeEn: 'TARGET', accent: '#4F46E5', bgEnd: '#F5F3FF', icon: 'target' }; case 'finding': - return { typeLabel: '发现', typeEn: 'FINDING', accent: '#E11D48', bgEnd: '#FFF1F2', icon: 'vulnerability' }; + return { typeLabel: '发现', typeEn: 'FINDING', accent: '#E11D48', bgEnd: '#FFF1F2', icon: 'finding', cardStyle: 'default' }; case 'exploit': - return { typeLabel: '利用', typeEn: 'EXPLOIT', accent: '#B45309', bgEnd: '#FFFBEB', icon: 'vulnerability' }; + return { typeLabel: '利用', typeEn: 'EXPLOIT', accent: '#B45309', bgEnd: '#FFFBEB', icon: 'vulnerability', cardStyle: 'default' }; case 'vulnerability': - return { typeLabel: '漏洞', typeEn: 'VULN', accent: '#BE123C', bgEnd: '#FFF1F2', icon: 'vulnerability' }; + return { typeLabel: '漏洞', typeEn: 'VULN', accent: '#9333EA', bgEnd: '#F5F3FF', icon: 'vuln', cardStyle: 'default' }; case 'auth': return { typeLabel: '认证', typeEn: 'AUTH', accent: '#0D9488', bgEnd: '#F0FDFA', icon: 'default' }; case 'infra': @@ -148,19 +153,24 @@ return nodeWidth - CARD_TEXT_X - CARD_PAD - CARD_TEXT_PAD_RIGHT; } - function computeNodeLayout(type, summary, statusBadge, theme) { + function computeNodeLayout(type, summary, statusBadge, theme, factKey) { const width = type === 'target' ? CARD_TARGET_W : CARD_MIN_W; const textW = cardTextWidth(width); const t = theme || nodeTheme(type); const headerLines = wrapTextLines(buildHeaderText(t, statusBadge), textW, CARD_HEADER_FS, 2, true); - const summaryLines = wrapTextLines(summary, textW, CARD_SUMMARY_FS, 4, true); + const keyText = String(factKey || '').trim(); + const keyLines = keyText ? wrapTextLines(keyText, textW, CARD_KEY_FS, 2, false) : []; + const summaryLines = wrapTextLines(summary, textW, CARD_SUMMARY_FS, keyLines.length ? 3 : 4, true); + const keyBlockHeight = keyLines.length + ? CARD_SECTION_GAP + keyLines.length * CARD_KEY_LH + CARD_SECTION_GAP + : CARD_SECTION_GAP; const height = Math.min( CARD_MAX_H, Math.max( CARD_MIN_H, CARD_PAD + headerLines.length * CARD_HEADER_LH + - CARD_SECTION_GAP + + keyBlockHeight + summaryLines.length * CARD_SUMMARY_LH + CARD_PAD, ), @@ -169,8 +179,11 @@ width, height, headerLines, + keyLines, summaryLines, - searchLabel: headerLines.join(' ') + '\n' + summaryLines.join(' '), + searchLabel: [headerLines.join(' '), keyLines.join(' '), summaryLines.join(' ')] + .filter(Boolean) + .join('\n'), }; } @@ -183,6 +196,21 @@ `` ); } + if (kind === 'finding') { + return ( + `` + + `` + + `` + ); + } + if (kind === 'vuln') { + return ( + `` + + `` + + `` + + `` + ); + } if (kind === 'vulnerability') { return ( `` + @@ -198,7 +226,7 @@ } function buildNodeCardSvgUrl(theme, layout, confidence) { - const { width, height, headerLines, summaryLines } = layout; + const { width, height, headerLines, keyLines, summaryLines } = layout; const accent = theme.accent; const bgEnd = theme.bgEnd; const conf = (confidence || '').toLowerCase(); @@ -207,7 +235,14 @@ const iconX = CARD_PAD; const iconY = (height - CARD_ICON) / 2; const headerY = CARD_PAD + CARD_HEADER_FS; - const summaryY = CARD_PAD + headerLines.length * CARD_HEADER_LH + CARD_SECTION_GAP + CARD_SUMMARY_FS; + const keyY = CARD_PAD + headerLines.length * CARD_HEADER_LH + CARD_SECTION_GAP + CARD_KEY_FS; + const summaryY = + CARD_PAD + + headerLines.length * CARD_HEADER_LH + + (keyLines.length + ? CARD_SECTION_GAP + keyLines.length * CARD_KEY_LH + CARD_SECTION_GAP + : CARD_SECTION_GAP) + + CARD_SUMMARY_FS; const stroke = isTentative ? `stroke="${accent}" stroke-width="1.5" stroke-dasharray="8 5" stroke-opacity="0.9"` @@ -220,6 +255,13 @@ ) .join(''); + const keySvg = keyLines + .map( + (line, i) => + `${escapeXml(line)}`, + ) + .join(''); + const summarySvg = summaryLines .map( (line, i) => @@ -238,7 +280,7 @@ `` + `` + svgIconGroup(theme.icon, accent, iconX, iconY) + - `${headerSvg}${summarySvg}` + + `${headerSvg}${keySvg}${summarySvg}` + ``; try { @@ -249,6 +291,10 @@ } function destroy() { + if (_resizeObs) { + _resizeObs.disconnect(); + _resizeObs = null; + } if (_cy) { _cy.destroy(); _cy = null; @@ -256,9 +302,28 @@ _graphData = null; } + function observeContainerResize(container) { + if (_resizeObs) { + _resizeObs.disconnect(); + _resizeObs = null; + } + if (!container || typeof ResizeObserver === 'undefined') return; + _resizeObs = new ResizeObserver(() => { + if (_cy) { + try { + _cy.resize(); + } catch (e) { + console.warn('graph resize', e); + } + } + }); + _resizeObs.observe(container); + } + function centerGraph() { if (!_cy) return; try { + _cy.resize(); _cy.fit(undefined, 56); if (_cy.zoom() < 0.65) { _cy.zoom(0.65); @@ -273,17 +338,13 @@ function pathGraphNodeLayer(type, factKey) { const key = (factKey || '').toLowerCase(); if (key.startsWith('vuln:')) return '4'; - if (key.startsWith('target/')) return '0'; - if (key.startsWith('infra/') || key.startsWith('auth/') || key.startsWith('business/')) return '1'; - if (key.startsWith('exploit/') || key.startsWith('evidence/')) return '3'; - if (key.startsWith('poc/')) return '3'; - if (key.startsWith('chain/')) return '2'; - if (key.startsWith('finding/')) return '2'; const t = (type || '').toLowerCase(); if (t === 'target') return '0'; - if (t === 'infra' || t === 'auth') return '1'; + if (t === 'infra' || t === 'auth' || t === 'business') return '1'; if (t === 'exploit' || t === 'poc') return '3'; - if (t === 'chain' || t === 'finding' || t === 'vulnerability') return '2'; + if (t === 'vulnerability' || t === 'vuln') return '3'; + if (t === 'chain' || t === 'finding') return '2'; + if (t === 'note') return '2'; return '2'; } @@ -408,16 +469,19 @@ nodes.forEach((node) => { nodeIds.add(node.id); - const theme = nodeTheme(node.type || node.category || 'note'); - const label = node.label || node.fact_key || node.id; + const visualType = resolveGraphNodeType(node); + const theme = nodeTheme(visualType); + const factKey = node.fact_key || node.id; + const summary = (node.summary || node.label || '').trim() || '—'; const statusBadge = buildStatusBadge(node.confidence); - const layout = computeNodeLayout(node.type || node.category || 'note', label, statusBadge, theme); + const layout = computeNodeLayout(visualType, summary, statusBadge, theme, factKey); elements.push({ data: { id: node.id, label: layout.searchLabel, factKey: node.fact_key || node.id, - type: node.type || 'note', + category: node.category || '', + type: visualType, typeLabel: theme.typeLabel, typeEn: theme.typeEn, accentColor: theme.accent, @@ -529,6 +593,7 @@ }); applyElkLayout(validEdges, isComplex); + observeContainerResize(container); return _cy; } @@ -577,21 +642,28 @@ } } - /** 与后端 GraphNodeType 一致:优先 fact_key 前缀,再 category/type。 */ + /** 与后端 GraphNodeType 一致:优先 category,vuln: 合成节点例外;无 category 时回退 type/key。 */ function resolveGraphNodeType(node) { if (!node) return 'note'; const key = String(node.fact_key || node.id || '').toLowerCase(); + if (key.startsWith('vuln:')) return 'vulnerability'; + const cat = String(node.category || '').toLowerCase(); + if (cat) { + if (cat === 'vuln') return 'vulnerability'; + if (cat === 'missing') return 'missing'; + return cat; + } + const t = String(node.type || '').toLowerCase(); + if (t === 'vuln') return 'vulnerability'; + if (t) return t; if (key.startsWith('target/')) return 'target'; - if (key.startsWith('exploit/') || key.startsWith('poc/') || key.startsWith('evidence/')) return 'exploit'; + if (key.startsWith('exploit/') || key.startsWith('evidence/')) return 'exploit'; + if (key.startsWith('poc/')) return 'poc'; if (key.startsWith('chain/')) return 'chain'; if (key.startsWith('finding/')) return 'finding'; if (key.startsWith('auth/')) return 'auth'; - if (key.startsWith('infra/')) return 'infra'; - if (key.startsWith('business/')) return 'business'; - if (key.startsWith('vuln:')) return 'vulnerability'; - const t = String(node.type || node.category || 'note').toLowerCase(); - if (t === 'vuln') return 'vulnerability'; - return t || 'note'; + if (key.startsWith('infra/') || key.startsWith('business/')) return 'infra'; + return 'note'; } global.ProjectFactGraph = { diff --git a/web/static/js/projects.js b/web/static/js/projects.js index 2e1e4fff..80b2bf05 100644 --- a/web/static/js/projects.js +++ b/web/static/js/projects.js @@ -920,20 +920,31 @@ function renderProjectFactGraphEdges(factKey, graphData, selectedEdgeId) { if (!edges.length) wrap.hidden = false; } +function graphVulnIdFromKey(factKey) { + const key = String(factKey || ''); + if (!key.startsWith('vuln:')) return null; + return key.slice(5); +} + function showProjectFactGraphNode(factKey, graphData, selectedEdgeId) { - if (!factKey || String(factKey).startsWith('vuln:')) { + if (!factKey) { closeProjectFactGraphSidebar(); return; } _selectedGraphFactKey = factKey; _selectedGraphEdgeId = selectedEdgeId || null; const node = (graphData?.nodes || []).find((n) => n.fact_key === factKey || n.id === factKey); + const vulnId = graphVulnIdFromKey(factKey); + const isVulnNode = !!vulnId; const sidebar = document.getElementById('project-fact-graph-sidebar'); const titleEl = document.getElementById('project-fact-graph-node-title'); const metaEl = document.getElementById('project-fact-graph-node-meta'); const categoryEl = document.getElementById('project-fact-graph-node-category'); + const detailBtn = document.getElementById('project-fact-graph-detail-btn'); + const editBtn = document.getElementById('project-fact-graph-edit-btn'); if (!sidebar || !titleEl || !metaEl) return; - titleEl.textContent = factKey; + titleEl.textContent = isVulnNode ? vulnId : factKey; + titleEl.title = isVulnNode ? vulnId : factKey; if (categoryEl) { const visualType = typeof ProjectFactGraph !== 'undefined' && ProjectFactGraph.resolveGraphNodeType @@ -950,11 +961,16 @@ function showProjectFactGraphNode(factKey, graphData, selectedEdgeId) { } const conf = node?.confidence || ''; const summary = (node?.summary || node?.label || '').trim(); - if (summary || conf) { + if (summary || conf || isVulnNode) { const parts = []; if (summary) { parts.push(`${escapeHtml(summary)}`); } + if (isVulnNode) { + parts.push( + `${escapeHtml(tp('projects.graphVulnSidebarHint'))}`, + ); + } if (conf) { parts.push(formatConfidenceBadge(conf)); } @@ -962,6 +978,12 @@ function showProjectFactGraphNode(factKey, graphData, selectedEdgeId) { } else { metaEl.textContent = ''; } + if (detailBtn) { + detailBtn.textContent = isVulnNode ? tp('projects.viewVulnerability') : tp('projects.details'); + } + if (editBtn) { + editBtn.hidden = isVulnNode; + } renderProjectFactGraphEdges(factKey, graphData, _selectedGraphEdgeId); if (_selectedGraphEdgeId && typeof ProjectFactGraph !== 'undefined') { ProjectFactGraph.selectEdge(_selectedGraphEdgeId); @@ -1003,7 +1025,13 @@ async function deleteProjectFactEdge(edgeId) { } function openSelectedGraphFactDetail() { - if (_selectedGraphFactKey) viewProjectFactBody(_selectedGraphFactKey); + if (!_selectedGraphFactKey) return; + const vulnId = graphVulnIdFromKey(_selectedGraphFactKey); + if (vulnId) { + openVulnerabilityDetail(vulnId); + return; + } + viewProjectFactBody(_selectedGraphFactKey); } function editSelectedGraphFact() { diff --git a/web/templates/index.html b/web/templates/index.html index 796a256c..4e33e108 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1664,11 +1664,24 @@