feat: add internal alpha frontend shell
This commit is contained in:
1 parent
eeb3c7634e
commit
b741fd799b
10 files changed
+1982
-15
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
"""Server-rendered frontend foundation for MinerOps."""
|
||||||
@@ -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)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from html import escape
|
from html import escape
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote, urlsplit
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import psycopg
|
import psycopg
|
||||||
@@ -633,20 +633,60 @@ def reset_password(
|
|||||||
|
|
||||||
|
|
||||||
def protected_page(path: str) -> bool:
|
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/")
|
prefixes = ("/miners/", "/agent/", "/tools/")
|
||||||
return path in exact or path.startswith(prefixes)
|
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:
|
def required_page_permission(path: str) -> str:
|
||||||
if path == "/accounts":
|
if path == "/accounts":
|
||||||
return "users.manage"
|
return "users.manage"
|
||||||
if path == "/system-health":
|
if path == "/system-health":
|
||||||
return "system_health.view"
|
return "system_health.view"
|
||||||
if path == "/repairs":
|
if path in {"/repairs", "/operations/repairs"}:
|
||||||
return "repairs.view"
|
return "repairs.view"
|
||||||
if path == "/locations":
|
if path in {"/locations", "/monitoring/map"}:
|
||||||
return "locations.view"
|
return "locations.view"
|
||||||
|
if path == "/assets/miners":
|
||||||
|
return "miners.view"
|
||||||
if path.startswith("/agent/"):
|
if path.startswith("/agent/"):
|
||||||
return "agents.view"
|
return "agents.view"
|
||||||
if path.startswith("/miners/"):
|
if path.startswith("/miners/"):
|
||||||
@@ -781,16 +821,19 @@ def install_identity_middleware(app) -> None:
|
|||||||
if path == "/login":
|
if path == "/login":
|
||||||
user = request_user_or_none(request)
|
user = request_user_or_none(request)
|
||||||
if user:
|
if user:
|
||||||
next_path = request.query_params.get("next") or "/dashboard"
|
return RedirectResponse(
|
||||||
if not next_path.startswith("/") or next_path.startswith("//"):
|
safe_next_path(request.query_params.get("next")),
|
||||||
next_path = "/dashboard"
|
status_code=303,
|
||||||
return RedirectResponse(next_path, status_code=303)
|
)
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
if protected_page(path):
|
if protected_page(path):
|
||||||
user = request_user_or_none(request)
|
user = request_user_or_none(request)
|
||||||
if not user:
|
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)
|
response = RedirectResponse(destination, status_code=303)
|
||||||
clear_session_cookie(response, request)
|
clear_session_cookie(response, request)
|
||||||
return response
|
return response
|
||||||
@@ -853,7 +896,8 @@ button:disabled { opacity: .6; cursor: wait; }
|
|||||||
<body>
|
<body>
|
||||||
<header><div class="brand">MinerOps</div></header>
|
<header><div class="brand">MinerOps</div></header>
|
||||||
<main>
|
<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>
|
<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">
|
<form id="authForm">
|
||||||
<div class="field setup-only"><label for="displayName">Display name</label><input id="displayName" autocomplete="name" maxlength="120"></div>
|
<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;
|
let setupRequired = false;
|
||||||
|
|
||||||
function destination() {
|
function destination() {
|
||||||
const value = new URLSearchParams(location.search).get("next") || "/dashboard";
|
return panel.dataset.next || "/dashboard";
|
||||||
return value.startsWith("/") && !value.startsWith("//") ? value : "/dashboard";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionHint() {
|
function sessionHint() {
|
||||||
@@ -1063,8 +1106,12 @@ dialog::backdrop { background: rgba(18, 27, 35, .48); }
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/login", response_class=HTMLResponse)
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
def login_page():
|
def login_page(request: Request):
|
||||||
return LOGIN_HTML
|
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)
|
@router.get("/accounts", response_class=HTMLResponse)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.identity import (
|
|||||||
install_identity_middleware,
|
install_identity_middleware,
|
||||||
router as identity_router,
|
router as identity_router,
|
||||||
)
|
)
|
||||||
|
from app.frontend.router import router as frontend_router
|
||||||
from app.inventory.router import router as inventory_router
|
from app.inventory.router import router as inventory_router
|
||||||
from app.components.router import router as components_router
|
from app.components.router import router as components_router
|
||||||
from app.repairs.exceptions import (
|
from app.repairs.exceptions import (
|
||||||
@@ -41,6 +42,7 @@ from app.repairs.router import (
|
|||||||
|
|
||||||
app = FastAPI(title=settings.app_name)
|
app = FastAPI(title=settings.app_name)
|
||||||
app.include_router(identity_router)
|
app.include_router(identity_router)
|
||||||
|
app.include_router(frontend_router)
|
||||||
app.include_router(inventory_router)
|
app.include_router(inventory_router)
|
||||||
app.include_router(components_router)
|
app.include_router(components_router)
|
||||||
install_identity_middleware(app)
|
install_identity_middleware(app)
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.frontend.router import router
|
||||||
|
from app.identity import (
|
||||||
|
get_current_user,
|
||||||
|
router as identity_router,
|
||||||
|
safe_next_path,
|
||||||
|
install_identity_middleware,
|
||||||
|
protected_page,
|
||||||
|
required_page_permission,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ROUTES = (
|
||||||
|
"/dashboard",
|
||||||
|
"/monitoring/map",
|
||||||
|
"/operations/repairs",
|
||||||
|
"/assets/miners",
|
||||||
|
)
|
||||||
|
STATIC_ROOT = Path(__file__).parents[1] / "app" / "frontend" / "static"
|
||||||
|
|
||||||
|
|
||||||
|
class FrontendShellTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.app = FastAPI()
|
||||||
|
self.app.include_router(router)
|
||||||
|
self.client = TestClient(self.app)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
def authenticate(self, permissions=None, role="Read Only"):
|
||||||
|
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||||
|
"id": "00000000-0000-0000-0000-000000000321",
|
||||||
|
"username": "operator",
|
||||||
|
"display_name": "Floor Operator",
|
||||||
|
"role": role,
|
||||||
|
"permissions": list(
|
||||||
|
permissions
|
||||||
|
or (
|
||||||
|
"dashboard.view",
|
||||||
|
"locations.view",
|
||||||
|
"repairs.view",
|
||||||
|
"miners.view",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_routes_require_authentication(self):
|
||||||
|
for path in ROUTES:
|
||||||
|
with self.subTest(path=path):
|
||||||
|
self.assertEqual(self.client.get(path).status_code, 401)
|
||||||
|
|
||||||
|
def test_all_direct_routes_render_authenticated_shell(self):
|
||||||
|
self.authenticate()
|
||||||
|
for path in ROUTES:
|
||||||
|
with self.subTest(path=path):
|
||||||
|
response = self.client.get(path)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("MinerOps", response.text)
|
||||||
|
self.assertIn(
|
||||||
|
"Operational Intelligence for Mining",
|
||||||
|
response.text,
|
||||||
|
)
|
||||||
|
self.assertIn("Floor Operator", response.text)
|
||||||
|
self.assertIn(
|
||||||
|
'aria-label="Primary navigation"',
|
||||||
|
response.text,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_navigation_connects_all_internal_alpha_views(self):
|
||||||
|
self.authenticate()
|
||||||
|
response = self.client.get("/dashboard")
|
||||||
|
for path in ROUTES:
|
||||||
|
self.assertIn(f'href="{path}"', response.text)
|
||||||
|
self.assertIn('aria-current="page"', response.text)
|
||||||
|
|
||||||
|
def test_each_route_requires_its_specific_permission(self):
|
||||||
|
cases = (
|
||||||
|
("/dashboard", "dashboard.view"),
|
||||||
|
("/monitoring/map", "locations.view"),
|
||||||
|
("/operations/repairs", "repairs.view"),
|
||||||
|
("/assets/miners", "miners.view"),
|
||||||
|
)
|
||||||
|
for path, permission in cases:
|
||||||
|
with self.subTest(path=path):
|
||||||
|
self.authenticate(permissions=("unrelated.permission",))
|
||||||
|
self.assertEqual(self.client.get(path).status_code, 403)
|
||||||
|
self.authenticate(permissions=(permission,))
|
||||||
|
self.assertEqual(self.client.get(path).status_code, 200)
|
||||||
|
|
||||||
|
def test_main_authentication_gate_redirects_to_existing_login(self):
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
install_identity_middleware(app)
|
||||||
|
with TestClient(app) as client, patch(
|
||||||
|
"app.identity.request_user_or_none",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
for path in ROUTES:
|
||||||
|
with self.subTest(path=path):
|
||||||
|
response = client.get(path, follow_redirects=False)
|
||||||
|
self.assertEqual(response.status_code, 303)
|
||||||
|
self.assertTrue(
|
||||||
|
response.headers["location"].startswith(
|
||||||
|
"/login?next="
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_page_permission_mapping_covers_new_routes(self):
|
||||||
|
expected = {
|
||||||
|
"/dashboard": "dashboard.view",
|
||||||
|
"/monitoring/map": "locations.view",
|
||||||
|
"/operations/repairs": "repairs.view",
|
||||||
|
"/assets/miners": "miners.view",
|
||||||
|
}
|
||||||
|
for path, permission in expected.items():
|
||||||
|
with self.subTest(path=path):
|
||||||
|
self.assertTrue(protected_page(path))
|
||||||
|
self.assertEqual(
|
||||||
|
required_page_permission(path),
|
||||||
|
permission,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dashboard_does_not_fabricate_operational_values(self):
|
||||||
|
self.authenticate()
|
||||||
|
response = self.client.get("/dashboard")
|
||||||
|
self.assertIn(
|
||||||
|
"Live health summary not yet connected",
|
||||||
|
response.text,
|
||||||
|
)
|
||||||
|
self.assertIn("No events are fabricated", response.text)
|
||||||
|
self.assertNotIn("100 TH/s", response.text)
|
||||||
|
|
||||||
|
def test_map_is_a_structured_honest_placeholder(self):
|
||||||
|
self.authenticate()
|
||||||
|
response = self.client.get("/monitoring/map")
|
||||||
|
for label in (
|
||||||
|
"Site",
|
||||||
|
"Room",
|
||||||
|
"Rack",
|
||||||
|
"Status layer",
|
||||||
|
"Selection details",
|
||||||
|
):
|
||||||
|
self.assertIn(label, response.text)
|
||||||
|
self.assertIn(
|
||||||
|
"Live layout and telemetry data are not",
|
||||||
|
response.text,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"connected to this Internal Alpha canvas",
|
||||||
|
response.text,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_data_views_include_all_request_states(self):
|
||||||
|
self.authenticate()
|
||||||
|
for path in ("/operations/repairs", "/assets/miners"):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
response_text = self.client.get(path).text
|
||||||
|
for state in (
|
||||||
|
"loading",
|
||||||
|
"empty",
|
||||||
|
"error",
|
||||||
|
"denied",
|
||||||
|
"ready",
|
||||||
|
):
|
||||||
|
self.assertIn(
|
||||||
|
f'data-view-state="{state}"',
|
||||||
|
response_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_frontend_assets_are_responsive_and_accessible(self):
|
||||||
|
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
||||||
|
javascript = (STATIC_ROOT / "app.js").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
self.assertIn("@media (max-width: 560px)", css)
|
||||||
|
self.assertIn(":focus-visible", css)
|
||||||
|
self.assertIn(".skip-link", css)
|
||||||
|
self.assertIn('response.status === 401', javascript)
|
||||||
|
self.assertIn('response.status === 403', javascript)
|
||||||
|
self.assertIn('credentials: "same-origin"', javascript)
|
||||||
|
|
||||||
|
def test_static_assets_are_served_without_an_api_route(self):
|
||||||
|
css_response = self.client.get("/assets/minerops-app.css")
|
||||||
|
script_response = self.client.get("/assets/minerops-app.js")
|
||||||
|
self.assertEqual(css_response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
css_response.headers["content-type"],
|
||||||
|
"text/css; charset=utf-8",
|
||||||
|
)
|
||||||
|
self.assertEqual(script_response.status_code, 200)
|
||||||
|
paths = {route.path for route in router.routes}
|
||||||
|
self.assertFalse(any(path.startswith("/api/") for path in paths))
|
||||||
|
|
||||||
|
def test_live_app_prefers_new_shell_and_preserves_legacy_pages(self):
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
first_handlers = {}
|
||||||
|
for route in app.routes:
|
||||||
|
if route.path not in first_handlers:
|
||||||
|
first_handlers[route.path] = route.endpoint
|
||||||
|
|
||||||
|
for path in ROUTES:
|
||||||
|
with self.subTest(path=path):
|
||||||
|
self.assertEqual(
|
||||||
|
first_handlers[path].__module__,
|
||||||
|
"app.frontend.router",
|
||||||
|
)
|
||||||
|
for path in (
|
||||||
|
"/repairs",
|
||||||
|
"/locations",
|
||||||
|
"/miners/{miner_id}/detail",
|
||||||
|
):
|
||||||
|
with self.subTest(legacy_path=path):
|
||||||
|
self.assertIn(path, first_handlers)
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_next_path_accepts_only_protected_local_pages(self):
|
||||||
|
valid = (
|
||||||
|
"/dashboard",
|
||||||
|
"/assets/miners?query=S19%20Pro",
|
||||||
|
"/miners/00000000-0000-0000-0000-000000000123/detail",
|
||||||
|
)
|
||||||
|
for value in valid:
|
||||||
|
with self.subTest(valid=value):
|
||||||
|
self.assertEqual(safe_next_path(value), value)
|
||||||
|
|
||||||
|
invalid = (
|
||||||
|
None,
|
||||||
|
"",
|
||||||
|
"https://example.invalid/dashboard",
|
||||||
|
"//example.invalid/dashboard",
|
||||||
|
r"\\example.invalid\dashboard",
|
||||||
|
"/../login",
|
||||||
|
"/assets/%2f%2fexample.invalid",
|
||||||
|
"/dashboard#fragment",
|
||||||
|
"/login",
|
||||||
|
"/api/inventory/miners",
|
||||||
|
" /dashboard",
|
||||||
|
"/dashboard\n",
|
||||||
|
)
|
||||||
|
for value in invalid:
|
||||||
|
with self.subTest(invalid=value):
|
||||||
|
self.assertEqual(safe_next_path(value), "/dashboard")
|
||||||
|
|
||||||
|
def test_login_redirect_preserves_direct_link_query(self):
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(identity_router)
|
||||||
|
app.include_router(router)
|
||||||
|
install_identity_middleware(app)
|
||||||
|
with TestClient(app) as client, patch(
|
||||||
|
"app.identity.request_user_or_none",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
response = client.get(
|
||||||
|
"/assets/miners?query=S19%20Pro",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 303)
|
||||||
|
location = urlsplit(response.headers["location"])
|
||||||
|
self.assertEqual(location.path, "/login")
|
||||||
|
self.assertEqual(
|
||||||
|
parse_qs(location.query)["next"],
|
||||||
|
["/assets/miners?query=S19%20Pro"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_authenticated_login_redirect_rejects_unsafe_next(self):
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(identity_router)
|
||||||
|
install_identity_middleware(app)
|
||||||
|
with TestClient(app) as client, patch(
|
||||||
|
"app.identity.request_user_or_none",
|
||||||
|
return_value={"id": "user"},
|
||||||
|
):
|
||||||
|
for value in (
|
||||||
|
"https://example.invalid",
|
||||||
|
"//example.invalid",
|
||||||
|
r"\\example.invalid\dashboard",
|
||||||
|
"/../login",
|
||||||
|
):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
response = client.get(
|
||||||
|
"/login",
|
||||||
|
params={"next": value},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 303)
|
||||||
|
self.assertEqual(
|
||||||
|
response.headers["location"],
|
||||||
|
"/dashboard",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_login_template_escapes_safe_query_destination(self):
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(identity_router)
|
||||||
|
hostile = '/assets/miners?query="><script>alert(1)</script>'
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get("/login", params={"next": hostile})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertNotIn(hostile, response.text)
|
||||||
|
self.assertIn(""><script>", response.text)
|
||||||
|
self.assertNotIn("new URLSearchParams(location.search)", response.text)
|
||||||
|
|
||||||
|
def test_user_values_are_html_escaped(self):
|
||||||
|
hostile = '<img src=x onerror=alert(1)>'
|
||||||
|
self.app.dependency_overrides[get_current_user] = lambda: {
|
||||||
|
"id": "00000000-0000-0000-0000-000000000321",
|
||||||
|
"username": "operator",
|
||||||
|
"display_name": hostile,
|
||||||
|
"role": "Read Only",
|
||||||
|
"permissions": ["dashboard.view", "miners.view"],
|
||||||
|
}
|
||||||
|
response = self.client.get("/dashboard")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertNotIn(hostile, response.text)
|
||||||
|
self.assertIn("<img src=x onerror=alert(1)>", response.text)
|
||||||
|
|
||||||
|
def test_navigation_and_search_are_permission_aware(self):
|
||||||
|
self.authenticate(permissions=("dashboard.view",))
|
||||||
|
response = self.client.get("/dashboard")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn('href="/dashboard"', response.text)
|
||||||
|
for path in ROUTES[1:]:
|
||||||
|
self.assertNotIn(f'href="{path}"', response.text)
|
||||||
|
self.assertNotIn('id="globalSearchForm"', response.text)
|
||||||
|
self.assertIn('data-can-view-miners="false"', response.text)
|
||||||
|
|
||||||
|
def test_assets_disable_caching_and_missing_assets_are_404(self):
|
||||||
|
for path in (
|
||||||
|
"/assets/minerops-app.css",
|
||||||
|
"/assets/minerops-app.js",
|
||||||
|
):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
response = self.client.get(path)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.headers["cache-control"], "no-store")
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.get("/assets/missing-minerops-file.js").status_code,
|
||||||
|
404,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_trailing_slash_redirects_are_consistent(self):
|
||||||
|
self.authenticate()
|
||||||
|
for path in ROUTES:
|
||||||
|
with self.subTest(path=path):
|
||||||
|
response = self.client.get(
|
||||||
|
path + "/",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 307)
|
||||||
|
self.assertEqual(
|
||||||
|
urlsplit(response.headers["location"]).path,
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_shell_markup_has_accessibility_landmarks(self):
|
||||||
|
self.authenticate()
|
||||||
|
for path in ROUTES:
|
||||||
|
with self.subTest(path=path):
|
||||||
|
text = self.client.get(path).text
|
||||||
|
self.assertEqual(text.count("<h1>"), 1)
|
||||||
|
self.assertIn('class="skip-link"', text)
|
||||||
|
self.assertIn('<main class="main-content"', text)
|
||||||
|
self.assertIn('aria-label="Primary navigation"', text)
|
||||||
|
self.assertIn('aria-controls="primaryNav"', text)
|
||||||
|
|
||||||
|
def test_javascript_uses_safe_dom_and_strict_failure_states(self):
|
||||||
|
javascript = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
|
||||||
|
for unsafe in ("innerHTML", "insertAdjacentHTML", "document.write"):
|
||||||
|
self.assertNotIn(unsafe, javascript)
|
||||||
|
self.assertIn("element.textContent = values.text", javascript)
|
||||||
|
self.assertIn("cell.textContent = text(content)", javascript)
|
||||||
|
self.assertIn("encodeURIComponent(repair.miner_id)", javascript)
|
||||||
|
self.assertIn("encodeURIComponent(miner.id)", javascript)
|
||||||
|
self.assertIn("new AbortController()", javascript)
|
||||||
|
self.assertIn("invalid repair response", javascript)
|
||||||
|
self.assertIn("invalid inventory response", javascript)
|
||||||
|
self.assertIn("window.location.replace", javascript)
|
||||||
|
|
||||||
|
def test_styles_have_no_motion_to_reduce(self):
|
||||||
|
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
||||||
|
self.assertNotIn("animation:", css)
|
||||||
|
self.assertNotIn("transition:", css)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -149,7 +149,7 @@ echo "Checking the unauthenticated login gate..."
|
|||||||
setup_status="$(curl --fail --silent "${base_url}/api/auth/setup-status")"
|
setup_status="$(curl --fail --silent "${base_url}/api/auth/setup-status")"
|
||||||
[[ "${setup_status}" == *'"setup_required":true'* ]]
|
[[ "${setup_status}" == *'"setup_required":true'* ]]
|
||||||
[[ "$(status_for "" GET /login)" == "200" ]]
|
[[ "$(status_for "" GET /login)" == "200" ]]
|
||||||
for path in dashboard repairs locations system-health accounts; do
|
for path in dashboard repairs locations system-health accounts monitoring/map operations/repairs assets/miners; do
|
||||||
[[ "$(status_for "" GET "/${path}")" == "303" ]]
|
[[ "$(status_for "" GET "/${path}")" == "303" ]]
|
||||||
done
|
done
|
||||||
[[ "$(status_for "" GET /api/scanner/status)" == "401" ]]
|
[[ "$(status_for "" GET /api/scanner/status)" == "401" ]]
|
||||||
@@ -162,6 +162,11 @@ setup_response="$(curl --fail --silent -c "${admin_cookie}" \
|
|||||||
[[ "${setup_response}" == *'"role":"Admin"'* ]]
|
[[ "${setup_response}" == *'"role":"Admin"'* ]]
|
||||||
[[ "$(status_for "${admin_cookie}" GET /dashboard)" == "200" ]]
|
[[ "$(status_for "${admin_cookie}" GET /dashboard)" == "200" ]]
|
||||||
[[ "$(status_for "${admin_cookie}" GET /accounts)" == "200" ]]
|
[[ "$(status_for "${admin_cookie}" GET /accounts)" == "200" ]]
|
||||||
|
for path in monitoring/map operations/repairs assets/miners; do
|
||||||
|
[[ "$(status_for "${admin_cookie}" GET "/${path}")" == "200" ]]
|
||||||
|
done
|
||||||
|
[[ "$(status_for "" GET /assets/minerops-app.css)" == "200" ]]
|
||||||
|
[[ "$(status_for "" GET /assets/minerops-app.js)" == "200" ]]
|
||||||
|
|
||||||
for account in \
|
for account in \
|
||||||
'manager|Operations Manager|Manager|manager@example.com' \
|
'manager|Operations Manager|Manager|manager@example.com' \
|
||||||
@@ -196,6 +201,9 @@ for login in \
|
|||||||
"${base_url}/api/auth/login")"
|
"${base_url}/api/auth/login")"
|
||||||
[[ "${login_response}" == *"\"role\":\"${role}\""* ]]
|
[[ "${login_response}" == *"\"role\":\"${role}\""* ]]
|
||||||
[[ "$(status_for "${cookie_jar}" GET /dashboard)" == "200" ]]
|
[[ "$(status_for "${cookie_jar}" GET /dashboard)" == "200" ]]
|
||||||
|
[[ "$(status_for "${cookie_jar}" GET /monitoring/map)" == "200" ]]
|
||||||
|
[[ "$(status_for "${cookie_jar}" GET /operations/repairs)" == "200" ]]
|
||||||
|
[[ "$(status_for "${cookie_jar}" GET /assets/miners)" == "200" ]]
|
||||||
done
|
done
|
||||||
|
|
||||||
[[ "$(status_for "${manager_cookie}" GET /system-health)" == "200" ]]
|
[[ "$(status_for "${manager_cookie}" GET /system-health)" == "200" ]]
|
||||||
|
|||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
|
||||||
|
project_name="${MINEROPS_FRONTEND_TEST_PROJECT:-minerops-frontend-shell-$$}"
|
||||||
|
compose=(
|
||||||
|
docker compose
|
||||||
|
--project-name "${project_name}"
|
||||||
|
--env-file .env.dev
|
||||||
|
-f compose.dev.yaml
|
||||||
|
)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
if [[ ! -f .env.dev ]]; then
|
||||||
|
echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building frontend test image..."
|
||||||
|
"${compose[@]}" build backend
|
||||||
|
|
||||||
|
echo "Running focused frontend shell tests..."
|
||||||
|
"${compose[@]}" run --rm --no-deps -v "${repo_root}/backend/tests:/app/tests:ro" backend python -m unittest -v tests.test_frontend_shell
|
||||||
|
|
||||||
|
echo "Frontend shell tests passed."
|
||||||
Reference in new issue
Block a user