1765 lines
76 KiB
JavaScript
1765 lines
76 KiB
JavaScript
const API = '/api/';
|
|
let teams = [];
|
|
let events = [];
|
|
let documents = [];
|
|
let editingDocId = null;
|
|
let linkingEventId = null;
|
|
let nodes = [];
|
|
let links = [];
|
|
let shapes = [];
|
|
let selectedNodeId = null;
|
|
let selectedNodeIds = [];
|
|
let selectedShapeId = null;
|
|
|
|
let canvas, ctx;
|
|
let canvasNodes = [];
|
|
let canvasLinks = [];
|
|
let canvasShapes = [];
|
|
let panX = 0, panY = 0;
|
|
let isPanning = false;
|
|
let panStartX, panStartY;
|
|
|
|
let dragTarget = null;
|
|
let dragType = null;
|
|
let dragOffX, dragOffY;
|
|
let dragHandle = null;
|
|
let dragOrig = null;
|
|
let selectStartX, selectStartY;
|
|
let selectRect = null;
|
|
|
|
let nextShapeZ = 0;
|
|
let copyBuffer = null;
|
|
let editingNodeId = null;
|
|
let editingShapeId = null;
|
|
|
|
const DOC_TYPE_ICONS = {
|
|
deployment: { icon: 'fa-server', color: '#06b6d4' },
|
|
attack: { icon: 'fa-bolt', color: '#ef4444' },
|
|
'incident-report': { icon: 'fa-exclamation-triangle', color: '#f59e0b' },
|
|
remediation: { icon: 'fa-wrench', color: '#22c55e' },
|
|
exercise: { icon: 'fa-dumbbell', color: '#3b82f6' }
|
|
};
|
|
|
|
const DOC_TYPE_LABELS = {
|
|
deployment: 'Deployment',
|
|
attack: 'Attack',
|
|
'incident-report': 'Incident Report',
|
|
remediation: 'Remediation',
|
|
exercise: 'Exercise'
|
|
};
|
|
|
|
// ==================== AUTH / SESSION ====================
|
|
let currentUser = null;
|
|
let currentRole = null;
|
|
|
|
async function checkSession() {
|
|
try {
|
|
const res = await fetch('/api/session');
|
|
const data = await res.json();
|
|
if (data.loggedin) {
|
|
currentUser = data.username;
|
|
currentRole = data.role;
|
|
document.getElementById('userDisplay').textContent = data.username;
|
|
if (data.role === 'admin') document.getElementById('settingsBtn').classList.remove('d-none');
|
|
document.getElementById('loginOverlay').style.display = 'none';
|
|
return true;
|
|
}
|
|
} catch (_) {}
|
|
document.getElementById('loginOverlay').style.display = 'flex';
|
|
throw new Error('not logged in');
|
|
}
|
|
|
|
async function performLogin(authToken) {
|
|
const errEl = document.getElementById('loginError');
|
|
const sucEl = document.getElementById('loginSuccess');
|
|
errEl.style.display = 'none';
|
|
sucEl.style.display = 'none';
|
|
try {
|
|
const res = await fetch('/api/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ auth_token: authToken })
|
|
});
|
|
const text = await res.text();
|
|
let data;
|
|
try { data = JSON.parse(text); } catch (e) {
|
|
errEl.textContent = 'Server returned: ' + text.substring(0, 100);
|
|
errEl.style.display = 'block';
|
|
return;
|
|
}
|
|
if (data.status === 'success') {
|
|
currentUser = data.username;
|
|
currentRole = data.role;
|
|
document.getElementById('userDisplay').textContent = data.username;
|
|
if (data.role === 'admin') document.getElementById('settingsBtn').classList.remove('d-none');
|
|
document.getElementById('loginOverlay').style.display = 'none';
|
|
window.history.replaceState({}, '', '/');
|
|
initApp();
|
|
} else {
|
|
errEl.textContent = data.error || 'Login failed';
|
|
errEl.style.display = 'block';
|
|
}
|
|
} catch (e) {
|
|
errEl.textContent = 'Could not reach server. Try rebuilding Docker: docker compose down && docker compose build && docker compose up -d';
|
|
errEl.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
await apiFetch('logout', { method: 'POST' });
|
|
currentUser = null;
|
|
currentRole = null;
|
|
document.getElementById('settingsBtn').classList.add('d-none');
|
|
document.getElementById('userDisplay').textContent = '';
|
|
document.getElementById('loginOverlay').style.display = 'flex';
|
|
}
|
|
|
|
// Init
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
document.getElementById('loginBtn').addEventListener('click', () => {
|
|
const callbackUrl = window.location.origin + '/?auth_callback=1';
|
|
window.location.href = 'https://auth.jakach.ch/?send_to=' + encodeURIComponent(callbackUrl);
|
|
});
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
const authToken = params.get('auth');
|
|
if (authToken) {
|
|
document.getElementById('loginOverlay').style.display = 'flex';
|
|
const btn = document.querySelector('#loginOverlay .btn');
|
|
if (btn) btn.textContent = 'Authenticating...';
|
|
performLogin(authToken);
|
|
} else {
|
|
checkSession().then(initApp).catch(() => {});
|
|
}
|
|
});
|
|
|
|
function initApp() {
|
|
canvas = document.getElementById('networkCanvas');
|
|
ctx = canvas.getContext('2d');
|
|
resizeCanvas();
|
|
|
|
loadTeams().then(async () => {
|
|
await Promise.all([loadEvents(), loadDocuments()]);
|
|
});
|
|
loadNetworkData();
|
|
|
|
document.getElementById('saveEvent').addEventListener('click', saveEvent);
|
|
document.getElementById('saveNode').addEventListener('click', saveNode);
|
|
document.getElementById('saveLink').addEventListener('click', saveLink);
|
|
document.getElementById('saveShape').addEventListener('click', saveShape);
|
|
document.getElementById('saveLinkDocs').addEventListener('click', saveLinkDocs);
|
|
document.getElementById('addUserBtn').addEventListener('click', addUser);
|
|
document.getElementById('addUserBtn').addEventListener('click', addUser);
|
|
document.getElementById('logoutBtn').addEventListener('click', logout);
|
|
document.getElementById('teamFilter').addEventListener('change', renderTimeline);
|
|
document.getElementById('tagFilter').addEventListener('change', renderTimeline);
|
|
document.getElementById('searchEvents').addEventListener('input', renderTimeline);
|
|
document.getElementById('saveDocument').addEventListener('click', saveDocument);
|
|
document.getElementById('documentModal').addEventListener('hidden.bs.modal', () => {
|
|
editingDocId = null;
|
|
window._editDocData = null;
|
|
document.getElementById('documentForm').reset();
|
|
});
|
|
document.getElementById('docTeamFilter').addEventListener('change', renderDocuments);
|
|
document.getElementById('docTypeFilter').addEventListener('change', renderDocuments);
|
|
document.getElementById('searchDocs').addEventListener('input', renderDocuments);
|
|
document.getElementById('shapeOpacity').addEventListener('input', (e) => {
|
|
document.getElementById('opacityVal').textContent = parseFloat(e.target.value).toFixed(2);
|
|
});
|
|
|
|
const ALLOWED_EXTENSIONS = ['pdf', 'md', 'txt', 'docx', 'xlsx', 'csv', 'pptx', 'evidence'];
|
|
|
|
document.getElementById('eventFileInput').addEventListener('change', (e) => {
|
|
const preview = document.getElementById('eventFilePreview');
|
|
const files = Array.from(e.target.files);
|
|
const invalid = files.filter(f => {
|
|
const ext = f.name.split('.').pop().toLowerCase();
|
|
return !ALLOWED_EXTENSIONS.includes(ext);
|
|
});
|
|
if (invalid.length) {
|
|
preview.innerHTML = '<div class="text-danger small"><i class="fas fa-exclamation-circle me-1"></i>Invalid type(s): ' +
|
|
invalid.map(f => f.name).join(', ') +
|
|
'. Allowed: ' + ALLOWED_EXTENSIONS.join(', ') + '</div>';
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
if (!files.length) { preview.innerHTML = ''; return; }
|
|
preview.innerHTML = files.map(f =>
|
|
`<div class="small text-secondary"><i class="fas ${getFileIcon(f.type, f.name)} me-1"></i>${esc(f.name)} (${formatFileSize(f.size)})</div>`
|
|
).join('');
|
|
});
|
|
|
|
document.getElementById('eventTagsInput').addEventListener('input', renderEventTagsPreview);
|
|
|
|
document.getElementById('eventModal').addEventListener('hidden.bs.modal', () => {
|
|
document.getElementById('eventTagsDisplay').innerHTML = '';
|
|
document.getElementById('eventTagsInput').value = '';
|
|
});
|
|
|
|
canvas.addEventListener('mousedown', onMouseDown);
|
|
canvas.addEventListener('mousemove', onMouseMove);
|
|
canvas.addEventListener('mouseup', onMouseUp);
|
|
canvas.addEventListener('dblclick', onDblClick);
|
|
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
window.addEventListener('resize', () => { resizeCanvas(); renderNetwork(); });
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
|
if (!document.activeElement || document.activeElement.tagName !== 'INPUT') {
|
|
if (selectedNodeId) copyNode(selectedNodeId);
|
|
else if (selectedShapeId) copyShape(selectedShapeId);
|
|
}
|
|
return;
|
|
}
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
|
|
if (!document.activeElement || document.activeElement.tagName !== 'INPUT') {
|
|
if (copyBuffer) pasteItem();
|
|
}
|
|
return;
|
|
}
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
|
if (!document.activeElement || document.activeElement.tagName !== 'INPUT') {
|
|
e.preventDefault();
|
|
selectedNodeIds = canvasNodes.map(n => n.id);
|
|
selectedNodeId = selectedNodeIds.length > 0 ? selectedNodeIds[0] : null;
|
|
selectedShapeId = null;
|
|
renderNodeList(); renderShapeList(); renderNetwork();
|
|
}
|
|
return;
|
|
}
|
|
if (e.key === 'Delete') {
|
|
if (!document.activeElement || document.activeElement.tagName !== 'INPUT') {
|
|
if (selectedNodeIds.length > 0) deleteSelectedNodes();
|
|
else if (selectedShapeId) deleteSelectedShape(selectedShapeId);
|
|
}
|
|
}
|
|
if (e.key === 'Escape') {
|
|
selectedNodeId = null; selectedNodeIds = [];
|
|
selectedShapeId = null;
|
|
renderNetwork();
|
|
renderNodeList();
|
|
renderShapeList();
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(tab => {
|
|
tab.addEventListener('shown.bs.tab', () => {
|
|
if (tab.id === 'network-tab') { resizeCanvas(); renderNetwork(); }
|
|
if (tab.id === 'documents-tab') { loadDocuments(); }
|
|
});
|
|
});
|
|
|
|
document.documentElement.setAttribute('data-bs-theme', 'dark');
|
|
}
|
|
|
|
function resizeCanvas() {
|
|
const wrapper = document.getElementById('networkCanvasWrapper');
|
|
canvas.width = wrapper.clientWidth;
|
|
canvas.height = wrapper.clientHeight;
|
|
}
|
|
|
|
async function apiFetch(path, options = {}) {
|
|
const res = await fetch(API + path, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
...options
|
|
});
|
|
return res.json();
|
|
}
|
|
|
|
async function loadTeams() {
|
|
teams = await apiFetch('teams');
|
|
const sel = document.getElementById('eventTeam');
|
|
const filter = document.getElementById('teamFilter');
|
|
sel.innerHTML = '';
|
|
filter.innerHTML = '<option value="">All Teams</option>';
|
|
teams.forEach(t => {
|
|
sel.innerHTML += `<option value="${t.id}">${t.name}</option>`;
|
|
filter.innerHTML += `<option value="${t.id}">${t.name}</option>`;
|
|
});
|
|
}
|
|
|
|
async function loadEvents() {
|
|
events = await apiFetch('events');
|
|
syncHashes.events = JSON.stringify(events);
|
|
loadAllTags();
|
|
renderTimeline();
|
|
}
|
|
|
|
let allTags = [];
|
|
const PAGE_SIZE = 25;
|
|
let renderedCount = 0;
|
|
let filteredEvents = [];
|
|
let isLoadingMore = false;
|
|
|
|
async function loadAllTags() {
|
|
try {
|
|
allTags = await apiFetch('tags');
|
|
const sel = document.getElementById('tagFilter');
|
|
if (!sel) return;
|
|
const current = sel.value;
|
|
sel.innerHTML = '<option value="">All Tags</option>' + allTags.map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
|
|
sel.value = current;
|
|
} catch (e) {}
|
|
}
|
|
|
|
function renderTimeline() {
|
|
const container = document.getElementById('timelineContainer');
|
|
const teamFilter = document.getElementById('teamFilter').value;
|
|
const tagFilter = document.getElementById('tagFilter').value;
|
|
const search = document.getElementById('searchEvents').value.toLowerCase();
|
|
|
|
filteredEvents = events;
|
|
if (teamFilter) filteredEvents = filteredEvents.filter(e => e.team_id == teamFilter);
|
|
if (tagFilter) filteredEvents = filteredEvents.filter(e => e.tags && e.tags.includes(tagFilter));
|
|
if (search) filteredEvents = filteredEvents.filter(e =>
|
|
e.title.toLowerCase().includes(search) ||
|
|
(e.description && e.description.toLowerCase().includes(search)) ||
|
|
(e.tags && e.tags.some(t => t.includes(search)))
|
|
);
|
|
|
|
renderedCount = 0;
|
|
container.innerHTML = '';
|
|
container.removeAttribute('style');
|
|
renderMoreEvents();
|
|
}
|
|
|
|
function renderMoreEvents() {
|
|
const container = document.getElementById('timelineContainer');
|
|
|
|
if (!filteredEvents.length) {
|
|
container.innerHTML = '<div class="text-center text-secondary py-5"><i class="fas fa-book-open fs-1 mb-2"></i><p>No events yet. Create your first incident entry!</p></div>';
|
|
return;
|
|
}
|
|
|
|
const next = Math.min(renderedCount + PAGE_SIZE, filteredEvents.length);
|
|
const batch = filteredEvents.slice(renderedCount, next);
|
|
|
|
const html = batch.map(e => {
|
|
const date = new Date(e.occurred_at).toLocaleString();
|
|
return `
|
|
<div class="timeline-item" data-event-id="${e.id}">
|
|
<div class="timeline-dot severity-${e.severity}"></div>
|
|
<div class="card timeline-card bg-dark border-secondary" style="border-left-color: ${e.team_color}">
|
|
<div class="card-body py-1 px-3">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div class="d-flex align-items-center gap-1 flex-wrap">
|
|
<span class="badge severity-badge" style="background:${e.team_color}20;color:${e.team_color}">${esc(e.team_name)}</span>
|
|
<span class="badge bg-${e.severity === 'critical' ? 'danger' : e.severity === 'high' ? 'warning' : e.severity === 'medium' ? 'warning' : e.severity === 'low' ? 'success' : 'info'} severity-badge">${e.severity}</span>
|
|
<span class="badge bg-secondary severity-badge">${e.event_type}</span>
|
|
${(e.tags && e.tags.length) ? e.tags.map(t => `<span class="badge" style="font-size:.55rem;background:#1e2a45;color:#94a3b8;border:1px solid #2a3a60;text-transform:lowercase;">${esc(t)}</span>`).join('') : ''}
|
|
</div>
|
|
<div class="d-flex align-items-center gap-1">
|
|
<small class="event-meta">${date}</small>
|
|
<button class="btn btn-outline-info btn-sm py-0 px-1" onclick="linkDocsToEvent(${e.id})" title="Link documents" style="font-size:.65rem;"><i class="fas fa-link"></i></button>
|
|
<button class="btn btn-outline-danger btn-sm py-0 px-1" onclick="deleteEvent(${e.id}, this)" title="Delete event" style="font-size:.65rem;"><i class="fas fa-trash-alt"></i></button>
|
|
</div>
|
|
</div>
|
|
<h6 class="event-title mb-0 mt-1">${esc(e.title)}</h6>
|
|
${e.description ? '<p class="mb-0 small text-secondary">' + renderDocLinks(e.description) + '</p>' : ''}
|
|
<div class="doc-links-inline" id="docLinks-${e.id}">${renderInlineDocLinks(e.description || '')}</div>
|
|
<div class="attachments-list" id="attachments-${e.id}" data-event-id="${e.id}"></div>
|
|
<div class="mt-1" id="comments-${e.id}">
|
|
${(e.comments && e.comments.length) ? `<div class="d-flex align-items-center mb-1"><small class="text-secondary fw-bold" style="font-size:.7rem;"><i class="fas fa-comment-dots me-1"></i>Comments (${e.comments.length})</small></div>` : ''}
|
|
<div class="comment-log">${renderComments(e)}</div>
|
|
<div class="input-group input-group-sm comment-input-group mt-1">
|
|
<input type="text" class="form-control form-control-sm comment-input" placeholder="Write a comment..." onkeydown="if(event.key===\'Enter\') addComment(${e.id}, this)">
|
|
<button class="btn btn-outline-secondary btn-sm" onclick="addComment(${e.id}, this)"><i class="fas fa-paper-plane"></i></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.innerHTML = html;
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
while (wrapper.firstChild) fragment.appendChild(wrapper.firstChild);
|
|
container.appendChild(fragment);
|
|
|
|
renderedCount = next;
|
|
|
|
batch.forEach(e => {
|
|
const el = document.querySelector(`.timeline-item[data-event-id="${e.id}"] .attachments-list`);
|
|
if (el) renderAttachments(e.id, el);
|
|
});
|
|
|
|
if (renderedCount < filteredEvents.length) {
|
|
const sentinel = document.createElement('div');
|
|
sentinel.id = 'timeline-sentinel';
|
|
sentinel.className = 'text-center py-3';
|
|
sentinel.innerHTML = '<button class="btn btn-outline-primary btn-sm" onclick="renderMoreEvents()"><i class="fas fa-chevron-down me-1"></i>Load more (' + (filteredEvents.length - renderedCount) + ' remaining)</button>';
|
|
container.appendChild(sentinel);
|
|
observeSentinel();
|
|
}
|
|
}
|
|
|
|
let sentinelObserver = null;
|
|
let suppressNextMouse = false;
|
|
|
|
function observeSentinel() {
|
|
if (sentinelObserver) sentinelObserver.disconnect();
|
|
const sentinel = document.getElementById('timeline-sentinel');
|
|
if (!sentinel) return;
|
|
sentinelObserver = new IntersectionObserver((entries) => {
|
|
if (entries[0].isIntersecting && !isLoadingMore) {
|
|
isLoadingMore = true;
|
|
renderMoreEvents();
|
|
isLoadingMore = false;
|
|
}
|
|
}, { rootMargin: '200px' });
|
|
sentinelObserver.observe(sentinel);
|
|
}
|
|
|
|
function renderDocLinks(text) {
|
|
const parts = text.split(/(\[doc:\d+\].*?\[\/doc\])/g);
|
|
return parts.map(p => {
|
|
const m = p.match(/\[doc:(\d+)\](.*?)\[\/doc\]/);
|
|
if (m) {
|
|
return `<a href="#" class="doc-link" onclick="event.preventDefault();openDocument(${m[1]})" style="color:var(--neptune-accent);text-decoration:underline;cursor:pointer;">${esc(m[2])}</a>`;
|
|
}
|
|
return esc(p);
|
|
}).join('');
|
|
}
|
|
|
|
function renderInlineDocLinks(desc) {
|
|
const re = /\[doc:(\d+)\](.*?)\[\/doc\]/g;
|
|
const labels = [];
|
|
let m;
|
|
while ((m = re.exec(desc)) !== null) {
|
|
const id = parseInt(m[1]);
|
|
const title = m[2];
|
|
labels.push(`<a href="#" onclick="event.preventDefault();openDocument(${id})" class="doc-link-badge" style="display:inline-block;padding:1px 6px;margin:1px;border-radius:3px;background:var(--neptune-accent);color:#fff;font-size:.65rem;text-decoration:none;">${esc(title)}</a>`);
|
|
}
|
|
return labels.length ? '<small class="text-secondary me-1"><i class="fas fa-file-alt me-1"></i></small>' + labels.join('') : '';
|
|
}
|
|
|
|
function openDocument(id) {
|
|
const tab = document.getElementById('documents-tab');
|
|
if (tab) tab.click();
|
|
setTimeout(() => {
|
|
const container = document.getElementById('documentContainer');
|
|
if (!container) return;
|
|
const cards = container.querySelectorAll('.doc-card');
|
|
cards.forEach(c => {
|
|
c.style.transition = 'box-shadow .3s, border-color .3s';
|
|
c.style.boxShadow = '';
|
|
c.style.borderColor = '';
|
|
});
|
|
const target = container.querySelector(`[data-doc-id="${id}"]`);
|
|
if (target) {
|
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
target.style.boxShadow = '0 0 20px rgba(59,130,246,.5)';
|
|
target.style.borderColor = 'var(--neptune-accent)';
|
|
setTimeout(() => {
|
|
target.style.boxShadow = '';
|
|
target.style.borderColor = '';
|
|
}, 3000);
|
|
} else {
|
|
loadDocuments();
|
|
setTimeout(() => {
|
|
const el = document.getElementById('documentContainer').querySelector(`[data-doc-id="${id}"]`);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
el.style.boxShadow = '0 0 20px rgba(59,130,246,.5)';
|
|
el.style.borderColor = 'var(--neptune-accent)';
|
|
setTimeout(() => { el.style.boxShadow = ''; el.style.borderColor = ''; }, 3000);
|
|
}
|
|
}, 500);
|
|
}
|
|
const filter = document.getElementById('searchDocs');
|
|
if (filter) filter.value = '';
|
|
document.getElementById('docTeamFilter').value = '';
|
|
document.getElementById('docTypeFilter').value = '';
|
|
}, 300);
|
|
}
|
|
|
|
function linkDocsToEvent(eventId) {
|
|
linkingEventId = eventId;
|
|
const list = document.getElementById('linkDocsList');
|
|
if (!documents.length) {
|
|
list.innerHTML = '<div class="text-secondary small text-center py-3">No documents available. Create some first.</div>';
|
|
} else {
|
|
const alreadyLinked = extractDocIdsFromDesc(events.find(e => e.id == eventId)?.description || '');
|
|
list.innerHTML = documents.map(d => {
|
|
const checked = alreadyLinked.includes(d.id) ? 'checked' : '';
|
|
return `<div class="form-check mb-1">
|
|
<input class="form-check-input" type="checkbox" value="${d.id}" id="ld-${d.id}" ${checked}>
|
|
<label class="form-check-label small" for="ld-${d.id}">${esc(d.title)} <span class="text-secondary">(${DOC_TYPE_LABELS[d.doc_type] || d.doc_type})</span></label>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
new bootstrap.Modal(document.getElementById('linkDocsModal')).show();
|
|
}
|
|
|
|
function extractDocIdsFromDesc(desc) {
|
|
const ids = [];
|
|
const re = /\[doc:(\d+)\]/g;
|
|
let m;
|
|
while ((m = re.exec(desc)) !== null) ids.push(parseInt(m[1]));
|
|
return ids;
|
|
}
|
|
|
|
async function saveLinkDocs() {
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('linkDocsModal'));
|
|
if (!linkingEventId) { modal.hide(); return; }
|
|
const checks = document.querySelectorAll('#linkDocsList input[type="checkbox"]:checked');
|
|
const selectedIds = Array.from(checks).map(c => parseInt(c.value));
|
|
const event = events.find(e => e.id == linkingEventId);
|
|
if (!event) { modal.hide(); return; }
|
|
|
|
let desc = event.description || '';
|
|
desc = desc.replace(/\[doc:\d+\].*?\[\/doc\]\s*/g, '').trim();
|
|
const docParts = selectedIds.map(id => {
|
|
const d = documents.find(x => x.id == id);
|
|
return d ? `[doc:${d.id}]${d.title}[/doc]` : '';
|
|
}).filter(Boolean);
|
|
if (docParts.length) desc = (desc ? desc + '\n' : '') + 'Linked: ' + docParts.join(', ');
|
|
await apiFetch('events/' + linkingEventId, { method: 'PUT', body: JSON.stringify({ description: desc }) });
|
|
modal.hide();
|
|
linkingEventId = null;
|
|
loadEvents();
|
|
}
|
|
|
|
function renderComments(event) {
|
|
if (!event.comments || !event.comments.length) return '';
|
|
return event.comments.map(c => `
|
|
<div class="comment-box d-flex">
|
|
<div class="me-2 text-secondary" style="font-size:.7rem;"><i class="fas fa-user-circle"></i></div>
|
|
<div class="flex-grow-1">
|
|
<div class="d-flex justify-content-between">
|
|
<span class="comment-author">${esc(c.author)}</span>
|
|
<span class="text-secondary" style="font-size:.7rem;">${new Date(c.created_at).toLocaleString()}</span>
|
|
</div>
|
|
<div class="comment-body">${esc(c.body)}</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
async function addComment(eventId, el) {
|
|
const container = el.closest('.comment-input-group') || el.parentElement;
|
|
const input = container.querySelector('.comment-input');
|
|
const body = input.value.trim();
|
|
if (!body) return;
|
|
const author = currentUser || 'Anonymous';
|
|
await apiFetch('comments', { method: 'POST', body: JSON.stringify({ event_id: eventId, author, body }) });
|
|
input.value = '';
|
|
loadEvents();
|
|
}
|
|
|
|
async function deleteEvent(id, btn) {
|
|
if (!confirm('Delete this event?')) return;
|
|
btn.disabled = true;
|
|
await apiFetch('events/' + id, { method: 'DELETE' });
|
|
loadEvents();
|
|
}
|
|
|
|
async function uploadAttachment(eventId, file) {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('event_id', eventId);
|
|
await fetch('/api/attachments', { method: 'POST', body: formData });
|
|
}
|
|
|
|
async function loadAttachments(eventId) {
|
|
try {
|
|
const res = await fetch('/api/attachments?event_id=' + eventId);
|
|
return await res.json();
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function deleteAttachment(id, el) {
|
|
if (!confirm('Delete this file?')) return;
|
|
await apiFetch('attachments/' + id, { method: 'DELETE' });
|
|
const container = el.closest('.attachments-list');
|
|
if (container) {
|
|
const eventId = container.dataset.eventId;
|
|
renderAttachments(eventId, container);
|
|
}
|
|
}
|
|
|
|
async function renderAttachments(eventId, container) {
|
|
container.innerHTML = '<small class="text-secondary"><i class="fas fa-spinner fa-spin me-1"></i>Loading...</small>';
|
|
const attachments = await loadAttachments(eventId);
|
|
if (!attachments.length) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
container.innerHTML = attachments.map(a => {
|
|
const icon = getFileIcon(a.mime_type, a.original_name);
|
|
const size = formatFileSize(a.file_size);
|
|
const ext = a.original_name.split('.').pop().toLowerCase();
|
|
const viewable = ['txt', 'md', 'csv'].includes(ext);
|
|
const href = viewable ? '#' : '/download/?file=' + encodeURIComponent(a.stored_name) + '&mode=download';
|
|
const onclick = viewable ? ` onclick="event.preventDefault();openFileViewer('${esc(a.stored_name)}','${esc(a.original_name)}')"` : '';
|
|
return `<div class="attachment-item d-flex align-items-center justify-content-between py-1">
|
|
<div class="d-flex align-items-center" style="min-width:0;">
|
|
<i class="fas ${icon} me-2" style="font-size:.85rem;flex-shrink:0;"></i>
|
|
<a href="${href}"${onclick} target="_blank" class="small text-decoration-none text-truncate" style="color:var(--neptune-accent);">${esc(a.original_name)}</a>
|
|
<small class="text-secondary ms-2 flex-shrink-0">(${size})</small>
|
|
</div>
|
|
<div class="d-flex align-items-center gap-1 flex-shrink-0">
|
|
<a href="/download/?file=${encodeURIComponent(a.stored_name)}&mode=download" class="btn btn-outline-primary btn-sm py-0 px-1" title="Download" style="font-size:.6rem;"><i class="fas fa-download"></i></a>
|
|
<button class="btn btn-outline-danger btn-sm py-0 px-1" onclick="deleteAttachment(${a.id}, this)" title="Delete file" style="font-size:.6rem;"><i class="fas fa-times"></i></button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function formatFileSize(bytes) {
|
|
if (!bytes) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB'];
|
|
let i = 0;
|
|
let size = bytes;
|
|
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
|
return size.toFixed(1) + ' ' + units[i];
|
|
}
|
|
|
|
function getFileIcon(mime, name) {
|
|
if (mime.startsWith('image/')) return 'fa-file-image';
|
|
if (mime === 'application/pdf') return 'fa-file-pdf';
|
|
if (mime.startsWith('text/')) return 'fa-file-alt';
|
|
if (mime.includes('spreadsheet') || mime.includes('excel') || name.endsWith('.csv') || name.endsWith('.xls') || name.endsWith('.xlsx')) return 'fa-file-excel';
|
|
if (mime.includes('document') || mime.includes('word') || name.endsWith('.doc') || name.endsWith('.docx')) return 'fa-file-word';
|
|
if (name.endsWith('.md')) return 'fa-file-alt';
|
|
return 'fa-file';
|
|
}
|
|
|
|
function openFileViewer(storedName, originalName) {
|
|
const modalEl = document.getElementById('fileViewerModal');
|
|
document.getElementById('fileViewerName').textContent = originalName;
|
|
document.getElementById('fileViewerDownloadBtn').onclick = () => {
|
|
window.open('/download/?file=' + encodeURIComponent(storedName) + '&mode=download', '_blank');
|
|
};
|
|
const body = document.getElementById('fileViewerBody');
|
|
body.innerHTML = '<div class="text-center text-secondary py-5"><div class="spinner-border" role="status"></div><p class="mt-2 small">Loading file...</p></div>';
|
|
|
|
const modal = new bootstrap.Modal(modalEl);
|
|
modal.show();
|
|
|
|
const ext = originalName.split('.').pop().toLowerCase();
|
|
if (ext === 'pdf') {
|
|
body.innerHTML = `<iframe src="/download/?file=${encodeURIComponent(storedName)}&mode=view" style="width:100%;min-height:80vh;border:none;"></iframe>`;
|
|
return;
|
|
}
|
|
|
|
fetch('/download/?file=' + encodeURIComponent(storedName) + '&mode=view')
|
|
.then(r => {
|
|
if (!r.ok) throw new Error('Failed to load');
|
|
return r.text();
|
|
})
|
|
.then(text => {
|
|
if (ext === 'md') {
|
|
body.innerHTML = '<div class="p-3" style="white-space:pre-wrap;font-family:var(--bs-font-monospace);font-size:.85rem;line-height:1.6;color:#e2e8f0;">' + esc(text) + '</div>';
|
|
} else {
|
|
body.innerHTML = '<div class="p-3" style="white-space:pre-wrap;font-family:var(--bs-font-monospace);font-size:.85rem;line-height:1.6;color:#e2e8f0;">' + esc(text) + '</div>';
|
|
}
|
|
})
|
|
.catch(() => {
|
|
body.innerHTML = '<div class="text-center text-danger py-5"><i class="fas fa-exclamation-circle fs-2 mb-2"></i><p class="small">Failed to load file</p></div>';
|
|
});
|
|
}
|
|
|
|
async function saveEvent() {
|
|
const tags = parseEventTags();
|
|
const data = {
|
|
team_id: document.getElementById('eventTeam').value,
|
|
title: document.getElementById('eventTitle').value,
|
|
description: document.getElementById('eventDescription').value,
|
|
severity: document.getElementById('eventSeverity').value,
|
|
event_type: document.getElementById('eventType').value,
|
|
occurred_at: new Date().toISOString().slice(0, 16),
|
|
tags: tags
|
|
};
|
|
if (!data.title) return alert('Title required');
|
|
|
|
const result = await apiFetch('events', { method: 'POST', body: JSON.stringify(data) });
|
|
const eventId = result.id;
|
|
|
|
const fileInput = document.getElementById('eventFileInput');
|
|
if (fileInput.files.length > 0) {
|
|
for (const file of fileInput.files) {
|
|
await uploadAttachment(eventId, file);
|
|
}
|
|
}
|
|
bootstrap.Modal.getInstance(document.getElementById('eventModal')).hide();
|
|
document.getElementById('eventForm').reset();
|
|
document.getElementById('eventFilePreview').innerHTML = '';
|
|
document.getElementById('eventTagsDisplay').innerHTML = '';
|
|
document.getElementById('eventTagsInput').value = '';
|
|
fileInput.value = '';
|
|
loadEvents();
|
|
}
|
|
|
|
function parseEventTags() {
|
|
const input = document.getElementById('eventTagsInput');
|
|
if (!input) return [];
|
|
return input.value.split(',').map(t => t.trim().toLowerCase()).filter(t => t.length > 0);
|
|
}
|
|
|
|
function renderEventTagsPreview() {
|
|
const tags = parseEventTags();
|
|
const container = document.getElementById('eventTagsDisplay');
|
|
if (!container) return;
|
|
container.innerHTML = tags.map(t => `<span class="badge bg-secondary me-1" style="font-size:.7rem;">${esc(t)}</span>`).join('');
|
|
}
|
|
|
|
function renderTimelineTags(tags) {
|
|
if (!tags || !tags.length) return '';
|
|
return '<div class="d-flex flex-wrap gap-1 mb-1">' + tags.map(t =>
|
|
`<span class="badge" style="font-size:.6rem;background:#1e2a45;color:#94a3b8;border:1px solid #2a3a60;">${esc(t)}</span>`
|
|
).join('') + '</div>';
|
|
}
|
|
|
|
// ==================== NETWORK MAP DATA ====================
|
|
async function loadNetworkData() {
|
|
const [n, l, s] = await Promise.all([
|
|
apiFetch('nodes'),
|
|
apiFetch('links'),
|
|
apiFetch('shapes')
|
|
]);
|
|
nodes = Array.isArray(n) ? n : [];
|
|
links = Array.isArray(l) ? l : [];
|
|
shapes = Array.isArray(s) ? s : [];
|
|
populateNodeSelects();
|
|
renderNodeList();
|
|
renderShapeList();
|
|
renderNodeToolbar();
|
|
setupCanvasDrop();
|
|
startSync();
|
|
if (shapes.length) nextShapeZ = Math.max(...shapes.map(x => x.z_index)) + 1;
|
|
buildCanvasGraph();
|
|
renderNetwork();
|
|
}
|
|
|
|
function populateNodeSelects() {
|
|
const html = nodes.map(n => '<option value="' + n.id + '">' + esc(n.label) + ' (' + (n.ip_address || 'no IP') + ')</option>').join('');
|
|
document.getElementById('linkSource').innerHTML = html;
|
|
document.getElementById('linkTarget').innerHTML = html;
|
|
}
|
|
|
|
function renderNodeList() {
|
|
const list = document.getElementById('nodeList');
|
|
const iconMap = { host:'fa-desktop', server:'fa-server', router:'fa-route', firewall:'fa-shield-halved', switch:'fa-network-wired', cloud:'fa-cloud', endpoint:'fa-laptop', other:'fa-circle' };
|
|
list.innerHTML = nodes.map(n => {
|
|
return '<div class="list-group-item bg-dark border-secondary node-list-item py-2 ' + (selectedNodeIds.includes(n.id) ? 'active' : '') + '" onclick="selectNode(' + n.id + ', event.shiftKey)">' +
|
|
'<div class="d-flex align-items-center">' +
|
|
'<span class="status-dot status-' + n.status + '"></span>' +
|
|
'<i class="fas ' + (iconMap[n.node_type] || 'fa-circle') + ' me-2" style="color:' + getNodeColorVal(n.node_type) + ';font-size:.85rem;"></i>' +
|
|
'<div>' +
|
|
'<strong class="small">' + esc(n.label) + '</strong>' +
|
|
'<div class="text-secondary" style="font-size:.7rem;">' + (n.ip_address || '—') + ' · ' + n.node_type + '</div>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
function renderShapeList() {
|
|
const list = document.getElementById('shapeList');
|
|
list.innerHTML = shapes.map(s => {
|
|
return '<div class="list-group-item bg-dark border-secondary py-2 ' + (selectedShapeId == s.id ? 'active' : '') + '" onclick="selectShape(' + s.id + ')">' +
|
|
'<div class="d-flex align-items-center justify-content-between">' +
|
|
'<div>' +
|
|
'<span style="display:inline-block;width:12px;height:12px;border-radius:2px;background:' + s.border_color + ';margin-right:.4rem;"></span>' +
|
|
'<strong class="small">' + esc(s.label || (s.shape_type === 'rectangle' ? 'Box' : 'Ellipse')) + '</strong>' +
|
|
'</div>' +
|
|
'<div>' +
|
|
'<small class="text-secondary me-2">' + s.shape_type + '</small>' +
|
|
'<button class="btn btn-sm btn-outline-primary py-0 px-1" onclick="event.stopPropagation();editSelectedShape(' + s.id + ')" title="Edit"><i class="fas fa-pen" style="font-size:.7rem;"></i></button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
function selectNode(id, add) {
|
|
if (add) {
|
|
const idx = selectedNodeIds.indexOf(id);
|
|
if (idx >= 0) { selectedNodeIds.splice(idx, 1); if (selectedNodeIds.length === 0) selectedNodeId = null; else selectedNodeId = selectedNodeIds[0]; }
|
|
else { selectedNodeIds.push(id); selectedNodeId = id; }
|
|
} else {
|
|
selectedNodeId = id;
|
|
selectedNodeIds = [id];
|
|
}
|
|
selectedShapeId = null;
|
|
const n = nodes.find(x => x.id == id);
|
|
if (n) {
|
|
document.getElementById('nodeDetails').innerHTML =
|
|
'<div class="small">' +
|
|
'<div class="d-flex align-items-center mb-1"><span class="status-dot status-' + n.status + ' me-2"></span><strong>' + esc(n.label) + '</strong></div>' +
|
|
'<div><span class="text-secondary">IP:</span> ' + (n.ip_address || '—') + '</div>' +
|
|
'<div><span class="text-secondary">Type:</span> ' + n.node_type + '</div>' +
|
|
'<div><span class="text-secondary">Status:</span> ' + n.status + '</div>' +
|
|
'<div><span class="text-secondary">Group:</span> ' + n.group_name + '</div>' +
|
|
(n.notes ? '<div class="mt-1 p-1 rounded" style="background:#0d1117;font-size:.75rem;color:#94a3b8;"><i class="fas fa-sticky-note me-1"></i>' + esc(n.notes) + '</div>' : (typeof n.notes !== 'undefined' ? '<div class="mt-1 text-secondary" style="font-size:.7rem;"><i class="fas fa-sticky-note me-1"></i>No notes</div>' : '')) +
|
|
(selectedNodeIds.length > 1 ? '<small class="text-secondary">+' + (selectedNodeIds.length - 1) + ' more selected</small>' : '') +
|
|
'<div class="mt-2 d-flex gap-1">' +
|
|
'<button class="btn btn-outline-primary btn-sm" onclick="editSelectedNode(' + n.id + ')"><i class="fas fa-pen me-1"></i>Edit</button>' +
|
|
'<button class="btn btn-outline-danger btn-sm" onclick="deleteSelectedNodes()"><i class="fas fa-trash me-1"></i>Delete</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}
|
|
renderNodeList();
|
|
renderShapeList();
|
|
renderNetwork();
|
|
}
|
|
|
|
function selectShape(id) {
|
|
selectedShapeId = id;
|
|
selectedNodeId = null;
|
|
selectedNodeIds = [];
|
|
renderNodeList();
|
|
renderShapeList();
|
|
renderNetwork();
|
|
}
|
|
|
|
async function deleteSelectedNodes() {
|
|
const ids = [...selectedNodeIds];
|
|
if (!ids.length) return;
|
|
const ok = await showConfirm('Delete ' + ids.length + ' node(s) and their connections?');
|
|
if (!ok) return;
|
|
selectedNodeId = null;
|
|
selectedNodeIds = [];
|
|
for (const id of ids) await apiFetch('nodes/' + id, { method: 'DELETE' });
|
|
loadNetworkData();
|
|
}
|
|
|
|
async function deleteSelectedShape(id) {
|
|
if (!id) return;
|
|
const ok = await showConfirm('Delete this shape?');
|
|
if (!ok) return;
|
|
selectedShapeId = null;
|
|
await apiFetch('shapes/' + id, { method: 'DELETE' });
|
|
loadNetworkData();
|
|
}
|
|
|
|
function copyNode(id) {
|
|
const n = nodes.find(x => x.id == id);
|
|
if (!n) return;
|
|
copyBuffer = { type: 'node', data: Object.assign({}, n) };
|
|
}
|
|
|
|
function copyShape(id) {
|
|
const s = shapes.find(x => x.id == id);
|
|
if (!s) return;
|
|
copyBuffer = { type: 'shape', data: Object.assign({}, s) };
|
|
}
|
|
|
|
async function pasteItem() {
|
|
if (!copyBuffer) return;
|
|
const offset = 30;
|
|
if (copyBuffer.type === 'node') {
|
|
const d = copyBuffer.data;
|
|
await apiFetch('nodes', { method: 'POST', body: JSON.stringify({
|
|
label: d.label + ' (copy)', ip_address: d.ip_address,
|
|
node_type: d.node_type, status: d.status, group_name: d.group_name,
|
|
pos_x: (parseFloat(d.pos_x) || 100) + offset,
|
|
pos_y: (parseFloat(d.pos_y) || 100) + offset
|
|
})});
|
|
loadNetworkData();
|
|
} else if (copyBuffer.type === 'shape') {
|
|
const d = copyBuffer.data;
|
|
await apiFetch('shapes', { method: 'POST', body: JSON.stringify({
|
|
label: d.label + ' (copy)', shape_type: d.shape_type,
|
|
pos_x: (parseFloat(d.pos_x) || 100) + offset,
|
|
pos_y: (parseFloat(d.pos_y) || 100) + offset,
|
|
width: d.width || 200, height: d.height || 150,
|
|
color: d.color || '#1e3a5f', border_color: d.border_color || '#3b82f6',
|
|
opacity: parseFloat(d.opacity) || 0.15, z_index: nextShapeZ++
|
|
})});
|
|
loadNetworkData();
|
|
}
|
|
}
|
|
|
|
function showConfirm(msg) {
|
|
return new Promise((resolve) => {
|
|
const modalEl = document.getElementById('confirmModal');
|
|
const modal = new bootstrap.Modal(modalEl);
|
|
document.getElementById('confirmMsg').textContent = msg;
|
|
const btn = document.getElementById('confirmBtn');
|
|
let resolved = false;
|
|
const done = (val) => { if (resolved) return; resolved = true; modal.hide(); resolve(val); };
|
|
const onKey = (e) => { if (e.key === 'Enter') { e.preventDefault(); done(true); } };
|
|
btn.onclick = () => done(true);
|
|
modalEl.addEventListener('hidden.bs.modal', () => { document.removeEventListener('keydown', onKey); if (!resolved) resolve(false); });
|
|
document.addEventListener('keydown', onKey);
|
|
modal.show();
|
|
});
|
|
}
|
|
|
|
async function saveNode() {
|
|
const data = {
|
|
label: document.getElementById('nodeLabel').value,
|
|
ip_address: document.getElementById('nodeIp').value,
|
|
node_type: document.getElementById('nodeType').value,
|
|
status: document.getElementById('nodeStatus').value,
|
|
group_name: document.getElementById('nodeGroup').value || 'default',
|
|
notes: document.getElementById('nodeNotes').value,
|
|
pos_x: Math.random() * canvas.width * 0.6 + canvas.width * 0.2 - panX,
|
|
pos_y: Math.random() * canvas.height * 0.6 + canvas.height * 0.2 - panY
|
|
};
|
|
if (!data.label) return alert('Label required');
|
|
if (editingNodeId) {
|
|
await apiFetch('nodes/' + editingNodeId, { method: 'PUT', body: JSON.stringify({
|
|
label: data.label, ip_address: data.ip_address,
|
|
node_type: data.node_type, status: data.status,
|
|
group_name: data.group_name, notes: data.notes
|
|
})});
|
|
editingNodeId = null;
|
|
} else {
|
|
await apiFetch('nodes', { method: 'POST', body: JSON.stringify(data) });
|
|
}
|
|
bootstrap.Modal.getInstance(document.getElementById('nodeModal')).hide();
|
|
document.getElementById('nodeForm').reset();
|
|
document.getElementById('nodeModalLabel').textContent = 'Add Network Node';
|
|
document.getElementById('saveNode').innerHTML = '<i class="fas fa-save me-1"></i> Add Node';
|
|
loadNetworkData();
|
|
}
|
|
|
|
function editSelectedNode(id) {
|
|
const n = nodes.find(x => x.id == id);
|
|
if (!n) return;
|
|
editingNodeId = id;
|
|
document.getElementById('nodeLabel').value = n.label;
|
|
document.getElementById('nodeIp').value = n.ip_address || '';
|
|
document.getElementById('nodeType').value = n.node_type;
|
|
document.getElementById('nodeStatus').value = n.status;
|
|
document.getElementById('nodeGroup').value = n.group_name;
|
|
document.getElementById('nodeNotes').value = n.notes || '';
|
|
document.getElementById('nodeModalLabel').textContent = 'Edit Network Node';
|
|
document.getElementById('saveNode').innerHTML = '<i class="fas fa-save me-1"></i> Update Node';
|
|
new bootstrap.Modal(document.getElementById('nodeModal')).show();
|
|
}
|
|
|
|
function editSelectedShape(id) {
|
|
const s = shapes.find(x => x.id == id);
|
|
if (!s) return;
|
|
editingShapeId = id;
|
|
document.getElementById('shapeLabel').value = s.label;
|
|
document.getElementById('shapeType').value = s.shape_type;
|
|
document.getElementById('shapeColor').value = s.color;
|
|
document.getElementById('shapeBorderColor').value = s.border_color;
|
|
document.getElementById('shapeOpacity').value = s.opacity;
|
|
document.getElementById('opacityVal').textContent = s.opacity;
|
|
document.getElementById('shapeModalLabel').textContent = 'Edit Shape';
|
|
document.getElementById('saveShape').innerHTML = '<i class="fas fa-save me-1"></i> Update Shape';
|
|
new bootstrap.Modal(document.getElementById('shapeModal')).show();
|
|
}
|
|
|
|
async function saveLink() {
|
|
const data = {
|
|
source_id: document.getElementById('linkSource').value,
|
|
target_id: document.getElementById('linkTarget').value,
|
|
link_type: document.getElementById('linkType').value,
|
|
label: document.getElementById('linkLabel').value
|
|
};
|
|
if (data.source_id === data.target_id) return alert('Source and target must differ');
|
|
await apiFetch('links', { method: 'POST', body: JSON.stringify(data) });
|
|
bootstrap.Modal.getInstance(document.getElementById('linkModal')).hide();
|
|
document.getElementById('linkForm').reset();
|
|
loadNetworkData();
|
|
}
|
|
|
|
async function saveShape() {
|
|
const data = {
|
|
label: document.getElementById('shapeLabel').value,
|
|
shape_type: document.getElementById('shapeType').value,
|
|
color: document.getElementById('shapeColor').value,
|
|
border_color: document.getElementById('shapeBorderColor').value,
|
|
opacity: parseFloat(document.getElementById('shapeOpacity').value),
|
|
};
|
|
if (editingShapeId) {
|
|
const s = shapes.find(x => x.id == editingShapeId);
|
|
data.pos_x = s.pos_x; data.pos_y = s.pos_y; data.width = s.width; data.height = s.height; data.z_index = s.z_index;
|
|
await apiFetch('shapes/' + editingShapeId, { method: 'PUT', body: JSON.stringify(data) });
|
|
editingShapeId = null;
|
|
} else {
|
|
data.pos_x = canvas.width / 2 - 100 - panX;
|
|
data.pos_y = canvas.height / 2 - 75 - panY;
|
|
data.width = 200; data.height = 150;
|
|
data.z_index = nextShapeZ++;
|
|
await apiFetch('shapes', { method: 'POST', body: JSON.stringify(data) });
|
|
}
|
|
bootstrap.Modal.getInstance(document.getElementById('shapeModal')).hide();
|
|
document.getElementById('shapeForm').reset();
|
|
document.getElementById('shapeModalLabel').textContent = 'Add Shape';
|
|
document.getElementById('saveShape').innerHTML = '<i class="fas fa-save me-1"></i> Add Shape';
|
|
loadNetworkData();
|
|
}
|
|
|
|
function getNodeColorVal(type) {
|
|
const c = { host:'#3b82f6', server:'#8b5cf6', router:'#f59e0b', firewall:'#ef4444', switch:'#06b6d4', cloud:'#22c55e', endpoint:'#ec4899', other:'#6b7280' };
|
|
return c[type] || '#6b7280';
|
|
}
|
|
|
|
const NODE_FA_ICONS = {
|
|
host: { icon: '\uf108', color: '#3b82f6' },
|
|
server: { icon: '\uf233', color: '#8b5cf6' },
|
|
router: { icon: '\uf4d8', color: '#f59e0b' },
|
|
firewall: { icon: '\uf3ed', color: '#ef4444' },
|
|
switch: { icon: '\uf6ff', color: '#06b6d4' },
|
|
cloud: { icon: '\uf0c2', color: '#22c55e' },
|
|
endpoint: { icon: '\uf109', color: '#ec4899' },
|
|
other: { icon: '\uf111', color: '#6b7280' }
|
|
};
|
|
|
|
function buildCanvasGraph() {
|
|
canvasNodes = nodes.map(n => {
|
|
const fa = NODE_FA_ICONS[n.node_type] || NODE_FA_ICONS.other;
|
|
return { id: n.id, label: n.label, ip: n.ip_address, type: n.node_type,
|
|
status: n.status, group: n.group_name,
|
|
x: parseFloat(n.pos_x) || 100, y: parseFloat(n.pos_y) || 100,
|
|
icon: fa.icon, color: fa.color, w: 36, h: 36 };
|
|
});
|
|
canvasLinks = links.map(l => ({
|
|
source: canvasNodes.find(n => n.id == l.source_id),
|
|
target: canvasNodes.find(n => n.id == l.target_id),
|
|
type: l.link_type, label: l.label
|
|
})).filter(l => l.source && l.target);
|
|
canvasShapes = shapes.map(s => ({
|
|
id: s.id, label: s.label, type: s.shape_type,
|
|
x: parseFloat(s.pos_x), y: parseFloat(s.pos_y),
|
|
w: parseFloat(s.width), h: parseFloat(s.height),
|
|
color: s.color, borderColor: s.border_color,
|
|
opacity: parseFloat(s.opacity), z: parseInt(s.z_index)
|
|
}));
|
|
}
|
|
|
|
function renderNetwork() {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.save();
|
|
ctx.translate(panX, panY);
|
|
canvasShapes.sort((a, b) => a.z - b.z).forEach(drawShape);
|
|
canvasLinks.forEach(l => {
|
|
ctx.beginPath();
|
|
ctx.moveTo(l.source.x, l.source.y);
|
|
ctx.lineTo(l.target.x, l.target.y);
|
|
const colors = { direct: '#334155', vpn: '#eab308', wireless: '#22c55e', monitored: '#3b82f6' };
|
|
ctx.strokeStyle = colors[l.type] || '#334155';
|
|
ctx.lineWidth = l.type === 'vpn' ? 2.5 : 1.5;
|
|
if (l.type === 'vpn' || l.type === 'wireless') ctx.setLineDash([6, 4]);
|
|
else ctx.setLineDash([]);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
if (l.label) {
|
|
ctx.fillStyle = '#94a3b8';
|
|
ctx.font = '10px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(l.label, (l.source.x + l.target.x) / 2, (l.source.y + l.target.y) / 2 - 8);
|
|
}
|
|
});
|
|
canvasNodes.forEach(drawCanvasNode);
|
|
if (selectRect && (selectRect.w > 0 || selectRect.h > 0)) {
|
|
ctx.strokeStyle = '#3b82f6';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.setLineDash([4, 4]);
|
|
ctx.strokeRect(selectRect.x, selectRect.y, selectRect.w, selectRect.h);
|
|
ctx.fillStyle = 'rgba(59, 130, 246, 0.08)';
|
|
ctx.fillRect(selectRect.x, selectRect.y, selectRect.w, selectRect.h);
|
|
ctx.setLineDash([]);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawShape(s) {
|
|
ctx.save();
|
|
const sel = selectedShapeId == s.id;
|
|
ctx.globalAlpha = s.opacity;
|
|
if (s.type === 'ellipse') {
|
|
ctx.beginPath();
|
|
ctx.ellipse(s.x + s.w / 2, s.y + s.h / 2, s.w / 2, s.h / 2, 0, 0, Math.PI * 2);
|
|
} else {
|
|
ctx.beginPath();
|
|
ctx.roundRect(s.x, s.y, s.w, s.h, 8);
|
|
}
|
|
ctx.fillStyle = s.color;
|
|
ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
ctx.strokeStyle = sel ? '#ffffff' : s.borderColor;
|
|
ctx.lineWidth = sel ? 2.5 : 1.5;
|
|
ctx.setLineDash([5, 3]);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
if (sel) {
|
|
getShapeHandles(s).forEach(h => {
|
|
ctx.beginPath();
|
|
ctx.arc(h.x, h.y, 5, 0, Math.PI * 2);
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#3b82f6';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
});
|
|
}
|
|
ctx.fillStyle = '#94a3b8';
|
|
ctx.font = '12px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(s.label || '', s.x + s.w / 2, s.y - 8);
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawCanvasNode(n) {
|
|
const sel = selectedNodeIds.includes(n.id);
|
|
ctx.save();
|
|
if (sel) { ctx.shadowColor = n.color; ctx.shadowBlur = 18; }
|
|
ctx.beginPath();
|
|
ctx.arc(n.x, n.y, 22, 0, Math.PI * 2);
|
|
ctx.fillStyle = n.color + '18';
|
|
ctx.fill();
|
|
ctx.strokeStyle = sel ? '#ffffff' : n.color;
|
|
ctx.lineWidth = sel ? 2.5 : 1.5;
|
|
ctx.stroke();
|
|
ctx.shadowBlur = 0;
|
|
ctx.save();
|
|
ctx.font = '900 22px "Font Awesome 6 Free", "FontAwesome", "Font Awesome 5 Free"';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillStyle = n.color;
|
|
ctx.fillText(n.icon, n.x, n.y);
|
|
ctx.restore();
|
|
ctx.beginPath();
|
|
ctx.arc(n.x + 17, n.y - 17, 5, 0, Math.PI * 2);
|
|
const sc = { online: '#22c55e', offline: '#6b7280', unknown: '#9ca3af', compromised: '#ef4444', monitoring: '#eab308' };
|
|
ctx.fillStyle = sc[n.status] || '#9ca3af';
|
|
ctx.fill();
|
|
if (n.status === 'compromised') { ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 2; ctx.stroke(); }
|
|
ctx.fillStyle = '#e2e8f0';
|
|
ctx.font = sel ? 'bold 11px sans-serif' : '10px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(n.label, n.x, n.y + 34);
|
|
if (n.ip) {
|
|
ctx.fillStyle = '#64748b';
|
|
ctx.font = '9px sans-serif';
|
|
ctx.fillText(n.ip, n.x, n.y + 46);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function getShapeHandles(s) {
|
|
return [
|
|
{ x: s.x, y: s.y, cx: 0, cy: 0 },
|
|
{ x: s.x + s.w, y: s.y, cx: 1, cy: 0 },
|
|
{ x: s.x + s.w, y: s.y + s.h, cx: 1, cy: 1 },
|
|
{ x: s.x, y: s.y + s.h, cx: 0, cy: 1 }
|
|
];
|
|
}
|
|
|
|
function renderNodeToolbar() {
|
|
const bar = document.getElementById('nodeToolbar');
|
|
if (!bar) return;
|
|
const iconMap = [
|
|
{ type: 'host', icon: 'fa-desktop', color: '#3b82f6', label: 'Host' },
|
|
{ type: 'server', icon: 'fa-server', color: '#8b5cf6', label: 'Server' },
|
|
{ type: 'router', icon: 'fa-route', color: '#f59e0b', label: 'Router' },
|
|
{ type: 'firewall', icon: 'fa-shield-halved', color: '#ef4444', label: 'Firewall' },
|
|
{ type: 'switch', icon: 'fa-network-wired', color: '#06b6d4', label: 'Switch' },
|
|
{ type: 'cloud', icon: 'fa-cloud', color: '#22c55e', label: 'Cloud' },
|
|
{ type: 'endpoint', icon: 'fa-laptop', color: '#ec4899', label: 'Endpoint' },
|
|
{ type: 'other', icon: 'fa-circle', color: '#6b7280', label: 'Other' },
|
|
{ type: 'shape:rectangle', icon: 'fa-vector-square', color: '#3b82f6', label: 'Box', isShape: true },
|
|
{ type: 'shape:ellipse', icon: 'fa-circle', color: '#3b82f6', label: 'Ellipse', isShape: true },
|
|
];
|
|
bar.innerHTML = '<span class="text-secondary me-1 small" style="font-size:.7rem;line-height:26px;">Drag to canvas:</span>';
|
|
iconMap.forEach(t => {
|
|
const el = document.createElement('span');
|
|
el.className = 'd-inline-flex align-items-center gap-1 px-2 py-1 rounded';
|
|
el.draggable = true;
|
|
el.style.cssText = 'cursor:grab;font-size:.75rem;color:' + t.color + ';background:' + t.color + '12;border:1px solid ' + t.color + '30;';
|
|
el.innerHTML = '<i class="fas ' + t.icon + '" style="font-size:.75rem;"></i><span>' + t.label + '</span>';
|
|
el.addEventListener('dragstart', (e) => {
|
|
e.dataTransfer.setData('text/plain', t.type);
|
|
e.dataTransfer.effectAllowed = 'copy';
|
|
});
|
|
bar.appendChild(el);
|
|
});
|
|
}
|
|
|
|
function setupCanvasDrop() {
|
|
const wrapper = document.getElementById('networkCanvasWrapper');
|
|
wrapper.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
|
|
wrapper.addEventListener('drop', async (e) => {
|
|
e.preventDefault();
|
|
suppressNextMouse = true;
|
|
setTimeout(() => { suppressNextMouse = false; }, 100);
|
|
const data = e.dataTransfer.getData('text/plain');
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left - panX;
|
|
const my = e.clientY - rect.top - panY;
|
|
|
|
if (data.startsWith('shape:')) {
|
|
const shapeType = data.split(':')[1];
|
|
await apiFetch('shapes', { method: 'POST', body: JSON.stringify({
|
|
label: shapeType === 'rectangle' ? 'Box' : 'Ellipse',
|
|
shape_type: shapeType,
|
|
pos_x: mx - 100, pos_y: my - 75,
|
|
width: 200, height: 150,
|
|
color: '#1e3a5f', border_color: '#3b82f6',
|
|
opacity: 0.15, z_index: nextShapeZ++
|
|
})});
|
|
loadNetworkData();
|
|
} else {
|
|
await apiFetch('nodes', { method: 'POST', body: JSON.stringify({
|
|
label: data.charAt(0).toUpperCase() + data.slice(1),
|
|
ip_address: '', node_type: data || 'host', status: 'unknown',
|
|
group_name: 'default', notes: '',
|
|
pos_x: mx, pos_y: my
|
|
})});
|
|
loadNetworkData();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Real-time sync: poll backend every 5 seconds for all data
|
|
let syncInterval = null;
|
|
let syncHashes = { events: null, documents: null, network: null };
|
|
|
|
function startSync() {
|
|
if (syncInterval) return;
|
|
syncInterval = setInterval(async () => {
|
|
// Skip sync if a modal is open (user is editing)
|
|
const openModals = document.querySelectorAll('.modal.show');
|
|
if (openModals.length > 0) return;
|
|
try {
|
|
const [eventsData, docsData, nodesData, linksData, shapesData] = await Promise.all([
|
|
apiFetch('events'),
|
|
apiFetch('documents'),
|
|
apiFetch('nodes'),
|
|
apiFetch('links'),
|
|
apiFetch('shapes'),
|
|
]);
|
|
|
|
const eventsHash = JSON.stringify(eventsData);
|
|
if (eventsHash !== syncHashes.events) {
|
|
syncHashes.events = eventsHash;
|
|
events = Array.isArray(eventsData) ? eventsData : [];
|
|
loadAllTags();
|
|
renderTimeline();
|
|
}
|
|
|
|
const docsHash = JSON.stringify(docsData);
|
|
if (docsHash !== syncHashes.documents) {
|
|
syncHashes.documents = docsHash;
|
|
documents = Array.isArray(docsData) ? docsData : [];
|
|
renderDocuments();
|
|
}
|
|
|
|
const netHash = JSON.stringify({ n: nodesData, l: linksData, s: shapesData });
|
|
if (netHash !== syncHashes.network) {
|
|
syncHashes.network = netHash;
|
|
nodes = Array.isArray(nodesData) ? nodesData : [];
|
|
links = Array.isArray(linksData) ? linksData : [];
|
|
shapes = Array.isArray(shapesData) ? shapesData : [];
|
|
populateNodeSelects();
|
|
renderNodeList();
|
|
renderShapeList();
|
|
if (shapes.length) nextShapeZ = Math.max(...shapes.map(x => x.z_index)) + 1;
|
|
buildCanvasGraph();
|
|
renderNetwork();
|
|
}
|
|
} catch (e) {}
|
|
}, 5000);
|
|
}
|
|
|
|
function getCanvasNodeAt(mx, my) {
|
|
return canvasNodes.find(n => Math.hypot(mx - n.x, my - n.y) < 28);
|
|
}
|
|
|
|
function getShapeAt(mx, my) {
|
|
for (let i = canvasShapes.length - 1; i >= 0; i--) {
|
|
const s = canvasShapes[i];
|
|
if (mx >= s.x && mx <= s.x + s.w && my >= s.y && my <= s.y + s.h) return s;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getShapeResizeHandleAt(mx, my) {
|
|
for (const s of canvasShapes) {
|
|
if (selectedShapeId != s.id) continue;
|
|
for (const h of getShapeHandles(s)) {
|
|
if (Math.hypot(mx - h.x, my - h.y) < 8) return { shape: s, cx: h.cx, cy: h.cy, sx: h.x, sy: h.y };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function onMouseDown(e) {
|
|
if (e.button === 2) {
|
|
isPanning = true;
|
|
panStartX = e.clientX;
|
|
panStartY = e.clientY;
|
|
canvas.style.cursor = 'grabbing';
|
|
return;
|
|
}
|
|
if (e.button !== 0) return;
|
|
if (suppressNextMouse) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left - panX;
|
|
const my = e.clientY - rect.top - panY;
|
|
const resize = getShapeResizeHandleAt(mx, my);
|
|
if (resize) {
|
|
dragType = 'resize'; dragTarget = resize.shape;
|
|
dragOffX = mx; dragOffY = my;
|
|
dragOrig = { x: resize.shape.x, y: resize.shape.y, w: resize.shape.w, h: resize.shape.h, cx: resize.cx, cy: resize.cy };
|
|
return;
|
|
}
|
|
const node = getCanvasNodeAt(mx, my);
|
|
if (node) {
|
|
if (e.shiftKey) { selectNode(node.id, true); }
|
|
else if (!selectedNodeIds.includes(node.id)) { selectNode(node.id); }
|
|
dragType = 'node'; dragTarget = node;
|
|
dragOffX = mx - node.x; dragOffY = my - node.y;
|
|
return;
|
|
}
|
|
const shape = getShapeAt(mx, my);
|
|
if (shape) {
|
|
selectedNodeId = null; selectedNodeIds = []; selectedShapeId = shape.id;
|
|
dragType = 'shape'; dragTarget = shape;
|
|
dragOffX = mx - shape.x; dragOffY = my - shape.y;
|
|
renderNodeList(); renderShapeList();
|
|
const resizeNow = getShapeResizeHandleAt(mx, my);
|
|
if (resizeNow) {
|
|
dragType = 'resize'; dragTarget = resizeNow.shape;
|
|
dragOffX = mx; dragOffY = my;
|
|
dragOrig = { x: resizeNow.shape.x, y: resizeNow.shape.y, w: resizeNow.shape.w, h: resizeNow.shape.h, cx: resizeNow.cx, cy: resizeNow.cy };
|
|
}
|
|
return;
|
|
}
|
|
selectedNodeId = null; selectedNodeIds = []; selectedShapeId = null;
|
|
renderNodeList(); renderShapeList();
|
|
dragType = 'select';
|
|
selectStartX = mx; selectStartY = my;
|
|
selectRect = { x: mx, y: my, w: 0, h: 0 };
|
|
}
|
|
|
|
function onMouseMove(e) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
if (dragType === 'node' && dragTarget) {
|
|
const dx = e.clientX - rect.left - panX - dragOffX - dragTarget.x;
|
|
const dy = e.clientY - rect.top - panY - dragOffY - dragTarget.y;
|
|
for (const c of canvasNodes) { if (selectedNodeIds.includes(c.id)) { c.x += dx; c.y += dy; } }
|
|
renderNetwork(); return;
|
|
}
|
|
if (dragType === 'shape' && dragTarget) {
|
|
dragTarget.x = e.clientX - rect.left - panX - dragOffX;
|
|
dragTarget.y = e.clientY - rect.top - panY - dragOffY;
|
|
renderNetwork(); return;
|
|
}
|
|
if (dragType === 'resize' && dragTarget) {
|
|
const dx = e.clientX - rect.left - panX - dragOffX;
|
|
const dy = e.clientY - rect.top - panY - dragOffY;
|
|
const s = dragTarget; const o = dragOrig;
|
|
let nx = o.x, ny = o.y, nw = o.w, nh = o.h;
|
|
if (o.cx === 0) { nx = o.x + dx; nw = o.w - dx; } else { nw = o.w + dx; }
|
|
if (o.cy === 0) { ny = o.y + dy; nh = o.h - dy; } else { nh = o.h + dy; }
|
|
if (nw < 50) { if (o.cx === 0) nx = o.x + o.w - 50; nw = 50; }
|
|
if (nh < 50) { if (o.cy === 0) ny = o.y + o.h - 50; nh = 50; }
|
|
s.x = nx; s.y = ny; s.w = nw; s.h = nh;
|
|
renderNetwork(); return;
|
|
}
|
|
if (dragType === 'select') {
|
|
const mx = e.clientX - rect.left - panX;
|
|
const my = e.clientY - rect.top - panY;
|
|
selectRect.x = Math.min(selectStartX, mx);
|
|
selectRect.y = Math.min(selectStartY, my);
|
|
selectRect.w = Math.abs(mx - selectStartX);
|
|
selectRect.h = Math.abs(my - selectStartY);
|
|
renderNetwork(); return;
|
|
}
|
|
if (isPanning) {
|
|
panX += e.clientX - panStartX;
|
|
panY += e.clientY - panStartY;
|
|
panStartX = e.clientX;
|
|
panStartY = e.clientY;
|
|
renderNetwork(); return;
|
|
}
|
|
const mx = e.clientX - rect.left - panX;
|
|
const my = e.clientY - rect.top - panY;
|
|
if (getShapeResizeHandleAt(mx, my)) canvas.style.cursor = 'nwse-resize';
|
|
else if (getCanvasNodeAt(mx, my) || getShapeAt(mx, my)) canvas.style.cursor = 'pointer';
|
|
else canvas.style.cursor = 'grab';
|
|
}
|
|
|
|
function onMouseUp(e) {
|
|
if (dragType === 'node') {
|
|
for (const c of canvasNodes) { if (selectedNodeIds.includes(c.id)) apiFetch('nodes/' + c.id, { method: 'PUT', body: JSON.stringify({ pos_x: c.x, pos_y: c.y }) }); }
|
|
} else if (dragTarget && (dragType === 'shape' || dragType === 'resize')) {
|
|
apiFetch('shapes/' + dragTarget.id, { method: 'PUT', body: JSON.stringify({ pos_x: dragTarget.x, pos_y: dragTarget.y, width: dragTarget.w, height: dragTarget.h }) });
|
|
} else if (dragType === 'select') {
|
|
selectRect = null;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left - panX;
|
|
const my = e.clientY - rect.top - panY;
|
|
const rx = Math.min(selectStartX, mx);
|
|
const ry = Math.min(selectStartY, my);
|
|
const rw = Math.abs(mx - selectStartX);
|
|
const rh = Math.abs(my - selectStartY);
|
|
if (rw > 5 || rh > 5) {
|
|
const found = canvasNodes.filter(n => n.x >= rx && n.x <= rx + rw && n.y >= ry && n.y <= ry + rh);
|
|
selectedNodeIds = found.map(n => n.id);
|
|
selectedNodeId = found.length > 0 ? found[0].id : null;
|
|
selectedShapeId = null;
|
|
renderNodeList(); renderShapeList(); renderNetwork();
|
|
}
|
|
}
|
|
dragType = null; dragTarget = null; dragOrig = null;
|
|
selectRect = null;
|
|
isPanning = false;
|
|
canvas.style.cursor = 'grab';
|
|
}
|
|
|
|
function onDblClick(e) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left - panX;
|
|
const my = e.clientY - rect.top - panY;
|
|
const node = getCanvasNodeAt(mx, my);
|
|
if (node) { selectNode(node.id); return; }
|
|
const shape = getShapeAt(mx, my);
|
|
if (shape) { selectedNodeId = null; selectedShapeId = shape.id; renderShapeList(); renderNetwork(); }
|
|
}
|
|
|
|
function esc(s) {
|
|
if (!s) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = s;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
async function loadRegistrationSetting() {
|
|
try {
|
|
const res = await apiFetch('registration');
|
|
const toggle = document.getElementById('registrationToggle');
|
|
if (toggle) toggle.checked = res.registration_enabled === true;
|
|
} catch (e) {}
|
|
}
|
|
|
|
async function saveRegistrationSetting() {
|
|
const toggle = document.getElementById('registrationToggle');
|
|
if (!toggle) return;
|
|
const enabled = toggle.checked;
|
|
try {
|
|
await apiFetch('registration', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ registration_enabled: enabled })
|
|
});
|
|
} catch (e) {
|
|
alert('Failed to update registration setting');
|
|
}
|
|
}
|
|
|
|
async function loadUsers() {
|
|
const list = document.getElementById('userList');
|
|
try {
|
|
const users = await apiFetch('settings');
|
|
list.innerHTML = users.map(u => {
|
|
return '<div class="d-flex justify-content-between align-items-center py-1 border-bottom border-secondary">' +
|
|
'<div><strong class="small">' + esc(u.username) + '</strong>' +
|
|
'<span class="badge bg-' + (u.role === 'admin' ? 'warning' : 'secondary') + ' ms-1" style="font-size:.6rem;">' + u.role + '</span>' +
|
|
'<div class="text-secondary" style="font-size:.7rem;">' + u.user_token.substring(0, 16) + '...</div></div>' +
|
|
(u.role !== 'admin' ? '<button class="btn btn-outline-danger btn-sm py-0 px-1" onclick="removeUser(' + u.id + ')"><i class="fas fa-times" style="font-size:.7rem;"></i></button>' : '') +
|
|
'</div>';
|
|
}).join('');
|
|
} catch (e) {
|
|
list.innerHTML = '<div class="text-danger small">Failed to load users</div>';
|
|
}
|
|
}
|
|
|
|
async function addUser() {
|
|
const token = document.getElementById('addUserToken').value.trim();
|
|
if (!token) return;
|
|
try {
|
|
const res = await apiFetch('settings', { method: 'POST', body: JSON.stringify({ user_token: token }) });
|
|
if (res.status === 'success') {
|
|
document.getElementById('addUserToken').value = '';
|
|
loadUsers();
|
|
} else {
|
|
alert(res.error || 'Failed to add user');
|
|
}
|
|
} catch (e) {
|
|
alert('Failed to add user');
|
|
}
|
|
}
|
|
|
|
async function removeUser(id) {
|
|
if (!confirm('Remove this user?')) return;
|
|
try {
|
|
await apiFetch('settings', { method: 'DELETE', body: JSON.stringify({ id }) });
|
|
loadUsers();
|
|
} catch (e) {
|
|
alert('Failed to remove user');
|
|
}
|
|
}
|
|
|
|
document.getElementById('settingsModal').addEventListener('show.bs.modal', () => {
|
|
loadUsers();
|
|
loadRegistrationSetting();
|
|
});
|
|
document.getElementById('registrationToggle').addEventListener('change', saveRegistrationSetting);
|
|
|
|
// ==================== DOCUMENTS ====================
|
|
|
|
async function loadDocuments() {
|
|
try {
|
|
documents = await apiFetch('documents');
|
|
syncHashes.documents = JSON.stringify(documents);
|
|
} catch (e) {
|
|
documents = [];
|
|
}
|
|
populateDocTeamFilter();
|
|
renderDocuments();
|
|
}
|
|
|
|
function populateDocTeamFilter() {
|
|
const sel = document.getElementById('docTeamFilter');
|
|
const teamSel = document.getElementById('docTeam');
|
|
sel.innerHTML = '<option value="">All Teams</option>';
|
|
teamSel.innerHTML = '';
|
|
teams.forEach(t => {
|
|
sel.innerHTML += `<option value="${t.id}">${t.name}</option>`;
|
|
teamSel.innerHTML += `<option value="${t.id}">${t.name}</option>`;
|
|
});
|
|
}
|
|
|
|
function renderDocuments() {
|
|
const container = document.getElementById('documentContainer');
|
|
if (!container) { console.error('documentContainer not found'); return; }
|
|
console.log('renderDocuments called, documents count:', documents.length);
|
|
const teamFilter = document.getElementById('docTeamFilter').value;
|
|
const typeFilter = document.getElementById('docTypeFilter').value;
|
|
const search = document.getElementById('searchDocs').value.toLowerCase();
|
|
|
|
let filtered = documents;
|
|
if (teamFilter) filtered = filtered.filter(d => d.team_id == teamFilter);
|
|
if (typeFilter) filtered = filtered.filter(d => d.doc_type == typeFilter);
|
|
if (search) filtered = filtered.filter(d =>
|
|
d.title.toLowerCase().includes(search) ||
|
|
(d.content && d.content.toLowerCase().includes(search))
|
|
);
|
|
|
|
if (!filtered.length) {
|
|
container.innerHTML = '<div class="text-center text-secondary py-5"><i class="fas fa-file-alt fs-1 mb-2"></i><p>No documents yet. Create a deployment, attack report, or more!</p></div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = '<div class="row">' + filtered.map(d => {
|
|
const meta = DOC_TYPE_ICONS[d.doc_type] || DOC_TYPE_ICONS.deployment;
|
|
const label = DOC_TYPE_LABELS[d.doc_type] || d.doc_type;
|
|
const date = new Date(d.occurred_at).toLocaleString();
|
|
let contentPreview = '';
|
|
if (d.content) {
|
|
try { const p = JSON.parse(d.content); contentPreview = p.notes || ''; } catch (e) { contentPreview = d.content; }
|
|
if (contentPreview.length > 150) contentPreview = contentPreview.substring(0, 150) + '...';
|
|
}
|
|
return `
|
|
<div class="col-md-6 col-lg-4 mb-3">
|
|
<div class="card bg-dark border-secondary h-100 doc-card" data-doc-id="${d.id}">
|
|
<div class="card-header border-secondary py-2 d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<span class="badge me-1" style="background:${meta.color}20;color:${meta.color};border:1px solid ${meta.color}40;">
|
|
<i class="fas ${meta.icon} me-1"></i>${label}
|
|
</span>
|
|
<span class="badge bg-secondary" style="font-size:.6rem;">${esc(d.team_name)}</span>
|
|
</div>
|
|
<small class="text-secondary" style="font-size:.7rem;">${date}</small>
|
|
</div>
|
|
<div class="card-body py-2">
|
|
<h6 class="mb-1">${esc(d.title)}</h6>
|
|
${contentPreview ? '<p class="small text-secondary mb-0">' + esc(contentPreview) + '</p>' : ''}
|
|
</div>
|
|
<div class="card-footer border-secondary py-1 text-end">
|
|
<button class="btn btn-outline-primary btn-sm py-0 px-1" onclick="editDocument(${d.id})" title="Edit" style="font-size:.7rem;"><i class="fas fa-pen"></i></button>
|
|
<button class="btn btn-outline-danger btn-sm py-0 px-1" onclick="deleteDocument(${d.id})" title="Delete" style="font-size:.7rem;"><i class="fas fa-trash"></i></button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('') + '</div>';
|
|
}
|
|
|
|
const DOC_FIELD_TEMPLATES = {
|
|
deployment: [
|
|
{ id: 'docTarget', label: 'Target System / Host', type: 'network-node' },
|
|
{ id: 'docVersion', label: 'Version / Build', type: 'text', placeholder: 'e.g. v2.1.0, build 42' },
|
|
{ id: 'docStatus', label: 'Status', type: 'select', options: ['Planned', 'In Progress', 'Completed', 'Rolled Back', 'Failed'] }
|
|
],
|
|
attack: [
|
|
{ id: 'docSource', label: 'Attack Source / IP', type: 'network-node' },
|
|
{ id: 'docVector', label: 'Attack Vector', type: 'select', options: ['Phishing', 'Brute Force', 'Malware', 'DDoS', 'SQL Injection', 'XSS', 'Social Engineering', 'Physical', 'Other'] },
|
|
{ id: 'docImpact', label: 'Impact', type: 'select', options: ['None', 'Low', 'Medium', 'High', 'Critical'] }
|
|
],
|
|
'incident-report': [
|
|
{ id: 'docSeverity', label: 'Severity', type: 'select', options: ['Info', 'Low', 'Medium', 'High', 'Critical'] },
|
|
{ id: 'docDetectedBy', label: 'Detected By', type: 'text', placeholder: 'e.g. SOC, Automated Alert, User Report' },
|
|
{ id: 'docContainment', label: 'Containment Status', type: 'select', options: ['Not Contained', 'In Progress', 'Contained', 'Resolved'] }
|
|
],
|
|
remediation: [
|
|
{ id: 'docAffected', label: 'Affected Systems', type: 'text', placeholder: 'e.g. mail-server-02, all endpoints' },
|
|
{ id: 'docAction', label: 'Action Taken', type: 'select', options: ['Patch Applied', 'Config Change', 'Rule Updated', 'Access Revoked', 'Network Isolated', 'Other'] },
|
|
{ id: 'docVerified', label: 'Verification', type: 'select', options: ['Pending', 'Verified', 'Failed Verification'] }
|
|
],
|
|
exercise: [
|
|
{ id: 'docScenario', label: 'Scenario', type: 'text', placeholder: 'e.g. Ransomware simulation, phishing drill' },
|
|
{ id: 'docParticipants', label: 'Participants', type: 'text', placeholder: 'e.g. Blue Team, SOC, external red team' },
|
|
{ id: 'docOutcome', label: 'Outcome', type: 'select', options: ['Pass', 'Partial Pass', 'Fail', 'Inconclusive', 'Not Evaluated'] }
|
|
]
|
|
};
|
|
|
|
function updateDocFormFields() {
|
|
const docType = document.getElementById('docType').value;
|
|
const container = document.getElementById('dynamicDocFields');
|
|
const templates = DOC_FIELD_TEMPLATES[docType] || [];
|
|
container.innerHTML = templates.map(f => {
|
|
if (f.type === 'network-node') {
|
|
const opts = nodes.map(n => `<option value="${n.label} (${n.ip_address || 'no IP'})">${esc(n.label)} ${n.ip_address ? '(' + esc(n.ip_address) + ')' : ''}</option>`).join('');
|
|
return `
|
|
<div class="mb-2">
|
|
<label class="form-label small">${f.label}</label>
|
|
<select class="form-select form-select-sm" id="${f.id}">
|
|
<option value="">-- Select network node --</option>
|
|
${opts}
|
|
</select>
|
|
</div>`;
|
|
}
|
|
if (f.type === 'select') {
|
|
const opts = f.options.map(o => `<option value="${o}">${o}</option>`).join('');
|
|
return `
|
|
<div class="mb-2">
|
|
<label class="form-label small">${f.label}</label>
|
|
<select class="form-select form-select-sm" id="${f.id}">
|
|
${opts}
|
|
</select>
|
|
</div>`;
|
|
}
|
|
return `
|
|
<div class="mb-2">
|
|
<label class="form-label small">${f.label}</label>
|
|
<input type="text" class="form-control form-control-sm" id="${f.id}" placeholder="${f.placeholder || ''}">
|
|
</div>`;
|
|
}).join('');
|
|
if (editingDocId && window._editDocData) {
|
|
const data = window._editDocData;
|
|
templates.forEach(f => {
|
|
const el = document.getElementById(f.id);
|
|
if (el && data._parsedFields?.[f.id]) el.value = data._parsedFields[f.id];
|
|
});
|
|
}
|
|
}
|
|
|
|
function collectDocFields() {
|
|
const docType = document.getElementById('docType').value;
|
|
const templates = DOC_FIELD_TEMPLATES[docType] || [];
|
|
const fields = {};
|
|
templates.forEach(f => {
|
|
const el = document.getElementById(f.id);
|
|
if (el) fields[f.id] = el.value;
|
|
});
|
|
return fields;
|
|
}
|
|
|
|
async function saveDocument() {
|
|
const docType = document.getElementById('docType').value;
|
|
const teamId = document.getElementById('docTeam').value;
|
|
const title = document.getElementById('docTitle').value.trim();
|
|
const notes = document.getElementById('docContent').value.trim();
|
|
const fields = collectDocFields();
|
|
|
|
if (!title) return alert('Title is required');
|
|
if (!teamId) return alert('Team is required');
|
|
|
|
const templates = DOC_FIELD_TEMPLATES[docType] || [];
|
|
const fieldsObj = {};
|
|
templates.forEach(f => {
|
|
const v = fields[f.id];
|
|
if (v) fieldsObj[f.id] = v;
|
|
});
|
|
|
|
const data = {
|
|
doc_type: docType,
|
|
team_id: teamId,
|
|
title: title,
|
|
content: JSON.stringify({ fields: fieldsObj, notes: notes }),
|
|
occurred_at: new Date().toISOString().slice(0, 16)
|
|
};
|
|
|
|
if (editingDocId) {
|
|
await apiFetch('documents/' + editingDocId, { method: 'PUT', body: JSON.stringify(data) });
|
|
editingDocId = null;
|
|
} else {
|
|
await apiFetch('documents', { method: 'POST', body: JSON.stringify(data) });
|
|
}
|
|
|
|
bootstrap.Modal.getInstance(document.getElementById('documentModal')).hide();
|
|
document.getElementById('documentForm').reset();
|
|
window._editDocData = null;
|
|
loadDocuments();
|
|
loadEvents();
|
|
}
|
|
|
|
function openNewDocument(type) {
|
|
editingDocId = null;
|
|
document.getElementById('documentModalLabel').textContent = 'New ' + (DOC_TYPE_LABELS[type] || type) + ' Document';
|
|
document.getElementById('saveDocument').innerHTML = '<i class="fas fa-save me-1"></i> Save Document';
|
|
document.getElementById('docType').value = type;
|
|
document.getElementById('docTitle').value = '';
|
|
document.getElementById('docContent').value = '';
|
|
window._editDocData = null;
|
|
updateDocFormFields();
|
|
new bootstrap.Modal(document.getElementById('documentModal')).show();
|
|
}
|
|
|
|
async function editDocument(id) {
|
|
const d = documents.find(x => x.id == id);
|
|
if (!d) return;
|
|
editingDocId = id;
|
|
window._editDocData = d;
|
|
|
|
let parsedNotes = '';
|
|
const parsedFields = {};
|
|
if (d.content) {
|
|
try {
|
|
const parsed = JSON.parse(d.content);
|
|
if (parsed.fields) Object.assign(parsedFields, parsed.fields);
|
|
parsedNotes = parsed.notes || '';
|
|
} catch (e) {
|
|
parsedNotes = d.content;
|
|
}
|
|
}
|
|
|
|
document.getElementById('documentModalLabel').textContent = 'Edit ' + (DOC_TYPE_LABELS[d.doc_type] || d.doc_type) + ' Document';
|
|
document.getElementById('saveDocument').innerHTML = '<i class="fas fa-save me-1"></i> Update Document';
|
|
document.getElementById('docType').value = d.doc_type;
|
|
document.getElementById('docTeam').value = d.team_id;
|
|
document.getElementById('docTitle').value = d.title;
|
|
document.getElementById('docContent').value = parsedNotes;
|
|
|
|
d._parsedFields = parsedFields;
|
|
updateDocFormFields();
|
|
new bootstrap.Modal(document.getElementById('documentModal')).show();
|
|
}
|
|
|
|
async function deleteDocument(id) {
|
|
if (!confirm('Delete this document?')) return;
|
|
await apiFetch('documents/' + id, { method: 'DELETE' });
|
|
loadDocuments();
|
|
loadEvents();
|
|
}
|
|
|
|
if (!CanvasRenderingContext2D.prototype.roundRect) {
|
|
CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) {
|
|
if (r > w / 2) r = w / 2;
|
|
if (r > h / 2) r = h / 2;
|
|
this.moveTo(x + r, y);
|
|
this.lineTo(x + w - r, y);
|
|
this.quadraticCurveTo(x + w, y, x + w, y + r);
|
|
this.lineTo(x + w, y + h - r);
|
|
this.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
|
this.lineTo(x + r, y + h);
|
|
this.quadraticCurveTo(x, y + h, x, y + h - r);
|
|
this.lineTo(x, y + r);
|
|
this.quadraticCurveTo(x, y, x + r, y);
|
|
return this;
|
|
};
|
|
} |