feat(admin): add full alerts dashboard functionality

This commit is contained in:
2026-05-01 00:46:10 +03:00
parent 3844473eb3
commit 36d49a970e
10 changed files with 972 additions and 18 deletions

View File

@@ -205,7 +205,8 @@
/* ===========================
Dashboard Window
=========================== */
.admin-dashboard-window {
.admin-dashboard-window,
.admin-workspace-window {
width: 100%;
min-height: 0;
padding: 0;
@@ -215,11 +216,13 @@
background-image: linear-gradient(180deg, rgba(255,255,255,.24), rgba(0,0,0,.06));
}
.admin-dashboard-window > .win98-titlebar {
.admin-dashboard-window > .win98-titlebar,
.admin-workspace-window > .win98-titlebar {
margin: 2px 2px 0;
}
.admin-dashboard-window > .menu-bar {
.admin-dashboard-window > .menu-bar,
.admin-workspace-window > .menu-bar {
flex: 0 0 auto;
height: auto;
min-height: 24px;
@@ -234,11 +237,13 @@
z-index: 30;
}
.admin-dashboard-window > .menu-bar .menu-button {
.admin-dashboard-window > .menu-bar .menu-button,
.admin-workspace-window > .menu-bar .menu-button {
color: #000000;
}
.admin-dashboard-window > .dashboard-body {
.admin-dashboard-window > .dashboard-body,
.admin-workspace-window > .admin-workspace-body {
flex: 1 1 auto;
margin-top: 0;
padding: 0 10px 10px;
@@ -673,7 +678,8 @@ body.is-compact .admin-section-body {
flex: 0 0 auto;
}
.admin-dashboard-window {
.admin-dashboard-window,
.admin-workspace-window {
min-height: 100dvh;
border-left: 0;
border-right: 0;

394
static/css/alerts.css Normal file
View File

@@ -0,0 +1,394 @@
.alerts-page-body {
display: grid;
gap: 10px;
}
.alerts-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.alerts-stat-card {
min-width: 0;
padding: 8px;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
}
.alerts-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
.alerts-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
.alerts-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
.alerts-stat-label {
margin: 0 0 4px;
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
color: #333333;
}
.alerts-stat-value {
margin: 0;
font-size: 24px;
line-height: 24px;
font-weight: bold;
}
.alerts-stat-note {
margin: 6px 0 0;
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: rgba(255,255,255,.65);
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #a0a0a0;
border-bottom: 1px solid #a0a0a0;
font-size: 12px;
line-height: 12px;
}
.alerts-content-grid {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(320px, .7fr);
gap: 10px;
min-height: 0;
}
.alerts-column {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.alerts-list-panel {
flex: 1 1 auto;
min-height: 520px;
}
.alerts-actions-panel {
flex: 1 1 auto;
min-height: 220px;
}
.alerts-panel {
display: flex;
flex-direction: column;
min-height: 0;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
box-shadow: inset 1px 1px 0 rgba(255,255,255,.7), inset -1px -1px 0 rgba(0,0,0,.08);
}
.alerts-panel-header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 34px;
padding: 6px 8px;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
box-shadow: inset 1px 1px 0 #f7f7f7;
}
.alerts-panel-title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
min-height: 22px;
font-weight: bold;
font-size: 15px;
line-height: 15px;
}
.alerts-panel-sub {
color: #444444;
font-size: 12px;
line-height: 12px;
font-weight: normal;
}
.alerts-panel-tools {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.alerts-panel-body {
flex: 1 1 auto;
min-height: 0;
padding: 10px;
overflow: auto;
background-color: #ffffff;
background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
}
.alerts-tool-button,
.alerts-row-button,
.alerts-footer-button {
min-width: 64px;
height: 24px;
padding: 0 8px;
font-size: 12px;
line-height: 12px;
}
.alerts-action-button {
width: 100%;
min-width: 0;
}
.alerts-toolbar-grid {
display: grid;
grid-template-columns: minmax(180px, 1.2fr) repeat(4, minmax(110px, .6fr));
gap: 8px;
margin-bottom: 8px;
}
.alerts-input,
.alerts-select,
.alerts-textarea {
width: 100%;
min-width: 0;
color: #000000;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
padding: 4px 6px;
font-family: inherit;
font-size: 13px;
}
.alerts-input,
.alerts-select {
height: 28px;
}
.alerts-table-wrap {
height: 430px;
overflow: auto;
background: #ffffff;
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.alerts-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
line-height: 14px;
color: #000000;
}
.alerts-table thead th {
position: sticky;
top: 0;
z-index: 2;
padding: 6px;
text-align: left;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
box-shadow: inset 0 1px 0 #ffffff;
}
.alerts-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
.alerts-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
.alerts-table tbody tr:hover { background: #d8e5f8; }
.alerts-table tbody tr.is-selected { background: #c5dcff; }
.alerts-table td {
padding: 6px;
border-bottom: 1px solid #e1e1e1;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alerts-col-check { width: 34px; }
.alerts-col-severity { width: 76px; }
.alerts-col-status { width: 82px; }
.alerts-col-code { width: 70px; }
.alerts-col-time { width: 110px; }
.alerts-col-actions { width: 88px; }
.alerts-pill {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: #f1f1f1;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #b0b0b0;
border-bottom: 1px solid #b0b0b0;
font-size: 12px;
line-height: 12px;
}
.alerts-pill.low { background: #deebff; }
.alerts-pill.medium { background: #fff2c8; }
.alerts-pill.high { background: #ffdcdc; }
.alerts-pill.open { background: #f2e1ff; }
.alerts-pill.acked { background: #e2f0e2; }
.alerts-pill.closed { background: #ececec; }
.alerts-info-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.alerts-info-item {
display: grid;
grid-template-columns: 110px minmax(0, 1fr);
gap: 8px;
align-items: start;
padding: 6px 8px;
background: #f5f5f5;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #c0c0c0;
border-bottom: 1px solid #c0c0c0;
}
.alerts-info-item strong {
font-size: 13px;
line-height: 13px;
}
.alerts-info-item span {
min-width: 0;
color: #222222;
word-break: break-word;
}
.alerts-json-box {
max-height: 180px;
overflow: auto;
margin: 0;
padding: 8px;
color: #b7ffc8;
background: #050505;
border-top: 2px solid #808080;
border-left: 2px solid #808080;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
font-family: "MonoCraft", "Courier New", monospace;
font-size: 12px;
line-height: 15px;
white-space: pre-wrap;
word-break: break-word;
}
.alerts-mini-note {
margin-top: 8px;
padding: 8px;
color: #000000;
background: #ffffcc;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #a08000;
border-bottom: 1px solid #a08000;
font-size: 12px;
line-height: 15px;
}
.alerts-action-stack {
display: grid;
gap: 8px;
}
.alerts-footerbar {
flex: 0 0 auto;
min-height: 42px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border-top: 1px solid #ffffff;
background: #dfdfdf;
}
.alerts-footer-left,
.alerts-footer-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
min-width: 0;
}
.alerts-status-pill {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 8px;
color: #000000;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
font-size: 12px;
line-height: 12px;
}
@media (max-width: 1120px) {
.alerts-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.alerts-content-grid {
grid-template-columns: 1fr;
}
.alerts-toolbar-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.alerts-summary-grid,
.alerts-toolbar-grid {
grid-template-columns: 1fr;
}
.alerts-table-wrap {
height: 360px;
}
.alerts-panel-header,
.alerts-footerbar {
align-items: flex-start;
flex-direction: column;
}
.alerts-info-item {
grid-template-columns: 1fr;
}
}

216
static/js/admin/alerts.js Normal file
View File

@@ -0,0 +1,216 @@
(() => {
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 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 detailEls = {
title: document.getElementById("detail-title"),
severity: document.getElementById("detail-severity"),
status: document.getElementById("detail-status"),
code: document.getElementById("detail-code"),
trace: document.getElementById("detail-trace"),
time: document.getElementById("detail-time"),
description: document.getElementById("detail-description"),
metadata: document.getElementById("detail-metadata")
};
if (!alertsBody || !searchInput || !statusFilter || !selectedCountEl) 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);
}
function allRows() {
return Array.from(alertsBody.querySelectorAll("tr"));
}
function visibleRows() {
return allRows().filter((row) => row.style.display !== "none");
}
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();
const severity = severityFilter.value;
const status = statusFilter.value;
const group = sourceFilter.value;
allRows().forEach((row) => {
const haystack = [
row.dataset.title,
row.dataset.description,
row.dataset.code,
row.dataset.trace,
row.dataset.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 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();
}
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 changeSelectedStatus(nextStatus) {
const rows = selectedRows();
if (!rows.length) {
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");
}
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}`);
}
}
[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);
});
selectAll?.addEventListener("change", () => {
visibleRows().forEach((row) => {
const checkbox = row.querySelector(".row-check");
if (checkbox) checkbox.checked = selectAll.checked;
});
updateSelectedCount();
});
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => {
menuController.close();
runCommand(button.dataset.command);
});
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") menuController.close();
if (event.key === "F5") {
event.preventDefault();
runCommand("refresh");
}
});
applyFilters();
updateDetails(allRows()[0]);
})();

View File

@@ -96,8 +96,8 @@
"dashboard-snapshot": "CURRENTLY_MOCKED_LEAVE_AS_IS: dashboard snapshot export would start here.",
logout: "CURRENTLY_MOCKED_LEAVE_AS_IS: logout would submit to the account logout route.",
"compact-mode": "Toggled compact density.",
"show-all-boxes": "TO-DO: navigate to /account/boxes.",
"show-all-alerts": "TO-DO: navigate to /account/alerts.",
"show-all-boxes": "TO-DO: navigate to the admin boxes view when that page exists.",
"show-all-alerts": "TO-DO: navigate to /admin/alerts.",
"export-boxes": "CURRENTLY_MOCKED_LEAVE_AS_IS: boxes CSV export would be requested.",
"export-alerts": "CURRENTLY_MOCKED_LEAVE_AS_IS: alerts JSON export would be requested.",
"cleanup-dry-run": "CURRENTLY_MOCKED_LEAVE_AS_IS: cleanup dry run would calculate affected boxes without deleting.",
@@ -105,8 +105,8 @@
"config-snapshot": "CURRENTLY_MOCKED_LEAVE_AS_IS: config snapshot would summarize runtime settings and sources.",
"support-summary": "CURRENTLY_MOCKED_LEAVE_AS_IS: support summary would collect safe diagnostic information.",
"thumbnail-rebuild": "CURRENTLY_MOCKED_LEAVE_AS_IS: thumbnail rebuild would enqueue preview regeneration.",
"open-users": "TO-DO: navigate to /account/users for admins.",
"open-settings": "TO-DO: navigate to /account/settings for admins.",
"open-users": "TO-DO: navigate to the admin users view when that page exists.",
"open-settings": "TO-DO: navigate to the admin settings view when that page exists.",
"alerts-help": "Alerts use title, description, severity, metadata JSON, trace identifier, and unique numeric code.",
shortcuts: "Shortcuts: F5 refresh, Alt+A alerts, Alt+B boxes, Alt+R activity, Esc close menus/modal.",
about: "WarpBox dashboard mock v5, single-window Win98 account dashboard."