adding file upload & tags
This commit is contained in:
@@ -256,4 +256,28 @@ kbd {
|
||||
font-size: .65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .03em;
|
||||
}
|
||||
|
||||
/* Attachments */
|
||||
.attachments-list {
|
||||
border-top: 1px solid var(--neptune-border);
|
||||
padding-top: .35rem;
|
||||
margin-top: .35rem;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
border-bottom: 1px solid rgba(30, 42, 69, 0.5);
|
||||
}
|
||||
|
||||
.attachment-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.attachment-item a {
|
||||
color: var(--neptune-accent);
|
||||
}
|
||||
|
||||
.attachment-item a:hover {
|
||||
color: #60a5fa;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
+145
-3
@@ -152,6 +152,7 @@ function initApp() {
|
||||
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', () => {
|
||||
@@ -166,6 +167,22 @@ function initApp() {
|
||||
document.getElementById('opacityVal').textContent = parseFloat(e.target.value).toFixed(2);
|
||||
});
|
||||
|
||||
document.getElementById('eventFileInput').addEventListener('change', (e) => {
|
||||
const preview = document.getElementById('eventFilePreview');
|
||||
const files = e.target.files;
|
||||
if (!files.length) { preview.innerHTML = ''; return; }
|
||||
preview.innerHTML = Array.from(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);
|
||||
@@ -250,19 +267,36 @@ async function loadTeams() {
|
||||
|
||||
async function loadEvents() {
|
||||
events = await apiFetch('events');
|
||||
loadAllTags();
|
||||
renderTimeline();
|
||||
}
|
||||
|
||||
let allTags = [];
|
||||
|
||||
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();
|
||||
|
||||
let filtered = events;
|
||||
if (teamFilter) filtered = filtered.filter(e => e.team_id == teamFilter);
|
||||
if (tagFilter) filtered = filtered.filter(e => e.tags && e.tags.includes(tagFilter));
|
||||
if (search) filtered = filtered.filter(e =>
|
||||
e.title.toLowerCase().includes(search) ||
|
||||
(e.description && e.description.toLowerCase().includes(search))
|
||||
(e.description && e.description.toLowerCase().includes(search)) ||
|
||||
(e.tags && e.tags.some(t => t.includes(search)))
|
||||
);
|
||||
|
||||
if (!filtered.length) {
|
||||
@@ -287,6 +321,7 @@ function renderTimeline() {
|
||||
</div>
|
||||
<h6 class="event-title mt-1 mb-1">${esc(e.title)}</h6>
|
||||
${e.description ? '<p class="mb-1 small text-secondary">' + renderDocLinks(e.description) + '</p>' : ''}
|
||||
${renderTimelineTags(e.tags)}
|
||||
<div class="d-flex justify-content-between align-items-center mt-1">
|
||||
<div class="doc-links-inline" id="docLinks-${e.id}">${renderInlineDocLinks(e.description || '')}</div>
|
||||
<div>
|
||||
@@ -294,6 +329,7 @@ function renderTimeline() {
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1" onclick="deleteEvent(${e.id}, this)" title="Delete event" style="font-size:.7rem;"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="attachments-list mt-1" id="attachments-${e.id}" data-event-id="${e.id}"></div>
|
||||
<div class="mt-2" id="comments-${e.id}">
|
||||
<div class="d-flex align-items-center mb-1"><small class="text-secondary fw-bold"><i class="fas fa-comment-dots me-1"></i>Comments ${e.comments && e.comments.length ? '(' + e.comments.length + ')' : ''}</small></div>
|
||||
<div class="comment-log">${renderComments(e)}</div>
|
||||
@@ -306,6 +342,11 @@ function renderTimeline() {
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
filtered.forEach(e => {
|
||||
const container = document.getElementById('attachments-' + e.id);
|
||||
if (container) renderAttachments(e.id, container);
|
||||
});
|
||||
}
|
||||
|
||||
function renderDocLinks(text) {
|
||||
@@ -450,22 +491,123 @@ async function deleteEvent(id, btn) {
|
||||
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);
|
||||
return `<div class="attachment-item d-flex align-items-center justify-content-between py-1">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas ${icon} me-2" style="font-size:.85rem;"></i>
|
||||
<a href="/uploads/${a.stored_name}" target="_blank" class="small text-decoration-none" style="word-break:break-all;">${esc(a.original_name)}</a>
|
||||
<small class="text-secondary ms-2">(${size})</small>
|
||||
</div>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1 ms-2" onclick="deleteAttachment(${a.id}, this)" title="Delete file" style="font-size:.65rem;"><i class="fas fa-times"></i></button>
|
||||
</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';
|
||||
}
|
||||
|
||||
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)
|
||||
occurred_at: new Date().toISOString().slice(0, 16),
|
||||
tags: tags
|
||||
};
|
||||
if (!data.title) return alert('Title required');
|
||||
await apiFetch('events', { method: 'POST', body: JSON.stringify(data) });
|
||||
|
||||
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([
|
||||
|
||||
+18
-2
@@ -63,12 +63,17 @@
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<select class="form-select form-select-sm" id="teamFilter">
|
||||
<option value="">All Teams</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="col-md-3">
|
||||
<select class="form-select form-select-sm" id="tagFilter">
|
||||
<option value="">All Tags</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control form-control-sm" id="searchEvents" placeholder="Search events...">
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,6 +245,17 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Tags</label>
|
||||
<input type="text" class="form-control form-control-sm" id="eventTagsInput" placeholder="e.g. phishing, ransomware, ioc">
|
||||
<small class="text-secondary">Comma-separated tags</small>
|
||||
<div id="eventTagsDisplay" class="d-flex flex-wrap gap-1 mt-1"></div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">File Attachments</label>
|
||||
<input type="file" class="form-control form-control-sm" id="eventFileInput" multiple>
|
||||
<div id="eventFilePreview" class="mt-1"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
|
||||
Reference in New Issue
Block a user