feat: add Fleet operations view

This commit is contained in:
lussierj committed 2026-08-11 17:37:36 +00:00
1 parent c6d01df31c
commit 786a1ab910
12 files changed
+1062 -148

No files matched your search

+9
View File
@@ -54,6 +54,15 @@ def mine_map_script() -> Response:
)
@router.get("/assets/minerops-fleet.js", include_in_schema=False)
def fleet_script() -> Response:
return Response(
(STATIC_ROOT / "fleet.js").read_text(encoding="utf-8"),
media_type="text/javascript",
headers={"Cache-Control": "no-store"},
)
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
def dashboard_page(current_user: DashboardUser) -> str:
return render_page("dashboard", current_user)
+111
View File
@@ -1246,3 +1246,114 @@ button.map-position:hover {
.map-search { grid-column: auto; }
.position-grid { grid-template-columns: repeat(2, minmax(104px, 1fr)); }
}
/* Fleet operations */
.fleet-panel { padding: 14px; }
.fleet-filters {
display: grid;
grid-template-columns: minmax(220px, 1.6fr) repeat(5, minmax(130px, 1fr));
align-items: end;
gap: 9px;
margin-bottom: 12px;
}
.fleet-filters .field { min-width: 0; }
.fleet-filters .field input,
.fleet-filters .field select { min-height: 38px; }
.fleet-search { grid-column: span 2; }
.check-control {
display: flex;
align-items: center;
min-height: 38px;
gap: 7px;
color: #40505f;
font-size: 12px;
font-weight: 750;
}
.check-control input { width: 17px; height: 17px; }
.fleet-filter-actions { display: flex; gap: 7px; }
.fleet-filter-actions .quiet-button { color: var(--ink); }
.fleet-table { min-width: 1320px; }
.fleet-table th,
.fleet-table td { padding: 8px 9px; }
.fleet-table th { white-space: nowrap; }
.fleet-row { border-left: 4px solid transparent; }
.fleet-row[data-status="offline"] { border-left-color: #b42318; background: #fff5f4; }
.fleet-row[data-status="unstable"] { border-left-color: #9a5b00; background: #fff9ed; }
.fleet-row[data-status="repair"] { border-left-color: #6b4ca5; background: #f7f3fc; }
.fleet-row[data-status="unknown"] { border-left-color: #596675; background: #f7f8fa; }
.fleet-row[data-status="online"],
.fleet-row[data-status="spare"],
.fleet-row[data-status="retired"] { border-left-color: #c6ced5; }
.fleet-stack { display: grid; gap: 2px; }
.fleet-primary { font-weight: 700; }
.fleet-status { font-weight: 750; white-space: nowrap; }
.fleet-status::before {
content: "";
display: inline-block;
width: 8px;
height: 8px;
margin-right: 7px;
border: 2px solid currentColor;
border-radius: 50%;
}
.fleet-status[data-status="online"] { color: #315c45; }
.fleet-status[data-status="offline"] { color: #8f1d14; }
.fleet-status[data-status="unstable"] { color: #754600; }
.fleet-status[data-status="repair"] { color: #523884; }
.fleet-status[data-status="unknown"] { color: #354351; }
.fleet-status[data-status="spare"] { color: #075b91; }
.fleet-status[data-status="retired"] { color: #4d5660; }
.fleet-uuid { max-width: 150px; overflow: hidden; text-overflow: ellipsis; }
.fleet-location { min-width: 180px; max-width: 260px; }
.fleet-actions { display: grid; gap: 6px; white-space: nowrap; }
.quiet-link { color: #40505f; font-weight: 700; }
.fleet-result-summary { margin: 0 0 8px; color: var(--muted); font-size: 12px; }
.fleet-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 9px;
margin-top: 12px;
color: #40505f;
font-size: 12px;
font-weight: 700;
}
.fleet-pagination select { min-height: 34px; }
@media (max-width: 1180px) {
.fleet-filters { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.fleet-search { grid-column: span 2; }
}
@media (max-width: 680px) {
.fleet-panel { padding: 12px; }
.fleet-panel .panel-head { align-items: flex-start; }
.fleet-filters { grid-template-columns: 1fr 1fr; }
.fleet-search { grid-column: 1 / -1; }
.fleet-filter-actions { grid-column: 1 / -1; }
.fleet-filter-actions > * { flex: 1; }
.fleet-table { min-width: 0; }
.fleet-table tbody { gap: 12px; }
.fleet-table tr { border-left-width: 4px; padding: 8px; }
.fleet-table td {
display: grid;
grid-template-columns: minmax(95px, .8fr) minmax(0, 1.6fr);
gap: 10px;
padding: 7px 5px;
}
.fleet-table td::before {
content: attr(data-label);
color: #40505f;
font-size: 11px;
font-weight: 750;
}
.fleet-location { min-width: 0; max-width: none; }
.fleet-actions { white-space: normal; }
.fleet-pagination { justify-content: space-between; flex-wrap: wrap; }
}
@media (max-width: 420px) {
.fleet-filters { grid-template-columns: 1fr; }
.fleet-search, .fleet-filter-actions { grid-column: auto; }
.fleet-table td { grid-template-columns: 88px minmax(0, 1fr); }
}
-103
View File
@@ -239,110 +239,7 @@
}
}
function locationText(miner) {
if (miner.room_id) return "Room " + miner.room_id;
if (miner.site_id) return "Site " + miner.site_id;
return "Not assigned";
}
async function loadMiners(query) {
const rows = document.getElementById("minerRows");
if (!rows) return;
showState("loading");
const params = new URLSearchParams({ limit: "100", offset: "0" });
if (query) params.set("query", query);
try {
const envelope = await api(
"/api/inventory/miners?" + params.toString()
);
if (!envelope || !Array.isArray(envelope.data)) {
throw new Error(
"MinerOps returned an invalid inventory response."
);
}
const miners = envelope.data;
if (!miners.every(function (miner) {
return miner &&
typeof miner === "object" &&
typeof miner.id === "string" &&
miner.id.trim() !== "";
})) {
throw new Error(
"MinerOps returned an incomplete inventory response."
);
}
rows.replaceChildren();
if (!miners.length) {
showState(
"empty",
query
? "No authorized miners match this search."
: "No miners have been added or discovered yet."
);
return;
}
for (const miner of miners) {
const row = create("tr");
const identity = create("div");
const link = create("a", {
className: "object-link",
text: text(miner.asset_id, "No Asset ID"),
href: "/miners/" +
encodeURIComponent(miner.id) +
"/detail"
});
identity.appendChild(link);
identity.appendChild(create("span", {
className: "secondary",
text: miner.id
}));
addCell(row, "Asset ID", identity);
addCell(
row,
"Current MAC",
miner.current_mac || "Not observed"
);
addCell(row, "Model", miner.model);
addCell(row, "Lifecycle", status(miner.lifecycle_status));
addCell(row, "Runtime", status(miner.runtime_status));
addCell(row, "Location", locationText(miner));
rows.appendChild(row);
}
showState("ready");
} catch (error) {
showState(
error.permissionDenied ? "denied" : "error",
error.message
);
}
}
function setupMiners() {
const form = document.getElementById("minerSearchForm");
const input = document.getElementById("minerSearchInput");
if (!form || !input) return;
const initial =
new URLSearchParams(window.location.search).get("query") || "";
input.value = initial;
form.addEventListener("submit", function (event) {
event.preventDefault();
const query = input.value.trim();
const url = query
? "/assets/miners?query=" + encodeURIComponent(query)
: "/assets/miners";
window.history.pushState({ query: query }, "", url);
loadMiners(query);
});
window.addEventListener("popstate", function () {
const query =
new URLSearchParams(window.location.search).get("query") || "";
input.value = query;
loadMiners(query);
});
loadMiners(initial);
}
setupShell();
if (view === "repairs") loadRepairs();
if (view === "miners") setupMiners();
})();
+357
View File
@@ -0,0 +1,357 @@
(function () {
"use strict";
if (document.body.dataset.view !== "miners") return;
const canViewLocations = document.body.dataset.canViewLocations === "true";
const ids = [
"fleetFilters", "fleetQuery", "fleetLifecycle", "fleetModel",
"fleetRuntime", "fleetRepair", "fleetOperational", "fleetTags",
"fleetProblems", "fleetReset", "fleetRows", "fleetResultSummary",
"fleetFreshness", "fleetPrevious", "fleetNext", "fleetLimit"
];
const el = {};
for (const id of ids) el[id] = document.getElementById(id);
if (canViewLocations) {
el.fleetSite = document.getElementById("fleetSite");
el.fleetRoom = document.getElementById("fleetRoom");
}
const limits = new Set([25, 50, 100, 250]);
const state = { offset: 0, limit: 100, returned: 0, locationOptions: null,
siteId: "", roomId: "" };
function display(value, fallback) {
if (value === null || value === undefined || String(value).trim() === "") {
return fallback || "Unavailable";
}
return String(value);
}
function words(value) {
return display(value, "unknown").replaceAll("_", " ").toLowerCase();
}
function create(tag, className, value) {
const node = document.createElement(tag);
if (className) node.className = className;
if (value !== undefined) node.textContent = value;
return node;
}
function showState(name, message) {
for (const node of document.querySelectorAll("[data-view-state]")) {
node.hidden = node.dataset.viewState !== name;
}
const active = document.querySelector('[data-view-state="' + name + '"]');
const detail = active && active.querySelector("[data-state-detail]");
if (detail && message) detail.textContent = message;
}
function finite(value) {
return typeof value === "number" && Number.isFinite(value);
}
function metric(value, suffix) {
return finite(value) ? String(value) + suffix : "Unavailable";
}
function age(value) {
if (!value) return "Never observed";
const time = new Date(value).valueOf();
if (Number.isNaN(time)) return "Observation time unavailable";
const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000));
if (seconds < 60) return seconds + "s ago";
if (seconds < 3600) return Math.floor(seconds / 60) + "m ago";
if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago";
return Math.floor(seconds / 86400) + "d ago";
}
function operational(miner) {
if (miner.active_repair || miner.lifecycle_status === "in_repair") {
return { key: "repair", label: "In repair", problem: true };
}
if (miner.lifecycle_status === "retired") {
return { key: "retired", label: "Retired", problem: false };
}
if (miner.lifecycle_status === "spare") {
return { key: "spare", label: "Spare", problem: false };
}
const runtime = words(miner.runtime_status);
if (runtime === "offline") {
return { key: "offline", label: "Offline", problem: true };
}
if (miner.hash_stable === false) {
return { key: "unstable", label: "Hash unstable", problem: true };
}
if (runtime === "online") {
return { key: "online", label: "Online", problem: false };
}
return { key: "unknown", label: "Operational state unknown", problem: true };
}
function prefixedLabel(value, prefix) {
const label = display(value, "");
if (!label) return "";
return new RegExp("^" + prefix + "\\b", "i").test(label)
? label : prefix + " " + label;
}
function locationText(miner) {
if (!canViewLocations || !miner.location_position_id) return "Not mapped";
const parts = [];
if (miner.site_name) parts.push(miner.site_name);
if (miner.room_name) parts.push(miner.room_name);
if (miner.rack_label) parts.push(prefixedLabel(miner.rack_label, "Rack"));
if (miner.shelf_label) parts.push(prefixedLabel(miner.shelf_label, "Shelf"));
parts.push(display(
miner.position_label,
miner.position_index === null ? "Position unavailable" : "Position " + miner.position_index
));
return parts.join(" / ");
}
function detailLink(miner) {
return "/miners/" + encodeURIComponent(miner.id) + "/detail";
}
function mapLink(miner) {
if (!canViewLocations || !miner.location_position_id) return null;
const params = new URLSearchParams({ miner_id: miner.id });
if (miner.site_id && miner.room_id) {
params.set("site_id", miner.site_id);
params.set("room_id", miner.room_id);
}
return "/monitoring/map?" + params.toString();
}
function addCell(row, label, content, className) {
const cell = create("td", className || "");
cell.dataset.label = label;
if (content instanceof Node) cell.appendChild(content);
else cell.textContent = display(content);
row.appendChild(cell);
}
function stack(primary, secondary) {
const box = create("div", "fleet-stack");
box.append(create("span", "fleet-primary", display(primary)));
if (secondary) box.append(create("span", "secondary", secondary));
return box;
}
function renderRow(miner) {
const condition = operational(miner);
const row = create("tr", "fleet-row");
row.dataset.status = condition.key;
const identity = create("div", "fleet-stack");
const link = create("a", "object-link", display(miner.asset_id, "No Asset ID"));
link.href = detailLink(miner);
identity.append(link, create("span", "secondary fleet-uuid", miner.id));
addCell(row, "Miner", identity, "fleet-identity");
const status = create("div", "fleet-stack");
const marker = create("span", "fleet-status", condition.label);
marker.dataset.status = condition.key;
status.append(marker, create("span", "secondary", age(miner.observed_at)));
addCell(row, "Operational", status);
addCell(row, "Model / Serial", stack(miner.model, display(miner.miner_sn)));
addCell(row, "Network", stack(miner.current_mac, display(miner.current_ip)));
addCell(row, "Location", locationText(miner), "fleet-location");
addCell(row, "Hashrate", metric(miner.hashrate_ths, " TH/s"));
addCell(row, "Power", metric(miner.power_w, " W"));
addCell(row, "Max chip temp", metric(miner.chip_temp_max_c, " C"));
const repair = miner.active_repair
? words(miner.active_repair.status) + " / " + display(miner.active_repair.reason)
: "No active repair";
addCell(row, "Lifecycle / Repair", stack(words(miner.lifecycle_status), repair));
const actions = create("div", "fleet-actions");
const open = create("a", "command-link", "Open miner");
open.href = detailLink(miner);
actions.appendChild(open);
const mapHref = mapLink(miner);
if (mapHref) {
const map = create("a", "quiet-link", "View on Mine Map");
map.href = mapHref;
actions.appendChild(map);
}
addCell(row, "Actions", actions);
return row;
}
function option(value, label) {
const node = document.createElement("option");
node.value = value;
node.textContent = label;
return node;
}
function syncLocationOptions(selectedSite, selectedRoom) {
if (!canViewLocations || !state.locationOptions) return;
el.fleetSite.replaceChildren(option("", "All sites"));
for (const site of state.locationOptions.sites || []) {
el.fleetSite.appendChild(option(String(site.id), site.name));
}
el.fleetSite.value = Array.from(el.fleetSite.options).some(function (item) {
return item.value === selectedSite;
}) ? selectedSite : "";
el.fleetRoom.replaceChildren(option("", "All rooms"));
for (const room of state.locationOptions.rooms || []) {
if (!el.fleetSite.value || String(room.site_id) === el.fleetSite.value) {
el.fleetRoom.appendChild(option(String(room.id), room.name));
}
}
el.fleetRoom.value = Array.from(el.fleetRoom.options).some(function (item) {
return item.value === selectedRoom;
}) ? selectedRoom : "";
state.siteId = el.fleetSite.value;
state.roomId = el.fleetRoom.value;
}
function readUrl() {
const params = new URLSearchParams(window.location.search);
el.fleetQuery.value = (params.get("query") || "").slice(0, 255);
el.fleetLifecycle.value = params.get("lifecycle_status") || "";
el.fleetModel.value = (params.get("model") || "").slice(0, 255);
el.fleetRuntime.value = params.get("runtime_status") || "";
el.fleetRepair.value = params.get("repair") || "";
el.fleetOperational.value = params.get("operational_state") || "";
el.fleetTags.value = params.getAll("tags").join(", ");
el.fleetProblems.checked = params.get("problems") === "1";
const requestedLimit = Number(params.get("limit"));
state.limit = limits.has(requestedLimit) ? requestedLimit : 100;
state.offset = Math.max(0, Number.parseInt(params.get("offset") || "0", 10) || 0);
el.fleetLimit.value = String(state.limit);
if (canViewLocations) {
state.siteId = params.get("site_id") || "";
state.roomId = params.get("room_id") || "";
syncLocationOptions(state.siteId, state.roomId);
}
}
function requestParams() {
const params = new URLSearchParams();
const values = {
query: el.fleetQuery.value.trim(),
lifecycle_status: el.fleetLifecycle.value,
model: el.fleetModel.value.trim(),
runtime_status: el.fleetRuntime.value,
repair: el.fleetRepair.value,
operational_state: el.fleetOperational.value,
problems: el.fleetProblems.checked ? "1" : "",
site_id: canViewLocations ? state.siteId : "",
room_id: canViewLocations ? state.roomId : "",
limit: String(state.limit), offset: String(state.offset)
};
for (const pair of Object.entries(values)) {
if (pair[1]) params.set(pair[0], pair[1]);
}
for (const tag of el.fleetTags.value.split(",")) {
const clean = tag.trim();
if (clean) params.append("tags", clean);
}
return params;
}
function setUrl(mode) {
const params = requestParams();
window.history[mode === "push" ? "pushState" : "replaceState"](
{}, "", "/assets/miners?" + params.toString()
);
}
async function load() {
showState("loading");
const controller = new AbortController();
const timer = window.setTimeout(function () { controller.abort(); }, 15000);
try {
const response = await fetch("/api/inventory/miners?" + requestParams().toString(), {
credentials: "same-origin", headers: { Accept: "application/json" },
signal: controller.signal
});
if (response.status === 401) {
window.location.replace("/login?next=" + encodeURIComponent(
window.location.pathname + window.location.search
));
return;
}
if (response.status === 403) {
const denied = new Error("Your account cannot view this fleet information.");
denied.permissionDenied = true;
throw denied;
}
if (!response.ok) throw new Error("The fleet service returned " + response.status + ".");
const envelope = await response.json();
if (!envelope || !Array.isArray(envelope.data) || !envelope.meta) {
throw new Error("MinerOps returned an incomplete fleet response.");
}
state.returned = envelope.data.length;
state.locationOptions = envelope.meta.location_options || null;
const selectedSite = canViewLocations ? state.siteId : "";
const selectedRoom = canViewLocations ? state.roomId : "";
syncLocationOptions(selectedSite, selectedRoom);
el.fleetRows.replaceChildren();
for (const miner of envelope.data) el.fleetRows.appendChild(renderRow(miner));
if (!envelope.data.length) {
el.fleetPrevious.disabled = state.offset === 0;
el.fleetNext.disabled = true;
showState("empty", state.offset
? "No miners are present on this page. Return to the previous page."
: "No authorized miners match the current filters.");
return;
}
el.fleetPrevious.disabled = state.offset === 0;
el.fleetNext.disabled = state.returned < state.limit;
const first = state.offset + 1;
const last = state.offset + state.returned;
el.fleetResultSummary.textContent = "Showing miners " + first + "-" + last +
". Total fleet size is not calculated.";
el.fleetFreshness.textContent = state.returned + " miners on this page";
showState("ready");
} catch (error) {
showState(error.permissionDenied ? "denied" : "error",
error && error.name === "AbortError"
? "MinerOps took too long to respond. Retry in a moment."
: display(error && error.message, "The fleet could not be loaded."));
} finally {
window.clearTimeout(timer);
}
}
el.fleetFilters.addEventListener("submit", function (event) {
event.preventDefault();
if (canViewLocations) {
state.siteId = el.fleetSite.value; state.roomId = el.fleetRoom.value;
}
state.offset = 0; setUrl("push"); load();
});
el.fleetReset.addEventListener("click", function () {
el.fleetFilters.reset(); state.offset = 0; state.limit = 100;
state.siteId = ""; state.roomId = "";
syncLocationOptions("", ""); setUrl("push"); load();
});
el.fleetPrevious.addEventListener("click", function () {
state.offset = Math.max(0, state.offset - state.limit); setUrl("push"); load();
});
el.fleetNext.addEventListener("click", function () {
state.offset += state.limit; setUrl("push"); load();
});
el.fleetLimit.addEventListener("change", function () {
state.limit = Number(el.fleetLimit.value); state.offset = 0; setUrl("push"); load();
});
if (canViewLocations) {
el.fleetSite.addEventListener("change", function () {
state.siteId = el.fleetSite.value; state.roomId = "";
syncLocationOptions(state.siteId, "");
});
el.fleetRoom.addEventListener("change", function () {
state.roomId = el.fleetRoom.value;
});
}
window.addEventListener("popstate", function () { readUrl(); load(); });
readUrl();
load();
}());
+67 -23
View File
@@ -222,33 +222,72 @@ def _repairs_content() -> str:
"""
def _miners_content() -> str:
def _miners_content(can_view_locations: bool) -> str:
location_filters = """
<div class="field"><label for="fleetSite">Site</label>
<select id="fleetSite"><option value="">All sites</option></select></div>
<div class="field"><label for="fleetRoom">Room</label>
<select id="fleetRoom"><option value="">All rooms</option></select></div>
""" if can_view_locations else ""
return f"""
<section class="panel" aria-labelledby="minerListTitle">
<section class="panel fleet-panel" aria-labelledby="minerListTitle">
<div class="panel-head">
<div>
<h2 id="minerListTitle">Miner inventory</h2>
<p class="panel-copy">Real inventory records. Search matches the
fields supported by the Inventory API.</p>
</div>
<span class="freshness">Up to 100 results</span>
<div><h2 id="minerListTitle">Fleet</h2>
<p class="panel-copy">Current miner identity, operating state, telemetry, and repair context.</p></div>
<span class="freshness" id="fleetFreshness">Loading current fleet</span>
</div>
<form class="filter-bar" id="minerSearchForm" role="search">
<div class="field">
<label for="minerSearchInput">Search miners</label>
<input id="minerSearchInput" type="search" autocomplete="off"
placeholder="Asset ID, MAC, serial, model, or IP">
<form class="fleet-filters" id="fleetFilters" role="search">
<div class="field fleet-search"><label for="fleetQuery">Search miners</label>
<input id="fleetQuery" type="search" autocomplete="off"
placeholder="Asset ID, MAC, IP, model, or serial"></div>
<div class="field"><label for="fleetLifecycle">Lifecycle</label>
<select id="fleetLifecycle"><option value="">All lifecycle states</option>
<option value="active">Active</option><option value="spare">Spare</option>
<option value="in_repair">In repair</option><option value="retired">Retired</option></select></div>
<div class="field"><label for="fleetModel">Model</label>
<input id="fleetModel" type="text" autocomplete="off" placeholder="Any model"></div>
<div class="field"><label for="fleetRuntime">Runtime</label>
<select id="fleetRuntime"><option value="">All runtime states</option>
<option value="online">Online</option><option value="offline">Offline</option>
<option value="unknown">Unknown</option></select></div>
<div class="field"><label for="fleetRepair">Repair</label>
<select id="fleetRepair"><option value="">Any repair state</option>
<option value="active">Active repair</option><option value="none">No active repair</option></select></div>
<div class="field"><label for="fleetOperational">Operational state</label>
<select id="fleetOperational"><option value="">All operational states</option>
<option value="online">Online</option><option value="offline">Offline</option>
<option value="unstable">Hash unstable</option><option value="repair">In repair</option>
<option value="spare">Spare</option><option value="retired">Retired</option>
<option value="unknown">Unknown</option></select></div>
{location_filters}
<div class="field"><label for="fleetTags">Tags</label>
<input id="fleetTags" type="text" autocomplete="off" placeholder="Comma separated"></div>
<label class="check-control" for="fleetProblems">
<input id="fleetProblems" type="checkbox"> Problems only</label>
<div class="fleet-filter-actions">
<button class="command-button" type="submit">Apply</button>
<button class="quiet-button" id="fleetReset" type="button">Reset</button>
</div>
<button class="command-button" type="submit">Search</button>
</form>
{_state_messages("miners")}
<div class="table-wrap" data-view-state="ready" hidden>
<table>
<thead><tr><th>Asset ID</th><th>Current MAC</th><th>Model</th>
<th>Lifecycle</th><th>Runtime</th><th>Location</th></tr></thead>
<tbody id="minerRows"></tbody>
</table>
<div data-view-state="ready" hidden>
<p class="fleet-result-summary" id="fleetResultSummary" aria-live="polite"></p>
<div class="table-wrap fleet-table-wrap">
<table class="fleet-table">
<thead><tr><th>Miner</th><th>Operational</th><th>Model / Serial</th>
<th>Network</th><th>Location</th><th>Hashrate</th><th>Power</th>
<th>Max chip temp</th><th>Lifecycle / Repair</th><th>Actions</th></tr></thead>
<tbody id="fleetRows"></tbody>
</table>
</div>
</div>
<nav class="fleet-pagination" aria-label="Fleet pages">
<button class="quiet-button" id="fleetPrevious" type="button" disabled>Previous</button>
<label for="fleetLimit">Rows per page</label>
<select id="fleetLimit"><option value="25">25</option><option value="50">50</option>
<option value="100">100</option><option value="250">250</option></select>
<button class="quiet-button" id="fleetNext" type="button" disabled>Next</button>
</nav>
</section>
"""
@@ -297,11 +336,15 @@ def render_app_page(
content = (
_map_content(has_permission("locations.manage"))
if view == "map"
else _miners_content(has_permission("locations.view"))
if view == "miners"
else CONTENT[view]()
)
map_script = (
page_script = (
'<script src="/assets/minerops-mine-map.js" defer></script>'
if view == "map"
else '<script src="/assets/minerops-fleet.js" defer></script>'
if view == "miners"
else ""
)
return f"""<!doctype html>
@@ -313,7 +356,8 @@ def render_app_page(
<link rel="stylesheet" href="/assets/minerops-app.css">
</head>
<body data-view="{escape(view)}"
data-can-view-miners="{str(can_view_miners).lower()}">
data-can-view-miners="{str(can_view_miners).lower()}"
data-can-view-locations="{str(has_permission("locations.view")).lower()}">
<a class="skip-link" href="#mainContent">Skip to main content</a>
<div class="app-shell">
<header class="topbar">
@@ -358,6 +402,6 @@ def render_app_page(
</main>
</div>
<script src="/assets/minerops-app.js" defer></script>
{map_script}
{page_script}
</body>
</html>"""