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;
}

View File

@@ -0,0 +1,101 @@
{{define "content"}}
<style>
.builder-layout { display: flex; gap: 20px; align-items: stretch; margin-top: 20px; }
.builder-lhs { flex: 1.5; display: flex; flex-direction: column; overflow: hidden; }
.builder-rhs { flex: 1; display: flex; flex-direction: column; }
.builder-group { margin-bottom: 15px; }
.builder-label { display: block; margin-bottom: 5px; font-weight: 600; font-size: 0.9rem; color: var(--text-muted); }
.builder-input, .builder-select {
width: 100%; padding: 10px; border: 1px solid var(--border);
border-radius: 4px; font-family: inherit; box-sizing: border-box;
background: var(--card-bg); color: var(--text-main);
}
.builder-input:focus, .builder-select:focus { outline: none; border-color: var(--primary); }
.mapping-table td { vertical-align: middle; }
</style>
<div class="container">
<header style="padding: 0; box-shadow: none; border: none; margin-bottom: 10px; background: transparent;">
<h2>🔧 Adapter Builder: <span style="color: var(--text-muted); font-weight: normal;">{{.Filename}}</span></h2>
</header>
<div class="builder-layout">
<div class="card builder-lhs" style="padding: 0;">
<div class="toolbar">
<h3 style="font-size: 1.1rem; margin: 0;">1. Data Preview</h3>
<label for="local-file" class="btn btn-secondary" style="margin: 0;">
Load {{.Filename}}
</label>
<input type="file" id="local-file" accept=".csv,.json" style="display: none;">
</div>
<div class="scroll-container" style="padding: 20px; overflow-x: auto; max-height: 65vh; overflow-y: auto;">
<div id="preview-placeholder" style="text-align: center; color: var(--text-muted); margin-top: 40px;">
<p>Click "Load" to generate a data preview and extract column headers.</p>
</div>
<div id="preview-table-container"></div>
</div>
</div>
<div class="card builder-rhs">
<h3 style="font-size: 1.1rem; margin-top: 0; margin-bottom: 20px; color: var(--primary);">2. Schema Mapping</h3>
<form id="adapter-form" style="display: flex; flex-direction: column; height: 100%;">
<div style="display: flex; gap: 15px;">
<div class="builder-group" style="flex: 1;">
<label class="builder-label">Adapter Name</label>
<input type="text" id="name" class="builder-input" placeholder="e.g. AcmeScan" required>
</div>
<div class="builder-group" style="flex: 1;">
<label class="builder-label">Source Name</label>
<input type="text" id="source_name" class="builder-input" placeholder="e.g. Acme" required>
</div>
</div>
<div class="builder-group">
<label class="builder-label">Findings JSON Path <span style="font-weight: normal; font-size: 0.8rem;">('.' for CSV, 'data.alerts' for JSON)</span></label>
<input type="text" id="findings_path" class="builder-input" value="." required>
</div>
<hr style="border: 0; border-top: 1px solid var(--border); margin: 20px 0;">
<h4 style="margin-bottom: 15px; font-size: 1rem;">Map to Core Fields</h4>
<table class="mapping-table" style="margin-top: 0;">
<tbody>
<tr>
<td width="35%"><strong>Title <span style="color: var(--critical);">*</span></strong></td>
<td><select id="mapping_title" class="builder-select source-header" required><option value="">-- Load file --</option></select></td>
</tr>
<tr>
<td><strong>Asset <span style="color: var(--critical);">*</span></strong></td>
<td><select id="mapping_asset" class="builder-select source-header" required><option value="">-- Load file --</option></select></td>
</tr>
<tr>
<td><strong>Severity <span style="color: var(--critical);">*</span></strong></td>
<td><select id="mapping_severity" class="builder-select source-header" required><option value="">-- Load file --</option></select></td>
</tr>
<tr>
<td><strong>Description</strong></td>
<td><select id="mapping_description" class="builder-select source-header"><option value="">-- Optional --</option></select></td>
</tr>
<tr>
<td><strong>Remediation</strong></td>
<td><select id="mapping_remediation" class="builder-select source-header"><option value="">-- Optional --</option></select></td>
</tr>
</tbody>
</table>
<div style="margin-top: auto; padding-top: 20px;">
<button type="submit" class="btn disabled" id="save-btn" style="width: 100%; background: var(--primary); color: white; border: none; padding: 12px;">Save & Enable Adapter</button>
</div>
<div style="display: flex; gap: 15px; margin-top: 20px;">
<button class="btn" style="background: #2563eb; color: white;" onclick="saveAdapter()">💾 Save to Database</button>
<button class="btn btn-secondary" onclick="exportAdapterJSON()">⬇️ Export JSON Profile</button>
</div>
</form>
</div>
</div>
</div>
<script src="/static/builder.js"></script>
{{end}}

28
ui/templates/admin.gohtml Normal file
View File

@@ -0,0 +1,28 @@
{{define "content"}}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<div>
<h1 style="margin: 0; color: #0f172a;">The Sheriff's Office</h1>
<p style="color: #64748b; margin-top: 5px;">Strategic Command, Personnel, & Operations</p>
</div>
</div>
<div class="tab-nav">
<button class="tab-btn active" onclick="switchTab('tab-metrics', this)">📊 Metrics</button>
<button class="tab-btn" onclick="switchTab('tab-performance', this)">📡 Performance</button>
<button class="tab-btn" style="color: #94a3b8; cursor: not-allowed;" title="Available in RiskRancher Pro">
🔒 Risk Reviews (Pro)
</button>
<button class="tab-btn" onclick="switchTab('tab-config', this)">⚙️ Configuration</button>
<button class="tab-btn" onclick="switchTab('tab-feed', this)">📻 System Logs</button>
</div>
{{template "admin_metrics" .}}
{{template "admin_performance" .}}
{{template "admin_config" .}}
{{template "admin_feed" .}}
{{template "admin_modals" .}}
<script src="/static/admin.js"></script>
{{end}}

View File

@@ -0,0 +1,56 @@
{{define "content"}}
<div class="navbar-strategic" style="margin: -20px -20px 20px -20px; border-radius: 8px 8px 0 0;">
<div style="font-size: 1.2rem; font-weight: bold; letter-spacing: 1px;">RISK RANCHER</div>
<div>
<a href="/dashboard">The Corral (Tactical)</a>
<a href="/assets" style="color: white; border-bottom: 2px solid #3b82f6; padding-bottom: 5px;">Sheriff's Office (Strategic)</a>
</div>
</div>
<div>
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 20px;">
<div>
<h1 style="margin: 0; color: #0f172a;">Asset Risk Rollup</h1>
<p style="margin: 5px 0 0 0; color: #64748b;">Tracking {{.TotalCount}} vulnerable assets across the ranch.</p>
</div>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Asset Identifier</th>
<th style="text-align: center;">Total Active</th>
<th style="text-align: center;">Critical</th>
<th style="text-align: center;">High</th>
<th style="text-align: center;">Medium</th>
<th style="text-align: center;">Low</th>
</tr>
</thead>
<tbody>
{{range .Assets}}
<tr>
<td>
<a href="/dashboard?asset={{.AssetIdentifier}}" style="font-family: monospace; font-size: 1.05rem; color: #2563eb; text-decoration: none; font-weight: bold;">
{{.AssetIdentifier}}
</a>
</td>
<td style="text-align: center; font-weight: bold; color: #475569;">{{.TotalActive}}</td>
<td style="text-align: center;"><span class="count-badge {{if gt .Critical 0}}bg-critical{{else}}bg-zero{{end}}">{{.Critical}}</span></td>
<td style="text-align: center;"><span class="count-badge {{if gt .High 0}}bg-high{{else}}bg-zero{{end}}">{{.High}}</span></td>
<td style="text-align: center;"><span class="count-badge {{if gt .Medium 0}}bg-medium{{else}}bg-zero{{end}}">{{.Medium}}</span></td>
<td style="text-align: center;"><span class="count-badge {{if gt .Low 0}}bg-low{{else}}bg-zero{{end}}">{{.Low}}</span></td>
</tr>
{{else}}
<tr>
<td colspan="6" style="text-align: center; padding: 40px; color: #64748b;">
No vulnerable assets found. The ranch is secure!
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}

36
ui/templates/base.gohtml Normal file
View File

