feat(admin): add security and activity management features
This commit is contained in:
104
static/js/admin/activity.js
Normal file
104
static/js/admin/activity.js
Normal file
@@ -0,0 +1,104 @@
|
||||
(() => {
|
||||
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
|
||||
const dataNode = document.getElementById("activity-data");
|
||||
const body = document.getElementById("activity-body");
|
||||
const searchInput = document.getElementById("activity-search");
|
||||
const severityFilter = document.getElementById("activity-severity");
|
||||
const kindFilter = document.getElementById("activity-kind");
|
||||
const statusLeft = document.getElementById("activity-status-left");
|
||||
const toast = document.getElementById("toast");
|
||||
|
||||
if (!dataNode || !body || !searchInput) return;
|
||||
const events = parseData();
|
||||
|
||||
function parseData() {
|
||||
try {
|
||||
return JSON.parse(dataNode.textContent || "[]");
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = "info", duration = 1800) {
|
||||
window.WarpBoxUI?.toast?.(message, type, { target: toast, duration });
|
||||
}
|
||||
|
||||
function renderKindFilter() {
|
||||
const kinds = new Set(events.map((event) => event.kind || "unknown"));
|
||||
Array.from(kinds).sort().forEach((kind) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = kind;
|
||||
option.textContent = kind;
|
||||
kindFilter.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function createdLabel(value) {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return "-";
|
||||
return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
}
|
||||
|
||||
function filtered() {
|
||||
const query = searchInput.value.trim().toLowerCase();
|
||||
const severity = severityFilter.value;
|
||||
const kind = kindFilter.value;
|
||||
return events.filter((event) => {
|
||||
const haystack = [event.kind, event.message, event.ip, event.path, event.method].join(" ").toLowerCase();
|
||||
const matchesQuery = !query || haystack.includes(query);
|
||||
const matchesSeverity = severity === "all" || event.severity === severity;
|
||||
const matchesKind = kind === "all" || event.kind === kind;
|
||||
return matchesQuery && matchesSeverity && matchesKind;
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
const rows = filtered();
|
||||
body.innerHTML = "";
|
||||
rows.forEach((event) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${createdLabel(event.created_at)}</td>
|
||||
<td>${escapeHtml(event.kind || "-")}</td>
|
||||
<td>${escapeHtml(event.severity || "-")}</td>
|
||||
<td>${escapeHtml(event.ip || "-")}</td>
|
||||
<td>${escapeHtml(event.method || "-")}</td>
|
||||
<td title="${escapeHtml(event.path || "-")}">${escapeHtml(event.path || "-")}</td>
|
||||
<td title="${escapeHtml(event.message || "-")}">${escapeHtml(event.message || "-")}</td>
|
||||
`;
|
||||
body.appendChild(row);
|
||||
});
|
||||
statusLeft.textContent = `${rows.length} activity event(s) visible`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
|
||||
}
|
||||
|
||||
[searchInput, severityFilter, kindFilter].forEach((element) => {
|
||||
element.addEventListener(element.tagName === "INPUT" ? "input" : "change", render);
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
menuController.close();
|
||||
if (button.dataset.command === "refresh") {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (button.dataset.command === "export") {
|
||||
const blob = new Blob([JSON.stringify(filtered(), null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `warpbox-activity-${new Date().toISOString().replaceAll(":", "-")}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast("Visible activity exported");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
renderKindFilter();
|
||||
render();
|
||||
})();
|
||||
@@ -1,25 +1,16 @@
|
||||
(() => {
|
||||
const menuController = window.WarpBoxUI?.bindMenuBar?.() || {
|
||||
close() {
|
||||
document.querySelectorAll(".menu-item.is-open").forEach((item) => {
|
||||
item.classList.remove("is-open");
|
||||
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
}
|
||||
};
|
||||
const toast = document.getElementById("toast");
|
||||
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
|
||||
const dataNode = document.getElementById("alerts-data");
|
||||
const alertsBody = document.getElementById("alerts-body");
|
||||
const searchInput = document.getElementById("search-input");
|
||||
const severityFilter = document.getElementById("severity-filter");
|
||||
const statusFilter = document.getElementById("status-filter");
|
||||
const sourceFilter = document.getElementById("source-filter");
|
||||
const sortFilter = document.getElementById("sort-filter");
|
||||
const alertsBody = document.getElementById("alerts-body");
|
||||
const selectedCountEl = document.getElementById("selected-count");
|
||||
const openCountEl = document.querySelector("[data-open-count]");
|
||||
const highCountEl = document.querySelector("[data-high-count]");
|
||||
const ackCountEl = document.querySelector("[data-ack-count]");
|
||||
const closedCountEl = document.querySelector("[data-closed-count]");
|
||||
const selectAll = document.getElementById("select-all");
|
||||
const selectedCountEl = document.getElementById("selected-count");
|
||||
const totalPill = document.getElementById("alerts-total-pill");
|
||||
const toast = document.getElementById("toast");
|
||||
|
||||
const detailEls = {
|
||||
title: document.getElementById("detail-title"),
|
||||
@@ -32,185 +23,243 @@
|
||||
metadata: document.getElementById("detail-metadata")
|
||||
};
|
||||
|
||||
if (!alertsBody || !searchInput || !statusFilter || !selectedCountEl) return;
|
||||
if (!dataNode || !alertsBody) return;
|
||||
|
||||
const state = {
|
||||
alerts: parseData(),
|
||||
selected: new Set(),
|
||||
activeID: null
|
||||
};
|
||||
|
||||
function parseData() {
|
||||
try {
|
||||
return JSON.parse(dataNode.textContent || "[]");
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = "info", duration = 1800) {
|
||||
if (window.WarpBoxUI) {
|
||||
window.WarpBoxUI.toast(message, type, { target: toast, duration });
|
||||
return;
|
||||
}
|
||||
if (!toast) return;
|
||||
toast.textContent = message;
|
||||
toast.classList.add("is-visible");
|
||||
window.setTimeout(() => toast.classList.remove("is-visible"), duration);
|
||||
window.WarpBoxUI?.toast?.(message, type, { target: toast, duration });
|
||||
}
|
||||
|
||||
function allRows() {
|
||||
return Array.from(alertsBody.querySelectorAll("tr"));
|
||||
function createdLabel(value) {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return "-";
|
||||
return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
}
|
||||
|
||||
function visibleRows() {
|
||||
return allRows().filter((row) => row.style.display !== "none");
|
||||
function allAlerts() {
|
||||
return state.alerts.slice();
|
||||
}
|
||||
|
||||
function selectedRows() {
|
||||
return allRows().filter((row) => row.querySelector(".row-check")?.checked && row.style.display !== "none");
|
||||
}
|
||||
|
||||
function updateSelectedCount() {
|
||||
selectedCountEl.textContent = `Selected: ${selectedRows().length}`;
|
||||
}
|
||||
|
||||
function updateSummaryCounts() {
|
||||
const rows = visibleRows();
|
||||
openCountEl.textContent = String(rows.filter((row) => row.dataset.status === "open").length);
|
||||
highCountEl.textContent = String(rows.filter((row) => row.dataset.severity === "high" && row.dataset.status !== "closed").length);
|
||||
ackCountEl.textContent = String(rows.filter((row) => row.dataset.status === "acked").length);
|
||||
closedCountEl.textContent = String(rows.filter((row) => row.dataset.status === "closed").length);
|
||||
}
|
||||
|
||||
function updateDetails(row) {
|
||||
if (!row) return;
|
||||
allRows().forEach((item) => item.classList.remove("is-selected"));
|
||||
row.classList.add("is-selected");
|
||||
detailEls.title.textContent = row.dataset.title || "";
|
||||
detailEls.severity.textContent = row.dataset.severity || "";
|
||||
detailEls.status.textContent = row.dataset.status || "";
|
||||
detailEls.code.textContent = row.dataset.code || "";
|
||||
detailEls.trace.textContent = row.dataset.trace || "";
|
||||
detailEls.time.textContent = row.dataset.time || "";
|
||||
detailEls.description.textContent = row.dataset.description || "";
|
||||
try {
|
||||
detailEls.metadata.textContent = JSON.stringify(JSON.parse(row.dataset.metadata || "{}"), null, 2);
|
||||
} catch (_) {
|
||||
detailEls.metadata.textContent = row.dataset.metadata || "{}";
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const search = searchInput.value.trim().toLowerCase();
|
||||
function filteredAlerts() {
|
||||
const query = searchInput.value.trim().toLowerCase();
|
||||
const severity = severityFilter.value;
|
||||
const status = statusFilter.value;
|
||||
const group = sourceFilter.value;
|
||||
|
||||
allRows().forEach((row) => {
|
||||
const rows = allAlerts().filter((alert) => {
|
||||
const haystack = [
|
||||
row.dataset.title,
|
||||
row.dataset.description,
|
||||
row.dataset.code,
|
||||
row.dataset.trace,
|
||||
row.dataset.group
|
||||
alert.title,
|
||||
alert.message,
|
||||
alert.code,
|
||||
alert.trace,
|
||||
alert.group
|
||||
].join(" ").toLowerCase();
|
||||
const matchesSearch = !search || haystack.includes(search);
|
||||
const matchesSeverity = severity === "all" || row.dataset.severity === severity;
|
||||
const matchesStatus = status === "all" || row.dataset.status === status;
|
||||
const matchesGroup = group === "all" || row.dataset.group === group;
|
||||
row.style.display = matchesSearch && matchesSeverity && matchesStatus && matchesGroup ? "" : "none";
|
||||
const matchesSearch = !query || haystack.includes(query);
|
||||
const matchesSeverity = severity === "all" || alert.severity === severity;
|
||||
const matchesStatus = status === "all" || alert.status === status;
|
||||
const matchesGroup = group === "all" || alert.group === group;
|
||||
return matchesSearch && matchesSeverity && matchesStatus && matchesGroup;
|
||||
});
|
||||
|
||||
const order = { high: 3, medium: 2, low: 1 };
|
||||
visibleRows().sort((a, b) => {
|
||||
if (sortFilter.value === "severity") return order[b.dataset.severity] - order[a.dataset.severity];
|
||||
if (sortFilter.value === "oldest") return Number(a.dataset.id) - Number(b.dataset.id);
|
||||
return Number(b.dataset.id) - Number(a.dataset.id);
|
||||
}).forEach((row) => alertsBody.appendChild(row));
|
||||
|
||||
const selectedVisible = visibleRows().find((row) => row.classList.contains("is-selected"));
|
||||
if (!selectedVisible && visibleRows()[0]) updateDetails(visibleRows()[0]);
|
||||
updateSelectedCount();
|
||||
updateSummaryCounts();
|
||||
rows.sort((a, b) => {
|
||||
if (sortFilter.value === "severity") return (order[b.severity] || 0) - (order[a.severity] || 0);
|
||||
if (sortFilter.value === "oldest") return String(a.created_at).localeCompare(String(b.created_at));
|
||||
return String(b.created_at).localeCompare(String(a.created_at));
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function setRowStatus(row, nextStatus) {
|
||||
row.dataset.status = nextStatus;
|
||||
const statusCell = row.children[3]?.querySelector(".alerts-pill");
|
||||
if (!statusCell) return;
|
||||
statusCell.className = `alerts-pill ${nextStatus}`;
|
||||
statusCell.textContent = nextStatus;
|
||||
function ensureActive(rows) {
|
||||
if (rows.length === 0) {
|
||||
state.activeID = null;
|
||||
return null;
|
||||
}
|
||||
const found = rows.find((item) => item.id === state.activeID);
|
||||
if (found) return found;
|
||||
state.activeID = rows[0].id;
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
function changeSelectedStatus(nextStatus) {
|
||||
const rows = selectedRows();
|
||||
if (!rows.length) {
|
||||
function render() {
|
||||
const rows = filteredAlerts();
|
||||
alertsBody.innerHTML = "";
|
||||
rows.forEach((alert) => alertsBody.appendChild(buildRow(alert)));
|
||||
const active = ensureActive(rows);
|
||||
if (active) renderDetails(active);
|
||||
renderSummary(rows);
|
||||
syncSelected();
|
||||
syncSelectAll(rows);
|
||||
}
|
||||
|
||||
function buildRow(alert) {
|
||||
const row = document.createElement("tr");
|
||||
if (state.activeID === alert.id) row.classList.add("is-selected");
|
||||
row.innerHTML = `
|
||||
<td><input type="checkbox" class="row-check"${state.selected.has(alert.id) ? " checked" : ""}></td>
|
||||
<td>${escapeHtml(alert.title || "-")}</td>
|
||||
<td><span class="alerts-pill ${escapeHtml(alert.severity || "low")}">${escapeHtml(alert.severity || "low")}</span></td>
|
||||
<td><span class="alerts-pill ${escapeHtml(alert.status || "open")}">${escapeHtml(alert.status || "open")}</span></td>
|
||||
<td>${escapeHtml(alert.code || "-")}</td>
|
||||
<td>${escapeHtml(alert.trace || "-")}</td>
|
||||
<td>${createdLabel(alert.created_at)}</td>
|
||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
||||
`;
|
||||
row.addEventListener("click", (event) => {
|
||||
if (event.target.closest("button") || event.target.closest("input")) return;
|
||||
state.activeID = alert.id;
|
||||
render();
|
||||
});
|
||||
row.querySelector(".row-open")?.addEventListener("click", () => {
|
||||
state.activeID = alert.id;
|
||||
render();
|
||||
});
|
||||
row.querySelector(".row-check")?.addEventListener("change", (event) => {
|
||||
if (event.target.checked) state.selected.add(alert.id);
|
||||
else state.selected.delete(alert.id);
|
||||
syncSelected();
|
||||
syncSelectAll(filteredAlerts());
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderDetails(alert) {
|
||||
detailEls.title.textContent = alert.title || "";
|
||||
detailEls.severity.textContent = alert.severity || "";
|
||||
detailEls.status.textContent = alert.status || "";
|
||||
detailEls.code.textContent = alert.code || "";
|
||||
detailEls.trace.textContent = alert.trace || "";
|
||||
detailEls.time.textContent = createdLabel(alert.created_at);
|
||||
detailEls.description.textContent = alert.message || "";
|
||||
detailEls.metadata.textContent = JSON.stringify(alert.meta || {}, null, 2);
|
||||
}
|
||||
|
||||
function renderSummary(rows) {
|
||||
const open = rows.filter((item) => item.status === "open").length;
|
||||
const high = rows.filter((item) => item.severity === "high" && item.status !== "closed").length;
|
||||
const ack = rows.filter((item) => item.status === "acked").length;
|
||||
const closed = rows.filter((item) => item.status === "closed").length;
|
||||
document.querySelector("[data-open-count]").textContent = String(open);
|
||||
document.querySelector("[data-high-count]").textContent = String(high);
|
||||
document.querySelector("[data-ack-count]").textContent = String(ack);
|
||||
document.querySelector("[data-closed-count]").textContent = String(closed);
|
||||
totalPill.textContent = `${rows.length} alerts`;
|
||||
}
|
||||
|
||||
function syncSelected() {
|
||||
selectedCountEl.textContent = `Selected: ${state.selected.size}`;
|
||||
}
|
||||
|
||||
function syncSelectAll(rows) {
|
||||
selectAll.checked = rows.length > 0 && rows.every((alert) => state.selected.has(alert.id));
|
||||
}
|
||||
|
||||
async function postAction(action, ids) {
|
||||
const response = await fetch("/admin/alerts/actions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, ids })
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || "Request failed");
|
||||
state.alerts = payload.alerts || [];
|
||||
}
|
||||
|
||||
async function runAction(action) {
|
||||
const ids = Array.from(state.selected);
|
||||
if (!ids.length && (action === "ack" || action === "close" || action === "delete")) {
|
||||
showToast("Select one or more alerts first", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
rows.forEach((row) => {
|
||||
setRowStatus(row, nextStatus);
|
||||
row.querySelector(".row-check").checked = false;
|
||||
});
|
||||
if (selectAll) selectAll.checked = false;
|
||||
updateSelectedCount();
|
||||
updateSummaryCounts();
|
||||
|
||||
const currentRow = visibleRows().find((row) => row.classList.contains("is-selected")) || visibleRows()[0];
|
||||
if (currentRow) updateDetails(currentRow);
|
||||
showToast(nextStatus === "acked" ? "Selected alerts acknowledged" : "Selected alerts closed");
|
||||
if (action === "open-only") {
|
||||
statusFilter.value = "open";
|
||||
render();
|
||||
showToast("Showing open alerts only");
|
||||
return;
|
||||
}
|
||||
if (action === "refresh") {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (action === "copy-meta") {
|
||||
const active = allAlerts().find((item) => item.id === state.activeID);
|
||||
if (active) {
|
||||
navigator.clipboard?.writeText(JSON.stringify(active.meta || {}, null, 2)).catch(() => {});
|
||||
}
|
||||
showToast("Metadata copied");
|
||||
return;
|
||||
}
|
||||
if (action === "export") {
|
||||
const blob = new Blob([JSON.stringify(filteredAlerts(), null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `warpbox-alerts-${new Date().toISOString().replaceAll(":", "-")}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast("Visible alerts exported");
|
||||
return;
|
||||
}
|
||||
if (action === "help-codes") {
|
||||
showToast("Codes map to internal security and service traces.");
|
||||
return;
|
||||
}
|
||||
if (action === "help-meta") {
|
||||
showToast("Metadata shows extra context for each alert.");
|
||||
return;
|
||||
}
|
||||
await postAction(action, ids);
|
||||
state.selected.clear();
|
||||
render();
|
||||
showToast(`Action complete: ${action}`, "success");
|
||||
}
|
||||
|
||||
const commandMessages = {
|
||||
refresh: "Alerts refreshed in mock view",
|
||||
export: "Visible alerts exported in mock view",
|
||||
"copy-meta": "Metadata copied in mock view",
|
||||
"help-codes": "Each alert code maps to a unique trigger point and trace identifier.",
|
||||
"help-meta": "Metadata explains why the alert happened and includes extra context."
|
||||
};
|
||||
|
||||
function runCommand(command) {
|
||||
switch (command) {
|
||||
case "ack":
|
||||
changeSelectedStatus("acked");
|
||||
return;
|
||||
case "close":
|
||||
changeSelectedStatus("closed");
|
||||
return;
|
||||
case "open-only":
|
||||
statusFilter.value = "open";
|
||||
applyFilters();
|
||||
showToast("Showing open alerts only");
|
||||
return;
|
||||
default:
|
||||
showToast(commandMessages[command] || `Mock action: ${command}`);
|
||||
}
|
||||
function escapeHtml(value) {
|
||||
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
|
||||
}
|
||||
|
||||
[searchInput, severityFilter, statusFilter, sourceFilter, sortFilter].forEach((control) => {
|
||||
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", applyFilters);
|
||||
});
|
||||
|
||||
allRows().forEach((row) => {
|
||||
row.addEventListener("click", (event) => {
|
||||
if (event.target.closest("button") || event.target.closest("input")) return;
|
||||
updateDetails(row);
|
||||
});
|
||||
row.querySelector(".row-open")?.addEventListener("click", () => updateDetails(row));
|
||||
row.querySelector(".row-check")?.addEventListener("change", updateSelectedCount);
|
||||
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", render);
|
||||
});
|
||||
|
||||
selectAll?.addEventListener("change", () => {
|
||||
visibleRows().forEach((row) => {
|
||||
const checkbox = row.querySelector(".row-check");
|
||||
if (checkbox) checkbox.checked = selectAll.checked;
|
||||
const rows = filteredAlerts();
|
||||
rows.forEach((alert) => {
|
||||
if (selectAll.checked) state.selected.add(alert.id);
|
||||
else state.selected.delete(alert.id);
|
||||
});
|
||||
updateSelectedCount();
|
||||
render();
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
button.addEventListener("click", async () => {
|
||||
menuController.close();
|
||||
runCommand(button.dataset.command);
|
||||
try {
|
||||
await runAction(button.dataset.command);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error", 3200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
document.addEventListener("keydown", async (event) => {
|
||||
if (event.key === "Escape") menuController.close();
|
||||
if (event.key === "F5") {
|
||||
event.preventDefault();
|
||||
runCommand("refresh");
|
||||
await runAction("refresh");
|
||||
}
|
||||
});
|
||||
|
||||
applyFilters();
|
||||
updateDetails(allRows()[0]);
|
||||
render();
|
||||
})();
|
||||
|
||||
272
static/js/admin/security.js
Normal file
272
static/js/admin/security.js
Normal file
@@ -0,0 +1,272 @@
|
||||
(() => {
|
||||
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
|
||||
const eventsNode = document.getElementById("security-events-data");
|
||||
const alertsNode = document.getElementById("security-alerts-data");
|
||||
const bansNode = document.getElementById("security-bans-data");
|
||||
const ipInput = document.getElementById("security-ip-input");
|
||||
const banUntilInput = document.getElementById("security-ban-until");
|
||||
const alertList = document.getElementById("security-alert-list");
|
||||
const activityBody = document.getElementById("security-activity-body");
|
||||
const bansBody = document.getElementById("security-bans-body");
|
||||
const bansCount = document.getElementById("security-bans-count");
|
||||
const toast = document.getElementById("toast");
|
||||
|
||||
const detail = {
|
||||
ip: document.getElementById("security-detail-ip"),
|
||||
risk: document.getElementById("security-detail-risk"),
|
||||
threat: document.getElementById("security-detail-threat"),
|
||||
geo: document.getElementById("security-detail-geo"),
|
||||
asn: document.getElementById("security-detail-asn"),
|
||||
until: document.getElementById("security-detail-until")
|
||||
};
|
||||
|
||||
if (!eventsNode || !alertsNode || !bansNode) return;
|
||||
const state = {
|
||||
events: parse(eventsNode),
|
||||
alerts: parse(alertsNode),
|
||||
bans: parse(bansNode),
|
||||
selectedIP: ""
|
||||
};
|
||||
|
||||
setDefaultBanUntil();
|
||||
|
||||
function parse(node) {
|
||||
try {
|
||||
return JSON.parse(node.textContent || "[]");
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = "info", duration = 1800) {
|
||||
window.WarpBoxUI?.toast?.(message, type, { target: toast, duration });
|
||||
}
|
||||
|
||||
function createdLabel(value) {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return "-";
|
||||
return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
}
|
||||
|
||||
function setDefaultBanUntil() {
|
||||
const base = new Date(Date.now() + 30 * 60 * 1000);
|
||||
const yyyy = String(base.getUTCFullYear());
|
||||
const mm = String(base.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(base.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(base.getUTCHours()).padStart(2, "0");
|
||||
const mi = String(base.getUTCMinutes()).padStart(2, "0");
|
||||
banUntilInput.value = `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
|
||||
}
|
||||
|
||||
function toRFC3339FromLocalUTC(datetimeLocalValue) {
|
||||
if (!datetimeLocalValue) return "";
|
||||
const date = new Date(datetimeLocalValue + ":00Z");
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function setSelectedIP(ip) {
|
||||
state.selectedIP = ip || "";
|
||||
if (state.selectedIP) ipInput.value = state.selectedIP;
|
||||
renderBans();
|
||||
renderIPDetails();
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderAlerts();
|
||||
renderActivity();
|
||||
renderBans();
|
||||
renderIPDetails();
|
||||
}
|
||||
|
||||
function renderAlerts() {
|
||||
alertList.innerHTML = "";
|
||||
state.alerts.slice(0, 12).forEach((alert) => {
|
||||
const entry = document.createElement("li");
|
||||
entry.textContent = `${createdLabel(alert.created_at)} | ${alert.severity || "low"} | ${alert.title || "-"}`;
|
||||
alertList.appendChild(entry);
|
||||
});
|
||||
}
|
||||
|
||||
function renderActivity() {
|
||||
activityBody.innerHTML = "";
|
||||
state.events
|
||||
.filter((event) => String(event.kind || "").startsWith("security") || String(event.kind || "").startsWith("auth.admin"))
|
||||
.slice(0, 60)
|
||||
.forEach((event) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${createdLabel(event.created_at)}</td>
|
||||
<td>${escapeHtml(event.kind || "-")}</td>
|
||||
<td>${escapeHtml(event.severity || "-")}</td>
|
||||
<td>${escapeHtml(event.ip || "-")}</td>
|
||||
<td>${escapeHtml(event.path || "-")}</td>
|
||||
<td title="${escapeHtml(event.message || "-")}">${escapeHtml(event.message || "-")}</td>
|
||||
`;
|
||||
activityBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderBans() {
|
||||
bansBody.innerHTML = "";
|
||||
const banMap = new Map(state.bans.map((entry) => [entry.ip, entry]));
|
||||
const ips = new Set();
|
||||
state.events.forEach((event) => {
|
||||
const ip = String(event.ip || "").trim();
|
||||
if (ip) ips.add(ip);
|
||||
});
|
||||
state.bans.forEach((entry) => {
|
||||
const ip = String(entry.ip || "").trim();
|
||||
if (ip) ips.add(ip);
|
||||
});
|
||||
const rows = Array.from(ips).sort();
|
||||
rows.forEach((ip) => {
|
||||
const ban = banMap.get(ip) || null;
|
||||
const status = ban ? "banned" : "observed";
|
||||
const row = document.createElement("tr");
|
||||
row.className = "security-bans-body-row";
|
||||
if (ip === state.selectedIP) row.classList.add("is-selected");
|
||||
row.innerHTML = `
|
||||
<td>${escapeHtml(ip || "-")}</td>
|
||||
<td>${status}</td>
|
||||
<td>${ban ? createdLabel(ban.until) : "-"}</td>
|
||||
`;
|
||||
row.addEventListener("click", () => setSelectedIP(ip));
|
||||
bansBody.appendChild(row);
|
||||
});
|
||||
bansCount.textContent = `${state.bans.length} active bans`;
|
||||
}
|
||||
|
||||
function renderIPDetails() {
|
||||
const ip = state.selectedIP || String(ipInput.value || "").trim();
|
||||
if (!ip) {
|
||||
detail.ip.textContent = "No IP selected";
|
||||
detail.risk.textContent = "-";
|
||||
detail.threat.textContent = "-";
|
||||
detail.geo.textContent = "Placeholder (geoipfast later)";
|
||||
detail.asn.textContent = "Placeholder";
|
||||
detail.until.textContent = "-";
|
||||
return;
|
||||
}
|
||||
const ban = state.bans.find((entry) => entry.ip === ip);
|
||||
detail.ip.textContent = ip;
|
||||
detail.risk.textContent = ban ? "high" : "medium";
|
||||
detail.threat.textContent = ban ? "Temporary banned source" : "Observed source";
|
||||
detail.geo.textContent = "Placeholder country/region lookup";
|
||||
detail.asn.textContent = "Placeholder ASN/provider lookup";
|
||||
detail.until.textContent = ban ? createdLabel(ban.until) : "Not banned";
|
||||
if (ban && ban.until) {
|
||||
const parsed = new Date(ban.until);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
const yyyy = String(parsed.getUTCFullYear());
|
||||
const mm = String(parsed.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(parsed.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(parsed.getUTCHours()).padStart(2, "0");
|
||||
const mi = String(parsed.getUTCMinutes()).padStart(2, "0");
|
||||
banUntilInput.value = `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
|
||||
}
|
||||
|
||||
async function postAction(action, payload = {}) {
|
||||
const response = await fetch("/admin/security/actions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, ...payload })
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(result.error || "Request failed");
|
||||
if (Array.isArray(result.bans)) state.bans = result.bans;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function banIP() {
|
||||
const ip = String(ipInput.value || "").trim();
|
||||
if (!ip) {
|
||||
showToast("Enter IP first", "warning");
|
||||
return;
|
||||
}
|
||||
const payload = await postAction("ban", { ip });
|
||||
setSelectedIP(ip);
|
||||
showToast(payload.message || "IP banned", "success");
|
||||
}
|
||||
|
||||
async function banUntil() {
|
||||
const ip = String(ipInput.value || "").trim();
|
||||
if (!ip) {
|
||||
showToast("Enter IP first", "warning");
|
||||
return;
|
||||
}
|
||||
const banUntil = toRFC3339FromLocalUTC(banUntilInput.value);
|
||||
if (!banUntil) {
|
||||
showToast("Set valid expiration date", "warning");
|
||||
return;
|
||||
}
|
||||
const payload = await postAction("ban_until", { ip, ban_until: banUntil });
|
||||
setSelectedIP(ip);
|
||||
showToast(payload.message || "IP ban expiration updated", "success");
|
||||
}
|
||||
|
||||
async function unbanIP() {
|
||||
const ip = state.selectedIP || String(ipInput.value || "").trim();
|
||||
if (!ip) {
|
||||
showToast("Select or enter IP first", "warning");
|
||||
return;
|
||||
}
|
||||
const payload = await postAction("unban", { ip });
|
||||
setSelectedIP("");
|
||||
showToast(payload.message || "IP unbanned", "success");
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
menuController.close();
|
||||
try {
|
||||
const command = button.dataset.command;
|
||||
if (command === "refresh") {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (command === "ban-ip") {
|
||||
await banIP();
|
||||
return;
|
||||
}
|
||||
if (command === "ban-until") {
|
||||
await banUntil();
|
||||
return;
|
||||
}
|
||||
if (command === "unban-ip") {
|
||||
await unbanIP();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message, "error", 3200);
|
||||
} finally {
|
||||
renderBans();
|
||||
renderIPDetails();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipInput.addEventListener("input", () => renderIPDetails());
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") menuController.close();
|
||||
if (event.key === "F5") {
|
||||
event.preventDefault();
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
if (state.bans.length > 0) {
|
||||
setSelectedIP(state.bans[0].ip);
|
||||
} else {
|
||||
const firstObserved = state.events.find((event) => String(event.ip || "").trim() !== "");
|
||||
if (firstObserved) setSelectedIP(String(firstObserved.ip || "").trim());
|
||||
}
|
||||
render();
|
||||
})();
|
||||
Reference in New Issue
Block a user