First release of open core
This commit is contained in:
147
ui/static/admin.js
Normal file
147
ui/static/admin.js
Normal file
@@ -0,0 +1,147 @@
|
||||
window.showUpsell = function(featureName) {
|
||||
document.getElementById('upsellFeatureName').innerText = featureName;
|
||||
document.getElementById('upsellModal').style.display = 'flex';
|
||||
};
|
||||
|
||||
function switchTab(tabId, btnElement) {
|
||||
document.querySelectorAll('.tab-pane').forEach(pane => pane.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.getElementById(tabId).classList.add('active');
|
||||
if(btnElement) btnElement.classList.add('active');
|
||||
}
|
||||
|
||||
|
||||
// --- CONFIG & USERS LOGIC ---
|
||||
window.deleteUser = async function(id) { if(confirm("Deactivate this user?")) await fetch(`/api/admin/users/${id}`, { method: 'DELETE' }).then(r => r.ok ? window.location.reload() : alert("Failed")); }
|
||||
window.editRole = async function(id, currentRole) {
|
||||
const newRole = prompt("Enter new role (RangeHand, Wrangler, Magistrate, Sheriff):", currentRole);
|
||||
if(newRole && newRole !== currentRole) await fetch(`/api/admin/users/${id}/role`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ global_role: newRole }) }).then(r => r.ok ? window.location.reload() : alert("Failed"));
|
||||
}
|
||||
window.resetPassword = async function(id) { if(confirm("Generate new password?")) await fetch(`/api/admin/users/${id}/reset-password`, { method: 'PATCH' }).then(async r => r.ok ? alert("New Password: \n\n" + await r.text()) : alert("Failed")); }
|
||||
window.deleteRule = async function(id) { if(confirm("Delete rule?")) await fetch(`/api/admin/routing/${id}`, { method: 'DELETE' }).then(r => r.ok ? window.location.reload() : alert("Failed")); }
|
||||
window.updateBackupPolicy = async function() { const pol = document.getElementById("backupPolicy").value; await fetch(`/api/admin/backup-policy`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ policy: pol }) }).then(r => r.ok ? alert("Saved") : alert("Failed")); }
|
||||
window.checkUpdates = async function() { await fetch(`/api/admin/check-updates`).then(async r => alert(await r.text())); }
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
|
||||
// --- LOGS ENGINE ---
|
||||
let currentLogPage = 1;
|
||||
async function loadLogs() {
|
||||
const filter = document.getElementById("logFilter").value;
|
||||
const container = document.getElementById("logContainer");
|
||||
container.innerHTML = `<div style="text-align: center; color: #3b82f6; padding: 40px; font-weight: bold;">⏳ Fetching Page ${currentLogPage}...</div>`;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/logs?page=${currentLogPage}&filter=${filter}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
container.innerHTML = "";
|
||||
if (!data.feed || data.feed.length === 0) container.innerHTML = `<p style="color: #94a3b8; text-align: center; padding: 40px;">No activity found.</p>`;
|
||||
else {
|
||||
data.feed.forEach(item => {
|
||||
const badgeStr = item.NewValue ? `<span style="font-family: monospace; background: #f1f5f9; padding: 2px 6px; border-radius: 3px; border: 1px solid #e2e8f0; display: inline-block; margin-top: 4px; font-size: 0.85rem;">${item.NewValue}</span>` : "";
|
||||
container.innerHTML += `<div style="margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px dashed #e2e8f0;"><span style="font-weight: bold; color: #0f172a;">${item.Actor}</span><span style="color: #64748b; font-size: 0.85rem; text-transform: uppercase; margin-left: 5px;">[${item.ActivityType.replace('_', ' ')}]</span><div style="font-size: 0.8rem; color: #94a3b8; float: right;">⏱️ ${item.TimeAgo}</div><br>${badgeStr}</div>`;
|
||||
});
|
||||
}
|
||||
const totalPages = Math.ceil(data.total / data.limit);
|
||||
document.getElementById("logPageInfo").innerText = `Showing page ${data.page} of ${totalPages || 1} (Total: ${data.total})`;
|
||||
document.getElementById("logPrevBtn").disabled = data.page <= 1;
|
||||
document.getElementById("logNextBtn").disabled = data.page >= totalPages;
|
||||
} catch (err) { container.innerHTML = `<p style="color: #dc2626; text-align: center; padding: 40px; font-weight: bold;">🚨 Error: ${err.message}</p>`; }
|
||||
}
|
||||
|
||||
const logFilter = document.getElementById("logFilter");
|
||||
if(logFilter) {
|
||||
logFilter.addEventListener("change", () => { currentLogPage = 1; loadLogs(); });
|
||||
document.getElementById("logPrevBtn").addEventListener("click", () => { if(currentLogPage > 1) { currentLogPage--; loadLogs(); } });
|
||||
document.getElementById("logNextBtn").addEventListener("click", () => { currentLogPage++; loadLogs(); });
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
// --- UI INITIALIZERS ---
|
||||
document.querySelectorAll('.risk-row').forEach(row => {
|
||||
const rationaleDiv = row.querySelector('.risk-rationale-cell');
|
||||
const typeCell = row.querySelector('.risk-type-cell');
|
||||
if (!rationaleDiv || !typeCell) return;
|
||||
let text = rationaleDiv.innerText.trim();
|
||||
if (text.includes('[EXTENSION]')) {
|
||||
typeCell.innerHTML = '<span style="background: #ffedd5; color: #ea580c; border: 1px solid #fdba74; padding: 6px 10px; border-radius: 6px; font-size: 0.75rem; font-weight: bold;">⏱️ TIME EXTENSION</span>';
|
||||
rationaleDiv.innerText = text.replace('[EXTENSION]', '').trim();
|
||||
rationaleDiv.style.borderLeft = "3px solid #ea580c";
|
||||
} else if (text.includes('[RISK ACCEPTANCE]')) {
|
||||
typeCell.innerHTML = '<span style="background: #fee2e2; color: #dc2626; border: 1px solid #fca5a5; padding: 6px 10px; border-radius: 6px; font-size: 0.75rem; font-weight: bold;">🛑 RISK ACCEPTANCE</span>';
|
||||
rationaleDiv.innerText = text.replace('[RISK ACCEPTANCE]', '').trim();
|
||||
rationaleDiv.style.borderLeft = "3px solid #dc2626";
|
||||
row.style.backgroundColor = "#fff5f5";
|
||||
} else {
|
||||
typeCell.innerHTML = '<span style="background: #f1f5f9; color: #64748b; border: 1px solid #e2e8f0; padding: 6px 10px; border-radius: 6px; font-size: 0.75rem; font-weight: bold;">📋 STANDARD</span>';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// --- SLA MATRIX SAVE ---
|
||||
const saveConfigBtn = document.getElementById("saveConfigBtn");
|
||||
if(saveConfigBtn) {
|
||||
saveConfigBtn.addEventListener("click", async function() {
|
||||
this.innerText = "Saving..."; this.disabled = true;
|
||||
const payload = {
|
||||
timezone: document.getElementById("configTimezone").value,
|
||||
business_start: parseInt(document.getElementById("configBizStart").value),
|
||||
business_end: parseInt(document.getElementById("configBizEnd").value),
|
||||
default_extension_days: parseInt(document.getElementById("configDefExt").value),
|
||||
slas: Array.from(document.querySelectorAll(".sla-row")).map(row => ({
|
||||
domain: row.getAttribute("data-domain"),
|
||||
severity: row.querySelector("span.badge").innerText.trim(),
|
||||
days_to_triage: parseInt(row.querySelector(".sla-triage").value),
|
||||
days_to_remediate: parseInt(row.querySelector(".sla-patch").value),
|
||||
max_extensions: parseInt(row.querySelector(".sla-ext").value)
|
||||
}))
|
||||
};
|
||||
const res = await fetch("/api/config", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
|
||||
if (res.ok) { this.innerText = "Saved!"; this.style.background = "#10b981"; setTimeout(() => { this.innerText = "Save Changes"; this.style.background = ""; this.disabled = false; }, 2000); }
|
||||
else { alert("Failed"); this.innerText = "Save Changes"; this.disabled = false; }
|
||||
});
|
||||
}
|
||||
|
||||
// SLA Domain Filter
|
||||
const domainFilter = document.getElementById("slaDomainFilter");
|
||||
if (domainFilter) {
|
||||
domainFilter.addEventListener("change", function() {
|
||||
document.querySelectorAll(".sla-row").forEach(row => row.style.display = row.getAttribute("data-domain") === this.value ? "table-row" : "none");
|
||||
});
|
||||
domainFilter.dispatchEvent(new Event("change"));
|
||||
}
|
||||
|
||||
// --- MODAL EVENT LISTENERS ---
|
||||
const openUserModal = document.getElementById("openUserModal");
|
||||
if (openUserModal) {
|
||||
openUserModal.addEventListener("click", () => document.getElementById("userModal").style.display = "flex");
|
||||
document.getElementById("cancelUser").addEventListener("click", () => document.getElementById("userModal").style.display = "none");
|
||||
document.getElementById("submitUser").addEventListener("click", async function() {
|
||||
const payload = { full_name: document.getElementById("newUserName").value, email: document.getElementById("newUserEmail").value, password: document.getElementById("newUserPassword").value, global_role: document.getElementById("newUserRole").value };
|
||||
if (!payload.full_name || !payload.email || !payload.password) return alert("Fill out all fields.");
|
||||
this.disabled = true;
|
||||
await fetch("/api/admin/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }).then(async r => r.ok ? window.location.reload() : alert(await r.text()));
|
||||
this.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
const newRuleType = document.getElementById("newRuleType");
|
||||
if (newRuleType) {
|
||||
newRuleType.addEventListener("change", function() {
|
||||
document.getElementById("newRuleMatchSource").style.display = this.value === "Source" ? "block" : "none";
|
||||
document.getElementById("newRuleMatchAsset").style.display = this.value === "Source" ? "none" : "block";
|
||||
});
|
||||
document.getElementById("openRuleModal").addEventListener("click", () => document.getElementById("ruleModal").style.display = "flex");
|
||||
document.getElementById("cancelRule").addEventListener("click", () => document.getElementById("ruleModal").style.display = "none");
|
||||
document.getElementById("submitRule").addEventListener("click", async function() {
|
||||
const ruleType = document.getElementById("newRuleType").value;
|
||||
const matchVal = ruleType === "Source" ? document.getElementById("newRuleMatchSource").value : document.getElementById("newRuleMatchAsset").value;
|
||||
const assigneeSelect = document.getElementById("newRuleAssignee");
|
||||
const selectedEmails = Array.from(assigneeSelect.selectedOptions).map(opt => opt.value).join(",");
|
||||
if (!matchVal || !selectedEmails) return alert("Fill out match value and assignee.");
|
||||
this.disabled = true; this.innerText = "Saving...";
|
||||
await fetch("/api/admin/routing", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rule_type: ruleType, match_value: matchVal, assignee: selectedEmails, role: "RangeHand" }) }).then(async r => r.ok ? window.location.reload() : alert(await r.text()));
|
||||
this.disabled = false; this.innerText = "Deploy Rule";
|
||||
});
|
||||
}
|
||||
});
|
||||
52
ui/static/auth.js
Normal file
52
ui/static/auth.js
Normal file
@@ -0,0 +1,52 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
// --- LOGIN LOGIC ---
|
||||
const loginForm = document.getElementById("loginForm");
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById("submitBtn");
|
||||
const errDiv = document.getElementById("errorMsg");
|
||||
|
||||
btn.innerText = "Authenticating..."; btn.disabled = true; errDiv.style.display = "none";
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: document.getElementById("email").value, password: document.getElementById("password").value })
|
||||
});
|
||||
|
||||
if (res.ok) window.location.href = "/dashboard";
|
||||
else { errDiv.innerText = "Invalid credentials. Please try again."; errDiv.style.display = "block"; btn.innerText = "Sign In"; btn.disabled = false; }
|
||||
} catch (err) { errDiv.innerText = "Network error."; errDiv.style.display = "block"; btn.innerText = "Sign In"; btn.disabled = false; }
|
||||
});
|
||||
}
|
||||
|
||||
// --- REGISTER LOGIC ---
|
||||
const registerForm = document.getElementById("registerForm");
|
||||
if (registerForm) {
|
||||
registerForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById("submitBtn");
|
||||
const errDiv = document.getElementById("errorMsg");
|
||||
|
||||
btn.innerText = "Securing System..."; btn.disabled = true; errDiv.style.display = "none";
|
||||
|
||||
const payload = {
|
||||
full_name: document.getElementById("fullname").value, email: document.getElementById("email").value,
|
||||
password: document.getElementById("password").value, global_role: "Sheriff"
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
|
||||
if (res.ok) {
|
||||
const loginRes = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: payload.email, password: payload.password }) });
|
||||
if (loginRes.ok) window.location.href = "/dashboard"; else window.location.href = "/login";
|
||||
} else {
|
||||
errDiv.innerText = await res.text() || "Registration failed. System might already be locked.";
|
||||
errDiv.style.display = "block"; btn.innerText = "Claim Sheriff Access"; btn.disabled = false;
|
||||
}
|
||||
} catch (err) { errDiv.innerText = "Network error."; errDiv.style.display = "block"; btn.innerText = "Claim Sheriff Access"; btn.disabled = false; }
|
||||
});
|
||||
}
|
||||
});
|
||||
198
ui/static/builder.js
Normal file
198
ui/static/builder.js
Normal file
@@ -0,0 +1,198 @@
|
||||
const fileInput = document.getElementById('local-file');
|
||||
const pathInput = document.getElementById('findings_path');
|
||||
let currentRawData = null;
|
||||
let isJson = false;
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
isJson = file.name.toLowerCase().endsWith('.json');
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
currentRawData = event.target.result;
|
||||
document.getElementById('preview-placeholder').style.display = 'none';
|
||||
|
||||
if (isJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(currentRawData);
|
||||
const guessedPath = autoDetectArrayPath(parsed);
|
||||
if (guessedPath) {
|
||||
pathInput.value = guessedPath;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Auto-detect failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
processPreview();
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
pathInput.addEventListener('input', () => {
|
||||
if (currentRawData && isJson) processPreview();
|
||||
});
|
||||
|
||||
function autoDetectArrayPath(obj) {
|
||||
if (Array.isArray(obj)) return ".";
|
||||
|
||||
let bestPath = "";
|
||||
let maxLen = -1;
|
||||
|
||||
function search(currentObj, currentPath) {
|
||||
if (Array.isArray(currentObj)) {
|
||||
if (currentObj.length > 0 && typeof currentObj[0] === 'object') {
|
||||
if (currentObj.length > maxLen) {
|
||||
maxLen = currentObj.length;
|
||||
bestPath = currentPath;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (currentObj !== null && typeof currentObj === 'object') {
|
||||
for (let key in currentObj) {
|
||||
let nextPath = currentPath ? currentPath + "." + key : key;
|
||||
search(currentObj[key], nextPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
search(obj, "");
|
||||
return bestPath || ".";
|
||||
}
|
||||
|
||||
|
||||
function processPreview() {
|
||||
let headers = [];
|
||||
let rows = [];
|
||||
|
||||
if (isJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(currentRawData);
|
||||
const findings = getNestedValue(parsed, pathInput.value);
|
||||
|
||||
|
||||
if (!Array.isArray(findings) || findings.length === 0) {
|
||||
const rawPreview = JSON.stringify(parsed, null, 2).substring(0, 1500) + "\n\n... (file truncated for preview)";
|
||||
|
||||
document.getElementById('preview-table-container').innerHTML =
|
||||
`<div style="padding: 15px; background: #1e293b; border-radius: 6px; font-family: monospace; overflow-x: auto; text-align: left; font-size: 0.85rem;">
|
||||
<p style="color: #fca5a5; margin-top: 0; font-weight: bold;">⚠️ Path "${pathInput.value}" is not an array.</p>
|
||||
<p style="color: #cbd5e1; margin-bottom: 10px;">Here is the structure of your file to help you find the correct path:</p>
|
||||
<pre style="margin: 0; color: #a6e22e;">${rawPreview}</pre>
|
||||
</div>`;
|
||||
|
||||
document.getElementById('save-btn').classList.add('disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('save-btn').classList.remove('disabled');
|
||||
headers = Object.keys(findings[0]);
|
||||
rows = findings.slice(0, 5).map(obj => headers.map(h => formatCell(obj[h])));
|
||||
} catch(e) {
|
||||
document.getElementById('preview-table-container').innerHTML = `<div style="color: var(--critical); padding: 20px; font-weight: bold;">JSON Parse Error: ${e.message}</div>`;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const lines = currentRawData.split('\n').filter(l => l.trim() !== '');
|
||||
headers = lines[0].split(',').map(h => h.trim());
|
||||
rows = lines.slice(1, 6).map(line => line.split(',').map(c => c.trim()));
|
||||
document.getElementById('save-btn').classList.remove('disabled');
|
||||
}
|
||||
|
||||
renderTable(headers, rows);
|
||||
populateDropdowns(headers);
|
||||
}
|
||||
|
||||
function getNestedValue(obj, path) {
|
||||
if (path === '' || path === '.') return obj;
|
||||
return path.split('.').reduce((acc, part) => acc && acc[part], obj);
|
||||
}
|
||||
|
||||
function formatCell(val) {
|
||||
if (typeof val === 'object') return JSON.stringify(val);
|
||||
if (val === undefined || val === null) return "";
|
||||
const str = String(val);
|
||||
return str.length > 50 ? str.substring(0, 47) + "..." : str;
|
||||
}
|
||||
|
||||
function renderTable(headers, rows) {
|
||||
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
|
||||
html += '<thead style="background: #f8fafc; text-transform: uppercase; color: #64748b;"><tr>' + headers.map(h => `<th style="padding: 10px; border-bottom: 2px solid #e2e8f0; text-align: left;">${h}</th>`).join('') + '</tr></thead><tbody>';
|
||||
rows.forEach(row => {
|
||||
html += '<tr>' + row.map(cell => `<td style="padding: 10px; border-bottom: 1px solid #e2e8f0;">${cell}</td>`).join('') + '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('preview-table-container').innerHTML = html;
|
||||
}
|
||||
|
||||
function populateDropdowns(headers) {
|
||||
const selects = document.querySelectorAll('.source-header');
|
||||
selects.forEach(select => {
|
||||
select.innerHTML = '<option value="">-- Select Column --</option>';
|
||||
headers.forEach(h => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = h;
|
||||
opt.textContent = h;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('adapter-form').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
name: document.getElementById('name').value,
|
||||
source_name: document.getElementById('source_name').value,
|
||||
findings_path: document.getElementById('findings_path').value,
|
||||
mapping_title: document.getElementById('mapping_title').value,
|
||||
mapping_asset: document.getElementById('mapping_asset').value,
|
||||
mapping_severity: document.getElementById('mapping_severity').value,
|
||||
mapping_description: document.getElementById('mapping_description').value,
|
||||
mapping_remediation: document.getElementById('mapping_remediation').value
|
||||
};
|
||||
|
||||
const resp = await fetch('/api/adapters', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
alert("Adapter Saved! Taking you back to the Landing Zone.");
|
||||
window.location.href = "/ingest";
|
||||
} else {
|
||||
alert("Failed to save adapter: " + await resp.text());
|
||||
}
|
||||
};
|
||||
|
||||
window.exportAdapterJSON = function() {
|
||||
const name = document.getElementById("adapterName").value.trim();
|
||||
const sourceName = document.getElementById("sourceName").value.trim();
|
||||
const rootPath = document.getElementById("rootPath").value.trim();
|
||||
|
||||
if (!name || !sourceName) {
|
||||
return alert("Adapter Name and Source Name are required to export.");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: name,
|
||||
source_name: sourceName,
|
||||
findings_path: rootPath,
|
||||
mapping_title: document.getElementById("mapTitle").value.trim(),
|
||||
mapping_asset: document.getElementById("mapAsset").value.trim(),
|
||||
mapping_severity: document.getElementById("mapSeverity").value.trim(),
|
||||
mapping_description: document.getElementById("mapDesc").value.trim(),
|
||||
mapping_remediation: document.getElementById("mapRem").value.trim()
|
||||
};
|
||||
|
||||
// Create a downloadable JSON blob
|
||||
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(payload, null, 4));
|
||||
const downloadAnchorNode = document.createElement('a');
|
||||
downloadAnchorNode.setAttribute("href", dataStr);
|
||||
downloadAnchorNode.setAttribute("download", `${sourceName.toLowerCase().replace(/\s+/g, '_')}_adapter.json`);
|
||||
document.body.appendChild(downloadAnchorNode);
|
||||
downloadAnchorNode.click();
|
||||
downloadAnchorNode.remove();
|
||||
};
|
||||
341
ui/static/dashboard.js
Normal file
341
ui/static/dashboard.js
Normal file
@@ -0,0 +1,341 @@
|
||||
|
||||
window.showUpsell = function(featureName) {
|
||||
const featureNameEl = document.getElementById('upsellFeatureName');
|
||||
const modalEl = document.getElementById('upsellModal');
|
||||
if (featureNameEl && modalEl) {
|
||||
featureNameEl.innerText = featureName;
|
||||
modalEl.style.display = 'flex';
|
||||
} else {
|
||||
alert("This feature (" + featureName + ") is available in RiskRancher Pro!");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
window.renderMarkdown = function(text) {
|
||||
if (!text) return "<i style='color:#94a3b8;'>No description provided.</i>";
|
||||
let html = text.replace(/!\[.*?\]\((.*?)\)/g, '<br><img src="$1" style="max-width: 100%; max-height: 400px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; margin: 10px 0; display: block; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);"><br>');
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
return html;
|
||||
};
|
||||
|
||||
|
||||
window.updateDrawerPreview = function() {
|
||||
const rawDesc = document.getElementById('drawerDescEdit').value;
|
||||
document.getElementById('drawerDescPreview').innerHTML = renderMarkdown(rawDesc);
|
||||
};
|
||||
|
||||
|
||||
window.openDrawer = function(id, title, asset, severity) {
|
||||
document.getElementById('drawerTicketID').value = id;
|
||||
document.getElementById('drawerTitle').innerText = title;
|
||||
document.getElementById('drawerAsset').innerText = asset;
|
||||
|
||||
const badge = document.getElementById('drawerBadge');
|
||||
badge.innerText = severity;
|
||||
badge.className = `badge ${severity.toLowerCase()}`;
|
||||
|
||||
document.getElementById('drawerSeverity').value = severity;
|
||||
document.getElementById('drawerComment').value = "";
|
||||
|
||||
const rawDesc = document.getElementById('desc-' + id) ? document.getElementById('desc-' + id).value : "";
|
||||
const rawRem = document.getElementById('rem-' + id) ? document.getElementById('rem-' + id).value : "";
|
||||
const rawEv = document.getElementById('ev-' + id) ? document.getElementById('ev-' + id).value : "";
|
||||
const status = document.getElementById('status-' + id) ? document.getElementById('status-' + id).value : "";
|
||||
const rawComment = document.getElementById('comment-' + id) ? document.getElementById('comment-' + id).value : "";
|
||||
const assignee = document.getElementById('assignee-' + id) ? document.getElementById('assignee-' + id).value : "";
|
||||
|
||||
document.getElementById('drawerDescEdit').value = rawDesc;
|
||||
document.getElementById('drawerRemEdit').value = rawRem;
|
||||
|
||||
const drawerAssignee = document.getElementById('drawerAssignee');
|
||||
if (drawerAssignee) {
|
||||
drawerAssignee.value = (assignee === "Unassigned") ? "" : assignee;
|
||||
}
|
||||
|
||||
const evBlock = document.getElementById('drawerEvidenceBlock');
|
||||
const evText = document.getElementById('drawerEvidenceText');
|
||||
if (evBlock && evText) {
|
||||
if (rawEv && rawEv.trim() !== "") {
|
||||
evText.innerText = rawEv;
|
||||
evBlock.style.display = "block";
|
||||
} else {
|
||||
evBlock.style.display = "none";
|
||||
evText.innerText = "";
|
||||
}
|
||||
}
|
||||
|
||||
const retBlock = document.getElementById('drawerReturnedBlock');
|
||||
const retText = document.getElementById('drawerReturnedText');
|
||||
if (retBlock && retText) {
|
||||
if (status === 'Returned to Security' && rawComment) {
|
||||
retText.innerText = rawComment;
|
||||
retBlock.style.display = "block";
|
||||
} else {
|
||||
retBlock.style.display = "none";
|
||||
retText.innerText = "";
|
||||
}
|
||||
}
|
||||
|
||||
const standardActions = document.getElementById('drawerStandardActions');
|
||||
const editControls = document.getElementById('drawerEditControls');
|
||||
|
||||
if (window.CurrentTab === 'archives') {
|
||||
if(standardActions) standardActions.style.display = 'none';
|
||||
if(editControls) editControls.style.display = 'none';
|
||||
} else {
|
||||
if(standardActions) standardActions.style.display = 'flex';
|
||||
if(editControls) editControls.style.display = 'block';
|
||||
}
|
||||
|
||||
updateDrawerPreview();
|
||||
|
||||
document.getElementById('ticketDrawer').style.width = '600px';
|
||||
document.getElementById('ticketDrawer').classList.add('open');
|
||||
document.getElementById('drawerOverlay').style.display = 'block';
|
||||
};
|
||||
|
||||
window.closeDrawer = function() {
|
||||
document.getElementById('ticketDrawer').classList.remove('open');
|
||||
document.getElementById('drawerOverlay').style.display = 'none';
|
||||
};
|
||||
|
||||
window.openNewTicketModal = function() {
|
||||
// Clear out old values just in case
|
||||
document.getElementById('newTicketTitle').value = '';
|
||||
document.getElementById('newTicketAsset').value = '';
|
||||
document.getElementById('newTicketDesc').value = '';
|
||||
document.getElementById('newTicketSeverity').value = 'High';
|
||||
|
||||
document.getElementById('newTicketModal').style.display = 'flex';
|
||||
};
|
||||
|
||||
window.submitNewTicket = async function() {
|
||||
const title = document.getElementById('newTicketTitle').value.trim();
|
||||
const asset = document.getElementById('newTicketAsset').value.trim();
|
||||
const severity = document.getElementById('newTicketSeverity').value;
|
||||
const desc = document.getElementById('newTicketDesc').value.trim();
|
||||
|
||||
if (!title || !asset) {
|
||||
return alert("Title and Asset Identifier are required!");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title: title,
|
||||
asset_identifier: asset,
|
||||
severity: severity,
|
||||
description: desc,
|
||||
source: "Manual",
|
||||
status: "Waiting to be Triaged"
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/tickets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert("Failed to create ticket.");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Network error.");
|
||||
}
|
||||
};
|
||||
|
||||
window.toggleAssetGroup = function(safeAsset) {
|
||||
document.querySelectorAll(`.group-${safeAsset}`).forEach(r => {
|
||||
r.style.display = r.style.display === "none" ? "table-row" : "none";
|
||||
});
|
||||
};
|
||||
|
||||
function initializeAssetTree() {
|
||||
const tbody = document.getElementById("ticketTableBody");
|
||||
if (!tbody) return;
|
||||
|
||||
const rows = Array.from(tbody.querySelectorAll("tr.ticket-row"));
|
||||
if (rows.length === 0) {
|
||||
document.getElementById("mainTableHeader").style.display = "table-header-group";
|
||||
tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; padding: 40px; color: #94a3b8; font-size: 0.95rem;">No tickets found in this queue. The ranch is quiet! 🤠</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const assets = {};
|
||||
rows.forEach(r => {
|
||||
const asset = r.getAttribute("data-asset") || "Unknown";
|
||||
if (!assets[asset]) assets[asset] = [];
|
||||
assets[asset].push(r);
|
||||
});
|
||||
|
||||
tbody.innerHTML = "";
|
||||
|
||||
for (const asset in assets) {
|
||||
const findings = assets[asset];
|
||||
const safeAsset = asset.replace(/[^a-zA-Z0-9-_]/g, '-');
|
||||
|
||||
let overdueCount = 0;
|
||||
let counts = { Critical: 0, High: 0, Medium: 0, Low: 0, Info: 0 };
|
||||
|
||||
findings.forEach(r => {
|
||||
const sev = r.querySelector('.badge').innerText.trim();
|
||||
if (counts[sev] !== undefined) counts[sev]++;
|
||||
const triageTimerSpan = r.querySelector('.triage-timer');
|
||||
if (triageTimerSpan) {
|
||||
const dueStr = triageTimerSpan.getAttribute('data-due');
|
||||
if (dueStr) {
|
||||
const due = new Date(dueStr);
|
||||
if (Math.ceil((due - new Date()) / (1000 * 60 * 60 * 24)) < 0) overdueCount++;
|
||||
}
|
||||
} else if (r.querySelector('span[style*="color: #dc2626"]')) {
|
||||
overdueCount++;
|
||||
}
|
||||
});
|
||||
|
||||
let badges = '';
|
||||
if (counts.Critical > 0) badges += `<span class="badge critical" style="margin-left:8px;">${counts.Critical} C</span>`;
|
||||
if (counts.High > 0) badges += `<span class="badge high" style="margin-left:4px;">${counts.High} H</span>`;
|
||||
if (counts.Medium > 0) badges += `<span class="badge medium" style="margin-left:4px;">${counts.Medium} M</span>`;
|
||||
if (counts.Low > 0) badges += `<span class="badge low" style="margin-left:4px;">${counts.Low} L</span>`;
|
||||
if (overdueCount > 0) badges += `<span class="badge" style="background: #fee2e2; color: #dc2626; border: 1px solid #fca5a5; margin-left:8px;">overdue:${overdueCount}</span>`;
|
||||
|
||||
let shareButtonHtml = '';
|
||||
if (window.CurrentTab === 'chute') {
|
||||
shareButtonHtml = `<button class="btn btn-secondary" style="padding: 4px 8px; font-size: 0.75rem; color: #94a3b8; border-color: #e2e8f0;" onclick="showUpsell('Passwordless Magic Links')">🔒 Share Asset Link</button>`;
|
||||
} else if (window.CurrentTab === 'holding_pen') {
|
||||
shareButtonHtml = `<span style="font-size: 0.75rem; color: #94a3b8; font-style: italic;">Assign out to share</span>`;
|
||||
}
|
||||
|
||||
const headerTr = document.createElement("tr");
|
||||
headerTr.className = "asset-header-row";
|
||||
headerTr.innerHTML = `
|
||||
<td style="padding: 12px 20px; background: #ffffff; border-top: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0;"><input type="checkbox" class="asset-cb" data-asset="${safeAsset}"></td>
|
||||
<td colspan="4" class="badges-cell" style="padding: 12px; background: #ffffff; border-top: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0; cursor: pointer;" onclick="toggleAssetGroup('${safeAsset}')">
|
||||
<span style="font-family: monospace; font-size: 1.05rem; color: #1e293b; font-weight: bold;">📂 ${asset}</span>
|
||||
<span style="color: #64748b; font-size: 0.85rem; font-weight: normal; margin-left: 5px;">(${findings.length})</span> ${badges}
|
||||
</td>
|
||||
<td colspan="2" style="padding: 12px 20px; text-align: right; background: #ffffff; border-top: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0;">${shareButtonHtml}</td>
|
||||
`;
|
||||
tbody.appendChild(headerTr);
|
||||
|
||||
const assetDetailsTr = document.createElement("tr");
|
||||
assetDetailsTr.className = `group-${safeAsset}`;
|
||||
assetDetailsTr.style.display = "none";
|
||||
assetDetailsTr.innerHTML = `
|
||||
<td colspan="7" style="padding: 0 20px 20px 60px; position: relative; background: #fafafa;">
|
||||
<div style="position: absolute; left: 35px; top: 0; bottom: 30px; width: 3px; background: #0f172a; border-radius: 2px;"></div>
|
||||
<div class="scroll-container" style="max-height: 350px; overflow-y: auto; overflow-x: hidden; padding-top: 10px; padding-right: 10px;">
|
||||
<table class="nested-table" style="width: 100%; border-collapse: separate; border-spacing: 0 8px;"><tbody></tbody></table>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(assetDetailsTr);
|
||||
|
||||
const innerTableBody = assetDetailsTr.querySelector('tbody');
|
||||
findings.forEach(r => {
|
||||
r.style.boxShadow = "0 1px 2px rgba(0,0,0,0.05)";
|
||||
const cells = r.querySelectorAll('td');
|
||||
if (cells.length >= 6) { cells[1].style.width = "120px"; cells[2].style.width = "100px"; cells[4].style.width = "160px"; cells[5].style.width = "160px"; }
|
||||
innerTableBody.appendChild(r);
|
||||
});
|
||||
|
||||
headerTr.querySelector('.asset-cb').addEventListener('change', function() {
|
||||
const isChecked = this.checked;
|
||||
innerTableBody.querySelectorAll('.ticket-cb').forEach(cb => cb.checked = isChecked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
window.markFalsePositive = async function() {
|
||||
const id = parseInt(document.getElementById("drawerTicketID").value);
|
||||
const comment = document.getElementById("drawerComment").value;
|
||||
if (!comment.trim()) return alert("An audit trail comment is strictly required.");
|
||||
|
||||
const btn = document.querySelector('button[onclick="markFalsePositive()"]');
|
||||
if (btn) {
|
||||
btn.innerText = "Processing...";
|
||||
btn.disabled = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/tickets/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
status: "False Positive",
|
||||
comment: "[False Positive] " + comment,
|
||||
actor: "Analyst"
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert("Failed.");
|
||||
if (btn) {
|
||||
btn.innerText = "🚫 Mark False Positive";
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Network error.");
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelectorAll('.triage-timer').forEach(el => {
|
||||
const dueStr = el.getAttribute('data-due');
|
||||
if (!dueStr) return;
|
||||
const diffDays = Math.ceil((new Date(dueStr) - new Date()) / (1000 * 60 * 60 * 24));
|
||||
const baseStyle = "display: inline-block; white-space: nowrap; padding: 4px 10px; border-radius: 12px; font-size: 0.8rem; font-weight: bold;";
|
||||
if (diffDays < 0) el.innerHTML = `<span style="${baseStyle} color: #dc2626; background: #fee2e2; border: 1px solid #fca5a5;">Overdue by ${Math.abs(diffDays)}d</span>`;
|
||||
else if (diffDays === 0) el.innerHTML = `<span style="${baseStyle} color: #ea580c; background: #ffedd5; border: 1px solid #fdba74;">Due Today</span>`;
|
||||
else el.innerHTML = `<span style="${baseStyle} color: #166534; background: #dcfce7; border: 1px solid #bbf7d0;">${diffDays} days left</span>`;
|
||||
});
|
||||
|
||||
initializeAssetTree();
|
||||
|
||||
const drawerSubmitBtn = document.getElementById("drawerSubmitBtn");
|
||||
if(drawerSubmitBtn) {
|
||||
drawerSubmitBtn.addEventListener("click", async function() {
|
||||
const id = document.getElementById("drawerTicketID").value;
|
||||
const newSev = document.getElementById("drawerSeverity").value;
|
||||
const comment = document.getElementById("drawerComment").value;
|
||||
const newDesc = document.getElementById("drawerDescEdit").value;
|
||||
const newRem = document.getElementById("drawerRemEdit").value;
|
||||
|
||||
const assigneeInput = document.getElementById("drawerAssignee");
|
||||
const newAssignee = assigneeInput ? assigneeInput.value.trim() : "";
|
||||
const currentStatus = document.getElementById("status-" + id).value;
|
||||
|
||||
let newStatus = currentStatus;
|
||||
if (newAssignee !== "" && newAssignee !== "Unassigned") {
|
||||
newStatus = "Assigned Out";
|
||||
} else if (currentStatus === "Returned to Security") {
|
||||
newStatus = "Waiting to be Triaged";
|
||||
}
|
||||
|
||||
if (!comment.trim()) return alert("An audit trail comment is strictly required when modifying a finding.");
|
||||
|
||||
this.innerText = "Saving..."; this.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/api/tickets/${id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
severity: newSev,
|
||||
comment: comment,
|
||||
description: newDesc,
|
||||
recommended_remediation: newRem,
|
||||
actor: "Analyst",
|
||||
status: newStatus,
|
||||
assignee: newAssignee || "Unassigned"
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) window.location.reload();
|
||||
else { alert("Update failed."); this.innerText = "Save & Dispatch"; this.disabled = false; }
|
||||
} catch (err) { alert("Network error."); this.disabled = false; }
|
||||
});
|
||||
}
|
||||
});
|
||||
68
ui/static/ingest.js
Normal file
68
ui/static/ingest.js
Normal file
@@ -0,0 +1,68 @@
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
|
||||
async function processFile(file) {
|
||||
const statusText = document.getElementById('status-text');
|
||||
document.getElementById('status-area').classList.remove('d-none');
|
||||
|
||||
const adapterSelect = document.getElementById('adapter-select');
|
||||
const adapterId = adapterSelect.value;
|
||||
|
||||
let adapterName = "";
|
||||
if (adapterSelect.selectedIndex > 0) {
|
||||
adapterName = adapterSelect.options[adapterSelect.selectedIndex].getAttribute('data-name');
|
||||
}
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
if (file.name.toLowerCase().endsWith('.json')) {
|
||||
if (!adapterName) {
|
||||
statusText.innerText = "Unknown JSON format. Redirecting to Adapter Builder...";
|
||||
setTimeout(() => {
|
||||
window.location.href = `/admin/adapters/new?filename=${encodeURIComponent(file.name)}`;
|
||||
}, 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawText = await file.text();
|
||||
response = await fetch(`/api/ingest/${encodeURIComponent(adapterName)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: rawText
|
||||
});
|
||||
}
|
||||
else {
|
||||
let formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (adapterId) {
|
||||
formData.append('adapter_id', adapterId);
|
||||
}
|
||||
|
||||
response = await fetch('/api/ingest/csv', { method: 'POST', body: formData });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
statusText.innerText = "Format not recognized. Redirecting to Adapter Builder...";
|
||||
setTimeout(() => {
|
||||
window.location.href = `/admin/adapters/new?filename=${encodeURIComponent(file.name)}`;
|
||||
}, 1200);
|
||||
} else {
|
||||
const errText = await response.text();
|
||||
throw new Error(errText);
|
||||
}
|
||||
} else {
|
||||
statusText.innerText = "Yeehaw! Tickets corralled successfully.";
|
||||
setTimeout(() => window.location.href = "/dashboard", 800);
|
||||
}
|
||||
} catch (err) {
|
||||
statusText.innerText = "Stampede! Error: " + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
dropZone.onclick = () => fileInput.click();
|
||||
fileInput.onchange = (e) => processFile(e.target.files[0]);
|
||||
dropZone.ondragover = (e) => { e.preventDefault(); dropZone.style.background = "#e1f5fe"; };
|
||||
dropZone.ondragleave = () => dropZone.style.background = "#f8f9fa";
|
||||
dropZone.ondrop = (e) => { e.preventDefault(); processFile(e.dataTransfer.files[0]); };
|
||||
282
ui/static/parser.js
Normal file
282
ui/static/parser.js
Normal file
@@ -0,0 +1,282 @@
|
||||
|
||||
const reportID = window.location.pathname.split("/").pop();
|
||||
const clipBtn = document.getElementById('clip-btn');
|
||||
const viewer = document.getElementById('document-viewer');
|
||||
|
||||
window.activeTextarea = null;
|
||||
document.addEventListener('focusin', function(e) {
|
||||
if (e.target && e.target.classList.contains('draft-desc')) {
|
||||
window.activeTextarea = e.target;
|
||||
}
|
||||
});
|
||||
|
||||
viewer.addEventListener('mouseup', function(e) {
|
||||
let selection = window.getSelection();
|
||||
let text = selection.toString().trim();
|
||||
|
||||
if (text.length > 5) {
|
||||
clipBtn.style.top = `${e.pageY - 50}px`;
|
||||
clipBtn.style.left = `${e.pageX - 60}px`;
|
||||
clipBtn.style.display = 'block';
|
||||
|
||||
clipBtn.onclick = async () => {
|
||||
await saveNewDraft(text);
|
||||
clipBtn.style.display = 'none';
|
||||
selection.removeAllRanges();
|
||||
};
|
||||
} else {
|
||||
clipBtn.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
if (e.target !== clipBtn && !viewer.contains(e.target)) {
|
||||
clipBtn.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
viewer.addEventListener('click', async function(e) {
|
||||
if (e.target.tagName === 'IMG' && e.target.classList.contains('pentest-img')) {
|
||||
|
||||
const originalBorder = e.target.style.border;
|
||||
e.target.style.transition = "border 0.2s, transform 0.2s";
|
||||
e.target.style.border = "4px solid #f59e0b";
|
||||
e.target.style.transform = "scale(0.98)";
|
||||
|
||||
try {
|
||||
const uploadRes = await fetch('/api/images/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image_data: e.target.src })
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) throw new Error("Failed to upload image");
|
||||
const data = await uploadRes.json();
|
||||
const markdownImage = ``;
|
||||
|
||||
if (window.activeTextarea) {
|
||||
const start = window.activeTextarea.selectionStart;
|
||||
const end = window.activeTextarea.selectionEnd;
|
||||
const text = window.activeTextarea.value;
|
||||
|
||||
window.activeTextarea.value = text.substring(0, start) + `\n${markdownImage}\n` + text.substring(end);
|
||||
|
||||
const draftId = window.activeTextarea.getAttribute('data-id');
|
||||
updateLivePreview(draftId);
|
||||
updateDraftField(draftId);
|
||||
|
||||
} else {
|
||||
if (confirm("📸 Extract this screenshot into a BRAND NEW finding?\n\n(Tip: To add it to an existing finding, just click inside its Description box first!)")) {
|
||||
await saveNewDraft(markdownImage);
|
||||
}
|
||||
}
|
||||
|
||||
e.target.style.border = "4px solid #10b981";
|
||||
setTimeout(() => {
|
||||
e.target.style.border = originalBorder;
|
||||
e.target.style.transform = "scale(1)";
|
||||
}, 800);
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
e.target.style.border = "4px solid #ef4444";
|
||||
alert("Error extracting image: " + err.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function saveNewDraft(text) {
|
||||
try {
|
||||
const res = await fetch(`/api/drafts/report/${reportID}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ description: text })
|
||||
});
|
||||
if (res.ok) loadDrafts();
|
||||
else alert("Failed to save draft: " + await res.text());
|
||||
} catch (err) {
|
||||
alert("Network error saving draft.");
|
||||
}
|
||||
}
|
||||
|
||||
window.updateDraftField = function(id) {
|
||||
const card = document.querySelector(`.draft-card[data-id="${id}"]`);
|
||||
if (!card) return;
|
||||
|
||||
const payload = {
|
||||
title: card.querySelector('.draft-title').value,
|
||||
asset_identifier: card.querySelector('.draft-asset').value,
|
||||
severity: card.querySelector('.draft-severity').value,
|
||||
description: card.querySelector('.draft-desc').value,
|
||||
recommended_remediation: card.querySelector('.draft-remediation').value
|
||||
};
|
||||
|
||||
fetch(`/api/drafts/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(e => console.error("Auto-save failed", e));
|
||||
}
|
||||
|
||||
window.renderMarkdown = function(text) {
|
||||
if (!text) return "";
|
||||
let html = text.replace(/!\[.*?\]\((.*?)\)/g, '<br><img src="$1" style="max-width: 100%; max-height: 200px; object-fit: contain; border: 1px solid #e2e8f0; border-radius: 4px; margin: 10px 0; display: block;"><br>');
|
||||
return html;
|
||||
}
|
||||
|
||||
window.updateLivePreview = function(id) {
|
||||
const card = document.querySelector(`.draft-card[data-id="${id}"]`);
|
||||
if (!card) return;
|
||||
const desc = card.querySelector('.draft-desc').value;
|
||||
const preview = document.getElementById(`preview-${id}`);
|
||||
|
||||
if (desc.includes('![')) {
|
||||
preview.style.display = 'block';
|
||||
preview.innerHTML = renderMarkdown(desc);
|
||||
} else {
|
||||
preview.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDrafts() {
|
||||
try {
|
||||
const res = await fetch(`/api/drafts/report/${reportID}`);
|
||||
if (!res.ok) return;
|
||||
const drafts = await res.json();
|
||||
|
||||
const list = document.getElementById('draft-list');
|
||||
if (!drafts || drafts.length === 0) {
|
||||
list.innerHTML = `<div style="text-align: center; color: #94a3b8; margin-top: 40px; font-weight: bold;">No drafts yet.<br><br>Highlight text on the left to begin clipping.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
drafts.forEach(d => {
|
||||
html += `
|
||||
<div class="card draft-card" data-id="${d.id}" style="margin-bottom: 20px; padding: 15px; border: 1px solid #cbd5e1; box-shadow: 0 2px 4px rgba(0,0,0,0.05); background: white;">
|
||||
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
|
||||
<input type="text" class="draft-title" onchange="updateDraftField(${d.id})" placeholder="Finding Title (Required)" value="${d.title || ''}" style="flex: 2; padding: 8px; border: 1px solid #cbd5e1; border-radius: 4px; font-weight: bold; color: #0f172a;">
|
||||
<select class="draft-severity" onchange="updateDraftField(${d.id})" style="flex: 1; padding: 8px; border: 1px solid #cbd5e1; border-radius: 4px; background: white; color: #0f172a;">
|
||||
<option value="Critical" ${d.severity === 'Critical' ? 'selected' : ''}>Critical</option>
|
||||
<option value="High" ${d.severity === 'High' ? 'selected' : ''}>High</option>
|
||||
<option value="Medium" ${d.severity === 'Medium' ? 'selected' : 'selected'}>Medium</option>
|
||||
<option value="Low" ${d.severity === 'Low' ? 'selected' : ''}>Low</option>
|
||||
<option value="Info" ${d.severity === 'Info' ? 'selected' : ''}>Info</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<input type="text" class="draft-asset" onchange="updateDraftField(${d.id})" placeholder="Asset Identifier (e.g. api.ranch.com) (Required)" value="${d.asset_identifier || ''}" style="width: 100%; padding: 8px; border: 1px solid #cbd5e1; border-radius: 4px; color: #0f172a;">
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<label style="font-size: 0.75rem; font-weight: bold; color: #64748b;">Description (Markdown Images Supported)</label>
|
||||
<textarea class="draft-desc" data-id="${d.id}" onkeyup="updateLivePreview(${d.id}); updateDraftField(${d.id})" onchange="updateDraftField(${d.id})" placeholder="Vulnerability Description..." style="width: 100%; height: 80px; padding: 8px; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 0.85rem; font-family: inherit; resize: vertical; color: #334155;">${d.description || ''}</textarea>
|
||||
|
||||
<div id="preview-${d.id}" style="margin-top: 5px; padding: 10px; background: #f8fafc; border: 1px dashed #94a3b8; border-radius: 4px; display: ${d.description && d.description.includes('![') ? 'block' : 'none'}; max-height: 250px; overflow-y: auto; resize: vertical;">
|
||||
${renderMarkdown(d.description || '')}
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<textarea class="draft-remediation" onchange="updateDraftField(${d.id})" placeholder="Recommended Remediation..." style="width: 100%; height: 60px; padding: 8px; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 0.85rem; font-family: inherit; resize: vertical; color: #334155;">${d.recommended_remediation || ''}</textarea>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; border-top: 1px dashed #e2e8f0; padding-top: 10px;">
|
||||
<button class="btn" style="padding: 4px 10px; font-size: 0.75rem; color: #0284c7; background: #e0f2fe; border: 1px solid #7dd3fc;" data-text="${encodeURIComponent(d.description || '')}" onclick="smartSnap(this)">📍 Snap to Text</button>
|
||||
<button class="btn" style="padding: 4px 10px; font-size: 0.75rem; color: #dc2626; background: #fee2e2; border: 1px solid #fca5a5;" onclick="deleteDraft(${d.id})">🗑️ Discard</button>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
list.innerHTML = html;
|
||||
} catch (err) {
|
||||
console.error("Failed to load drafts", err);
|
||||
}
|
||||
}
|
||||
|
||||
window.deleteDraft = async function(id) {
|
||||
if (!confirm("Discard this finding?")) return;
|
||||
try {
|
||||
const res = await fetch(`/api/drafts/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) loadDrafts();
|
||||
} catch (err) {
|
||||
alert("Error discarding draft.");
|
||||
}
|
||||
}
|
||||
|
||||
window.promoteAllDrafts = async function() {
|
||||
const cards = document.querySelectorAll('.draft-card');
|
||||
if (cards.length === 0) return alert("No drafts to promote!");
|
||||
|
||||
const payload = [];
|
||||
let hasError = false;
|
||||
|
||||
cards.forEach(card => {
|
||||
const id = parseInt(card.getAttribute('data-id'));
|
||||
const titleInput = card.querySelector('.draft-title');
|
||||
const assetInput = card.querySelector('.draft-asset');
|
||||
|
||||
const title = titleInput.value.trim();
|
||||
const asset = assetInput.value.trim();
|
||||
const severity = card.querySelector('.draft-severity').value;
|
||||
const description = card.querySelector('.draft-desc').value.trim();
|
||||
const remediation = card.querySelector('.draft-remediation').value.trim();
|
||||
|
||||
if (!title || !asset) {
|
||||
if (!title) titleInput.style.borderColor = '#dc2626';
|
||||
if (!asset) assetInput.style.borderColor = '#dc2626';
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
payload.push({ id, title, asset_identifier: asset, severity, description, recommended_remediation: remediation });
|
||||
});
|
||||
|
||||
if (hasError) return alert("🚨 Title and Asset Identifier are required.");
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/reports/promote/${reportID}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert("🤠 Yeehaw! Findings promoted to your Holding Pen.");
|
||||
window.location.href = "/dashboard";
|
||||
} else {
|
||||
alert("Failed to promote: " + await res.text());
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Network error during promotion.");
|
||||
}
|
||||
}
|
||||
|
||||
window.smartSnap = function(btnElement) {
|
||||
const viewer = document.getElementById('document-viewer');
|
||||
const fullText = decodeURIComponent(btnElement.getAttribute('data-text'));
|
||||
|
||||
const searchStr = fullText.split('\n')[0].substring(0, 30).trim();
|
||||
if (!searchStr || searchStr.startsWith("![")) return alert("Cannot snap to image blocks.");
|
||||
|
||||
const paragraphs = viewer.getElementsByTagName('p');
|
||||
let foundElement = null;
|
||||
|
||||
for (let p of paragraphs) {
|
||||
if (p.innerText.length > 50 && p.innerText.includes(searchStr)) {
|
||||
foundElement = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundElement) {
|
||||
foundElement.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const originalBg = foundElement.style.backgroundColor;
|
||||
foundElement.style.transition = "background-color 0.4s";
|
||||
foundElement.style.backgroundColor = "#bfdbfe";
|
||||
setTimeout(() => foundElement.style.backgroundColor = originalBg, 1200);
|
||||
|
||||
const originalText = btnElement.innerText;
|
||||
btnElement.innerText = "🎯 Snapped!";
|
||||
setTimeout(() => btnElement.innerText = originalText, 1500);
|
||||
} else {
|
||||
alert("Could not locate the exact paragraph body in the document.");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", loadDrafts);
|
||||
409
ui/static/style.css
Normal file
409
ui/static/style.css
Normal file
@@ -0,0 +1,409 @@
|
||||
:root {
|
||||
--bg-color: #f4f4f9;
|
||||
--card-bg: #ffffff;
|
||||
--text-main: #333333;
|
||||
--text-muted: #6b7280;
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--border: #e5e7eb;
|
||||
--critical: #ef4444;
|
||||
--high: #f97316;
|
||||
--medium: #eab308;
|
||||
--low: #3b82f6;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: var(--card-bg);
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* A simple, clean table for our vulnerabilities */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f9fafb;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.critical { background-color: var(--critical); }
|
||||
.badge.high { background-color: var(--high); }
|
||||
.badge.medium { background-color: var(--medium); color: #000; }
|
||||
.badge.low { background-color: var(--low); }
|
||||
|
||||
|
||||
/* File: RiskRancher/ui/static/style.css (Append to bottom) */
|
||||
|
||||
/* The Tab Navigation Bar */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 20px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.tab-link {
|
||||
padding: 10px 5px;
|
||||
text-decoration: none;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-link.active {
|
||||
color: var(--primary);
|
||||
border-bottom: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
/* Pagination Controls */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-main);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.btn.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Info Severity Badge */
|
||||
.badge.info { background-color: #9ca3af; }
|
||||
|
||||
/* SLA Badges */
|
||||
.sla-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.sla-safe { background-color: #dcfce7; color: #166534; border-color: #bbf7d0; }
|
||||
.sla-warn { background-color: #fef08a; color: #854d0e; border-color: #fde047; }
|
||||
.sla-breach { background-color: #fee2e2; color: #991b1b; border-color: #fecaca; }
|
||||
|
||||
/* Table Checkboxes for future Bulk Actions */
|
||||
td input[type="checkbox"], th input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
THE CORRAL (TABS & PILLS)
|
||||
========================================== */
|
||||
|
||||
/* The Tab Navigation */
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
margin-bottom: 20px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.nav-tabs a {
|
||||
text-decoration: none;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
padding: 10px 5px;
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-tabs a:hover {
|
||||
color: #0f172a;
|
||||
border-bottom-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.nav-tabs a.active {
|
||||
color: #2563eb; /* A nice, sharp blue */
|
||||
border-bottom-color: #2563eb;
|
||||
}
|
||||
|
||||
/* The Filter Pills */
|
||||
.filter-pills {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px; /* Perfect pill shape */
|
||||
background-color: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #cbd5e1;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.pill.active {
|
||||
background-color: #1e293b;
|
||||
color: white;
|
||||
border-color: #1e293b;
|
||||
}
|
||||
|
||||
/* The Metric Badges inside the Pills */
|
||||
.pill .badge {
|
||||
background-color: white;
|
||||
color: #000;
|
||||
border-radius: 50%;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.8rem;
|
||||
margin-left: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Specific Pill Colors (When Active) */
|
||||
.pill-critical.active { background-color: #dc2626; border-color: #dc2626; color: white; }
|
||||
.pill-critical.active .badge { color: #dc2626; }
|
||||
|
||||
.pill-overdue.active { background-color: #ea580c; border-color: #ea580c; color: white; }
|
||||
.pill-overdue.active .badge { color: #ea580c; }
|
||||
|
||||
.pill-mine.active { background-color: #2563eb; border-color: #2563eb; color: white; }
|
||||
.pill-mine.active .badge { color: #2563eb; }
|
||||
|
||||
/* ==========================================
|
||||
BULK ACTION MODAL
|
||||
========================================== */
|
||||
.modal-overlay {
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(15, 23, 42, 0.6); /* Slate backdrop */
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white; padding: 24px; border-radius: 8px;
|
||||
width: 400px; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-content h3 { margin-top: 0; color: #0f172a; }
|
||||
.modal-content label { display: block; margin-bottom: 6px; font-weight: 600; font-size: 0.9rem; color: #475569; }
|
||||
.modal-content select, .modal-content textarea {
|
||||
width: 100%; padding: 10px; margin-bottom: 20px;
|
||||
border: 1px solid #cbd5e1; border-radius: 4px; font-family: inherit; box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; }
|
||||
.btn-secondary { background-color: #e2e8f0; color: #1e293b; }
|
||||
.btn-secondary:hover { background-color: #cbd5e1; }
|
||||
.false-positive { background: #f1f5f9; color: #475569; border: 1px dashed #cbd5e1; }
|
||||
|
||||
/* ==========================================
|
||||
🚀 THE SLIDE-OUT DRAWER & MODALS
|
||||
========================================== */
|
||||
.drawer-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(15, 23, 42, 0.4); z-index: 1000; backdrop-filter: blur(2px); }
|
||||
.drawer { position: fixed; top: 0; right: -650px; width: 600px; height: 100vh; background: white; box-shadow: -4px 0 25px rgba(0,0,0,0.15); transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 1001; display: flex; flex-direction: column; }
|
||||
.drawer.open { right: 0; }
|
||||
.drawer-header { padding: 25px; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: flex-start; background: #f8fafc; }
|
||||
.drawer-body { padding: 25px; overflow-y: auto; flex: 1; }
|
||||
.drawer-footer { padding: 20px 25px; border-top: 1px solid #e2e8f0; background: #f8fafc; display: flex; justify-content: flex-end; gap: 10px; }
|
||||
|
||||
/* ==========================================
|
||||
🚀 UNIFIED TABS & TOOLBARS
|
||||
========================================== */
|
||||
.tab-nav { display: flex; gap: 4px; margin-bottom: 0; padding-left: 20px; position: relative; z-index: 2; }
|
||||
.tab-btn { padding: 10px 24px; cursor: pointer; text-decoration: none; background: #f8fafc; border: 1px solid #cbd5e1; font-size: 0.95rem; color: #64748b; font-weight: 600; border-radius: 6px 6px 0 0; margin-bottom: -1px; transition: background 0.2s, color 0.2s; }
|
||||
.tab-btn:hover { background: #e2e8f0; color: #0f172a; }
|
||||
.tab-btn.active { background: #ffffff; color: #2563eb; border-bottom: 1px solid #ffffff; z-index: 3; }
|
||||
.tab-pane { display: none; background: #ffffff; border: 1px solid #cbd5e1; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); padding: 20px; position: relative; z-index: 1; min-height: 400px; }
|
||||
.tab-pane.active { display: block; }
|
||||
.unified-card { background: white; border: 1px solid #cbd5e1; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); position: relative; z-index: 2; overflow: hidden; }
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; }
|
||||
|
||||
/* ==========================================
|
||||
🗂️ TREE BRANCHES & SCROLLING CSS
|
||||
========================================== */
|
||||
.asset-header-row td { transition: background 0.2s; }
|
||||
.asset-header-row:hover td { background: #f1f5f9 !important; }
|
||||
.nested-table tr td { background: #ffffff; border-top: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0; padding: 12px; }
|
||||
.nested-table tr td:first-child { border-left: 1px solid #e2e8f0; border-top-left-radius: 8px; border-bottom-left-radius: 8px; position: relative; }
|
||||
.nested-table tr td:last-child { border-right: 1px solid #e2e8f0; border-top-right-radius: 8px; border-bottom-right-radius: 8px; }
|
||||
.nested-table tr td:first-child::before { content: ""; position: absolute; left: -25px; top: 50%; width: 25px; height: 3px; background: #0f172a; }
|
||||
.scroll-container::-webkit-scrollbar { width: 8px; }
|
||||
.scroll-container::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 4px; }
|
||||
.scroll-container::-webkit-scrollbar-thumb { background: #94a3b8; border-radius: 4px; }
|
||||
.scroll-container::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
📊 KPI CARDS (Sheriff Dashboard)
|
||||
========================================== */
|
||||
.kpi-card {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
padding: 15px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.kpi-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
🔒 AUTH PAGES (Login & Register)
|
||||
========================================== */
|
||||
.auth-card { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); width: 100%; max-width: 400px; text-align: center; }
|
||||
.auth-card.dark { background: #1e293b; box-shadow: 0 10px 25px rgba(0,0,0,0.5); max-width: 450px; border: 1px solid #334155; }
|
||||
.auth-card input { width: 100%; padding: 10px; margin: 10px 0 20px 0; border: 1px solid #cbd5e1; border-radius: 4px; box-sizing: border-box; }
|
||||
.auth-card.dark input { border: 1px solid #475569; background: #0f172a; color: white; }
|
||||
.auth-card .btn { width: 100%; padding: 12px; font-weight: bold; cursor: pointer; font-size: 1rem; }
|
||||
.error { color: #dc2626; margin-bottom: 15px; font-size: 0.9rem; display: none; }
|
||||
.error.dark { color: #ef4444; }
|
||||
|
||||
/* ==========================================
|
||||
📊 ASSET ROLLUP VIEW
|
||||
========================================== */
|
||||
.count-badge { padding: 4px 8px; border-radius: 999px; font-weight: bold; font-size: 0.85rem; display: inline-block; min-width: 20px; text-align: center; }
|
||||
.bg-critical { background: #fee2e2; color: #dc2626; }
|
||||
.bg-high { background: #ffedd5; color: #ea580c; }
|
||||
.bg-medium { background: #fef9c3; color: #ca8a04; }
|
||||
.bg-low { background: #e0f2fe; color: #0284c7; }
|
||||
.bg-zero { background: #f1f5f9; color: #94a3b8; font-weight: normal; }
|
||||
|
||||
.navbar-strategic { background: #0f172a; color: white; padding: 15px 30px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.navbar-strategic a { color: #cbd5e1; text-decoration: none; margin-left: 20px; font-weight: 500; }
|
||||
.navbar-strategic a:hover { color: white; }
|
||||
|
||||
.notification-bubble {
|
||||
background: #e11d48;
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75rem;
|
||||
margin-left: 8px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 4px rgba(225, 29, 72, 0.3);
|
||||
}
|
||||
|
||||
|
||||
.archived-row {
|
||||
background-color: #fafafa !important;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.archived-row td {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.archived-row .badge {
|
||||
filter: grayscale(0.5) opacity(0.7);
|
||||
}
|
||||
|
||||
.archived-row a {
|
||||
color: #64748b !important;
|
||||
}
|
||||
|
||||
.archive-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
display: inline-block;
|
||||
}
|
||||
Reference in New Issue
Block a user