From b741fd799bbb2cf8662ca37ab677d41d9c16ecb8 Mon Sep 17 00:00:00 2001 From: JC Date: Mon, 10 Aug 2026 17:26:07 +0000 Subject: [PATCH] feat: add internal alpha frontend shell --- backend/app/frontend/__init__.py | 1 + backend/app/frontend/router.py | 77 +++ backend/app/frontend/static/app.css | 755 +++++++++++++++++++++++++++ backend/app/frontend/static/app.js | 348 ++++++++++++ backend/app/frontend/views.py | 301 +++++++++++ backend/app/identity.py | 75 ++- backend/app/main.py | 2 + backend/tests/test_frontend_shell.py | 394 ++++++++++++++ scripts/test-dev-baseline.sh | 10 +- scripts/test-frontend-shell.sh | 34 ++ 10 files changed, 1982 insertions(+), 15 deletions(-) create mode 100644 backend/app/frontend/__init__.py create mode 100644 backend/app/frontend/router.py create mode 100644 backend/app/frontend/static/app.css create mode 100644 backend/app/frontend/static/app.js create mode 100644 backend/app/frontend/views.py create mode 100644 backend/tests/test_frontend_shell.py create mode 100755 scripts/test-frontend-shell.sh diff --git a/backend/app/frontend/__init__.py b/backend/app/frontend/__init__.py new file mode 100644 index 0000000..ca6f16c --- /dev/null +++ b/backend/app/frontend/__init__.py @@ -0,0 +1 @@ +"""Server-rendered frontend foundation for MinerOps.""" diff --git a/backend/app/frontend/router.py b/backend/app/frontend/router.py new file mode 100644 index 0000000..070d797 --- /dev/null +++ b/backend/app/frontend/router.py @@ -0,0 +1,77 @@ +"""HTTP routes for the MinerOps Internal Alpha application shell.""" + +from pathlib import Path +from typing import Annotated + +from fastapi import APIRouter, Depends +from fastapi.responses import HTMLResponse, Response + +from app.frontend.views import render_app_page +from app.identity import require_permission, user_has_permission + + +router = APIRouter(tags=["frontend"]) +STATIC_ROOT = Path(__file__).with_name("static") + +DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))] +MapUser = Annotated[dict, Depends(require_permission("locations.view"))] +RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))] +MinerUser = Annotated[dict, Depends(require_permission("miners.view"))] + + +def render_page(view: str, current_user: dict) -> str: + return render_app_page( + view, + current_user, + lambda permission: user_has_permission(current_user, permission), + ) + + +@router.get("/assets/minerops-app.css", include_in_schema=False) +def frontend_styles() -> Response: + return Response( + (STATIC_ROOT / "app.css").read_text(encoding="utf-8"), + media_type="text/css", + headers={"Cache-Control": "no-store"}, + ) + + +@router.get("/assets/minerops-app.js", include_in_schema=False) +def frontend_script() -> Response: + return Response( + (STATIC_ROOT / "app.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) + + +@router.get( + "/monitoring/map", + response_class=HTMLResponse, + include_in_schema=False, +) +def mine_map_page(current_user: MapUser) -> str: + return render_page("map", current_user) + + +@router.get( + "/operations/repairs", + response_class=HTMLResponse, + include_in_schema=False, +) +def repair_center_page(current_user: RepairUser) -> str: + return render_page("repairs", current_user) + + +@router.get( + "/assets/miners", + response_class=HTMLResponse, + include_in_schema=False, +) +def miners_page(current_user: MinerUser) -> str: + return render_page("miners", current_user) diff --git a/backend/app/frontend/static/app.css b/backend/app/frontend/static/app.css new file mode 100644 index 0000000..88b10d6 --- /dev/null +++ b/backend/app/frontend/static/app.css @@ -0,0 +1,755 @@ +:root { + color-scheme: light; + font-family: Inter, "Segoe UI", Arial, sans-serif; + font-synthesis: none; + --ink: #17202a; + --muted: #5a6773; + --line: #ccd4dc; + --surface: #ffffff; + --canvas: #f3f5f7; + --nav: #0f172a; + --accent: #f97316; + --focus: #1677b8; + --good: #177245; + --warn: #9a5b00; + --danger: #b42318; +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + background: var(--canvas); +} + +body { + margin: 0; + color: var(--ink); + background: var(--canvas); +} + +button, +input, +select { + font: inherit; +} + +a { + color: inherit; +} + +.skip-link { + position: fixed; + left: 12px; + top: -60px; + z-index: 100; + padding: 10px 14px; + background: var(--surface); + border: 2px solid var(--focus); +} + +.skip-link:focus { + top: 12px; +} + +:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 3px; +} + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 224px minmax(0, 1fr); + grid-template-rows: 68px minmax(0, 1fr); +} + +.topbar { + grid-column: 1 / -1; + display: grid; + grid-template-columns: 224px minmax(240px, 620px) 1fr; + align-items: center; + gap: 20px; + min-height: 68px; + padding: 10px 18px; + color: #ffffff; + background: var(--nav); + border-bottom: 3px solid var(--accent); +} + +.brand-area { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.brand { + display: block; + min-width: 0; + text-decoration: none; +} + +.brand-name { + display: block; + font-size: 20px; + font-weight: 800; +} + +.brand-tagline { + display: block; + margin-top: 2px; + color: #d3dae2; + font-size: 11px; +} + +.global-search { + position: relative; +} + +.global-search label { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; +} + +.global-search input { + width: 100%; + min-height: 42px; + padding: 9px 14px; + border: 1px solid #647385; + border-radius: 5px; + color: var(--ink); + background: #ffffff; +} + +.session-tools { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 10px; + min-width: 0; +} + +.session-copy { + min-width: 0; + text-align: right; +} + +.session-user, +.session-role { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-user { + font-size: 13px; + font-weight: 750; +} + +.session-role { + color: #d3dae2; + font-size: 11px; +} + +.quiet-button, +.menu-button { + min-height: 40px; + border: 1px solid #8390a0; + border-radius: 5px; + padding: 8px 11px; + color: #ffffff; + background: transparent; + cursor: pointer; +} + +.menu-button { + display: none; +} + +.primary-nav { + grid-column: 1; + grid-row: 2; + padding: 20px 12px; + color: #eef2f6; + background: #17212d; +} + +.primary-nav ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 5px; +} + +.primary-nav a { + display: block; + min-height: 42px; + padding: 11px 12px; + border-left: 3px solid transparent; + text-decoration: none; + font-size: 14px; + font-weight: 650; +} + +.primary-nav a:hover { + background: #263343; +} + +.primary-nav a[aria-current="page"] { + border-color: var(--accent); + background: #2c3948; +} + +.nav-note { + margin: 22px 12px 0; + color: #aeb9c5; + font-size: 12px; + line-height: 1.45; +} + +.main-content { + grid-column: 2; + grid-row: 2; + min-width: 0; + padding: 26px clamp(16px, 3vw, 42px) 48px; +} + +.page-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 22px; +} + +.eyebrow { + margin: 0 0 5px; + color: var(--muted); + font-size: 12px; + font-weight: 750; + text-transform: uppercase; +} + +h1 { + margin: 0; + font-size: 32px; + letter-spacing: 0; +} + +.page-summary { + max-width: 760px; + margin: 8px 0 0; + color: var(--muted); + line-height: 1.5; +} + +.freshness { + margin: 4px 0 0; + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +.metrics { + display: grid; + grid-template-columns: repeat(5, minmax(130px, 1fr)); + gap: 12px; + margin-bottom: 24px; +} + +.metric, +.panel, +.map-inspector { + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface); +} + +.metric { + min-height: 122px; + padding: 15px; +} + +.metric-label { + display: block; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.metric-value { + display: block; + margin: 14px 0 6px; + font-size: 25px; + font-weight: 800; +} + +.metric-state { + display: block; + color: var(--warn); + font-size: 12px; +} + +.content-grid { + display: grid; + grid-template-columns: minmax(0, 1.7fr) minmax(260px, .7fr); + gap: 18px; +} + +.panel { + padding: 18px; + min-width: 0; +} + +.panel h2 { + margin: 0; + font-size: 18px; +} + +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 15px; +} + +.panel-copy { + margin: 7px 0 0; + color: var(--muted); + font-size: 13px; + line-height: 1.45; +} + +.state-message { + padding: 26px 16px; + border: 1px dashed #aeb8c2; + text-align: center; + color: var(--muted); +} + +.state-message strong { + display: block; + margin-bottom: 6px; + color: var(--ink); +} + +.state-message[data-kind="error"] { + border-color: #d4a09b; + color: var(--danger); +} + +.state-message[hidden] { + display: none; +} + +.table-wrap { + width: 100%; + overflow-x: auto; + border: 1px solid var(--line); +} + +table { + width: 100%; + border-collapse: collapse; + background: var(--surface); +} + +th, +td { + padding: 11px 12px; + border-bottom: 1px solid #e3e8ed; + text-align: left; + vertical-align: top; +} + +th { + color: #40505f; + background: #f7f8fa; + font-size: 12px; +} + +td { + font-size: 13px; +} + +tbody tr:last-child td { + border-bottom: 0; +} + +.object-link { + color: #075b91; + font-weight: 750; +} + +.secondary { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 11px; +} + +.status-text { + font-weight: 750; +} + +.status-text::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + margin-right: 7px; + border-radius: 50%; + background: #758493; +} + +.status-text[data-status="active"]::before, +.status-text[data-status="online"]::before { + background: var(--good); +} + +.status-text[data-status="in_repair"]::before, +.status-text[data-status="repair"]::before { + background: var(--warn); +} + +.status-text[data-status="critical"]::before { + background: var(--danger); +} + +.filter-bar { + display: flex; + align-items: end; + gap: 10px; + margin-bottom: 14px; +} + +.field { + display: grid; + gap: 6px; + min-width: min(100%, 320px); +} + +.field label { + color: #40505f; + font-size: 12px; + font-weight: 750; +} + +.field input, +.field select { + min-height: 42px; + border: 1px solid #9da9b5; + border-radius: 5px; + padding: 8px 10px; + background: #ffffff; +} + +.command-button { + min-height: 42px; + border: 1px solid #1b5f89; + border-radius: 5px; + padding: 8px 15px; + color: #ffffff; + background: #176b9a; + font-weight: 750; + cursor: pointer; +} + +.command-button:disabled { + cursor: not-allowed; + opacity: .58; +} + +.map-layout { + display: grid; + grid-template-columns: 210px minmax(340px, 1fr) 280px; + min-height: 520px; + border: 1px solid var(--line); + background: var(--surface); +} + +.map-controls, +.map-inspector { + padding: 16px; +} + +.map-controls { + border-right: 1px solid var(--line); +} + +.map-controls h2, +.map-inspector h2 { + margin: 0 0 14px; + font-size: 16px; +} + +.hierarchy { + margin: 0 0 20px; + padding-left: 18px; + color: #40505f; + line-height: 1.8; +} + +.control-stack { + display: grid; + gap: 12px; +} + +.map-viewport { + display: grid; + place-items: center; + min-height: 420px; + padding: 28px; + background: #e8edf1; + box-shadow: inset 0 0 0 10px #dde4ea; +} + +.map-empty { + max-width: 460px; + padding: 22px; + border: 1px solid #9da9b5; + background: #ffffff; + text-align: center; +} + +.map-empty strong { + display: block; + margin-bottom: 8px; +} + +.map-inspector { + border: 0; + border-left: 1px solid var(--line); + border-radius: 0; +} + +.definition-list { + display: grid; + grid-template-columns: auto 1fr; + gap: 10px 14px; + margin: 0; + font-size: 13px; +} + +.definition-list dt { + color: var(--muted); +} + +.definition-list dd { + margin: 0; + font-weight: 650; +} + +.notice { + padding: 12px 14px; + border-left: 4px solid var(--warn); + background: #fff8e8; + color: #694100; + font-size: 13px; + line-height: 1.45; +} + +@media (max-width: 1120px) { + .metrics { + grid-template-columns: repeat(3, minmax(140px, 1fr)); + } + + .map-layout { + grid-template-columns: 190px minmax(320px, 1fr); + } + + .map-inspector { + grid-column: 1 / -1; + border-top: 1px solid var(--line); + border-left: 0; + } +} + +@media (max-width: 820px) { + .app-shell { + grid-template-columns: 1fr; + grid-template-rows: auto auto minmax(0, 1fr); + } + + .topbar { + position: sticky; + top: 0; + z-index: 30; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + } + + .brand-tagline, + .session-copy { + display: none; + } + + .menu-button { + display: inline-block; + } + + .global-search { + grid-column: 1 / -1; + grid-row: 2; + } + + .primary-nav { + display: none; + grid-column: 1; + grid-row: 2; + padding: 10px 12px; + } + + .primary-nav[data-open="true"] { + display: block; + } + + .primary-nav ul { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .primary-nav a { + min-height: 46px; + padding: 12px 8px; + text-align: center; + } + + .nav-note { + display: none; + } + + .main-content { + grid-column: 1; + grid-row: 3; + padding-top: 20px; + } + + .metrics { + grid-template-columns: repeat(2, minmax(130px, 1fr)); + } + + .content-grid { + grid-template-columns: 1fr; + } + + .map-layout { + grid-template-columns: 1fr; + } + + .map-controls, + .map-inspector { + border: 0; + border-bottom: 1px solid var(--line); + } +} + +@media (max-width: 560px) { + .topbar { + padding: 9px 10px; + } + + .brand-name { + font-size: 18px; + } + + .quiet-button { + padding: 7px 9px; + } + + .main-content { + padding: 18px 12px 36px; + } + + .page-head { + display: block; + } + + .freshness { + margin-top: 10px; + white-space: normal; + } + + .metrics { + grid-template-columns: 1fr; + } + + .metric { + min-height: 100px; + } + + .primary-nav ul { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .filter-bar { + align-items: stretch; + flex-direction: column; + } + + .field { + min-width: 0; + width: 100%; + } + + .command-button { + width: 100%; + } + + .table-wrap { + border: 0; + overflow: visible; + } + + table, + thead, + tbody, + tr, + th, + td { + display: block; + } + + thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + } + + tbody { + display: grid; + gap: 10px; + } + + tr { + border: 1px solid var(--line); + background: var(--surface); + } + + td { + display: grid; + grid-template-columns: minmax(90px, .45fr) 1fr; + gap: 10px; + border-bottom: 1px solid #e3e8ed; + } + + td::before { + content: attr(data-label); + color: var(--muted); + font-size: 11px; + font-weight: 750; + } + + .map-viewport { + min-height: 340px; + padding: 14px; + } +} diff --git a/backend/app/frontend/static/app.js b/backend/app/frontend/static/app.js new file mode 100644 index 0000000..d9aac49 --- /dev/null +++ b/backend/app/frontend/static/app.js @@ -0,0 +1,348 @@ +(function () { + "use strict"; + + const view = document.body.dataset.view || ""; + const canViewMiners = + document.body.dataset.canViewMiners === "true"; + + function text(value, fallback) { + if (value === null || value === undefined || String(value).trim() === "") { + return fallback || "Not available"; + } + return String(value); + } + + function statusName(value) { + return text(value, "unknown").toLowerCase().replaceAll(" ", "_"); + } + + function create(tag, options) { + const element = document.createElement(tag); + const values = options || {}; + if (values.className) element.className = values.className; + if (values.text !== undefined) element.textContent = values.text; + if (values.href) element.href = values.href; + return element; + } + + function showState(name, message) { + for (const state of document.querySelectorAll("[data-view-state]")) { + state.hidden = state.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; + } + + async function api(path) { + const controller = new AbortController(); + const timeout = window.setTimeout(function () { + controller.abort(); + }, 15000); + let response; + try { + response = await fetch(path, { + credentials: "same-origin", + headers: { Accept: "application/json" }, + signal: controller.signal + }); + } catch (error) { + if (error && error.name === "AbortError") { + throw new Error( + "MinerOps took too long to respond. Retry in a moment." + ); + } + throw new Error( + "MinerOps could not reach the backend. Check the connection and retry." + ); + } finally { + window.clearTimeout(timeout); + } + if (response.status === 401) { + window.location.replace( + "/login?next=" + + encodeURIComponent(window.location.pathname + window.location.search) + ); + throw new Error("Your session expired. Sign in again to continue."); + } + if (response.status === 403) { + const denied = new Error( + "Your account does not have permission to view this information." + ); + denied.permissionDenied = true; + throw denied; + } + if (!response.ok) { + throw new Error( + "MinerOps could not load this information. Retry in a moment." + ); + } + try { + return await response.json(); + } catch (error) { + throw new Error( + "MinerOps returned an invalid response. Retry in a moment." + ); + } + } + + function setupShell() { + const menu = document.getElementById("menuButton"); + const nav = document.getElementById("primaryNav"); + if (menu && nav) { + menu.addEventListener("click", function () { + const open = nav.dataset.open !== "true"; + nav.dataset.open = String(open); + menu.setAttribute("aria-expanded", String(open)); + }); + } + + const search = document.getElementById("globalSearchForm"); + const searchInput = document.getElementById("globalSearchInput"); + if (search && searchInput) { + search.addEventListener("submit", function (event) { + event.preventDefault(); + const query = searchInput.value.trim(); + const target = query + ? "/assets/miners?query=" + encodeURIComponent(query) + : "/assets/miners"; + window.location.assign(target); + }); + } + + const logout = document.getElementById("logoutButton"); + if (logout) { + logout.addEventListener("click", async function () { + logout.disabled = true; + try { + await fetch("/api/auth/logout", { + method: "POST", + credentials: "same-origin" + }); + } finally { + window.location.replace("/login"); + } + }); + } + } + + function addCell(row, label, content) { + const cell = create("td"); + cell.dataset.label = label; + if (content instanceof Node) cell.appendChild(content); + else cell.textContent = text(content); + row.appendChild(cell); + } + + function status(value) { + const label = create("span", { + className: "status-text", + text: text(value, "Unknown").replaceAll("_", " ") + }); + label.dataset.status = statusName(value); + return label; + } + + function dateTime(value) { + if (!value) return "Not available"; + const parsed = new Date(value); + return Number.isNaN(parsed.valueOf()) + ? String(value) + : parsed.toLocaleString(); + } + + async function loadRepairs() { + const rows = document.getElementById("repairRows"); + if (!rows) return; + showState("loading"); + try { + const envelope = await api("/api/repairs/queue"); + if ( + !envelope || + !envelope.data || + typeof envelope.data !== "object" || + !Array.isArray(envelope.data.items) + ) { + throw new Error( + "MinerOps returned an invalid repair response." + ); + } + const items = envelope.data.items; + if (!items.every(function (item) { + return item && + typeof item === "object" && + typeof item.miner_id === "string" && + item.miner_id.trim() !== ""; + })) { + throw new Error( + "MinerOps returned an incomplete repair response." + ); + } + rows.replaceChildren(); + if (!items.length) { + showState("empty"); + return; + } + for (const repair of items) { + const row = create("tr"); + const identity = create("div"); + const minerLabel = text( + repair.current_mac, + text(repair.miner_id, "Unknown miner") + ); + if (canViewMiners) { + identity.appendChild(create("a", { + className: "object-link", + text: minerLabel, + href: "/miners/" + + encodeURIComponent(repair.miner_id) + + "/detail" + })); + } else { + identity.appendChild(create("strong", { + text: minerLabel + })); + } + identity.appendChild(create("span", { + className: "secondary", + text: text(repair.current_ip, "No current IP") + })); + addCell(row, "Miner", identity); + addCell(row, "Model", repair.model); + addCell( + row, + "Repair state", + status(repair.latest_event_status || repair.asset_state) + ); + addCell( + row, + "Reported issue", + repair.reported_error || + repair.latest_event_title || + "No reported issue" + ); + addCell( + row, + "Last update", + dateTime(repair.latest_event_at || repair.state_updated_at) + ); + rows.appendChild(row); + } + showState("ready"); + } catch (error) { + showState( + error.permissionDenied ? "denied" : "error", + error.message + ); + } + } + + 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(); +})(); diff --git a/backend/app/frontend/views.py b/backend/app/frontend/views.py new file mode 100644 index 0000000..42ef0a6 --- /dev/null +++ b/backend/app/frontend/views.py @@ -0,0 +1,301 @@ +"""Shared semantic HTML for the MinerOps Internal Alpha shell.""" + +from html import escape +from typing import Callable + + +NAVIGATION = ( + ("dashboard", "Dashboard", "/dashboard", "dashboard.view"), + ("map", "Mine Map", "/monitoring/map", "locations.view"), + ("repairs", "Repair Center", "/operations/repairs", "repairs.view"), + ("miners", "Miners", "/assets/miners", "miners.view"), +) + +PAGE_DETAILS = { + "dashboard": ( + "Fleet overview", + "Dashboard", + "A concise starting point for operational state and recent activity.", + ), + "map": ( + "Monitoring / Spatial operations", + "Mine Map", + "Inspect where miners and operational conditions are concentrated.", + ), + "repairs": ( + "Operations", + "Repair Center", + "Review miners currently represented in the backend repair queue.", + ), + "miners": ( + "Assets", + "Miners", + "Search durable miner inventory and inspect current identity and state.", + ), +} + + +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( + "
" + f"{escape(label)}" + f"{escape(value)}" + f"{escape(state)}" + "
" + for label, value, state in metrics + ) + return f""" +
+ {metric_html} +
+
+
+
+
+

Recent activity

+

Operational events will appear here when + the dashboard activity feed is connected.

+
+
+
+ No activity feed connected + No events are fabricated for this placeholder. +
+
+ +
+""" + + +def _map_content() -> str: + return """ +
Live layout and telemetry data are not +connected to this Internal Alpha canvas. No production state is represented +below.
+
+ +
+
+ Operational canvas + Site, room, rack, miner, and status overlays will render here + when authoritative layout and telemetry sources are connected. +
+
+ +
+""" + + +def _state_messages(noun: str) -> str: + return f""" +
+ Loading {noun} + Requesting current information from MinerOps. +
+ + + +""" + + +def _repairs_content() -> str: + return f""" +
+
+
+

Current repair queue

+

Real records from the existing repair queue + API. Open a miner to continue in its current detail view.

+
+ Loaded on request +
+ {_state_messages("repair records")} + +
+""" + + +def _miners_content() -> str: + return f""" +
+
+
+

Miner inventory

+

Real inventory records. Search matches the + fields supported by the Inventory API.

+
+ Up to 100 results +
+ + {_state_messages("miners")} + +
+""" + + +CONTENT = { + "dashboard": _dashboard_content, + "map": _map_content, + "repairs": _repairs_content, + "miners": _miners_content, +} + + +def render_app_page( + view: str, + current_user: dict, + has_permission: Callable[[str], bool], +) -> str: + context, title, summary = PAGE_DETAILS[view] + display_name = escape( + str( + current_user.get("display_name") + or current_user.get("username") + or "MinerOps user" + ) + ) + role = escape(str(current_user.get("role") or "Authenticated user")) + can_view_miners = has_permission("miners.view") + navigation = "".join( + ( + f'
  • {escape(label)}
  • ' + if key == view + else f'
  • {escape(label)}
  • ' + ) + for key, label, href, permission in NAVIGATION + if has_permission(permission) + ) + global_search = ( + '' + if can_view_miners + else '' + ) + content = CONTENT[view]() + return f""" + + + + + {escape(title)} | MinerOps + + + + +
    +
    + + {global_search} +
    + +
    + {display_name} + {role} +
    + +
    +
    + +
    +
    +
    +

    {escape(context)}

    +

    {escape(title)}

    +

    {escape(summary)}

    +
    +

    Current view: {escape(title)}

    +
    + {content} +
    +
    + + +""" diff --git a/backend/app/identity.py b/backend/app/identity.py index c22edae..c888b19 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta, timezone from html import escape from typing import Any, Callable -from urllib.parse import quote +from urllib.parse import quote, urlsplit import jwt import psycopg @@ -633,20 +633,60 @@ def reset_password( def protected_page(path: str) -> bool: - exact = {"/dashboard", "/repairs", "/locations", "/system-health", "/accounts"} + exact = { + "/dashboard", + "/repairs", + "/locations", + "/system-health", + "/accounts", + "/monitoring/map", + "/operations/repairs", + "/assets/miners", + } prefixes = ("/miners/", "/agent/", "/tools/") return path in exact or path.startswith(prefixes) +def safe_next_path(value: str | None) -> str: + """Return a safe local application destination for login redirects.""" + fallback = "/dashboard" + if not value or value != value.strip(): + return fallback + if any(ord(character) < 32 or ord(character) == 127 for character in value): + return fallback + + try: + target = urlsplit(value) + except ValueError: + return fallback + + path = target.path + if ( + target.scheme + or target.netloc + or target.fragment + or not path.startswith("/") + or path.startswith("//") + or "\\" in path + or "%" in path + or any(segment in {".", ".."} for segment in path.split("/")) + or not protected_page(path) + ): + return fallback + return value + + def required_page_permission(path: str) -> str: if path == "/accounts": return "users.manage" if path == "/system-health": return "system_health.view" - if path == "/repairs": + if path in {"/repairs", "/operations/repairs"}: return "repairs.view" - if path == "/locations": + if path in {"/locations", "/monitoring/map"}: return "locations.view" + if path == "/assets/miners": + return "miners.view" if path.startswith("/agent/"): return "agents.view" if path.startswith("/miners/"): @@ -781,16 +821,19 @@ def install_identity_middleware(app) -> None: if path == "/login": user = request_user_or_none(request) if user: - next_path = request.query_params.get("next") or "/dashboard" - if not next_path.startswith("/") or next_path.startswith("//"): - next_path = "/dashboard" - return RedirectResponse(next_path, status_code=303) + return RedirectResponse( + safe_next_path(request.query_params.get("next")), + status_code=303, + ) return await call_next(request) if protected_page(path): user = request_user_or_none(request) if not user: - destination = "/login?next=" + quote(path, safe="/") + requested_path = path + if request.url.query: + requested_path += "?" + request.url.query + destination = "/login?next=" + quote(requested_path, safe="") response = RedirectResponse(destination, status_code=303) clear_session_cookie(response, request) return response @@ -853,7 +896,8 @@ button:disabled { opacity: .6; cursor: wait; }
    MinerOps
    -