feat: add operational Dashboard
This commit is contained in:
1 parent
4a1959ecd6
commit
b8b741ccee
23 files changed
+1278
-59
No files matched your search
@@ -1489,3 +1489,132 @@ button.map-position:hover {
|
||||
.repair-search, .repair-filter-actions { grid-column: auto; }
|
||||
.repair-table td { grid-template-columns: 90px minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
/* Dashboard */
|
||||
.dashboard-status-band {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dashboard-status-band p { margin: 0; }
|
||||
.dashboard-permission-note { color: var(--muted); font-size: 12px; }
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-section {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.dashboard-section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dashboard-section-head .eyebrow { margin: 0 0 3px; }
|
||||
.dashboard-section h2 { margin: 0; font-size: 19px; }
|
||||
.dashboard-section h3 { margin: 0 0 8px; font-size: 14px; }
|
||||
.dashboard-section .object-link { white-space: nowrap; }
|
||||
|
||||
.dashboard-total {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-left: 4px solid var(--accent);
|
||||
padding: 9px 11px;
|
||||
background: #f6f8fa;
|
||||
}
|
||||
|
||||
.dashboard-total span { color: var(--muted); font-weight: 700; }
|
||||
.dashboard-total strong { font-size: 25px; }
|
||||
|
||||
.dashboard-dimension-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dashboard-count-list,
|
||||
.dashboard-status-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard-count-list > div,
|
||||
.dashboard-status-list > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 36px;
|
||||
border-top: 1px solid #e3e7eb;
|
||||
}
|
||||
|
||||
.dashboard-count-list dt,
|
||||
.dashboard-status-list dt { color: #40505f; }
|
||||
.dashboard-count-list dd,
|
||||
.dashboard-status-list dd { margin: 0; font-weight: 800; }
|
||||
.dashboard-status-list { margin-top: 10px; }
|
||||
.dashboard-status-list a { font-weight: 700; }
|
||||
.dashboard-boundary { margin: 13px 0 0; color: var(--muted); font-size: 12px; }
|
||||
|
||||
.attention-list { display: grid; gap: 7px; }
|
||||
.attention-list a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--line);
|
||||
border-left-width: 4px;
|
||||
border-radius: 5px;
|
||||
padding: 8px 11px;
|
||||
background: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.attention-list a[data-state="offline"] { border-left-color: var(--danger); }
|
||||
.attention-list a[data-state="unknown"] { border-left-color: #596675; }
|
||||
.attention-list a[data-state="unstable"] { border-left-color: var(--warn); }
|
||||
.attention-list a[data-state="repair"] { border-left-color: #6b4ca5; }
|
||||
.attention-list span { font-weight: 750; }
|
||||
.attention-list strong { font-size: 18px; }
|
||||
|
||||
.dashboard-location-counts {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dashboard-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.dashboard-status-band,
|
||||
.dashboard-section-head { align-items: flex-start; flex-direction: column; }
|
||||
.dashboard-dimension-grid { grid-template-columns: 1fr; }
|
||||
.dashboard-location-counts { grid-template-columns: 1fr; }
|
||||
.dashboard-section { padding: 13px; }
|
||||
.dashboard-section .object-link { white-space: normal; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-section *,
|
||||
.attention-list * { scroll-behavior: auto; }
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
if (document.body.dataset.view !== "dashboard") return;
|
||||
|
||||
const ids = [
|
||||
"dashboardLoading", "dashboardError", "dashboardErrorDetail",
|
||||
"dashboardRetry", "dashboardEmpty", "dashboardContent",
|
||||
"dashboardLoadStatus", "dashboardFleetTotal", "dashboardRuntimeOnline",
|
||||
"dashboardRuntimeOffline", "dashboardRuntimeUnknown",
|
||||
"dashboardLifecycleRepair", "dashboardLifecycleSpare",
|
||||
"dashboardLifecycleRetired", "dashboardAttentionOffline",
|
||||
"dashboardAttentionUnknown", "dashboardAttentionUnstable",
|
||||
"dashboardAttentionRepair", "dashboardActiveRepairs",
|
||||
"dashboardRepairStatuses", "dashboardSites", "dashboardRooms",
|
||||
"dashboardPositions", "dashboardOccupied", "dashboardEmptyPositions",
|
||||
"dashboardMappedMiners", "dashboardUnmappedMiners"
|
||||
];
|
||||
const el = {};
|
||||
for (const id of ids) el[id] = document.getElementById(id);
|
||||
let controller = null;
|
||||
|
||||
const STATUS_LABELS = {
|
||||
OPEN: "Open",
|
||||
IN_PROGRESS: "In progress",
|
||||
WAITING_FOR_PARTS: "Waiting for parts",
|
||||
READY_FOR_TEST: "Ready for test",
|
||||
open: "Open",
|
||||
diagnosing: "Diagnosing",
|
||||
waiting_parts: "Waiting parts",
|
||||
in_progress: "In progress",
|
||||
testing: "Testing",
|
||||
ready_to_shelf: "Ready to shelf",
|
||||
needs_followup: "Needs follow-up",
|
||||
repair: "In repair",
|
||||
unknown: "Unknown"
|
||||
};
|
||||
|
||||
function setCount(node, value) {
|
||||
if (node) node.textContent = Number.isInteger(value) ? String(value) : "Unavailable";
|
||||
}
|
||||
|
||||
function show(name) {
|
||||
el.dashboardLoading.hidden = name !== "loading";
|
||||
el.dashboardError.hidden = name !== "error";
|
||||
el.dashboardEmpty.hidden = name !== "empty";
|
||||
el.dashboardContent.hidden = name !== "ready";
|
||||
}
|
||||
|
||||
function statusLabel(value) {
|
||||
if (Object.prototype.hasOwnProperty.call(STATUS_LABELS, value)) {
|
||||
return STATUS_LABELS[value];
|
||||
}
|
||||
return String(value || "Unknown").replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function renderFleet(fleet) {
|
||||
if (!fleet || !el.dashboardFleetTotal) return;
|
||||
setCount(el.dashboardFleetTotal, fleet.total);
|
||||
setCount(el.dashboardRuntimeOnline, fleet.runtime_online);
|
||||
setCount(el.dashboardRuntimeOffline, fleet.runtime_offline);
|
||||
setCount(el.dashboardRuntimeUnknown, fleet.runtime_unknown);
|
||||
setCount(el.dashboardLifecycleRepair, fleet.lifecycle_in_repair);
|
||||
setCount(el.dashboardLifecycleSpare, fleet.lifecycle_spare);
|
||||
setCount(el.dashboardLifecycleRetired, fleet.lifecycle_retired);
|
||||
const attention = fleet.attention || {};
|
||||
setCount(el.dashboardAttentionOffline, attention.offline);
|
||||
setCount(el.dashboardAttentionUnknown, attention.unknown);
|
||||
setCount(el.dashboardAttentionUnstable, attention.unstable);
|
||||
setCount(el.dashboardAttentionRepair, attention.repair);
|
||||
}
|
||||
|
||||
function renderRepairs(repairs) {
|
||||
if (!repairs || !el.dashboardActiveRepairs) return;
|
||||
setCount(el.dashboardActiveRepairs, repairs.active);
|
||||
const entries = Object.entries(repairs.status_counts || {}).sort(function (left, right) {
|
||||
return statusLabel(left[0]).localeCompare(statusLabel(right[0]));
|
||||
});
|
||||
el.dashboardRepairStatuses.replaceChildren();
|
||||
if (!entries.length) {
|
||||
const row = document.createElement("div");
|
||||
const term = document.createElement("dt");
|
||||
const value = document.createElement("dd");
|
||||
term.textContent = "No active repair statuses";
|
||||
value.textContent = "0";
|
||||
row.append(term, value);
|
||||
el.dashboardRepairStatuses.appendChild(row);
|
||||
return;
|
||||
}
|
||||
for (const [status, count] of entries) {
|
||||
const row = document.createElement("div");
|
||||
const term = document.createElement("dt");
|
||||
const link = document.createElement("a");
|
||||
const value = document.createElement("dd");
|
||||
const params = new URLSearchParams({ scope: "active", status: status });
|
||||
link.href = "/operations/repairs?" + params.toString();
|
||||
link.textContent = statusLabel(status);
|
||||
term.appendChild(link);
|
||||
value.textContent = Number.isInteger(count) ? String(count) : "Unavailable";
|
||||
row.append(term, value);
|
||||
el.dashboardRepairStatuses.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLocations(locations) {
|
||||
if (!locations || !el.dashboardSites) return;
|
||||
setCount(el.dashboardSites, locations.sites);
|
||||
setCount(el.dashboardRooms, locations.rooms);
|
||||
setCount(el.dashboardPositions, locations.positions);
|
||||
setCount(el.dashboardOccupied, locations.occupied_positions);
|
||||
setCount(el.dashboardEmptyPositions, locations.empty_positions);
|
||||
setCount(el.dashboardMappedMiners, locations.mapped_miners);
|
||||
setCount(el.dashboardUnmappedMiners, locations.unmapped_miners);
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
if (controller) controller.abort();
|
||||
controller = new AbortController();
|
||||
show("loading");
|
||||
try {
|
||||
const response = await fetch("/api/dashboard/overview", {
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal
|
||||
});
|
||||
if (response.status === 401) {
|
||||
window.location.replace("/login?next=" + encodeURIComponent("/dashboard"));
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error("The overview service returned " + response.status + ".");
|
||||
const payload = await response.json();
|
||||
const data = payload && payload.data ? payload.data : {};
|
||||
const available = payload && payload.meta ? payload.meta.available_sections || [] : [];
|
||||
if (!available.length) {
|
||||
show("empty");
|
||||
return;
|
||||
}
|
||||
renderFleet(data.fleet);
|
||||
renderRepairs(data.repairs);
|
||||
renderLocations(data.locations);
|
||||
const generated = new Date(data.generated_at);
|
||||
el.dashboardLoadStatus.textContent = Number.isNaN(generated.valueOf())
|
||||
? "Operational summary loaded"
|
||||
: "Operational summary generated " + generated.toLocaleString();
|
||||
show("ready");
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") return;
|
||||
el.dashboardErrorDetail.textContent = error.message || "The operational overview could not be loaded.";
|
||||
show("error");
|
||||
}
|
||||
}
|
||||
|
||||
el.dashboardRetry.addEventListener("click", loadOverview);
|
||||
loadOverview();
|
||||
}());
|
||||
Reference in new issue
Block a user