mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-20 13:00:10 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46a7d338a4 |
+518
-21
@@ -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;
|
||||
}
|
||||
}
|
||||
/* —— 项目设置:左右分栏 + 底部危险区,无内层滚动 —— */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "分类",
|
||||
|
||||
@@ -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, '"')
|
||||
.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 (
|
||||
`<g transform="translate(${x}, ${y}) scale(${scale})">` +
|
||||
`<circle cx="12" cy="12" r="6" fill="none" stroke="${color}" stroke-width="2"/>` +
|
||||
`<circle cx="12" cy="12" r="2.5" fill="${color}"/></g>`
|
||||
);
|
||||
}
|
||||
if (kind === 'vulnerability') {
|
||||
return (
|
||||
`<g transform="translate(${x}, ${y}) scale(${scale})">` +
|
||||
`<path d="M12 3l9 16H3z" fill="none" stroke="${color}" stroke-width="2"/>` +
|
||||
`<line x1="12" y1="9" x2="12" y2="13" stroke="${color}" stroke-width="2"/>` +
|
||||
`<circle cx="12" cy="16" r="1" fill="${color}"/></g>`
|
||||
);
|
||||
}
|
||||
return (
|
||||
`<g transform="translate(${x}, ${y}) scale(${scale})">` +
|
||||
`<circle cx="12" cy="12" r="5" fill="${color}" opacity="0.85"/></g>`
|
||||
);
|
||||
}
|
||||
|
||||
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) =>
|
||||
`<text x="${CARD_TEXT_X}" y="${headerY + i * CARD_HEADER_LH}" font-size="${CARD_HEADER_FS}" font-weight="700" fill="${accent}" fill-opacity="0.88" font-family='${CARD_FONT}'>${escapeXml(line)}</text>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const summarySvg = summaryLines
|
||||
.map(
|
||||
(line, i) =>
|
||||
`<text x="${CARD_TEXT_X}" y="${summaryY + i * CARD_SUMMARY_LH}" font-size="${CARD_SUMMARY_FS}" font-weight="600" fill="#0f172a" font-family='${CARD_FONT}'>${escapeXml(line)}</text>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const textClipW = width - CARD_TEXT_X - CARD_PAD - 2;
|
||||
const textClipH = height - CARD_PAD * 2 + 4;
|
||||
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
|
||||
`<defs><linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">` +
|
||||
`<stop offset="0%" stop-color="#FFFFFF"/><stop offset="100%" stop-color="${bgEnd}"/></linearGradient>` +
|
||||
`<clipPath id="textClip"><rect x="${CARD_TEXT_X}" y="${CARD_PAD - 2}" width="${textClipW}" height="${textClipH}"/></clipPath></defs>` +
|
||||
`<g${isDeprecated ? ' opacity="0.55"' : ''}>` +
|
||||
`<rect x="0.75" y="0.75" width="${width - 1.5}" height="${height - 1.5}" rx="12" fill="url(#bg)" ${stroke}/>` +
|
||||
svgIconGroup(theme.icon, accent, iconX, iconY) +
|
||||
`<g clip-path="url(#textClip)">${headerSvg}${summarySvg}</g>` +
|
||||
`</g></svg>`;
|
||||
|
||||
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 = '<div class="error-message">Cytoscape 未加载</div>';
|
||||
}
|
||||
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
|
||||
? '<ol class="project-fact-graph-empty-steps">' +
|
||||
steps.map((s) => '<li>' + escapeHtml(String(s)) + '</li>').join('') +
|
||||
'</ol>'
|
||||
: '';
|
||||
const actionHtml =
|
||||
actionLabel && options.onEmptyAction
|
||||
? '<button type="button" class="btn-primary btn-small project-fact-graph-empty-cta">' +
|
||||
escapeHtml(actionLabel) +
|
||||
'</button>'
|
||||
: '';
|
||||
container.innerHTML =
|
||||
'<div class="project-fact-graph-empty">' +
|
||||
'<div class="project-fact-graph-empty-icon" aria-hidden="true">' +
|
||||
'<svg width="48" height="48" viewBox="0 0 24 24" fill="none"><circle cx="6" cy="6" r="2.5" fill="#4F46E5" opacity="0.9"/><circle cx="18" cy="6" r="2.5" fill="#E11D48" opacity="0.9"/><circle cx="12" cy="18" r="2.5" fill="#0D9488" opacity="0.9"/>' +
|
||||
'<path d="M8 7l4 9M16 7l-4 9M8 7h8" stroke="#CBD5E1" stroke-width="1.5" stroke-linecap="round"/></svg>' +
|
||||
'</div>' +
|
||||
(title ? '<h4 class="project-fact-graph-empty-title">' + escapeHtml(title) + '</h4>' : '') +
|
||||
'<p class="project-fact-graph-empty-hint">' + escapeHtml(hint) + '</p>' +
|
||||
stepsHtml +
|
||||
actionHtml +
|
||||
'</div>';
|
||||
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);
|
||||
+317
-5
@@ -64,6 +64,8 @@ Host: ...
|
||||
## 关联
|
||||
- related_vulnerability_id: <可选>
|
||||
- 依赖事实: <fact_key,如 auth/session_cookie>
|
||||
- 结构化出边(自动同步):
|
||||
- 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 = `<div class="loading-spinner">${escapeHtml(tp('common.loading'))}</div>`;
|
||||
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 =
|
||||
`<span class="projects-graph-stat-badge"><strong>${nodeCount}</strong> ${escapeHtml(tp('projects.graphStatsNodes'))}</span>` +
|
||||
`<span class="projects-graph-stat-badge"><strong>${edgeCount}</strong> ${escapeHtml(tp('projects.graphStatsEdges'))}</span>`;
|
||||
}
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="error-message">${escapeHtml(e.message || tp('common.loadFailed'))}</div>`;
|
||||
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 `<p class="project-fact-graph-edges-empty">${escapeHtml(tp('projects.graphEdgesEmpty'))}</p>`;
|
||||
}
|
||||
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
|
||||
? `<span class="project-fact-graph-edge-synthetic" title="${escapeHtml(tp('projects.graphEdgeSynthetic'))}">—</span>`
|
||||
: `<button type="button" class="project-fact-graph-edge-delete" data-edge-id="${escapeHtml(e.id)}" onclick="event.stopPropagation(); deleteProjectFactEdge(this.dataset.edgeId)" title="${escapeHtml(tp('projects.graphDeleteEdge'))}">×</button>`;
|
||||
return `<div class="project-fact-graph-edge-item${selected}" data-edge-id="${escapeHtml(e.id)}" onclick="focusProjectFactGraphEdge(${JSON.stringify(e.id)})">
|
||||
<span class="project-fact-graph-edge-dir">${escapeHtml(dirLabel)}</span>
|
||||
<span class="project-fact-graph-edge-type">${escapeHtml(e.type || '')}</span>
|
||||
<span class="project-fact-graph-edge-arrow">${arrow}</span>
|
||||
<span class="project-fact-graph-edge-peer" title="${escapeHtml(other)}">${escapeHtml(other)}</span>
|
||||
${deleteBtn}
|
||||
</div>`;
|
||||
})
|
||||
.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(`<span class="project-fact-graph-node-summary">${escapeHtml(label)}</span>`);
|
||||
}
|
||||
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 = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tp('common.loading'))}</td></tr>`;
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="8">${escapeHtml(tp('common.loading'))}</td></tr>`;
|
||||
const qs = buildProjectFactsQueryParams().toString();
|
||||
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${qs}`);
|
||||
if (!res.ok) {
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tp('common.loadFailed'))}</td></tr>`;
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="8">${escapeHtml(tp('common.loadFailed'))}</td></tr>`;
|
||||
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 = `<tr class="is-empty-row"><td colspan="7">${
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="8">${
|
||||
hasFilter ? tp('projects.noMatchingFacts') : tp('projects.noFacts')
|
||||
}</td></tr>`;
|
||||
refreshProjectHeaderStats();
|
||||
@@ -797,10 +1059,16 @@ async function loadProjectFacts() {
|
||||
const pinBadge = f.pinned
|
||||
? `<span class="projects-list-item-badge" title="${escapeHtml(tp('projects.pinned'))}">${escapeHtml(tp('projects.pinned'))}</span>`
|
||||
: '';
|
||||
const lc = f.link_counts || {};
|
||||
const linkBadge =
|
||||
lc.outgoing || lc.incoming
|
||||
? `<span class="projects-fact-link-badge" title="${escapeHtml(tp('projects.linkCountsTitle'))}">↑${lc.outgoing || 0} ↓${lc.incoming || 0}</span>`
|
||||
: '<span class="projects-fact-link-badge projects-fact-link-badge--empty">—</span>';
|
||||
return `<tr>
|
||||
<td class="cell-fact-key"><code class="projects-fact-key-chip" title="${keyEsc}">${keyEsc}</code>${pinBadge}${vulnLink}</td>
|
||||
<td class="cell-fact-category">${formatCategoryBadge(f.category)}</td>
|
||||
<td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td>
|
||||
<td class="cell-fact-links">${linkBadge}</td>
|
||||
<td>${formatFactBodyBadge(f)}</td>
|
||||
<td>${formatConfidenceBadge(f.confidence)}</td>
|
||||
<td>${formatProjectTime(f.updated_at, f.created_at)}</td>
|
||||
@@ -849,6 +1117,7 @@ async function loadProjectConversations() {
|
||||
<td class="col-actions">
|
||||
<div class="projects-table-actions">
|
||||
<button type="button" class="projects-action-btn projects-action-btn--view" data-conv-id="${idEsc}" onclick="openProjectConversation(this.dataset.convId)">${escapeHtml(tp('projects.open'))}</button>
|
||||
<button type="button" class="projects-action-btn" data-conv-id="${idEsc}" onclick="promoteConversationAttackChain(this.dataset.convId)" title="${escapeHtml(tp('projects.promoteAttackChainTitle'))}">${escapeHtml(tp('projects.promoteAttackChain'))}</button>
|
||||
<button type="button" class="projects-action-btn projects-action-btn--mute" data-conv-id="${idEsc}" onclick="unbindConversationFromProject(this.dataset.convId)" title="${escapeHtml(tp('projects.unbindProjectTitle'))}">${escapeHtml(tp('projects.unbind'))}</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -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;
|
||||
|
||||
@@ -1498,6 +1498,13 @@
|
||||
</aside>
|
||||
<main class="projects-detail" id="projects-detail-main">
|
||||
<div class="projects-detail-placeholder" id="projects-detail-placeholder">
|
||||
<div class="projects-placeholder-icon" aria-hidden="true">
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="4" width="18" height="16" rx="3" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M3 9h18M8 4V9M16 4V9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M8 14h8M8 17h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 data-i18n="projects.selectOrCreateTitle">选择或创建项目</h3>
|
||||
<p data-i18n="projects.selectOrCreateHint">项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。</p>
|
||||
<button class="btn-primary" type="button" onclick="showNewProjectModal()" data-i18n="projects.createFirstProject">创建第一个项目</button>
|
||||
@@ -1527,6 +1534,7 @@
|
||||
</header>
|
||||
<nav class="projects-tabs" role="tablist">
|
||||
<button type="button" id="project-tab-facts" class="projects-tab is-active" role="tab" onclick="switchProjectTab('facts')" data-i18n="projects.tabFacts">事实黑板</button>
|
||||
<button type="button" id="project-tab-graph" class="projects-tab" role="tab" onclick="switchProjectTab('graph')" data-i18n="projects.tabGraph">攻击路径</button>
|
||||
<button type="button" id="project-tab-conversations" class="projects-tab" role="tab" onclick="switchProjectTab('conversations')" data-i18n="projects.tabConversations">关联对话</button>
|
||||
<button type="button" id="project-tab-vulns" class="projects-tab" role="tab" onclick="switchProjectTab('vulns')" data-i18n="projects.tabVulns">关联漏洞</button>
|
||||
<button type="button" id="project-tab-settings" class="projects-tab" role="tab" onclick="switchProjectTab('settings')" data-i18n="projects.tabSettings">设置</button>
|
||||
@@ -1587,11 +1595,81 @@
|
||||
</div>
|
||||
<div class="projects-table-wrap">
|
||||
<table class="data-table data-table--projects">
|
||||
<thead><tr><th>Key</th><th data-i18n="projects.category">分类</th><th data-i18n="projects.summary">摘要</th><th>Body</th><th data-i18n="projects.confidence">置信度</th><th data-i18n="projects.updated">更新</th><th class="col-actions" data-i18n="common.actions">操作</th></tr></thead>
|
||||
<thead><tr><th>Key</th><th data-i18n="projects.category">分类</th><th data-i18n="projects.summary">摘要</th><th data-i18n="projects.linksColumn">关系</th><th>Body</th><th data-i18n="projects.confidence">置信度</th><th data-i18n="projects.updated">更新</th><th class="col-actions" data-i18n="common.actions">操作</th></tr></thead>
|
||||
<tbody id="project-facts-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div id="project-panel-graph" class="projects-panel projects-panel--graph" role="tabpanel" hidden>
|
||||
<div class="projects-fact-toolbar projects-graph-toolbar">
|
||||
<p class="projects-fact-toolbar-hint" role="note">
|
||||
<svg class="projects-fact-toolbar-hint-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M12 10v6M12 8h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span data-i18n="projects.graphToolbarHint">攻击路径图:展示 target → finding → exploit 因果关系;虚线边表示待确认。点击节点查看详情。</span>
|
||||
</p>
|
||||
<div class="projects-fact-toolbar-filters projects-graph-toolbar-row">
|
||||
<label class="projects-fact-filter-field">
|
||||
<span class="projects-fact-filter-label" data-i18n="projects.graphView">视图</span>
|
||||
<select id="project-graph-view" onchange="loadProjectFactGraph()">
|
||||
<option value="path" data-i18n="projects.graphViewPath">攻击路径</option>
|
||||
<option value="full" data-i18n="projects.graphViewFull">完整关系</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="projects-fact-filter-field projects-fact-filter-field--search projects-graph-search-field">
|
||||
<span class="sr-only" data-i18n="projects.graphSearchSr">搜索节点</span>
|
||||
<svg class="projects-fact-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M20 20L16 16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<input type="search" id="project-graph-search" placeholder="搜索节点…" oninput="filterProjectFactGraph()" autocomplete="off" data-i18n="projects.graphSearchPlaceholder" data-i18n-attr="placeholder">
|
||||
</label>
|
||||
<div class="projects-graph-actions" role="group" aria-label="Graph actions">
|
||||
<button type="button" class="projects-graph-action-btn" onclick="loadProjectFactGraph()" title="刷新" data-i18n="projects.graphRefresh" data-i18n-attr="title">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M21 3v6h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span data-i18n="projects.graphRefresh">刷新</span>
|
||||
</button>
|
||||
<button type="button" class="projects-graph-action-btn" onclick="centerProjectFactGraph()" title="居中" data-i18n="projects.graphCenter" data-i18n-attr="title">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<span data-i18n="projects.graphCenter">居中</span>
|
||||
</button>
|
||||
<button type="button" class="projects-graph-action-btn projects-graph-action-btn--connect" id="project-graph-connect-btn" onclick="toggleProjectFactGraphConnectMode()" data-i18n="projects.graphConnect">连边</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="projects-graph-legend" aria-hidden="true">
|
||||
<span class="projects-graph-legend-item"><i style="--legend-color:#4F46E5"></i><span data-i18n="projects.graphLegendDiscovered">discovered_on</span></span>
|
||||
<span class="projects-graph-legend-item"><i style="--legend-color:#64748B"></i><span data-i18n="projects.graphLegendLeads">leads_to</span></span>
|
||||
<span class="projects-graph-legend-item"><i style="--legend-color:#DC2626"></i><span data-i18n="projects.graphLegendExploits">exploits</span></span>
|
||||
<span class="projects-graph-legend-item projects-graph-legend-item--dashed"><i style="--legend-color:#94A3B8"></i><span data-i18n="projects.graphLegendTentative">待确认</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-fact-graph-layout">
|
||||
<div id="project-fact-graph-container" class="project-fact-graph-container"></div>
|
||||
<aside id="project-fact-graph-sidebar" class="project-fact-graph-sidebar" hidden>
|
||||
<div class="project-fact-graph-sidebar-header">
|
||||
<div class="project-fact-graph-sidebar-title-wrap">
|
||||
<span id="project-fact-graph-node-category" class="project-fact-graph-node-category"></span>
|
||||
<h4 id="project-fact-graph-node-title">—</h4>
|
||||
</div>
|
||||
<button type="button" class="project-fact-graph-sidebar-close" onclick="closeProjectFactGraphSidebar()" aria-label="关闭" data-i18n="common.close" data-i18n-attr="aria-label">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p id="project-fact-graph-node-meta" class="project-fact-graph-node-meta"></p>
|
||||
<div id="project-fact-graph-edges-wrap" class="project-fact-graph-edges-wrap" hidden>
|
||||
<h5 class="project-fact-graph-edges-title" data-i18n="projects.graphEdgesTitle">关系边</h5>
|
||||
<p class="project-fact-graph-edges-hint" data-i18n="projects.graphEdgesHint">点击图中连线可定位;误连可在此删除。</p>
|
||||
<div id="project-fact-graph-edges-list" class="project-fact-graph-edges-list"></div>
|
||||
</div>
|
||||
<div class="project-fact-graph-sidebar-actions">
|
||||
<button type="button" class="btn-primary btn-small" id="project-fact-graph-detail-btn" onclick="openSelectedGraphFactDetail()" data-i18n="projects.details">详情</button>
|
||||
<button type="button" class="btn-secondary btn-small" id="project-fact-graph-edit-btn" onclick="editSelectedGraphFact()" data-i18n="common.edit">编辑</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<div id="project-fact-graph-stats" class="project-fact-graph-stats"></div>
|
||||
</div>
|
||||
<div id="project-panel-conversations" class="projects-panel" role="tabpanel" hidden>
|
||||
<div class="projects-panel-toolbar projects-panel-toolbar--hint">
|
||||
<p class="projects-fact-toolbar-hint" role="note">
|
||||
@@ -4339,6 +4417,11 @@
|
||||
<label for="fact-modal-related-vuln" data-i18n="projects.relatedVulnIdLabel">关联漏洞 ID</label>
|
||||
<input type="text" id="fact-modal-related-vuln" class="form-input" placeholder="可选" data-i18n="projects.optional" data-i18n-attr="placeholder">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fact-modal-links" data-i18n="projects.factLinksLabel">关系边(出边)</label>
|
||||
<textarea id="fact-modal-links" class="form-input" rows="4" placeholder="discovered_on: target/primary_domain leads_to: finding/swagger" data-i18n="projects.factLinksPlaceholder" data-i18n-attr="placeholder"></textarea>
|
||||
<p class="projects-field-hint" data-i18n="projects.factLinksHint">每行一条:type: fact_key。常用 type:discovered_on、depends_on、leads_to、enables、exploits。保存时替换全部出边。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="projects-modal-footer">
|
||||
<button class="btn-secondary" type="button" onclick="closeFactModal()" data-i18n="common.cancel">取消</button>
|
||||
@@ -4396,6 +4479,7 @@
|
||||
<script src="/static/js/terminal.js"></script>
|
||||
<script src="/static/js/knowledge.js"></script>
|
||||
<script src="/static/js/skills.js"></script>
|
||||
<script src="/static/js/fact-graph.js"></script>
|
||||
<script src="/static/js/projects.js"></script>
|
||||
<script src="/static/js/vulnerability.js?v=12"></script>
|
||||
<script src="/static/js/webshell.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user