@@ -0,0 +1,36 @@
{{define "base"}}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RiskRancher OSS</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header style="display: flex; justify-content: space-between; align-items: center; padding: 10px 20px; background: white; border-bottom: 1px solid #e2e8f0;">
<div class="logo">
<h2 style="margin: 0;"><a href="/dashboard" style="color: #0f172a; text-decoration: none;">🐴 RiskRancher</a></h2>
</div>
<nav style="display: flex; align-items: center; gap: 15px;">
<span style="color: #475569; font-size: 0.9rem; font-family: monospace; padding-left: 15px; margin-left: 5px;">Community Edition</span>
<button onclick="logout()" style="background: #f1f5f9; color: #dc2626; border: 1px solid #e2e8f0; padding: 6px 12px; border-radius: 4px; font-weight: bold; cursor: pointer; font-size: 0.85rem;">Log Out</button>
</nav>
</header>
<script>
async function logout() {
try {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.href = '/login';
} catch (e) { alert("Failed to log out."); }
}
</script>
<main class="container">
{{template "content" .}}
</main>
<footer style="text-align: center; padding: 20px; margin-top: 40px; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 0.85rem;">
🐴 RiskRancher Core Edition | Version: <strong>{{.Version}}</strong> | Build: <strong>{{.Commit}}</strong>
</footer>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,112 @@
{{define "admin_config"}}
<div id="tab-config" class="tab-pane">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<div style="border: 1px solid #e2e8f0; border-radius: 8px; padding: 15px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">🤠 Personnel</h3>
<button id="openUserModal" class="btn" style="padding: 6px 12px; font-size: 0.85rem;">+ Add User</button>
</div>
<table style="font-size: 0.9rem; width: 100%;">
<thead><tr><th style="text-align: left;">Name</th><th style="text-align: left;">Role</th><th style="text-align: right;">Actions</th></tr></thead>
<tbody>
{{range .Users}}
<tr style="border-bottom: 1px solid #f1f5f9;">
<td style="padding: 8px 0;"><strong>{{.FullName}}</strong><br><span style="font-family: monospace; font-size: 0.8rem; color: #64748b;">{{.Email}}</span></td>
<td><span class="badge" style="background: #1e293b;">{{.GlobalRole}}</span></td>
<td style="text-align: right;">
<button class="btn btn-secondary" style="padding: 2px 6px; font-size: 0.75rem;" onclick="resetPassword({{.ID}})" title="Reset Password">🔑</button>
<button class="btn btn-secondary" style="padding: 2px 6px; font-size: 0.75rem;" onclick="editRole({{.ID}}, '{{.GlobalRole}}')" title="Change Role">🛡️</button>
<button class="btn btn-secondary" style="padding: 2px 6px; font-size: 0.75rem; color: #dc2626; border-color: #fca5a5;" onclick="deleteUser({{.ID}})" title="Deactivate">🗑️</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; padding: 15px;">
<div style="border: 1px solid #e2e8f0; border-radius: 8px; padding: 15px; background: #f8fafc;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0; color: #64748b;">🛤️ Source Routing</h3>
<button class="btn" disabled style="padding: 6px 12px; font-size: 0.85rem; background: #cbd5e1; color: white; cursor: not-allowed;">🔒 Pro Feature</button>
</div>
<div style="text-align: center; padding: 20px; border: 1px dashed #cbd5e1; border-radius: 6px; background: white;">
<p style="color: #475569; font-size: 0.9rem; margin-bottom: 10px;">Automate ticket assignment and triage based on asset tags or CVEs.</p>
<a href="https://RiskRancher.com/pro" target="_blank" style="color: #8b5cf6; text-decoration: none; font-weight: bold; font-size: 0.85rem;">Learn about RiskRancher Pro &rarr;</a>
</div>
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 20px;">
<div style="border: 1px solid #e2e8f0; border-radius: 8px; padding: 15px; position: relative;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<div>
<h3 style="margin: 0;">⏱️ SLA Policies & System Time</h3>
<p style="margin: 2px 0 0 0; font-size: 0.8rem; color: #64748b;">Locked to Standard FedRAMP/NIST Default Timeframes</p>
</div>
<button class="btn" style="padding: 6px 12px; font-size: 0.85rem; background: #f8fafc; color: #64748b; border: 1px solid #cbd5e1;" onclick="showUpsell('Custom SLA Timers & Business Hours')">🔒 Customize (Pro)</button>
</div>
<div style="display: flex; gap: 30px; opacity: 0.7; pointer-events: none;">
<div style="flex: 1; padding: 15px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0;">
<h4 style="margin-top: 0;">Base Configuration</h4>
<label>System Timezone:</label>
<select disabled style="width: 100%; padding: 6px; margin-bottom: 10px; background: #e2e8f0;">
<option selected>UTC (Universal)</option>
</select>
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
<div style="flex: 1;"><label>Biz Start:</label><input type="text" disabled value="09:00" style="width: 100%; padding: 6px; background: #e2e8f0;"></div>
<div style="flex: 1;"><label>Biz End:</label><input type="text" disabled value="17:00" style="width: 100%; padding: 6px; background: #e2e8f0;"></div>
</div>
</div>
<div style="flex: 2;">
<div style="margin-bottom: 10px; display: flex; justify-content: space-between;">
<h4 style="margin: 0;">SLA Matrix (Days to Patch)</h4>
</div>
<table style="font-size: 0.85rem; width: 100%; text-align: left;">
<thead><tr><th style="padding-bottom: 5px;">Severity</th><th>Triage</th><th>Patch</th></tr></thead>
<tbody>
<tr class="sla-row"><td style="padding: 4px 0;"><span class="badge critical">Critical</span></td><td>1</td><td>3</td></tr>
<tr class="sla-row"><td style="padding: 4px 0;"><span class="badge high">High</span></td><td>3</td><td>14</td></tr>
<tr class="sla-row"><td style="padding: 4px 0;"><span class="badge medium">Medium</span></td><td>7</td><td>30</td></tr>
<tr class="sla-row"><td style="padding: 4px 0;"><span class="badge low">Low</span></td><td>14</td><td>90</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; padding: 15px; display: flex; flex-direction: column;">
<h3 style="margin: 0 0 15px 0;">⚙️ Operations</h3>
<div style="margin-bottom: 20px;">
<label style="font-weight: bold; display: flex; justify-content: space-between;">
Automated Backups
<span style="font-size: 0.75rem; color: #8b5cf6; font-weight: normal;">Pro Feature</span>
</label>
<div style="display: flex; gap: 10px; margin-top: 5px;">
<select disabled style="flex: 1; padding: 6px; background: #f1f5f9; color: #94a3b8; cursor: not-allowed; border: 1px solid #cbd5e1;">
<option>Manual Only (Free Core)</option>
<option>Daily Automated</option>
<option>Weekly Automated</option>
</select>
<button class="btn btn-secondary" style="color: #94a3b8; border-color: #cbd5e1;" onclick="showUpsell('Automated DB Backups')">🔒 Apply</button>
</div>
</div>
<div style="margin-bottom: 20px;">
<label style="font-weight: bold;">Data Portability</label>
<a href="/api/admin/export" class="btn btn-secondary" style="display: block; text-align: center; margin-top: 5px; background: #e0e7ff; color: #3730a3; border-color: #c7d2fe;">⬇️ Export JSON State</a>
</div>
<div style="margin-top: auto; padding-top: 15px; border-top: 1px solid #e2e8f0;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 0.85rem; color: #475569;">Core Engine: <strong>{{.Version}} ({{.Commit}})</strong></span>
<button class="btn" style="padding: 4px 10px; font-size: 0.8rem; color: white; background: #0f172a;" onclick="checkUpdates()">Updates</button>
</div>
</div>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,33 @@
{{define "admin_feed"}}
<div id="tab-feed" class="tab-pane">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<div>
<h3 style="margin: 0;">📻 System Logs</h3>
<p style="font-size: 0.85rem; color: #64748b; margin: 0;">Real-time tamper-evident system audit log.</p>
</div>
<div>
<select id="logFilter" style="padding: 8px; border-radius: 4px; border: 1px solid #cbd5e1; font-size: 0.9rem;">
<option value="All">All Activity</option>
<option value="status_change">Status Changes</option>
<option value="risk_request">Risk Requests</option>
<option value="magistrate_review">Magistrate Reviews</option>
<option value="assigned_to">Assignments</option>
<option value="comment">Comments</option>
<option value="read_receipt">Read Receipts</option>
</select>
</div>
</div>
<div id="logContainer" style="min-height: 400px; padding-right: 5px; font-size: 0.95rem;">
<div style="text-align: center; color: #94a3b8; padding: 40px;">Loading logs...</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 20px; padding-top: 15px; border-top: 1px solid #e2e8f0;">
<span id="logPageInfo" style="font-size: 0.85rem; color: #64748b;">Showing 0 of 0</span>
<div>
<button id="logPrevBtn" class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem;" disabled>Previous</button>
<button id="logNextBtn" class="btn btn-secondary" style="padding: 6px 12px; font-size: 0.85rem;" disabled>Next</button>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,80 @@
{{define "admin_metrics"}}
<div id="tab-metrics" class="tab-pane active">
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-bottom: 20px;">
<a href="/dashboard?filter=critical" class="kpi-card" style="text-align: center; border-top: 4px solid #dc2626;">
<h4 style="margin: 0; color: #64748b; font-size: 0.85rem; text-transform: uppercase;">Active CISA KEVs</h4>
<p style="font-size: 2rem; margin: 10px 0 0 0; font-weight: bold; color: {{if gt .Analytics.ActiveKEVs 0}}#dc2626{{else}}#10b981{{end}};">{{.Analytics.ActiveKEVs}}</p>
</a>
<a href="/dashboard?filter=critical" class="kpi-card" style="text-align: center; border-top: 4px solid #ea580c;">
<h4 style="margin: 0; color: #64748b; font-size: 0.85rem; text-transform: uppercase;">Open Criticals</h4>
<p style="font-size: 2rem; margin: 10px 0 0 0; font-weight: bold; color: #ea580c;">{{.Analytics.OpenCriticals}}</p>
</a>
<a href="/dashboard?filter=overdue" class="kpi-card" style="text-align: center; border-top: 4px solid #eab308;">
<h4 style="margin: 0; color: #64748b; font-size: 0.85rem; text-transform: uppercase;">SLA Breaches</h4>
<p style="font-size: 2rem; margin: 10px 0 0 0; font-weight: bold; color: #eab308;">{{.Analytics.TotalOverdue}}</p>
</a>
<div class="kpi-card" style="text-align: center; border-top: 4px solid #3b82f6; cursor: default;">
<h4 style="margin: 0; color: #64748b; font-size: 0.85rem; text-transform: uppercase;">Global MTTR (Days)</h4>
<p style="font-size: 2rem; margin: 10px 0 0 0; font-weight: bold; color: #3b82f6;">{{.Analytics.GlobalMTTRDays}}</p>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-top: 20px;">
<div class="card" style="display: flex; flex-direction: column; justify-content: center; background: white; padding: 20px; border-radius: 8px; border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">
<h3 style="margin: 0 0 15px 0;">🔴 Open Risk Profile ({{.Analytics.Severity.Total}})</h3>
<div style="display: flex; height: 24px; border-radius: 12px; overflow: hidden; margin-bottom: 20px; background: #f1f5f9;">
{{if gt .Analytics.Severity.Total 0}}
<div style="width: {{.Analytics.Severity.CritPct}}%; background: #dc2626;" title="Critical: {{.Analytics.Severity.Critical}}"></div>
<div style="width: {{.Analytics.Severity.HighPct}}%; background: #ea580c;" title="High: {{.Analytics.Severity.High}}"></div>
<div style="width: {{.Analytics.Severity.MedPct}}%; background: #eab308;" title="Medium: {{.Analytics.Severity.Medium}}"></div>
<div style="width: {{.Analytics.Severity.LowPct}}%; background: #3b82f6;" title="Low: {{.Analytics.Severity.Low}}"></div>
<div style="width: {{.Analytics.Severity.InfoPct}}%; background: #94a3b8;" title="Info: {{.Analytics.Severity.Info}}"></div>
{{else}}
<div style="width: 100%; text-align: center; color: #94a3b8; font-size: 0.8rem; line-height: 24px;">No Open Findings</div>
{{end}}
</div>
<div style="display: flex; justify-content: space-between; font-size: 0.8rem; font-weight: bold; text-align: center;">
<div style="color: #dc2626; flex: 1;">Crit<br><span style="font-size: 1.1rem;">{{.Analytics.Severity.Critical}}</span></div>
<div style="color: #ea580c; flex: 1;">High<br><span style="font-size: 1.1rem;">{{.Analytics.Severity.High}}</span></div>
<div style="color: #eab308; flex: 1;">Med<br><span style="font-size: 1.1rem;">{{.Analytics.Severity.Medium}}</span></div>
<div style="color: #3b82f6; flex: 1;">Low<br><span style="font-size: 1.1rem;">{{.Analytics.Severity.Low}}</span></div>
</div>
</div>
<div class="card" style="display: flex; flex-direction: column; justify-content: center; background: white; padding: 20px; border-radius: 8px; border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">
<h3 style="margin: 0 0 15px 0;">🟢 Resolution Profile (Closed) ({{.Analytics.Resolution.Total}})</h3>
<div style="display: flex; height: 24px; border-radius: 12px; overflow: hidden; margin-bottom: 20px; background: #f1f5f9;">
{{if gt .Analytics.Resolution.Total 0}}
<div style="width: {{.Analytics.Resolution.PatchedPct}}%; background: #10b981;" title="Patched: {{.Analytics.Resolution.Patched}}"></div>
<div style="width: {{.Analytics.Resolution.RiskAccPct}}%; background: #8b5cf6;" title="Risk Accepted: {{.Analytics.Resolution.RiskAccepted}}"></div>
<div style="width: {{.Analytics.Resolution.FalsePosPct}}%; background: #64748b;" title="False Positive: {{.Analytics.Resolution.FalsePositive}}"></div>
{{else}}
<div style="width: 100%; text-align: center; color: #94a3b8; font-size: 0.8rem; line-height: 24px;">No Closed Findings</div>
{{end}}
</div>
<div style="display: flex; justify-content: space-between; font-size: 0.8rem; font-weight: bold; text-align: center;">
<div style="color: #10b981; flex: 1;">Patched<br><span style="font-size: 1.1rem;">{{.Analytics.Resolution.Patched}}</span></div>
<div style="color: #8b5cf6; flex: 1;">Accepted<br><span style="font-size: 1.1rem;">{{.Analytics.Resolution.RiskAccepted}}</span></div>
<div style="color: #64748b; flex: 1;">False Pos<br><span style="font-size: 1.1rem;">{{.Analytics.Resolution.FalsePositive}}</span></div>
</div>
</div>
<div class="card" style="background: white; padding: 20px; border-radius: 8px; border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">
<h3 style="margin: 0 0 15px 0;">🎯 Top Vulnerable Assets</h3>
{{range .Analytics.TopAssets}}
<a href="/dashboard?asset={{.Asset}}" style="display: block; text-decoration: none; color: inherit; margin-bottom: 12px; padding: 4px; border-radius: 4px; transition: background 0.2s;" onmouseover="this.style.background='#f8fafc'" onmouseout="this.style.background='transparent'">
<div style="display: flex; justify-content: space-between; font-size: 0.85rem; margin-bottom: 4px;">
<span style="font-family: monospace; font-weight: bold; color: #3b82f6;">{{.Asset}}</span>
<span style="color: #64748b; font-weight: bold;">{{.Count}} Findings</span>
</div>
<div style="background: #e2e8f0; height: 8px; border-radius: 4px; overflow: hidden;">
<div style="width: {{.Percentage}}%; background: #0f172a; height: 100%; border-radius: 4px;"></div>
</div>
</a>
{{else}}
<p style="color: #94a3b8; text-align: center; font-size: 0.9rem; padding: 20px;">No vulnerable assets found.</p>
{{end}}
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,25 @@
{{define "admin_modals"}}
<div id="userModal" class="modal-overlay" style="display: none;">
<div class="modal-content" style="width: 400px;">
<h3>Add New Personnel</h3>
<label>Full Name:</label>
<input type="text" id="newUserName" style="width: 100%; padding: 8px; margin-bottom: 15px;" placeholder="e.g. Alice Cloud">
<label>Email Address:</label>
<input type="email" id="newUserEmail" style="width: 100%; padding: 8px; margin-bottom: 15px;" placeholder="alice@ranch.com">
<label>Temporary Password:</label>
<input type="password" id="newUserPassword" style="width: 100%; padding: 8px; margin-bottom: 15px;" placeholder="••••••••">
<label>Global Role:</label>
<select id="newUserRole" style="width: 100%; padding: 8px; margin-bottom: 20px;">
<option value="RangeHand">RangeHand (Analyst)</option>
<option value="Wrangler">Wrangler (IT / Submitter)</option>
<option value="Magistrate">Magistrate (Risk Approver)</option>
<option value="Sheriff">Sheriff (Global Admin)</option>
</select>
<div class="modal-actions">
<button id="cancelUser" class="btn btn-secondary">Cancel</button>
<button id="submitUser" class="btn">Create User</button>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,114 @@
{{define "admin_performance"}}
<div id="tab-performance" class="tab-pane">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<div>
<h3 style="margin: 0;">📡 Integration Performance</h3>
<p style="font-size: 0.85rem; color: #64748b; margin: 0;">Diagnostic breakdown of global KPIs, bottlenecks, and SLA tracking by scanner source.</p>
</div>
</div>
<table style="font-size: 0.9rem; width: 100%; text-align: left; border-collapse: collapse;">
<thead>
<tr style="border-bottom: 2px solid #e2e8f0;">
<th style="padding-bottom: 12px; width: 15%;">Integration</th>
<th style="padding-bottom: 12px; text-align: center; width: 20%;">🔥 High Risk Drivers<br><span style="font-size: 0.75rem; color: #94a3b8; font-weight: normal;">(Ties to KPI Metrics)</span></th>
<th style="padding-bottom: 12px; text-align: center; width: 15%;">🕒 Analyst Backlog<br><span style="font-size: 0.75rem; color: #94a3b8; font-weight: normal;">(Triage Phase)</span></th>
<th style="padding-bottom: 12px; text-align: center; width: 15%;">⏳ IT Bottlenecks<br><span style="font-size: 0.75rem; color: #94a3b8; font-weight: normal;">(Patch Phase)</span></th>
<th style="padding-bottom: 12px; text-align: center; width: 15%;">🛡️ Resolution Hygiene<br><span style="font-size: 0.75rem; color: #94a3b8; font-weight: normal;">(Closed Profile)</span></th>
<th style="padding-bottom: 12px; width: 20%;">Strategic Insight</th>
</tr>
</thead>
<tbody>
{{range .Analytics.SourceHealth}}
<tr style="border-bottom: 1px solid #f1f5f9;">
<td style="padding: 16px 0;">
<strong style="font-size: 1rem; color: #0f172a;">{{.Source}}</strong><br>
<span style="font-size: 0.8rem; color: #64748b;">{{.TotalOpen}} Total Open</span>
</td>
<td style="text-align: center;">
<div style="display: flex; gap: 8px; justify-content: center;">
{{if gt .Criticals 0}}<span title="Open Criticals" style="background: #fff7ed; color: #ea580c; border: 1px solid #fdba74; padding: 2px 8px; border-radius: 4px; font-weight: bold; font-size: 0.8rem;">{{.Criticals}} CRIT</span>{{end}}
{{if gt .CisaKEVs 0}}<span title="Active CISA KEVs" style="background: #fee2e2; color: #dc2626; border: 1px solid #fca5a5; padding: 2px 8px; border-radius: 4px; font-weight: bold; font-size: 0.8rem;">{{.CisaKEVs}} KEV</span>{{end}}
{{if and (eq .Criticals 0) (eq .CisaKEVs 0)}}<span style="color: #94a3b8; font-size: 0.85rem;">-</span>{{end}}
</div>
</td>
<td style="text-align: center;">
{{if gt .Untriaged 0}}
<span style="background: #f1f5f9; color: #475569; padding: 4px 10px; border-radius: 12px; font-weight: bold; font-size: 0.8rem;">{{.Untriaged}} Pending</span>
{{else}}
<span style="color: #10b981; font-size: 0.85rem; font-weight: bold;">✓ Clear</span>
{{end}}
</td>
<td style="text-align: center;">
{{if gt .PatchOverdue 0}}
<span style="background: #fee2e2; color: #dc2626; padding: 4px 10px; border-radius: 12px; font-weight: bold; font-size: 0.8rem; box-shadow: 0 0 5px rgba(220, 38, 38, 0.3);">{{.PatchOverdue}} Overdue</span>
{{else if gt .PendingRisk 0}}
<span style="background: #f3e8ff; color: #7e22ce; padding: 4px 10px; border-radius: 12px; font-weight: bold; font-size: 0.8rem;" title="Waiting on Risk Reviews Tab">{{.PendingRisk}} Excepted</span>
{{else}}
<span style="color: #10b981; font-size: 0.85rem; font-weight: bold;">✓ Met</span>
{{end}}
</td>
<td style="text-align: center;">
<div style="font-size: 0.8rem; font-weight: bold; color: #0f172a; margin-bottom: 4px;">{{.TotalClosed}} Closed</div>
<div style="display: flex; gap: 6px; justify-content: center; font-size: 0.75rem;">
{{if gt .Patched 0}}<span style="color: #10b981;" title="Patched">✓ {{.Patched}}</span>{{end}}
{{if gt .RiskAccepted 0}}<span style="color: #8b5cf6;" title="Risk Accepted">⚖️ {{.RiskAccepted}}</span>{{end}}
{{if gt .FalsePositive 0}}<span style="color: #64748b;" title="False Positive">🚫 {{.FalsePositive}}</span>{{end}}
{{if eq .TotalClosed 0}}<span style="color: #94a3b8;">-</span>{{end}}
</div>
</td>
<td>
<div style="font-weight: bold; color: {{if or (eq .StrategicNote "✅ Routine Processing") (eq .StrategicNote "✅ Healthy Resolution Velocity")}}#10b981{{else}}#0f172a{{end}}; font-size: 0.85rem;">{{.StrategicNote}}</div>
<div style="font-family: monospace; font-size: 0.75rem; color: #64748b; margin-top: 4px;">Lead: {{.TopAssignee}}</div>
</td>
</tr>
{{else}}
<tr><td colspan="6" style="text-align: center; padding: 40px; color: #94a3b8;">No active sources found.</td></tr>
{{end}}
</tbody>
</table>
<div style="margin-top: 40px; display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<div>
<h3 style="margin: 0;">🔌 Recent Sync History</h3>
<p style="font-size: 0.85rem; color: #64748b; margin: 0;">Operational ledger of all API pushes, webhooks, and CSV uploads.</p>
</div>
</div>
<table style="font-size: 0.85rem; width: 100%; text-align: left; border-collapse: collapse; background: white; border: 1px solid #e2e8f0; border-radius: 8px;">
<thead>
<tr style="background: #f8fafc; border-bottom: 2px solid #e2e8f0;">
<th style="padding: 10px 15px;">Timestamp</th>
<th style="padding: 10px 15px;">Source</th>
<th style="padding: 10px 15px; text-align: center;">Status</th>
<th style="padding: 10px 15px; text-align: center;">Records Processed</th>
<th style="padding: 10px 15px;">Diagnostics</th>
</tr>
</thead>
<tbody>
{{range .SyncLogs}}
<tr style="border-bottom: 1px solid #f1f5f9;">
<td style="padding: 10px 15px; color: #64748b; font-family: monospace;">{{.CreatedAt}}</td>
<td style="padding: 10px 15px; font-weight: bold; color: #0f172a;">{{.Source}}</td>
<td style="padding: 10px 15px; text-align: center;">
{{if eq .Status "Success"}}
<span style="background: #dcfce7; color: #166534; padding: 2px 8px; border-radius: 12px; font-weight: bold;">✓ Success</span>
{{else}}
<span style="background: #fee2e2; color: #dc2626; padding: 2px 8px; border-radius: 12px; font-weight: bold;">✗ Failed</span>
{{end}}
</td>
<td style="padding: 10px 15px; text-align: center; font-weight: bold; color: #475569;">{{.RecordsProcessed}}</td>
<td style="padding: 10px 15px; color: #dc2626; font-family: monospace; font-size: 0.8rem;">{{.ErrorMessage}}</td>
</tr>
{{else}}
<tr><td colspan="5" style="text-align: center; padding: 20px; color: #94a3b8;">No syncs recorded yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
{{end}}

