First release of open core

This commit is contained in:
t
2026-04-02 10:57:36 -04:00
parent 1c94f12d1c
commit 084c1321fc
101 changed files with 8812 additions and 17 deletions

147
ui/static/admin.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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 = `![Proof of Concept](${data.url})`;
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
View 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;
}