{escape(context)}
+{escape(title)}
+{escape(summary)}
+Current view: {escape(title)}
+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(
+ "
Operational events will appear here when + the dashboard activity feed is connected.
+Real records from the existing repair queue + API. Open a miner to continue in its current detail view.
+| Miner | Model | Repair state | +Reported issue | Last update |
|---|
Real inventory records. Search matches the + fields supported by the Inventory API.
+| Asset ID | Current MAC | Model | +Lifecycle | Runtime | Location |
|---|
{escape(context)}
+{escape(summary)}
+Current view: {escape(title)}
+Use your MinerOps account to continue.