@@ -64,6 +64,9 @@ try {
|
||||
case 'links':
|
||||
handleLinks($method, $id, $db);
|
||||
break;
|
||||
case 'documents':
|
||||
handleDocuments($method, $id, $db);
|
||||
break;
|
||||
case 'shapes':
|
||||
handleShapes($method, $id, $db);
|
||||
break;
|
||||
@@ -447,6 +450,115 @@ function handleLinks($method, $id, $db) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleDocuments($method, $id, $db) {
|
||||
$username = $_SESSION['neptune_username'] ?? 'Unknown';
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
$stmt = $db->prepare("
|
||||
SELECT d.*, t.name AS team_name, t.color AS team_color
|
||||
FROM documents d JOIN teams t ON d.team_id = t.id
|
||||
WHERE d.id = ?
|
||||
");
|
||||
$stmt->execute([$id]);
|
||||
echo json_encode($stmt->fetch(PDO::FETCH_ASSOC));
|
||||
} else {
|
||||
$teamFilter = $_GET['team_id'] ?? null;
|
||||
$typeFilter = $_GET['doc_type'] ?? null;
|
||||
$sql = "
|
||||
SELECT d.*, t.name AS team_name, t.color AS team_color
|
||||
FROM documents d JOIN teams t ON d.team_id = t.id
|
||||
";
|
||||
$params = [];
|
||||
$conditions = [];
|
||||
if ($teamFilter) {
|
||||
$conditions[] = "d.team_id = ?";
|
||||
$params[] = $teamFilter;
|
||||
}
|
||||
if ($typeFilter) {
|
||||
$conditions[] = "d.doc_type = ?";
|
||||
$params[] = $typeFilter;
|
||||
}
|
||||
if ($conditions) $sql .= " WHERE " . implode(' AND ', $conditions);
|
||||
$sql .= " ORDER BY d.occurred_at DESC";
|
||||
$stmt = $db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
echo json_encode($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
break;
|
||||
case 'POST':
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO documents (doc_type, team_id, title, content, occurred_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([
|
||||
$data['doc_type'],
|
||||
$data['team_id'],
|
||||
$data['title'],
|
||||
$data['content'] ?? '',
|
||||
$data['occurred_at'] ?? date('Y-m-d H:i:s')
|
||||
]);
|
||||
$docId = $db->lastInsertId();
|
||||
// Also create a timeline event for this document
|
||||
$teamId = $data['team_id'];
|
||||
$docType = $data['doc_type'];
|
||||
$typeLabels = ['deployment' => 'Deployment', 'attack' => 'Attack', 'incident-report' => 'Incident Report', 'remediation' => 'Remediation', 'exercise' => 'Exercise'];
|
||||
$typeLabel = $typeLabels[$docType] ?? ucfirst($docType);
|
||||
$eventTitle = $typeLabel . ': ' . $data['title'];
|
||||
$eventDesc = $username . ' created document "' . $data['title'] . '" (' . $typeLabel . ')';
|
||||
$stmt2 = $db->prepare("
|
||||
INSERT INTO events (team_id, title, description, severity, event_type, occurred_at)
|
||||
VALUES (?, ?, ?, 'info', 'document', ?)
|
||||
");
|
||||
$stmt2->execute([$teamId, $eventTitle, $eventDesc, $data['occurred_at'] ?? date('Y-m-d H:i:s')]);
|
||||
echo json_encode(['id' => $docId]);
|
||||
break;
|
||||
case 'PUT':
|
||||
if ($id) {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$fields = [];
|
||||
$params = [];
|
||||
foreach (['doc_type','team_id','title','content','occurred_at'] as $f) {
|
||||
if (isset($data[$f])) {
|
||||
$fields[] = "$f = ?";
|
||||
$params[] = $data[$f];
|
||||
}
|
||||
}
|
||||
if ($fields) {
|
||||
$params[] = $id;
|
||||
$stmt = $db->prepare("UPDATE documents SET " . implode(', ', $fields) . " WHERE id = ?");
|
||||
$stmt->execute($params);
|
||||
// Create a timeline event for the edit
|
||||
if (isset($data['title']) || isset($data['doc_type'])) {
|
||||
$docType = $data['doc_type'] ?? '';
|
||||
$docTitle = $data['title'] ?? '';
|
||||
$teamId = $data['team_id'] ?? null;
|
||||
if ($teamId) {
|
||||
$typeLabels = ['deployment' => 'Deployment', 'attack' => 'Attack', 'incident-report' => 'Incident Report', 'remediation' => 'Remediation', 'exercise' => 'Exercise'];
|
||||
$typeLabel = $typeLabels[$docType] ?? ucfirst($docType);
|
||||
$eventTitle = 'Updated ' . $typeLabel . ': ' . $docTitle;
|
||||
$eventDesc = $username . ' updated document "' . $docTitle . '" (' . $typeLabel . ')';
|
||||
$stmt2 = $db->prepare("
|
||||
INSERT INTO events (team_id, title, description, severity, event_type, occurred_at)
|
||||
VALUES (?, ?, ?, 'info', 'document', ?)
|
||||
");
|
||||
$stmt2->execute([$teamId, $eventTitle, $eventDesc, date('Y-m-d H:i:s')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
echo json_encode(['updated' => true]);
|
||||
}
|
||||
break;
|
||||
case 'DELETE':
|
||||
if ($id) {
|
||||
$db->prepare("DELETE FROM documents WHERE id = ?")->execute([$id]);
|
||||
echo json_encode(['deleted' => true]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleShapes($method, $id, $db) {
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
|
||||
@@ -16,6 +16,17 @@ function getDbConnection() {
|
||||
}
|
||||
|
||||
function migrate($db) {
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS documents (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
doc_type VARCHAR(50) NOT NULL,
|
||||
team_id INT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT,
|
||||
occurred_at DATETIME NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
|
||||
)");
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS network_shapes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
label VARCHAR(255) NOT NULL DEFAULT '',
|
||||
|
||||
@@ -65,6 +65,18 @@ CREATE TABLE IF NOT EXISTS network_shapes (
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
doc_type VARCHAR(50) NOT NULL,
|
||||
team_id INT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT,
|
||||
occurred_at DATETIME NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT IGNORE INTO teams (name, color) VALUES
|
||||
('Blue Team', '#0d6efd'),
|
||||
('Red Team', '#dc3545'),
|
||||
|
||||
@@ -232,4 +232,28 @@ kbd {
|
||||
.comment-input-group .btn:hover {
|
||||
background: var(--neptune-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Document Cards */
|
||||
.doc-card {
|
||||
transition: border-color .2s, transform .15s;
|
||||
}
|
||||
|
||||
.doc-card:hover {
|
||||
border-color: var(--neptune-accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.doc-card .card-header {
|
||||
background: rgba(59,130,246,.05);
|
||||
}
|
||||
|
||||
.doc-card .card-footer {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.doc-card .badge {
|
||||
font-size: .65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .03em;
|
||||
}
|
||||
+269
-1
@@ -1,6 +1,8 @@
|
||||
const API = '/api/';
|
||||
let teams = [];
|
||||
let events = [];
|
||||
let documents = [];
|
||||
let editingDocId = null;
|
||||
let nodes = [];
|
||||
let links = [];
|
||||
let shapes = [];
|
||||
@@ -119,7 +121,10 @@ function initApp() {
|
||||
ctx = canvas.getContext('2d');
|
||||
resizeCanvas();
|
||||
|
||||
loadTeams().then(() => loadEvents());
|
||||
loadTeams().then(() => {
|
||||
loadEvents();
|
||||
loadDocuments();
|
||||
});
|
||||
loadNetworkData();
|
||||
|
||||
document.getElementById('saveEvent').addEventListener('click', saveEvent);
|
||||
@@ -130,6 +135,10 @@ function initApp() {
|
||||
document.getElementById('logoutBtn').addEventListener('click', logout);
|
||||
document.getElementById('teamFilter').addEventListener('change', renderTimeline);
|
||||
document.getElementById('searchEvents').addEventListener('input', renderTimeline);
|
||||
document.getElementById('saveDocument').addEventListener('click', saveDocument);
|
||||
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);
|
||||
});
|
||||
@@ -949,6 +958,265 @@ async function removeUser(id) {
|
||||
|
||||
document.getElementById('settingsModal').addEventListener('show.bs.modal', loadUsers);
|
||||
|
||||
// ==================== DOCUMENTS ====================
|
||||
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'
|
||||
};
|
||||
|
||||
async function loadDocuments() {
|
||||
documents = await apiFetch('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');
|
||||
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();
|
||||
const contentPreview = d.content ? d.content.substring(0, 150) + (d.content.length > 150 ? '...' : '') : '';
|
||||
return `
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="card bg-dark border-secondary h-100 doc-card">
|
||||
<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: 'text', placeholder: 'e.g. web-server-01, firewall-cluster' },
|
||||
{ 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: 'text', placeholder: 'e.g. 10.0.0.5, External C2' },
|
||||
{ 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 === '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('');
|
||||
// Restore values if editing
|
||||
if (editingDocId && window._editDocData) {
|
||||
const data = window._editDocData;
|
||||
templates.forEach(f => {
|
||||
const el = document.getElementById(f.id);
|
||||
if (el) el.value = data._fields?.[f.id] || '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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 content = document.getElementById('docContent').value.trim();
|
||||
const fields = collectDocFields();
|
||||
|
||||
if (!title) return alert('Title is required');
|
||||
if (!teamId) return alert('Team is required');
|
||||
|
||||
// Build structured content from fields
|
||||
const label = DOC_TYPE_LABELS[docType] || docType;
|
||||
let fullContent = content;
|
||||
const fieldEntries = Object.entries(fields).filter(([, v]) => v);
|
||||
if (fieldEntries.length) {
|
||||
const fieldPrefix = fieldEntries.map(([k, v]) => {
|
||||
const tmpl = (DOC_FIELD_TEMPLATES[docType] || []).find(t => t.id === k);
|
||||
return (tmpl ? tmpl.label + ': ' : k + ': ') + v;
|
||||
}).join('\n');
|
||||
fullContent = fieldPrefix + (content ? '\n\n' + content : '');
|
||||
}
|
||||
|
||||
const data = {
|
||||
doc_type: docType,
|
||||
team_id: teamId,
|
||||
title: title,
|
||||
content: fullContent,
|
||||
occurred_at: new Date().toISOString().slice(0, 16)
|
||||
};
|
||||
|
||||
if (editingDocId) {
|
||||
data._fields = fields;
|
||||
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();
|
||||
}
|
||||
|
||||
async function editDocument(id) {
|
||||
const d = documents.find(x => x.id == id);
|
||||
if (!d) return;
|
||||
editingDocId = id;
|
||||
window._editDocData = d;
|
||||
|
||||
// Parse structured content back into fields
|
||||
const docType = d.doc_type;
|
||||
const templates = DOC_FIELD_TEMPLATES[docType] || [];
|
||||
const parsedFields = {};
|
||||
if (d.content) {
|
||||
const lines = d.content.split('\n');
|
||||
let inContent = false;
|
||||
let contentParts = [];
|
||||
for (const line of lines) {
|
||||
if (!inContent && line.includes(': ')) {
|
||||
const colonIdx = line.indexOf(': ');
|
||||
const fieldLabel = line.substring(0, colonIdx);
|
||||
const fieldVal = line.substring(colonIdx + 2);
|
||||
const tmpl = templates.find(t => t.label === fieldLabel);
|
||||
if (tmpl) {
|
||||
parsedFields[tmpl.id] = fieldVal;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!line.trim() && !inContent) { inContent = true; continue; }
|
||||
if (inContent) contentParts.push(line);
|
||||
}
|
||||
d._parsedContent = contentParts.join('\n').trim();
|
||||
}
|
||||
|
||||
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 = d._parsedContent || '';
|
||||
|
||||
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;
|
||||
|
||||
@@ -32,6 +32,11 @@
|
||||
<i class="fas fa-project-diagram me-1"></i>Network Map
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="documents-tab" data-bs-toggle="tab" data-bs-target="#documents" type="button" role="tab">
|
||||
<i class="fas fa-file-alt me-1"></i>Documents
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="ms-auto d-flex align-items-center">
|
||||
<span class="text-secondary small me-2" id="userDisplay"></span>
|
||||
@@ -136,6 +141,56 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="documents" role="tabpanel">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<h4><i class="fas fa-file-alt text-primary"></i> Documents</h4>
|
||||
<small class="text-secondary">Standardized forms for deployments, attacks, incident reports & more</small>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#documentModal">
|
||||
<i class="fas fa-plus me-1"></i> New Document
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown"></button>
|
||||
<ul class="dropdown-menu dropdown-menu-end bg-dark border-secondary">
|
||||
<li><a class="dropdown-item small" href="#" onclick="openNewDocument('deployment')"><i class="fas fa-server me-2 text-info"></i>Deployment</a></li>
|
||||
<li><a class="dropdown-item small" href="#" onclick="openNewDocument('attack')"><i class="fas fa-bolt me-2 text-danger"></i>Attack</a></li>
|
||||
<li><a class="dropdown-item small" href="#" onclick="openNewDocument('incident-report')"><i class="fas fa-exclamation-triangle me-2 text-warning"></i>Incident Report</a></li>
|
||||
<li><a class="dropdown-item small" href="#" onclick="openNewDocument('remediation')"><i class="fas fa-wrench me-2 text-success"></i>Remediation</a></li>
|
||||
<li><a class="dropdown-item small" href="#" onclick="openNewDocument('exercise')"><i class="fas fa-dumbbell me-2 text-primary"></i>Exercise</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<select class="form-select form-select-sm" id="docTeamFilter">
|
||||
<option value="">All Teams</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<select class="form-select form-select-sm" id="docTypeFilter">
|
||||
<option value="">All Types</option>
|
||||
<option value="deployment">Deployment</option>
|
||||
<option value="attack">Attack</option>
|
||||
<option value="incident-report">Incident Report</option>
|
||||
<option value="remediation">Remediation</option>
|
||||
<option value="exercise">Exercise</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control form-control-sm" id="searchDocs" placeholder="Search documents...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="documentContainer">
|
||||
<div class="text-center text-secondary py-5">
|
||||
<div class="spinner-border" role="status"></div>
|
||||
<p class="mt-2">Loading documents...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -338,6 +393,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== DOCUMENT MODAL ==================== -->
|
||||
<div class="modal fade" id="documentModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content bg-dark">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title"><i class="fas fa-file-alt text-primary me-1"></i> <span id="documentModalLabel">New Document</span></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="documentForm">
|
||||
<div class="row mb-2">
|
||||
<div class="col">
|
||||
<label class="form-label small">Type</label>
|
||||
<select class="form-select form-select-sm" id="docType" onchange="updateDocFormFields()">
|
||||
<option value="deployment">Deployment</option>
|
||||
<option value="attack">Attack</option>
|
||||
<option value="incident-report">Incident Report</option>
|
||||
<option value="remediation">Remediation</option>
|
||||
<option value="exercise">Exercise</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label small">Team</label>
|
||||
<select class="form-select form-select-sm" id="docTeam" required></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Title</label>
|
||||
<input type="text" class="form-control form-control-sm" id="docTitle" required placeholder="e.g. Deploy new WAF, Phishing attack detected...">
|
||||
</div>
|
||||
<div class="mb-2" id="docFieldsContainer">
|
||||
<div id="dynamicDocFields"></div>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label class="form-label small">Notes / Additional Info</label>
|
||||
<textarea class="form-control form-control-sm" id="docContent" rows="4" placeholder="Any additional details..."></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="saveDocument"><i class="fas fa-save me-1"></i> Save Document</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== SETTINGS MODAL ==================== -->
|
||||
<div class="modal fade" id="settingsModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
|
||||
Reference in New Issue
Block a user