View File

@@ -0,0 +1,126 @@
{{define "dash_modals"}}
<div id="upsellModal" class="modal-overlay" style="display: none;">
<div class="modal-content" style="width: 400px; text-align: center;">
<div style="font-size: 3rem; margin-bottom: 10px;">🚀</div>
<h3 style="margin-top: 0; color: #0f172a;">RiskRancher Pro Required</h3>
<p style="color: #64748b; font-size: 0.95rem; margin-bottom: 20px;">The <strong id="upsellFeatureName">feature</strong> is available in the Pro edition. Scale your operations with automation, advanced RBAC, and branded reports.</p>
<div style="display: flex; gap: 10px; justify-content: center;">
<button class="btn btn-secondary" onclick="document.getElementById('upsellModal').style.display='none'">Close</button>
<a href="https://RiskRancher.com/pro" target="_blank" class="btn" style="background: #8b5cf6; color: white; text-decoration: none;">View Pro Plans</a>
</div>
</div>
</div>
<div id="newTicketModal" class="modal-overlay" style="display: none;">
<div class="modal-content" style="width: 500px; text-align: left;">
<h3 style="margin-top: 0; color: #0f172a; border-bottom: 1px solid #e2e8f0; padding-bottom: 10px;">+ Log Manual Finding</h3>
<div style="margin-bottom: 15px;">
<label style="font-weight: bold; display: block; margin-bottom: 5px; font-size: 0.9rem;">Title:</label>
<input type="text" id="newTicketTitle" placeholder="e.g. Open S3 Bucket found" style="width: 100%; padding: 8px; border: 1px solid #cbd5e1; border-radius: 4px; box-sizing: border-box;">
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px;">
<div>
<label style="font-weight: bold; display: block; margin-bottom: 5px; font-size: 0.9rem;">Asset Identifier:</label>
<input type="text" id="newTicketAsset" placeholder="e.g. aws-prod-bucket" style="width: 100%; padding: 8px; border: 1px solid #cbd5e1; border-radius: 4px; box-sizing: border-box;">
</div>
<div>
<label style="font-weight: bold; display: block; margin-bottom: 5px; font-size: 0.9rem;">Severity:</label>
<select id="newTicketSeverity" style="width: 100%; padding: 8px; border: 1px solid #cbd5e1; border-radius: 4px;">
<option value="Critical">Critical</option>
<option value="High" selected>High</option>
<option value="Medium">Medium</option>
<option value="Low">Low</option>
<option value="Info">Info</option>
</select>
</div>
</div>
<div style="margin-bottom: 20px;">
<label style="font-weight: bold; display: block; margin-bottom: 5px; font-size: 0.9rem;">Description (Markdown supported):</label>
<textarea id="newTicketDesc" style="width: 100%; padding: 8px; border: 1px solid #cbd5e1; border-radius: 4px; height: 100px; resize: vertical; box-sizing: border-box;"></textarea>
</div>
<div style="display: flex; justify-content: flex-end; gap: 10px;">
<button class="btn btn-secondary" onclick="document.getElementById('newTicketModal').style.display='none'">Cancel</button>
<button class="btn" style="background: #10b981; color: white; border: none;" onclick="submitNewTicket()">Create Finding</button>
</div>
</div>
</div>
<div id="drawerOverlay" class="drawer-overlay" onclick="closeDrawer()"></div>
<div id="ticketDrawer" class="drawer">
<div class="drawer-header">
<div>
<span id="drawerBadge" class="badge" style="margin-bottom: 8px; display: inline-block;"></span>
<h3 id="drawerTitle" style="margin: 0; color: #0f172a; font-size: 1.25rem;"></h3>
<div id="drawerAsset" style="font-family: monospace; color: #475569; font-size: 0.9rem; margin-top: 8px; background: #e2e8f0; padding: 2px 6px; border-radius: 4px; display: inline-block;"></div>
</div>
<button onclick="closeDrawer()" style="background: none; border: none; font-size: 1.8rem; cursor: pointer; color: #94a3b8;">&times;</button>
</div>
<div class="drawer-body" style="max-height: 75vh; overflow-y: auto; padding-right: 10px;">
<input type="hidden" id="drawerTicketID">
<div id="drawerEvidenceBlock" style="display: none; margin-bottom: 20px; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 6px; padding: 15px; border-left: 4px solid #166534;">
<label style="font-weight: bold; color: #14532d; display: block; margin-bottom: 5px;">IT Wrangler Evidence:</label>
<div id="drawerEvidenceText" style="font-family: monospace; font-size: 0.9rem; color: #166534; white-space: pre-wrap; word-break: break-word;"></div>
</div>
<div id="drawerReturnedBlock" style="display: none; margin-bottom: 20px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 6px; padding: 15px; border-left: 4px solid #991b1b;">
<label style="font-weight: bold; color: #7f1d1d; display: block; margin-bottom: 5px;">🔄 Reason for Return:</label>
<div id="drawerReturnedText" style="font-size: 0.9rem; color: #991b1b; white-space: pre-wrap; word-break: break-word;"></div>
</div>
<div style="margin-bottom: 20px;">
<label style="font-weight: bold; color: #334155; display: block; margin-bottom: 5px;">Description (Live Preview):</label>
<div id="drawerDescPreview" style="background: #f8fafc; border: 1px solid #cbd5e1; border-radius: 6px; padding: 15px; font-size: 0.95rem; color: #1e293b; overflow-x: auto;">
</div>
</div>
<div id="drawerEditControls">
<div style="margin-bottom: 20px;">
<label style="font-weight: bold; color: #334155; display: block; margin-bottom: 5px;">Edit Description (Markdown):</label>
<textarea id="drawerDescEdit" onkeyup="updateDrawerPreview()" onchange="updateDrawerPreview()" style="width: 100%; padding: 10px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.9rem; font-family: monospace; resize: vertical; min-height: 120px;"></textarea>
</div>
<div style="margin-bottom: 20px;">
<label style="font-weight: bold; color: #334155; display: block; margin-bottom: 5px;">Edit Recommended Remediation:</label>
<textarea id="drawerRemEdit" style="width: 100%; padding: 10px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.9rem; resize: vertical; min-height: 80px;"></textarea>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px;">
<div>
<label style="font-weight: bold; color: #334155; display: block; margin-bottom: 5px;">Adjust Severity:</label>
<select id="drawerSeverity" style="width: 100%; padding: 10px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.95rem;">
<option value="Critical">Critical</option>
<option value="High">High</option>
<option value="Medium">Medium</option>
<option value="Low">Low</option>
<option value="Info">Info</option>
</select>
</div>
<div>
<label style="font-weight: bold; color: #334155; display: block; margin-bottom: 5px;">Assign to IT (Email):</label>
<input type="text" id="drawerAssignee" placeholder="e.g. sysadmin@company.com" style="width: 100%; padding: 10px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box;">
</div>
</div>
<div style="background: #eff6ff; padding: 15px; border-radius: 6px; border-left: 4px solid #3b82f6;">
<label style="font-weight: bold; color: #1e3a8a; display: block; margin-bottom: 5px;">Audit Trail Comment (Required if altering):</label>
<input type="text" id="drawerComment" style="width: 100%; padding: 10px; border: 1px solid #bfdbfe; border-radius: 6px; box-sizing: border-box; font-size: 0.95rem;" placeholder="e.g., Updated remediation steps and severity...">
</div>
</div>
</div>
<div class="drawer-footer" style="display: flex; justify-content: space-between; width: 100%;">
<button class="btn btn-secondary" onclick="closeDrawer()">Discard</button>
<div id="drawerStandardActions" style="display: flex; gap: 10px;">
<button class="btn btn-secondary" style="color: #ea580c; border-color: #fdba74; background: #fffbeb;" onclick="markFalsePositive()">🚫 Mark False Positive</button>
<button class="btn" id="drawerSubmitBtn" style="background: #2563eb; color: white;">Save & Dispatch</button>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,108 @@
{{define "dash_table"}}
<div class="unified-card">
<div class="toolbar">
<div style="display: flex; align-items: center; gap: 15px;">
<h3 style="margin: 0; color: #0f172a;">Asset Triage Queue</h3>
</div>
{{if eq .CurrentTab "archives"}}
<select id="statusFilter" class="filter-dropdown" onchange="window.location.href='/dashboard?tab=archives&filter=' + this.value">
<option value="all" {{if eq .CurrentFilter "all"}}selected{{end}}>All Archived</option>
<option value="Patched" {{if eq .CurrentFilter "Patched"}}selected{{end}}>✅ Patched</option>
<option value="False Positive" {{if eq .CurrentFilter "False Positive"}}selected{{end}}>👻 False Positives</option>
</select>
{{end}}
<div style="display: flex; gap: 10px;">
<button class="btn" style="background: #10b981; color: white; border: none; padding: 6px 14px; font-size: 0.85rem; font-weight: bold; cursor: pointer;" onclick="openNewTicketModal()">+ New Finding</button>
<a href="/ingest" class="btn" style="background: #2563eb; color: white; text-decoration: none; padding: 6px 14px; font-size: 0.85rem; display: flex; align-items: center;">📥 Import Data</a>
<button class="btn" style="font-size: 0.85rem; padding: 6px 14px; background: #f8fafc; color: #64748b; border: 1px solid #cbd5e1;" onclick="showUpsell('Analyst God-Mode (Bulk Actions)')">🔒 Bulk Actions</button>
</div>
</div>
{{if .CurrentAsset}}
<div style="background: #eff6ff; border-bottom: 1px solid #bfdbfe; color: #1e3a8a; padding: 10px 20px; font-size: 0.9rem; display: flex; justify-content: space-between; align-items: center;">
<div><strong>Asset Filter Active:</strong> Showing tickets for <code style="background: white; padding: 2px 6px; border-radius: 4px;">{{.CurrentAsset}}</code></div>
<a href="/dashboard?tab={{.CurrentTab}}" style="background: #2563eb; color: white; padding: 4px 10px; border-radius: 4px; text-decoration: none; font-size: 0.8rem;">Clear Filter</a>
</div>
{{end}}
<table style="width: 100%; border-collapse: collapse; text-align: left; margin: 0;">
<thead id="mainTableHeader" style="display: none;">
<tr style="border-bottom: 2px solid #e2e8f0;">
<th style="width: 40px; padding: 12px 20px;"><input type="checkbox" id="selectAll"></th>
<th style="padding: 12px;">Severity</th>
<th style="padding: 12px;">Source</th>
<th style="padding: 12px;">Finding</th>
<th style="padding: 12px;">IT Assignee</th>
<th style="padding: 12px;">{{if eq .CurrentTab "holding_pen"}}⏳ Time to Triage{{else}}SLA Status{{end}}</th>
{{if ne .CurrentTab "holding_pen"}}<th style="padding: 12px; text-align: right; padding-right: 20px;">Action</th>{{end}}
</tr>
</thead>
<tbody id="ticketTableBody">
{{range .Tickets}}
<tr class="ticket-row {{if eq $.CurrentTab "archives"}}archived-row{{end}}" data-asset="{{.AssetIdentifier}}">
<td style="width: 40px;"><input type="checkbox" class="ticket-cb" name="ticket_ids" value="{{.ID}}"></td>
<td style="width: 120px;"><span class="badge {{.Severity | lower}}">{{.Severity}}</span></td>
<td style="width: 100px; font-size: 0.85rem; color: #475569;">{{.Source}}</td>
<td>
<textarea id="desc-{{.ID}}" style="display:none;">{{.Description}}</textarea>
<textarea id="rem-{{.ID}}" style="display:none;">{{.RecommendedRemediation}}</textarea>
<textarea id="ev-{{.ID}}" style="display:none;">{{.PatchEvidence}}</textarea>
<input type="hidden" id="status-{{.ID}}" value="{{.Status}}">
<textarea id="comment-{{.ID}}" style="display:none;">{{.LatestComment}}</textarea>
<input type="hidden" id="assignee-{{.ID}}" value="{{.Assignee}}">
<a href="javascript:void(0)" style="font-weight: bold; color: #2563eb; text-decoration: none;" onclick="openDrawer('{{.ID}}', '{{.Title}}', '{{.AssetIdentifier}}', '{{.Severity}}')">{{.Title}}</a>
{{if eq .Status "Returned to Security"}}
<div style="margin-top: 8px; background: #fef2f2; border-left: 3px solid #991b1b; padding: 6px 10px; font-size: 0.8rem; color: #7f1d1d; border-radius: 0 4px 4px 0;">
<strong>🔄 Returned by IT:</strong> {{.LatestComment}}
</div>
{{end}}
</td>
<td style="width: 150px;">
{{if eq .Assignee "Unassigned"}}
<span style="color: #94a3b8; font-style: italic; font-size: 0.85rem;">Unassigned</span>
{{else}}
<span style="background: #e0e7ff; color: #3730a3; padding: 2px 8px; border-radius: 12px; font-size: 0.85rem; font-weight: bold;">{{.Assignee}}</span>
{{end}}
</td>
<td style="width: 150px;">
{{if eq $.CurrentTab "holding_pen"}}
<span class="triage-timer" data-due="{{.TriageDueDate.Format "2006-01-02T15:04:05Z07:00"}}"></span>
{{else}}
{{if .IsOverdue}}<span style="color: #dc2626; font-weight: bold;">{{.SLAString}}</span>{{else}}<span style="color: #10b981; font-weight: bold;">{{.SLAString}}</span>{{end}}
{{end}}
</td>
{{if eq $.CurrentTab "holding_pen"}}
{{else if eq $.CurrentTab "chute"}}
<td style="text-align: right;"><button class="btn btn-secondary" style="padding: 4px 8px; font-size: 0.8rem; color: #94a3b8; border-color: #e2e8f0;" onclick="showUpsell('Passwordless Magic Links')">🔒 Share Asset Link</button></td>
{{else if eq $.CurrentTab "archives"}}
<td style="text-align: right; vertical-align: middle;">
{{if eq .Status "Patched"}}
<span class="archive-badge" style="background: #dcfce7; color: #166534; border: 1px solid #bbf7d0;">✅ RESOLVED</span>
{{else if eq .Status "False Positive"}}
<span class="archive-badge" style="background: #fff7ed; color: #9a3412; border: 1px solid #ffedd5;">👻 IGNORED</span>
{{end}}
<div style="font-size: 0.75rem; color: #94a3b8; margin-top: 4px;">
Archived {{.UpdatedAt.Format "Jan 02"}} (Took {{.SLAString}})
</div>
</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
<div class="pagination" style="padding: 20px;">
<span style="color: var(--text-muted); font-size: 0.9rem;">Page <strong>{{.CurrentPage}}</strong> of <strong>{{.TotalPages}}</strong></span>
<div style="display: flex; gap: 10px;">
<a href="/dashboard?tab={{.CurrentTab}}&page={{if .HasPrev}}{{.PrevPage}}{{else}}1{{end}}" class="btn {{if not .HasPrev}}disabled{{end}}">&larr; Previous</a>
<a href="/dashboard?tab={{.CurrentTab}}&page={{if .HasNext}}{{.NextPage}}{{else}}{{.TotalPages}}{{end}}" class="btn {{if not .HasNext}}disabled{{end}}">Next &rarr;</a>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,19 @@
{{define "dash_tabs"}}
<div class="tab-nav">
<a href="/dashboard?tab=holding_pen" class="tab-btn {{if eq .CurrentTab "holding_pen"}}active{{end}}">
📩 Holding Pen
{{if gt .ReturnedCount 0}}
<span class="notification-bubble">{{.ReturnedCount}}</span>
{{end}}
</a>
<a href="/dashboard?tab=chute" class="tab-btn {{if eq .CurrentTab "chute"}}active{{end}}">🤠 The Chute (Assigned)</a>
<a href="javascript:void(0)" onclick="showUpsell('E2E Exception & Verification Pipeline')" class="tab-btn" style="color: #94a3b8;">
🔒 Pending Verification (Pro)
</a>
<a href="/dashboard?tab=archives" class="tab-btn {{if eq .CurrentTab "archives"}}active{{end}}">
🗄️ The Archives
</a>
</div>
{{end}}

