diff --git a/web/static/css/style.css b/web/static/css/style.css index a8138eef..58e319ac 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -23860,9 +23860,17 @@ button.chat-files-dropdown-item:hover:not(:disabled) { min-height: 420px; } .projects-placeholder-icon { - font-size: 3rem; - margin-bottom: 16px; - opacity: 0.85; + display: flex; + align-items: center; + justify-content: center; + width: 88px; + height: 88px; + margin-bottom: 20px; + color: #3b82f6; + background: linear-gradient(145deg, #eff6ff 0%, #dbeafe 100%); + border: 1px solid #bfdbfe; + border-radius: 22px; + box-shadow: 0 8px 24px rgba(59, 130, 246, 0.12); } .projects-detail-placeholder h3 { margin: 0 0 8px; @@ -23883,7 +23891,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) { background: #ffffff; border: 1px solid var(--border-color, #e2e8f0); border-radius: 14px; - box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 8px 24px rgba(15, 23, 42, 0.04); overflow: hidden; min-height: 0; align-self: stretch; @@ -24066,6 +24074,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) { color: #0066ff; background: #fff; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); + font-weight: 600; } .projects-panel { flex: 1; @@ -24309,11 +24318,17 @@ button.chat-files-dropdown-item:hover:not(:disabled) { #project-panel-vulns .projects-table-wrap { flex: 1 1 auto; min-height: 0; - overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; } +#project-panel-conversations .projects-table-wrap, +#project-panel-vulns .projects-table-wrap { + overflow-x: hidden; +} +#project-panel-facts .projects-table-wrap { + overflow-x: auto; +} #project-panel-facts .projects-table-wrap .data-table--projects thead th, #project-panel-conversations .projects-table-wrap .data-table--projects thead th, #project-panel-vulns .projects-table-wrap .data-table--projects thead th { @@ -24332,12 +24347,6 @@ button.chat-files-dropdown-item:hover:not(:disabled) { .projects-panel-toolbar--hint .projects-fact-toolbar-hint { margin: 0; } -#project-panel-facts .data-table--projects th:nth-child(1) { width: 20%; } -#project-panel-facts .data-table--projects th:nth-child(2) { width: 9%; } -#project-panel-facts .data-table--projects th:nth-child(3) { width: 30%; } -#project-panel-facts .data-table--projects th:nth-child(4) { width: 9%; } -#project-panel-facts .data-table--projects th:nth-child(5) { width: 10%; } -#project-panel-facts .data-table--projects th:nth-child(6) { width: 10%; } #project-panel-facts .data-table--projects .cell-fact-key { overflow: hidden; max-width: 0; @@ -24345,6 +24354,16 @@ button.chat-files-dropdown-item:hover:not(:disabled) { #project-panel-facts .data-table--projects .cell-fact-category { white-space: nowrap; } +#project-panel-facts .data-table--projects .cell-summary { + max-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +#project-panel-facts .data-table--projects .cell-fact-links { + text-align: center; + white-space: nowrap; +} #project-panel-facts .projects-fact-key-chip { display: inline-block; max-width: 100%; @@ -24463,23 +24482,23 @@ button.chat-files-dropdown-item:hover:not(:disabled) { } #project-panel-facts .data-table--projects th:nth-child(1), #project-panel-facts .data-table--projects td:nth-child(1) { - width: 19%; + width: 13%; } #project-panel-facts .data-table--projects th:nth-child(2), #project-panel-facts .data-table--projects td:nth-child(2) { - width: 9%; + width: 7%; } #project-panel-facts .data-table--projects th:nth-child(3), #project-panel-facts .data-table--projects td:nth-child(3) { - width: 28%; + width: 22%; } #project-panel-facts .data-table--projects th:nth-child(4), #project-panel-facts .data-table--projects td:nth-child(4) { - width: 8%; + width: 5%; } #project-panel-facts .data-table--projects th:nth-child(5), #project-panel-facts .data-table--projects td:nth-child(5) { - width: 9%; + width: 8%; } #project-panel-facts .data-table--projects th:nth-child(6), #project-panel-facts .data-table--projects td:nth-child(6) { @@ -24487,8 +24506,485 @@ button.chat-files-dropdown-item:hover:not(:disabled) { } #project-panel-facts .data-table--projects th:nth-child(7), #project-panel-facts .data-table--projects td:nth-child(7) { - width: 19%; + width: 9%; } +#project-panel-facts .data-table--projects th.col-actions, +#project-panel-facts .data-table--projects td.col-actions { + width: 28%; + min-width: 196px; + max-width: 240px; + position: sticky; + right: 0; + z-index: 3; + background: #fff; + box-shadow: -6px 0 10px rgba(15, 23, 42, 0.05); +} +#project-panel-facts .data-table--projects thead th.col-actions { + z-index: 6; + background: #f8fafc; +} +#project-panel-facts .data-table--projects tbody tr:hover td.col-actions { + background: #f8fafc; +} +#project-panel-facts .data-table--projects .col-actions .projects-table-actions { + flex-wrap: nowrap; +} + +/* 项目事实攻击路径图 */ +#project-panel-graph.projects-panel--graph { + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; + padding-bottom: 16px; +} +#project-panel-graph .projects-graph-toolbar { + flex: 0 0 auto; +} +#project-panel-graph .project-fact-graph-layout { + flex: 1 1 auto; + min-height: 0; +} +.projects-graph-toolbar-row { + align-items: flex-end; +} +.projects-graph-search-field { + flex: 1 1 180px; + max-width: 280px; +} +.projects-graph-actions { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + padding: 3px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 10px; +} +.projects-graph-action-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 11px; + font-size: 0.8125rem; + font-weight: 500; + color: #475569; + background: transparent; + border: 1px solid transparent; + border-radius: 7px; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} +.projects-graph-action-btn svg { + flex-shrink: 0; + opacity: 0.75; +} +.projects-graph-action-btn:hover { + color: #0f172a; + background: #fff; + border-color: #e2e8f0; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06); +} +.projects-graph-action-btn--connect { + color: #4338ca; + background: #eef2ff; + border-color: #c7d2fe; +} +.projects-graph-action-btn--connect:hover, +.projects-graph-action-btn--connect-active { + color: #fff; + background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); + border-color: transparent; + box-shadow: 0 2px 8px rgba(79, 70, 229, 0.35); +} +.projects-graph-legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 14px; + padding-top: 2px; +} +.projects-graph-legend-item { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + color: #64748b; +} +.projects-graph-legend-item 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 { + border-top-style: dashed; + opacity: 0.7; +} +.project-fact-graph-layout { + position: relative; + display: flex; + min-height: 480px; + align-items: stretch; +} +.project-fact-graph-container { + flex: 1 1 auto; + width: 100%; + min-height: 480px; + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 14px; + background-color: #f8fafc; + background-image: + radial-gradient(circle at 1px 1px, rgba(148, 163, 184, 0.35) 1px, transparent 0); + background-size: 20px 20px; + position: relative; + overflow: hidden; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8), 0 1px 3px rgba(15, 23, 42, 0.04); +} +.project-fact-graph-container::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient(ellipse at center, transparent 55%, rgba(241, 245, 249, 0.65) 100%); + z-index: 1; +} +.project-fact-graph-container .loading-spinner, +.project-fact-graph-container .project-fact-graph-empty, +.project-fact-graph-container .error-message { + position: relative; + z-index: 2; +} +.project-fact-graph-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + height: 100%; + min-height: 420px; + padding: 40px 32px; +} +.project-fact-graph-empty-icon { + display: flex; + align-items: center; + justify-content: center; + width: 80px; + height: 80px; + margin-bottom: 18px; + background: rgba(255, 255, 255, 0.85); + border: 1px solid #e2e8f0; + border-radius: 20px; + box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06); +} +.project-fact-graph-empty-title { + margin: 0 0 8px; + font-size: 1.0625rem; + font-weight: 600; + color: #0f172a; + letter-spacing: -0.01em; +} +.project-fact-graph-empty-hint { + margin: 0 0 16px; + max-width: 420px; + font-size: 0.875rem; + line-height: 1.6; + color: #64748b; +} +.project-fact-graph-empty-steps { + margin: 0 0 20px; + padding-left: 1.2rem; + max-width: 400px; + text-align: left; + font-size: 0.8125rem; + line-height: 1.65; + color: #475569; +} +.project-fact-graph-empty-steps li { + margin-bottom: 4px; +} +.project-fact-graph-empty-steps li::marker { + color: #6366f1; + font-weight: 600; +} +.project-fact-graph-empty-cta { + margin-top: 4px; +} +.project-fact-graph-sidebar { + position: absolute; + top: 12px; + right: 12px; + bottom: 12px; + width: min(300px, calc(100% - 24px)); + z-index: 12; + border: 1px solid rgba(226, 232, 240, 0.95); + border-radius: 14px; + padding: 16px; + background: rgba(255, 255, 255, 0.96); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 8px 32px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(15, 23, 42, 0.06); + animation: projectGraphSidebarIn 0.2s ease; + overflow-x: hidden; + overflow-y: auto; +} +.project-fact-graph-sidebar[hidden] { + display: none !important; +} +@keyframes projectGraphSidebarIn { + from { + opacity: 0; + transform: translateX(12px); + } + to { + opacity: 1; + transform: translateX(0); + } +} +.project-fact-graph-sidebar-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} +.project-fact-graph-sidebar-title-wrap { + min-width: 0; + flex: 1; +} +.project-fact-graph-sidebar-header h4 { + margin: 4px 0 0; + font-size: 0.9rem; + font-weight: 600; + word-break: break-all; + color: #0f172a; + line-height: 1.35; +} +.project-fact-graph-node-category { + display: inline-block; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 8px; + border-radius: 999px; + background: #f1f5f9; + color: #64748b; + border: 1px solid #e2e8f0; +} +.project-fact-graph-node-category--target { color: #4338ca; background: #eef2ff; border-color: #c7d2fe; } +.project-fact-graph-node-category--finding { color: #be123c; background: #fff1f2; border-color: #fecdd3; } +.project-fact-graph-node-category--exploit, +.project-fact-graph-node-category--poc { color: #c2410c; background: #ffedd5; border-color: #fdba74; } +.project-fact-graph-node-category--auth { color: #0f766e; background: #f0fdfa; border-color: #99f6e4; } +.project-fact-graph-sidebar-close { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + color: #64748b; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} +.project-fact-graph-sidebar-close:hover { + color: #0f172a; + border-color: #cbd5e1; + background: #f8fafc; +} +.project-fact-graph-node-meta { + margin: 0; + font-size: 0.8125rem; + line-height: 1.55; + color: #64748b; + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + min-width: 0; + word-break: break-word; + overflow-wrap: anywhere; +} +.project-fact-graph-node-summary { + display: block; + width: 100%; + min-width: 0; + color: #475569; +} +.project-fact-graph-edges-wrap { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 4px; + border-top: 1px solid #f1f5f9; +} +.project-fact-graph-edges-title { + margin: 0; + font-size: 0.75rem; + font-weight: 600; + color: #475569; + letter-spacing: 0.02em; +} +.project-fact-graph-edges-hint { + margin: 0; + font-size: 0.72rem; + line-height: 1.45; + color: #94a3b8; + word-break: break-word; + overflow-wrap: anywhere; +} +.project-fact-graph-edges-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; +} +.project-fact-graph-edges-empty { + margin: 0; + font-size: 0.8125rem; + color: #94a3b8; +} +.project-fact-graph-edge-item { + display: grid; + grid-template-columns: auto auto auto 1fr auto; + align-items: center; + gap: 4px 6px; + padding: 6px 8px; + font-size: 0.75rem; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; +} +.project-fact-graph-edge-item:hover { + border-color: #cbd5e1; + background: #fff; +} +.project-fact-graph-edge-item.is-selected { + border-color: #818cf8; + background: #eef2ff; + box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.25); +} +.project-fact-graph-edge-dir { + font-size: 0.6875rem; + font-weight: 600; + color: #64748b; + white-space: nowrap; +} +.project-fact-graph-edge-type { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.6875rem; + color: #4338ca; + white-space: nowrap; +} +.project-fact-graph-edge-arrow { + color: #94a3b8; +} +.project-fact-graph-edge-peer { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #334155; + min-width: 0; +} +.project-fact-graph-edge-delete { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: 1px solid #fecaca; + border-radius: 6px; + background: #fff; + color: #dc2626; + font-size: 1rem; + line-height: 1; + cursor: pointer; + flex-shrink: 0; +} +.project-fact-graph-edge-delete:hover { + background: #fef2f2; + border-color: #f87171; +} +.project-fact-graph-edge-synthetic { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + color: #cbd5e1; + flex-shrink: 0; +} +.project-fact-graph-sidebar-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + padding-top: 4px; + border-top: 1px solid #f1f5f9; +} +.project-fact-graph-stats { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 12px 0 0; + flex: 0 0 auto; +} +.projects-graph-stat-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.8125rem; + color: #64748b; + background: #f8fafc; + border: 1px solid #e2e8f0; + padding: 4px 12px; + border-radius: 999px; +} +.projects-graph-stat-badge strong { + font-size: 0.9375rem; + font-weight: 700; + color: #0f172a; + font-variant-numeric: tabular-nums; +} +#project-panel-graph .projects-fact-toolbar-filters { + flex-wrap: wrap; +} +.projects-fact-link-badge { + font-size: 0.78rem; + font-variant-numeric: tabular-nums; + color: var(--text-secondary, #64748b); +} +.projects-fact-link-badge--empty { + opacity: 0.45; +} +@media (max-width: 1100px) { + .project-fact-graph-sidebar { + width: min(280px, calc(100% - 24px)); + } + .projects-graph-actions { + margin-left: 0; + width: 100%; + justify-content: flex-end; + } +} + @media (max-width: 1400px) { .projects-detail-header { padding: 16px 18px 14px; @@ -24513,11 +25009,12 @@ button.chat-files-dropdown-item:hover:not(:disabled) { } #project-panel-facts .data-table--projects th:nth-child(3), #project-panel-facts .data-table--projects td:nth-child(3) { - width: 24%; + width: 22%; } - #project-panel-facts .data-table--projects th:nth-child(7), - #project-panel-facts .data-table--projects td:nth-child(7) { - width: 23%; + #project-panel-facts .data-table--projects th.col-actions, + #project-panel-facts .data-table--projects td.col-actions { + min-width: 188px; + max-width: 220px; } } /* —— 项目设置:左右分栏 + 底部危险区,无内层滚动 —— */ diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 927d68e8..2ff498d7 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -258,10 +258,58 @@ "vulnerabilityManagement": "Vulnerability management", "addFactCta": "+ Add fact", "tabFacts": "Fact board", + "tabGraph": "Attack path", "tabConversations": "Bound conversations", "tabVulns": "Related vulnerabilities", "tabSettings": "Settings", "factToolbarHint": "Index includes key and summary only (must include what + where + how to verify); put attack chain / POC in body, and reproduce via get_project_fact.", + "graphToolbarHint": "Attack path graph shows target → finding → exploit causality. Dashed edges are tentative. Click a node for details.", + "graphView": "View", + "graphViewPath": "Attack path", + "graphViewFull": "Full graph", + "graphSearchSr": "Search nodes", + "graphSearchPlaceholder": "Search nodes…", + "graphRefresh": "Refresh", + "graphCenter": "Center", + "graphEmpty": "No graph data yet. Add links on finding/exploit facts (discovered_on → target/*) to build the path.", + "graphEmptyTitle": "Build your attack path", + "graphEmptyStep1": "Add target facts (domains, endpoints, scope)", + "graphEmptyStep2": "Record findings/exploits with links between facts", + "graphEmptyStep3": "Use Connect mode or edit facts to add relationships", + "graphEmptyCta": "Add first fact", + "graphStats": "Nodes: {{nodes}} | Edges: {{edges}}", + "graphStatsNodes": "Nodes", + "graphStatsEdges": "Edges", + "graphLegendDiscovered": "discovered_on", + "graphLegendLeads": "leads_to", + "graphLegendExploits": "exploits", + "graphLegendTentative": "Tentative (dashed)", + "factLinksLabel": "Outgoing links", + "factLinksPlaceholder": "discovered_on: target/primary_domain\nleads_to: finding/swagger", + "factLinksHint": "One per line: type: fact_key. Common types: discovered_on, depends_on, leads_to, enables, exploits. Saving replaces all outgoing links.", + "linksColumn": "Links", + "linkCountsTitle": "Outgoing / incoming edge counts", + "graphConnect": "Connect", + "graphConnectActive": "Connecting…", + "graphConnectPickTarget": "Source {{source}} selected — click target node", + "graphEdgeTypePrompt": "Edge type (discovered_on / leads_to / depends_on / enables / exploits)", + "graphConnectFailed": "Failed to create edge", + "graphConnectSuccess": "Edge created", + "graphEdgesTitle": "Links", + "graphEdgesHint": "Click an edge in the graph to focus it. Delete mistaken links here.", + "graphEdgesEmpty": "No links yet", + "graphEdgeOutgoing": "Outgoing", + "graphEdgeIncoming": "Incoming", + "graphEdgeSynthetic": "Auto-generated from fact link; edit the fact to remove", + "confirmDeleteGraphEdge": "Delete this link?", + "graphEdgeDeleteFailed": "Failed to delete edge", + "graphEdgeDeleteSuccess": "Edge deleted", + "graphDeleteEdge": "Delete", + "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.", + "promoteAttackChainFailed": "Promote failed", + "promoteAttackChainSuccess": "Promoted: {{facts_created}} new / {{facts_updated}} updated / {{edges_created}} edges", "searchFactsSr": "Search facts", "searchFactsPlaceholder": "Search key, summary, body…", "category": "Category", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 9290be23..ab2dfcde 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -246,10 +246,58 @@ "vulnerabilityManagement": "漏洞管理", "addFactCta": "+ 添加事实", "tabFacts": "事实黑板", + "tabGraph": "攻击路径", "tabConversations": "关联对话", "tabVulns": "关联漏洞", "tabSettings": "设置", "factToolbarHint": "索引仅含 key 与摘要(须含「什么 + 在哪 + 如何验证」);攻击链 / POC 写在 body,Agent 通过 get_project_fact 复现", + "graphToolbarHint": "攻击路径图:展示 target → finding → exploit 因果关系;虚线边表示待确认。点击节点查看详情。", + "graphView": "视图", + "graphViewPath": "攻击路径", + "graphViewFull": "完整关系", + "graphSearchSr": "搜索节点", + "graphSearchPlaceholder": "搜索节点…", + "graphRefresh": "刷新", + "graphCenter": "居中", + "graphEmpty": "暂无路径图数据。为 finding/exploit 类事实添加关系边(discovered_on → target/*)后将在此展示。", + "graphEmptyTitle": "构建攻击路径图", + "graphEmptyStep1": "添加 target 类事实(目标、域名、入口)", + "graphEmptyStep2": "记录 finding / exploit 并在 links 中连边", + "graphEmptyStep3": "使用「连边」模式或编辑事实手动补关系", + "graphEmptyCta": "添加第一条事实", + "graphStats": "节点: {{nodes}} | 边: {{edges}}", + "graphStatsNodes": "节点", + "graphStatsEdges": "边", + "graphLegendDiscovered": "discovered_on", + "graphLegendLeads": "leads_to", + "graphLegendExploits": "exploits", + "graphLegendTentative": "待确认(虚线)", + "factLinksLabel": "关系边(出边)", + "factLinksPlaceholder": "discovered_on: target/primary_domain\nleads_to: finding/swagger", + "factLinksHint": "每行一条:type: fact_key。常用 type:discovered_on、depends_on、leads_to、enables、exploits。保存时替换全部出边。", + "linksColumn": "关系", + "linkCountsTitle": "出边数 / 入边数", + "graphConnect": "连边", + "graphConnectActive": "连边中…", + "graphConnectPickTarget": "已选 {{source}},请点击目标节点", + "graphEdgeTypePrompt": "边类型(discovered_on / leads_to / depends_on / enables / exploits)", + "graphConnectFailed": "创建边失败", + "graphConnectSuccess": "边已创建", + "graphEdgesTitle": "关系边", + "graphEdgesHint": "点击图中连线可定位;误连可在此删除。", + "graphEdgesEmpty": "暂无关系边", + "graphEdgeOutgoing": "出边", + "graphEdgeIncoming": "入边", + "graphEdgeSynthetic": "由事实关联自动生成,请编辑事实解除", + "confirmDeleteGraphEdge": "确定删除此关系边?", + "graphEdgeDeleteFailed": "删除边失败", + "graphEdgeDeleteSuccess": "边已删除", + "graphDeleteEdge": "删边", + "promoteAttackChain": "沉淀攻击链", + "promoteAttackChainTitle": "将对话攻击链沉淀为项目事实与边", + "confirmPromoteAttackChain": "将该对话的攻击链沉淀到本项目?会创建/更新事实与关系边。", + "promoteAttackChainFailed": "沉淀失败", + "promoteAttackChainSuccess": "已沉淀:新建 {{facts_created}} / 更新 {{facts_updated}} / 边 {{edges_created}}", "searchFactsSr": "搜索事实", "searchFactsPlaceholder": "搜索 key、摘要、body…", "category": "分类", diff --git a/web/static/js/fact-graph.js b/web/static/js/fact-graph.js new file mode 100644 index 00000000..e604e23d --- /dev/null +++ b/web/static/js/fact-graph.js @@ -0,0 +1,555 @@ +/** + * 项目事实图渲染(Cytoscape + ELK),供项目管理页使用。 + * 节点采用 SVG 卡片背景(图标 + 多行文字),避免 Cytoscape 原生 label 定位问题。 + */ +(function (global) { + 'use strict'; + + let _cy = null; + let _graphData = null; + let _onNodeSelect = null; + let _onEdgeSelect = null; + + const EDGE_COLORS = { + discovered_on: '#4F46E5', + leads_to: '#64748B', + enables: '#E11D48', + exploits: '#DC2626', + depends_on: '#0D9488', + contains: '#6366F1', + part_of: '#6366F1', + supports: '#94A3B8', + links_vuln: '#BE123C', + }; + + const CARD_PAD = 14; + const CARD_TEXT_PAD_RIGHT = 12; + const CARD_ICON = 36; + const CARD_ICON_GAP = 12; + const CARD_TEXT_X = CARD_PAD + CARD_ICON + CARD_ICON_GAP; + const CARD_MIN_W = 300; + const CARD_TARGET_W = 360; + const CARD_MIN_H = 88; + const CARD_MAX_H = 152; + const CARD_HEADER_FS = 11; + const CARD_HEADER_LH = 16; + 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'; + + 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' }; + case 'vulnerability': + return { typeLabel: '漏洞', typeEn: 'VULN', accent: '#BE123C', bgEnd: '#FFF1F2', icon: 'vulnerability' }; + case 'auth': + return { typeLabel: '认证', typeEn: 'AUTH', accent: '#0D9488', bgEnd: '#F0FDFA', icon: 'default' }; + case 'infra': + return { typeLabel: '基础设施', typeEn: 'INFRA', accent: '#64748B', bgEnd: '#F8FAFC', icon: 'default' }; + case 'missing': + return { typeLabel: '缺失', typeEn: 'MISSING', accent: '#CBD5E1', bgEnd: '#F1F5F9', icon: 'default' }; + default: + return { typeLabel: '备注', typeEn: 'NOTE', accent: '#94A3B8', bgEnd: '#F8FAFC', icon: 'default' }; + } + } + + function escapeXml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function escapeHtml(str) { + return escapeXml(str); + } + + function buildStatusBadge(confidence) { + const conf = (confidence || '').toLowerCase(); + if (conf === 'tentative') return '待确认'; + if (conf === 'deprecated') return '已废弃'; + return ''; + } + + function buildHeaderText(theme, statusBadge) { + const line = (theme.typeEn || '') + ' · ' + (theme.typeLabel || ''); + return statusBadge ? line + ' · ' + statusBadge : line; + } + + function isWideChar(ch) { + const code = ch.codePointAt(0) || 0; + if (code >= 0x4e00 && code <= 0x9fff) return true; + if (code >= 0x3400 && code <= 0x4dbf) return true; + if (code >= 0xf900 && code <= 0xfaff) return true; + if (code >= 0xff00 && code <= 0xffef) return true; + return /[·:,。;!?【】()《》、「」]/.test(ch); + } + + function charWidth(ch, fontSize, bold) { + const scale = bold ? 1.05 : 1; + if (ch === ' ') return fontSize * 0.3 * scale; + if (isWideChar(ch)) return fontSize * scale; + return fontSize * 0.58 * scale; + } + + function lineWidth(text, fontSize, bold) { + let width = 0; + for (const ch of text) width += charWidth(ch, fontSize, bold); + return width; + } + + function wrapTextLines(text, maxWidth, fontSize, maxLines, bold) { + const raw = String(text || '').replace(/\s+/g, ' ').trim(); + if (!raw) return ['—']; + const safeWidth = Math.max(40, maxWidth - 4); + const chars = [...raw]; + const lines = []; + let index = 0; + while (index < chars.length && lines.length < maxLines) { + let line = ''; + let width = 0; + while (index < chars.length) { + const ch = chars[index]; + const nextWidth = charWidth(ch, fontSize, bold); + if (line && width + nextWidth > safeWidth) break; + line += ch; + width += nextWidth; + index += 1; + if (width >= safeWidth) break; + } + if (line) lines.push(line); + } + if (index < chars.length && lines.length) { + let last = lines[lines.length - 1]; + while (last.length > 1 && lineWidth(last + '…', fontSize, bold) > safeWidth) { + last = last.slice(0, -1); + } + lines[lines.length - 1] = last + '…'; + } + return lines.length ? lines : ['—']; + } + + function cardTextWidth(nodeWidth) { + return nodeWidth - CARD_TEXT_X - CARD_PAD - CARD_TEXT_PAD_RIGHT; + } + + function computeNodeLayout(type, summary, statusBadge, theme) { + 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 height = Math.min( + CARD_MAX_H, + Math.max( + CARD_MIN_H, + CARD_PAD + + headerLines.length * CARD_HEADER_LH + + CARD_SECTION_GAP + + summaryLines.length * CARD_SUMMARY_LH + + CARD_PAD, + ), + ); + return { + width, + height, + headerLines, + summaryLines, + searchLabel: headerLines.join(' ') + '\n' + summaryLines.join(' '), + }; + } + + function svgIconGroup(kind, color, x, y) { + const scale = (CARD_ICON / 24).toFixed(3); + if (kind === 'target') { + return ( + `` + + `` + + `` + ); + } + if (kind === 'vulnerability') { + return ( + `` + + `` + + `` + + `` + ); + } + return ( + `` + + `` + ); + } + + function buildNodeCardSvgUrl(theme, layout, confidence) { + const { width, height, headerLines, summaryLines } = layout; + const accent = theme.accent; + const bgEnd = theme.bgEnd; + const conf = (confidence || '').toLowerCase(); + const isTentative = conf === 'tentative'; + const isDeprecated = conf === 'deprecated'; + 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 stroke = isTentative + ? `stroke="${accent}" stroke-width="1.5" stroke-dasharray="8 5" stroke-opacity="0.9"` + : `stroke="${accent}" stroke-width="1.5" stroke-opacity="0.72"`; + + const headerSvg = headerLines + .map( + (line, i) => + `${escapeXml(line)}`, + ) + .join(''); + + const summarySvg = summaryLines + .map( + (line, i) => + `${escapeXml(line)}`, + ) + .join(''); + + const textClipW = width - CARD_TEXT_X - CARD_PAD - 2; + const textClipH = height - CARD_PAD * 2 + 4; + + const svg = + `` + + `` + + `` + + `` + + `` + + `` + + svgIconGroup(theme.icon, accent, iconX, iconY) + + `${headerSvg}${summarySvg}` + + ``; + + try { + return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); + } catch (e) { + return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); + } + } + + function destroy() { + if (_cy) { + _cy.destroy(); + _cy = null; + } + _graphData = null; + } + + function centerGraph() { + if (!_cy) return; + try { + _cy.fit(undefined, 56); + if (_cy.zoom() < 0.65) { + _cy.zoom(0.65); + _cy.center(); + } + } catch (e) { + console.warn('centerGraph', e); + } + } + + function applyElkLayout(validEdges, isComplex) { + const layoutOptions = { + name: 'breadthfirst', + directed: true, + spacingFactor: isComplex ? 3.0 : 2.5, + padding: 40, + }; + const elkInstance = typeof ELK !== 'undefined' ? new ELK() : null; + if (!elkInstance) { + const layout = _cy.layout(layoutOptions); + layout.one('layoutstop', () => setTimeout(centerGraph, 100)); + layout.run(); + return; + } + const nodeGap = isComplex ? 45 : 60; + const layerGap = isComplex ? 70 : 95; + const elkGraph = { + id: 'root', + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'DOWN', + 'elk.spacing.nodeNode': String(nodeGap), + 'elk.layered.spacing.nodeNodeBetweenLayers': String(layerGap), + 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF', + }, + children: (_graphData.nodes || []).map((node) => { + const n = _cy ? _cy.getElementById(node.id) : null; + const w = n.length ? n.data('nodeWidth') : node.type === 'target' ? CARD_TARGET_W : CARD_MIN_W; + const h = n.length ? n.data('nodeHeight') : CARD_MIN_H; + return { id: node.id, width: w, height: h }; + }), + edges: validEdges.map((edge) => ({ + id: edge.id, + sources: [edge.source], + targets: [edge.target], + })), + }; + elkInstance + .layout(elkGraph) + .then((laidOut) => { + (laidOut.children || []).forEach((elkNode) => { + const cyNode = _cy.getElementById(elkNode.id); + if (cyNode.length && elkNode.x != null) { + cyNode.position({ + x: elkNode.x + (elkNode.width || 0) / 2, + y: elkNode.y + (elkNode.height || 0) / 2, + }); + } + }); + setTimeout(centerGraph, 120); + }) + .catch(() => { + const layout = _cy.layout(layoutOptions); + layout.one('layoutstop', () => setTimeout(centerGraph, 100)); + layout.run(); + }); + } + + function render(container, graphData, options) { + if (!container || typeof cytoscape === 'undefined') { + if (container) { + container.innerHTML = '
Cytoscape 未加载
'; + } + return null; + } + destroy(); + _graphData = graphData || { nodes: [], edges: [] }; + _onNodeSelect = options && options.onNodeSelect; + _onEdgeSelect = options && options.onEdgeSelect; + + const nodes = _graphData.nodes || []; + const edges = _graphData.edges || []; + if (!nodes.length) { + const title = (options && options.emptyTitle) || ''; + const hint = (options && options.emptyText) || '暂无事实关系'; + const steps = (options && options.emptySteps) || []; + const actionLabel = options && options.emptyActionLabel; + const stepsHtml = steps.length + ? '
    ' + + steps.map((s) => '
  1. ' + escapeHtml(String(s)) + '
  2. ').join('') + + '
' + : ''; + const actionHtml = + actionLabel && options.onEmptyAction + ? '' + : ''; + container.innerHTML = + '
' + + '' + + (title ? '

' + escapeHtml(title) + '

' : '') + + '

' + escapeHtml(hint) + '

' + + stepsHtml + + actionHtml + + '
'; + const cta = container.querySelector('.project-fact-graph-empty-cta'); + if (cta && typeof options.onEmptyAction === 'function') { + cta.addEventListener('click', options.onEmptyAction); + } + return null; + } + + container.innerHTML = ''; + const isComplex = nodes.length > 15 || edges.length > 25; + const elements = []; + const nodeIds = new Set(); + + 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 statusBadge = buildStatusBadge(node.confidence); + const layout = computeNodeLayout(node.type || node.category || 'note', label, statusBadge, theme); + elements.push({ + data: { + id: node.id, + label: layout.searchLabel, + factKey: node.fact_key || node.id, + type: node.type || 'note', + typeLabel: theme.typeLabel, + typeEn: theme.typeEn, + accentColor: theme.accent, + statusBadge: statusBadge, + confidence: node.confidence || '', + nodeWidth: layout.width, + nodeHeight: layout.height, + cardSvgUrl: buildNodeCardSvgUrl(theme, layout, node.confidence), + }, + }); + }); + + const validEdges = []; + edges.forEach((edge, idx) => { + if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) return; + const id = edge.id || 'e-' + idx; + validEdges.push({ ...edge, id }); + elements.push({ + data: { + id, + source: edge.source, + target: edge.target, + type: edge.type || 'leads_to', + confidence: edge.confidence || 'confirmed', + }, + }); + }); + + _cy = cytoscape({ + container, + elements, + style: [ + { + selector: 'node', + style: { + label: '', + width: (ele) => ele.data('nodeWidth') || CARD_MIN_W, + height: (ele) => ele.data('nodeHeight') || CARD_MIN_H, + shape: 'round-rectangle', + 'background-color': '#ffffff', + 'background-image': (ele) => ele.data('cardSvgUrl') || 'none', + 'background-width': (ele) => (ele.data('nodeWidth') || CARD_MIN_W) + 'px', + 'background-height': (ele) => (ele.data('nodeHeight') || CARD_MIN_H) + 'px', + 'background-position-x': '50%', + 'background-position-y': '50%', + 'background-fit': 'none', + 'border-width': 0, + 'background-opacity': 1, + }, + }, + { + selector: 'edge', + style: { + width: 2.2, + 'line-color': (ele) => EDGE_COLORS[ele.data('type')] || '#CBD5E1', + 'target-arrow-color': (ele) => EDGE_COLORS[ele.data('type')] || '#CBD5E1', + 'target-arrow-shape': 'triangle', + 'curve-style': 'bezier', + opacity: (ele) => (ele.data('confidence') === 'tentative' ? 0.55 : 0.9), + 'line-style': (ele) => (ele.data('confidence') === 'tentative' ? 'dashed' : 'solid'), + }, + }, + { + selector: 'edge:selected', + style: { + width: 3.5, + opacity: 1, + 'line-color': '#4F46E5', + 'target-arrow-color': '#4F46E5', + }, + }, + { + selector: 'node:selected', + style: { + 'border-width': 3, + 'border-color': '#4F46E5', + 'border-opacity': 1, + }, + }, + ], + minZoom: 0.35, + maxZoom: 3, + }); + + _cy.on('tap', 'node', (evt) => { + const d = evt.target.data(); + const key = d.factKey || d.id; + if (_connectMode && _connectPick) { + _connectPick(key); + return; + } + if (typeof _onNodeSelect === 'function') { + _onNodeSelect(key, d); + } + }); + + _cy.on('tap', 'edge', (evt) => { + if (_connectMode && _connectPick) return; + const d = evt.target.data(); + if (typeof _onEdgeSelect === 'function') { + _onEdgeSelect(d.id, d); + } + }); + + _cy.on('tap', (evt) => { + if (evt.target === _cy) { + clearEdgeSelection(); + } + }); + + applyElkLayout(validEdges, isComplex); + return _cy; + } + + function filterBySearch(query) { + if (!_cy) return; + const q = (query || '').trim().toLowerCase(); + _cy.nodes().forEach((n) => { + if (!q) { + n.style('opacity', 1); + return; + } + const text = ( + (n.data('label') || '') + + ' ' + + (n.data('factKey') || '') + + ' ' + + (n.data('typeLabel') || '') + ).toLowerCase(); + n.style('opacity', text.includes(q) ? 1 : 0.15); + }); + _cy.edges().forEach((e) => { + e.style('opacity', q ? 0.12 : 0.9); + }); + } + + let _connectMode = false; + let _connectPick = null; + + function selectEdge(edgeId) { + if (!_cy || !edgeId) return; + _cy.elements().unselect(); + const edge = _cy.getElementById(edgeId); + if (edge.length) edge.select(); + } + + function clearEdgeSelection() { + if (!_cy) return; + _cy.elements().unselect(); + } + + function setConnectMode(enabled, onPick) { + _connectMode = !!enabled; + _connectPick = typeof onPick === 'function' ? onPick : null; + if (_cy) { + _cy.userPanningEnabled(!_connectMode); + } + } + + global.ProjectFactGraph = { + render, + destroy, + center: centerGraph, + filterBySearch, + setConnectMode, + selectEdge, + clearEdgeSelection, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/web/static/js/projects.js b/web/static/js/projects.js index 64d24a28..20cb554d 100644 --- a/web/static/js/projects.js +++ b/web/static/js/projects.js @@ -64,6 +64,8 @@ Host: ... ## 关联 - related_vulnerability_id: <可选> - 依赖事实: +- 结构化出边(自动同步): + - discovered_on: target/primary_domain ## 备注与不确定性 <待验证假设、环境差异、绕过尝试记录>`; @@ -730,20 +732,280 @@ async function selectProject(id) { function switchProjectTab(tab) { currentProjectTab = tab; - ['facts', 'conversations', 'vulns', 'settings'].forEach((t) => { + ['facts', 'graph', 'conversations', 'vulns', 'settings'].forEach((t) => { const btn = document.getElementById(`project-tab-${t}`); const panel = document.getElementById(`project-panel-${t}`); if (btn) btn.classList.toggle('is-active', t === tab); if (panel) panel.hidden = t !== tab; }); if (tab === 'facts') loadProjectFacts(); + if (tab === 'graph') loadProjectFactGraph(); if (tab === 'conversations') loadProjectConversations(); if (tab === 'vulns') loadProjectVulnerabilities(); } +let _selectedGraphFactKey = null; +let _selectedGraphEdgeId = null; +let _currentGraphData = null; +let _graphConnectMode = false; +let _graphConnectSource = null; + +function toggleProjectFactGraphConnectMode() { + _graphConnectMode = !_graphConnectMode; + _graphConnectSource = null; + const btn = document.getElementById('project-graph-connect-btn'); + if (btn) { + btn.classList.toggle('is-active', _graphConnectMode); + btn.textContent = _graphConnectMode ? tp('projects.graphConnectActive') : tp('projects.graphConnect'); + btn.classList.toggle('projects-graph-action-btn--connect-active', _graphConnectMode); + } + if (typeof ProjectFactGraph !== 'undefined') { + ProjectFactGraph.setConnectMode(_graphConnectMode, handleGraphConnectNodePick); + } +} + +async function handleGraphConnectNodePick(factKey) { + if (!factKey || String(factKey).startsWith('vuln:')) return; + if (!_graphConnectSource) { + _graphConnectSource = factKey; + if (typeof showNotification === 'function') { + showNotification(tpFmt('projects.graphConnectPickTarget', `已选源节点 ${factKey},请点击目标节点`, { source: factKey }), 'info'); + } + return; + } + if (_graphConnectSource === factKey) return; + const edgeType = window.prompt(tp('projects.graphEdgeTypePrompt'), 'leads_to'); + if (!edgeType) { + _graphConnectSource = null; + return; + } + const res = await apiFetch(`/api/projects/${currentProjectId}/fact-edges`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source_fact_key: _graphConnectSource, + target_fact_key: factKey, + edge_type: edgeType.trim(), + }), + }); + _graphConnectSource = null; + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return alert(err.error || tp('projects.graphConnectFailed')); + } + if (typeof showNotification === 'function') showNotification(tp('projects.graphConnectSuccess'), 'success'); + loadProjectFactGraph(); + loadProjectFacts(); +} + +function formatOutgoingLinksForModal(links) { + if (!links || !links.length) return ''; + return links + .map((e) => `${e.edge_type || e.type}: ${e.target_fact_key || e.to}`) + .join('\n'); +} + +async function loadProjectFactGraph() { + const container = document.getElementById('project-fact-graph-container'); + const statsEl = document.getElementById('project-fact-graph-stats'); + if (!container || !currentProjectId) return; + container.innerHTML = `
${escapeHtml(tp('common.loading'))}
`; + closeProjectFactGraphSidebar(); + const view = document.getElementById('project-graph-view')?.value || 'path'; + const hideDeprecated = document.getElementById('project-facts-filter-hide-deprecated')?.checked !== false; + const params = new URLSearchParams({ view }); + if (!hideDeprecated) params.set('exclude_deprecated', '0'); + try { + const res = await apiFetch(`/api/projects/${currentProjectId}/fact-graph?${params}`); + if (!res.ok) throw new Error(tp('common.loadFailed')); + const data = await res.json(); + _currentGraphData = data; + if (typeof ProjectFactGraph !== 'undefined') { + ProjectFactGraph.render(container, data, { + emptyText: tp('projects.graphEmpty'), + emptyTitle: tp('projects.graphEmptyTitle'), + emptySteps: [ + tp('projects.graphEmptyStep1'), + tp('projects.graphEmptyStep2'), + tp('projects.graphEmptyStep3'), + ], + emptyActionLabel: tp('projects.graphEmptyCta'), + onEmptyAction: () => showAddFactModal(), + onNodeSelect: (factKey) => showProjectFactGraphNode(factKey, _currentGraphData), + onEdgeSelect: (edgeId) => showProjectFactGraphEdge(edgeId, _currentGraphData), + }); + } + const nodeCount = (data.nodes || []).length; + const edgeCount = (data.edges || []).length; + if (statsEl) { + statsEl.innerHTML = + `${nodeCount} ${escapeHtml(tp('projects.graphStatsNodes'))}` + + `${edgeCount} ${escapeHtml(tp('projects.graphStatsEdges'))}`; + } + } catch (e) { + container.innerHTML = `
${escapeHtml(e.message || tp('common.loadFailed'))}
`; + if (statsEl) statsEl.textContent = ''; + } +} + +function filterProjectFactGraph() { + const q = document.getElementById('project-graph-search')?.value || ''; + if (typeof ProjectFactGraph !== 'undefined') { + ProjectFactGraph.filterBySearch(q); + } +} + +function centerProjectFactGraph() { + if (typeof ProjectFactGraph !== 'undefined') ProjectFactGraph.center(); +} + +function closeProjectFactGraphSidebar() { + _selectedGraphFactKey = null; + _selectedGraphEdgeId = null; + if (typeof ProjectFactGraph !== 'undefined') ProjectFactGraph.clearEdgeSelection(); + const sidebar = document.getElementById('project-fact-graph-sidebar'); + if (sidebar) sidebar.hidden = true; +} + +function isSyntheticGraphEdge(edge) { + if (!edge) return true; + const id = String(edge.id || ''); + const type = String(edge.type || ''); + return id.startsWith('vuln-link:') || type === 'links_vuln'; +} + +function getGraphEdgesForFact(factKey, graphData) { + if (!factKey || !graphData?.edges) return []; + return graphData.edges.filter((e) => e.source === factKey || e.target === factKey); +} + +function renderGraphEdgesListHtml(factKey, graphData, selectedEdgeId) { + const edges = getGraphEdgesForFact(factKey, graphData); + if (!edges.length) { + return `

${escapeHtml(tp('projects.graphEdgesEmpty'))}

`; + } + return edges + .map((e) => { + const isOut = e.source === factKey; + const dirLabel = isOut ? tp('projects.graphEdgeOutgoing') : tp('projects.graphEdgeIncoming'); + const other = isOut ? e.target : e.source; + const arrow = isOut ? '→' : '←'; + const selected = e.id === selectedEdgeId ? ' is-selected' : ''; + const synthetic = isSyntheticGraphEdge(e); + const deleteBtn = synthetic + ? `` + : ``; + return `
+ ${escapeHtml(dirLabel)} + ${escapeHtml(e.type || '')} + ${arrow} + ${escapeHtml(other)} + ${deleteBtn} +
`; + }) + .join(''); +} + +function renderProjectFactGraphEdges(factKey, graphData, selectedEdgeId) { + const wrap = document.getElementById('project-fact-graph-edges-wrap'); + const list = document.getElementById('project-fact-graph-edges-list'); + if (!wrap || !list) return; + const edges = getGraphEdgesForFact(factKey, graphData); + wrap.hidden = false; + list.innerHTML = renderGraphEdgesListHtml(factKey, graphData, selectedEdgeId); + if (selectedEdgeId) { + const selectedEl = list.querySelector('[data-edge-id="' + String(selectedEdgeId).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"]'); + if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' }); + } + if (!edges.length) wrap.hidden = false; +} + +function showProjectFactGraphNode(factKey, graphData, selectedEdgeId) { + if (!factKey || String(factKey).startsWith('vuln:')) { + closeProjectFactGraphSidebar(); + return; + } + _selectedGraphFactKey = factKey; + _selectedGraphEdgeId = selectedEdgeId || null; + const node = (graphData?.nodes || []).find((n) => n.fact_key === factKey || n.id === factKey); + 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'); + if (!sidebar || !titleEl || !metaEl) return; + titleEl.textContent = factKey; + if (categoryEl) { + const cat = node?.category || node?.type || ''; + categoryEl.textContent = cat; + categoryEl.hidden = !cat; + categoryEl.className = 'project-fact-graph-node-category project-fact-graph-node-category--' + (cat || 'note'); + } + const conf = node?.confidence || ''; + const label = node?.label || ''; + if (label || conf) { + const parts = []; + if (label) { + parts.push(`${escapeHtml(label)}`); + } + if (conf) { + parts.push(formatConfidenceBadge(conf)); + } + metaEl.innerHTML = parts.join(''); + } else { + metaEl.textContent = ''; + } + renderProjectFactGraphEdges(factKey, graphData, _selectedGraphEdgeId); + if (_selectedGraphEdgeId && typeof ProjectFactGraph !== 'undefined') { + ProjectFactGraph.selectEdge(_selectedGraphEdgeId); + } else if (typeof ProjectFactGraph !== 'undefined') { + ProjectFactGraph.clearEdgeSelection(); + } + sidebar.hidden = false; +} + +function showProjectFactGraphEdge(edgeId, graphData) { + const edge = (graphData?.edges || []).find((e) => e.id === edgeId); + if (!edge) return; + const anchorKey = edge.source && !String(edge.source).startsWith('vuln:') ? edge.source : edge.target; + showProjectFactGraphNode(anchorKey, graphData, edgeId); +} + +function focusProjectFactGraphEdge(edgeId) { + if (!edgeId || !_currentGraphData) return; + showProjectFactGraphEdge(edgeId, _currentGraphData); +} + +async function deleteProjectFactEdge(edgeId) { + if (!edgeId || !currentProjectId) return; + const edge = (_currentGraphData?.edges || []).find((e) => e.id === edgeId); + if (isSyntheticGraphEdge(edge)) return; + if (!confirm(tp('projects.confirmDeleteGraphEdge'))) return; + const res = await apiFetch(`/api/projects/${currentProjectId}/fact-edges/${encodeURIComponent(edgeId)}`, { + method: 'DELETE', + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return alert(err.error || tp('projects.graphEdgeDeleteFailed')); + } + if (typeof showNotification === 'function') showNotification(tp('projects.graphEdgeDeleteSuccess'), 'success'); + const keepKey = _selectedGraphFactKey; + await loadProjectFactGraph(); + if (keepKey) showProjectFactGraphNode(keepKey, _currentGraphData); + loadProjectFacts(); +} + +function openSelectedGraphFactDetail() { + if (_selectedGraphFactKey) viewProjectFactBody(_selectedGraphFactKey); +} + +function editSelectedGraphFact() { + if (_selectedGraphFactKey) showEditFactModal(_selectedGraphFactKey); +} + function buildProjectFactsQueryParams() { const params = new URLSearchParams(); params.set('limit', '200'); + params.set('include_link_counts', 'true'); const search = document.getElementById('project-facts-search')?.value?.trim(); const category = document.getElementById('project-facts-filter-category')?.value?.trim(); const confidence = document.getElementById('project-facts-filter-confidence')?.value?.trim(); @@ -768,11 +1030,11 @@ function debouncedLoadProjectFacts() { async function loadProjectFacts() { const tbody = document.getElementById('project-facts-tbody'); if (!tbody || !currentProjectId) return; - tbody.innerHTML = `${escapeHtml(tp('common.loading'))}`; + tbody.innerHTML = `${escapeHtml(tp('common.loading'))}`; const qs = buildProjectFactsQueryParams().toString(); const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${qs}`); if (!res.ok) { - tbody.innerHTML = `${escapeHtml(tp('common.loadFailed'))}`; + tbody.innerHTML = `${escapeHtml(tp('common.loadFailed'))}`; return; } const facts = await res.json(); @@ -782,7 +1044,7 @@ async function loadProjectFacts() { document.getElementById('project-facts-filter-category')?.value || document.getElementById('project-facts-filter-confidence')?.value || document.getElementById('project-facts-filter-sparse')?.checked; - tbody.innerHTML = `${ + tbody.innerHTML = `${ hasFilter ? tp('projects.noMatchingFacts') : tp('projects.noFacts') }`; refreshProjectHeaderStats(); @@ -797,10 +1059,16 @@ async function loadProjectFacts() { const pinBadge = f.pinned ? `${escapeHtml(tp('projects.pinned'))}` : ''; + const lc = f.link_counts || {}; + const linkBadge = + lc.outgoing || lc.incoming + ? `↑${lc.outgoing || 0} ↓${lc.incoming || 0}` + : ''; return ` ${keyEsc}${pinBadge}${vulnLink} ${formatCategoryBadge(f.category)} ${escapeHtml(f.summary)} + ${linkBadge} ${formatFactBodyBadge(f)} ${formatConfidenceBadge(f.confidence)} ${formatProjectTime(f.updated_at, f.created_at)} @@ -849,6 +1117,7 @@ async function loadProjectConversations() {
+
@@ -869,6 +1138,32 @@ function openProjectConversation(conversationId) { }, 200); } +async function promoteConversationAttackChain(conversationId) { + if (!currentProjectId || !conversationId) return; + if (!confirm(tp('projects.confirmPromoteAttackChain'))) return; + const res = await apiFetch( + `/api/projects/${currentProjectId}/promote-attack-chain/${encodeURIComponent(conversationId)}`, + { method: 'POST' }, + ); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return alert(err.error || tp('projects.promoteAttackChainFailed')); + } + const data = await res.json(); + if (typeof showNotification === 'function') { + showNotification( + tpFmt( + 'projects.promoteAttackChainSuccess', + `已沉淀 ${data.facts_created || 0} 新 / ${data.facts_updated || 0} 更新 / ${data.edges_created || 0} 边`, + data, + ), + 'success', + ); + } + loadProjectFacts(); + if (currentProjectTab === 'graph') loadProjectFactGraph(); +} + async function unbindConversationFromProject(conversationId) { if (!conversationId || !confirm(tp('projects.confirmUnbindConversation'))) return; const res = await apiFetch(`/api/conversations/${encodeURIComponent(conversationId)}/project`, { @@ -1509,6 +1804,8 @@ function resetFactModalForm() { if (pinEl) pinEl.checked = false; const rel = document.getElementById('fact-modal-related-vuln'); if (rel) rel.value = ''; + const linksEl = document.getElementById('fact-modal-links'); + if (linksEl) linksEl.value = ''; updateFactFormHints(); } @@ -1540,6 +1837,8 @@ function fillFactModalForm(f) { } const rel = document.getElementById('fact-modal-related-vuln'); if (rel) rel.value = f.related_vulnerability_id || ''; + const linksEl = document.getElementById('fact-modal-links'); + if (linksEl) linksEl.value = formatOutgoingLinksForModal(f.outgoing_links); const pinEl = document.getElementById('fact-modal-pinned'); if (pinEl) pinEl.checked = !!f.pinned; updateFactFormHints(); @@ -1556,7 +1855,7 @@ async function showEditFactModal(factKey) { resetFactModalForm(); openProjectsOverlay('fact-modal', { focus: false }); const res = await apiFetch( - `/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`, + `/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}&include_links=true`, ); if (!res.ok) { closeFactModal(); @@ -1594,6 +1893,7 @@ async function saveFactModal() { confidence: document.getElementById('fact-modal-confidence').value, pinned: !!document.getElementById('fact-modal-pinned')?.checked, related_vulnerability_id: document.getElementById('fact-modal-related-vuln')?.value?.trim() || '', + links_text: document.getElementById('fact-modal-links')?.value || '', }; const editId = window._factModalEditId; const res = editId @@ -1613,12 +1913,14 @@ async function saveFactModal() { } closeFactModal(); loadProjectFacts(); + if (currentProjectTab === 'graph') loadProjectFactGraph(); } async function deleteProjectFact(id) { if (!confirm(tp('projects.confirmDeleteFact'))) return; await apiFetch(`/api/projects/${currentProjectId}/facts/${id}`, { method: 'DELETE' }); loadProjectFacts(); + if (currentProjectTab === 'graph') loadProjectFactGraph(); } function parseProjectDate(t) { @@ -1974,5 +2276,15 @@ window.viewFactsForVulnerability = viewFactsForVulnerability; window.openProjectConversation = openProjectConversation; window.unbindConversationFromProject = unbindConversationFromProject; window.loadProjectConversations = loadProjectConversations; +window.loadProjectFactGraph = loadProjectFactGraph; +window.filterProjectFactGraph = filterProjectFactGraph; +window.centerProjectFactGraph = centerProjectFactGraph; +window.closeProjectFactGraphSidebar = closeProjectFactGraphSidebar; +window.openSelectedGraphFactDetail = openSelectedGraphFactDetail; +window.editSelectedGraphFact = editSelectedGraphFact; +window.promoteConversationAttackChain = promoteConversationAttackChain; +window.deleteProjectFactEdge = deleteProjectFactEdge; +window.focusProjectFactGraphEdge = focusProjectFactGraphEdge; +window.toggleProjectFactGraphConnectMode = toggleProjectFactGraphConnectMode; window.rebuildProjectNameMap = rebuildProjectNameMap; window.projectNameById = projectNameById; diff --git a/web/templates/index.html b/web/templates/index.html index ae307b47..2f2d1c40 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1498,6 +1498,13 @@
+

选择或创建项目

项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。

@@ -1527,6 +1534,7 @@
- +
Key分类摘要Body置信度更新操作
Key分类摘要关系Body置信度更新操作
+