From e19d8e39bdc49da461c6c8d7d803eda797b21ced Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=AC=E6=98=8E?=
<83812544+Ed1s0nZ@users.noreply.github.com>
Date: Sun, 21 Jun 2026 01:52:14 +0800
Subject: [PATCH] Add files via upload
---
web/static/css/style.css | 72 ++++++++++++++++---
web/static/i18n/en-US.json | 10 +++
web/static/i18n/zh-CN.json | 10 +++
web/static/js/fact-graph.js | 134 +++++++++++++++++++++++++++---------
web/static/js/projects.js | 36 ++++++++--
web/templates/index.html | 23 +++++--
6 files changed, 236 insertions(+), 49 deletions(-)
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 @@