feat: add operational Dashboard
This commit is contained in:
1 parent
4a1959ecd6
commit
b8b741ccee
23 files changed
+1278
-59
No files matched your search
@@ -45,6 +45,15 @@ def frontend_script() -> Response:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assets/minerops-dashboard.js", include_in_schema=False)
|
||||
def dashboard_script() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "dashboard.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assets/minerops-mine-map.js", include_in_schema=False)
|
||||
def mine_map_script() -> Response:
|
||||
return Response(
|
||||
|
||||
@@ -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();
|
||||
}());
|
||||
+104
-48
@@ -13,7 +13,7 @@ NAVIGATION = (
|
||||
|
||||
PAGE_DETAILS = {
|
||||
"dashboard": (
|
||||
"Fleet overview",
|
||||
"Operational overview",
|
||||
"Dashboard",
|
||||
"A concise starting point for operational state and recent activity.",
|
||||
),
|
||||
@@ -35,46 +35,104 @@ PAGE_DETAILS = {
|
||||
}
|
||||
|
||||
|
||||
def _dashboard_content() -> str:
|
||||
metrics = (
|
||||
("Site health", "--", "Live health summary not yet connected"),
|
||||
("Total miners", "--", "Inventory summary not yet connected"),
|
||||
("Miners in repair", "--", "Repair summary not yet connected"),
|
||||
("Current hashrate", "-- TH/s", "Telemetry not yet connected"),
|
||||
("Current power", "-- kW", "Telemetry not yet connected"),
|
||||
)
|
||||
metric_html = "".join(
|
||||
"<article class='metric'>"
|
||||
f"<span class='metric-label'>{escape(label)}</span>"
|
||||
f"<strong class='metric-value'>{escape(value)}</strong>"
|
||||
f"<span class='metric-state'>{escape(state)}</span>"
|
||||
"</article>"
|
||||
for label, value, state in metrics
|
||||
def _dashboard_content(
|
||||
can_view_miners: bool,
|
||||
can_view_repairs: bool,
|
||||
can_view_locations: bool,
|
||||
) -> str:
|
||||
fleet = """
|
||||
<section class="dashboard-section dashboard-fleet" aria-labelledby="fleetOverviewTitle">
|
||||
<div class="dashboard-section-head"><div><p class="eyebrow">Assets</p>
|
||||
<h2 id="fleetOverviewTitle">Fleet overview</h2></div>
|
||||
<a class="object-link" href="/assets/miners">Open Fleet</a></div>
|
||||
<div class="dashboard-total"><span>Total miners</span>
|
||||
<strong id="dashboardFleetTotal">-</strong></div>
|
||||
<div class="dashboard-dimension-grid">
|
||||
<section aria-labelledby="runtimeTitle"><h3 id="runtimeTitle">Runtime</h3>
|
||||
<dl class="dashboard-count-list">
|
||||
<div><dt>Online</dt><dd id="dashboardRuntimeOnline">-</dd></div>
|
||||
<div><dt>Offline</dt><dd id="dashboardRuntimeOffline">-</dd></div>
|
||||
<div><dt>Unknown</dt><dd id="dashboardRuntimeUnknown">-</dd></div>
|
||||
</dl></section>
|
||||
<section aria-labelledby="lifecycleTitle"><h3 id="lifecycleTitle">Lifecycle</h3>
|
||||
<dl class="dashboard-count-list">
|
||||
<div><dt>In repair</dt><dd id="dashboardLifecycleRepair">-</dd></div>
|
||||
<div><dt>Spare</dt><dd id="dashboardLifecycleSpare">-</dd></div>
|
||||
<div><dt>Retired</dt><dd id="dashboardLifecycleRetired">-</dd></div>
|
||||
</dl></section>
|
||||
</div>
|
||||
<p class="dashboard-boundary">Runtime and lifecycle are separate dimensions and may overlap.</p>
|
||||
</section>
|
||||
<section class="dashboard-section dashboard-attention" aria-labelledby="attentionTitle">
|
||||
<div class="dashboard-section-head"><div><p class="eyebrow">Current state</p>
|
||||
<h2 id="attentionTitle">Needs attention</h2></div></div>
|
||||
<nav class="attention-list" aria-label="Fleet conditions requiring attention">
|
||||
<a href="/assets/miners?operational_state=offline" data-state="offline">
|
||||
<span>Offline</span><strong id="dashboardAttentionOffline">-</strong></a>
|
||||
<a href="/assets/miners?operational_state=unknown" data-state="unknown">
|
||||
<span>Unknown</span><strong id="dashboardAttentionUnknown">-</strong></a>
|
||||
<a href="/assets/miners?operational_state=unstable" data-state="unstable">
|
||||
<span>Hash unstable</span><strong id="dashboardAttentionUnstable">-</strong></a>
|
||||
<a href="/assets/miners?operational_state=repair" data-state="repair">
|
||||
<span>In repair</span><strong id="dashboardAttentionRepair">-</strong></a>
|
||||
</nav>
|
||||
</section>
|
||||
""" if can_view_miners else ""
|
||||
repairs = """
|
||||
<section class="dashboard-section" aria-labelledby="dashboardRepairsTitle">
|
||||
<div class="dashboard-section-head"><div><p class="eyebrow">Operations</p>
|
||||
<h2 id="dashboardRepairsTitle">Repairs</h2></div>
|
||||
<a class="object-link" href="/operations/repairs?scope=active">Open Repair Center</a></div>
|
||||
<div class="dashboard-total"><span>Active repair records</span>
|
||||
<strong id="dashboardActiveRepairs">-</strong></div>
|
||||
<dl class="dashboard-status-list" id="dashboardRepairStatuses"></dl>
|
||||
</section>
|
||||
""" if can_view_repairs else ""
|
||||
mapped_metrics = """
|
||||
<div><dt>Mapped miners</dt><dd id="dashboardMappedMiners">-</dd></div>
|
||||
<div><dt>Unmapped miners</dt><dd id="dashboardUnmappedMiners">-</dd></div>
|
||||
""" if can_view_miners else ""
|
||||
locations = f"""
|
||||
<section class="dashboard-section" aria-labelledby="dashboardLocationsTitle">
|
||||
<div class="dashboard-section-head"><div><p class="eyebrow">Spatial operations</p>
|
||||
<h2 id="dashboardLocationsTitle">Locations</h2></div>
|
||||
<a class="object-link" href="/monitoring/map">Open Mine Map</a></div>
|
||||
<dl class="dashboard-count-list dashboard-location-counts">
|
||||
<div><dt>Sites</dt><dd id="dashboardSites">-</dd></div>
|
||||
<div><dt>Rooms</dt><dd id="dashboardRooms">-</dd></div>
|
||||
<div><dt>Positions</dt><dd id="dashboardPositions">-</dd></div>
|
||||
<div><dt>Occupied</dt><dd id="dashboardOccupied">-</dd></div>
|
||||
<div><dt>Empty</dt><dd id="dashboardEmptyPositions">-</dd></div>
|
||||
{mapped_metrics}
|
||||
</dl>
|
||||
</section>
|
||||
""" if can_view_locations else ""
|
||||
available = sum((can_view_miners, can_view_repairs, can_view_locations))
|
||||
partial = (
|
||||
'<p class="dashboard-permission-note">This overview includes only the '
|
||||
'operational areas available to your account.</p>'
|
||||
if 0 < available < 3 else ""
|
||||
)
|
||||
return f"""
|
||||
<section class="metrics" aria-label="Fleet summary placeholders">
|
||||
{metric_html}
|
||||
</section>
|
||||
<div class="content-grid">
|
||||
<section class="panel" aria-labelledby="activityTitle">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2 id="activityTitle">Recent activity</h2>
|
||||
<p class="panel-copy">Operational events will appear here when
|
||||
the dashboard activity feed is connected.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="state-message">
|
||||
<strong>No activity feed connected</strong>
|
||||
<span>No events are fabricated for this placeholder.</span>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="panel" aria-labelledby="attentionTitle">
|
||||
<h2 id="attentionTitle">Data boundary</h2>
|
||||
<p class="panel-copy">Summary cards intentionally show unavailable
|
||||
values until live APIs are connected and freshness can be represented
|
||||
accurately.</p>
|
||||
</aside>
|
||||
<div class="state-message" id="dashboardLoading" role="status">
|
||||
<strong>Loading operational overview</strong>
|
||||
<span>Requesting current fleet, repair, and location summaries.</span>
|
||||
</div>
|
||||
<div class="state-message" id="dashboardError" data-kind="error" role="alert" hidden>
|
||||
<strong>Dashboard unavailable</strong>
|
||||
<span id="dashboardErrorDetail">The operational overview could not be loaded.</span>
|
||||
<button class="command-button" id="dashboardRetry" type="button">Retry</button>
|
||||
</div>
|
||||
<div class="state-message" id="dashboardEmpty" hidden>
|
||||
<strong>No operational sections available</strong>
|
||||
<span>Your account can open Dashboard but has no additional overview permissions.</span>
|
||||
</div>
|
||||
<div id="dashboardContent" hidden>
|
||||
<div class="dashboard-status-band">
|
||||
<p id="dashboardLoadStatus" class="freshness" aria-live="polite">Waiting for current summaries</p>
|
||||
{partial}
|
||||
</div>
|
||||
<div class="dashboard-grid">{fleet}{repairs}{locations}</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
@@ -345,14 +403,6 @@ def _miners_content(can_view_locations: bool) -> str:
|
||||
"""
|
||||
|
||||
|
||||
CONTENT = {
|
||||
"dashboard": _dashboard_content,
|
||||
"map": _map_content,
|
||||
"repairs": _repairs_content,
|
||||
"miners": _miners_content,
|
||||
}
|
||||
|
||||
|
||||
def render_app_page(
|
||||
view: str,
|
||||
current_user: dict,
|
||||
@@ -395,7 +445,11 @@ def render_app_page(
|
||||
has_permission("locations.view"), has_permission("miners.view")
|
||||
)
|
||||
if view == "repairs"
|
||||
else CONTENT[view]()
|
||||
else _dashboard_content(
|
||||
has_permission("miners.view"),
|
||||
has_permission("repairs.view"),
|
||||
has_permission("locations.view"),
|
||||
)
|
||||
)
|
||||
page_script = (
|
||||
'<script src="/assets/minerops-mine-map.js" defer></script>'
|
||||
@@ -404,6 +458,8 @@ def render_app_page(
|
||||
if view == "miners"
|
||||
else '<script src="/assets/minerops-repair-center.js" defer></script>'
|
||||
if view == "repairs"
|
||||
else '<script src="/assets/minerops-dashboard.js" defer></script>'
|
||||
if view == "dashboard"
|
||||
else ""
|
||||
)
|
||||
return f"""<!doctype html>
|
||||
|
||||
Reference in new issue
Block a user