From 8e42e72e421f44ab1834acb645ef7996b1d70f15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:30:29 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 453 ++++++++++++++ web/static/i18n/en-US.json | 178 ++++++ web/static/i18n/zh-CN.json | 188 +++++- web/static/js/assets.js | 1057 +++++++++++++++++++++++++++++++++ web/static/js/auth.js | 3 + web/static/js/chat.js | 58 +- web/static/js/i18n.js | 7 +- web/static/js/info-collect.js | 5 +- web/static/js/projects.js | 50 +- web/static/js/rbac-guards.js | 4 + web/static/js/rbac.js | 2 +- web/static/js/router.js | 20 +- web/templates/index.html | 325 ++++++++-- 13 files changed, 2283 insertions(+), 67 deletions(-) create mode 100644 web/static/js/assets.js diff --git a/web/static/css/style.css b/web/static/css/style.css index 338b3ebb..cbd91d3a 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -16,6 +16,8 @@ --bg-primary: #ffffff; --bg-secondary: #f8f9fa; --bg-tertiary: #f1f3f5; + --card-bg: var(--bg-primary); + --input-bg: var(--bg-primary); --text-primary: #1a1a1a; --text-secondary: #6c757d; --text-muted: #adb5bd; @@ -48,6 +50,8 @@ html[data-theme="dark"] { --bg-primary: #111827; --bg-secondary: #0b1120; --bg-tertiary: #1f2937; + --card-bg: #111827; + --input-bg: #0b1120; --text-primary: #e5e7eb; --text-secondary: #a7b0c0; --text-muted: #6b7280; @@ -221,6 +225,36 @@ html[data-theme="dark"] .main-sidebar { background: rgba(0, 0, 0, 0.3); } +.nav-section-label { + padding: 16px 19px 6px; + color: var(--text-secondary); + font-size: 0.6875rem; + font-weight: 600; + line-height: 1; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; + opacity: 0.72; + user-select: none; +} + +.nav-section-label:first-child { + padding-top: 2px; +} + +.main-sidebar.collapsed .nav-section-label { + height: 1px; + margin: 8px 12px; + padding: 0; + overflow: hidden; + background: var(--border-color); + opacity: 0.7; +} + +.main-sidebar.collapsed .nav-section-label span { + display: none; +} + .nav-item { margin-bottom: 0; } @@ -31348,6 +31382,7 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error { background: #fff; } #project-panel-facts, +#project-panel-assets, #project-panel-conversations, #project-panel-vulns { display: flex; @@ -31357,10 +31392,12 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error { } #project-panel-facts .projects-fact-toolbar, #project-panel-vulns .projects-fact-toolbar, +#project-panel-assets .projects-panel-toolbar, #project-panel-conversations .projects-panel-toolbar { flex: 0 0 auto; } #project-panel-facts .projects-table-wrap, +#project-panel-assets .projects-table-wrap, #project-panel-conversations .projects-table-wrap, #project-panel-vulns .projects-table-wrap { flex: 1 1 auto; @@ -31370,6 +31407,7 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error { -webkit-overflow-scrolling: touch; } #project-panel-conversations .projects-table-wrap, +#project-panel-assets .projects-table-wrap, #project-panel-vulns .projects-table-wrap { overflow-x: hidden; } @@ -31377,6 +31415,7 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error { overflow-x: auto; } #project-panel-facts .projects-table-wrap .data-table--projects thead th, +#project-panel-assets .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 { position: sticky; @@ -31394,6 +31433,16 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error { .projects-panel-toolbar--hint .projects-fact-toolbar-hint { margin: 0; } +.projects-panel-count { flex-shrink: 0; padding: 4px 9px; border-radius: 999px; color: #2563eb; background: #eff6ff; font-size: .75rem; font-weight: 600; } +.projects-asset-target { display: block; max-width: 100%; padding: 0; border: 0; background: transparent; color: #2563eb; font: inherit; font-weight: 600; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.projects-asset-target:hover { text-decoration: underline; } +#project-panel-assets .data-table--projects th:nth-child(1), #project-panel-assets .data-table--projects td:nth-child(1) { width: 24%; } +#project-panel-assets .data-table--projects th:nth-child(2), #project-panel-assets .data-table--projects td:nth-child(2) { width: 12%; } +#project-panel-assets .data-table--projects th:nth-child(3), #project-panel-assets .data-table--projects td:nth-child(3) { width: 22%; } +#project-panel-assets .data-table--projects th:nth-child(4), #project-panel-assets .data-table--projects td:nth-child(4) { width: 10%; } +#project-panel-assets .data-table--projects th:nth-child(5), #project-panel-assets .data-table--projects td:nth-child(5) { width: 16%; } +#project-panel-assets .data-table--projects th:nth-child(6), #project-panel-assets .data-table--projects td:nth-child(6) { width: 8%; } +#project-panel-assets .data-table--projects th:nth-child(7), #project-panel-assets .data-table--projects td:nth-child(7) { width: 8%; } #project-panel-facts .data-table--projects .cell-fact-key { overflow: hidden; max-width: 0; @@ -39899,3 +39948,407 @@ html[data-theme="dark"] .workflow-toolbar #workflow-connect-btn[aria-pressed="tr grid-template-columns: minmax(0, 1fr); } } +/* Asset management */ +.asset-page-subtitle { margin: 5px 0 0; color: var(--text-secondary, #6b7280); font-size: 13px; font-weight: 400; } +.asset-content { display: flex; flex-direction: column; gap: 20px; } +#page-asset-library .asset-content { overflow: hidden; } +#page-asset-library .asset-toolbar { flex: 0 0 auto; } +.asset-overview-summary { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(270px, .75fr); gap: 16px; } +.asset-total-card,.asset-recent-card,.asset-panel { border: 1px solid var(--border-color, #e5e7eb); border-radius: 16px; background: var(--card-bg, #fff); box-shadow: 0 8px 24px rgba(15,23,42,.035); } +.asset-total-card { min-width: 0; padding: 22px 24px 20px; } +.asset-total-card__heading { display: flex; align-items: center; gap: 12px; } +.asset-total-card__heading p { margin: 4px 0 0; color: var(--text-secondary, #6b7280); font-size: 12px; } +.asset-overview-icon { display: grid; width: 40px; height: 40px; flex: 0 0 40px; place-items: center; border-radius: 11px; } +.asset-overview-icon svg { width: 21px; height: 21px; } +.asset-overview-icon--blue { background: rgba(37,99,235,.11); color: #3b82f6; } +.asset-overview-icon--violet { background: rgba(99,102,241,.11); color: #6366f1; } +.asset-eyebrow { color: var(--text-primary, #111827); font-size: 14px; font-weight: 650; } +.asset-total-value { display: block; margin: 24px 0 22px; color: var(--text-primary, #111827); font-size: clamp(42px, 4vw, 58px); font-weight: 720; letter-spacing: -.045em; line-height: .9; } +.asset-type-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); overflow: hidden; border: 1px solid var(--border-color, #e5e7eb); border-radius: 11px; background: var(--bg-secondary, #f8fafc); } +.asset-type-item { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 8px; min-width: 0; padding: 13px 14px; } +.asset-type-item + .asset-type-item { border-left: 1px solid var(--border-color, #e5e7eb); } +.asset-type-item > span:not(.asset-type-dot) { overflow: hidden; color: var(--text-secondary, #64748b); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.asset-type-item strong { color: var(--text-primary, #111827); font-size: 16px; } +.asset-type-dot { width: 7px; height: 7px; border-radius: 50%; background: #3b82f6; box-shadow: 0 0 0 4px rgba(59,130,246,.10); } +.asset-type-item--domain .asset-type-dot { background: #8b5cf6; box-shadow: 0 0 0 4px rgba(139,92,246,.10); } +.asset-type-item--port .asset-type-dot { background: #14b8a6; box-shadow: 0 0 0 4px rgba(20,184,166,.10); } +.asset-recent-card { display: flex; min-width: 0; flex-direction: column; overflow: hidden; padding: 22px; color: var(--text-primary,#111827); } +.asset-recent-card__top { display: flex; align-items: center; justify-content: space-between; } +.asset-recent-badge { padding: 5px 8px; border: 1px solid var(--border-color,#e5e7eb); border-radius: 999px; background: var(--bg-secondary,#f8fafc); color: var(--text-secondary,#64748b); font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.asset-recent-card__body { display: flex; margin-top: auto; flex-direction: column; gap: 7px; padding: 25px 0 18px; } +.asset-recent-card__body span { color: var(--text-secondary,#64748b); font-size: 13px; } +.asset-recent-card__body strong { font-size: clamp(38px,3.5vw,52px); font-weight: 720; letter-spacing: -.04em; line-height: 1; } +.asset-recent-progress { height: 5px; overflow: hidden; border-radius: 999px; background: var(--bg-secondary,#eef2f7); } +.asset-recent-progress i { display: block; width: 0; height: 100%; border-radius: inherit; background: linear-gradient(90deg,#3b82f6,#8b5cf6); transition: width .35s ease; } +.asset-recent-caption { margin: 9px 0 0; color: var(--text-secondary,#64748b); font-size: 11px; } +.asset-insight-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 4px 2px 0; } +.asset-insight-heading h3 { margin: 0; color: var(--text-primary,#111827); font-size: 17px; } +.asset-insight-heading p { margin: 5px 0 0; color: var(--text-secondary,#64748b); font-size: 12px; } +.asset-period-switch { display: inline-flex; padding: 3px; border: 1px solid var(--border-color,#e5e7eb); border-radius: 9px; background: var(--card-bg,#fff); } +.asset-period-switch button { min-width: 54px; height: 29px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: var(--text-secondary,#64748b); cursor: pointer; font: inherit; font-size: 11px; } +.asset-period-switch button:hover { color: var(--text-primary,#111827); } +.asset-period-switch button.active { background: rgba(59,130,246,.12); color: #2563eb; font-weight: 650; } +.asset-trend-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; } +.asset-trend-panel { min-width: 0; } +.asset-trend-panel .asset-panel-header { min-height: 42px; margin-bottom: 14px; } +.asset-trend-header { display: grid; grid-template-columns: minmax(190px,1fr) auto; grid-template-areas: "heading legend" "heading summary"; align-items: center; column-gap: 24px; row-gap: 8px; } +.asset-trend-heading { grid-area: heading; min-width: 0; } +.asset-trend-header .asset-chart-legend { grid-area: legend; } +.asset-trend-header .asset-chart-summary { grid-area: summary; justify-content: flex-end; } +.asset-chart-legend { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 12px; } +.asset-chart-legend span { display: inline-flex; align-items: center; gap: 5px; color: var(--text-secondary,#64748b); font-size: 10px; white-space: nowrap; } +.asset-chart-legend span::before { width: 7px; height: 7px; border-radius: 50%; background: #3b82f6; content: ''; } +.asset-chart-legend .asset-legend-inactive::before { background: #8b5cf6; } +.asset-chart-legend .asset-legend-risk::before { background: #f59e0b; } +.asset-chart-legend .asset-legend-high::before { background: #ef4444; } +.asset-line-chart { min-height: 220px; } +.asset-line-chart svg { display: block; width: 100%; height: 220px; overflow: visible; color: var(--text-muted,#94a3b8); } +.asset-chart-grid line { stroke: var(--border-color,#e5e7eb); stroke-width: 1; stroke-dasharray: 3 4; } +.asset-chart-grid text { fill: currentColor; font-size: 9px; } +.asset-chart-summary { display: flex; align-items: center; gap: 18px; padding: 0; } +.asset-chart-summary div { display: flex; align-items: baseline; gap: 6px; white-space: nowrap; } +.asset-chart-summary span { color: var(--text-secondary,#64748b); font-size: 10px; } +.asset-chart-summary strong { font-size: 17px; line-height: 1; } +.asset-coverage-layout { display: grid; grid-template-columns: 180px minmax(0,1fr); align-items: center; gap: 28px; } +.asset-coverage-gauge { --asset-coverage-value: 0%; display: grid; width: 142px; height: 142px; margin: 2px auto; place-items: center; border-radius: 50%; background: conic-gradient(#3b82f6 0 var(--asset-coverage-value),var(--bg-secondary,#eef2f7) var(--asset-coverage-value) 100%); } +.asset-coverage-gauge::before { grid-area: 1/1; width: 104px; height: 104px; border-radius: 50%; background: var(--card-bg,#fff); content: ''; } +.asset-coverage-gauge > div { grid-area: 1/1; z-index: 1; text-align: center; } +.asset-coverage-gauge strong,.asset-coverage-gauge span { display: block; } +.asset-coverage-gauge strong { color: var(--text-primary,#111827); font-size: 27px; letter-spacing: -.03em; } +.asset-coverage-gauge span { margin-top: 5px; color: var(--text-secondary,#64748b); font-size: 10px; } +.asset-coverage-metrics { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); overflow: hidden; border: 1px solid var(--border-color,#e5e7eb); border-radius: 12px; background: var(--bg-secondary,#f8fafc); } +.asset-coverage-metrics > div { min-width: 0; padding: 17px 18px; } +.asset-coverage-metrics > div + div { border-left: 1px solid var(--border-color,#e5e7eb); } +.asset-coverage-metrics span,.asset-coverage-metrics strong,.asset-coverage-metrics small { display: block; } +.asset-coverage-metrics span { color: var(--text-secondary,#64748b); font-size: 11px; } +.asset-coverage-metrics strong { margin-top: 12px; color: var(--text-primary,#111827); font-size: 25px; letter-spacing: -.025em; } +.asset-coverage-metrics small { margin-top: 7px; overflow: hidden; color: var(--text-muted,#94a3b8); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } +.asset-coverage-metrics .asset-coverage-gap strong { color: #f59e0b; } +.asset-panel { padding: 22px 24px 24px; } +.asset-panel-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; } +.asset-panel-header h3 { margin: 0; font-size: 16px; } +.asset-panel-header p { margin: 6px 0 0; color: var(--text-secondary, #64748b); font-size: 12px; } +.asset-panel-meta { flex: 0 0 auto; padding: 5px 9px; border-radius: 999px; background: var(--bg-secondary, #f1f5f9); color: var(--text-secondary, #64748b); font-size: 11px; } +.asset-protocol-layout { display: grid; grid-template-columns: 300px minmax(0,1fr); gap: 24px; align-items: stretch; } +.asset-protocol-summary { display: flex; min-height: 320px; flex-direction: column; padding: 20px; border: 1px solid var(--border-color, #e5e7eb); border-radius: 13px; background: var(--bg-secondary, #f8fafc); } +.asset-protocol-summary[hidden] { display: none; } +.asset-protocol-summary__label { color: var(--text-secondary,#64748b); font-size: 11px; font-weight: 600; } +.asset-protocol-summary__main { display: flex; align-items: center; gap: 20px; padding: 24px 0; } +.asset-protocol-donut { display: grid; width: 126px; height: 126px; flex: 0 0 126px; place-items: center; border-radius: 50%; background: conic-gradient(from -90deg,#6366f1 0 0%,var(--border-color,#e5e7eb) 0 100%); } +.asset-protocol-donut::before { content: ''; grid-area: 1/1; width: 92px; height: 92px; border-radius: 50%; background: var(--card-bg,#fff); } +.asset-protocol-donut > div { grid-area: 1/1; z-index: 1; text-align: center; } +.asset-protocol-donut strong,.asset-protocol-donut span { display: block; } +.asset-protocol-donut strong { color: var(--text-primary,#111827); font-size: 25px; } +.asset-protocol-donut span { width: 74px; margin: 4px auto 0; color: var(--text-secondary,#64748b); font-size: 8px; } +.asset-protocol-lead { min-width: 0; } +.asset-protocol-lead span,.asset-protocol-lead strong,.asset-protocol-lead small { display: block; } +.asset-protocol-lead span { color: var(--text-secondary,#64748b); font-size: 10px; } +.asset-protocol-lead strong { margin-top: 8px; overflow: hidden; color: var(--text-primary,#111827); font-size: 20px; text-overflow: ellipsis; white-space: nowrap; } +.asset-protocol-lead small { margin-top: 5px; color: var(--text-secondary,#64748b); font-size: 11px; } +.asset-protocol-top-list { display: flex; margin-top: auto; flex-direction: column; gap: 9px; padding-top: 16px; border-top: 1px solid var(--border-color,#e5e7eb); } +.asset-protocol-top-list div { display: grid; grid-template-columns: 7px 1fr auto; align-items: center; gap: 8px; } +.asset-protocol-top-list i { width: 7px; height: 7px; border-radius: 2px; } +.asset-protocol-top-list span { overflow: hidden; color: var(--text-secondary,#64748b); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.asset-protocol-top-list strong { color: var(--text-primary,#111827); font-size: 10px; } +.asset-protocol-ranking { min-width: 0; padding: 0 2px; } +.asset-protocol-columns { display: grid; grid-template-columns: 26px 94px minmax(140px,1fr) 46px 42px; align-items: center; gap: 12px; margin-bottom: 7px; padding: 0 12px 9px; border-bottom: 1px solid var(--border-color,#e5e7eb); color: var(--text-muted,#94a3b8); font-size: 9px; } +.asset-protocol-columns span:nth-last-child(-n+2) { text-align: right; } +.asset-bar-chart { display: flex; min-width: 0; flex-direction: column; gap: 2px; } +.asset-bar-row { display: grid; grid-template-columns: 26px 94px minmax(140px,1fr) 46px 42px; min-height: 35px; align-items: center; gap: 12px; padding: 3px 12px; border-radius: 7px; transition: background .15s; } +.asset-bar-row:hover { background: var(--bg-secondary,#f8fafc); } +.asset-bar-rank { color: var(--text-muted, #94a3b8); font: 10px ui-monospace,SFMono-Regular,Menlo,monospace; } +.asset-bar-label { display: flex; align-items: center; gap: 7px; color: var(--text-primary,#111827); font-size: 11px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; } +.asset-bar-label::before { width: 6px; height: 6px; flex: 0 0 6px; border-radius: 2px; background: var(--protocol-color,#3b82f6); content: ''; } +.asset-bar-track { height: 6px; overflow: hidden; border-radius: 999px; background: var(--bg-secondary, #eef2f7); } +.asset-bar-track i { display: block; height: 100%; border-radius: inherit; background: var(--protocol-color,#3b82f6); } +.asset-bar-row strong,.asset-bar-row small { text-align: right; } +.asset-bar-row strong { font-size: 11px; } +.asset-bar-row small { color: var(--text-secondary, #6b7280); font-size: 11px; } +.asset-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 14px; border: 1px solid var(--border-color, #e5e7eb); border-radius: 12px; background: var(--card-bg, #fff); } +.asset-toolbar input { min-width: 300px; flex: 1; } +.asset-toolbar select { min-width: 130px; } +.asset-toolbar input,.asset-toolbar select { height: 38px; padding: 0 12px; border: 1px solid var(--border-color, #d8dee8); border-radius: 8px; background: var(--input-bg, #fff); color: var(--text-primary, #111827); } +.asset-custom-select { width: 100%; min-width: 0; font-weight: 400; } +.asset-custom-select--filter { width: auto; min-width: 150px; flex: 0 0 auto; } +.asset-custom-select .settings-custom-select-trigger { height: 42px; min-height: 42px; border-radius: 8px; background: var(--input-bg, #fff); font-size: 13px; font-weight: 400; } +.asset-custom-select .settings-custom-select-caret { width: 16px; height: 16px; color: var(--text-secondary, #64748b); font-size: 0; } +.asset-custom-select .settings-custom-select-caret::before { display: block; width: 7px; height: 7px; margin: 2px auto 0; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; content: ''; transform: rotate(45deg); } +.asset-custom-select.open .settings-custom-select-caret { transform: rotate(180deg); } +.asset-custom-select .settings-custom-select-menu { min-width: 100%; padding: 5px; border-radius: 9px; background: var(--card-bg, #fff); box-shadow: 0 12px 28px rgba(15,23,42,.16); } +.asset-custom-select .settings-custom-select-option { min-height: 34px; border-radius: 6px; font-size: 13px; } +.asset-custom-select--filter .settings-custom-select-trigger { height: 38px; min-height: 38px; padding: 7px 11px; } +.asset-custom-select--pagination { width: 76px; flex: 0 0 76px; } +.asset-custom-select--pagination .settings-custom-select-trigger { height: 32px; min-height: 32px; padding: 5px 9px; } +.asset-custom-select--pagination .settings-custom-select-menu { top: auto; bottom: calc(100% + 6px); } +.asset-batch-actions { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border: 1px solid rgba(37,99,235,.32); border-radius: 10px; background: rgba(37,99,235,.07); } +.asset-batch-actions[hidden] { display: none; } +.asset-batch-actions > span { margin-right: auto; color: var(--text-primary, #111827); font-size: 13px; font-weight: 650; } +.asset-list-meta { margin-left: auto; } +.asset-list-card { overflow: hidden; border: 1px solid var(--border-color, #e5e7eb); border-radius: 12px; background: var(--card-bg, #fff); box-shadow: 0 1px 3px rgba(15,23,42,.03); } +#page-asset-library .asset-list-card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } +.asset-table-wrap { overflow: auto; border: 1px solid var(--border-color, #e5e7eb); border-radius: 12px; background: var(--card-bg, #fff); } +.asset-list-card .asset-table-wrap { border: 0; border-radius: 0; } +#page-asset-library .asset-list-card .asset-table-wrap { flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden; } +.asset-list-card #asset-pagination.pagination-fixed { border: 0; border-top: 1px solid var(--border-color, #e5e7eb); border-radius: 0; box-shadow: none; } +#page-asset-library .asset-list-card #asset-pagination { flex: 0 0 auto; } +.asset-list-card #asset-pagination.pagination-fixed .pagination { border-radius: 0; } +.asset-table { width: 100%; min-width: 0; table-layout: fixed; border-collapse: collapse; } +.asset-table th,.asset-table td { padding: 13px 14px; border-bottom: 1px solid var(--border-color, #edf0f4); text-align: left; vertical-align: middle; } +.asset-table th { position: sticky; top: 0; background: var(--card-bg, #fff); color: var(--text-secondary, #64748b); font-size: 12px; font-weight: 600; z-index: 1; } +.asset-table td { font-size: 13px; } +.asset-table td:nth-child(2) { width: 220px; max-width: 280px; } +.asset-table th:nth-child(2),.asset-table td:nth-child(2) { width: auto; } +.asset-table th:nth-child(3),.asset-table td:nth-child(3) { width: 112px; } +.asset-table th:nth-child(4),.asset-table td:nth-child(4) { width: 140px; } +.asset-table th:nth-child(5),.asset-table td:nth-child(5) { width: 180px; } +.asset-table th:nth-child(6),.asset-table td:nth-child(6) { width: 96px; } +.asset-table th:nth-child(7),.asset-table td:nth-child(7) { width: 84px; } +.asset-table th:nth-child(8),.asset-table td:nth-child(8) { width: 84px; } +.asset-table th:nth-child(9),.asset-table td:nth-child(9) { width: 176px; } +.asset-check-cell { width: 42px; min-width: 42px; text-align: center !important; } +.asset-check-cell input { width: 15px; height: 15px; cursor: pointer; accent-color: #2563eb; } +.asset-table td strong,.asset-table td small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.asset-target-link { display: block; max-width: 100%; padding: 0; border: 0; background: transparent; color: #2563eb; font: inherit; font-weight: 650; text-align: left; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.asset-target-link:hover { text-decoration: underline; } +.asset-table td small { margin-top: 4px; color: var(--text-secondary, #64748b); } +.asset-tag { display: inline-block; margin: 5px 4px 0 0; padding: 2px 7px; border-radius: 999px; background: rgba(37,99,235,.1); color: #2563eb; font-size: 11px; } +.asset-project-badge { display: inline-flex; max-width: 180px; padding: 3px 8px; border-radius: 6px; background: rgba(99,102,241,.1); color: #4f46e5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.asset-service { display: block; max-width: 100%; overflow: hidden; color: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; } +.asset-status { display: inline-flex; padding: 3px 9px; border-radius: 999px; font-size: 12px; background: rgba(16,185,129,.12); color: #059669; } +.asset-status--inactive { background: rgba(100,116,139,.12); color: #64748b; } +.asset-risk { display: inline-flex; justify-content: center; min-width: 46px; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 650; background: rgba(100,116,139,.12); color: #64748b; } +.asset-risk--critical { background: rgba(190,24,93,.16); color: #be185d; } +.asset-risk--high { background: rgba(239,68,68,.15); color: #dc2626; } +.asset-risk--medium { background: rgba(245,158,11,.16); color: #d97706; } +.asset-risk--low { background: rgba(59,130,246,.14); color: #2563eb; } +.asset-risk--info { background: rgba(99,102,241,.13); color: #4f46e5; } +.asset-risk--normal { background: rgba(16,185,129,.13); color: #059669; } +.asset-row-actions { white-space: nowrap; } +.asset-row-actions .btn-link { margin-right: 8px; } +.asset-row-actions .asset-delete { color: #dc2626; } +.asset-vulnerability-link { min-width: 28px; padding: 3px 8px; border: 1px solid rgba(239,68,68,.28); border-radius: 999px; background: rgba(239,68,68,.10); color: #dc2626; font-weight: 700; cursor: pointer; } +.asset-scan-content { width: min(700px, calc(100vw - 32px)); } +.asset-project-content { width: min(500px, calc(100vw - 32px)); } +.asset-project-content .form-hint { margin: 10px 0 0; color: var(--text-secondary, #64748b); font-size: 12px; line-height: 1.5; } +.asset-project-content .settings-custom-select-trigger { height: 42px; border-radius: 8px; background: var(--input-bg, var(--bg-primary)); } +.asset-project-content .settings-custom-select-menu { z-index: 2700; } +.asset-project-content .settings-custom-select-option.is-disabled { display: none; } +.asset-editor-field > .asset-custom-select { width: 100%; } +.asset-editor-field > .asset-custom-select .settings-custom-select-trigger { width: 100%; } +.asset-scan-targets { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 18px; padding: 12px; border: 1px solid var(--border-color, #e5e7eb); border-radius: 10px; background: var(--bg-secondary, #f8fafc); } +.asset-scan-target-chip { max-width: 220px; padding: 4px 9px; border-radius: 999px; background: rgba(37,99,235,.11); color: #2563eb; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.asset-scan-prompt-label { display: block; margin-bottom: 8px; font-size: 13px; font-weight: 650; } +.asset-scan-prompt { width: 100%; min-height: 150px; padding: 12px; resize: vertical; border: 1px solid var(--border-color, #d8dee8); border-radius: 9px; background: var(--input-bg, #fff); color: var(--text-primary, #111827); font: inherit; line-height: 1.6; } +.asset-scan-modal .form-hint { display: block; margin: 8px 0 14px; color: var(--text-secondary, #64748b); } +.asset-scan-modal #asset-scan-execute-wrap[hidden] { display: none !important; } +.asset-pagination { display: flex; justify-content: flex-end; align-items: center; gap: 12px; } +.asset-editor-content { width: min(840px, calc(100vw - 32px)); max-height: calc(100vh - 48px); margin: 24px auto; } +.asset-editor-content .modal-header { padding: 20px 24px; } +.asset-editor-content .modal-header > div { min-width: 0; } +.asset-editor-content .modal-header h2 { font-size: 20px; } +.asset-editor-subtitle { margin: 5px 0 0; color: var(--text-secondary, #64748b); font-size: 13px; font-weight: 400; } +.asset-editor-body { padding: 22px 24px 24px; overscroll-behavior: contain; scrollbar-gutter: stable; } +.asset-editor-content .modal-footer { position: relative; background: var(--bg-primary, #fff); box-shadow: 0 -8px 24px rgba(15,23,42,.04); } +.asset-editor-primary { padding: 18px; border: 1px solid rgba(37,99,235,.22); border-radius: 12px; background: linear-gradient(135deg, rgba(37,99,235,.065), rgba(99,102,241,.025)); } +.asset-editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.asset-editor-primary-grid { margin-top: 18px; } +.asset-editor-field { display: flex; min-width: 0; flex-direction: column; gap: 7px; font-size: 13px; font-weight: 600; } +.asset-editor-field > span { display: flex; align-items: center; gap: 4px; } +.asset-editor-field > span b { color: #dc2626; font-size: 15px; } +.asset-editor-field input,.asset-editor-field select { box-sizing: border-box; width: 100%; height: 42px; padding: 0 12px; border: 1px solid var(--border-color, #d8dee8); border-radius: 8px; outline: none; background: var(--input-bg, #fff); color: var(--text-primary, #111827); font: inherit; font-weight: 400; transition: border-color .15s, box-shadow .15s, background .15s; } +.asset-editor-field input:focus,.asset-editor-field select:focus,.asset-tag-editor:focus-within { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,.12); } +.asset-editor-field input[aria-invalid="true"] { border-color: #dc2626; box-shadow: 0 0 0 3px rgba(220,38,38,.09); } +.asset-editor-target-field input { height: 48px; font-size: 15px; } +.asset-field-hint { color: var(--text-secondary, #64748b); font-size: 12px; font-weight: 400; line-height: 1.45; } +.asset-field-error { color: #dc2626; font-size: 12px; font-weight: 500; line-height: 1.45; } +.asset-form-error { margin-top: 14px; padding: 10px 12px; border: 1px solid rgba(220,38,38,.24); border-radius: 8px; background: rgba(220,38,38,.07); color: #b91c1c; font-size: 13px; line-height: 1.45; } +.asset-tag-editor { display: flex; min-height: 42px; align-items: center; flex-wrap: wrap; gap: 6px; padding: 5px 8px; border: 1px solid var(--border-color, #d8dee8); border-radius: 8px; background: var(--input-bg, #fff); transition: border-color .15s, box-shadow .15s; } +.asset-tag-chips { display: contents; } +.asset-tag-chip { display: inline-flex; max-width: 160px; align-items: center; gap: 5px; padding: 3px 7px; border-radius: 999px; background: rgba(37,99,235,.11); color: #1d4ed8; font-size: 12px; font-weight: 500; } +.asset-tag-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.asset-tag-chip button { width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; font-size: 14px; line-height: 14px; } +.asset-tag-chip button:hover { background: rgba(37,99,235,.14); } +.asset-tag-editor #asset-edit-tags { width: 130px; min-width: 90px; height: 30px; flex: 1; padding: 0 3px; border: 0; box-shadow: none; background: transparent; } +.asset-editor-details { margin-top: 24px; } +.asset-editor-section-group + .asset-editor-section-group { margin-top: 24px; padding-top: 24px; border-top: 1px solid var(--border-color, #e5e7eb); } +.asset-editor-group-heading { display: flex; min-width: 0; align-items: baseline; gap: 10px; margin-bottom: 12px; } +.asset-editor-group-heading::after { content: ''; height: 1px; min-width: 24px; flex: 1; background: var(--border-color, #e5e7eb); } +.asset-editor-group-heading h3 { flex: 0 0 auto; margin: 0; color: var(--text-primary, #111827); font-size: 13px; font-weight: 650; line-height: 1.45; } +.asset-editor-group-heading p { min-width: 0; margin: 0; color: var(--text-secondary, #64748b); font-size: 12px; font-weight: 400; line-height: 1.45; } +.asset-editor-wide { grid-column: 1 / -1; } +.asset-editor-submit-busy { pointer-events: none; opacity: .7; } +.asset-detail-modal { overflow: hidden; } +.asset-detail-modal .asset-detail-content { + position: absolute; + top: 0; + right: 0; + width: min(620px, 92vw); + max-width: none; + height: 100%; + max-height: none; + margin: 0; + border-width: 0 0 0 1px; + border-radius: 18px 0 0 18px; + animation: assetDrawerIn .22s ease-out; +} +.asset-detail-modal .modal-header { border-radius: 18px 0 0 0; } +.asset-detail-modal .modal-body { padding: 20px; } +.asset-detail-modal .modal-footer { flex-shrink: 0; } +@keyframes assetDrawerIn { from { opacity: .7; transform: translateX(100%); } to { opacity: 1; transform: translateX(0); } } +.asset-detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; overflow: hidden; border: 1px solid var(--border-color, #e5e7eb); border-radius: 12px; background: var(--border-color, #e5e7eb); } +.asset-detail-item { min-width: 0; padding: 15px 16px; background: var(--card-bg, #fff); } +.asset-detail-item--wide { grid-column: 1 / -1; } +.asset-detail-item > span { display: block; margin-bottom: 7px; color: var(--text-secondary, #64748b); font-size: 12px; } +.asset-detail-item > div { min-height: 20px; color: var(--text-primary, #111827); overflow-wrap: anywhere; } +.asset-detail-item code { display: block; padding: 9px; border-radius: 7px; background: var(--bg-secondary, #f4f6f8); white-space: pre-wrap; } + +/* Asset management dark theme */ +html[data-theme="dark"] .asset-stat-card, +html[data-theme="dark"] .asset-total-card, +html[data-theme="dark"] .asset-recent-card, +html[data-theme="dark"] .asset-panel, +html[data-theme="dark"] .asset-toolbar, +html[data-theme="dark"] .asset-list-card, +html[data-theme="dark"] .asset-table-wrap, +html[data-theme="dark"] .asset-table th, +html[data-theme="dark"] .asset-scan-targets, +html[data-theme="dark"] .asset-detail-item { + background: var(--card-bg); + border-color: var(--border-color); + box-shadow: none; +} + +html[data-theme="dark"] .asset-overview-icon--violet { + background: rgba(129, 140, 248, .14); + color: #a5b4fc; +} + +html[data-theme="dark"] .asset-type-grid, +html[data-theme="dark"] .asset-coverage-metrics, +html[data-theme="dark"] .asset-protocol-summary { + background: rgba(15, 23, 42, .38); + border-color: var(--border-color); +} + +html[data-theme="dark"] .asset-protocol-donut::before { + background: var(--card-bg); +} + +html[data-theme="dark"] .asset-coverage-gauge::before { + background: var(--card-bg); +} + +html[data-theme="dark"] .asset-period-switch button.active { + background: rgba(96,165,250,.16); + color: #93c5fd; +} + +html[data-theme="dark"] .asset-stat-card--accent { + border-color: rgba(96, 165, 250, .38); + background: linear-gradient(135deg, rgba(37, 99, 235, .20), rgba(124, 58, 237, .10)); +} + +html[data-theme="dark"] .asset-stat-card--accent strong, +html[data-theme="dark"] .asset-target-link, +html[data-theme="dark"] .asset-row-actions .btn-link { + color: #60a5fa; +} + +html[data-theme="dark"] .asset-toolbar input, +html[data-theme="dark"] .asset-toolbar select, +html[data-theme="dark"] .asset-editor-field input, +html[data-theme="dark"] .asset-editor-field select, +html[data-theme="dark"] .asset-editor-grid input, +html[data-theme="dark"] .asset-editor-grid select { + background: var(--input-bg); + border-color: var(--border-color); + color: var(--text-primary); +} + +html[data-theme="dark"] .asset-toolbar input::placeholder, +html[data-theme="dark"] .asset-editor-field input::placeholder, +html[data-theme="dark"] .asset-editor-grid input::placeholder { + color: var(--text-muted); +} + +html[data-theme="dark"] .asset-table tbody tr:hover td { + background: rgba(96, 165, 250, .06); +} + +html[data-theme="dark"] .asset-tag { + background: rgba(96, 165, 250, .16); + color: #93c5fd; +} + +html[data-theme="dark"] .asset-project-badge { + background: rgba(167, 139, 250, .16); + color: #c4b5fd; +} + +html[data-theme="dark"] .asset-status { + background: rgba(52, 211, 153, .14); + color: #6ee7b7; +} + +html[data-theme="dark"] .asset-status--inactive { + background: rgba(148, 163, 184, .14); + color: #cbd5e1; +} +html[data-theme="dark"] .asset-risk--critical { color: #f9a8d4; } +html[data-theme="dark"] .asset-risk--high { color: #fca5a5; } +html[data-theme="dark"] .asset-risk--medium { color: #fcd34d; } +html[data-theme="dark"] .asset-risk--low { color: #93c5fd; } +html[data-theme="dark"] .asset-risk--info { color: #c4b5fd; } +html[data-theme="dark"] .asset-risk--normal { color: #6ee7b7; } + +html[data-theme="dark"] .asset-row-actions .asset-delete { + color: #f87171; +} + +html[data-theme="dark"] .asset-batch-actions { border-color: rgba(96,165,250,.36); background: rgba(37,99,235,.13); } +html[data-theme="dark"] .asset-scan-prompt { background: var(--input-bg); border-color: var(--border-color); color: var(--text-primary); } +html[data-theme="dark"] .asset-vulnerability-link { color: #fca5a5; border-color: rgba(248,113,113,.35); background: rgba(248,113,113,.12); } +html[data-theme="dark"] .asset-custom-select .settings-custom-select-trigger, +html[data-theme="dark"] .asset-custom-select .settings-custom-select-menu { background: var(--input-bg); border-color: var(--border-color); color: var(--text-primary); } +html[data-theme="dark"] .asset-custom-select .settings-custom-select-menu { box-shadow: 0 14px 32px rgba(0,0,0,.34); } + +html[data-theme="dark"] .asset-detail-grid { + border-color: var(--border-color); + background: var(--border-color); +} + +html[data-theme="dark"] .asset-detail-item code { + background: var(--bg-secondary); + color: var(--text-secondary); +} +html[data-theme="dark"] .asset-editor-primary { border-color: rgba(96,165,250,.32); background: linear-gradient(135deg, rgba(37,99,235,.18), rgba(99,102,241,.08)); } +html[data-theme="dark"] .asset-tag-editor { border-color: var(--border-color); background: var(--card-bg); } +html[data-theme="dark"] .asset-editor-content .modal-footer { background: var(--bg-primary); box-shadow: 0 -8px 24px rgba(0,0,0,.12); } +html[data-theme="dark"] .asset-tag-chip { background: rgba(96,165,250,.16); color: #93c5fd; } +html[data-theme="dark"] .asset-form-error { color: #fca5a5; } +@media (max-width: 1180px) { + .asset-overview-summary { grid-template-columns: minmax(0,1.45fr) minmax(240px,.75fr); } + .asset-coverage-metrics { grid-template-columns: repeat(2,minmax(0,1fr)); } + .asset-coverage-metrics > div:nth-child(3) { border-top: 1px solid var(--border-color,#e5e7eb); border-left: 0; } + .asset-coverage-metrics > div:nth-child(4) { border-top: 1px solid var(--border-color,#e5e7eb); } + .asset-protocol-layout { grid-template-columns: 1fr; } + .asset-protocol-summary { width: auto; min-height: 0; } + .asset-protocol-summary__main { justify-content: center; } + .asset-protocol-top-list { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); margin-top: 0; } +} +@media (max-width: 1050px) { + .asset-table th:nth-child(4),.asset-table td:nth-child(4) { display: none; } +} +@media (max-width: 820px) { + .asset-table th:nth-child(3),.asset-table td:nth-child(3) { display: none; } + .asset-table th:nth-child(5),.asset-table td:nth-child(5) { width: 145px; } + .asset-table th:nth-child(9),.asset-table td:nth-child(9) { width: 150px; } +} +@media (max-width: 760px) { + .asset-overview-summary { grid-template-columns: 1fr; } + .asset-trend-grid { grid-template-columns: 1fr; } + .asset-insight-heading { align-items: flex-start; flex-direction: column; } + .asset-coverage-layout { grid-template-columns: 1fr; } + .asset-recent-card { min-height: 230px; } +} +@media (max-width: 640px) { .asset-editor-grid,.asset-detail-grid { grid-template-columns: 1fr; } .asset-total-card,.asset-recent-card,.asset-panel { padding: 18px; } .asset-type-grid,.asset-coverage-metrics { grid-template-columns: 1fr; } .asset-type-item + .asset-type-item,.asset-coverage-metrics > div + div,.asset-coverage-metrics > div:nth-child(4) { border-top: 1px solid var(--border-color,#e5e7eb); border-left: 0; } .asset-trend-header { grid-template-columns: 1fr auto; grid-template-areas: "heading summary"; column-gap: 12px; } .asset-chart-legend { display: none; } .asset-chart-summary { justify-content: flex-end; padding: 0; } .asset-chart-summary span { display: none; } .asset-protocol-summary { box-sizing: border-box; width: 100%; } .asset-protocol-summary__main { align-items: flex-start; flex-direction: column; } .asset-protocol-top-list { grid-template-columns: 1fr; } .asset-bar-row,.asset-protocol-columns { grid-template-columns: 20px 64px 1fr 36px; gap: 8px; padding-right: 4px; padding-left: 4px; } .asset-bar-row small,.asset-protocol-columns span:last-child { display: none; } .asset-editor-content { width: 100%; max-height: 100vh; min-height: 100vh; margin: 0; border-radius: 0; } .asset-editor-content .modal-header,.asset-editor-body,.asset-editor-content .modal-footer { padding-left: 16px; padding-right: 16px; } .asset-editor-subtitle { max-width: 270px; } .asset-editor-primary { padding: 14px; } .asset-editor-group-heading { align-items: flex-start; flex-wrap: wrap; gap: 3px 8px; } .asset-editor-group-heading p { width: 100%; } .asset-editor-section-group + .asset-editor-section-group { margin-top: 20px; padding-top: 20px; } .asset-editor-wide,.asset-detail-item--wide { grid-column: auto; } .asset-toolbar input { min-width: 100%; } } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index e6853e31..a6f61868 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -78,6 +78,9 @@ "nav": { "dashboard": "Dashboard", "chat": "Chat", + "assets": "Asset Management", + "assetOverview": "Asset Overview", + "assetLibrary": "Asset Library", "infoCollect": "Recon", "tasks": "Tasks", "projects": "Projects", @@ -109,6 +112,12 @@ "c2Profiles": "Traffic profiles", "platformRbac": "Platform Access" }, + "navGroups": { + "workbench": "Workspace", + "operations": "Security Operations", + "capabilities": "Capabilities", + "administration": "Administration" + }, "dashboard": { "title": "Dashboard", "refresh": "Refresh", @@ -286,9 +295,13 @@ "addFactCta": "+ Add fact", "tabFacts": "Fact board", "tabGraph": "Attack path", + "tabAssets": "Asset library", "tabConversations": "Bound conversations", "tabVulns": "Related vulnerabilities", "tabSettings": "Settings", + "boundAssetsHint": "Only assets bound to this project are shown; click a target for full details", + "noBoundAssets": "No assets are bound to this project", + "assetCount": "{{count}} assets", "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": "Graph arrows match stored fact links (source → target). Nodes are layered target→infra→finding→exploit. Dashed edges are tentative.", "graphView": "View", @@ -579,6 +592,7 @@ "liveTimelinePrunedRoundRange": "main-agent rounds {{from}}–{{to}}", "toolExecutionsCount": "{{n}} tool runs", "collapseToolExecutions": "Collapse tool runs", + "toolExecutionDetailPending": "Tool execution details have not synced yet. Please try again shortly.", "noProcessDetail": "No process details (execution may be too fast or no detailed events)", "copyMessageTitle": "Copy message", "deleteTurnTitle": "Delete this turn", @@ -998,6 +1012,169 @@ "parseModalApply": "Fill into query", "parseModalApplyRun": "Fill and query" }, + "assets": { + "overviewTitle": "Asset Overview", + "libraryTitle": "Asset Library", + "viewLibrary": "View asset library", + "totalAssets": "Total assets", + "totalAssetsHint": "Assets currently under continuous monitoring", + "ipCount": "IP addresses", + "domainCount": "Domains", + "portCount": "Ports", + "recentCount": "Discovered in 7 days", + "recentWindow": "7 days", + "recentShare": "{{percent}}% of all current assets", + "protocolDistribution": "Protocol distribution", + "protocolDistributionHint": "Exposure composition by identified service", + "protocolKinds": "{{count}} protocols", + "topProtocol": "Top protocol", + "topProtocolShare": "Top protocol share", + "protocolComposition": "Protocol composition", + "protocolRank": "Rank", + "protocolName": "Protocol", + "assetRatio": "Asset share", + "assetAmount": "Count", + "ratio": "Share", + "assetCountUnit": "{{count}} assets", + "postureChanges": "Posture changes", + "postureChangesHint": "Track asset and risk changes over the selected period", + "periodSelection": "Period selection", + "days7": "7 days", + "days30": "30 days", + "days90": "90 days", + "assetTrend": "Asset change trend", + "assetTrendHint": "New discoveries and currently inactive assets", + "addedAssets": "Added assets", + "inactiveAssets": "Inactive assets", + "riskTrend": "Risk discovery trend", + "riskTrendHint": "New vulnerabilities and critical or high-risk findings", + "discoveredRisks": "New vulnerabilities", + "highRisks": "Critical & high", + "scanCoverage": "Scan coverage", + "scanCoverageHint": "Identify assets that are unscanned or overdue for review", + "overallCoverage": "Overall coverage", + "scannedAssets": "Scanned assets", + "scannedAssetsHint": "Scanned at least once", + "recentlyScanned": "Covered in 30 days", + "neverScanned": "Never scanned", + "neverScannedHint": "Prioritize for scanning", + "staleScans": "Not scanned in 30+ days", + "staleScansHint": "Scan results may be stale", + "coverageOfTotal": "{{percent}}% of all assets", + "coverageMeta": "{{scanned}} / {{total}} covered", + "addAsset": "+ Add asset", + "addAssetTitle": "Add asset", + "editAssetTitle": "Edit asset", + "editorSubtitle": "Enter one target; other details are parsed automatically or can be added later", + "assetAddress": "Asset address", + "assetAddressHint": "Any non-empty target is accepted; recognized URLs, domains, IPs, and ports are parsed automatically", + "targetWhitespace": "The asset address cannot contain spaces", + "targetInvalid": "Enter a valid URL, domain, or IP address", + "hostInvalid": "The full URL is invalid or contains credentials", + "ipInvalid": "Invalid IP address", + "domainInvalid": "Invalid domain", + "protocolInvalid": "Invalid protocol", + "portInvalid": "Port must be between 1 and 65535", + "connectionInfo": "Connection", + "connectionInfoHint": "Parsed from the asset address automatically; adjust as needed", + "fingerprintInfo": "Identification", + "fingerprintInfoHint": "Add page and service fingerprints for easier discovery", + "locationAndStatus": "Location and status", + "locationAndStatusHint": "Record the asset location and current availability", + "hostUrl": "Full URL / Host", + "pageTitle": "Page title", + "serverProduct": "Service / product", + "tagsPlaceholder": "Press Enter or comma to add", + "tagsHint": "Classify by environment, business, or ownership", + "removeTag": "Remove tag {{tag}}", + "addAssetAction": "Add asset", + "discardChanges": "Discard unsaved changes?", + "createdSuccessfully": "Asset added", + "updatedSuccessfully": "Asset updated", + "duplicateMerged": "Asset already existed; details were safely merged", + "assetSkipped": "Asset was not saved; it may exist without update permission", + "searchPlaceholder": "Search host, IP, domain, title, service, or tag", + "allStatuses": "All statuses", + "allProjects": "All projects", + "unboundProject": "Not bound", + "project": "Project", + "archived": "Archived", + "statusActive": "Active", + "statusInactive": "Inactive", + "target": "Target", + "service": "Service", + "title": "Title / fingerprint", + "location": "Location", + "country": "Country / region", + "province": "Province / state", + "city": "City", + "source": "Source", + "lastSeen": "Last seen", + "status": "Status", + "domain": "Domain", + "port": "Port", + "protocol": "Protocol", + "server": "Server fingerprint", + "tags": "Tags (comma separated)", + "tagsLabel": "Tags", + "detailTitle": "Asset details", + "sourceQuery": "Source query", + "firstSeen": "First seen", + "totalMeta": "{{count}} assets", + "targetRequired": "Enter an asset address", + "loadFailed": "Failed to load assets", + "saveFailed": "Failed to save asset", + "saved": "Asset saved", + "deleteConfirm": "Delete this asset?", + "deleteFailed": "Failed to delete asset", + "importSelected": "Import selected", + "importOne": "Add to asset library", + "selectFirst": "Select results to import first", + "noValidImportTarget": "None of the selected results has a valid asset target", + "importFailed": "Failed to import assets", + "importDone": "Created {{created}}, updated {{updated}}", + "importDoneWithInvalid": "Created {{created}}, updated {{updated}}, skipped {{invalid}} results without valid targets", + "selectPage": "Select this page", + "selectAsset": "Select asset", + "selectedCount": "{{count}} selected", + "clearSelection": "Clear selection", + "bindProject": "Bind project", + "bindProjectTitle": "Bind project", + "chooseProject": "Choose a project", + "selectProjectRequired": "Choose a project to bind", + "bindProjectCount": "Update {{count}} assets", + "bindProjectHint": "All selected assets will be bound to this project.", + "confirmBinding": "Confirm", + "bindProjectDone": "Bound {{count}} assets", + "bindProjectFailed": "Failed to bind project", + "sendToChat": "Send to chat", + "sendToChatShort": "Scan", + "createScanTask": "Create scan tasks", + "scanTitle": "Scan assets", + "scanAssetCount": "{{count}} assets", + "userPrompt": "User prompt", + "promptHint": "Use {{asset_id}}, {{target}}, {{host}}, {{ip}}, {{domain}}, or {{port}} placeholders. Task mode creates one task per asset.", + "executeNow": "Run immediately after creation", + "confirmSend": "Send", + "confirmCreate": "Create tasks", + "defaultScanPrompt": "Perform an authorized security scan of {{target}} (asset ID: {{asset_id}}). Save confirmed findings with record_vulnerability, then call complete_asset_scan(id={{asset_id}}) to update the last scan time and related vulnerability count.", + "selectAssetsFirst": "Select assets first", + "promptRequired": "Enter a user prompt", + "scanSubmitFailed": "Failed to submit scan", + "scanTaskLinkFailed": "Tasks were created, but asset linking failed", + "scanConversationTitle": "Asset scan: {{targets}}", + "scanQueueTitle": "Batch asset scan", + "lastScan": "Last scan", + "relatedVulnerabilities": "Related findings", + "riskLevel": "Risk", + "riskCritical": "Critical", + "riskHigh": "High", + "riskMedium": "Medium", + "riskLow": "Low", + "riskInfo": "Info", + "riskNormal": "Clear", + "riskUnassessed": "Unassessed" + }, "vulnerability": { "title": "Vulnerability Management", "addVuln": "Add vulnerability", @@ -2496,6 +2673,7 @@ "knowledge": "Knowledge", "conversation": "Conversation", "vulnerability": "Vulnerability", + "asset": "Asset", "externalMcp": "External MCP", "task": "Tasks", "tool": "Tools", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index c2c6c421..5b2e1bb9 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -78,6 +78,9 @@ "nav": { "dashboard": "仪表盘", "chat": "对话", + "assets": "资产管理", + "assetOverview": "资产概览", + "assetLibrary": "资产库", "infoCollect": "信息收集", "tasks": "任务管理", "projects": "项目管理", @@ -90,11 +93,11 @@ "knowledge": "知识", "knowledgeRetrievalLogs": "检索历史", "knowledgeManagement": "知识管理", - "skills": "Skills", - "skillsMonitor": "Skills状态监控", - "skillsManagement": "Skills管理", - "agents": "Agents", - "agentsManagement": "Agent管理", + "skills": "技能", + "skillsMonitor": "技能状态", + "skillsManagement": "技能管理", + "agents": "智能体", + "agentsManagement": "智能体管理", "roles": "角色", "rolesManagement": "角色管理", "workflows": "工作流", @@ -109,6 +112,12 @@ "c2Profiles": "流量伪装", "platformRbac": "平台权限" }, + "navGroups": { + "workbench": "工作台", + "operations": "安全作业", + "capabilities": "能力中心", + "administration": "平台管理" + }, "dashboard": { "title": "仪表盘", "refresh": "刷新", @@ -274,9 +283,13 @@ "addFactCta": "+ 添加事实", "tabFacts": "事实黑板", "tabGraph": "攻击路径", + "tabAssets": "资产库", "tabConversations": "关联对话", "tabVulns": "关联漏洞", "tabSettings": "设置", + "boundAssetsHint": "仅展示绑定到当前项目的资产;点击目标可查看完整详情", + "noBoundAssets": "暂无绑定到此项目的资产", + "assetCount": "{{count}} 个资产", "factToolbarHint": "索引仅含 key 与摘要(须含「什么 + 在哪 + 如何验证」);攻击链 / POC 写在 body,Agent 通过 get_project_fact 复现", "graphToolbarHint": "攻击路径图箭头与事实存储方向一致(source → target);节点按 target→infra→finding→exploit 分层排布。虚线边为待确认。", "graphView": "视图", @@ -567,6 +580,7 @@ "liveTimelinePrunedRoundRange": "主代理第 {{from}}–{{to}} 轮", "toolExecutionsCount": "{{n}}次工具执行", "collapseToolExecutions": "收起工具执行", + "toolExecutionDetailPending": "工具执行详情尚未同步,请稍后重试。", "noProcessDetail": "暂无过程详情(可能执行过快或未触发详细事件)", "copyMessageTitle": "复制消息内容", "deleteTurnTitle": "删除本轮对话", @@ -986,6 +1000,169 @@ "parseModalApply": "填入查询框", "parseModalApplyRun": "填入并查询" }, + "assets": { + "overviewTitle": "资产概览", + "libraryTitle": "资产库", + "viewLibrary": "查看资产库", + "totalAssets": "资产总数", + "totalAssetsHint": "当前纳入持续监测的资产", + "ipCount": "IP 数量", + "domainCount": "域名数量", + "portCount": "端口数量", + "recentCount": "近 7 天发现", + "recentWindow": "7 天", + "recentShare": "占当前资产总量的 {{percent}}%", + "protocolDistribution": "协议分布", + "protocolDistributionHint": "按已识别服务查看资产暴露构成", + "protocolKinds": "{{count}} 种协议", + "topProtocol": "主要协议", + "topProtocolShare": "主要协议占比", + "protocolComposition": "协议构成", + "protocolRank": "排名", + "protocolName": "协议", + "assetRatio": "资产占比", + "assetAmount": "数量", + "ratio": "占比", + "assetCountUnit": "{{count}} 个资产", + "postureChanges": "态势变化", + "postureChangesHint": "观察资产与风险在选定周期内的变化", + "periodSelection": "周期选择", + "days7": "7 天", + "days30": "30 天", + "days90": "90 天", + "assetTrend": "资产增减趋势", + "assetTrendHint": "新增发现与当前已停用资产", + "addedAssets": "新增资产", + "inactiveAssets": "停用资产", + "riskTrend": "风险发现趋势", + "riskTrendHint": "新增漏洞及其中的高危与严重风险", + "discoveredRisks": "新增漏洞", + "highRisks": "高危及严重", + "scanCoverage": "扫描覆盖", + "scanCoverageHint": "识别未扫描与长期未复查的资产缺口", + "overallCoverage": "总体覆盖率", + "scannedAssets": "已扫描资产", + "scannedAssetsHint": "至少完成过一次扫描", + "recentlyScanned": "近 30 天覆盖", + "neverScanned": "从未扫描", + "neverScannedHint": "建议优先纳入扫描", + "staleScans": "超过 30 天未扫描", + "staleScansHint": "扫描结果可能已过期", + "coverageOfTotal": "占全部资产 {{percent}}%", + "coverageMeta": "{{scanned}} / {{total}} 已覆盖", + "addAsset": "+ 新增资产", + "addAssetTitle": "新增资产", + "editAssetTitle": "编辑资产", + "editorSubtitle": "输入一个目标即可,其余信息可自动解析或稍后补充", + "assetAddress": "资产地址", + "assetAddressHint": "仅需填写非空目标;可识别的 URL、域名、IP 和端口会自动解析", + "targetWhitespace": "资产地址不能包含空格", + "targetInvalid": "请输入有效的 URL、域名或 IP 地址", + "hostInvalid": "完整 URL 格式无效或包含凭据", + "ipInvalid": "IP 地址格式无效", + "domainInvalid": "域名格式无效", + "protocolInvalid": "协议格式无效", + "portInvalid": "端口必须在 1–65535 之间", + "connectionInfo": "连接信息", + "connectionInfoHint": "由资产地址自动解析,可按需修正", + "fingerprintInfo": "识别信息", + "fingerprintInfoHint": "补充页面与服务指纹,便于检索和识别", + "locationAndStatus": "地理与状态", + "locationAndStatusHint": "记录资产归属地区和当前启用状态", + "hostUrl": "完整 URL / Host", + "pageTitle": "页面标题", + "serverProduct": "服务 / 产品", + "tagsPlaceholder": "输入后按回车或逗号", + "tagsHint": "用于环境、业务或责任范围分类", + "removeTag": "移除标签 {{tag}}", + "addAssetAction": "添加资产", + "discardChanges": "放弃尚未保存的更改吗?", + "createdSuccessfully": "资产已添加", + "updatedSuccessfully": "资产已更新", + "duplicateMerged": "资产已存在,信息已安全合并", + "assetSkipped": "资产未保存,可能已存在且你没有更新权限", + "searchPlaceholder": "搜索主机、IP、域名、标题、服务或标签", + "allStatuses": "全部状态", + "allProjects": "全部项目", + "unboundProject": "暂不绑定", + "project": "所属项目", + "archived": "已归档", + "statusActive": "活跃", + "statusInactive": "停用", + "target": "目标", + "service": "服务", + "title": "标题/指纹", + "location": "地区", + "country": "国家/地区", + "province": "省份/州", + "city": "城市", + "source": "来源", + "lastSeen": "最近发现", + "status": "状态", + "domain": "域名", + "port": "端口", + "protocol": "协议", + "server": "服务指纹", + "tags": "标签(逗号分隔)", + "tagsLabel": "标签", + "detailTitle": "资产详情", + "sourceQuery": "来源查询", + "firstSeen": "首次发现", + "totalMeta": "共 {{count}} 条", + "targetRequired": "请输入资产地址", + "loadFailed": "加载资产失败", + "saveFailed": "保存资产失败", + "saved": "资产已保存", + "deleteConfirm": "确定删除该资产吗?", + "deleteFailed": "删除资产失败", + "importSelected": "入库所选", + "importOne": "加入资产库", + "selectFirst": "请先选择需要入库的结果", + "noValidImportTarget": "所选结果中没有可入库的有效资产目标", + "importFailed": "资产入库失败", + "importDone": "已新增 {{created}} 条,更新 {{updated}} 条", + "importDoneWithInvalid": "已新增 {{created}} 条,更新 {{updated}} 条,跳过 {{invalid}} 条无有效目标的结果", + "selectPage": "选择本页", + "selectAsset": "选择资产", + "selectedCount": "已选择 {{count}} 项", + "clearSelection": "取消选择", + "bindProject": "绑定项目", + "bindProjectTitle": "绑定项目", + "chooseProject": "请选择项目", + "selectProjectRequired": "请选择要绑定的项目", + "bindProjectCount": "将更新 {{count}} 个资产", + "bindProjectHint": "所选资产将统一绑定到该项目。", + "confirmBinding": "确认绑定", + "bindProjectDone": "已绑定 {{count}} 个资产", + "bindProjectFailed": "绑定项目失败", + "sendToChat": "发送到对话", + "sendToChatShort": "扫描", + "createScanTask": "创建扫描任务", + "scanTitle": "扫描资产", + "scanAssetCount": "共 {{count}} 个资产", + "userPrompt": "用户提示词", + "promptHint": "可使用 {{asset_id}}、{{target}}、{{host}}、{{ip}}、{{domain}}、{{port}} 占位符;创建任务时会为每个资产生成一条任务。", + "executeNow": "创建后立即执行", + "confirmSend": "确认发送", + "confirmCreate": "创建任务", + "defaultScanPrompt": "请对资产 {{target}}(资产ID:{{asset_id}})进行授权安全扫描,优先检查暴露服务、已知漏洞、弱口令和常见 Web 风险;通过 record_vulnerability 保存确认的漏洞,完成后调用 complete_asset_scan(id={{asset_id}}) 回写上次扫描时间和相关漏洞。", + "selectAssetsFirst": "请先选择资产", + "promptRequired": "请输入用户提示词", + "scanSubmitFailed": "提交扫描失败", + "scanTaskLinkFailed": "任务已创建,但资产关联失败", + "scanConversationTitle": "资产扫描:{{targets}}", + "scanQueueTitle": "资产批量扫描", + "lastScan": "上次扫描", + "relatedVulnerabilities": "相关漏洞", + "riskLevel": "风险等级", + "riskCritical": "严重", + "riskHigh": "高危", + "riskMedium": "中危", + "riskLow": "低危", + "riskInfo": "提示", + "riskNormal": "正常", + "riskUnassessed": "未评估" + }, "vulnerability": { "title": "漏洞管理", "addVuln": "添加漏洞", @@ -2484,6 +2661,7 @@ "knowledge": "知识库", "conversation": "对话", "vulnerability": "漏洞", + "asset": "资产", "externalMcp": "外部 MCP", "task": "任务", "tool": "工具", diff --git a/web/static/js/assets.js b/web/static/js/assets.js new file mode 100644 index 00000000..f72aa507 --- /dev/null +++ b/web/static/js/assets.js @@ -0,0 +1,1057 @@ +const ASSET_PAGE_SIZE_KEY = 'cyberstrike.asset_page_size'; +function getAssetPageSize() { + try { + const value = Number(localStorage.getItem(ASSET_PAGE_SIZE_KEY)); + return [10, 20, 50, 100].includes(value) ? value : 20; + } catch (error) { + return 20; + } +} +const assetPageState = { page: 1, pageSize: getAssetPageSize(), total: 0, totalPages: 1, items: [], projects: [], projectsLoaded: false, detailIndex: -1, editIndex: -1, detailAsset: null, editAsset: null, selected: new Map(), scanMode: 'chat', scanAssets: [], editorTags: [], editorDirty: false, editorBusy: false, editorReturnFocus: null, editorInteractionsReady: false, editorParsedTarget: '' }; +let assetOverviewDays = 30; + +const ASSET_CUSTOM_SELECT_IDS = [ + 'asset-status-filter', + 'asset-project-filter', + 'asset-batch-project', + 'asset-edit-project', + 'asset-edit-status', + 'asset-page-size-pagination' +]; + +function enhanceAssetSelect(select) { + if (!select || typeof enhanceSettingsSelect !== 'function') return; + enhanceSettingsSelect(select); + const wrapper = select.closest('.settings-custom-select'); + if (!wrapper) return; + wrapper.classList.add('asset-custom-select'); + wrapper.classList.toggle('asset-custom-select--filter', select.id === 'asset-status-filter' || select.id === 'asset-project-filter'); + wrapper.classList.toggle('asset-custom-select--pagination', select.id === 'asset-page-size-pagination'); +} + +function initAssetCustomSelects(root) { + const scope = root || document; + ASSET_CUSTOM_SELECT_IDS.forEach(id => { + const select = scope.getElementById ? scope.getElementById(id) : scope.querySelector(`#${id}`); + if (select) enhanceAssetSelect(select); + }); +} + +function syncAssetSelect(selectOrId) { + const select = typeof selectOrId === 'string' ? document.getElementById(selectOrId) : selectOrId; + if (!select) return; + enhanceAssetSelect(select); + if (typeof syncSettingsCustomSelect === 'function') syncSettingsCustomSelect(select); +} + +function assetT(key, fallback, options) { + if (window.i18next && typeof window.i18next.t === 'function') { + const value = window.i18next.t(key, options || {}); + if (value && value !== key) return value; + } + return fallback; +} + +async function loadAssetOverview() { + try { + const response = await apiFetch('/api/assets/stats?days=' + assetOverviewDays); + if (!response.ok) throw new Error(await response.text()); + const stats = await response.json(); + ['total', 'ips', 'domains', 'ports', 'recent'].forEach(key => { + const el = document.getElementById('asset-stat-' + key); + if (el) el.textContent = Number(stats[key] || 0).toLocaleString(); + }); + renderAssetRecentSummary(Number(stats.recent || 0), Number(stats.total || 0)); + renderAssetTrendCharts(stats.asset_trend || [], stats.risk_trend || []); + renderAssetCoverage(stats.coverage || {}, Number(stats.total || 0)); + renderAssetProtocolChart(stats.protocols || [], Number(stats.total || 0)); + } catch (error) { + console.error('加载资产概览失败:', error); + if (typeof showInlineToast === 'function') showInlineToast(assetT('assets.loadFailed', '加载资产失败') + ': ' + error.message); + } +} + +function setAssetOverviewPeriod(days) { + const normalized = [7, 30, 90].includes(Number(days)) ? Number(days) : 30; + if (normalized === assetOverviewDays) return; + assetOverviewDays = normalized; + document.querySelectorAll('.asset-period-switch button').forEach(button => { + const active = Number(button.dataset.days) === normalized; + button.classList.toggle('active', active); + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + loadAssetOverview(); +} + +function renderAssetRecentSummary(recent, total) { + const percent = total ? Math.min(100, Math.round(recent / total * 100)) : 0; + const bar = document.getElementById('asset-recent-progress'); + const caption = document.getElementById('asset-recent-rate'); + if (bar) bar.style.width = percent + '%'; + if (caption) caption.textContent = assetT('assets.recentShare', `占当前资产总量的 ${percent}%`, { percent }); +} + +function renderAssetTrendCharts(assetTrend, riskTrend) { + renderAssetLineChart('asset-growth-chart', 'asset-growth-summary', assetTrend, [ + { key: 'added', label: assetT('assets.addedAssets', '新增资产'), color: '#3b82f6', fill: 'rgba(59,130,246,.13)' }, + { key: 'inactive', label: assetT('assets.inactiveAssets', '停用资产'), color: '#8b5cf6' } + ]); + renderAssetLineChart('asset-risk-chart', 'asset-risk-summary', riskTrend, [ + { key: 'discovered', label: assetT('assets.discoveredRisks', '新增漏洞'), color: '#f59e0b', fill: 'rgba(245,158,11,.12)' }, + { key: 'high_risk', label: assetT('assets.highRisks', '高危及严重'), color: '#ef4444' } + ]); +} + +function renderAssetLineChart(rootId, summaryId, points, series) { + const root = document.getElementById(rootId); + if (!root) return; + const data = Array.isArray(points) ? points : []; + const totals = series.map(item => data.reduce((sum, point) => sum + Number(point[item.key] || 0), 0)); + const summaryRoot = document.getElementById(summaryId); + const summaryContent = series.map((item, index) => `
${escapeHtml(item.label)}${totals[index].toLocaleString()}
`).join(''); + if (summaryRoot) summaryRoot.innerHTML = summaryContent; + if (!data.length) { + root.innerHTML = '
' + escapeHtml(assetT('common.noData', '暂无数据')) + '
'; + return; + } + const width = 680, height = 220, left = 34, right = 12, top = 18, bottom = 28; + const plotWidth = width - left - right, plotHeight = height - top - bottom; + const maxValue = Math.max(1, ...data.flatMap(point => series.map(item => Number(point[item.key] || 0)))); + const x = index => left + (data.length === 1 ? plotWidth / 2 : index / (data.length - 1) * plotWidth); + const y = value => top + plotHeight - (Number(value || 0) / maxValue * plotHeight); + const grid = [0, .25, .5, .75, 1].map(ratio => { + const gy = top + plotHeight * ratio; + const label = Math.round(maxValue * (1 - ratio)); + return `${label}`; + }).join(''); + const paths = series.map(item => { + const coords = data.map((point, index) => `${x(index).toFixed(1)},${y(point[item.key]).toFixed(1)}`); + const line = ``; + if (!item.fill || !coords.length) return line; + const area = `M ${x(0).toFixed(1)} ${top + plotHeight} L ${coords.join(' L ')} L ${x(data.length - 1).toFixed(1)} ${top + plotHeight} Z`; + return `${line}`; + }).join(''); + const labelIndexes = [...new Set([0, Math.floor((data.length - 1) / 2), data.length - 1])]; + const labels = labelIndexes.map(index => `${escapeHtml(String(data[index].date || '').slice(5))}`).join(''); + const aria = series.map((item, index) => `${item.label} ${totals[index]}`).join(','); + root.innerHTML = `${grid}${labels}${paths}`; +} + +function renderAssetCoverage(coverage, total) { + const rate = Math.max(0, Math.min(100, Number(coverage.rate || 0))); + const recentRate = Math.max(0, Math.min(100, Number(coverage.recent_rate || 0))); + const values = { + scanned: Number(coverage.scanned || 0), + recent: Number(coverage.scanned_30d || 0), + never: Number(coverage.never_scanned || 0), + stale: Number(coverage.stale || 0) + }; + const gauge = document.getElementById('asset-coverage-gauge'); + if (gauge) gauge.style.setProperty('--asset-coverage-value', rate + '%'); + const setText = (id, value) => { const el = document.getElementById(id); if (el) el.textContent = value; }; + setText('asset-coverage-rate', rate + '%'); + setText('asset-coverage-scanned', values.scanned.toLocaleString()); + setText('asset-coverage-recent', values.recent.toLocaleString()); + setText('asset-coverage-recent-rate', assetT('assets.coverageOfTotal', `占全部资产 ${recentRate}%`, { percent: recentRate })); + setText('asset-coverage-never', values.never.toLocaleString()); + setText('asset-coverage-stale', values.stale.toLocaleString()); + setText('asset-coverage-status', total ? assetT('assets.coverageMeta', `${values.scanned} / ${total} 已覆盖`, { scanned: values.scanned, total }) : '—'); +} + +function renderAssetProtocolChart(items, total) { + const root = document.getElementById('asset-protocol-chart'); + const summary = document.getElementById('asset-protocol-summary'); + const meta = document.getElementById('asset-protocol-meta'); + const topList = document.getElementById('asset-protocol-top-list'); + if (!root) return; + if (!items.length) { + root.innerHTML = '
' + escapeHtml(assetT('common.noData', '暂无数据')) + '
'; + if (summary) summary.hidden = true; + if (topList) topList.innerHTML = ''; + if (meta) meta.textContent = assetT('assets.protocolKinds', '0 种协议', { count: 0 }); + return; + } + if (summary) summary.hidden = false; + if (meta) meta.textContent = assetT('assets.protocolKinds', `${items.length} 种协议`, { count: items.length }); + const max = Math.max(...items.map(item => Number(item.count || 0)), 1); + const protocolTotal = items.reduce((sum, item) => sum + Number(item.count || 0), 0); + const leading = items.reduce((best, item) => Number(item.count || 0) > Number(best.count || 0) ? item : best, items[0]); + const leadingCount = Number(leading.count || 0); + const leadingPercent = (total || protocolTotal) ? Math.round(leadingCount / (total || protocolTotal) * 100) : 0; + const colors = ['#4f7df3', '#6d5df6', '#13b8a6', '#f59e0b', '#ec4899', '#06b6d4', '#8b5cf6', '#94a3b8']; + if (summary) { + const donut = summary.querySelector('.asset-protocol-donut'); + const percentNode = donut?.querySelector('strong'); + const leadName = summary.querySelector('.asset-protocol-lead strong'); + const leadCount = summary.querySelector('.asset-protocol-lead small'); + if (donut) { + const denominator = Math.max(total || protocolTotal, 1); + let cursor = 0; + const stops = items.map((item, index) => { + const start = cursor; + cursor = Math.min(100, cursor + Number(item.count || 0) / denominator * 100); + return `${colors[index % colors.length]} ${start.toFixed(2)}% ${cursor.toFixed(2)}%`; + }); + if (cursor < 100) stops.push(`var(--border-color,#e5e7eb) ${cursor.toFixed(2)}% 100%`); + donut.style.background = `conic-gradient(from -90deg,${stops.join(',')})`; + } + if (percentNode) percentNode.textContent = leadingPercent + '%'; + if (leadName) leadName.textContent = leading.name || 'unknown'; + if (leadCount) leadCount.textContent = assetT('assets.assetCountUnit', `${leadingCount} 个资产`, { count: leadingCount }); + } + if (topList) { + topList.innerHTML = items.slice(0, 3).map((item, index) => { + const count = Number(item.count || 0); + const percent = total ? count / total * 100 : 0; + const displayPercent = percent > 0 && percent < 1 ? '<1%' : Math.round(percent) + '%'; + return `
${escapeHtml(item.name || 'unknown')}${escapeHtml(displayPercent)}
`; + }).join(''); + } + root.innerHTML = items.map((item, index) => { + const count = Number(item.count || 0); + const width = Math.max(3, Math.round(count / max * 100)); + const percent = total ? count / total * 100 : 0; + const displayPercent = percent > 0 && percent < 1 ? '<1%' : Math.round(percent) + '%'; + const color = colors[index % colors.length]; + return `
${String(index + 1).padStart(2, '0')}${escapeHtml(item.name || 'unknown')}
${count}${displayPercent}
`; + }).join(''); +} + +async function loadAssets(page) { + // 无显式页码表示进入资产库或点击顶部“刷新”,此时同步项目筛选项。 + // 翻页、搜索等带页码的操作继续复用缓存,避免重复请求项目列表。 + await ensureAssetProjects(page == null); + assetPageState.page = Number(page || assetPageState.page || 1); + const params = new URLSearchParams({ page: assetPageState.page, page_size: assetPageState.pageSize }); + const q = document.getElementById('asset-search')?.value.trim() || ''; + const status = document.getElementById('asset-status-filter')?.value || ''; + const projectId = document.getElementById('asset-project-filter')?.value || ''; + if (q) params.set('q', q); + if (status) params.set('status', status); + if (projectId) params.set('project_id', projectId); + const body = document.getElementById('asset-table-body'); + if (body) body.innerHTML = '' + escapeHtml(assetT('common.loading', '加载中...')) + ''; + try { + const response = await apiFetch('/api/assets?' + params.toString()); + if (!response.ok) throw new Error(await response.text()); + const data = await response.json(); + assetPageState.items = data.assets || []; + assetPageState.total = data.total || 0; + assetPageState.totalPages = data.total_pages || 1; + assetPageState.page = data.page || 1; + if (assetPageState.page > assetPageState.totalPages) { + return loadAssets(assetPageState.totalPages); + } + renderAssetRows(); + updateAssetSelectionUI(); + renderAssetPagination(); + const meta = document.getElementById('asset-list-meta'); + if (meta) meta.textContent = assetT('assets.totalMeta', `共 ${data.total || 0} 条`, { count: data.total || 0 }); + } catch (error) { + console.error('加载资产失败:', error); + assetPageState.items = []; + assetPageState.total = 0; + assetPageState.totalPages = 1; + if (body) body.innerHTML = '' + escapeHtml(assetT('assets.loadFailed', '加载资产失败')) + ''; + renderAssetPagination(); + } +} + +function assetTargetLabel(asset) { + return asset.host || asset.domain || asset.ip || '-'; +} + +function assetRiskPresentation(level) { + const normalized = ['critical', 'high', 'medium', 'low', 'info', 'normal'].includes(level) ? level : 'unassessed'; + const labels = { + critical: assetT('assets.riskCritical', '严重'), + high: assetT('assets.riskHigh', '高危'), + medium: assetT('assets.riskMedium', '中危'), + low: assetT('assets.riskLow', '低危'), + info: assetT('assets.riskInfo', '提示'), + normal: assetT('assets.riskNormal', '正常'), + unassessed: assetT('assets.riskUnassessed', '未评估') + }; + return { level: normalized, label: labels[normalized] }; +} + +function renderAssetRows() { + const body = document.getElementById('asset-table-body'); + if (!body) return; + if (!assetPageState.items.length) { + body.innerHTML = '' + escapeHtml(assetT('common.noData', '暂无数据')) + ''; + return; + } + body.innerHTML = assetPageState.items.map((asset, index) => { + const service = [asset.protocol, asset.port ? ':' + asset.port : ''].join('') || '-'; + const targetHint = [asset.host, asset.ip, asset.domain].filter(Boolean).filter((value, i, values) => values.indexOf(value) === i).join(' · '); + const lastScan = asset.last_scan_at ? new Date(asset.last_scan_at).toLocaleString() : '-'; + const vulnerabilityCount = Number(asset.vulnerability_count || 0); + const risk = assetRiskPresentation(asset.risk_level); + const statusLabel = asset.status === 'inactive' ? assetT('assets.statusInactive', '停用') : assetT('assets.statusActive', '活跃'); + return ` + + + ${escapeHtml(service)} + ${asset.project_name ? `${escapeHtml(asset.project_name)}` : '-'} + ${escapeHtml(lastScan)}${vulnerabilityCount > 0 ? `` : '0'} + ${escapeHtml(risk.label)} + ${escapeHtml(statusLabel)} + + `; + }).join(''); + if (typeof applyRBACToUI === 'function') applyRBACToUI(body); +} + +function toggleAssetSelection(index, checked) { + const asset = assetPageState.items[Number(index)]; + if (!asset) return; + if (checked) assetPageState.selected.set(asset.id, asset); + else assetPageState.selected.delete(asset.id); + updateAssetSelectionUI(); +} + +function toggleAssetPageSelection(checked) { + assetPageState.items.forEach(asset => { + if (checked) assetPageState.selected.set(asset.id, asset); + else assetPageState.selected.delete(asset.id); + }); + renderAssetRows(); + updateAssetSelectionUI(); +} + +function clearAssetSelection() { + assetPageState.selected.clear(); + renderAssetRows(); + updateAssetSelectionUI(); +} + +function updateAssetSelectionUI() { + const count = assetPageState.selected.size; + const actions = document.getElementById('asset-batch-actions'); + const label = document.getElementById('asset-selected-count'); + if (actions) actions.hidden = count === 0; + if (label) label.textContent = assetT('assets.selectedCount', `已选择 ${count} 项`, { count }); + const pageToggle = document.getElementById('asset-select-page'); + if (pageToggle) { + const selectedOnPage = assetPageState.items.filter(asset => assetPageState.selected.has(asset.id)).length; + pageToggle.checked = assetPageState.items.length > 0 && selectedOnPage === assetPageState.items.length; + pageToggle.indeterminate = selectedOnPage > 0 && selectedOnPage < assetPageState.items.length; + } +} + +async function openAssetProjectModal() { + const assets = Array.from(assetPageState.selected.values()); + if (!assets.length) { + alert(assetT('assets.selectAssetsFirst', '请先选择资产')); + return; + } + await ensureAssetProjects(true); + populateAssetProjectSelects(); + const select = document.getElementById('asset-batch-project'); + const projectIds = Array.from(new Set(assets.map(asset => asset.project_id || ''))); + if (select) { + select.value = projectIds.length === 1 && projectIds[0] ? projectIds[0] : ''; + if (typeof syncSettingsCustomSelect === 'function') syncSettingsCustomSelect(select); + } + const subtitle = document.getElementById('asset-project-subtitle'); + if (subtitle) subtitle.textContent = assetT('assets.bindProjectCount', `将更新 ${assets.length} 个资产`, { count: assets.length }); + if (typeof openAppModal === 'function') openAppModal('asset-project-modal'); + else document.getElementById('asset-project-modal').style.display = 'flex'; + if (select) select.focus(); +} + +function closeAssetProjectModal() { + if (typeof closeAppModal === 'function') closeAppModal('asset-project-modal'); + else document.getElementById('asset-project-modal').style.display = 'none'; +} + +async function submitAssetProjectBinding() { + const ids = Array.from(assetPageState.selected.keys()); + if (!ids.length) return; + const selectedProject = document.getElementById('asset-batch-project')?.value || ''; + if (!selectedProject) { + alert(assetT('assets.selectProjectRequired', '请选择要绑定的项目')); + return; + } + const projectId = selectedProject; + const button = document.getElementById('asset-project-submit'); + if (button) button.disabled = true; + try { + const response = await apiFetch('/api/assets/project-binding', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ asset_ids: ids, project_id: projectId }) + }); + if (!response.ok) throw new Error(await assetEditorResponseError(response)); + closeAssetProjectModal(); + clearAssetSelection(); + await loadAssets(assetPageState.page); + if (typeof showInlineToast === 'function') { + showInlineToast(assetT('assets.bindProjectDone', `已绑定 ${ids.length} 个资产`, { count: ids.length })); + } + } catch (error) { + alert(assetT('assets.bindProjectFailed', '绑定项目失败') + ': ' + error.message); + } finally { + if (button) button.disabled = false; + } +} + +function assetScanPromptDefault() { + const placeholders = { asset_id: '{{asset_id}}', target: '{{target}}', host: '{{host}}', ip: '{{ip}}', domain: '{{domain}}', port: '{{port}}' }; + return assetT('assets.defaultScanPrompt', '请对资产 {{target}}(资产ID:{{asset_id}})进行授权安全扫描,优先检查暴露服务、已知漏洞、弱口令和常见 Web 风险;通过 record_vulnerability 保存确认的漏洞,完成后调用 complete_asset_scan(id={{asset_id}}) 回写上次扫描时间和相关漏洞。', placeholders); +} + +function openAssetScanModal(mode, index) { + const one = Number.isInteger(index) ? assetPageState.items[index] : null; + const assets = one ? [one] : Array.from(assetPageState.selected.values()); + if (!assets.length) { + alert(assetT('assets.selectAssetsFirst', '请先选择资产')); + return; + } + assetPageState.scanMode = mode === 'task' ? 'task' : 'chat'; + assetPageState.scanAssets = assets; + const taskMode = assetPageState.scanMode === 'task'; + document.getElementById('asset-scan-title').textContent = taskMode ? assetT('assets.createScanTask', '创建扫描任务') : assetT('assets.sendToChat', '发送到对话'); + document.getElementById('asset-scan-subtitle').textContent = assetT('assets.scanAssetCount', `${assets.length} 个资产`, { count: assets.length }); + document.getElementById('asset-scan-targets').innerHTML = assets.slice(0, 12).map(asset => `${escapeHtml(assetTargetLabel(asset))}`).join('') + (assets.length > 12 ? `+${assets.length - 12}` : ''); + document.getElementById('asset-scan-prompt').value = assetScanPromptDefault(); + document.getElementById('asset-scan-hint').textContent = assetT('assets.promptHint', '可使用 {{asset_id}}、{{target}}、{{host}}、{{ip}}、{{domain}}、{{port}} 占位符;创建任务时会为每个资产生成一条任务。', { asset_id: '{{asset_id}}', target: '{{target}}', host: '{{host}}', ip: '{{ip}}', domain: '{{domain}}', port: '{{port}}' }); + const executeWrap = document.getElementById('asset-scan-execute-wrap'); + executeWrap.hidden = !taskMode; + document.getElementById('asset-scan-submit').textContent = taskMode ? assetT('assets.confirmCreate', '创建任务') : assetT('assets.confirmSend', '确认发送'); + if (typeof openAppModal === 'function') openAppModal('asset-scan-modal'); + else document.getElementById('asset-scan-modal').style.display = 'flex'; +} + +function closeAssetScanModal() { + if (typeof closeAppModal === 'function') closeAppModal('asset-scan-modal'); + else document.getElementById('asset-scan-modal').style.display = 'none'; +} + +function renderAssetScanPrompt(template, asset) { + const values = { + asset_id: asset.id || '', target: assetTargetLabel(asset), host: asset.host || '', ip: asset.ip || '', domain: asset.domain || '', port: asset.port || '' + }; + return Object.keys(values).reduce((text, key) => text.replaceAll(`{{${key}}}`, String(values[key])), template); +} + +function commonAssetProjectId(assets) { + const ids = Array.from(new Set(assets.map(asset => asset.project_id || ''))); + return ids.length === 1 ? ids[0] : ''; +} + +async function recordAssetScanLinks(scans) { + const response = await apiFetch('/api/assets/scan-links', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scans }) }); + if (!response.ok) throw new Error(await response.text()); +} + +async function submitAssetScan() { + const assets = assetPageState.scanAssets.slice(); + const template = document.getElementById('asset-scan-prompt').value.trim(); + if (!assets.length || !template) { + alert(assetT('assets.promptRequired', '请输入用户提示词')); + return; + } + const button = document.getElementById('asset-scan-submit'); + button.disabled = true; + try { + if (assetPageState.scanMode === 'task') { + await createAssetScanTasks(assets, template); + } else { + await sendAssetsToChat(assets, template); + } + closeAssetScanModal(); + clearAssetSelection(); + await loadAssets(assetPageState.page); + } catch (error) { + console.error('提交资产扫描失败:', error); + alert(assetT('assets.scanSubmitFailed', '提交扫描失败') + ': ' + error.message); + } finally { + button.disabled = false; + } +} + +async function sendAssetsToChat(assets, template) { + const targets = assets.map(assetTargetLabel).join(', '); + const message = assets.map(asset => renderAssetScanPrompt(template, asset)).join('\n\n---\n\n'); + const response = await apiFetch('/api/conversations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: assetT('assets.scanConversationTitle', `资产扫描:${targets}`, { targets }), projectId: commonAssetProjectId(assets) }) }); + if (!response.ok) throw new Error(await response.text()); + const conversation = await response.json(); + await recordAssetScanLinks(assets.map(asset => ({ asset_id: asset.id, conversation_id: conversation.id }))); + switchPage('chat'); + await loadConversation(conversation.id); + const input = document.getElementById('chat-input'); + input.value = message; + if (typeof adjustTextareaHeight === 'function') adjustTextareaHeight(input); + // 消息流可能持续很久;启动发送即可返回,让提交弹窗立即关闭。 + void sendMessage(); +} + +async function createAssetScanTasks(assets, template) { + const tasks = assets.map(asset => renderAssetScanPrompt(template, asset)); + const executeNow = !!document.getElementById('asset-scan-execute-now').checked; + const response = await apiFetch('/api/batch-tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: assetT('assets.scanQueueTitle', '资产批量扫描'), tasks, executeNow, projectId: commonAssetProjectId(assets), concurrency: 1, agentMode: 'eino_single', scheduleMode: 'manual' }) }); + if (!response.ok) throw new Error(await response.text()); + const result = await response.json(); + const queueTasks = result.queue && Array.isArray(result.queue.tasks) ? result.queue.tasks : []; + if (queueTasks.length !== assets.length) throw new Error(assetT('assets.scanTaskLinkFailed', '任务已创建,但资产关联失败')); + await recordAssetScanLinks(assets.map((asset, index) => ({ asset_id: asset.id, queue_id: result.queueId, task_id: queueTasks[index].id }))); + switchPage('tasks'); + if (typeof showBatchQueueDetail === 'function') showBatchQueueDetail(result.queueId); +} + +function openAssetVulnerabilities(index) { + const asset = assetPageState.items[Number(index)]; + if (!asset) return; + if (asset.last_scan_task_id) { + window.location.hash = `vulnerabilities?task_id=${encodeURIComponent(asset.last_scan_task_id)}`; + return; + } + window.location.hash = asset.last_scan_conversation_id + ? `vulnerabilities?conversation_id=${encodeURIComponent(asset.last_scan_conversation_id)}` + : 'vulnerabilities'; +} + +function renderAssetPagination() { + const root = document.getElementById('asset-pagination'); + if (!root) return; + const page = assetPageState.page; + const totalPages = assetPageState.totalPages || 1; + const total = assetPageState.total || 0; + const pageSize = assetPageState.pageSize; + const start = total === 0 ? 0 : (page - 1) * pageSize + 1; + const end = total === 0 ? 0 : Math.min(page * pageSize, total); + const atFirst = page <= 1 || total === 0; + const atLast = page >= totalPages || total === 0; + root.innerHTML = ``; + syncAssetSelect('asset-page-size-pagination'); +} + +function changeAssetPageSize() { + const select = document.getElementById('asset-page-size-pagination'); + const size = Number(select?.value); + if (![10, 20, 50, 100].includes(size)) return; + assetPageState.pageSize = size; + try { localStorage.setItem(ASSET_PAGE_SIZE_KEY, String(size)); } catch (error) { /* ignore */ } + loadAssets(1); +} + +async function ensureAssetProjects(force) { + if (assetPageState.projectsLoaded && !force) return; + try { + const response = await apiFetch('/api/projects?limit=500'); + if (!response.ok) throw new Error(await response.text()); + const data = await response.json(); + assetPageState.projects = data.projects || []; + assetPageState.projectsLoaded = true; + populateAssetProjectSelects(); + } catch (error) { + console.warn('加载资产项目选项失败:', error); + assetPageState.projectsLoaded = true; + } +} + +function populateAssetProjectSelects() { + const configs = [ + ['asset-project-filter', assetT('assets.allProjects', '全部项目')], + ['asset-edit-project', assetT('assets.unboundProject', '暂不绑定')] + ]; + configs.forEach(([id, emptyLabel]) => { + const el = document.getElementById(id); + if (!el) return; + const current = el.value; + el.innerHTML = `` + assetPageState.projects.map(project => ``).join(''); + el.value = current; + syncAssetSelect(el); + }); + const batch = document.getElementById('asset-batch-project'); + if (batch) { + const current = batch.value; + batch.innerHTML = `` + assetPageState.projects.map(project => ``).join(''); + batch.value = current; + syncAssetSelect(batch); + } +} + +async function openAssetEditor(indexOrAsset) { + // 项目可能在资产页首次加载后被新增、编辑或归档,打开编辑器时重新拉取, + // 避免下拉框长期复用 projectsLoaded 缓存而只能通过整页刷新更新。 + await ensureAssetProjects(true); + const isIndex = Number.isInteger(indexOrAsset); + const asset = isIndex ? assetPageState.items[indexOrAsset] : (indexOrAsset && typeof indexOrAsset === 'object' ? indexOrAsset : null); + assetPageState.editIndex = isIndex && asset ? indexOrAsset : -1; + assetPageState.editAsset = asset; + assetPageState.editorReturnFocus = document.activeElement; + document.getElementById('asset-edit-id').value = asset?.id || ''; + document.getElementById('asset-edit-host').value = asset?.host || ''; + document.getElementById('asset-edit-ip').value = asset?.ip || ''; + document.getElementById('asset-edit-domain').value = asset?.domain || ''; + document.getElementById('asset-edit-port').value = asset?.port || ''; + document.getElementById('asset-edit-protocol').value = asset?.protocol || ''; + document.getElementById('asset-edit-server').value = asset?.server || ''; + document.getElementById('asset-edit-project').value = asset?.project_id || ''; + document.getElementById('asset-edit-country').value = asset?.country || ''; + document.getElementById('asset-edit-province').value = asset?.province || ''; + document.getElementById('asset-edit-city').value = asset?.city || ''; + document.getElementById('asset-edit-title-value').value = asset?.title || ''; + document.getElementById('asset-edit-tags').value = ''; + assetPageState.editorTags = Array.from(new Set((asset?.tags || []).map(value => String(value).trim()).filter(Boolean))); + renderAssetEditorTags(); + document.getElementById('asset-edit-status').value = asset?.status || 'active'; + syncAssetSelect('asset-edit-project'); + syncAssetSelect('asset-edit-status'); + document.getElementById('asset-edit-target').value = assetEditorTargetFromAsset(asset); + assetPageState.editorParsedTarget = document.getElementById('asset-edit-target').value.trim(); + clearAssetEditorErrors(); + document.getElementById('asset-editor-title').textContent = asset ? assetT('assets.editAssetTitle', '编辑资产') : assetT('assets.addAssetTitle', '新增资产'); + const submit = document.getElementById('asset-editor-submit'); + submit.textContent = asset ? assetT('common.save', '保存') : assetT('assets.addAssetAction', '添加资产'); + ensureAssetEditorInteractions(); + assetPageState.editorDirty = false; + assetPageState.editorBusy = false; + setAssetEditorBusy(false); + if (typeof openAppModal === 'function') openAppModal('asset-editor-modal', { focusEl: document.getElementById('asset-edit-target') }); + else document.getElementById('asset-editor-modal').style.display = 'flex'; +} + +function closeAssetEditor(force) { + if (!force && assetPageState.editorDirty && !confirm(assetT('assets.discardChanges', '放弃尚未保存的更改吗?'))) return; + if (typeof closeAppModal === 'function') closeAppModal('asset-editor-modal'); + else document.getElementById('asset-editor-modal').style.display = 'none'; + const returnFocus = assetPageState.editorReturnFocus; + assetPageState.editorDirty = false; + if (returnFocus && typeof returnFocus.focus === 'function') requestAnimationFrame(() => returnFocus.focus()); +} + +function assetEditorTargetFromAsset(asset) { + if (!asset) return ''; + if (asset.host) return String(asset.host); + const target = asset.domain || asset.ip || ''; + if (!target) return ''; + const wrapped = String(target).includes(':') && !String(target).startsWith('[') ? `[${target}]` : target; + return `${wrapped}${Number(asset.port || 0) > 0 ? ':' + Number(asset.port) : ''}`; +} + +function assetEditorDefaultPort(protocol) { + return ({ http: 80, https: 443, ssh: 22, ftp: 21, smtp: 25, rdp: 3389, mysql: 3306, postgresql: 5432, redis: 6379, mongodb: 27017 })[protocol] || 0; +} + +function assetEditorProtocolForPort(port) { + return ({ 80: 'http', 443: 'https', 22: 'ssh', 21: 'ftp', 25: 'smtp', 3389: 'rdp', 3306: 'mysql', 5432: 'postgresql', 6379: 'redis', 27017: 'mongodb' })[port] || ''; +} + +function assetEditorIsIPv4(value) { + const parts = String(value).split('.'); + return parts.length === 4 && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255); +} + +function assetEditorIsIPv6(value) { + const candidate = String(value).replace(/^\[|\]$/g, ''); + if (!candidate.includes(':') || !/^[0-9a-f:.]+$/i.test(candidate)) return false; + try { return new URL(`http://[${candidate}]/`).hostname.length > 2; } catch (error) { return false; } +} + +function assetEditorNormalizeDomain(value) { + const raw = String(value || '').trim().replace(/\.$/, ''); + if (!raw || raw.length > 253 || raw.includes('_') || /[\/?#@]/.test(raw)) return ''; + try { + const hostname = new URL(`http://${raw}/`).hostname.toLowerCase().replace(/\.$/, ''); + if (!hostname || hostname.length > 253 || hostname.split('.').some(label => !label || label.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label))) return ''; + return hostname; + } catch (error) { return ''; } +} + +function parseAssetEditorTarget(value) { + const raw = String(value || '').trim(); + if (!raw) throw new Error(assetT('assets.targetRequired', '请输入资产地址')); + const opaqueTarget = () => ({ host: raw, ip: '', domain: '', port: 0, protocol: '' }); + let hostname = ''; + let port = 0; + let protocol = ''; + let host = ''; + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) { + let parsed; + try { parsed = new URL(raw); } catch (error) { return opaqueTarget(); } + if (!parsed.hostname || parsed.username || parsed.password) return opaqueTarget(); + protocol = parsed.protocol.replace(':', '').toLowerCase(); + hostname = parsed.hostname.replace(/^\[|\]$/g, ''); + port = parsed.port ? Number(parsed.port) : assetEditorDefaultPort(protocol); + host = raw; + } else { + let authority = raw.replace(/\/$/, ''); + const bracketed = authority.match(/^\[([^\]]+)](?::(\d+))?$/); + if (bracketed) { + hostname = bracketed[1]; + port = Number(bracketed[2] || 0); + } else if ((authority.match(/:/g) || []).length === 1 && /:\d+$/.test(authority)) { + const splitAt = authority.lastIndexOf(':'); + hostname = authority.slice(0, splitAt); + port = Number(authority.slice(splitAt + 1)); + } else { + hostname = authority; + } + protocol = assetEditorProtocolForPort(port); + } + if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(assetT('assets.portInvalid', '端口必须在 1–65535 之间')); + const isIP = assetEditorIsIPv4(hostname) || assetEditorIsIPv6(hostname); + const domain = isIP ? '' : assetEditorNormalizeDomain(hostname); + if (!isIP && (!domain || assetEditorIsIPv4(domain) || assetEditorIsIPv6(domain))) return opaqueTarget(); + return { host, ip: isIP ? hostname.toLowerCase() : '', domain, port, protocol }; +} + +function applyAssetEditorTarget(showError) { + const input = document.getElementById('asset-edit-target'); + try { + const parsed = parseAssetEditorTarget(input.value); + document.getElementById('asset-edit-host').value = parsed.host; + document.getElementById('asset-edit-ip').value = parsed.ip; + document.getElementById('asset-edit-domain').value = parsed.domain; + document.getElementById('asset-edit-port').value = parsed.port || ''; + document.getElementById('asset-edit-protocol').value = parsed.protocol; + assetPageState.editorParsedTarget = input.value.trim(); + setAssetEditorFieldError('asset-edit-target', ''); + return parsed; + } catch (error) { + if (showError) setAssetEditorFieldError('asset-edit-target', error.message); + return null; + } +} + +function setAssetEditorFieldError(inputId, message) { + const input = document.getElementById(inputId); + const error = document.getElementById(inputId + '-error'); + if (input) input.setAttribute('aria-invalid', message ? 'true' : 'false'); + if (error) { error.textContent = message || ''; error.hidden = !message; } +} + +function setAssetEditorFormError(message) { + const error = document.getElementById('asset-editor-form-error'); + if (!error) return; + error.textContent = message || ''; + error.hidden = !message; +} + +function clearAssetEditorErrors() { + ['asset-edit-target', 'asset-edit-host', 'asset-edit-ip', 'asset-edit-domain', 'asset-edit-port', 'asset-edit-protocol'].forEach(id => setAssetEditorFieldError(id, '')); + setAssetEditorFormError(''); +} + +function addAssetEditorTags(raw) { + String(raw || '').split(/[,,]/).map(value => value.trim()).filter(Boolean).forEach(tag => { + if (!assetPageState.editorTags.includes(tag) && assetPageState.editorTags.length < 30) assetPageState.editorTags.push(tag); + }); + document.getElementById('asset-edit-tags').value = ''; + assetPageState.editorDirty = true; + renderAssetEditorTags(); +} + +function removeAssetEditorTag(index) { + assetPageState.editorTags.splice(Number(index), 1); + assetPageState.editorDirty = true; + renderAssetEditorTags(); +} + +function renderAssetEditorTags() { + const root = document.getElementById('asset-tag-chips'); + if (!root) return; + root.replaceChildren(...assetPageState.editorTags.map((tag, index) => { + const chip = document.createElement('span'); + chip.className = 'asset-tag-chip'; + const text = document.createElement('span'); + text.textContent = tag; + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = '×'; + button.setAttribute('aria-label', assetT('assets.removeTag', `移除标签 ${tag}`, { tag })); + button.onclick = () => removeAssetEditorTag(index); + chip.append(text, button); + return chip; + })); +} + +function ensureAssetEditorInteractions() { + if (assetPageState.editorInteractionsReady) return; + assetPageState.editorInteractionsReady = true; + const form = document.getElementById('asset-editor-form'); + const target = document.getElementById('asset-edit-target'); + const tagInput = document.getElementById('asset-edit-tags'); + form.addEventListener('input', event => { + if (event.target !== tagInput) assetPageState.editorDirty = true; + if (event.target === target) setAssetEditorFieldError('asset-edit-target', ''); + setAssetEditorFormError(''); + }); + target.addEventListener('blur', () => { if (target.value.trim()) applyAssetEditorTarget(true); }); + tagInput.addEventListener('keydown', event => { + if ((event.key === 'Enter' || event.key === ',') && tagInput.value.trim()) { event.preventDefault(); addAssetEditorTags(tagInput.value); } + else if (event.key === 'Backspace' && !tagInput.value && assetPageState.editorTags.length) removeAssetEditorTag(assetPageState.editorTags.length - 1); + }); + tagInput.addEventListener('blur', () => { if (tagInput.value.trim()) addAssetEditorTags(tagInput.value); }); + document.addEventListener('keydown', event => { + if (typeof isAppModalOpen !== 'function' || !isAppModalOpen('asset-editor-modal')) return; + if (event.key === 'Escape') { event.preventDefault(); closeAssetEditor(); return; } + if (event.key !== 'Tab') return; + const focusable = Array.from(form.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), summary, [tabindex]:not([tabindex="-1"])')).filter(el => !el.hidden && el.offsetParent !== null); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } + }); +} + +function collectAssetEditor() { + const existing = assetPageState.editAsset || {}; + return { + host: document.getElementById('asset-edit-host').value.trim(), ip: document.getElementById('asset-edit-ip').value.trim(), + domain: document.getElementById('asset-edit-domain').value.trim(), port: Number(document.getElementById('asset-edit-port').value || 0), + protocol: document.getElementById('asset-edit-protocol').value.trim(), server: document.getElementById('asset-edit-server').value.trim(), + title: document.getElementById('asset-edit-title-value').value.trim(), status: document.getElementById('asset-edit-status').value, + source: existing.source || 'manual', source_query: existing.source_query || '', + country: document.getElementById('asset-edit-country').value.trim(), province: document.getElementById('asset-edit-province').value.trim(), city: document.getElementById('asset-edit-city').value.trim(), + project_id: document.getElementById('asset-edit-project').value, + tags: assetPageState.editorTags.slice() + }; +} + +function validateAssetEditor() { + clearAssetEditorErrors(); + const target = document.getElementById('asset-edit-target').value.trim(); + let parsed = null; + if (target !== assetPageState.editorParsedTarget) parsed = applyAssetEditorTarget(true); + else { + try { parsed = parseAssetEditorTarget(target); } + catch (error) { setAssetEditorFieldError('asset-edit-target', error.message); } + } + if (!parsed) { document.getElementById('asset-edit-target').focus(); return false; } + const failAdvanced = (id, message) => { + setAssetEditorFieldError(id, message); + requestAnimationFrame(() => document.getElementById(id).focus()); + return false; + }; + const ip = document.getElementById('asset-edit-ip').value.trim(); + if (ip && !assetEditorIsIPv4(ip) && !assetEditorIsIPv6(ip)) return failAdvanced('asset-edit-ip', assetT('assets.ipInvalid', 'IP 地址格式无效')); + const domainInput = document.getElementById('asset-edit-domain'); + const domain = domainInput.value.trim(); + if (domain) { + const normalizedDomain = assetEditorNormalizeDomain(domain); + if (!normalizedDomain) return failAdvanced('asset-edit-domain', assetT('assets.domainInvalid', '域名格式无效')); + domainInput.value = normalizedDomain; + } + const portInput = document.getElementById('asset-edit-port'); + if (portInput.value !== '' && (!/^\d+$/.test(portInput.value) || Number(portInput.value) < 1 || Number(portInput.value) > 65535)) { + return failAdvanced('asset-edit-port', assetT('assets.portInvalid', '端口必须在 1–65535 之间')); + } + const protocol = document.getElementById('asset-edit-protocol').value.trim().toLowerCase(); + if (protocol && !/^[a-z][a-z0-9+.-]{0,31}$/.test(protocol)) return failAdvanced('asset-edit-protocol', assetT('assets.protocolInvalid', '协议格式无效')); + document.getElementById('asset-edit-protocol').value = protocol; + return true; +} + +function setAssetEditorBusy(busy) { + assetPageState.editorBusy = Boolean(busy); + const submit = document.getElementById('asset-editor-submit'); + if (!submit) return; + submit.disabled = Boolean(busy); + submit.classList.toggle('asset-editor-submit-busy', Boolean(busy)); +} + +async function assetEditorResponseError(response) { + const text = await response.text(); + try { return JSON.parse(text).error || text; } catch (error) { return text; } +} + +async function saveAsset() { + if (assetPageState.editorBusy || !validateAssetEditor()) return; + const pendingTag = document.getElementById('asset-edit-tags').value.trim(); + if (pendingTag) addAssetEditorTags(pendingTag); + const id = document.getElementById('asset-edit-id').value; + const asset = collectAssetEditor(); + setAssetEditorBusy(true); + try { + const response = id + ? await apiFetch('/api/assets/' + encodeURIComponent(id), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(asset) }) + : await apiFetch('/api/assets/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ assets: [asset], source: 'manual' }) }); + if (!response.ok) throw new Error(await assetEditorResponseError(response)); + const result = await response.json(); + if (!id && Number(result.skipped || 0) > 0 && Number(result.created || 0) === 0 && Number(result.updated || 0) === 0) { + throw new Error(assetT('assets.assetSkipped', '资产未保存,可能已存在且你没有更新权限')); + } + closeAssetEditor(true); + await loadAssets(id ? assetPageState.page : 1); + const projectAssetsPanel = document.getElementById('project-panel-assets'); + if (projectAssetsPanel && !projectAssetsPanel.hidden && typeof window.loadProjectAssets === 'function') await window.loadProjectAssets(); + const message = id + ? assetT('assets.updatedSuccessfully', '资产已更新') + : Number(result.updated || 0) > 0 + ? assetT('assets.duplicateMerged', '资产已存在,信息已安全合并') + : assetT('assets.createdSuccessfully', '资产已添加'); + if (typeof showInlineToast === 'function') showInlineToast(message); + } catch (error) { + setAssetEditorFormError(assetT('assets.saveFailed', '保存资产失败') + ': ' + error.message); + } finally { + setAssetEditorBusy(false); + } +} + +async function deleteAsset(index) { + const asset = assetPageState.items[index]; + if (!asset || !confirm(assetT('assets.deleteConfirm', '确定删除该资产吗?'))) return; + const response = await apiFetch('/api/assets/' + encodeURIComponent(asset.id), { method: 'DELETE' }); + if (!response.ok) { + alert(assetT('assets.deleteFailed', '删除资产失败')); + return; + } + await loadAssets(assetPageState.page); +} + +function fofaResultToAsset(row, fields) { + const value = name => { + if (row && !Array.isArray(row) && typeof row === 'object') { + return row[name] != null ? String(row[name]).trim() : ''; + } + const idx = Array.isArray(fields) ? fields.indexOf(name) : -1; + return idx >= 0 && Array.isArray(row) && row[idx] != null ? String(row[idx]).trim() : ''; + }; + const port = Number.parseInt(value('port'), 10); + const rawIP = value('ip'); + const ip = assetEditorIsIPv4(rawIP) || assetEditorIsIPv6(rawIP) ? rawIP.toLowerCase() : ''; + const rawDomain = value('domain'); + const domain = rawDomain && !assetEditorIsIPv4(rawDomain) && !assetEditorIsIPv6(rawDomain) + ? assetEditorNormalizeDomain(rawDomain) + : ''; + const rawProtocol = value('protocol').toLowerCase(); + return { + host: value('host'), ip, port: Number.isFinite(port) ? port : 0, domain, + protocol: /^[a-z][a-z0-9+.-]{0,31}$/.test(rawProtocol) ? rawProtocol : '', title: value('title'), server: value('server'), country: value('country'), + province: value('province'), city: value('city'), source: 'fofa', status: 'active' + }; +} + +async function importFofaAssetsByIndexes(indexes) { + const payload = window.infoCollectState || infoCollectState; + const current = payload && payload.currentPayload; + if (!current || !current.results || !indexes.length) { + alert(assetT('assets.selectFirst', '请先选择需要入库的结果')); + return; + } + const converted = indexes.map(index => fofaResultToAsset(current.results[index], current.fields)); + const assets = converted.filter(asset => asset.host || asset.ip || asset.domain); + const invalidCount = converted.length - assets.length; + if (!assets.length) { + alert(assetT('assets.noValidImportTarget', '所选结果中没有可入库的有效资产目标')); + return; + } + const response = await apiFetch('/api/assets/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ assets, source: 'fofa', source_query: current.query || '' }) }); + if (!response.ok) { + alert(assetT('assets.importFailed', '资产入库失败') + ': ' + await response.text()); + return; + } + const result = await response.json(); + if (typeof showInlineToast === 'function') { + const message = invalidCount > 0 + ? assetT('assets.importDoneWithInvalid', `已新增 ${result.created} 条,更新 ${result.updated} 条,跳过 ${invalidCount} 条无有效目标的结果`, { ...result, invalid: invalidCount }) + : assetT('assets.importDone', `已新增 ${result.created} 条,更新 ${result.updated} 条`, result); + showInlineToast(message); + } +} + +function importSelectedFofaAssets() { + const indexes = Array.from(infoCollectState.selectedRowIndexes || []).sort((a, b) => a - b); + return importFofaAssetsByIndexes(indexes); +} + +function importFofaRowAsset(index) { + return importFofaAssetsByIndexes([Number(index)]); +} + +function assetDetailItem(label, value, wide) { + return `
${escapeHtml(label)}
${value || '-'}
`; +} + +function openAssetDetail(index) { + const asset = assetPageState.items[Number(index)]; + assetPageState.detailIndex = Number(index); + openAssetDetailRecord(asset); +} + +function openAssetDetailRecord(asset) { + if (!asset) return; + assetPageState.detailAsset = asset; + const subtitle = document.getElementById('asset-detail-subtitle'); + if (subtitle) subtitle.textContent = assetTargetLabel(asset); + const tags = (asset.tags || []).map(tag => `${escapeHtml(tag)}`).join(''); + const values = [ + assetDetailItem(assetT('assets.project', '所属项目'), escapeHtml(asset.project_name || '')), + assetDetailItem(assetT('assets.status', '状态'), escapeHtml(asset.status === 'inactive' ? assetT('assets.statusInactive', '停用') : assetT('assets.statusActive', '活跃'))), + assetDetailItem('Host', escapeHtml(asset.host || '')), + assetDetailItem('IP', escapeHtml(asset.ip || '')), + assetDetailItem(assetT('assets.domain', '域名'), escapeHtml(asset.domain || '')), + assetDetailItem(assetT('assets.service', '服务'), escapeHtml([asset.protocol, asset.port ? ':' + asset.port : ''].join(''))), + assetDetailItem(assetT('assets.title', '标题/指纹'), escapeHtml(asset.title || ''), true), + assetDetailItem(assetT('assets.server', '服务指纹'), escapeHtml(asset.server || '')), + assetDetailItem(assetT('assets.location', '地区'), escapeHtml([asset.country, asset.province, asset.city].filter(Boolean).join(' / '))), + assetDetailItem(assetT('assets.source', '来源'), escapeHtml(asset.source || '')), + assetDetailItem(assetT('assets.sourceQuery', '来源查询'), asset.source_query ? `${escapeHtml(asset.source_query)}` : ''), + assetDetailItem(assetT('assets.tagsLabel', '标签'), tags, true), + assetDetailItem(assetT('assets.firstSeen', '首次发现'), escapeHtml(asset.first_seen_at ? new Date(asset.first_seen_at).toLocaleString() : '')), + assetDetailItem(assetT('assets.lastSeen', '最近发现'), escapeHtml(asset.last_seen_at ? new Date(asset.last_seen_at).toLocaleString() : '')), + assetDetailItem(assetT('assets.lastScan', '上次扫描'), escapeHtml(asset.last_scan_at ? new Date(asset.last_scan_at).toLocaleString() : '')), + assetDetailItem(assetT('assets.relatedVulnerabilities', '相关漏洞'), String(Number(asset.vulnerability_count || 0))) + ]; + const grid = document.getElementById('asset-detail-grid'); + if (grid) grid.innerHTML = values.join(''); + if (typeof applyRBACToUI === 'function') applyRBACToUI(document.getElementById('asset-detail-modal')); + if (typeof openAppModal === 'function') openAppModal('asset-detail-modal'); + else document.getElementById('asset-detail-modal').style.display = 'flex'; +} + +function closeAssetDetail() { + if (typeof closeAppModal === 'function') closeAppModal('asset-detail-modal'); + else document.getElementById('asset-detail-modal').style.display = 'none'; +} + +function editAssetFromDetail() { + const asset = assetPageState.detailAsset; + closeAssetDetail(); + if (asset) openAssetEditor(asset); +} + +window.loadAssetOverview = loadAssetOverview; +window.loadAssets = loadAssets; +window.openAssetScanModal = openAssetScanModal; +window.closeAssetScanModal = closeAssetScanModal; +window.submitAssetScan = submitAssetScan; +window.toggleAssetSelection = toggleAssetSelection; +window.toggleAssetPageSelection = toggleAssetPageSelection; +window.clearAssetSelection = clearAssetSelection; +window.openAssetVulnerabilities = openAssetVulnerabilities; +window.changeAssetPageSize = changeAssetPageSize; +window.openAssetEditor = openAssetEditor; +window.closeAssetEditor = closeAssetEditor; +window.saveAsset = saveAsset; +window.deleteAsset = deleteAsset; +window.importSelectedFofaAssets = importSelectedFofaAssets; +window.importFofaRowAsset = importFofaRowAsset; +window.openAssetDetail = openAssetDetail; +window.openAssetDetailRecord = openAssetDetailRecord; +window.closeAssetDetail = closeAssetDetail; +window.editAssetFromDetail = editAssetFromDetail; + +document.addEventListener('DOMContentLoaded', () => initAssetCustomSelects()); +document.addEventListener('languagechange', () => ASSET_CUSTOM_SELECT_IDS.forEach(syncAssetSelect)); diff --git a/web/static/js/auth.js b/web/static/js/auth.js index 67b43323..7013aacc 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -360,6 +360,9 @@ const PAGE_PERMISSION_MAP = { chat: 'chat:read', hitl: 'hitl:read', 'info-collect': 'fofa:execute', + assets: 'asset:read', + 'asset-overview': 'asset:read', + 'asset-library': 'asset:read', tasks: 'tasks:read', workflows: 'workflow:read', projects: 'project:read', diff --git a/web/static/js/chat.js b/web/static/js/chat.js index d925683d..d35449d0 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -3079,6 +3079,29 @@ function getCachedToolExecutionSummaries(messageElement) { } } +/** + * 过程摘要中的早期/快速工具结果可能没有 executionId,但消息本身会按调用顺序保存 ID。 + * 合并两份数据,避免渲染摘要时丢失可用的弹窗详情入口。 + */ +function mergeToolExecutionSummariesWithIds(summaries, executionIds) { + const normalizedSummaries = Array.isArray(summaries) + ? summaries.map(normalizeToolExecutionSummaryForButton) + : []; + const normalizedIds = normalizeMcpExecutionIds(executionIds); + const claimedIds = new Set( + normalizedSummaries.map((item) => item.executionId).filter(Boolean) + ); + const fallbackIds = normalizedIds.filter((id) => !claimedIds.has(id)); + let fallbackIndex = 0; + return normalizedSummaries.map((item) => { + if (item.executionId || fallbackIndex >= fallbackIds.length) return item; + return { + ...item, + executionId: fallbackIds[fallbackIndex++] + }; + }); +} + function setPendingToolExecutionSummaries(messageElement, summaries) { if (!messageElement || !messageElement.dataset || !Array.isArray(summaries)) return; const normalized = cacheToolExecutionSummaries(messageElement, summaries); @@ -3337,9 +3360,28 @@ window.setMcpExecutionSummaryCount = setMcpExecutionSummaryCount; window.setPendingMcpExecutionIds = setPendingMcpExecutionIds; window.setPendingToolExecutionSummaries = setPendingToolExecutionSummaries; +async function openTaskToolExecutionDetail(messageElement, item, index) { + let detailItem = item; + if (!detailItem.executionId) { + const refreshedItem = await resolveToolExecutionSummaryForFocus(messageElement, '', index); + const mergedItems = mergeToolExecutionSummariesWithIds( + getCachedToolExecutionSummaries(messageElement), + getCachedMcpExecutionIds(messageElement) + ); + detailItem = mergedItems[index] || refreshedItem || detailItem; + } + if (detailItem.executionId) { + await showMCPDetail(detailItem.executionId); + return; + } + alert(typeof window.t === 'function' + ? window.t('chat.toolExecutionDetailPending') + : '工具执行详情尚未同步,请稍后重试。'); +} + /** * 声明式渲染工具调用列表。 - * 过程摘要是展示与定位的唯一模型;executionIds 仅在摘要尚未到达时提供占位。 + * 过程摘要是展示详情入口的唯一模型;executionIds 仅在摘要尚未到达时提供占位。 * 每次更新整体替换列表,避免增量追加产生双重状态。 */ function renderMcpCallButtons(messageElement) { @@ -3350,7 +3392,7 @@ function renderMcpCallButtons(messageElement) { const executionIds = getCachedMcpExecutionIds(messageElement); const summaries = getCachedToolExecutionSummaries(messageElement); const items = summaries.length > 0 - ? summaries + ? mergeToolExecutionSummariesWithIds(summaries, executionIds) : executionIds.map((executionId) => normalizeToolExecutionSummaryForButton({ executionId })); const renderVersion = String((parseInt(toolList.dataset.renderVersion, 10) || 0) + 1); @@ -3367,17 +3409,7 @@ function renderMcpCallButtons(messageElement) { if (item.toolCallId) { btn.dataset.toolCallId = item.toolCallId; } - btn.onclick = async () => { - let focusItem = item; - if (!focusItem.processDetailId && !focusItem.toolCallId && focusItem.executionId) { - focusItem = await resolveToolExecutionSummaryForFocus( - messageElement, - focusItem.executionId, - index - ) || focusItem; - } - await focusToolExecutionInProcessDetails(messageElement, focusItem, index); - }; + btn.onclick = () => openTaskToolExecutionDetail(messageElement, item, index); if (item.toolName) { renderToolExecutionButtonContent(btn, item.toolName, String(index + 1), item.status); } else { diff --git a/web/static/js/i18n.js b/web/static/js/i18n.js index a32e8ec7..4cb8b3b6 100644 --- a/web/static/js/i18n.js +++ b/web/static/js/i18n.js @@ -66,7 +66,9 @@ const skipText = el.getAttribute('data-i18n-skip-text') === 'true'; const isFormControl = (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'); const attrList = el.getAttribute('data-i18n-attr'); - const text = i18next.t(key); + const translated = i18next.t(key); + // 缺键时保留模板中的后备文案,避免页面直接显示 assets.project 一类内部键名。 + const text = translated && translated !== key ? translated : ''; // 仅当元素无子元素(仅文本或空)时才替换文本,避免覆盖卡片内的数字、子节点等;input/textarea 永不设置 textContent const hasNoElementChildren = !el.querySelector('*'); if (!skipText && !isFormControl && hasNoElementChildren && text && typeof text === 'string') { @@ -80,7 +82,7 @@ var val = text; if (attr === 'title' && titleKey) { var titleText = i18next.t(titleKey); - if (titleText && typeof titleText === 'string') val = titleText; + if (titleText && titleText !== titleKey && typeof titleText === 'string') val = titleText; } if (val && typeof val === 'string') { el.setAttribute(attr, val); @@ -233,4 +235,3 @@ }); }); })(); - diff --git a/web/static/js/info-collect.js b/web/static/js/info-collect.js index f18df63c..959fd2df 100644 --- a/web/static/js/info-collect.js +++ b/web/static/js/info-collect.js @@ -556,6 +556,9 @@ function renderFofaResults(payload) { + `; @@ -566,6 +569,7 @@ function renderFofaResults(payload) { // 更新全选框状态 syncSelectAllCheckbox(); + if (typeof applyRBACToUI === 'function') applyRBACToUI(els.tbody); } function inferTargetFromRow(row, fields) { @@ -1147,4 +1151,3 @@ if (document.readyState === 'loading') { } else { updateSelectedMeta(); } - diff --git a/web/static/js/projects.js b/web/static/js/projects.js index 20099f79..ddadabe3 100644 --- a/web/static/js/projects.js +++ b/web/static/js/projects.js @@ -7,6 +7,7 @@ const PROJECTS_LIST_PAGE_SIZE_KEY = 'cyberstrike.projects_list_page_size'; let currentProjectId = null; let currentProjectUpdatedAt = null; let currentProjectTab = 'facts'; +let currentProjectAssets = []; const projectNameById = {}; let _projectsListReady = false; let _projectsFetchPromise = null; @@ -1016,8 +1017,9 @@ async function selectProject(id) { } function switchProjectTab(tab) { + if (tab === 'assets' && typeof hasPermission === 'function' && !hasPermission('asset:read')) tab = 'facts'; currentProjectTab = tab; - ['facts', 'graph', 'conversations', 'vulns', 'settings'].forEach((t) => { + ['facts', 'graph', 'assets', '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); @@ -1025,10 +1027,54 @@ function switchProjectTab(tab) { }); if (tab === 'facts') loadProjectFacts(); if (tab === 'graph') loadProjectFactGraph(); + if (tab === 'assets') loadProjectAssets(); if (tab === 'conversations') loadProjectConversations(); if (tab === 'vulns') loadProjectVulnerabilities(); } +async function loadProjectAssets() { + const tbody = document.getElementById('project-assets-tbody'); + const countEl = document.getElementById('project-assets-count'); + if (!tbody || !currentProjectId) return; + tbody.innerHTML = `${escapeHtml(tpFmt('common.loading', '加载中...'))}`; + const qs = new URLSearchParams({ project_id: currentProjectId, page: '1', page_size: '100' }); + const res = await apiFetch(`/api/assets?${qs.toString()}`); + if (!res.ok) { + currentProjectAssets = []; + if (countEl) countEl.textContent = '0'; + tbody.innerHTML = `${escapeHtml(tpFmt('common.loadFailed', '加载失败'))}`; + return; + } + const data = await res.json(); + currentProjectAssets = data.assets || []; + if (countEl) countEl.textContent = tpFmt('projects.assetCount', `${data.total || 0} 个资产`, { count: data.total || 0 }); + if (!currentProjectAssets.length) { + tbody.innerHTML = `${escapeHtml(tpFmt('projects.noBoundAssets', '暂无绑定到此项目的资产'))}`; + return; + } + tbody.innerHTML = currentProjectAssets.map((asset, index) => { + const target = asset.host || asset.domain || asset.ip || '-'; + const service = [asset.protocol, asset.port ? ':' + asset.port : ''].join('') || '-'; + const fingerprint = [asset.title, asset.server].filter(Boolean).join(' · ') || '-'; + const updated = asset.last_seen_at ? new Date(asset.last_seen_at).toLocaleString() : '-'; + const status = asset.status === 'inactive' ? tpFmt('assets.statusInactive', '停用') : tpFmt('assets.statusActive', '活跃'); + return ` + + ${escapeHtml(service)} + ${escapeHtml(fingerprint)} + ${escapeHtml(asset.source || '-')} + ${escapeHtml(updated)} + ${escapeHtml(status)} +
+ `; + }).join(''); +} + +function openProjectAssetDetail(index) { + const asset = currentProjectAssets[Number(index)]; + if (asset && typeof window.openAssetDetailRecord === 'function') window.openAssetDetailRecord(asset); +} + let _selectedGraphFactKey = null; let _selectedGraphEdgeId = null; let _currentGraphData = null; @@ -2759,6 +2805,8 @@ window.viewFactsForVulnerability = viewFactsForVulnerability; window.openProjectConversation = openProjectConversation; window.unbindConversationFromProject = unbindConversationFromProject; window.loadProjectConversations = loadProjectConversations; +window.loadProjectAssets = loadProjectAssets; +window.openProjectAssetDetail = openProjectAssetDetail; window.loadProjectFactGraph = loadProjectFactGraph; window.filterProjectFactGraph = filterProjectFactGraph; window.centerProjectFactGraph = centerProjectFactGraph; diff --git a/web/static/js/rbac-guards.js b/web/static/js/rbac-guards.js index c6cf98b0..d889db08 100644 --- a/web/static/js/rbac-guards.js +++ b/web/static/js/rbac-guards.js @@ -113,6 +113,10 @@ scanFofaRow: 'fofa:execute', batchScanSelectedFofaRows: 'fofa:execute', exportFofaResults: 'fofa:execute', + importSelectedFofaAssets: 'asset:write', + importFofaRowAsset: 'asset:write', + saveAsset: 'asset:write', + deleteAsset: 'asset:delete', // 任务队列 showBatchImportModal: 'tasks:write', diff --git a/web/static/js/rbac.js b/web/static/js/rbac.js index 401e90fc..eec6f7d1 100644 --- a/web/static/js/rbac.js +++ b/web/static/js/rbac.js @@ -47,7 +47,7 @@ const rbacScopeMeta = { const rbacResourceLabels = { user: '平台用户', project: '项目', conversation: '对话', vulnerability: '漏洞', webshell: 'WebShell 连接', - batch_task: '批量任务', c2_listener: 'C2 监听器', + batch_task: '批量任务', c2_listener: 'C2 监听器', asset: '资产', }; function rbacText(value, fallback = '') { diff --git a/web/static/js/router.js b/web/static/js/router.js index 4b77dbcd..759ab9c8 100644 --- a/web/static/js/router.js +++ b/web/static/js/router.js @@ -81,7 +81,7 @@ function initRouter() { const hashParts = hash.split('?'); let pageId = hashParts[0]; if (pageId === 'c2') pageId = 'c2-listeners'; - if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { + if (pageId && ['dashboard', 'chat', 'hitl', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { switchPage(pageId); if (pageId === 'chat') { scheduleChatConversationFromHash(500); @@ -141,7 +141,15 @@ function updateNavState(pageId) { }); // 设置活动状态 - if (pageId === 'mcp-monitor' || pageId === 'mcp-management') { + if (pageId === 'asset-overview' || pageId === 'asset-library' || pageId === 'info-collect') { + const assetItem = document.querySelector('.nav-item[data-page="assets"]'); + if (assetItem) { + assetItem.classList.add('active'); + assetItem.classList.add('expanded'); + } + const submenuItem = document.querySelector(`.nav-submenu-item[data-page="${pageId}"]`); + if (submenuItem) submenuItem.classList.add('active'); + } else if (pageId === 'mcp-monitor' || pageId === 'mcp-management') { // MCP子菜单项 const mcpItem = document.querySelector('.nav-item[data-page="mcp"]'); if (mcpItem) { @@ -374,6 +382,12 @@ async function initPage(pageId) { initInfoCollectPage(); } break; + case 'asset-overview': + if (typeof loadAssetOverview === 'function') loadAssetOverview(); + break; + case 'asset-library': + if (typeof loadAssets === 'function') loadAssets(); + break; case 'tasks': // 初始化任务管理页面 if (typeof initTasksPage === 'function') { @@ -549,7 +563,7 @@ document.addEventListener('DOMContentLoaded', function() { let pageId = hashParts[0]; if (pageId === 'c2') pageId = 'c2-listeners'; - if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { + if (pageId && ['dashboard', 'chat', 'hitl', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { switchPage(pageId); if (pageId === 'chat') { scheduleChatConversationFromHash(200); diff --git a/web/templates/index.html b/web/templates/index.html index 76eda1e2..1b855a9d 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -23,7 +23,7 @@ } })(); - + @@ -179,6 +179,9 @@