View File

@@ -0,0 +1,17 @@
{{define "content"}}
<script>
window.CurrentTab = "{{.CurrentTab}}";
</script>
{{template "dash_tabs" .}}
<div class="tab-pane active">
{{template "dash_table" .}}
</div>
{{template "dash_modals" .}}
<script src="/static/dashboard.js"></script>
{{end}}

View File

@@ -0,0 +1,41 @@
{{define "content"}}
<div style="max-width: 900px; margin: 0 auto; padding: 20px;">
<h2 style="color: #0f172a; margin-bottom: 5px;">📥 Data Ingestion & Parsers</h2>
<p style="color: #64748b; margin-bottom: 30px;">Bring your findings into the ranch.</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 30px;">
<div style="border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">
<div style="font-size: 2rem; margin-bottom: 10px;">📝</div>
<h3 style="margin-top: 0;">Pentest Report Parser</h3>
<p style="color: #475569; font-size: 0.9rem; margin-bottom: 20px; min-height: 40px;">Upload a Word (DOCX) or PDF penetration test report. We'll extract the findings and map them to tickets.</p>
<a href="/reports/upload" class="btn" style="background: #2563eb; color: white; text-decoration: none; display: inline-block;">Upload Report</a>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">
<div style="font-size: 2rem; margin-bottom: 10px;">🛠️</div>
<h3 style="margin-top: 0;">Custom Adapter Builder</h3>
<p style="color: #475569; font-size: 0.9rem; margin-bottom: 20px; min-height: 40px;">Using a proprietary scanner? Build a visual JSON mapping to seamlessly ingest its outputs.</p>
<a href="/admin/adapters/new" class="btn btn-secondary" style="text-decoration: none; display: inline-block;">Build New Adapter</a>
</div>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">
<h3 style="margin-top: 0;">📡 Standard Scanner Ingestion</h3>
<form id="ingestForm">
<label style="display: block; margin-bottom: 5px; font-weight: bold; color: #334155;">1. Select Configured Adapter:</label>
<select id="adapterSelect" style="width: 100%; padding: 10px; border: 1px solid #cbd5e1; border-radius: 6px; margin-bottom: 15px;">
{{range .Adapters}}
<option value="{{.Name}}">{{.Name}} (Source: {{.SourceName}})</option>
{{end}}
</select>
<label style="display: block; margin-bottom: 5px; font-weight: bold; color: #334155;">2. Upload Scan Results (JSON/CSV):</label>
<input type="file" id="scanFile" style="width: 100%; padding: 10px; border: 1px dashed #cbd5e1; border-radius: 6px; margin-bottom: 20px; background: #f8fafc;">
<button type="button" class="btn" style="background: #10b981; color: white; width: 100%; font-size: 1.05rem;" onclick="uploadScan()">Run Ingestion Process</button>
</form>
<div id="ingestResult" style="margin-top: 15px; padding: 10px; display: none; border-radius: 6px;"></div>
</div>
</div>
<script src="/static/ingest.js"></script>
{{end}}

