feat: add internal alpha frontend shell

This commit is contained in:
lussierj committed 2026-08-10 17:26:07 +00:00
1 parent eeb3c7634e
commit b741fd799b
10 files changed
+1982 -15

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Server-rendered frontend foundation for MinerOps."""
+77
View File
@@ -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)
+755
View File
@@ -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;
}
}
+348
View File
@@ -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();
})();
+301
View File
@@ -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(
"<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
)
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>
"""
def _map_content() -> str:
return """
<div class="notice" role="status">Live layout and telemetry data are not
connected to this Internal Alpha canvas. No production state is represented
below.</div>
<section class="map-layout" aria-label="Mine Map placeholder">
<aside class="map-controls" aria-labelledby="mapHierarchyTitle">
<h2 id="mapHierarchyTitle">Location hierarchy</h2>
<ul class="hierarchy">
<li>Site</li>
<li>Room</li>
<li>Rack</li>
<li>Shelf / position</li>
</ul>
<div class="control-stack">
<div class="field">
<label for="statusLayer">Status layer</label>
<select id="statusLayer" disabled>
<option>Live status unavailable</option>
</select>
</div>
<div class="field">
<label for="mapFilter">Filters</label>
<input id="mapFilter" type="search"
placeholder="Filters not connected" disabled>
</div>
</div>
</aside>
<div class="map-viewport" aria-label="Operational map viewport">
<div class="map-empty">
<strong>Operational canvas</strong>
<span>Site, room, rack, miner, and status overlays will render here
when authoritative layout and telemetry sources are connected.</span>
</div>
</div>
<aside class="map-inspector" aria-labelledby="selectionTitle">
<h2 id="selectionTitle">Selection details</h2>
<dl class="definition-list">
<dt>Selection</dt><dd>None</dd>
<dt>Status</dt><dd>Not connected</dd>
<dt>Freshness</dt><dd>Unavailable</dd>
</dl>
</aside>
</section>
"""
def _state_messages(noun: str) -> str:
return f"""
<div class="state-message" data-view-state="loading" role="status">
<strong>Loading {noun}</strong>
<span data-state-detail>Requesting current information from MinerOps.</span>
</div>
<div class="state-message" data-view-state="empty" hidden>
<strong>No {noun} found</strong>
<span data-state-detail>There is no information to show in this view.</span>
</div>
<div class="state-message" data-view-state="denied" data-kind="error" hidden>
<strong>Access unavailable</strong>
<span data-state-detail>Your account cannot view this information.</span>
</div>
<div class="state-message" data-view-state="error" data-kind="error" hidden>
<strong>Could not load {noun}</strong>
<span data-state-detail>Retry after checking the MinerOps connection.</span>
</div>
"""
def _repairs_content() -> str:
return f"""
<section class="panel" aria-labelledby="repairQueueTitle">
<div class="panel-head">
<div>
<h2 id="repairQueueTitle">Current repair queue</h2>
<p class="panel-copy">Real records from the existing repair queue
API. Open a miner to continue in its current detail view.</p>
</div>
<span class="freshness">Loaded on request</span>
</div>
{_state_messages("repair records")}
<div class="table-wrap" data-view-state="ready" hidden>
<table>
<thead><tr><th>Miner</th><th>Model</th><th>Repair state</th>
<th>Reported issue</th><th>Last update</th></tr></thead>
<tbody id="repairRows"></tbody>
</table>
</div>
</section>
"""
def _miners_content() -> str:
return f"""
<section class="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>
<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">
</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>
</section>
"""
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'<li><a href="{href}" aria-current="page">{escape(label)}</a></li>'
if key == view
else f'<li><a href="{href}">{escape(label)}</a></li>'
)
for key, label, href, permission in NAVIGATION
if has_permission(permission)
)
global_search = (
'<form class="global-search" id="globalSearchForm" role="search">'
'<label for="globalSearchInput">Search miners</label>'
'<input id="globalSearchInput" type="search" autocomplete="off" '
'placeholder="Search miners (more sources planned)">'
'</form>'
if can_view_miners
else '<div class="global-search-unavailable" aria-hidden="true"></div>'
)
content = CONTENT[view]()
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(title)} | MinerOps</title>
<link rel="stylesheet" href="/assets/minerops-app.css">
</head>
<body data-view="{escape(view)}"
data-can-view-miners="{str(can_view_miners).lower()}">
<a class="skip-link" href="#mainContent">Skip to main content</a>
<div class="app-shell">
<header class="topbar">
<div class="brand-area">
<button class="menu-button" id="menuButton" type="button"
aria-controls="primaryNav" aria-expanded="false">Menu</button>
<a class="brand" href="/dashboard">
<span class="brand-name">MinerOps</span>
<span class="brand-tagline">Operational Intelligence for Mining</span>
</a>
</div>
{global_search}
<div class="session-tools">
<button class="quiet-button" type="button"
aria-label="Alerts are not yet connected"
title="Alerts are not yet connected"
disabled>Alerts: unavailable</button>
<div class="session-copy">
<span class="session-user">{display_name}</span>
<span class="session-role">{role}</span>
</div>
<button class="quiet-button" id="logoutButton"
type="button">Sign out</button>
</div>
</header>
<nav class="primary-nav" id="primaryNav"
aria-label="Primary navigation" data-open="false">
<ul>{navigation}</ul>
<p class="nav-note">Internal Alpha navigation. Additional
destinations appear only as working, authorized views.</p>
</nav>
<main class="main-content" id="mainContent" tabindex="-1">
<header class="page-head">
<div>
<p class="eyebrow">{escape(context)}</p>
<h1>{escape(title)}</h1>
<p class="page-summary">{escape(summary)}</p>
</div>
<p class="freshness">Current view: {escape(title)}</p>
</header>
{content}
</main>
</div>
<script src="/assets/minerops-app.js" defer></script>
</body>
</html>"""
+61 -14
View File
@@ -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; }
<body>
<header><div class="brand">MinerOps</div></header>
<main>
<section class="login-panel" id="panel">
<section class="login-panel" id="panel"
data-next="__MINEROPS_SAFE_NEXT_PATH__">
<div class="panel-head"><h1 id="title">Sign in</h1><p class="subtitle" id="subtitle">Use your MinerOps account to continue.</p></div>
<form id="authForm">
<div class="field setup-only"><label for="displayName">Display name</label><input id="displayName" autocomplete="name" maxlength="120"></div>
@@ -875,8 +919,7 @@ button:disabled { opacity: .6; cursor: wait; }
let setupRequired = false;
function destination() {
const value = new URLSearchParams(location.search).get("next") || "/dashboard";
return value.startsWith("/") && !value.startsWith("//") ? value : "/dashboard";
return panel.dataset.next || "/dashboard";
}
function sessionHint() {
@@ -1063,8 +1106,12 @@ dialog::backdrop { background: rgba(18, 27, 35, .48); }
@router.get("/login", response_class=HTMLResponse)
def login_page():
return LOGIN_HTML
def login_page(request: Request):
next_path = escape(
safe_next_path(request.query_params.get("next")),
quote=True,
)
return LOGIN_HTML.replace("__MINEROPS_SAFE_NEXT_PATH__", next_path)
@router.get("/accounts", response_class=HTMLResponse)
+2
View File
@@ -18,6 +18,7 @@ from app.identity import (
install_identity_middleware,
router as identity_router,
)
from app.frontend.router import router as frontend_router
from app.inventory.router import router as inventory_router
from app.components.router import router as components_router
from app.repairs.exceptions import (
@@ -41,6 +42,7 @@ from app.repairs.router import (
app = FastAPI(title=settings.app_name)
app.include_router(identity_router)
app.include_router(frontend_router)
app.include_router(inventory_router)
app.include_router(components_router)
install_identity_middleware(app)