mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-11-14 08:17:36 +01:00
2812 lines
122 KiB
JavaScript
2812 lines
122 KiB
JavaScript
// 漏洞管理相关功能
|
||
|
||
function vulnT(key, opts) {
|
||
if (typeof window.t === 'function') {
|
||
return window.t(key, opts);
|
||
}
|
||
return key;
|
||
}
|
||
|
||
function vulnDateLocale() {
|
||
if (typeof window.uiLocale === 'function') return window.uiLocale();
|
||
try {
|
||
const lang = (window.__locale || '').toLowerCase();
|
||
if (lang.indexOf('zh') === 0) return 'zh-CN';
|
||
if (lang.indexOf('ru') === 0) return 'ru-RU';
|
||
} catch (e) { /* ignore */ }
|
||
return 'en-US';
|
||
}
|
||
|
||
function vulnSeverityLabel(code) {
|
||
const m = {
|
||
critical: 'dashboard.severityCritical',
|
||
high: 'dashboard.severityHigh',
|
||
medium: 'dashboard.severityMedium',
|
||
low: 'dashboard.severityLow',
|
||
info: 'dashboard.severityInfo'
|
||
};
|
||
return m[code] ? vulnT(m[code]) : code;
|
||
}
|
||
|
||
function vulnStatusLabel(code) {
|
||
const m = {
|
||
open: 'vulnerabilityPage.statusOpen',
|
||
confirmed: 'vulnerabilityPage.statusConfirmed',
|
||
fixed: 'vulnerabilityPage.statusFixed',
|
||
false_positive: 'vulnerabilityPage.statusFalsePositive',
|
||
ignored: 'vulnerabilityPage.statusIgnored'
|
||
};
|
||
return m[code] ? vulnT(m[code]) : code;
|
||
}
|
||
|
||
const VULN_STATUS_CODES = ['open', 'confirmed', 'fixed', 'ignored', 'false_positive'];
|
||
const VULNERABILITY_REMOVE_ANIM_MS = 200;
|
||
|
||
function getVulnerabilityScrollContainer() {
|
||
const page = document.getElementById('page-vulnerabilities');
|
||
return page ? page.querySelector('.page-content') : null;
|
||
}
|
||
|
||
function getExpandedVulnerabilityIds() {
|
||
const ids = [];
|
||
document.querySelectorAll('#vulnerabilities-list .vulnerability-content').forEach(function (el) {
|
||
if (el.style.display !== 'none') {
|
||
const id = (el.id || '').replace(/^content-/, '');
|
||
if (id) ids.push(id);
|
||
}
|
||
});
|
||
return ids;
|
||
}
|
||
|
||
function restoreExpandedVulnerabilityDetails(expandedIds) {
|
||
if (!expandedIds || !expandedIds.length) return;
|
||
expandedIds.forEach(function (id) {
|
||
const content = document.getElementById('content-' + id);
|
||
const icon = document.getElementById('expand-icon-' + id);
|
||
if (!content || content.style.display !== 'none') return;
|
||
content.style.display = 'block';
|
||
const row = content.closest('.vulnerability-table-detail');
|
||
if (row) row.hidden = false;
|
||
const trigger = document.getElementById('vulnerability-row-toggle-' + id);
|
||
if (trigger) trigger.setAttribute('aria-expanded', 'true');
|
||
if (icon) icon.style.transform = 'rotate(90deg)';
|
||
loadVulnerabilityRelatedFacts(id).catch(function (e) { console.warn(e); });
|
||
});
|
||
}
|
||
|
||
function buildVulnerabilityStatusPicker(vuln) {
|
||
const current = vuln.status || 'open';
|
||
const id = escapeHtml(vuln.id);
|
||
const label = escapeHtml(vulnT('vulnerabilityPage.statusChangeLabel'));
|
||
const caretSvg = '<svg class="vuln-status-picker-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||
const options = VULN_STATUS_CODES.map(function (code) {
|
||
const selected = code === current;
|
||
const selCls = selected ? ' is-selected' : '';
|
||
const ariaSel = selected ? ' aria-selected="true"' : ' aria-selected="false"';
|
||
return '<button type="button" class="vuln-status-picker-option' + selCls + '" role="option" data-value="' + code + '"' + ariaSel + '>' +
|
||
'<span class="vuln-status-picker-check" aria-hidden="true">✓</span>' +
|
||
'<span class="vuln-status-picker-label">' + escapeHtml(vulnStatusLabel(code)) + '</span>' +
|
||
'</button>';
|
||
}).join('');
|
||
return '<div class="vuln-status-picker status-' + escapeHtml(current) + '" data-vuln-id="' + id + '" data-prev-status="' + escapeHtml(current) + '">' +
|
||
'<button type="button" class="vuln-status-picker-trigger" aria-label="' + label + '" aria-haspopup="listbox" aria-expanded="false">' +
|
||
'<span class="vuln-status-picker-value">' + escapeHtml(vulnStatusLabel(current)) + '</span>' +
|
||
caretSvg +
|
||
'</button>' +
|
||
'<div class="vuln-status-picker-menu" role="listbox" hidden>' + options + '</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
const VULN_STATUS_PICKER_STATUS_CLASSES = VULN_STATUS_CODES.map(function (code) {
|
||
return 'status-' + code;
|
||
});
|
||
|
||
function setVulnerabilityStatusPickerDisabled(pickerEl, disabled) {
|
||
if (!pickerEl) return;
|
||
pickerEl.classList.toggle('is-disabled', !!disabled);
|
||
const trigger = pickerEl.querySelector('.vuln-status-picker-trigger');
|
||
if (trigger) trigger.disabled = !!disabled;
|
||
}
|
||
|
||
function updateVulnerabilityStatusPicker(pickerEl, status) {
|
||
if (!pickerEl) return;
|
||
const code = status || 'open';
|
||
VULN_STATUS_PICKER_STATUS_CLASSES.forEach(function (cls) {
|
||
pickerEl.classList.remove(cls);
|
||
});
|
||
pickerEl.classList.add('status-' + code);
|
||
pickerEl.dataset.prevStatus = code;
|
||
const valueEl = pickerEl.querySelector('.vuln-status-picker-value');
|
||
if (valueEl) valueEl.textContent = vulnStatusLabel(code);
|
||
pickerEl.querySelectorAll('.vuln-status-picker-option').forEach(function (opt) {
|
||
const isSel = opt.getAttribute('data-value') === code;
|
||
opt.classList.toggle('is-selected', isSel);
|
||
opt.setAttribute('aria-selected', isSel ? 'true' : 'false');
|
||
});
|
||
}
|
||
|
||
let vulnerabilityStatusPickerDocBound = false;
|
||
|
||
function closeAllVulnerabilityStatusPickers() {
|
||
document.querySelectorAll('.vuln-status-picker.open').forEach(function (picker) {
|
||
picker.classList.remove('open');
|
||
const menu = picker.querySelector('.vuln-status-picker-menu');
|
||
const trigger = picker.querySelector('.vuln-status-picker-trigger');
|
||
if (menu) menu.hidden = true;
|
||
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
||
});
|
||
}
|
||
|
||
function initVulnerabilityStatusPickers(root) {
|
||
if (!vulnerabilityStatusPickerDocBound) {
|
||
document.addEventListener('click', closeAllVulnerabilityStatusPickers);
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape') closeAllVulnerabilityStatusPickers();
|
||
});
|
||
vulnerabilityStatusPickerDocBound = true;
|
||
}
|
||
|
||
const scope = root || document.getElementById('vulnerabilities-list');
|
||
if (!scope) return;
|
||
|
||
scope.querySelectorAll('.vuln-status-picker').forEach(function (picker) {
|
||
if (picker.dataset.bound === '1') return;
|
||
picker.dataset.bound = '1';
|
||
|
||
picker.addEventListener('click', function (e) { e.stopPropagation(); });
|
||
picker.addEventListener('keydown', function (e) { e.stopPropagation(); });
|
||
|
||
const trigger = picker.querySelector('.vuln-status-picker-trigger');
|
||
const menu = picker.querySelector('.vuln-status-picker-menu');
|
||
if (!trigger || !menu) return;
|
||
|
||
trigger.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
if (picker.classList.contains('is-disabled')) return;
|
||
const wasOpen = picker.classList.contains('open');
|
||
closeAllVulnerabilityStatusPickers();
|
||
closeAllVulnDetailProjectSelects();
|
||
closeAllVulnFilterCustomSelects();
|
||
if (!wasOpen) {
|
||
picker.classList.add('open');
|
||
menu.hidden = false;
|
||
trigger.setAttribute('aria-expanded', 'true');
|
||
}
|
||
});
|
||
|
||
menu.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
const opt = e.target.closest('.vuln-status-picker-option');
|
||
if (!opt || picker.classList.contains('is-disabled')) return;
|
||
const newStatus = opt.getAttribute('data-value');
|
||
const vulnId = picker.dataset.vulnId;
|
||
closeAllVulnerabilityStatusPickers();
|
||
changeVulnerabilityStatus(vulnId, newStatus, picker);
|
||
});
|
||
});
|
||
}
|
||
|
||
function vulnerabilityStatusMatchesFilter(status) {
|
||
const filterStatus = (vulnerabilityFilters.status || '').trim();
|
||
return !filterStatus || filterStatus === status;
|
||
}
|
||
|
||
async function removeVulnerabilityCard(vulnId) {
|
||
const card = document.getElementById('vulnerability-card-' + vulnId);
|
||
if (card) {
|
||
card.classList.add('vulnerability-card--removing');
|
||
await new Promise(resolve => setTimeout(resolve, VULNERABILITY_REMOVE_ANIM_MS));
|
||
}
|
||
await loadVulnerabilities(null, { preserveScroll: true, silent: true });
|
||
}
|
||
|
||
async function changeVulnerabilityStatus(vulnId, newStatus, pickerEl) {
|
||
if (!vulnId || !pickerEl) return;
|
||
const prevStatus = pickerEl.dataset.prevStatus || newStatus;
|
||
if (newStatus === prevStatus) return;
|
||
|
||
setVulnerabilityStatusPickerDisabled(pickerEl, true);
|
||
try {
|
||
const response = await apiFetch('/api/vulnerabilities/' + encodeURIComponent(vulnId), {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status: newStatus }),
|
||
});
|
||
if (!response.ok) {
|
||
const err = await response.json().catch(function () { return {}; });
|
||
throw new Error(err.error || vulnT('vulnerabilityPage.statusUpdateFailed'));
|
||
}
|
||
|
||
updateVulnerabilityStatusPicker(pickerEl, newStatus);
|
||
if (typeof refreshVulnerabilityInspector === 'function') refreshVulnerabilityInspector(vulnId);
|
||
loadVulnerabilityStats();
|
||
|
||
if (!vulnerabilityStatusMatchesFilter(newStatus)) {
|
||
await removeVulnerabilityCard(vulnId);
|
||
} else {
|
||
await loadVulnerabilities(null, { preserveScroll: true, silent: true });
|
||
}
|
||
} catch (error) {
|
||
console.error('更新漏洞状态失败:', error);
|
||
updateVulnerabilityStatusPicker(pickerEl, prevStatus);
|
||
alert(vulnT('vulnerabilityPage.statusUpdateFailed') + ': ' + error.message);
|
||
} finally {
|
||
setVulnerabilityStatusPickerDisabled(pickerEl, false);
|
||
}
|
||
}
|
||
|
||
// 从localStorage读取每页显示数量,默认为20
|
||
const getVulnerabilityPageSize = () => {
|
||
const saved = Number(localStorage.getItem('vulnerabilityPageSize'));
|
||
return [10, 20, 50, 100].includes(saved) ? saved : 20;
|
||
};
|
||
|
||
let currentVulnerabilityId = null;
|
||
let vulnerabilityFilters = {
|
||
q: '',
|
||
id: '',
|
||
project_id: '',
|
||
conversation_id: '',
|
||
task_id: '',
|
||
conversation_tag: '',
|
||
task_tag: '',
|
||
severity: '',
|
||
status: ''
|
||
};
|
||
let vulnerabilityPagination = {
|
||
currentPage: 1,
|
||
pageSize: getVulnerabilityPageSize(),
|
||
total: 0,
|
||
totalPages: 1
|
||
};
|
||
const VULNERABILITY_VIEWS = ['host', 'task', 'none'];
|
||
const savedVulnerabilityView = localStorage.getItem('vulnerabilityListView');
|
||
let vulnerabilityView = savedVulnerabilityView === 'case' ? 'none'
|
||
: VULNERABILITY_VIEWS.includes(savedVulnerabilityView) ? savedVulnerabilityView : 'host';
|
||
let vulnerabilityListRequest = 0;
|
||
let vulnerabilityStatsRequest = 0;
|
||
const savedVulnerabilitySort = localStorage.getItem('vulnerabilityListSort');
|
||
let vulnerabilitySort = ['severity', 'latest'].includes(savedVulnerabilitySort) ? savedVulnerabilitySort : '';
|
||
let vulnerabilityHostQuery = '';
|
||
let vulnerabilityTaskQuery = '';
|
||
let vulnerabilityBrowserSelection = '';
|
||
let vulnerabilityBrowserRows = [];
|
||
let vulnerabilityHostSearchTimer = null;
|
||
|
||
const VULN_STAT_SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
||
let vulnerabilityStatCardsBound = false;
|
||
let vulnerabilityFilterPanelBound = false;
|
||
let vulnerabilityFilterOptionsCache = null;
|
||
let vulnerabilityMoreFiltersPopoverOpen = false;
|
||
let vulnerabilityFilterDebounceTimer = null;
|
||
const VULNERABILITY_FILTER_DEBOUNCE_MS = 400;
|
||
const VULNERABILITY_DATALIST_MAX = 8;
|
||
const VULNERABILITY_DATALIST_MIN_QUERY = 2;
|
||
|
||
const VULN_FILTER_CHIP_FIELDS = [
|
||
{ key: 'q', labelKey: 'vulnerabilityPage.searchKeywordShort' },
|
||
{ key: 'id', labelKey: 'vulnerabilityPage.vulnId' },
|
||
{ key: 'status', labelKey: null, format: 'status' },
|
||
{ key: 'severity', labelKey: null, format: 'severity' },
|
||
{ key: 'project_id', labelKey: null },
|
||
{ key: 'conversation_id', labelKey: 'vulnerabilityPage.conversationId' },
|
||
{ key: 'task_id', labelKey: 'vulnerabilityPage.taskOrQueueId' },
|
||
{ key: 'conversation_tag', labelKey: 'vulnerabilityPage.conversationTag' },
|
||
{ key: 'task_tag', labelKey: 'vulnerabilityPage.taskTag' }
|
||
];
|
||
|
||
// 从地址栏 #vulnerabilities?conversation_id= / ?task_id= / ?id= 同步筛选(通知/对话菜单/任务管理联动)
|
||
function syncVulnerabilityFiltersFromLocationHash() {
|
||
const hash = window.location.hash.slice(1);
|
||
const hashParts = hash.split('?');
|
||
if (hashParts[0] !== 'vulnerabilities' || hashParts.length < 2) {
|
||
return;
|
||
}
|
||
const params = new URLSearchParams(hashParts.slice(1).join('?'));
|
||
const vid = (params.get('id') || '').trim();
|
||
const cid = (params.get('conversation_id') || '').trim();
|
||
const tid = (params.get('task_id') || '').trim();
|
||
const sev = (params.get('severity') || '').trim();
|
||
const st = (params.get('status') || '').trim();
|
||
const convTag = (params.get('conversation_tag') || '').trim();
|
||
const taskTag = (params.get('task_tag') || '').trim();
|
||
const pid = (params.get('project_id') || '').trim();
|
||
const q = (params.get('q') || params.get('search') || '').trim();
|
||
if (!vid && !cid && !tid && !sev && !st && !convTag && !taskTag && !q && !pid) {
|
||
return;
|
||
}
|
||
|
||
vulnerabilityFilters.q = '';
|
||
vulnerabilityFilters.id = '';
|
||
vulnerabilityFilters.project_id = '';
|
||
vulnerabilityFilters.conversation_id = '';
|
||
vulnerabilityFilters.task_id = '';
|
||
vulnerabilityFilters.conversation_tag = '';
|
||
vulnerabilityFilters.task_tag = '';
|
||
vulnerabilityFilters.severity = '';
|
||
vulnerabilityFilters.status = '';
|
||
const searchEl = document.getElementById('vulnerability-search-filter');
|
||
const exactIdEl = document.getElementById('vulnerability-exact-id-filter');
|
||
const convEl = document.getElementById('vulnerability-conversation-filter');
|
||
const taskEl = document.getElementById('vulnerability-task-filter');
|
||
const convTagEl = document.getElementById('vulnerability-conversation-tag-filter');
|
||
const taskTagEl = document.getElementById('vulnerability-task-tag-filter');
|
||
const projEl = document.getElementById('vulnerability-project-filter');
|
||
const sevEl = document.getElementById('vulnerability-severity-filter');
|
||
const stEl = document.getElementById('vulnerability-status-filter');
|
||
if (searchEl) searchEl.value = '';
|
||
if (exactIdEl) exactIdEl.value = '';
|
||
if (convEl) convEl.value = '';
|
||
if (taskEl) taskEl.value = '';
|
||
if (convTagEl) convTagEl.value = '';
|
||
if (taskTagEl) taskTagEl.value = '';
|
||
if (projEl) projEl.value = '';
|
||
if (sevEl) sevEl.value = '';
|
||
if (stEl) stEl.value = '';
|
||
|
||
if (q) {
|
||
vulnerabilityFilters.q = q;
|
||
if (searchEl) searchEl.value = q;
|
||
}
|
||
if (pid) {
|
||
vulnerabilityFilters.project_id = pid;
|
||
if (projEl) projEl.value = pid;
|
||
}
|
||
if (vid) {
|
||
vulnerabilityFilters.id = vid;
|
||
if (exactIdEl) exactIdEl.value = vid;
|
||
}
|
||
if (cid) {
|
||
vulnerabilityFilters.conversation_id = cid;
|
||
if (convEl) convEl.value = cid;
|
||
}
|
||
if (tid) {
|
||
vulnerabilityFilters.task_id = tid;
|
||
if (taskEl) taskEl.value = tid;
|
||
}
|
||
if (convTag) {
|
||
vulnerabilityFilters.conversation_tag = convTag;
|
||
if (convTagEl) convTagEl.value = convTag;
|
||
}
|
||
if (taskTag) {
|
||
vulnerabilityFilters.task_tag = taskTag;
|
||
if (taskTagEl) taskTagEl.value = taskTag;
|
||
}
|
||
if (sev) {
|
||
vulnerabilityFilters.severity = sev;
|
||
if (sevEl) sevEl.value = sev;
|
||
}
|
||
if (st) {
|
||
vulnerabilityFilters.status = st;
|
||
if (stEl) stEl.value = st;
|
||
}
|
||
vulnerabilityPagination.currentPage = 1;
|
||
syncVulnerabilityStatCardActiveState();
|
||
updateVulnerabilityFilterPanelState();
|
||
renderVulnerabilityFilterChips();
|
||
syncAllVulnFilterCustomSelects();
|
||
}
|
||
|
||
// 初始化漏洞管理页面
|
||
async function initVulnerabilityPage() {
|
||
// 从localStorage加载每页条数设置
|
||
vulnerabilityPagination.pageSize = getVulnerabilityPageSize();
|
||
initVulnerabilityStatCards();
|
||
initVulnerabilityFilterPanel();
|
||
initVulnerabilityViewSwitch();
|
||
document.getElementById('vulnerability-sort').value = vulnerabilitySort;
|
||
syncVulnerabilityFiltersFromLocationHash();
|
||
await refreshVulnerabilityProjectFilter();
|
||
updateVulnerabilityFilterPanelState();
|
||
renderVulnerabilityFilterChips();
|
||
loadVulnerabilityFilterOptions();
|
||
loadVulnerabilityStats();
|
||
loadVulnerabilities();
|
||
}
|
||
|
||
function initVulnerabilityViewSwitch() {
|
||
const control = document.getElementById('vulnerability-view-switch');
|
||
if (!control || control.dataset.bound) return;
|
||
control.dataset.bound = 'true';
|
||
const buttons = control.querySelectorAll('button[data-vuln-view]');
|
||
const updateSelection = () => buttons.forEach(button => {
|
||
button.setAttribute('aria-pressed', String(button.dataset.vulnView === vulnerabilityView));
|
||
});
|
||
updateSelection();
|
||
control.addEventListener('click', event => {
|
||
const view = event.target.closest('button[data-vuln-view]')?.dataset.vulnView;
|
||
if (!VULNERABILITY_VIEWS.includes(view) || view === vulnerabilityView) return;
|
||
vulnerabilityView = view;
|
||
clearTimeout(vulnerabilityHostSearchTimer);
|
||
vulnerabilityBrowserSelection = '';
|
||
localStorage.setItem('vulnerabilityListView', view);
|
||
updateSelection();
|
||
const scroll = getVulnerabilityScrollContainer();
|
||
if (scroll) scroll.scrollTop = 0;
|
||
loadVulnerabilities(1);
|
||
});
|
||
}
|
||
|
||
function initVulnerabilityStatCards() {
|
||
if (vulnerabilityStatCardsBound) {
|
||
syncVulnerabilityStatCardActiveState();
|
||
return;
|
||
}
|
||
const root = document.getElementById('vulnerability-stat-cards');
|
||
if (!root) return;
|
||
vulnerabilityStatCardsBound = true;
|
||
root.addEventListener('click', onVulnerabilityStatCardClick);
|
||
root.addEventListener('keydown', onVulnerabilityStatCardKeydown);
|
||
}
|
||
|
||
function onVulnerabilityStatCardClick(ev) {
|
||
const totalCard = ev.target.closest('.stat-card.stat-card-total');
|
||
if (totalCard) {
|
||
applyVulnerabilitySeverityFilter('');
|
||
return;
|
||
}
|
||
const card = ev.target.closest('.stat-card.is-clickable[data-severity]');
|
||
if (!card) return;
|
||
const sev = card.getAttribute('data-severity');
|
||
if (!sev) return;
|
||
const sevEl = document.getElementById('vulnerability-severity-filter');
|
||
const current = sevEl ? sevEl.value : vulnerabilityFilters.severity;
|
||
applyVulnerabilitySeverityFilter(current === sev ? '' : sev);
|
||
}
|
||
|
||
function onVulnerabilityStatCardKeydown(ev) {
|
||
if (ev.key !== 'Enter' && ev.key !== ' ') return;
|
||
const card = ev.target.closest('.stat-card.is-clickable');
|
||
if (!card || !card.contains(ev.target)) return;
|
||
ev.preventDefault();
|
||
card.click();
|
||
}
|
||
|
||
function applyVulnerabilitySeverityFilter(severity) {
|
||
const sevEl = document.getElementById('vulnerability-severity-filter');
|
||
if (sevEl) sevEl.value = severity || '';
|
||
applyVulnerabilityFilters();
|
||
}
|
||
|
||
function readVulnerabilityFiltersFromForm() {
|
||
vulnerabilityFilters.q = (document.getElementById('vulnerability-search-filter')?.value || '').trim();
|
||
vulnerabilityFilters.id = (document.getElementById('vulnerability-exact-id-filter')?.value || '').trim();
|
||
vulnerabilityFilters.project_id = (document.getElementById('vulnerability-project-filter')?.value || '').trim();
|
||
vulnerabilityFilters.conversation_id = (document.getElementById('vulnerability-conversation-filter')?.value || '').trim();
|
||
vulnerabilityFilters.task_id = (document.getElementById('vulnerability-task-filter')?.value || '').trim();
|
||
vulnerabilityFilters.conversation_tag = (document.getElementById('vulnerability-conversation-tag-filter')?.value || '').trim();
|
||
vulnerabilityFilters.task_tag = (document.getElementById('vulnerability-task-tag-filter')?.value || '').trim();
|
||
vulnerabilityFilters.severity = document.getElementById('vulnerability-severity-filter')?.value || '';
|
||
vulnerabilityFilters.status = document.getElementById('vulnerability-status-filter')?.value || '';
|
||
return vulnerabilityFilters;
|
||
}
|
||
|
||
function hasVulnerabilityAdvancedFiltersActive() {
|
||
const f = vulnerabilityFilters;
|
||
return Boolean(f.id || f.conversation_id || f.task_id || f.conversation_tag || f.task_tag);
|
||
}
|
||
|
||
function hasAnyVulnerabilityFilterActive() {
|
||
const f = vulnerabilityFilters;
|
||
return Boolean(
|
||
f.q || f.id || f.project_id || f.conversation_id || f.task_id || f.conversation_tag || f.task_tag || f.severity || f.status
|
||
);
|
||
}
|
||
|
||
function applyVulnerabilityFilters() {
|
||
readVulnerabilityFiltersFromForm();
|
||
vulnerabilityPagination.currentPage = 1;
|
||
syncVulnerabilityStatCardActiveState();
|
||
updateVulnerabilityLocationHashFromFilters();
|
||
updateVulnerabilityFilterPanelState();
|
||
renderVulnerabilityFilterChips();
|
||
loadVulnerabilityStats();
|
||
loadVulnerabilities();
|
||
}
|
||
|
||
function updateVulnerabilityLocationHashFromFilters() {
|
||
const hash = window.location.hash.slice(1);
|
||
const hashParts = hash.split('?');
|
||
if (hashParts[0] !== 'vulnerabilities') return;
|
||
const params = new URLSearchParams(hashParts.length >= 2 ? hashParts.slice(1).join('?') : '');
|
||
const f = vulnerabilityFilters;
|
||
const pairs = [
|
||
['q', f.q],
|
||
['id', f.id],
|
||
['project_id', f.project_id],
|
||
['conversation_id', f.conversation_id],
|
||
['task_id', f.task_id],
|
||
['conversation_tag', f.conversation_tag],
|
||
['task_tag', f.task_tag],
|
||
['severity', f.severity],
|
||
['status', f.status]
|
||
];
|
||
pairs.forEach(function (pair) {
|
||
if (pair[1]) {
|
||
params.set(pair[0], pair[1]);
|
||
} else {
|
||
params.delete(pair[0]);
|
||
}
|
||
});
|
||
const qs = params.toString();
|
||
const newHash = qs ? 'vulnerabilities?' + qs : 'vulnerabilities';
|
||
if (window.location.hash.slice(1) === newHash) return;
|
||
const newFull = '#' + newHash;
|
||
if (typeof history.replaceState === 'function') {
|
||
history.replaceState(null, '', window.location.pathname + window.location.search + newFull);
|
||
} else {
|
||
window.location.hash = newHash;
|
||
}
|
||
}
|
||
|
||
function scheduleVulnerabilityFilterApply(immediate) {
|
||
if (vulnerabilityFilterDebounceTimer) {
|
||
clearTimeout(vulnerabilityFilterDebounceTimer);
|
||
vulnerabilityFilterDebounceTimer = null;
|
||
}
|
||
if (immediate) {
|
||
applyVulnerabilityFilters();
|
||
return;
|
||
}
|
||
vulnerabilityFilterDebounceTimer = setTimeout(function () {
|
||
vulnerabilityFilterDebounceTimer = null;
|
||
applyVulnerabilityFilters();
|
||
}, VULNERABILITY_FILTER_DEBOUNCE_MS);
|
||
}
|
||
|
||
function syncVulnerabilityAdvancedFieldsFromFilters() {
|
||
const map = {
|
||
id: 'vulnerability-exact-id-filter',
|
||
conversation_id: 'vulnerability-conversation-filter',
|
||
task_id: 'vulnerability-task-filter',
|
||
conversation_tag: 'vulnerability-conversation-tag-filter',
|
||
task_tag: 'vulnerability-task-tag-filter'
|
||
};
|
||
Object.keys(map).forEach(function (key) {
|
||
const el = document.getElementById(map[key]);
|
||
if (el) el.value = vulnerabilityFilters[key] || '';
|
||
});
|
||
}
|
||
|
||
function setVulnerabilityMoreFiltersPopoverOpen(open) {
|
||
const btn = document.getElementById('vulnerability-more-filters-btn');
|
||
const popover = document.getElementById('vulnerability-more-filters-popover');
|
||
if (!btn || !popover) return;
|
||
vulnerabilityMoreFiltersPopoverOpen = open;
|
||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
btn.classList.toggle('is-active', open);
|
||
popover.hidden = !open;
|
||
}
|
||
|
||
function toggleVulnerabilityMoreFiltersPopover(ev) {
|
||
if (ev) {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
}
|
||
const opening = !vulnerabilityMoreFiltersPopoverOpen;
|
||
if (opening) {
|
||
readVulnerabilityFiltersFromForm();
|
||
syncVulnerabilityAdvancedFieldsFromFilters();
|
||
}
|
||
setVulnerabilityMoreFiltersPopoverOpen(opening);
|
||
}
|
||
|
||
function closeVulnerabilityMoreFiltersPopover(revertDraft) {
|
||
if (revertDraft) syncVulnerabilityAdvancedFieldsFromFilters();
|
||
setVulnerabilityMoreFiltersPopoverOpen(false);
|
||
}
|
||
|
||
function clearVulnerabilityAdvancedFilterFields() {
|
||
['vulnerability-exact-id-filter', 'vulnerability-conversation-filter', 'vulnerability-task-filter',
|
||
'vulnerability-conversation-tag-filter', 'vulnerability-task-tag-filter'].forEach(function (id) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = '';
|
||
});
|
||
}
|
||
|
||
function applyVulnerabilityMoreFiltersFromPopover() {
|
||
closeVulnerabilityMoreFiltersPopover(false);
|
||
scheduleVulnerabilityFilterApply(true);
|
||
}
|
||
|
||
function onVulnerabilityFilterDocumentPointerDown(ev) {
|
||
if (!vulnerabilityMoreFiltersPopoverOpen) return;
|
||
const anchor = document.querySelector('.vulnerability-more-filters-anchor');
|
||
if (anchor && anchor.contains(ev.target)) return;
|
||
closeVulnerabilityMoreFiltersPopover(true);
|
||
}
|
||
|
||
function initVulnerabilityFilterPanel() {
|
||
const panel = document.getElementById('vulnerability-filter-panel');
|
||
if (!panel) return;
|
||
|
||
if (vulnerabilityFilterPanelBound) {
|
||
updateVulnerabilityFilterPanelState();
|
||
syncAllVulnFilterCustomSelects();
|
||
return;
|
||
}
|
||
vulnerabilityFilterPanelBound = true;
|
||
|
||
const stEl = document.getElementById('vulnerability-status-filter');
|
||
if (stEl) stEl.addEventListener('change', function () { scheduleVulnerabilityFilterApply(true); });
|
||
|
||
const searchEl = document.getElementById('vulnerability-search-filter');
|
||
if (searchEl) {
|
||
searchEl.addEventListener('input', function () { scheduleVulnerabilityFilterApply(false); });
|
||
searchEl.addEventListener('keydown', function (ev) {
|
||
if (ev.key === 'Enter') {
|
||
ev.preventDefault();
|
||
scheduleVulnerabilityFilterApply(true);
|
||
}
|
||
});
|
||
searchEl.addEventListener('search', function () {
|
||
if (searchEl.value === '') scheduleVulnerabilityFilterApply(true);
|
||
});
|
||
}
|
||
|
||
const moreBtn = document.getElementById('vulnerability-more-filters-btn');
|
||
if (moreBtn) moreBtn.addEventListener('click', toggleVulnerabilityMoreFiltersPopover);
|
||
|
||
const applyBtn = document.getElementById('vulnerability-more-filters-apply');
|
||
if (applyBtn) applyBtn.addEventListener('click', applyVulnerabilityMoreFiltersFromPopover);
|
||
|
||
const resetBtn = document.getElementById('vulnerability-more-filters-reset');
|
||
if (resetBtn) {
|
||
resetBtn.addEventListener('click', function () {
|
||
clearVulnerabilityAdvancedFilterFields();
|
||
applyVulnerabilityMoreFiltersFromPopover();
|
||
});
|
||
}
|
||
|
||
const advancedTextIds = [
|
||
'vulnerability-exact-id-filter',
|
||
'vulnerability-conversation-filter',
|
||
'vulnerability-task-filter',
|
||
'vulnerability-conversation-tag-filter',
|
||
'vulnerability-task-tag-filter'
|
||
];
|
||
advancedTextIds.forEach(function (id) {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.addEventListener('keydown', function (ev) {
|
||
if (ev.key === 'Enter') {
|
||
ev.preventDefault();
|
||
applyVulnerabilityMoreFiltersFromPopover();
|
||
}
|
||
});
|
||
});
|
||
|
||
document.addEventListener('mousedown', onVulnerabilityFilterDocumentPointerDown);
|
||
document.addEventListener('keydown', function (ev) {
|
||
if (ev.key === 'Escape' && vulnerabilityMoreFiltersPopoverOpen) {
|
||
closeVulnerabilityMoreFiltersPopover(true);
|
||
}
|
||
});
|
||
|
||
bindVulnerabilityFilterTypeaheads();
|
||
initVulnerabilityFilterSelects();
|
||
}
|
||
|
||
const VULN_FILTER_CUSTOM_SELECT_IDS = ['vulnerability-project-filter', 'vulnerability-status-filter'];
|
||
const vulnFilterCustomSelectMap = {};
|
||
let vulnFilterCustomSelectDocBound = false;
|
||
|
||
const VULN_FILTER_SELECT_CARET = '<svg class="vuln-filter-select-caret" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||
|
||
function closeAllVulnFilterCustomSelects() {
|
||
Object.keys(vulnFilterCustomSelectMap).forEach(function (id) {
|
||
const reg = vulnFilterCustomSelectMap[id];
|
||
if (!reg || !reg.wrapper) return;
|
||
reg.wrapper.classList.remove('open');
|
||
if (reg.trigger) reg.trigger.setAttribute('aria-expanded', 'false');
|
||
});
|
||
}
|
||
|
||
function syncVulnFilterCustomSelect(selectId) {
|
||
const reg = vulnFilterCustomSelectMap[selectId];
|
||
if (!reg) return;
|
||
const select = reg.select;
|
||
const dropdown = reg.dropdown;
|
||
const trigger = reg.trigger;
|
||
const valueSpan = trigger.querySelector('.vuln-filter-select-value');
|
||
|
||
dropdown.innerHTML = '';
|
||
Array.prototype.forEach.call(select.options, function (opt) {
|
||
const item = document.createElement('button');
|
||
item.type = 'button';
|
||
item.className = 'vuln-filter-select-option';
|
||
item.setAttribute('role', 'option');
|
||
item.setAttribute('data-value', opt.value);
|
||
if (opt.value === select.value) {
|
||
item.classList.add('is-selected');
|
||
item.setAttribute('aria-selected', 'true');
|
||
} else {
|
||
item.setAttribute('aria-selected', 'false');
|
||
}
|
||
const check = document.createElement('span');
|
||
check.className = 'vuln-filter-select-check';
|
||
check.setAttribute('aria-hidden', 'true');
|
||
check.textContent = '✓';
|
||
const label = document.createElement('span');
|
||
label.className = 'vuln-filter-select-label';
|
||
label.textContent = opt.textContent;
|
||
item.appendChild(check);
|
||
item.appendChild(label);
|
||
dropdown.appendChild(item);
|
||
});
|
||
|
||
const selectedOpt = select.options[select.selectedIndex];
|
||
if (valueSpan) {
|
||
valueSpan.textContent = selectedOpt ? selectedOpt.textContent : '';
|
||
}
|
||
trigger.disabled = !!select.disabled;
|
||
reg.wrapper.classList.toggle('is-disabled', !!select.disabled);
|
||
}
|
||
|
||
function syncAllVulnFilterCustomSelects() {
|
||
VULN_FILTER_CUSTOM_SELECT_IDS.forEach(syncVulnFilterCustomSelect);
|
||
}
|
||
|
||
function enhanceVulnFilterCustomSelect(selectId) {
|
||
const select = document.getElementById(selectId);
|
||
if (!select) return;
|
||
if (select.dataset.vulnCustomSelect === '1') {
|
||
syncVulnFilterCustomSelect(selectId);
|
||
return;
|
||
}
|
||
select.dataset.vulnCustomSelect = '1';
|
||
select.classList.add('vuln-filter-native-select');
|
||
select.tabIndex = -1;
|
||
select.setAttribute('aria-hidden', 'true');
|
||
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'vuln-filter-select';
|
||
|
||
const trigger = document.createElement('button');
|
||
trigger.type = 'button';
|
||
trigger.className = 'vuln-filter-select-trigger';
|
||
trigger.setAttribute('aria-haspopup', 'listbox');
|
||
trigger.setAttribute('aria-expanded', 'false');
|
||
const valueSpan = document.createElement('span');
|
||
valueSpan.className = 'vuln-filter-select-value';
|
||
trigger.appendChild(valueSpan);
|
||
trigger.insertAdjacentHTML('beforeend', VULN_FILTER_SELECT_CARET);
|
||
|
||
const dropdown = document.createElement('div');
|
||
dropdown.className = 'vuln-filter-select-dropdown';
|
||
dropdown.setAttribute('role', 'listbox');
|
||
|
||
const parent = select.parentNode;
|
||
parent.insertBefore(wrapper, select);
|
||
wrapper.appendChild(trigger);
|
||
wrapper.appendChild(dropdown);
|
||
wrapper.appendChild(select);
|
||
|
||
vulnFilterCustomSelectMap[selectId] = { wrapper: wrapper, trigger: trigger, dropdown: dropdown, select: select };
|
||
|
||
trigger.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
if (select.disabled) return;
|
||
if (typeof closeAllVulnerabilityStatusPickers === 'function') {
|
||
closeAllVulnerabilityStatusPickers();
|
||
}
|
||
closeAllVulnDetailProjectSelects();
|
||
const open = wrapper.classList.contains('open');
|
||
closeAllVulnFilterCustomSelects();
|
||
if (!open) {
|
||
wrapper.classList.add('open');
|
||
trigger.setAttribute('aria-expanded', 'true');
|
||
}
|
||
});
|
||
|
||
dropdown.addEventListener('click', function (e) {
|
||
const opt = e.target.closest('.vuln-filter-select-option');
|
||
if (!opt) return;
|
||
e.stopPropagation();
|
||
const val = opt.getAttribute('data-value');
|
||
if (val === null) return;
|
||
if (select.value !== val) {
|
||
select.value = val;
|
||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
wrapper.classList.remove('open');
|
||
trigger.setAttribute('aria-expanded', 'false');
|
||
syncVulnFilterCustomSelect(selectId);
|
||
});
|
||
}
|
||
|
||
function initVulnerabilityFilterSelects() {
|
||
if (!vulnFilterCustomSelectDocBound) {
|
||
document.addEventListener('click', closeAllVulnFilterCustomSelects);
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape') closeAllVulnFilterCustomSelects();
|
||
});
|
||
vulnFilterCustomSelectDocBound = true;
|
||
}
|
||
VULN_FILTER_CUSTOM_SELECT_IDS.forEach(enhanceVulnFilterCustomSelect);
|
||
syncAllVulnFilterCustomSelects();
|
||
}
|
||
|
||
const VULN_DETAIL_SELECT_CARET = '<svg class="vuln-detail-select-caret" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||
const vulnDetailProjectSelectMap = new WeakMap();
|
||
let vulnDetailProjectSelectDocBound = false;
|
||
|
||
function closeAllVulnDetailProjectSelects() {
|
||
document.querySelectorAll('.vuln-detail-select-ui.open').forEach(function (wrapper) {
|
||
wrapper.classList.remove('open');
|
||
const trigger = wrapper.querySelector('.vuln-detail-select-trigger');
|
||
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
||
});
|
||
}
|
||
|
||
function syncVulnDetailProjectSelect(select) {
|
||
const reg = vulnDetailProjectSelectMap.get(select);
|
||
if (!reg) return;
|
||
const dropdown = reg.dropdown;
|
||
const trigger = reg.trigger;
|
||
const valueSpan = trigger.querySelector('.vuln-detail-select-value');
|
||
|
||
dropdown.innerHTML = '';
|
||
Array.prototype.forEach.call(select.options, function (opt) {
|
||
const item = document.createElement('button');
|
||
item.type = 'button';
|
||
item.className = 'vuln-detail-select-option';
|
||
item.setAttribute('role', 'option');
|
||
item.setAttribute('data-value', opt.value);
|
||
if (opt.value === select.value) {
|
||
item.classList.add('is-selected');
|
||
item.setAttribute('aria-selected', 'true');
|
||
} else {
|
||
item.setAttribute('aria-selected', 'false');
|
||
}
|
||
const check = document.createElement('span');
|
||
check.className = 'vuln-detail-select-check';
|
||
check.setAttribute('aria-hidden', 'true');
|
||
check.textContent = '✓';
|
||
const label = document.createElement('span');
|
||
label.className = 'vuln-detail-select-label';
|
||
label.textContent = opt.textContent;
|
||
item.appendChild(check);
|
||
item.appendChild(label);
|
||
dropdown.appendChild(item);
|
||
});
|
||
|
||
const selectedOpt = select.options[select.selectedIndex];
|
||
if (valueSpan) {
|
||
valueSpan.textContent = selectedOpt ? selectedOpt.textContent : '';
|
||
}
|
||
trigger.disabled = !!select.disabled;
|
||
reg.wrapper.classList.toggle('is-disabled', !!select.disabled);
|
||
}
|
||
|
||
function enhanceVulnDetailProjectSelect(select) {
|
||
if (!select || !select.classList.contains('vulnerability-project-bind-select')) return;
|
||
if (select.dataset.vulnDetailCustomSelect === '1') {
|
||
syncVulnDetailProjectSelect(select);
|
||
return;
|
||
}
|
||
select.dataset.vulnDetailCustomSelect = '1';
|
||
select.classList.add('vuln-detail-native-select');
|
||
select.tabIndex = -1;
|
||
select.setAttribute('aria-hidden', 'true');
|
||
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'vuln-detail-select-ui';
|
||
|
||
const trigger = document.createElement('button');
|
||
trigger.type = 'button';
|
||
trigger.className = 'vuln-detail-select-trigger';
|
||
trigger.setAttribute('aria-haspopup', 'listbox');
|
||
trigger.setAttribute('aria-expanded', 'false');
|
||
const label = select.getAttribute('aria-label');
|
||
if (label) trigger.setAttribute('aria-label', label);
|
||
const valueSpan = document.createElement('span');
|
||
valueSpan.className = 'vuln-detail-select-value';
|
||
trigger.appendChild(valueSpan);
|
||
trigger.insertAdjacentHTML('beforeend', VULN_DETAIL_SELECT_CARET);
|
||
|
||
const dropdown = document.createElement('div');
|
||
dropdown.className = 'vuln-detail-select-dropdown';
|
||
dropdown.setAttribute('role', 'listbox');
|
||
|
||
const parent = select.parentNode;
|
||
parent.insertBefore(wrapper, select);
|
||
wrapper.appendChild(trigger);
|
||
wrapper.appendChild(dropdown);
|
||
wrapper.appendChild(select);
|
||
|
||
vulnDetailProjectSelectMap.set(select, { wrapper: wrapper, trigger: trigger, dropdown: dropdown, select: select });
|
||
|
||
trigger.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
if (select.disabled) return;
|
||
if (typeof closeAllVulnerabilityStatusPickers === 'function') {
|
||
closeAllVulnerabilityStatusPickers();
|
||
}
|
||
closeAllVulnFilterCustomSelects();
|
||
const open = wrapper.classList.contains('open');
|
||
closeAllVulnDetailProjectSelects();
|
||
if (!open) {
|
||
wrapper.classList.add('open');
|
||
trigger.setAttribute('aria-expanded', 'true');
|
||
}
|
||
});
|
||
|
||
dropdown.addEventListener('click', function (e) {
|
||
const opt = e.target.closest('.vuln-detail-select-option');
|
||
if (!opt) return;
|
||
e.stopPropagation();
|
||
const val = opt.getAttribute('data-value');
|
||
if (val === null) return;
|
||
if (select.value !== val) {
|
||
select.value = val;
|
||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
wrapper.classList.remove('open');
|
||
trigger.setAttribute('aria-expanded', 'false');
|
||
syncVulnDetailProjectSelect(select);
|
||
});
|
||
|
||
syncVulnDetailProjectSelect(select);
|
||
}
|
||
|
||
function initVulnerabilityProjectBindSelects(root) {
|
||
if (!vulnDetailProjectSelectDocBound) {
|
||
document.addEventListener('click', closeAllVulnDetailProjectSelects);
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Escape') closeAllVulnDetailProjectSelects();
|
||
});
|
||
vulnDetailProjectSelectDocBound = true;
|
||
}
|
||
const scope = root || document.getElementById('vulnerabilities-list');
|
||
if (!scope) return;
|
||
scope.querySelectorAll('select.vulnerability-project-bind-select').forEach(enhanceVulnDetailProjectSelect);
|
||
}
|
||
|
||
function countVulnerabilityAdvancedFiltersActive() {
|
||
const f = vulnerabilityFilters;
|
||
let n = 0;
|
||
if (f.id) n++;
|
||
if (f.conversation_id) n++;
|
||
if (f.task_id) n++;
|
||
if (f.conversation_tag) n++;
|
||
if (f.task_tag) n++;
|
||
return n;
|
||
}
|
||
|
||
function updateVulnerabilityAdvancedBadge() {
|
||
const badge = document.getElementById('vulnerability-advanced-badge');
|
||
if (!badge) return;
|
||
readVulnerabilityFiltersFromForm();
|
||
const n = countVulnerabilityAdvancedFiltersActive();
|
||
if (n > 0) {
|
||
badge.hidden = false;
|
||
badge.textContent = '(' + n + ')';
|
||
badge.setAttribute('aria-label', String(n));
|
||
} else {
|
||
badge.hidden = true;
|
||
badge.textContent = '';
|
||
badge.removeAttribute('aria-label');
|
||
}
|
||
}
|
||
|
||
function updateVulnerabilityFilterPanelState() {
|
||
const panel = document.getElementById('vulnerability-filter-panel');
|
||
if (!panel) return;
|
||
readVulnerabilityFiltersFromForm();
|
||
panel.classList.toggle('is-filtered', hasAnyVulnerabilityFilterActive());
|
||
updateVulnerabilityAdvancedBadge();
|
||
const clearBtn = document.getElementById('vulnerability-filter-clear-btn');
|
||
if (clearBtn) clearBtn.hidden = !hasAnyVulnerabilityFilterActive();
|
||
}
|
||
|
||
function formatVulnerabilityFilterChipValue(key, value) {
|
||
if (key === 'severity') return vulnSeverityLabel(value);
|
||
if (key === 'status') return vulnStatusLabel(value);
|
||
if (key === 'project_id') {
|
||
const name = typeof getProjectName === 'function' ? getProjectName(value) : '';
|
||
return name && name !== value ? name : value;
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function renderVulnerabilityFilterChips() {
|
||
const wrap = document.getElementById('vulnerability-filter-chips');
|
||
const list = document.getElementById('vulnerability-filter-chips-list');
|
||
if (!wrap || !list) return;
|
||
|
||
readVulnerabilityFiltersFromForm();
|
||
const chips = [];
|
||
VULN_FILTER_CHIP_FIELDS.forEach(function (field) {
|
||
const val = vulnerabilityFilters[field.key];
|
||
if (!val) return;
|
||
const label = field.labelKey ? vulnT(field.labelKey) : (field.key === 'project_id' ? '项目' : '');
|
||
const displayVal = formatVulnerabilityFilterChipValue(field.key, val);
|
||
const text = label ? label + ': ' + displayVal : displayVal;
|
||
chips.push({ key: field.key, text: text });
|
||
});
|
||
|
||
if (!chips.length) {
|
||
wrap.hidden = true;
|
||
list.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
wrap.hidden = false;
|
||
const removeLabel = vulnT('vulnerabilityPage.chipRemove');
|
||
list.innerHTML = chips.map(function (chip) {
|
||
return (
|
||
'<button type="button" class="vulnerability-filter-chip" role="listitem" data-filter-key="' +
|
||
escapeHtml(chip.key) + '" title="' + escapeHtml(removeLabel) + '">' +
|
||
'<span>' + escapeHtml(chip.text) + '</span>' +
|
||
'<span class="vulnerability-filter-chip-remove" aria-hidden="true">×</span>' +
|
||
'</button>'
|
||
);
|
||
}).join('');
|
||
|
||
list.querySelectorAll('.vulnerability-filter-chip').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
const key = btn.getAttribute('data-filter-key');
|
||
if (key) removeVulnerabilityFilterByKey(key);
|
||
});
|
||
});
|
||
}
|
||
|
||
function removeVulnerabilityFilterByKey(key) {
|
||
const map = {
|
||
q: 'vulnerability-search-filter',
|
||
id: 'vulnerability-exact-id-filter',
|
||
conversation_id: 'vulnerability-conversation-filter',
|
||
task_id: 'vulnerability-task-filter',
|
||
conversation_tag: 'vulnerability-conversation-tag-filter',
|
||
task_tag: 'vulnerability-task-tag-filter',
|
||
project_id: 'vulnerability-project-filter',
|
||
severity: 'vulnerability-severity-filter',
|
||
status: 'vulnerability-status-filter'
|
||
};
|
||
const elId = map[key];
|
||
if (elId) {
|
||
const el = document.getElementById(elId);
|
||
if (el) el.value = '';
|
||
}
|
||
if (Object.prototype.hasOwnProperty.call(vulnerabilityFilters, key)) {
|
||
vulnerabilityFilters[key] = '';
|
||
}
|
||
if (key === 'project_id' || key === 'status') {
|
||
syncAllVulnFilterCustomSelects();
|
||
}
|
||
applyVulnerabilityFilters();
|
||
}
|
||
|
||
async function loadVulnerabilityFilterOptions() {
|
||
if (typeof apiFetch === 'undefined') return;
|
||
try {
|
||
const response = await apiFetch('/api/vulnerabilities/filter-options');
|
||
if (!response.ok) return;
|
||
vulnerabilityFilterOptionsCache = await response.json();
|
||
populateVulnerabilityDatalist(
|
||
'vulnerability-conversation-tag-suggestions',
|
||
vulnerabilityFilterOptionsCache.conversation_tags,
|
||
{ max: 20 }
|
||
);
|
||
populateVulnerabilityDatalist(
|
||
'vulnerability-task-tag-suggestions',
|
||
vulnerabilityFilterOptionsCache.task_tags,
|
||
{ max: 20 }
|
||
);
|
||
clearVulnerabilityDatalist('vulnerability-conversation-suggestions');
|
||
clearVulnerabilityDatalist('vulnerability-task-suggestions');
|
||
} catch (e) {
|
||
console.warn('加载漏洞筛选建议失败', e);
|
||
}
|
||
}
|
||
|
||
function clearVulnerabilityDatalist(listId) {
|
||
const list = document.getElementById(listId);
|
||
if (list) list.innerHTML = '';
|
||
}
|
||
|
||
function populateVulnerabilityDatalist(listId, values, opts) {
|
||
const list = document.getElementById(listId);
|
||
if (!list || !Array.isArray(values)) return;
|
||
const max = (opts && opts.max) || VULNERABILITY_DATALIST_MAX;
|
||
const seen = new Set();
|
||
const unique = [];
|
||
values.forEach(function (v) {
|
||
const s = String(v || '').trim();
|
||
if (!s || seen.has(s)) return;
|
||
seen.add(s);
|
||
unique.push(s);
|
||
if (unique.length >= max) return;
|
||
});
|
||
list.innerHTML = unique.slice(0, max).map(function (v) {
|
||
return '<option value="' + escapeHtml(v) + '"></option>';
|
||
}).join('');
|
||
}
|
||
|
||
function filterVulnerabilitySuggestionPool(pool, query) {
|
||
if (!Array.isArray(pool) || !query) return [];
|
||
const q = query.toLowerCase();
|
||
const out = [];
|
||
for (let i = 0; i < pool.length && out.length < VULNERABILITY_DATALIST_MAX; i++) {
|
||
const s = String(pool[i] || '').trim();
|
||
if (s && s.toLowerCase().indexOf(q) !== -1) out.push(s);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function updateVulnerabilityTypeaheadDatalist(inputId, listId, poolKey) {
|
||
const el = document.getElementById(inputId);
|
||
if (!el || !vulnerabilityFilterOptionsCache) return;
|
||
const q = el.value.trim();
|
||
if (q.length < VULNERABILITY_DATALIST_MIN_QUERY) {
|
||
clearVulnerabilityDatalist(listId);
|
||
return;
|
||
}
|
||
let pool = vulnerabilityFilterOptionsCache[poolKey] || [];
|
||
if (poolKey === 'task_ids') {
|
||
pool = (vulnerabilityFilterOptionsCache.task_ids || []).concat(vulnerabilityFilterOptionsCache.queue_ids || []);
|
||
}
|
||
populateVulnerabilityDatalist(listId, filterVulnerabilitySuggestionPool(pool, q));
|
||
}
|
||
|
||
function bindVulnerabilityFilterTypeaheads() {
|
||
const pairs = [
|
||
{ inputId: 'vulnerability-conversation-filter', listId: 'vulnerability-conversation-suggestions', poolKey: 'conversation_ids' },
|
||
{ inputId: 'vulnerability-task-filter', listId: 'vulnerability-task-suggestions', poolKey: 'task_ids' }
|
||
];
|
||
pairs.forEach(function (pair) {
|
||
const el = document.getElementById(pair.inputId);
|
||
if (!el) return;
|
||
el.addEventListener('input', function () {
|
||
updateVulnerabilityTypeaheadDatalist(pair.inputId, pair.listId, pair.poolKey);
|
||
});
|
||
el.addEventListener('blur', function () {
|
||
setTimeout(function () { clearVulnerabilityDatalist(pair.listId); }, 150);
|
||
});
|
||
});
|
||
|
||
['vulnerability-conversation-tag-filter', 'vulnerability-task-tag-filter'].forEach(function (inputId) {
|
||
const el = document.getElementById(inputId);
|
||
if (!el) return;
|
||
el.addEventListener('focus', function () {
|
||
if (!vulnerabilityFilterOptionsCache) return;
|
||
const listId = inputId === 'vulnerability-conversation-tag-filter'
|
||
? 'vulnerability-conversation-tag-suggestions'
|
||
: 'vulnerability-task-tag-suggestions';
|
||
const key = inputId === 'vulnerability-conversation-tag-filter' ? 'conversation_tags' : 'task_tags';
|
||
const q = el.value.trim();
|
||
if (q.length >= VULNERABILITY_DATALIST_MIN_QUERY) {
|
||
populateVulnerabilityDatalist(listId, filterVulnerabilitySuggestionPool(vulnerabilityFilterOptionsCache[key], q), { max: 20 });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function syncVulnerabilityStatCardActiveState() {
|
||
const sevEl = document.getElementById('vulnerability-severity-filter');
|
||
const sev = (sevEl && sevEl.value) || vulnerabilityFilters.severity || '';
|
||
const root = document.getElementById('vulnerability-stat-cards');
|
||
if (!root) return;
|
||
root.querySelectorAll('.stat-card.is-clickable').forEach(function (card) {
|
||
if (card.classList.contains('stat-card-total')) {
|
||
card.classList.toggle('is-active', !sev);
|
||
card.setAttribute('aria-pressed', sev ? 'false' : 'true');
|
||
} else {
|
||
const cardSev = card.getAttribute('data-severity');
|
||
const active = Boolean(sev && cardSev === sev);
|
||
card.classList.toggle('is-active', active);
|
||
card.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateVulnerabilityStatStackedBar(bySeverity, total) {
|
||
const bar = document.getElementById('stat-stacked-bar');
|
||
if (!bar) return;
|
||
const segs = bar.querySelectorAll('.stat-stacked-seg');
|
||
if (!total) {
|
||
bar.classList.add('is-empty');
|
||
segs.forEach(function (seg) {
|
||
seg.style.flex = '0 0 0';
|
||
seg.style.display = 'none';
|
||
});
|
||
return;
|
||
}
|
||
bar.classList.remove('is-empty');
|
||
segs.forEach(function (seg) {
|
||
const sev = seg.getAttribute('data-sev');
|
||
const count = bySeverity[sev] || 0;
|
||
if (count <= 0) {
|
||
seg.style.display = 'none';
|
||
seg.style.flex = '0 0 0';
|
||
return;
|
||
}
|
||
seg.style.display = '';
|
||
const pct = Math.max((count / total) * 100, 0);
|
||
seg.style.flex = '1 1 ' + pct + '%';
|
||
});
|
||
}
|
||
|
||
// 加载漏洞统计
|
||
async function loadVulnerabilityStats() {
|
||
const request = ++vulnerabilityStatsRequest;
|
||
try {
|
||
// 检查apiFetch是否可用
|
||
if (typeof apiFetch === 'undefined') {
|
||
console.error('apiFetch未定义,请确保auth.js已加载');
|
||
throw new Error('apiFetch未定义');
|
||
}
|
||
|
||
const params = buildVulnerabilityDashboardStatsParams();
|
||
|
||
const response = await apiFetch(`/api/vulnerabilities/stats?${params.toString()}`);
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
console.error('获取统计失败:', response.status, errorText);
|
||
throw new Error(`获取统计失败: ${response.status}`);
|
||
}
|
||
|
||
const stats = await response.json();
|
||
if (request !== vulnerabilityStatsRequest) return;
|
||
updateVulnerabilityStats(stats);
|
||
} catch (error) {
|
||
if (request !== vulnerabilityStatsRequest) return;
|
||
console.error('加载漏洞统计失败:', error);
|
||
// 统计失败不影响列表显示,只重置统计为0
|
||
updateVulnerabilityStats(null);
|
||
}
|
||
}
|
||
|
||
// 更新漏洞统计显示
|
||
function updateVulnerabilityStats(stats) {
|
||
// 处理空值情况
|
||
if (!stats) {
|
||
stats = {
|
||
total: 0,
|
||
by_severity: {},
|
||
by_status: {}
|
||
};
|
||
}
|
||
|
||
const total = stats.total || 0;
|
||
const bySeverity = stats.by_severity || {};
|
||
|
||
const totalEl = document.getElementById('stat-total');
|
||
if (totalEl) {
|
||
totalEl.textContent = String(total);
|
||
totalEl.classList.toggle('is-zero', total === 0);
|
||
}
|
||
|
||
VULN_STAT_SEVERITIES.forEach(function (sev) {
|
||
const count = bySeverity[sev] || 0;
|
||
const valEl = document.getElementById('stat-' + sev);
|
||
const pctEl = document.getElementById('stat-' + sev + '-pct');
|
||
if (valEl) {
|
||
valEl.textContent = String(count);
|
||
valEl.classList.toggle('is-zero', count === 0);
|
||
}
|
||
if (pctEl) {
|
||
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||
pctEl.textContent = pct + '%';
|
||
pctEl.setAttribute('aria-hidden', total === 0 ? 'true' : 'false');
|
||
}
|
||
});
|
||
|
||
updateVulnerabilityStatStackedBar(bySeverity, total);
|
||
syncVulnerabilityStatCardActiveState();
|
||
}
|
||
|
||
// 加载漏洞列表
|
||
async function loadVulnerabilities(page = null, options = {}) {
|
||
const request = ++vulnerabilityListRequest;
|
||
const view = vulnerabilityView;
|
||
const opts = options && typeof options === 'object' ? options : {};
|
||
const preserveScroll = !!opts.preserveScroll;
|
||
const silent = !!opts.silent;
|
||
let expandedIds = opts.expandedIds;
|
||
|
||
const scrollEl = preserveScroll ? getVulnerabilityScrollContainer() : null;
|
||
const scrollTop = scrollEl ? scrollEl.scrollTop : 0;
|
||
if (expandedIds === undefined && preserveScroll) {
|
||
expandedIds = getExpandedVulnerabilityIds();
|
||
}
|
||
|
||
const listContainer = document.getElementById('vulnerabilities-list');
|
||
const hostOpenStates = preserveScroll
|
||
? new Map(Array.from(listContainer.querySelectorAll('.vulnerability-domain-branch'), branch => [branch.dataset.domain, branch.open]))
|
||
: new Map();
|
||
if (!silent) {
|
||
listContainer.innerHTML = `<div class="loading-spinner">${escapeHtml(vulnT('vulnerabilityPage.loading'))}</div>`;
|
||
updateVulnerabilityListSummary();
|
||
}
|
||
|
||
try {
|
||
// 检查apiFetch是否可用
|
||
if (typeof apiFetch === 'undefined') {
|
||
console.error('apiFetch未定义,请确保auth.js已加载');
|
||
throw new Error('apiFetch未定义');
|
||
}
|
||
|
||
// 如果指定了页码,使用页码;否则使用当前页码
|
||
if (page !== null) {
|
||
vulnerabilityPagination.currentPage = page;
|
||
}
|
||
|
||
const params = buildVulnerabilityFilterParams();
|
||
params.append('group_by', view);
|
||
params.append('summary', 'true');
|
||
if (vulnerabilitySort) params.append('sort', vulnerabilitySort);
|
||
if (view === 'host' && vulnerabilityHostQuery) params.append('host_q', vulnerabilityHostQuery);
|
||
if (view === 'task' && vulnerabilityTaskQuery) params.append('task_q', vulnerabilityTaskQuery);
|
||
params.append('page', vulnerabilityPagination.currentPage.toString());
|
||
params.append('limit', vulnerabilityPagination.pageSize.toString());
|
||
|
||
const response = await apiFetch(`/api/vulnerabilities?${params.toString()}`);
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
console.error('获取漏洞列表失败:', response.status, errorText);
|
||
throw new Error(`获取漏洞列表失败: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
if (request !== vulnerabilityListRequest) return;
|
||
|
||
// 判断响应格式:新格式(有total字段)还是旧格式(直接是数组)
|
||
let vulnerabilities;
|
||
if (Array.isArray(data)) {
|
||
// 旧格式:直接是数组
|
||
vulnerabilities = data;
|
||
// 使用数组长度作为总数(可能不准确,但至少能显示分页控件)
|
||
vulnerabilityPagination.total = data.length;
|
||
vulnerabilityPagination.totalPages = Math.max(1, Math.ceil(data.length / vulnerabilityPagination.pageSize));
|
||
console.warn('后端返回的是旧格式(数组),建议更新后端API以支持分页');
|
||
} else if ('vulnerabilities' in data) {
|
||
// 新格式:包含分页信息的对象(vulnerabilities可能为null或数组)
|
||
vulnerabilities = Array.isArray(data.vulnerabilities) ? data.vulnerabilities : [];
|
||
vulnerabilityPagination.total = data.total || 0;
|
||
vulnerabilityPagination.currentPage = data.page || vulnerabilityPagination.currentPage;
|
||
vulnerabilityPagination.pageSize = data.page_size || vulnerabilityPagination.pageSize;
|
||
vulnerabilityPagination.totalPages = data.total_pages || 1;
|
||
} else {
|
||
// 未知格式,尝试作为数组处理
|
||
vulnerabilities = [];
|
||
console.error('未知的响应格式:', data);
|
||
}
|
||
|
||
renderVulnerabilities(vulnerabilities, {
|
||
view, expandedIds: expandedIds || [], hostOpenStates,
|
||
preserveHostSelection: preserveScroll
|
||
});
|
||
renderVulnerabilityPagination();
|
||
if (page !== null && !preserveScroll) {
|
||
const container = getVulnerabilityScrollContainer();
|
||
if (container) container.scrollTop = 0;
|
||
}
|
||
|
||
if (preserveScroll && scrollEl) {
|
||
requestAnimationFrame(function () {
|
||
if (request === vulnerabilityListRequest) scrollEl.scrollTop = scrollTop;
|
||
});
|
||
}
|
||
} catch (error) {
|
||
if (request !== vulnerabilityListRequest) return;
|
||
console.error('加载漏洞列表失败:', error);
|
||
listContainer.innerHTML = `<div class="error-message">${escapeHtml(vulnT('vulnerabilityPage.loadListFailed'))}: ${escapeHtml(error.message)}</div>`;
|
||
updateVulnerabilityListSummary();
|
||
}
|
||
}
|
||
|
||
// 渲染漏洞列表
|
||
function renderVulnerabilities(vulnerabilities, renderOptions) {
|
||
const opts = renderOptions && typeof renderOptions === 'object' ? renderOptions : {};
|
||
const listContainer = document.getElementById('vulnerabilities-list');
|
||
if (typeof setVulnerabilityWorkspaceRows === 'function') setVulnerabilityWorkspaceRows(vulnerabilities || []);
|
||
if (opts.view === 'host' || opts.view === 'task') {
|
||
renderVulnerabilityBrowser(Array.isArray(vulnerabilities) ? vulnerabilities : [], opts);
|
||
return;
|
||
}
|
||
|
||
if (!Array.isArray(vulnerabilities) || vulnerabilities.length === 0) {
|
||
listContainer.innerHTML = '<div class="empty-state" data-i18n="vulnerabilityPage.noRecords">暂无漏洞记录</div>';
|
||
if (typeof window.applyTranslations === 'function') {
|
||
window.applyTranslations(listContainer);
|
||
}
|
||
updateVulnerabilityListSummary();
|
||
return;
|
||
}
|
||
|
||
const exactMatch = vulnerabilities.length === 1 && vulnerabilityFilters.id === vulnerabilities[0].id
|
||
? vulnerabilities[0].id
|
||
: null;
|
||
const expandedIds = exactMatch ? [exactMatch] : opts.expandedIds;
|
||
listContainer.innerHTML = renderVulnerabilityTable(vulnerabilities);
|
||
initVulnerabilityStatusPickers(listContainer);
|
||
initVulnerabilityProjectBindSelects(listContainer);
|
||
if (typeof window.applyTranslations === 'function') {
|
||
window.applyTranslations(listContainer);
|
||
}
|
||
if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(listContainer);
|
||
if (exactMatch && vulnerabilities[0].summary_only && typeof openVulnerabilityInspector === 'function') {
|
||
openVulnerabilityInspector(exactMatch);
|
||
} else {
|
||
restoreExpandedVulnerabilityDetails(expandedIds);
|
||
}
|
||
updateVulnerabilityListSummary();
|
||
}
|
||
|
||
function changeVulnerabilitySort(value) {
|
||
if (!['', 'severity', 'latest'].includes(value)) return;
|
||
vulnerabilitySort = value;
|
||
localStorage.setItem('vulnerabilityListSort', value);
|
||
loadVulnerabilities(1);
|
||
}
|
||
|
||
function updateVulnerabilityListSummary() {
|
||
const list = document.getElementById('vulnerabilities-list');
|
||
const toolbar = document.getElementById('vulnerability-list-toolbar');
|
||
if (!list || !toolbar) return;
|
||
const count = ['host','task'].includes(vulnerabilityView) && list.querySelector('.vulnerability-host-browser')
|
||
? new Set(vulnerabilityBrowserRows.map(vuln => vuln.id)).size
|
||
: list.querySelectorAll('.vulnerability-card, .vulnerability-table-item').length;
|
||
document.getElementById('vulnerability-page-summary').textContent = count
|
||
? vulnT('vulnerabilityPage.pageFindings', { count }) : '';
|
||
}
|
||
|
||
function renderVulnerabilityBrowser(vulnerabilities, opts) {
|
||
const activeSearch = document.activeElement?.id === 'vulnerability-host-search';
|
||
const caret = activeSearch ? document.activeElement.selectionStart : null;
|
||
vulnerabilityBrowserRows = vulnerabilities;
|
||
const isTask = opts.view === 'task';
|
||
const groups = new Map();
|
||
for (const vuln of vulnerabilities) {
|
||
const host = vuln.target_host || '';
|
||
const domain = vuln.target_domain || host;
|
||
let rootKey = 'domain:' + domain, rootLabel = domain;
|
||
let key = 'host:' + host, label = host || vulnT('vulnerabilityPage.unknownHost');
|
||
let flat = host === domain;
|
||
if (isTask) {
|
||
const tag = (vuln.task_tag || '').replace(/^ +| +$/g, '');
|
||
key = vuln.task_id ? 'task:' + vuln.task_id : 'tag:' + tag;
|
||
label = vuln.task_id ? vulnT('vulnerabilityPage.viewTaskGroup', {id:vuln.task_id}) : tag || vulnT('vulnerabilityPage.viewNoTask');
|
||
rootKey = vuln.task_queue_id ? 'queue:' + vuln.task_queue_id : key;
|
||
rootLabel = vuln.task_queue_title || (vuln.task_queue_id
|
||
? vulnT('vulnerabilityPage.taskQueueGroup', {id:vuln.task_queue_id}) : label);
|
||
flat = !vuln.task_queue_id;
|
||
}
|
||
if (!groups.has(rootKey)) groups.set(rootKey, {label:rootLabel, children:new Map(), items:[], flat});
|
||
const group = groups.get(rootKey);
|
||
group.flat = group.flat && flat;
|
||
group.items.push(vuln);
|
||
if (!group.children.has(key)) group.children.set(key, {label, items:[]});
|
||
group.children.get(key).items.push(vuln);
|
||
}
|
||
const button = (key, label, count, items = []) => `
|
||
<button type="button" class="vulnerability-host-option" data-host-key="${escapeAttr(key)}" aria-pressed="false">
|
||
<span class="vulnerability-host-option-name" title="${escapeAttr(label)}">${renderVulnerabilityHostRiskDot(items)}${escapeHtml(label)}</span><span class="vulnerability-host-number">${count}</span>
|
||
</button>`;
|
||
const tree = Array.from(groups, ([rootKey, group]) => {
|
||
const count = new Set(group.items.map(vuln => vuln.id)).size;
|
||
if (group.flat && group.children.size === 1) {
|
||
const [key,child] = group.children.entries().next().value;
|
||
return button(key, child.label, count, child.items);
|
||
}
|
||
const open = opts.hostOpenStates?.get(rootKey) ?? true;
|
||
return `<details class="vulnerability-domain-branch" data-domain="${escapeAttr(rootKey)}"${open ? ' open' : ''}>
|
||
<summary><button type="button" class="vulnerability-domain-label" data-host-key="${escapeAttr(rootKey)}" title="${escapeAttr(group.label)}" aria-pressed="false">${escapeHtml(group.label)}</button><span class="vulnerability-host-number">${count}</span></summary>
|
||
${Array.from(group.children, ([key, child]) => button(key, child.label, child.items.length, child.items)).join('')}
|
||
</details>`;
|
||
}).join('');
|
||
const list = document.getElementById('vulnerabilities-list');
|
||
list.innerHTML = `
|
||
<div class="vulnerability-host-browser">
|
||
<aside class="vulnerability-host-sidebar" aria-label="${escapeAttr(vulnT(isTask ? 'vulnerabilityPage.viewTask' : 'vulnerabilityPage.viewHost'))}">
|
||
<input id="vulnerability-host-search" type="search"
|
||
aria-label="${escapeAttr(vulnT(isTask ? 'vulnerabilityPage.taskSearch' : 'vulnerabilityPage.hostSearch'))}"
|
||
placeholder="${escapeAttr(vulnT(isTask ? 'vulnerabilityPage.taskSearch' : 'vulnerabilityPage.hostSearch'))}" value="${escapeAttr(isTask ? vulnerabilityTaskQuery : vulnerabilityHostQuery)}">
|
||
<nav class="vulnerability-host-tree">
|
||
${tree}
|
||
</nav>
|
||
</aside>
|
||
<section class="vulnerability-host-results">
|
||
<div class="vulnerability-host-results-heading">
|
||
<h3 id="vulnerability-host-selection"></h3>
|
||
<div class="vulnerability-host-heading-actions">
|
||
<button type="button" id="vulnerability-host-clear" class="btn-ghost" onclick="vulnerabilityBrowserSelection='';renderVulnerabilityBrowserTable()" hidden>${escapeHtml(vulnT(isTask ? 'vulnerabilityPage.clearTaskSelection' : 'vulnerabilityPage.clearHostSelection'))}</button>
|
||
<span id="vulnerability-host-selection-count"></span>
|
||
</div>
|
||
</div>
|
||
<div class="vulnerability-host-overview">
|
||
<div id="vulnerability-host-risk-summary"></div>
|
||
<button type="button" class="btn-secondary" id="vulnerability-host-export"
|
||
onclick="exportVisibleVulnerabilityBrowser()" data-require-permission="vulnerability:read">${escapeHtml(vulnT('vulnerabilityPage.exportVisibleHosts'))}</button>
|
||
</div>
|
||
<div id="vulnerability-host-table"></div>
|
||
</section>
|
||
</div>`;
|
||
const search = document.getElementById('vulnerability-host-search');
|
||
search.addEventListener('input', () => {
|
||
if (isTask) vulnerabilityTaskQuery = search.value.trim();
|
||
else vulnerabilityHostQuery = search.value.trim();
|
||
clearTimeout(vulnerabilityHostSearchTimer);
|
||
// Invalidate an in-flight response as soon as the next query is typed.
|
||
vulnerabilityListRequest++;
|
||
vulnerabilityHostSearchTimer = setTimeout(() => {
|
||
vulnerabilityBrowserSelection = '';
|
||
loadVulnerabilities(1, { silent: true });
|
||
}, VULNERABILITY_FILTER_DEBOUNCE_MS);
|
||
});
|
||
list.querySelector('.vulnerability-host-tree').addEventListener('click', event => {
|
||
const option = event.target.closest('button[data-host-key]');
|
||
if (!option) return;
|
||
event.preventDefault();
|
||
vulnerabilityBrowserSelection = option.dataset.hostKey;
|
||
renderVulnerabilityBrowserTable();
|
||
});
|
||
if (!opts.preserveHostSelection &&
|
||
!Array.from(list.querySelectorAll('[data-host-key]')).some(el => el.dataset.hostKey === vulnerabilityBrowserSelection)) {
|
||
vulnerabilityBrowserSelection = '';
|
||
}
|
||
renderVulnerabilityBrowserTable(opts.expandedIds);
|
||
if (activeSearch) {
|
||
search.focus({ preventScroll: true });
|
||
search.setSelectionRange(caret, caret);
|
||
}
|
||
}
|
||
|
||
function getVisibleVulnerabilityBrowserItems() {
|
||
const selection = vulnerabilityBrowserSelection;
|
||
const items = vulnerabilityBrowserRows.filter(vuln => !selection ||
|
||
selection === 'host:' + (vuln.target_host || '') ||
|
||
selection === 'domain:' + (vuln.target_domain || vuln.target_host || '') ||
|
||
(vuln.task_id && selection === 'task:' + vuln.task_id) ||
|
||
(vuln.task_queue_id && selection === 'queue:' + vuln.task_queue_id) ||
|
||
(!vuln.task_id && selection === 'tag:' + (vuln.task_tag || '').replace(/^ +| +$/g, '')));
|
||
return Array.from(new Map(items.map(vuln => [vuln.id,vuln])).values());
|
||
}
|
||
|
||
async function exportVisibleVulnerabilityBrowser() {
|
||
const items = getVisibleVulnerabilityBrowserItems();
|
||
if (!items.length) return;
|
||
try {
|
||
const full = [];
|
||
for (const item of items) {
|
||
if (!item.summary_only) { full.push(item); continue; }
|
||
const response = await apiFetch('/api/vulnerabilities/' + encodeURIComponent(item.id));
|
||
if (!response.ok) throw new Error(vulnT('vulnerabilityPage.exportFailed'));
|
||
full.push(await response.json());
|
||
}
|
||
const content = full.map(formatVulnerabilityAsMarkdown).join('\n\n---\n\n');
|
||
triggerTextDownload('vulnerabilities-' + vulnerabilityView + '-' + new Date().toISOString().slice(0, 10) + '.md', content);
|
||
} catch(error) { alert(error.message); }
|
||
}
|
||
|
||
function renderVulnerabilityHostRiskDot(items) {
|
||
const highest = VULN_STAT_SEVERITIES.find(severity => items.some(item => item.severity === severity));
|
||
return highest ? `<span class="vulnerability-host-risk-dot severity-${highest}" title="${escapeAttr(vulnSeverityLabel(highest))}"></span>` : '';
|
||
}
|
||
|
||
function renderVulnerabilityBrowserTable(expandedIds) {
|
||
const selection = vulnerabilityBrowserSelection;
|
||
const items = getVisibleVulnerabilityBrowserItems();
|
||
document.querySelectorAll('#vulnerabilities-list [data-host-key]').forEach(option => {
|
||
option.setAttribute('aria-pressed', String(option.dataset.hostKey === selection));
|
||
});
|
||
const selectedOption = Array.from(document.querySelectorAll('#vulnerabilities-list [data-host-key]')).find(option => option.dataset.hostKey === selection);
|
||
const label = selection ? (selectedOption?.querySelector('[title]')?.title || selectedOption?.title || selection.slice(selection.indexOf(':') + 1))
|
||
: vulnT(vulnerabilityView === 'task' ? 'vulnerabilityPage.allTasks' : 'vulnerabilityPage.allHosts');
|
||
const heading = document.getElementById('vulnerability-host-selection');
|
||
heading.textContent = label || vulnT('vulnerabilityPage.unknownHost');
|
||
heading.title = heading.textContent;
|
||
document.getElementById('vulnerability-host-clear').hidden = !selection;
|
||
document.getElementById('vulnerability-host-selection-count').textContent =
|
||
vulnT('vulnerabilityPage.groupPageCount', { count: items.length });
|
||
const counts = {};
|
||
let pending = 0;
|
||
for (const item of items) {
|
||
counts[item.severity] = (counts[item.severity] || 0) + 1;
|
||
if (!item.status || item.status === 'open' || item.status === 'confirmed') pending++;
|
||
}
|
||
document.getElementById('vulnerability-host-risk-summary').innerHTML =
|
||
VULN_STAT_SEVERITIES.filter(severity => counts[severity]).map(severity =>
|
||
`<span class="severity-badge severity-${severity}">${escapeHtml(vulnSeverityLabel(severity))} ${counts[severity]}</span>`
|
||
).join('') + `<span class="vulnerability-group-pending">${escapeHtml(vulnT('vulnerabilityPage.groupPending', { count: pending }))}</span>`;
|
||
document.getElementById('vulnerability-host-export').disabled = items.length === 0;
|
||
const container = document.getElementById('vulnerability-host-table');
|
||
container.innerHTML = renderVulnerabilityTable(items);
|
||
initVulnerabilityStatusPickers(container);
|
||
initVulnerabilityProjectBindSelects(container);
|
||
if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(container);
|
||
const exact = items.find(vuln => vulnerabilityFilters.id === vuln.id);
|
||
if (exact?.summary_only && typeof openVulnerabilityInspector === 'function') openVulnerabilityInspector(exact.id);
|
||
else restoreExpandedVulnerabilityDetails(exact ? [exact.id] : expandedIds);
|
||
updateVulnerabilityListSummary();
|
||
}
|
||
|
||
function renderVulnerabilityTable(items) {
|
||
return items.length ? `<div class="vulnerability-table-scroll">
|
||
<table class="vulnerability-host-table">
|
||
<colgroup>
|
||
<col class="vulnerability-col-select"><col class="vulnerability-col-severity"><col>
|
||
<col class="vulnerability-col-target"><col class="vulnerability-col-status">
|
||
<col class="vulnerability-col-task"><col class="vulnerability-col-date"><col class="vulnerability-col-actions">
|
||
</colgroup>
|
||
<thead><tr>
|
||
<th scope="col" class="vulnerability-select-column">${escapeHtml(vulnT('vulnerabilityPage.selectColumn'))}</th>
|
||
${['tableSeverity', 'tableTitle', 'detailTarget', 'tableStatus', 'tableTask', 'tableDate', 'tableActions']
|
||
.map(key => '<th scope="col">' + escapeHtml(vulnT('vulnerabilityPage.' + key)) + '</th>').join('')}
|
||
</tr></thead>
|
||
${items.map(renderVulnerabilityTableItem).join('')}
|
||
</table></div>` : `<div class="empty-state">${escapeHtml(vulnT('vulnerabilityPage.noRecords'))}</div>`;
|
||
}
|
||
|
||
function renderVulnerabilityTableItem(vuln) {
|
||
const id = escapeAttr(vuln.id);
|
||
const jsID = escapeJsStringAttr(vuln.id);
|
||
const task = vuln.task_tag || vuln.task_id || vulnT('vulnerabilityPage.viewNoTask');
|
||
const targetHosts = Array.from(new Map((vuln.targets || []).map(target => [target.host,target])).values());
|
||
const created = new Date(vuln.created_at);
|
||
return `<tbody class="vulnerability-table-item" id="vulnerability-card-${id}" data-vuln-id="${id}">
|
||
<tr>
|
||
<td class="vulnerability-select-column">${typeof vulnerabilitySelectionInput === 'function' ? vulnerabilitySelectionInput(vuln) : ''}</td>
|
||
<td><span class="severity-badge severity-${escapeAttr(vuln.severity)}">${escapeHtml(vulnSeverityLabel(vuln.severity))}</span></td>
|
||
<td class="vulnerability-table-title">
|
||
<button type="button" id="vulnerability-row-toggle-${id}" class="vulnerability-row-toggle"
|
||
aria-expanded="false" aria-controls="content-${id}" onclick="${vuln.summary_only ? 'openVulnerabilityInspector' : 'toggleVulnerabilityDetails'}(${jsID})">
|
||
<span id="expand-icon-${id}" class="vulnerability-row-caret" aria-hidden="true">›</span>
|
||
<span class="vulnerability-table-name" title="${escapeAttr(vuln.title)}">${escapeHtml(vuln.title)}</span>
|
||
</button>
|
||
<p>${escapeHtml((vuln.description || '').slice(0, 160))}</p>
|
||
${vuln.case_id ? `<span class="vulnerability-case-badge">${escapeHtml(vulnT('vulnerabilityPage.mergedRecord'))}</span>` : ''}
|
||
</td>
|
||
<td>${targetHosts.length ? targetHosts.slice(0,2).map(target =>
|
||
`<span class="vulnerability-table-target" title="${escapeAttr(target.raw)}">${escapeHtml(target.host)}</span>`).join('')
|
||
: `<span class="vulnerability-table-target" title="${escapeAttr(vuln.target || '')}">${escapeHtml(vuln.target_host || vuln.target || '—')}</span>`}
|
||
${(vuln.targets || []).length > 1 ? `<small>${escapeHtml(vulnT('vulnerabilityPage.targetCount', {count:vuln.targets.length}))}</small>` : ''}
|
||
</td>
|
||
<td>${buildVulnerabilityStatusPicker(vuln)}</td>
|
||
<td><span class="vulnerability-table-task" title="${escapeAttr(vuln.task_id || task)}">${escapeHtml(task)}</span></td>
|
||
<td class="vulnerability-table-date" title="${escapeAttr(created.toLocaleString(vulnDateLocale()))}">${escapeHtml(created.toLocaleString(vulnDateLocale(), {
|
||
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
|
||
}))}</td>
|
||
<td><div class="vulnerability-table-actions">
|
||
<button type="button" class="btn-ghost" data-require-permission="vulnerability:write" onclick="openVulnerabilityRetest(${jsID})" ${vuln.conversation_id ? '' : 'disabled'}>${escapeHtml(vulnT('vulnerabilityRetest.button'))}</button>
|
||
<button type="button" class="btn-ghost" onclick="openVulnerabilityDownload(${jsID}, event)">${escapeHtml(vulnT('vulnerabilityPage.tableDownload'))}</button>
|
||
<button type="button" class="btn-ghost" data-require-permission="vulnerability:write" onclick="editVulnerability(${jsID})">${escapeHtml(vulnT('common.edit'))}</button>
|
||
<button type="button" class="btn-ghost" data-require-permission="vulnerability:delete" onclick="deleteVulnerability(${jsID})">${escapeHtml(vulnT('common.delete'))}</button>
|
||
</div></td>
|
||
</tr>
|
||
${vuln.summary_only ? '' : `<tr class="vulnerability-table-detail" hidden><td colspan="8">${renderVulnerabilityDetailsContent(vuln)}</td></tr>`}
|
||
</tbody>`;
|
||
}
|
||
|
||
function renderVulnerabilityDetailsContent(vuln) {
|
||
return `
|
||
<div class="vulnerability-content" id="content-${vuln.id}" style="display: none;">
|
||
${vuln.description ? `<div class="vulnerability-description">${escapeHtml(vuln.description)}</div>` : ''}
|
||
<div class="vulnerability-details">
|
||
${vulnDetailField(vulnT('vulnerabilityPage.detailVulnId'), vuln.id, true)}
|
||
${vulnDetailProjectField(vuln)}
|
||
${vuln.type ? vulnDetailField(vulnT('vulnerabilityPage.detailType'), vuln.type, false) : ''}
|
||
${vuln.target ? vulnDetailField(vulnT('vulnerabilityPage.detailTarget'), vuln.target, false) : ''}
|
||
${vulnDetailConversationField(vuln.conversation_id)}
|
||
${vuln.task_id ? vulnDetailField(vulnT('vulnerabilityPage.detailTaskId'), vuln.task_id, true) : ''}
|
||
${vuln.task_queue_id ? vulnDetailField(vulnT('vulnerabilityPage.detailTaskQueueId'), vuln.task_queue_id, true) : ''}
|
||
${vuln.conversation_tag ? vulnDetailField(vulnT('vulnerabilityPage.detailConversationTag'), vuln.conversation_tag, false) : ''}
|
||
${vuln.task_tag ? vulnDetailField(vulnT('vulnerabilityPage.detailTaskTag'), vuln.task_tag, false) : ''}
|
||
</div>
|
||
<div class="vulnerability-repro">
|
||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailPreconditions'), vuln.preconditions)}
|
||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailReproductionSteps'), vuln.reproduction_steps)}
|
||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailEvidence'), vuln.evidence, { code: true })}
|
||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailImpact'), vuln.impact)}
|
||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailRecommendation'), vuln.recommendation)}
|
||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailRetestNotes'), vuln.retest_notes)}
|
||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailRetestLog'), vuln.retest_log, { code: true })}
|
||
</div>
|
||
<div class="vulnerability-related-facts" id="vuln-related-facts-${escapeAttr(vuln.id)}" data-project-id="${escapeAttr(vuln.project_id || '')}" data-vuln-id="${escapeAttr(vuln.id)}" hidden></div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 渲染分页控件
|
||
function renderVulnerabilityPagination() {
|
||
const paginationContainer = document.getElementById('vulnerability-pagination');
|
||
if (!paginationContainer) {
|
||
return;
|
||
}
|
||
|
||
const { currentPage, totalPages, total, pageSize } = vulnerabilityPagination;
|
||
const paginationLabel = {
|
||
host: 'vulnerabilityPage.hostPagination',
|
||
task: 'vulnerabilityPage.taskPagination',
|
||
none: 'vulnerabilityPage.recordPagination'
|
||
}[vulnerabilityView];
|
||
|
||
// 计算显示范围
|
||
const start = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
||
const end = total === 0 ? 0 : Math.min(currentPage * pageSize, total);
|
||
|
||
let paginationHTML = '<div class="pagination">';
|
||
|
||
// 左侧:显示范围信息和每页数量选择器(参考Skills样式)
|
||
paginationHTML += `
|
||
<div class="pagination-info">
|
||
<span>${escapeHtml(vulnT(paginationLabel, { start, end, total }))}</span>
|
||
<label class="pagination-page-size">
|
||
${escapeHtml(vulnT('skillsPage.perPageLabel'))}
|
||
<select id="vulnerability-page-size-pagination" onchange="changeVulnerabilityPageSize()">
|
||
<option value="10" ${pageSize === 10 ? 'selected' : ''}>10</option>
|
||
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
|
||
<option value="50" ${pageSize === 50 ? 'selected' : ''}>50</option>
|
||
<option value="100" ${pageSize === 100 ? 'selected' : ''}>100</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
`;
|
||
|
||
// 右侧:分页按钮(参考Skills样式:首页、上一页、第X/Y页、下一页、末页)
|
||
paginationHTML += `
|
||
<div class="pagination-controls">
|
||
<button class="btn-secondary" onclick="loadVulnerabilities(1)" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>${escapeHtml(vulnT('skillsPage.firstPage'))}</button>
|
||
<button class="btn-secondary" onclick="loadVulnerabilities(${currentPage - 1})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>${escapeHtml(vulnT('skillsPage.prevPage'))}</button>
|
||
<span class="pagination-page">${escapeHtml(vulnT('skillsPage.pageOf', { current: currentPage, total: totalPages || 1 }))}</span>
|
||
<button class="btn-secondary" onclick="loadVulnerabilities(${currentPage + 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>${escapeHtml(vulnT('skillsPage.nextPage'))}</button>
|
||
<button class="btn-secondary" onclick="loadVulnerabilities(${totalPages || 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>${escapeHtml(vulnT('skillsPage.lastPage'))}</button>
|
||
</div>
|
||
`;
|
||
|
||
paginationHTML += '</div>';
|
||
|
||
paginationContainer.innerHTML = paginationHTML;
|
||
if (typeof initPaginationSelects === 'function') initPaginationSelects(paginationContainer);
|
||
if (typeof window.applyTranslations === 'function') {
|
||
window.applyTranslations(paginationContainer);
|
||
}
|
||
}
|
||
|
||
// 改变每页显示数量
|
||
async function changeVulnerabilityPageSize() {
|
||
const pageSizeSelect = document.getElementById('vulnerability-page-size-pagination');
|
||
if (!pageSizeSelect) return;
|
||
|
||
const newPageSize = parseInt(pageSizeSelect.value, 10);
|
||
if (isNaN(newPageSize) || newPageSize < 1) {
|
||
return;
|
||
}
|
||
|
||
// 保存到localStorage
|
||
localStorage.setItem('vulnerabilityPageSize', newPageSize.toString());
|
||
|
||
const currentStartItem = (vulnerabilityPagination.currentPage - 1) * vulnerabilityPagination.pageSize;
|
||
vulnerabilityPagination.pageSize = newPageSize;
|
||
await loadVulnerabilities(Math.floor(currentStartItem / newPageSize) + 1);
|
||
}
|
||
|
||
function buildVulnerabilityProjectOptionsHtml(selectedId) {
|
||
const sel = (selectedId || '').trim();
|
||
let html = `<option value="">${escapeHtml(vulnT('vulnerabilityModal.projectNone'))}</option>`;
|
||
const entries = typeof projectNameById !== 'undefined' ? Object.entries(projectNameById) : [];
|
||
entries.sort((a, b) => (a[1] || '').localeCompare(b[1] || '', undefined, { sensitivity: 'base' }));
|
||
entries.forEach(([id, name]) => {
|
||
if (!id) return;
|
||
const selected = id === sel ? ' selected' : '';
|
||
html += `<option value="${escapeAttr(id)}"${selected}>${escapeHtml(name || id)}</option>`;
|
||
});
|
||
if (sel && !entries.some(([id]) => id === sel)) {
|
||
html += `<option value="${escapeAttr(sel)}" selected>${escapeHtml(sel)}</option>`;
|
||
}
|
||
return html;
|
||
}
|
||
|
||
async function populateVulnerabilityModalProjectSelect(selectedId) {
|
||
const sel = document.getElementById('vulnerability-project-id');
|
||
if (!sel) return;
|
||
try {
|
||
let list = [];
|
||
if (typeof fetchAllProjects === 'function') {
|
||
list = await fetchAllProjects();
|
||
} else {
|
||
const res = await apiFetch('/api/projects?limit=500');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
list = typeof parseProjectsListResponse === 'function'
|
||
? parseProjectsListResponse(data).items
|
||
: (Array.isArray(data) ? data : (data.projects || []));
|
||
}
|
||
}
|
||
if (list.length) {
|
||
if (typeof rebuildProjectNameMap === 'function') {
|
||
rebuildProjectNameMap(list);
|
||
} else if (typeof projectNameById !== 'undefined') {
|
||
list.forEach((p) => { if (p.id) projectNameById[p.id] = p.name || p.id; });
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('加载项目列表失败', e);
|
||
}
|
||
sel.innerHTML = buildVulnerabilityProjectOptionsHtml(selectedId || '');
|
||
sel.value = selectedId || '';
|
||
}
|
||
|
||
// 显示添加漏洞模态框
|
||
async function showAddVulnerabilityModal() {
|
||
if (typeof requirePermission === 'function' && !requirePermission('vulnerability:write')) return;
|
||
currentVulnerabilityId = null;
|
||
document.getElementById('vulnerability-modal-title').textContent = vulnT('vulnerability.addVuln');
|
||
|
||
const defaultProject = vulnerabilityFilters.project_id || '';
|
||
await populateVulnerabilityModalProjectSelect(defaultProject);
|
||
|
||
// 清空表单
|
||
document.getElementById('vulnerability-conversation-id').value = '';
|
||
document.getElementById('vulnerability-conversation-tag').value = '';
|
||
document.getElementById('vulnerability-task-tag').value = '';
|
||
document.getElementById('vulnerability-title').value = '';
|
||
document.getElementById('vulnerability-description').value = '';
|
||
document.getElementById('vulnerability-severity').value = '';
|
||
document.getElementById('vulnerability-status').value = 'open';
|
||
document.getElementById('vulnerability-type').value = '';
|
||
document.getElementById('vulnerability-target').value = '';
|
||
document.getElementById('vulnerability-preconditions').value = '';
|
||
document.getElementById('vulnerability-reproduction-steps').value = '';
|
||
document.getElementById('vulnerability-evidence').value = '';
|
||
document.getElementById('vulnerability-impact').value = '';
|
||
document.getElementById('vulnerability-recommendation').value = '';
|
||
document.getElementById('vulnerability-retest-notes').value = '';
|
||
|
||
openAppModal('vulnerability-modal');
|
||
}
|
||
|
||
// 编辑漏洞
|
||
async function editVulnerability(id) {
|
||
try {
|
||
currentVulnerabilityId = id;
|
||
document.getElementById('vulnerability-modal-title').textContent = vulnT('vulnerability.editVuln');
|
||
openAppModal('vulnerability-modal', { focus: false });
|
||
const response = await apiFetch(`/api/vulnerabilities/${id}`);
|
||
if (!response.ok) throw new Error(vulnT('vulnerabilityPage.fetchFailed'));
|
||
const vuln = await response.json();
|
||
deferModalContent(async () => {
|
||
document.getElementById('vulnerability-conversation-id').value = vuln.conversation_id || '';
|
||
document.getElementById('vulnerability-conversation-tag').value = vuln.conversation_tag || '';
|
||
document.getElementById('vulnerability-task-tag').value = vuln.task_tag || '';
|
||
document.getElementById('vulnerability-title').value = vuln.title || '';
|
||
document.getElementById('vulnerability-description').value = vuln.description || '';
|
||
document.getElementById('vulnerability-severity').value = vuln.severity || '';
|
||
document.getElementById('vulnerability-status').value = vuln.status || 'open';
|
||
document.getElementById('vulnerability-type').value = vuln.type || '';
|
||
document.getElementById('vulnerability-target').value = vuln.target || '';
|
||
document.getElementById('vulnerability-preconditions').value = vuln.preconditions || '';
|
||
document.getElementById('vulnerability-reproduction-steps').value = vuln.reproduction_steps || '';
|
||
document.getElementById('vulnerability-evidence').value = vuln.evidence || '';
|
||
document.getElementById('vulnerability-impact').value = vuln.impact || '';
|
||
document.getElementById('vulnerability-recommendation').value = vuln.recommendation || '';
|
||
document.getElementById('vulnerability-retest-notes').value = vuln.retest_notes || '';
|
||
await populateVulnerabilityModalProjectSelect(vuln.project_id || '');
|
||
document.getElementById('vulnerability-title')?.focus();
|
||
});
|
||
} catch (error) {
|
||
closeVulnerabilityModal();
|
||
console.error('加载漏洞失败:', error);
|
||
alert(vulnT('vulnerability.loadFailed') + ': ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 保存漏洞
|
||
async function saveVulnerability() {
|
||
if (typeof requirePermission === 'function' && !requirePermission('vulnerability:write')) return;
|
||
const conversationId = document.getElementById('vulnerability-conversation-id').value.trim();
|
||
const title = document.getElementById('vulnerability-title').value.trim();
|
||
const description = document.getElementById('vulnerability-description').value.trim();
|
||
const severity = document.getElementById('vulnerability-severity').value;
|
||
const type = document.getElementById('vulnerability-type').value.trim();
|
||
const target = document.getElementById('vulnerability-target').value.trim();
|
||
const reproductionSteps = document.getElementById('vulnerability-reproduction-steps').value.trim();
|
||
const evidence = document.getElementById('vulnerability-evidence').value.trim();
|
||
const impact = document.getElementById('vulnerability-impact').value.trim();
|
||
const recommendation = document.getElementById('vulnerability-recommendation').value.trim();
|
||
|
||
if (!title || !description || !severity || !type || !target || !reproductionSteps || !evidence || !impact || !recommendation) {
|
||
alert(vulnT('vulnerabilityPage.saveRequiredFields'));
|
||
return;
|
||
}
|
||
|
||
const projectId = (document.getElementById('vulnerability-project-id')?.value || '').trim();
|
||
|
||
const data = {
|
||
conversation_id: conversationId,
|
||
project_id: projectId,
|
||
conversation_tag: document.getElementById('vulnerability-conversation-tag').value.trim(),
|
||
task_tag: document.getElementById('vulnerability-task-tag').value.trim(),
|
||
title: title,
|
||
description: description,
|
||
severity: severity,
|
||
status: document.getElementById('vulnerability-status').value,
|
||
type: type,
|
||
target: target,
|
||
preconditions: document.getElementById('vulnerability-preconditions').value.trim(),
|
||
reproduction_steps: reproductionSteps,
|
||
evidence: evidence,
|
||
impact: impact,
|
||
recommendation: recommendation,
|
||
retest_notes: document.getElementById('vulnerability-retest-notes').value.trim()
|
||
};
|
||
|
||
try {
|
||
const url = currentVulnerabilityId
|
||
? `/api/vulnerabilities/${currentVulnerabilityId}`
|
||
: '/api/vulnerabilities';
|
||
const method = currentVulnerabilityId ? 'PUT' : 'POST';
|
||
|
||
let body = data;
|
||
if (currentVulnerabilityId) {
|
||
body = {
|
||
project_id: projectId,
|
||
conversation_tag: data.conversation_tag,
|
||
task_tag: data.task_tag,
|
||
title: data.title,
|
||
description: data.description,
|
||
severity: data.severity,
|
||
status: data.status,
|
||
type: data.type,
|
||
target: data.target,
|
||
preconditions: data.preconditions,
|
||
reproduction_steps: data.reproduction_steps,
|
||
evidence: data.evidence,
|
||
impact: data.impact,
|
||
recommendation: data.recommendation,
|
||
retest_notes: data.retest_notes,
|
||
};
|
||
}
|
||
|
||
const response = await apiFetch(url, {
|
||
method: method,
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(body)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || vulnT('vulnerabilityPage.saveFailed'));
|
||
}
|
||
|
||
const updated = await response.json();
|
||
if (typeof refreshVulnerabilityInspector === 'function') refreshVulnerabilityInspector(currentVulnerabilityId);
|
||
const editedId = currentVulnerabilityId;
|
||
const isEdit = !!editedId;
|
||
const expandedIds = isEdit ? getExpandedVulnerabilityIds() : [];
|
||
|
||
closeVulnerabilityModal();
|
||
loadVulnerabilityStats();
|
||
|
||
if (!isEdit) {
|
||
vulnerabilityPagination.currentPage = 1;
|
||
loadVulnerabilities();
|
||
return;
|
||
}
|
||
|
||
const newStatus = (updated && updated.status) || data.status;
|
||
if (!vulnerabilityStatusMatchesFilter(newStatus)) {
|
||
await removeVulnerabilityCard(editedId);
|
||
return;
|
||
}
|
||
|
||
await loadVulnerabilities(null, { preserveScroll: true, silent: true, expandedIds: expandedIds });
|
||
} catch (error) {
|
||
console.error('保存漏洞失败:', error);
|
||
alert(vulnT('vulnerabilityPage.saveFailed') + ': ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 删除漏洞
|
||
async function deleteVulnerability(id) {
|
||
if (!confirm(vulnT('vulnerability.deleteConfirm'))) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiFetch(`/api/vulnerabilities/${id}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (!response.ok) throw new Error(vulnT('vulnerabilityPage.deleteFailed'));
|
||
|
||
loadVulnerabilityStats();
|
||
await removeVulnerabilityCard(id);
|
||
} catch (error) {
|
||
console.error('删除漏洞失败:', error);
|
||
alert(vulnT('vulnerabilityPage.deleteFailed') + ': ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 关闭漏洞模态框
|
||
function closeVulnerabilityModal() {
|
||
closeAppModal('vulnerability-modal');
|
||
currentVulnerabilityId = null;
|
||
}
|
||
|
||
// 筛选漏洞(应用当前表单条件)
|
||
function filterVulnerabilities() {
|
||
applyVulnerabilityFilters();
|
||
}
|
||
|
||
// 清除筛选
|
||
function clearVulnerabilityFilters() {
|
||
closeVulnerabilityMoreFiltersPopover(false);
|
||
if (vulnerabilityFilterDebounceTimer) {
|
||
clearTimeout(vulnerabilityFilterDebounceTimer);
|
||
vulnerabilityFilterDebounceTimer = null;
|
||
}
|
||
const fields = [
|
||
'vulnerability-search-filter',
|
||
'vulnerability-exact-id-filter',
|
||
'vulnerability-conversation-filter',
|
||
'vulnerability-task-filter',
|
||
'vulnerability-conversation-tag-filter',
|
||
'vulnerability-task-tag-filter',
|
||
'vulnerability-project-filter',
|
||
'vulnerability-severity-filter',
|
||
'vulnerability-status-filter'
|
||
];
|
||
fields.forEach(function (id) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = '';
|
||
});
|
||
syncAllVulnFilterCustomSelects();
|
||
|
||
vulnerabilityFilters = {
|
||
q: '',
|
||
id: '',
|
||
project_id: '',
|
||
conversation_id: '',
|
||
task_id: '',
|
||
conversation_tag: '',
|
||
task_tag: '',
|
||
severity: '',
|
||
status: ''
|
||
};
|
||
|
||
applyVulnerabilityFilters();
|
||
}
|
||
|
||
// 刷新漏洞
|
||
function refreshVulnerabilities() {
|
||
loadVulnerabilityStats();
|
||
loadVulnerabilities(null, { preserveScroll: true, silent: true });
|
||
}
|
||
|
||
async function loadVulnerabilityAlertSubscription() {
|
||
const enabledEl = document.getElementById('vulnerability-alert-enabled');
|
||
const severityEl = document.getElementById('vulnerability-alert-min-severity');
|
||
if (!enabledEl || !severityEl || typeof apiFetch === 'undefined') return;
|
||
try {
|
||
const response = await apiFetch('/api/vulnerability-alerts/subscription');
|
||
if (!response.ok) throw new Error(await response.text());
|
||
const data = await response.json();
|
||
const sub = data.subscription || {};
|
||
enabledEl.checked = !!sub.enabled;
|
||
severityEl.value = sub.min_severity || 'high';
|
||
severityEl.disabled = false;
|
||
if (typeof window.refreshSettingsCustomSelects === 'function') {
|
||
window.refreshSettingsCustomSelects();
|
||
}
|
||
const hint = document.getElementById('vulnerability-alert-binding-hint');
|
||
const bindButton = document.getElementById('vulnerability-alert-bind-button');
|
||
if (hint && !data.delivery_ready) {
|
||
const supportedTypes = [
|
||
{ type: 'wecom', label: vulnT('settings.robots.wecom.title') },
|
||
{ type: 'lark', label: vulnT('settings.robots.lark.title') },
|
||
{ type: 'telegram', label: 'Telegram' },
|
||
{ type: 'slack', label: 'Slack' },
|
||
{ type: 'discord', label: 'Discord' }
|
||
];
|
||
const enabledPlatforms = supportedTypes.filter(function (item) {
|
||
if (typeof getRobotStatus !== 'function') return false;
|
||
const status = getRobotStatus(item.type);
|
||
return status && status.state === 'enabled';
|
||
}).map(function (item) { return item.label; });
|
||
hint.textContent = enabledPlatforms.length
|
||
? vulnT('vulnerabilityPage.alertConfiguredNotBound', { platforms: enabledPlatforms.join('、') })
|
||
: vulnT('vulnerabilityPage.alertNoBinding');
|
||
hint.classList.add('is-warning');
|
||
if (bindButton) bindButton.hidden = false;
|
||
} else if (hint) {
|
||
hint.textContent = vulnT('vulnerabilityPage.alertHint');
|
||
hint.classList.remove('is-warning');
|
||
if (bindButton) bindButton.hidden = true;
|
||
}
|
||
} catch (error) {
|
||
console.warn('加载漏洞提醒设置失败', error);
|
||
}
|
||
}
|
||
|
||
async function saveVulnerabilityAlertSubscription() {
|
||
const enabledEl = document.getElementById('vulnerability-alert-enabled');
|
||
const severityEl = document.getElementById('vulnerability-alert-min-severity');
|
||
if (!enabledEl || !severityEl) return;
|
||
enabledEl.disabled = true;
|
||
severityEl.disabled = true;
|
||
if (typeof window.refreshSettingsCustomSelects === 'function') {
|
||
window.refreshSettingsCustomSelects();
|
||
}
|
||
try {
|
||
const response = await apiFetch('/api/vulnerability-alerts/subscription', {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: enabledEl.checked, min_severity: severityEl.value })
|
||
});
|
||
if (!response.ok) throw new Error(await response.text());
|
||
if (typeof showNotification === 'function') showNotification(vulnT('vulnerabilityPage.alertSaved'), 'success');
|
||
} catch (error) {
|
||
if (typeof showNotification === 'function') showNotification(vulnT('vulnerabilityPage.alertSaveFailed'), 'error');
|
||
await loadVulnerabilityAlertSubscription();
|
||
} finally {
|
||
enabledEl.disabled = false;
|
||
severityEl.disabled = false;
|
||
if (typeof window.refreshSettingsCustomSelects === 'function') {
|
||
window.refreshSettingsCustomSelects();
|
||
}
|
||
}
|
||
}
|
||
|
||
// 切换漏洞详情展开/折叠
|
||
function toggleVulnerabilityDetails(id) {
|
||
const content = document.getElementById(`content-${id}`);
|
||
const icon = document.getElementById(`expand-icon-${id}`);
|
||
|
||
if (!content || !icon) return;
|
||
|
||
if (content.style.display === 'none') {
|
||
content.style.display = 'block';
|
||
icon.style.transform = 'rotate(90deg)';
|
||
loadVulnerabilityRelatedFacts(id).catch((e) => console.warn(e));
|
||
} else {
|
||
content.style.display = 'none';
|
||
icon.style.transform = 'rotate(0deg)';
|
||
}
|
||
const expanded = content.style.display !== 'none';
|
||
const row = content.closest('.vulnerability-table-detail');
|
||
if (row) row.hidden = !expanded;
|
||
const trigger = document.getElementById('vulnerability-row-toggle-' + id);
|
||
if (trigger) trigger.setAttribute('aria-expanded', String(expanded));
|
||
}
|
||
|
||
/** 展开漏洞详情时加载关联项目事实 */
|
||
async function loadVulnerabilityRelatedFacts(vulnId) {
|
||
const el = document.getElementById(`vuln-related-facts-${vulnId}`);
|
||
if (!el || el.dataset.loaded === '1') return;
|
||
const projectId = (el.dataset.projectId || '').trim();
|
||
if (!projectId) {
|
||
el.hidden = true;
|
||
el.dataset.loaded = '1';
|
||
return;
|
||
}
|
||
el.hidden = false;
|
||
el.innerHTML = '<span>加载关联事实…</span>';
|
||
try {
|
||
const res = await apiFetch(
|
||
`/api/projects/${encodeURIComponent(projectId)}/facts?related_vulnerability_id=${encodeURIComponent(vulnId)}&limit=20&exclude_deprecated=true`,
|
||
);
|
||
if (!res.ok) throw new Error('fetch failed');
|
||
const facts = await res.json();
|
||
if (!Array.isArray(facts) || !facts.length) {
|
||
el.innerHTML =
|
||
'<strong>关联事实</strong><p>暂无;可在「项目管理」事实详情中关联或生成漏洞草稿。</p>';
|
||
el.dataset.loaded = '1';
|
||
return;
|
||
}
|
||
const items = facts
|
||
.map((f) => {
|
||
const key = escapeHtml(f.fact_key);
|
||
const sum = escapeHtml((f.summary || '').slice(0, 120));
|
||
const pid = escapeAttr(projectId);
|
||
const rawKey = escapeAttr(f.fact_key);
|
||
return `<li><a role="button" href="#" data-project-id="${pid}" data-fact-key="${rawKey}" onclick="event.preventDefault();openProjectFactFromVulnerability(this.dataset.projectId,this.dataset.factKey)"><code>${key}</code></a> — ${sum}</li>`;
|
||
})
|
||
.join('');
|
||
el.innerHTML = `<strong>关联事实(${facts.length})</strong><ul>${items}</ul>`;
|
||
el.dataset.loaded = '1';
|
||
} catch (e) {
|
||
el.innerHTML = '<strong>关联事实</strong><p class="vulnerability-related-facts-error">加载失败</p>';
|
||
}
|
||
}
|
||
|
||
function openProjectFactFromVulnerability(projectId, factKey) {
|
||
if (!projectId || !factKey) return;
|
||
if (typeof switchPage === 'function') {
|
||
switchPage('projects');
|
||
}
|
||
setTimeout(async () => {
|
||
if (typeof window.initProjectsPage === 'function') {
|
||
await window.initProjectsPage();
|
||
}
|
||
if (typeof window.selectProject === 'function') {
|
||
await window.selectProject(projectId);
|
||
}
|
||
if (typeof window.switchProjectTab === 'function') {
|
||
window.switchProjectTab('facts');
|
||
}
|
||
if (typeof window.viewProjectFactBody === 'function') {
|
||
window.viewProjectFactBody(factKey);
|
||
}
|
||
}, 350);
|
||
}
|
||
|
||
window.openProjectFactFromVulnerability = openProjectFactFromVulnerability;
|
||
|
||
// HTML转义
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function escapeJsString(text) {
|
||
return JSON.stringify(String(text == null ? '' : text));
|
||
}
|
||
|
||
function escapeAttr(text) {
|
||
return escapeHtml(text).replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
function escapeJsStringAttr(text) {
|
||
return escapeAttr(escapeJsString(text));
|
||
}
|
||
|
||
/** 复制详情字段(编码由 encodeURIComponent 传入,避免引号截断) */
|
||
function vulnerabilityCopyEncoded(evt, encoded) {
|
||
if (evt && evt.stopPropagation) {
|
||
evt.stopPropagation();
|
||
}
|
||
let text = '';
|
||
try {
|
||
text = decodeURIComponent(encoded);
|
||
} catch (e) {
|
||
return;
|
||
}
|
||
const done = () => {
|
||
if (evt && evt.target && evt.target.closest) {
|
||
const btn = evt.target.closest('.vuln-detail-field__copy');
|
||
if (btn) {
|
||
const t0 = btn.getAttribute('title') || '';
|
||
btn.setAttribute('title', vulnT('common.copied'));
|
||
setTimeout(() => btn.setAttribute('title', t0), 1600);
|
||
}
|
||
}
|
||
};
|
||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||
navigator.clipboard.writeText(text).then(done).catch(() => {
|
||
try {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.left = '-9999px';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
done();
|
||
} catch (err) {
|
||
console.error('copy failed', err);
|
||
}
|
||
});
|
||
} else {
|
||
try {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.left = '-9999px';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
done();
|
||
} catch (err) {
|
||
console.error('copy failed', err);
|
||
}
|
||
}
|
||
}
|
||
|
||
function vulnDetailProjectField(vuln) {
|
||
const label = vulnT('vulnerabilityPage.detailProject');
|
||
const hint = escapeHtml(vulnT('vulnerabilityPage.projectBindHint'));
|
||
return `<div class="vuln-detail-field">
|
||
<div class="vuln-detail-field__label">${escapeHtml(label)}</div>
|
||
<div class="vuln-detail-field__row">
|
||
<select class="vulnerability-project-bind-select" data-vuln-id="${escapeAttr(vuln.id)}"
|
||
onchange="bindVulnerabilityProject(this.dataset.vulnId, this.value, true)"
|
||
title="${escapeAttr(hint)}" aria-label="${escapeAttr(label)}">
|
||
${buildVulnerabilityProjectOptionsHtml(vuln.project_id || '')}
|
||
</select>
|
||
<span class="vuln-detail-field__copy-spacer" aria-hidden="true"></span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function vulnDetailField(label, value, asCode) {
|
||
if (value === undefined || value === null || String(value) === '') {
|
||
return '';
|
||
}
|
||
const s = String(value);
|
||
const enc = encodeURIComponent(s);
|
||
const copyTitle = escapeHtml(vulnT('common.copy'));
|
||
const valueEl = asCode
|
||
? `<code class="vuln-detail-field-value">${escapeHtml(s)}</code>`
|
||
: `<span class="vuln-detail-field-value">${escapeHtml(s)}</span>`;
|
||
const copyBtn = `<button type="button" class="vuln-detail-field__copy" onclick="vulnerabilityCopyEncoded(event, '${enc}')" title="${copyTitle}" aria-label="${copyTitle}">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||
</button>`;
|
||
return `<div class="vuln-detail-field">
|
||
<div class="vuln-detail-field__label">${escapeHtml(label)}</div>
|
||
<div class="vuln-detail-field__row">${valueEl}<div class="vuln-detail-field__actions">${copyBtn}</div></div>
|
||
</div>`;
|
||
}
|
||
|
||
function vulnDetailConversationField(conversationId) {
|
||
if (conversationId === undefined || conversationId === null || String(conversationId).trim() === '') {
|
||
return '';
|
||
}
|
||
const s = String(conversationId).trim();
|
||
const enc = encodeURIComponent(s);
|
||
const copyTitle = escapeHtml(vulnT('common.copy'));
|
||
const openTitle = escapeHtml(vulnT('vulnerabilityPage.openConversation'));
|
||
const qId = JSON.stringify(s).replace(/"/g, '"');
|
||
const openBtn = `<button type="button" class="vuln-detail-field__action" onclick="openVulnerabilityConversation(${qId})" title="${openTitle}" aria-label="${openTitle}">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||
</button>`;
|
||
const copyBtn = `<button type="button" class="vuln-detail-field__copy" onclick="vulnerabilityCopyEncoded(event, '${enc}')" title="${copyTitle}" aria-label="${copyTitle}">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||
</button>`;
|
||
return `<div class="vuln-detail-field">
|
||
<div class="vuln-detail-field__label">${escapeHtml(vulnT('vulnerabilityPage.detailConversationId'))}</div>
|
||
<div class="vuln-detail-field__row"><code class="vuln-detail-field-value">${escapeHtml(s)}</code><div class="vuln-detail-field__actions">${openBtn}${copyBtn}</div></div>
|
||
</div>`;
|
||
}
|
||
|
||
function openVulnerabilityConversation(conversationId) {
|
||
if (typeof navigateToConversation === 'function') {
|
||
navigateToConversation(conversationId);
|
||
} else if (typeof window.navigateToConversation === 'function') {
|
||
window.navigateToConversation(conversationId);
|
||
}
|
||
}
|
||
|
||
function vulnNarrativeSection(label, value, options) {
|
||
if (value === undefined || value === null || String(value).trim() === '') return '';
|
||
const opts = options || {};
|
||
const tag = opts.code ? 'pre' : 'div';
|
||
const cls = opts.code ? 'vulnerability-section-body vulnerability-section-body--code' : 'vulnerability-section-body';
|
||
return `<section class="vulnerability-section">
|
||
<strong>${escapeHtml(label)}</strong>
|
||
<${tag} class="${cls}">${escapeHtml(String(value))}</${tag}>
|
||
</section>`;
|
||
}
|
||
|
||
// 将漏洞格式化为Markdown(章节标题随界面语言)
|
||
function formatVulnerabilityAsMarkdown(vuln) {
|
||
const severityText = vulnSeverityLabel(vuln.severity);
|
||
const statusText = vulnStatusLabel(vuln.status);
|
||
const loc = vulnDateLocale();
|
||
const createdDate = new Date(vuln.created_at).toLocaleString(loc);
|
||
const updatedDate = new Date(vuln.updated_at).toLocaleString(loc);
|
||
const L = (k) => vulnT('vulnerabilityMd.' + k);
|
||
|
||
let markdown = `# ${vuln.title}\n\n`;
|
||
|
||
markdown += `## ${L('headingBasic')}\n\n`;
|
||
markdown += `- **${L('labelId')}**: \`${vuln.id}\`\n`;
|
||
markdown += `- **${L('labelSeverity')}**: ${severityText}\n`;
|
||
markdown += `- **${L('labelStatus')}**: ${statusText}\n`;
|
||
if (vuln.type) {
|
||
markdown += `- **${L('labelType')}**: ${vuln.type}\n`;
|
||
}
|
||
if (vuln.target) {
|
||
markdown += `- **${L('labelTarget')}**: ${vuln.target}\n`;
|
||
}
|
||
markdown += `- **${L('labelConversationId')}**: \`${vuln.conversation_id}\`\n`;
|
||
if (vuln.task_id) {
|
||
markdown += `- **${L('labelTaskId')}**: \`${vuln.task_id}\`\n`;
|
||
}
|
||
if (vuln.task_queue_id) {
|
||
markdown += `- **${L('labelTaskQueueId')}**: \`${vuln.task_queue_id}\`\n`;
|
||
}
|
||
if (vuln.conversation_tag) {
|
||
markdown += `- **${L('labelConversationTag')}**: ${vuln.conversation_tag}\n`;
|
||
}
|
||
if (vuln.task_tag) {
|
||
markdown += `- **${L('labelTaskTag')}**: ${vuln.task_tag}\n`;
|
||
}
|
||
markdown += `- **${L('labelCreated')}**: ${createdDate}\n`;
|
||
markdown += `- **${L('labelUpdated')}**: ${updatedDate}\n\n`;
|
||
|
||
if (vuln.description) {
|
||
markdown += `## ${L('headingDescription')}\n\n${vuln.description}\n\n`;
|
||
}
|
||
|
||
if (vuln.preconditions) {
|
||
markdown += `## ${L('headingPreconditions')}\n\n${vuln.preconditions}\n\n`;
|
||
}
|
||
|
||
if (vuln.reproduction_steps) {
|
||
markdown += `## ${L('headingReproductionSteps')}\n\n${vuln.reproduction_steps}\n\n`;
|
||
}
|
||
|
||
if (vuln.evidence) {
|
||
markdown += `## ${L('headingEvidence')}\n\n\`\`\`\n${vuln.evidence}\n\`\`\`\n\n`;
|
||
}
|
||
|
||
if (vuln.impact) {
|
||
markdown += `## ${L('headingImpact')}\n\n${vuln.impact}\n\n`;
|
||
}
|
||
|
||
if (vuln.recommendation) {
|
||
markdown += `## ${L('headingRecommendation')}\n\n${vuln.recommendation}\n\n`;
|
||
}
|
||
|
||
if (vuln.retest_notes) {
|
||
markdown += `## ${L('headingRetestNotes')}\n\n${vuln.retest_notes}\n\n`;
|
||
}
|
||
|
||
if (vuln.retest_log) {
|
||
markdown += `## ${L('headingRetestLog')}\n\n\`\`\`\n${String(vuln.retest_log).trim()}\n\`\`\`\n\n`;
|
||
}
|
||
|
||
return markdown;
|
||
}
|
||
|
||
function buildVulnerabilityFilterParams() {
|
||
const params = new URLSearchParams();
|
||
if (vulnerabilityFilters.q) {
|
||
params.append('q', vulnerabilityFilters.q);
|
||
}
|
||
const keys = ['id', 'project_id', 'conversation_id', 'task_id', 'conversation_tag', 'task_tag', 'severity', 'status'];
|
||
keys.forEach(function (k) {
|
||
if (vulnerabilityFilters[k]) {
|
||
params.append(k, vulnerabilityFilters[k]);
|
||
}
|
||
});
|
||
return params;
|
||
}
|
||
|
||
/** 看板统计:保留项目/关键词等筛选,但不带严重度(卡片本身用于切换严重度筛选) */
|
||
function buildVulnerabilityDashboardStatsParams() {
|
||
const params = buildVulnerabilityFilterParams();
|
||
params.delete('severity');
|
||
return params;
|
||
}
|
||
|
||
function triggerTextDownload(fileName, content) {
|
||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = fileName;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function hasActiveVulnerabilityFilters() {
|
||
const keys = ['q', 'id', 'project_id', 'conversation_id', 'task_id', 'conversation_tag', 'task_tag', 'severity', 'status'];
|
||
return keys.some(function (k) {
|
||
return Boolean(vulnerabilityFilters[k]);
|
||
});
|
||
}
|
||
|
||
async function batchDeleteVulnerabilityReports() {
|
||
try {
|
||
const params = buildVulnerabilityFilterParams();
|
||
const statsResponse = await apiFetch(`/api/vulnerabilities/stats?${params.toString()}`);
|
||
if (!statsResponse.ok) {
|
||
throw new Error(vulnT('vulnerabilityPage.deleteFailed'));
|
||
}
|
||
const stats = await statsResponse.json();
|
||
const count = stats.total || 0;
|
||
if (count <= 0) {
|
||
alert(vulnT('vulnerabilityPage.batchDeleteNoResults'));
|
||
return;
|
||
}
|
||
|
||
const confirmKey = hasActiveVulnerabilityFilters()
|
||
? 'vulnerabilityPage.batchDeleteConfirm'
|
||
: 'vulnerabilityPage.batchDeleteConfirmAll';
|
||
if (!confirm(vulnT(confirmKey, { count: count }))) {
|
||
return;
|
||
}
|
||
|
||
const response = await apiFetch(`/api/vulnerabilities/batch?${params.toString()}`, {
|
||
method: 'DELETE'
|
||
});
|
||
if (!response.ok) {
|
||
const error = await response.json().catch(() => ({ error: vulnT('vulnerabilityPage.deleteFailed') }));
|
||
throw new Error(error.error || vulnT('vulnerabilityPage.deleteFailed'));
|
||
}
|
||
const data = await response.json();
|
||
const deleted = data.deleted || 0;
|
||
alert(vulnT('vulnerabilityPage.batchDeleteSuccess', { count: deleted }));
|
||
vulnerabilityPagination.currentPage = 1;
|
||
loadVulnerabilityStats();
|
||
loadVulnerabilities();
|
||
} catch (error) {
|
||
console.error('批量删除漏洞失败:', error);
|
||
alert(vulnT('vulnerabilityPage.batchDeleteFailed') + ': ' + error.message);
|
||
}
|
||
}
|
||
|
||
async function exportVulnerabilityReports() {
|
||
try {
|
||
const params = buildVulnerabilityFilterParams();
|
||
params.set('mode', 'summary');
|
||
params.set('group_by', 'conversation');
|
||
const response = await apiFetch(`/api/vulnerabilities/export?${params.toString()}`);
|
||
if (!response.ok) {
|
||
const error = await response.json().catch(() => ({ error: vulnT('vulnerabilityPage.exportFailedMessage') }));
|
||
throw new Error(error.error || vulnT('vulnerabilityPage.exportFailedMessage'));
|
||
}
|
||
const data = await response.json();
|
||
const files = Array.isArray(data.files) ? data.files : [];
|
||
if (!files.length) {
|
||
alert(vulnT('vulnerabilityPage.exportNoResults'));
|
||
return;
|
||
}
|
||
files.forEach((file, idx) => {
|
||
setTimeout(() => triggerTextDownload(file.filename || `vulnerability-export-${idx + 1}.md`, file.content || ''), idx * 120);
|
||
});
|
||
if (files.length > 1) {
|
||
alert(vulnT('vulnerabilityPage.exportStarted', { count: files.length }));
|
||
}
|
||
} catch (error) {
|
||
console.error('导出漏洞报告失败:', error);
|
||
alert(vulnT('vulnerabilityPage.exportFailed') + ': ' + error.message);
|
||
}
|
||
}
|
||
|
||
|
||
// 下载漏洞为Markdown格式
|
||
async function downloadVulnerabilityAsMarkdown(id, event) {
|
||
try {
|
||
const response = await apiFetch(`/api/vulnerabilities/${id}`);
|
||
if (!response.ok) {
|
||
throw new Error(vulnT('vulnerabilityPage.fetchFailed'));
|
||
}
|
||
|
||
const vuln = await response.json();
|
||
const markdown = formatVulnerabilityAsMarkdown(vuln);
|
||
|
||
// 创建Blob对象
|
||
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
|
||
|
||
// 创建下载链接
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
|
||
// 生成文件名(使用漏洞标题,清理特殊字符,保留中文)
|
||
const cleanTitle = vuln.title
|
||
.replace(/[<>:"/\\|?*]/g, '') // 移除Windows不允许的字符
|
||
.replace(/\s+/g, '_') // 空格替换为下划线
|
||
.substring(0, 50); // 限制长度
|
||
const fileName = `${cleanTitle}_${vuln.id.substring(0, 8)}.md`;
|
||
link.download = fileName;
|
||
|
||
// 触发下载
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
|
||
// 清理
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
|
||
// 显示成功提示
|
||
if (event && event.target) {
|
||
const button = event.target.closest('button');
|
||
if (button) {
|
||
const originalTitle = button.title || vulnT('vulnerabilityDownload.download');
|
||
button.title = vulnT('vulnerabilityPage.downloadOkTitle');
|
||
setTimeout(() => {
|
||
button.title = originalTitle;
|
||
}, 2000);
|
||
}
|
||
}
|
||
return true;
|
||
} catch (error) {
|
||
console.error('下载失败:', error);
|
||
alert(vulnT('vulnerabilityPage.downloadFailed') + ': ' + error.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 点击模态框外部关闭
|
||
window.onclick = function(event) {
|
||
const modal = document.getElementById('vulnerability-modal');
|
||
if (event.target === modal) {
|
||
closeVulnerabilityModal();
|
||
}
|
||
};
|
||
|
||
document.addEventListener('languagechange', async function () {
|
||
const page = document.getElementById('page-vulnerabilities');
|
||
if (page && page.classList.contains('active')) {
|
||
const panel = document.getElementById('vulnerability-filter-panel');
|
||
if (panel && typeof window.applyTranslations === 'function') {
|
||
window.applyTranslations(panel);
|
||
}
|
||
renderVulnerabilityFilterChips();
|
||
await refreshVulnerabilityProjectFilter();
|
||
syncAllVulnFilterCustomSelects();
|
||
loadVulnerabilities();
|
||
}
|
||
});
|
||
|
||
async function bindVulnerabilityProject(vulnId, projectId, silent) {
|
||
if (!vulnId) return;
|
||
try {
|
||
const response = await apiFetch(`/api/vulnerabilities/${encodeURIComponent(vulnId)}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ project_id: projectId || '' }),
|
||
});
|
||
if (!response.ok) {
|
||
const err = await response.json().catch(() => ({}));
|
||
throw new Error(err.error || vulnT('vulnerabilityPage.projectBindFailed'));
|
||
}
|
||
if (typeof refreshVulnerabilityInspector === 'function') refreshVulnerabilityInspector(vulnId);
|
||
if (!silent) {
|
||
alert(vulnT('vulnerabilityPage.projectBindOk'));
|
||
}
|
||
loadVulnerabilityStats();
|
||
const expandedIds = getExpandedVulnerabilityIds();
|
||
if (!expandedIds.includes(vulnId)) {
|
||
expandedIds.push(vulnId);
|
||
}
|
||
await loadVulnerabilities(null, { preserveScroll: true, silent: true, expandedIds: expandedIds });
|
||
} catch (error) {
|
||
console.error('绑定项目失败:', error);
|
||
alert(vulnT('vulnerabilityPage.projectBindFailed') + ': ' + error.message);
|
||
await loadVulnerabilities(null, { preserveScroll: true });
|
||
}
|
||
}
|
||
|
||
async function refreshVulnerabilityProjectFilter() {
|
||
const sel = document.getElementById('vulnerability-project-filter');
|
||
if (!sel) return;
|
||
try {
|
||
let list = [];
|
||
if (typeof fetchAllProjects === 'function') {
|
||
list = await fetchAllProjects(true);
|
||
} else {
|
||
const res = await apiFetch('/api/projects?limit=500');
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
list = typeof parseProjectsListResponse === 'function'
|
||
? parseProjectsListResponse(data).items
|
||
: (Array.isArray(data) ? data : (data.projects || []));
|
||
}
|
||
if (typeof rebuildProjectNameMap === 'function') {
|
||
rebuildProjectNameMap(list);
|
||
} else if (typeof projectNameById !== 'undefined') {
|
||
list.forEach((p) => { if (p.id) projectNameById[p.id] = p.name || p.id; });
|
||
}
|
||
const cur = vulnerabilityFilters.project_id || sel.value || '';
|
||
let html = '<option value="">' + escapeHtml(vulnT('vulnerabilityPage.allProjects')) + '</option>';
|
||
(list || []).forEach((p) => {
|
||
if (!p.id) return;
|
||
const selected = p.id === cur ? ' selected' : '';
|
||
const arch = p.status === 'archived' ? ' [' + vulnT('projects.archived') + ']' : '';
|
||
html += `<option value="${escapeAttr(p.id)}"${selected}>${escapeHtml(p.name || p.id)}${arch}</option>`;
|
||
});
|
||
sel.innerHTML = html;
|
||
if (cur) sel.value = cur;
|
||
syncVulnFilterCustomSelect('vulnerability-project-filter');
|
||
const modalSel = document.getElementById('vulnerability-project-id');
|
||
if (modalSel && isAppModalOpen('vulnerability-modal')) {
|
||
const modalCur = modalSel.value || '';
|
||
modalSel.innerHTML = buildVulnerabilityProjectOptionsHtml(modalCur);
|
||
modalSel.value = modalCur;
|
||
}
|
||
} catch (e) {
|
||
console.warn('加载项目筛选列表失败', e);
|
||
}
|
||
}
|
||
|
||
function setVulnerabilityProjectFilter(projectId) {
|
||
vulnerabilityFilters.project_id = projectId || '';
|
||
const sel = document.getElementById('vulnerability-project-filter');
|
||
if (sel) sel.value = projectId || '';
|
||
syncVulnFilterCustomSelect('vulnerability-project-filter');
|
||
applyVulnerabilityFilters();
|
||
}
|
||
|
||
function setVulnerabilityIdFilter(vulnId) {
|
||
vulnerabilityFilters.id = vulnId || '';
|
||
const el = document.getElementById('vulnerability-exact-id-filter');
|
||
if (el) el.value = vulnId || '';
|
||
applyVulnerabilityFilters();
|
||
}
|
||
|
||
window.refreshVulnerabilityProjectFilter = refreshVulnerabilityProjectFilter;
|
||
window.setVulnerabilityProjectFilter = setVulnerabilityProjectFilter;
|
||
window.setVulnerabilityIdFilter = setVulnerabilityIdFilter;
|
||
window.bindVulnerabilityProject = bindVulnerabilityProject;
|
||
window.buildVulnerabilityProjectOptionsHtml = buildVulnerabilityProjectOptionsHtml;
|
||
window.changeVulnerabilityStatus = changeVulnerabilityStatus;
|
||
window.openVulnerabilityConversation = openVulnerabilityConversation;
|
||
window.loadVulnerabilityAlertSubscription = loadVulnerabilityAlertSubscription;
|
||
window.saveVulnerabilityAlertSubscription = saveVulnerabilityAlertSubscription;
|
||
|
||
|
||
let vulnerabilityRetestId = '';
|
||
let vulnerabilityRetestSubmitting = false;
|
||
let vulnerabilityRetestOpenSeq = 0;
|
||
|
||
// Fold the operator's retest_notes into the prompt: that field already says how to verify the fix.
|
||
function vulnerabilityRetestPrompt(vuln) {
|
||
const base = vulnT('vulnerabilityRetest.defaultPrompt');
|
||
const guidance = String((vuln && vuln.retest_notes) || '').trim();
|
||
if (!guidance) return base;
|
||
return base + '\n\n' + vulnT('vulnerabilityRetest.guidanceHeading') + '\n' + guidance;
|
||
}
|
||
|
||
async function openVulnerabilityRetest(id) {
|
||
if (vulnerabilityRetestSubmitting) return;
|
||
const seq = ++vulnerabilityRetestOpenSeq;
|
||
try {
|
||
const response = await apiFetch(`/api/vulnerabilities/${encodeURIComponent(id)}`);
|
||
const vuln = await response.json();
|
||
if (seq !== vulnerabilityRetestOpenSeq || vulnerabilityRetestSubmitting) return;
|
||
if (!response.ok) throw new Error(vuln.error || vulnT('vulnerabilityPage.fetchFailed'));
|
||
vulnerabilityRetestId = id;
|
||
document.getElementById('vulnerability-retest-title').textContent = vuln.title;
|
||
document.getElementById('vulnerability-retest-prompt').value = vulnerabilityRetestPrompt(vuln);
|
||
document.getElementById('vulnerability-retest-error').textContent = '';
|
||
openAppModal('vulnerability-retest-modal');
|
||
} catch (err) { if (seq === vulnerabilityRetestOpenSeq) alert(err.message); }
|
||
}
|
||
|
||
async function confirmVulnerabilityRetest() {
|
||
if (vulnerabilityRetestSubmitting || !vulnerabilityRetestId) return;
|
||
const prompt = document.getElementById('vulnerability-retest-prompt').value.trim();
|
||
const error = document.getElementById('vulnerability-retest-error');
|
||
if (!prompt) { error.textContent = vulnT('vulnerabilityRetest.promptRequired'); return; }
|
||
vulnerabilityRetestSubmitting = true;
|
||
const button = document.getElementById('vulnerability-retest-confirm');
|
||
button.disabled = true;
|
||
let branchId = '';
|
||
const initialHash = window.location ? window.location.hash : '';
|
||
try {
|
||
const response = await apiFetch(`/api/vulnerabilities/${encodeURIComponent(vulnerabilityRetestId)}/retest`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt })
|
||
});
|
||
const result = await response.json();
|
||
if (!response.ok) throw new Error(result.error || vulnT('vulnerabilityPage.fetchFailed'));
|
||
branchId = result.conversation.id;
|
||
if (window.location && window.location.hash !== initialHash) throw new Error(vulnT('vulnerabilityRetest.navigationChanged'));
|
||
closeAppModal('vulnerability-retest-modal');
|
||
await navigateToConversation(branchId);
|
||
if (String(window.currentConversationId || '') !== branchId) throw new Error(vulnT('vulnerabilityRetest.navigationChanged'));
|
||
const input = document.getElementById('chat-input');
|
||
input.value = result.prompt;
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
// Wait through preflight and HTTP acceptance, then release the modal
|
||
// lock while the chat continues to own the long-running SSE stream.
|
||
let accepted = false;
|
||
let resolveAccepted;
|
||
const acceptance = new Promise(resolve => { resolveAccepted = resolve; });
|
||
const sending = sendMessage({ onRequestAccepted() { accepted = true; resolveAccepted(); } });
|
||
await Promise.race([acceptance, sending]);
|
||
if (!accepted) throw new Error(vulnT('vulnerabilityRetest.startFailed'));
|
||
void sending.catch(err => console.error('复测对话发送失败:', err));
|
||
if (typeof refreshChatProjectFoldersAfterAction === 'function') void refreshChatProjectFoldersAfterAction();
|
||
if (typeof loadConversations === 'function') void loadConversations();
|
||
} catch (err) {
|
||
error.textContent = err.message;
|
||
if (branchId) alert(vulnT('vulnerabilityRetest.branchCreated') + '\n' + branchId + '\n' + err.message);
|
||
} finally {
|
||
vulnerabilityRetestSubmitting = false;
|
||
button.disabled = false;
|
||
}
|
||
}
|