37
ui/templates/login.gohtml Normal file
View File

@@ -0,0 +1,37 @@
{{define "login"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>RiskRancher - Login</title>
<link rel="stylesheet" href="/static/style.css">
<style>body { display: flex; align-items: center; justify-content: center; height: 100vh; background-color: #f8fafc; margin: 0; }</style>
</head>
<body>
<div class="auth-card">
<h2 style="margin-top: 0;">🐴 RiskRancher</h2>
<p style="color: #64748b; margin-bottom: 20px;">Sign in to your SOC Dashboard</p>
<div id="errorMsg" class="error"></div>
<form id="loginForm">
<div style="text-align: left;">
<label style="font-weight: 600; font-size: 0.9rem;">Email Address</label>
<input type="email" id="email" required placeholder="tim@ranch.com">
<label style="font-weight: 600; font-size: 0.9rem;">Password</label>
<input type="password" id="password" required placeholder="••••••••">
</div>
<button type="submit" class="btn" id="submitBtn">Sign In</button>
</form>
<div style="margin-top: 20px; font-size: 0.85rem;">
First time setup? <a href="/register" style="color: #2563eb;">Initialize System</a>
</div>
</div>
<script src="/static/auth.js"></script>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,42 @@
{{define "register"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>RiskRancher - System Initialization</title>
<link rel="stylesheet" href="/static/style.css">
<style>body { display: flex; align-items: center; justify-content: center; height: 100vh; background-color: #0f172a; color: white; margin: 0; }</style>
</head>
<body>
<div class="auth-card dark">
<h2 style="margin-top: 0; color: #10b981;">🛡️ Initialize System</h2>
<p style="color: #94a3b8; margin-bottom: 25px; font-size: 0.9rem;">
Welcome to RiskRancher. The first user to register will automatically be granted the <strong>Sheriff (Global Admin)</strong> role.
</p>
<div id="errorMsg" class="error dark"></div>
<form id="registerForm">
<div style="text-align: left;">
<label style="font-weight: 600; font-size: 0.9rem; color: #cbd5e1;">Full Name</label>
<input type="text" id="fullname" required placeholder="Wyatt Earp">
<label style="font-weight: 600; font-size: 0.9rem; color: #cbd5e1;">Email Address</label>
<input type="email" id="email" required placeholder="admin@ranch.com">
<label style="font-weight: 600; font-size: 0.9rem; color: #cbd5e1;">Secure Password</label>
<input type="password" id="password" required placeholder="••••••••">
</div>
<button type="submit" class="btn" id="submitBtn" style="background-color: #10b981;">Claim Sheriff Access</button>
</form>
<div style="margin-top: 20px; font-size: 0.85rem; color: #64748b;">
System already initialized? <a href="/login" style="color: #3b82f6;">Login here</a>
</div>
</div>
<script src="/static/auth.js"></script>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,37 @@
{{define "content"}}
<div class="container-fluid" style="padding: 20px; max-width: 1500px; margin: 0 auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<div>
<h2 style="margin: 0; color: #0f172a;">📝 Manual Pentest Parser</h2>
<p style="color: #64748b; margin-top: 5px;">Highlight text in the DOCX viewer to extract vulnerabilities.</p>
</div>
</div>
<div style="display: flex; gap: 20px; align-items: stretch; height: 75vh;">
<div class="card" style="flex: 1.5; padding: 0; display: flex; flex-direction: column; overflow: hidden;">
<div class="toolbar" style="background: #f8fafc; border-bottom: 1px solid #e2e8f0; padding: 15px 20px;">
<h3 style="margin: 0; font-size: 1.1rem; color: #0f172a;">📄 Document Viewer</h3>
</div>
<div id="document-viewer" style="padding: 40px; overflow-y: auto; flex: 1; background: #ffffff; line-height: 1.8; font-size: 1.05rem; color: #334155; border-radius: 0 0 8px 8px;">
{{.RenderedHTML}}
</div>
</div>
<div class="card" style="flex: 1; padding: 0; display: flex; flex-direction: column; background: #f8fafc;">
<div class="toolbar" style="background: #0f172a; color: white; border-bottom: 1px solid #e2e8f0; padding: 15px 20px; display: flex; justify-content: space-between; align-items: center; border-radius: 8px 8px 0 0;">
<h3 style="margin: 0; font-size: 1.1rem; color: white;">📋 Draft Findings</h3>
<button class="btn" style="background: #10b981; color: white; border: none; font-size: 0.85rem; padding: 6px 12px; font-weight: bold;" onclick="promoteAllDrafts()">Promote All to Tickets</button>
</div>
<div id="draft-list" class="scroll-container" style="padding: 20px; overflow-y: auto; flex: 1;">
<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>
</div>
</div>
</div>
</div>
<button id="clip-btn" class="btn" style="position: absolute; display: none; z-index: 1000; background: #2563eb; color: white; border: none; box-shadow: 0 4px 10px rgba(0,0,0,0.2); border-radius: 6px; padding: 6px 12px; font-weight: bold; font-size: 0.85rem;">✂️ Clip as Finding</button>
<script src="/static/parser.js"></script>
{{end}}

View File

@@ -0,0 +1,56 @@
{{define "content"}}
<div class="container mt-5" style="display: flex; justify-content: center; padding-top: 50px;">
<div class="card" style="width: 100%; max-width: 600px; padding: 40px; text-align: center; box-shadow: 0 4px 6px rgba(0,0,0,0.05);">
<h2 style="color: #0f172a; margin-top: 0;">📄 Upload Pentest Report</h2>
<p style="color: #64748b;">Upload a .docx manual assessment to enter the clipping parser.</p>
<div id="drop-zone" style="border: 3px dashed #3b82f6; padding: 50px 20px; border-radius: 8px; cursor: pointer; margin-top: 30px; background: #f8fafc; transition: background 0.2s;">
<h4 style="margin: 0; color: #1d4ed8;">Drag & Drop DOCX Here</h4>
<p style="margin-top: 10px; color: #94a3b8; font-size: 0.9rem;">or click to browse your computer</p>
<input type="file" id="file-input" hidden accept=".docx">
</div>
<div id="status-area" style="display: none; margin-top: 25px;">
<span style="font-weight: bold; color: #2563eb; padding: 10px 20px; background: #eff6ff; border-radius: 6px;">⏳ Processing Document...</span>
</div>
</div>
</div>
<script>
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
const statusArea = document.getElementById('status-area');
dropZone.onclick = () => fileInput.click();
fileInput.onchange = (e) => processFile(e.target.files[0]);
dropZone.ondragover = (e) => { e.preventDefault(); dropZone.style.background = "#e0e7ff"; };
dropZone.ondragleave = () => dropZone.style.background = "#f8fafc";
dropZone.ondrop = (e) => {
e.preventDefault();
dropZone.style.background = "#f8fafc";
processFile(e.dataTransfer.files[0]);
};
async function processFile(file) {
if (!file.name.toLowerCase().endsWith('.docx')) {
alert("Whoops! Please upload a .docx file.");
return;
}
statusArea.style.display = 'block';
let formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/reports/upload', { method: 'POST', body: formData });
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
window.location.href = '/reports/parser/' + data.file_id;
} catch (err) {
alert("Upload failed: " + err.message);
statusArea.style.display = 'none';
}
}
</script>
{{end}}

345
ui/ui.go Normal file
View File

@@ -0,0 +1,345 @@
package ui
import (
"bytes"
"embed"
"html/template"
"io/fs"
"log"
"math"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"epigas.gitea.cloud/RiskRancher/core/pkg/auth"
"epigas.gitea.cloud/RiskRancher/core/pkg/domain"
"epigas.gitea.cloud/RiskRancher/core/pkg/report"
)
//go:embed templates/* templates/components/* static/*
var CoreUIFS embed.FS
var (
AppVersion = "dev"
AppCommit = "none"
)
var CoreTemplates *template.Template
var Pages map[string]*template.Template
// SetVersionInfo is called by main.go on startup to inject ldflags
func SetVersionInfo(version, commit string) {
AppVersion = version
AppCommit = commit
}
func init() {
funcMap := template.FuncMap{"lower": strings.ToLower}
Pages = make(map[string]*template.Template)
var err error
CoreTemplates, err = template.New("").Funcs(funcMap).ParseFS(CoreUIFS, "templates/*.gohtml", "templates/components/*.gohtml")
if err != nil && !strings.Contains(err.Error(), "pattern matches no files") {
log.Printf("Warning: Failed to parse master core templates: %v", err)
}
dashTmpl := template.New("").Funcs(funcMap)
dashTmpl, err = dashTmpl.ParseFS(CoreUIFS, "templates/base.gohtml", "templates/dashboard.gohtml", "templates/components/*.gohtml")
if err != nil {
log.Fatalf("FATAL: Failed to parse dashboard shell. Err: %v", err)
}
Pages["dashboard"] = dashTmpl
adminTmpl := template.New("").Funcs(funcMap)
adminTmpl, err = adminTmpl.ParseFS(CoreUIFS, "templates/base.gohtml", "templates/admin.gohtml", "templates/components/*.gohtml")
if err != nil {
log.Fatalf("FATAL: Failed to parse admin shell. Err: %v", err)
}
Pages["admin"] = adminTmpl
Pages["login"], err = template.New("").Funcs(funcMap).ParseFS(CoreUIFS, "templates/login.gohtml")
if err != nil {
log.Fatalf("FATAL: Failed to parse login. Err: %v", err)
}
Pages["register"], err = template.New("").Funcs(funcMap).ParseFS(CoreUIFS, "templates/register.gohtml")
if err != nil {
log.Fatalf("FATAL: Failed to parse register. Err: %v", err)
}
Pages["assets"], err = template.New("").Funcs(funcMap).ParseFS(CoreUIFS, "templates/base.gohtml", "templates/assets.gohtml", "templates/components/*.gohtml")
if err != nil {
log.Fatalf("FATAL: Failed to parse assets. Err: %v", err)
}
ingestTmpl := template.New("").Funcs(funcMap)
ingestTmpl, err = ingestTmpl.ParseFS(CoreUIFS, "templates/base.gohtml", "templates/ingest.gohtml", "templates/components/*.gohtml")
if err != nil {
log.Fatalf("FATAL: Failed to parse ingest shell. Err: %v", err)
}
Pages["ingest"] = ingestTmpl
adapterTmpl := template.New("").Funcs(funcMap)
adapterTmpl, err = adapterTmpl.ParseFS(CoreUIFS, "templates/base.gohtml", "templates/adapter_builder.gohtml", "templates/components/*.gohtml")
if err != nil {
log.Fatalf("FATAL: Failed to parse adapter builder shell. Err: %v", err)
}
Pages["adapter_builder"] = adapterTmpl
uploadTmpl := template.New("").Funcs(funcMap)
uploadTmpl, err = uploadTmpl.ParseFS(CoreUIFS, "templates/base.gohtml", "templates/report_upload.gohtml", "templates/components/*.gohtml")
if err != nil {
log.Fatalf("FATAL: Failed to parse report upload template. Err: %v", err)
}
Pages["report_upload"] = uploadTmpl
parserTmpl := template.New("").Funcs(funcMap)
parserTmpl, err = parserTmpl.ParseFS(CoreUIFS, "templates/base.gohtml", "templates/report_parser.gohtml", "templates/components/*.gohtml")
if err != nil {
log.Fatalf("FATAL: Failed to parse report parser template. Err: %v", err)
}
Pages["report_parser"] = parserTmpl
}
func StaticHandler() http.Handler {
staticFS, err := fs.Sub(CoreUIFS, "static")
if err != nil {
log.Fatal("Failed to load embedded static files:", err)
}
return http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))
}
type PageData struct {
Tickets any
CurrentTab string
CurrentFilter string
CurrentAsset string
ReturnedCount int
CountCritical int
CountOverdue int
CountMine int
CurrentPage int
TotalPages int
NextPage int
PrevPage int
CountVerification int
HasNext bool
HasPrev bool
Version string
Commit string
}
func HandleDashboard(store domain.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userIDVal := r.Context().Value(auth.UserIDKey)
if userIDVal == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
userID := userIDVal.(int)
user, err := store.GetUserByID(r.Context(), userID)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.GlobalRole == "Sheriff" {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
currentUserEmail := user.Email
currentUserRole := user.GlobalRole
tab := r.URL.Query().Get("tab")
if tab == "" {
tab = "holding_pen"
}
statusFilter := tab
if tab == "holding_pen" {
statusFilter = "Waiting to be Triaged"
} else if tab == "chute" {
statusFilter = "Assigned Out"
} else if tab == "verification" {
statusFilter = "Pending Verification"
}
filter := r.URL.Query().Get("filter")
assetFilter := r.URL.Query().Get("asset")
pageStr := r.URL.Query().Get("page")
page, _ := strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
limit := 50
offset := (page - 1) * limit
tickets, totalRecords, metrics, err := store.GetDashboardTickets(
r.Context(), statusFilter, filter, assetFilter, currentUserEmail, currentUserRole, limit, offset,
)
if err != nil {
http.Error(w, "Database query error: "+err.Error(), http.StatusInternalServerError)
return
}
totalPages := int(math.Ceil(float64(totalRecords) / float64(limit)))
if totalPages == 0 {
totalPages = 1
}
data := PageData{
Tickets: tickets,
CurrentTab: tab,
CurrentFilter: filter,
CurrentAsset: assetFilter,
ReturnedCount: metrics["returned"],
CountCritical: metrics["critical"],
CountOverdue: metrics["overdue"],
CountMine: metrics["mine"],
CountVerification: metrics["verification"],
CurrentPage: page,
TotalPages: totalPages,
NextPage: page + 1,
PrevPage: page - 1,
HasNext: page < totalPages,
HasPrev: page > 1,
Version: AppVersion,
Commit: AppCommit,
}
var buf bytes.Buffer
if err := Pages["dashboard"].ExecuteTemplate(&buf, "base", data); err != nil {
http.Error(w, "Template rendering error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w)
}
}
func HandleLoginUI() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := Pages["login"].ExecuteTemplate(w, "login", nil); err != nil {
http.Error(w, "Template render error: "+err.Error(), http.StatusInternalServerError)
}
}
}
func HandleRegisterUI() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := Pages["register"].ExecuteTemplate(w, "register", nil); err != nil {
http.Error(w, "Template render error: "+err.Error(), http.StatusInternalServerError)
}
}
}
func HandleAdminDashboard(store domain.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
users, _ := store.GetAllUsers(r.Context())
config, _ := store.GetAppConfig(r.Context())
slas, _ := store.GetSLAPolicies(r.Context())
adapters, _ := store.GetAdapters(r.Context())
analytics, _ := store.GetSheriffAnalytics(r.Context())
activityFeed, _ := store.GetGlobalActivityFeed(r.Context(), 15)
syncLogs, _ := store.GetRecentSyncLogs(r.Context(), 10)
data := map[string]any{
"Users": users,
"Config": config,
"SLAs": slas,
"Adapters": adapters,
"Analytics": analytics,
"Feed": activityFeed,
"SyncLogs": syncLogs,
"Version": AppVersion,
"Commit": AppCommit,
}
var buf bytes.Buffer
if err := Pages["admin"].ExecuteTemplate(&buf, "base", data); err != nil {
http.Error(w, "Template render error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w)
}
}
func HandleIngestUI(store domain.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
adapters, _ := store.GetAdapters(r.Context())
data := map[string]any{
"Adapters": adapters,
"Version": AppVersion,
"Commit": AppCommit,
}
if err := Pages["ingest"].ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, "Template render error: "+err.Error(), http.StatusInternalServerError)
}
}
}
func HandleAdapterBuilderUI(store domain.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data := map[string]any{
"Filename": r.URL.Query().Get("filename"),
"Version": AppVersion,
"Commit": AppCommit,
}
if err := Pages["adapter_builder"].ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, "Template render error: "+err.Error(), http.StatusInternalServerError)
}
}
}
func HandleParserUI(store domain.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
reportID := r.PathValue("id")
filePath := filepath.Join(report.UploadDir, reportID)
recorder := httptest.NewRecorder()
report.ServeDOCXAsHTML(recorder, filePath)
safeHTML := template.HTML(recorder.Body.String())
data := map[string]any{
"ReportID": reportID,
"RenderedHTML": safeHTML,
"Version": AppVersion,
"Commit": AppCommit,
}
if err := Pages["report_parser"].ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, "Template render error: "+err.Error(), http.StatusInternalServerError)
}
}
}
func HandlePentestUploadUI(store domain.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data := map[string]any{
"Version": AppVersion,
"Commit": AppCommit,
}
if err := Pages["report_upload"].ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, "Template render error: "+err.Error(), http.StatusInternalServerError)
}
}
}