Author SHA1 Message Date
lussierj c3501b987e feat: add system health diagnostics 2026-08-20 16:06:31 +00:00
lussierj 0dd26a3f24 merge: incorporate current main 2026-08-19 20:15:40 +00:00
lussierj 577226cb4e Update README.md 2026-07-28 11:21:26 -05:00
18 changed files with 1604 additions and 886 deletions

No files matched your search

+269 -145
View File
@@ -1,198 +1,322 @@
<p align="center"> <p align="center">
<img src="branding/wordmark.svg" alt="MinerOps" width="320"> <img src="branding/logo.png" alt="MinerOps Logo" width="180">
</p> </p>
<h1 align="center">MinerOps</h1> <h1 align="center">MinerOps</h1>
<p align="center"> <p align="center">
<strong>The self-hosted operating system for cryptocurrency mining operations.</strong> <strong>The self-hosted operating system for cryptocurrency mining operations.</strong>
<br>
Know every miner. Track every repair. Control every rack.
</p> </p>
<p align="center">
Know every miner • Track every repair • Control every rack
</p>
<p align="center">
<img src="https://img.shields.io/badge/status-active%20development-orange" />
<img src="https://img.shields.io/badge/python-3.13-blue" />
<img src="https://img.shields.io/badge/license-TBD-lightgrey" />
</p>
---
## Overview ## Overview
MinerOps is a self-hosted platform intended to provide one authoritative source MinerOps is an open-source operations platform built for cryptocurrency mining facilities.
of truth for cryptocurrency mining operations. It is being built to connect
asset records, network identity, repair history, physical location, telemetry,
and operational controls without depending on a hosted management service.
The repository contains a working early-stage application and the first Rather than relying on spreadsheets, disconnected monitoring tools, and handwritten repair logs, MinerOps brings every operational workflow together into a single self-hosted application.
foundations of a more modular architecture. Current development is focused on
turning those foundations into well-defined product domains with tested service
and persistence boundaries.
The longer-term product direction combines: MinerOps is designed to manage the complete lifecycle of a mining fleet, including inventory, physical locations, repairs, monitoring, maintenance, and secure remote management.
- Miner inventory Originally developed for WhatsMiner fleets, the platform is being built around a modular architecture that will support additional miner platforms through protocol adapters in future releases.
- Network discovery and identity tracking
- Repairs and maintenance
- Locations and electrical infrastructure
- Monitoring and alerts
- Fleet control
- Scheduling and automation
- Historical records and reporting
These areas describe the product direction, not a claim that every workflow is ---
complete or ready for production use.
## Why MinerOps? # Vision
Mining operations often accumulate several partial versions of the truth: Running a mining operation requires far more than knowing whether a miner is online.
spreadsheets for assets, separate monitoring tools for performance, informal
repair notes, and location knowledge held by individual technicians.
That fragmentation becomes harder to manage when IP addresses change, control MinerOps is designed to become the operational hub for an entire mining facility by combining:
boards or network interfaces are replaced, equipment moves between racks, and
repair history is incomplete. MinerOps is intended to give operators one
self-hosted operational record that follows each miner through its full
lifecycle.
## Core Capabilities - Asset Inventory
- Physical Location Management
- Repair & Maintenance Tracking
- Live Fleet Monitoring
- Historical Reporting
- Secure Remote Management
- Operational Analytics
- Fleet Automation
### Asset and Inventory Management into one cohesive platform.
Maintain stable asset records independently of a miner's current IP address. The long-term goal is to replace multiple disconnected systems with a single source of truth for mining operations.
The inventory schema and backend domain foundation currently support Asset IDs,
hardware identifiers, lifecycle state, tags, notes, location references, and
seen timestamps. Inventory HTTP APIs and product workflows are the next
application milestone.
### Miner Identity and Discovery ---
Associate changing network information with durable miner records. Existing # Current Status
code includes discovery and network identity foundations; formal identity
history and transfer workflows remain planned work.
### Repairs and Maintenance ⚠️ **MinerOps is under active development.**
Build a complete service history around each miner, including repair status, The current focus is building a scalable, maintainable architecture before expanding user-facing functionality.
notes, parts, and return-to-service workflows. Operational repair functionality
exists today, while extraction into a dedicated domain architecture is still
planned.
### Locations and Infrastructure ## Completed
Track where equipment is installed and relate miners to sites, rooms, racks,
shelves, and operational infrastructure. Existing location workflows will be
refined as the modular architecture develops.
### Monitoring and Health
Collect miner status and telemetry, identify stale or unhealthy equipment, and
support operational health views. The repository contains current telemetry and
retention foundations; broader alerting remains planned.
### Fleet Operations
Provide controlled fleet actions while keeping command policy, authorization,
and site-local execution explicit. Existing command workflows are under active
development and should not be treated as a finished control platform.
### Automation and Scheduling
Coordinate recurring scans, scheduled actions, retention, and other repeatable
operations. Scheduling and automation will expand after the core inventory and
identity domains are established.
### Audit History and Reporting
Preserve important operational changes and support practical reporting across
assets, repairs, locations, and fleet activity. Audit-log and reporting
foundations exist; the complete reporting model remains planned.
## Current Status
MinerOps is under active development.
**Current development stage:** `v0.2 - Foundation`
Verified foundations in the repository include:
- Docker-based development environment - Docker-based development environment
- PostgreSQL data store - PostgreSQL + Redis backend
- Redis service - Alembic migration framework
- Alembic schema migrations - Authentication
- Session-based authentication and login gating - Role-based access control
- Admin, Manager, Technician, and Read Only role foundations - Manual inventory schema
- Manual miner inventory database schema - Inventory domain architecture
- Inventory Pydantic models - Automated migration testing
- Inventory repository and service domain foundation - Disposable development environment
- Automated repository and service tests for the Inventory domain
- Disposable development baseline tests
**Next application milestone:** `v0.3 - Inventory` ## Currently In Progress
**Next technical feature:** Inventory API - Inventory API
- Inventory user interface
- Domain-driven architecture migration
## Architecture ## Planned
MinerOps is moving toward a modular backend with deliberate separation between - Asset identity & MAC history
transport, business rules, and persistence: - Physical location management
- Repair & work order management
- Parts inventory
- Fleet monitoring
- Site agents
- Multi-version WhatsMiner API support
- Alerting & notifications
- Bulk operations
- Scheduled automation
- Reporting & analytics
- Production deployment tools
```text ---
Frontend
| # Core Features
v
FastAPI routers ## 📦 Asset Inventory
|
v Maintain a permanent identity for every miner independent of its network configuration.
Domain services
| Planned capabilities include:
v
Repositories - Asset IDs
| - MAC history
v - Control board tracking
- PSU serial tracking
- Firmware history
- Lifecycle management
- Tags
- Notes
- Asset movement history
---
## 📍 Physical Location Tracking
Track exactly where every miner is installed.
Supports:
- Sites
- Rooms
- Rows
- Racks
- Shelves
- Positions
with complete movement history.
---
## 🔧 Repair Management
Track every repair from diagnosis through return to production.
Features include:
- Work orders
- Technician notes
- Parts used
- Repair history
- Testing
- Attachments
- Maintenance mode
---
## 📊 Fleet Monitoring
Monitor operational health across the fleet.
Planned monitoring includes:
- Hashrate
- Temperature
- Fan status
- Hashboard health
- PSU status
- Pool connectivity
- Worker information
- Firmware
- Alerts
---
## 🌐 Network Discovery
Automatically discover miners while maintaining stable asset identities.
Planned capabilities include:
- Configurable scan ranges
- Duplicate detection
- MAC history
- IP history
- Capability detection
- Discovery review workflow
---
## 🎛 Secure Remote Control
Safely execute commands against supported miners.
Examples include:
- Reboot
- Shutdown
- Performance mode
- Pool configuration
- Firmware operations
All commands will be permission-aware, audited, and designed for safe execution.
---
## 🛰 Site Agents
Future releases will include lightweight Site Agents responsible for:
- Network discovery
- Miner polling
- Local command execution
- Secure communication with the MinerOps server
This architecture allows mining networks to remain isolated while still enabling centralized fleet management.
---
# Architecture
MinerOps follows a layered architecture that separates business logic from persistence.
```
FastAPI Routers
│
▼
Service Layer
│
▼
Repository Layer
│
▼
PostgreSQL PostgreSQL
``` ```
FastAPI routers will own HTTP request and response concerns. Domain services Business rules belong in Services.
will own operational rules and orchestration. Repositories will own database
access. The Inventory domain is the first backend area established with these
boundaries; other working application areas are still being migrated
incrementally.
## Technology Stack Database access belongs in Repositories.
Current technologies: Schema changes are managed exclusively through Alembic migrations.
This architecture keeps MinerOps modular, testable, and easy to extend.
---
# Technology Stack
Current
- Python - Python
- FastAPI - FastAPI
- Pydantic
- PostgreSQL - PostgreSQL
- Alembic
- Redis - Redis
- Docker and Docker Compose - Alembic
- Nginx development gateway - Docker Compose
- Pydantic
A typed, responsive frontend with a shared design system is planned. Its Planned
technology selection has not yet been finalized in this repository.
## Roadmap - React
- TypeScript
- Site Agents
- Protocol adapters
- Background task processing
- `v0.2 - Foundation` ---
- `v0.3 - Inventory`
- `v0.4 - Identity`
- `v0.5 - Operations`
- `v0.6 - Monitoring`
- `v0.7 - Automation`
- `v1.0 - Production Ready`
Roadmap scope and release boundaries may evolve as development continues and # Development Philosophy
operational requirements become clearer.
## Documentation MinerOps is built around a few guiding principles.
Detailed product, architecture, development, database, testing, and roadmap - Self-hosted first
documentation will live under `docs/`. That documentation set is still being - Reliability over unnecessary complexity
developed, so this README does not link to files that do not yet exist. - Small, reviewable commits
- Test before commit
- Database changes managed through Alembic
- Business logic separated from persistence
- Secure by default
- Role-based authorization
- Comprehensive audit logging
- Incremental development over large rewrites
## Contributing ---
MinerOps is in early development. Contribution guidelines will be added as the # Roadmap
architecture, development workflow, and review expectations stabilize.
## License | Version | Focus |
|----------|---------------------------|
| ✅ v0.2 | Foundation |
| 🚧 v0.3 | Inventory |
| ⏳ v0.4 | Asset Identity |
| ⏳ v0.5 | Operations |
| ⏳ v0.6 | Monitoring |
| ⏳ v0.7 | Automation |
| 🎯 v1.0 | Production Release |
The MinerOps license has not yet been finalized. No license file is currently Detailed planning will be maintained in **docs/ROADMAP.md**.
included in this repository.
---
# Documentation
Project documentation is maintained in the `docs/` directory.
Documentation includes:
- Architecture
- Roadmap
- Architecture Decisions
- Development Guide
- Coding Standards
- Database Design
- API Documentation
- Deployment Guide
- Testing Guide
---
# Contributing
MinerOps is currently under active development.
Contributions, ideas, bug reports, and feature requests are welcome as the project continues to evolve.
---
# License
License to be determined.
+2
View File
@@ -5,6 +5,8 @@ class Settings(BaseSettings):
app_name: str = "MinerOps Console" app_name: str = "MinerOps Console"
app_env: str = "development" app_env: str = "development"
app_timezone: str = "America/Winnipeg" app_timezone: str = "America/Winnipeg"
minerops_build_commit: str | None = None
minerops_build_version: str | None = None
postgres_db: str = "minerops" postgres_db: str = "minerops"
postgres_user: str = "minerops" postgres_user: str = "minerops"
@@ -0,0 +1,139 @@
.health-toolbar,
.health-section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.health-toolbar {
margin-bottom: 12px;
}
.health-toolbar h2,
.health-toolbar p,
.health-section h2,
.health-section h3 {
margin-top: 0;
}
.health-toolbar p {
margin-bottom: 0;
color: var(--muted-text, #52606d);
}
.health-notice {
border-left: 4px solid #64748b;
background: #f1f5f9;
margin: 0 0 14px;
padding: 10px 12px;
}
.health-notice[data-state="partial"] {
border-left-color: #b45309;
background: #fff7ed;
}
.health-notice[data-state="error"] {
border-left-color: #b91c1c;
background: #fef2f2;
}
.health-section {
border-top: 1px solid #cbd5e1;
padding: 16px 0;
}
.health-columns,
.health-integrity-grid,
.health-facts {
display: grid;
gap: 14px;
}
.health-columns {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.health-integrity-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.health-facts {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0;
}
.health-facts-compact {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.health-facts div {
min-width: 0;
}
.health-facts dt {
color: #52606d;
font-size: 0.78rem;
font-weight: 700;
}
.health-facts dd {
font-weight: 700;
margin: 3px 0 0;
overflow-wrap: anywhere;
}
.health-links {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.health-links a:focus-visible,
#healthRefresh:focus-visible {
outline: 3px solid #0f766e;
outline-offset: 3px;
}
.table-scroll {
overflow-x: auto;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
}
.table-scroll th,
.table-scroll td {
border-bottom: 1px solid #dbe2e8;
padding: 7px 8px;
text-align: left;
}
@media (max-width: 900px) {
.health-integrity-grid,
.health-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.health-toolbar,
.health-section-head {
align-items: stretch;
flex-direction: column;
}
.health-columns,
.health-integrity-grid,
.health-facts,
.health-facts-compact {
grid-template-columns: 1fr;
}
#healthRefresh {
width: 100%;
}
}
@@ -0,0 +1,164 @@
"use strict";
(function () {
const notice = document.getElementById("healthNotice");
const content = document.getElementById("healthContent");
const refresh = document.getElementById("healthRefresh");
function setText(id, value, fallback) {
const element = document.getElementById(id);
if (element) {
element.textContent = value === null || value === undefined || value === ""
? (fallback || "Unavailable") : String(value);
}
}
function number(value) {
return typeof value === "number" ? value.toLocaleString() : "Unavailable";
}
function timestamp(value) {
if (!value) return "Unavailable";
const date = new Date(value);
return Number.isNaN(date.valueOf()) ? "Unavailable" : date.toLocaleString();
}
function factualAge(value) {
if (!value) return "Unavailable";
const observed = new Date(value);
if (Number.isNaN(observed.valueOf())) return "Unavailable";
const seconds = Math.max(0, Math.floor((Date.now() - observed.valueOf()) / 1000));
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + (minutes === 1 ? " minute ago" : " minutes ago");
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + (hours === 1 ? " hour ago" : " hours ago");
const days = Math.floor(hours / 24);
return days + (days === 1 ? " day ago" : " days ago");
}
function replaceFacts(id, facts) {
const list = document.getElementById(id);
list.replaceChildren();
for (const [label, value] of facts) {
const row = document.createElement("div");
const term = document.createElement("dt");
const detail = document.createElement("dd");
term.textContent = label;
detail.textContent = value;
row.append(term, detail);
list.append(row);
}
}
function render(payload) {
const data = payload.data;
const app = data.application;
const postgres = data.postgresql;
const migration = postgres.migration;
const telemetry = postgres.telemetry;
setText("healthAppStatus", app.status);
setText("healthEnvironment", app.environment);
setText("healthBuildCommit", app.build_commit);
setText("healthBuildVersion", app.build_version);
setText("healthServerTime", timestamp(app.server_time));
setText("healthMigrationState", migration.state);
setText("healthCurrentRevision", migration.current_revision);
setText("healthExpectedRevision", migration.expected_revision);
setText("healthPostgresqlStatus", postgres.status);
setText("healthRedisStatus", data.redis.status);
setText("healthSnapshotCount", telemetry ? number(telemetry.snapshot_count) : null);
setText("healthOldestObservation", telemetry ? timestamp(telemetry.oldest_observed_at) : null);
setText("healthLatestObservation", telemetry ? timestamp(telemetry.latest_observed_at) : null);
setText("healthObservationAge", telemetry ? factualAge(telemetry.latest_observed_at) : null);
const fleet = postgres.fleet;
replaceFacts("healthFleetFacts", fleet ? [
["Total miners", number(fleet.total_miners)],
["Without Asset ID", number(fleet.miners_without_asset_id)],
["Unmapped", number(fleet.unmapped_miners)],
["In multiple positions", number(fleet.miners_in_multiple_positions)],
["Duplicate current MAC groups", number(fleet.duplicate_current_mac_groups)]
] : [["Diagnostics", "Unavailable"]]);
const locations = postgres.locations;
replaceFacts("healthLocationFacts", locations ? [
["Sites", number(locations.sites)], ["Rooms", number(locations.rooms)],
["Positions", number(locations.positions)],
["Occupied", number(locations.occupied_positions)],
["Empty", number(locations.empty_positions)],
["Legacy unscoped", number(locations.unscoped_legacy_positions)],
["Expected MAC mismatches", number(locations.expected_mac_mismatches)]
] : [["Diagnostics", "Unavailable"]]);
const repairs = postgres.repairs;
replaceFacts("healthRepairFacts", repairs ? [
["Active modular repairs", number(repairs.active_modular_repairs)],
["Duplicate active groups", number(repairs.duplicate_active_repair_groups)],
["Legacy repair only", number(repairs.legacy_repair_only_miners)],
["Lifecycle repair without active repair", number(repairs.lifecycle_repair_without_active_repair)],
["Active repair lifecycle mismatches", number(repairs.active_repair_lifecycle_mismatches)],
["Waiting placement", number(repairs.waiting_placement_records)],
["Repair events", number(repairs.repair_events)],
["Repair parts", number(repairs.repair_parts)]
] : [["Diagnostics", "Unavailable"]]);
const retention = postgres.retention;
replaceFacts("healthRetentionFacts", retention ? [
["Raw telemetry retention", retention.raw_retention_enabled ? "Enabled" : "Not enabled"],
["Hourly rollups", retention.hourly.table_present ? "Present; manual population" : "Unavailable"],
["Hourly latest bucket", timestamp(retention.hourly.latest_bucket)],
["Daily rollups", retention.daily.table_present ? "Present; manual population" : "Unavailable"],
["Daily latest bucket", timestamp(retention.daily.latest_bucket)],
["Latest recorded run", timestamp(retention.latest_recorded_run_at)],
["Latest run mode", retention.latest_recorded_run_mode || "None recorded"],
["Completeness", "Not guaranteed"]
] : [["Diagnostics", "Unavailable"]]);
const tableBody = document.getElementById("healthTableCounts");
tableBody.replaceChildren();
const rows = postgres.table_counts || [];
for (const table of rows) {
const row = document.createElement("tr");
const name = document.createElement("th");
const count = document.createElement("td");
name.scope = "row";
name.textContent = table.name;
count.textContent = number(table.row_count);
row.append(name, count);
tableBody.append(row);
}
if (!rows.length) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = 2;
cell.textContent = "Table diagnostics unavailable.";
row.append(cell);
tableBody.append(row);
}
content.hidden = false;
notice.dataset.state = payload.meta.partial ? "partial" : "ready";
notice.textContent = payload.meta.partial
? "Diagnostics loaded with unavailable sections: " + payload.meta.unavailable_sections.join(", ") + "."
: "Diagnostics refreshed at " + timestamp(payload.meta.generated_at) + ".";
}
async function load() {
refresh.disabled = true;
notice.dataset.state = "loading";
notice.textContent = "Loading diagnostics.";
try {
const response = await fetch("/api/system/health-summary", {
credentials: "same-origin", headers: {"Accept": "application/json"}
});
const payload = await response.json().catch(function () { return {}; });
if (!response.ok) throw new Error("System Health request failed");
render(payload);
} catch (error) {
notice.dataset.state = "error";
notice.textContent = "System Health diagnostics could not be loaded.";
} finally {
refresh.disabled = false;
}
}
refresh.addEventListener("click", load);
load();
}());
@@ -0,0 +1,54 @@
"""Semantic content for the Internal Alpha System Health page."""
def system_health_content() -> str:
return """
<section class="health-toolbar" aria-labelledby="healthOverviewTitle">
<div><h2 id="healthOverviewTitle">Operations diagnostics</h2>
<p>Availability, data facts, integrity counts, and maintenance state.</p></div>
<button class="primary-button" id="healthRefresh" type="button">Refresh</button>
</section>
<p class="health-notice" id="healthNotice" role="status" aria-live="polite">Loading diagnostics.</p>
<div id="healthContent" hidden>
<section class="health-section" aria-labelledby="applicationHealthTitle">
<h2 id="applicationHealthTitle">Application</h2>
<dl class="health-facts">
<div><dt>Application</dt><dd id="healthAppStatus">-</dd></div>
<div><dt>Environment</dt><dd id="healthEnvironment">-</dd></div>
<div><dt>Build commit</dt><dd id="healthBuildCommit">-</dd></div>
<div><dt>Build version</dt><dd id="healthBuildVersion">-</dd></div>
<div><dt>Server time</dt><dd id="healthServerTime">-</dd></div>
<div><dt>Schema</dt><dd id="healthMigrationState">-</dd></div>
<div><dt>Database revision</dt><dd id="healthCurrentRevision">-</dd></div>
<div><dt>Expected revision</dt><dd id="healthExpectedRevision">-</dd></div>
</dl>
</section>
<div class="health-columns">
<section class="health-section" aria-labelledby="servicesHealthTitle"><h2 id="servicesHealthTitle">Service availability</h2>
<dl class="health-facts health-facts-compact"><div><dt>PostgreSQL</dt><dd id="healthPostgresqlStatus">-</dd></div>
<div><dt>Redis</dt><dd id="healthRedisStatus">-</dd></div></dl></section>
<section class="health-section" aria-labelledby="telemetryHealthTitle"><h2 id="telemetryHealthTitle">Telemetry</h2>
<dl class="health-facts health-facts-compact"><div><dt>Raw snapshots</dt><dd id="healthSnapshotCount">-</dd></div>
<div><dt>Oldest observation</dt><dd id="healthOldestObservation">-</dd></div>
<div><dt>Latest observation</dt><dd id="healthLatestObservation">-</dd></div>
<div><dt>Latest age</dt><dd id="healthObservationAge">-</dd></div></dl></section>
</div>
<section class="health-section" aria-labelledby="integrityHealthTitle">
<div class="health-section-head"><h2 id="integrityHealthTitle">Operational integrity facts</h2>
<div class="health-links"><a href="/assets/miners">Fleet</a><a href="/monitoring/map">Mine Map</a>
<a href="/operations/repairs">Repair Center</a><a href="/monitoring/analytics">Trends</a></div></div>
<div class="health-integrity-grid">
<section aria-labelledby="fleetIntegrityTitle"><h3 id="fleetIntegrityTitle">Fleet</h3><dl id="healthFleetFacts" class="health-facts health-facts-compact"></dl></section>
<section aria-labelledby="locationIntegrityTitle"><h3 id="locationIntegrityTitle">Locations</h3><dl id="healthLocationFacts" class="health-facts health-facts-compact"></dl></section>
<section aria-labelledby="repairIntegrityTitle"><h3 id="repairIntegrityTitle">Repairs</h3><dl id="healthRepairFacts" class="health-facts health-facts-compact"></dl></section>
</div>
</section>
<div class="health-columns">
<section class="health-section" aria-labelledby="retentionHealthTitle"><h2 id="retentionHealthTitle">Retention and rollups</h2>
<dl id="healthRetentionFacts" class="health-facts health-facts-compact"></dl></section>
<section class="health-section" aria-labelledby="tablesHealthTitle"><h2 id="tablesHealthTitle">Operational tables</h2>
<div class="table-scroll"><table><thead><tr><th scope="col">Table</th><th scope="col">Rows</th></tr></thead>
<tbody id="healthTableCounts"></tbody></table></div></section>
</div>
</div>
"""
+16 -3
View File
@@ -5,6 +5,7 @@ from typing import Callable
from app.frontend.miner_detail_view import miner_detail_content from app.frontend.miner_detail_view import miner_detail_content
from app.frontend.monitoring_trends_view import monitoring_trends_content from app.frontend.monitoring_trends_view import monitoring_trends_content
from app.frontend.system_health_view import system_health_content
NAVIGATION = ( NAVIGATION = (
@@ -13,6 +14,7 @@ NAVIGATION = (
("analytics", "Trends", "/monitoring/analytics", "miners.view"), ("analytics", "Trends", "/monitoring/analytics", "miners.view"),
("repairs", "Repair Center", "/operations/repairs", "repairs.view"), ("repairs", "Repair Center", "/operations/repairs", "repairs.view"),
("miners", "Miners", "/assets/miners", "miners.view"), ("miners", "Miners", "/assets/miners", "miners.view"),
("system_health", "System Health", "/system-health", "system_health.view"),
) )
PAGE_DETAILS = { PAGE_DETAILS = {
@@ -46,6 +48,11 @@ PAGE_DETAILS = {
"Miner Detail", "Miner Detail",
"Current identity, operating facts, location, and authorized history.", "Current identity, operating facts, location, and authorized history.",
), ),
"system_health": (
"Administration / Diagnostics",
"System Health",
"Inspect application services, operational data, and maintenance facts.",
),
} }
@@ -455,7 +462,9 @@ def render_app_page(
else '<div class="global-search-unavailable" aria-hidden="true"></div>' else '<div class="global-search-unavailable" aria-hidden="true"></div>'
) )
content = ( content = (
miner_detail_content() system_health_content()
if view == "system_health"
else miner_detail_content()
if view == "miner_detail" if view == "miner_detail"
else monitoring_trends_content(has_permission("locations.view")) else monitoring_trends_content(has_permission("locations.view"))
if view == "analytics" if view == "analytics"
@@ -474,7 +483,9 @@ def render_app_page(
) )
) )
page_script = ( page_script = (
'<script src="/assets/minerops-miner-detail.js" defer></script>' '<script src="/assets/minerops-system-health.js" defer></script>'
if view == "system_health"
else '<script src="/assets/minerops-miner-detail.js" defer></script>'
if view == "miner_detail" if view == "miner_detail"
else '<script src="/assets/minerops-monitoring-trends.js" defer></script>' else '<script src="/assets/minerops-monitoring-trends.js" defer></script>'
if view == "analytics" if view == "analytics"
@@ -489,7 +500,9 @@ def render_app_page(
else "" else ""
) )
page_style = ( page_style = (
'<link rel="stylesheet" href="/assets/minerops-monitoring-trends.css">' '<link rel="stylesheet" href="/assets/minerops-system-health.css">'
if view == "system_health"
else '<link rel="stylesheet" href="/assets/minerops-monitoring-trends.css">'
if view == "analytics" else "" if view == "analytics" else ""
) )
object_attributes = "" object_attributes = ""
+4 -738
View File
@@ -32,6 +32,8 @@ from app.components.router import router as components_router
from app.miner_detail.router import router as miner_detail_router from app.miner_detail.router import router as miner_detail_router
from app.monitoring.router import router as monitoring_router from app.monitoring.router import router as monitoring_router
from app.monitoring.frontend import router as monitoring_frontend_router from app.monitoring.frontend import router as monitoring_frontend_router
from app.system_health.frontend import router as system_health_frontend_router
from app.system_health.router import router as system_health_router
from app.repairs.exceptions import ( from app.repairs.exceptions import (
ControlBoardReplacementActorRequired, ControlBoardReplacementActorRequired,
InvalidMacAddress, InvalidMacAddress,
@@ -67,6 +69,8 @@ app.include_router(dashboard_router)
app.include_router(miner_detail_router) app.include_router(miner_detail_router)
app.include_router(monitoring_frontend_router) app.include_router(monitoring_frontend_router)
app.include_router(monitoring_router) app.include_router(monitoring_router)
app.include_router(system_health_frontend_router)
app.include_router(system_health_router)
install_identity_middleware(app) install_identity_middleware(app)
@@ -34663,744 +34667,6 @@ async def minerops_inject_repair_history_modal(request, call_next):
# MINEROPS_SYSTEM_HEALTH_V1
from fastapi.responses import HTMLResponse as MinerOpsSystemHealthHTMLResponse
from pathlib import Path as MinerOpsSystemPath
@app.get("/api/system/health-summary")
def minerops_system_health_summary(
current_user: dict = Depends(get_current_user),
):
import re
from collections import Counter
ensure_location_tables()
ensure_maintenance_tables()
source_path = MinerOpsSystemPath(__file__)
source_text = source_path.read_text(errors="ignore")
source_lines = source_text.splitlines()
route_re = re.compile(r'^\s*@app\.(get|post|patch|delete|put)\("([^"]+)"')
routes = []
for line_no, line in enumerate(source_lines, start=1):
match = route_re.search(line)
if match:
routes.append(
{
"method": match.group(1).upper(),
"path": match.group(2),
"line": line_no,
}
)
route_counter = Counter((r["method"], r["path"]) for r in routes)
duplicate_routes = []
for (method, route_path), count in sorted(route_counter.items(), key=lambda item: (item[0][1], item[0][0])):
if count > 1:
duplicate_routes.append(
{
"method": method,
"path": route_path,
"count": count,
"lines": [
r["line"]
for r in routes
if r["method"] == method and r["path"] == route_path
],
}
)
middleware_lines = [
line_no
for line_no, line in enumerate(source_lines, start=1)
if '@app.middleware("http")' in line
]
asset_routes = [
r
for r in routes
if str(r["path"]).startswith("/assets/")
]
patch_markers = []
for line_no, line in enumerate(source_lines, start=1):
stripped = line.strip()
if stripped.startswith("# MINEROPS_") or stripped.startswith("# Step "):
patch_markers.append(
{
"line": line_no,
"text": stripped,
}
)
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
relname AS table_name,
pg_total_relation_size(relid) AS total_bytes,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 15;
"""
)
largest_tables = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
relname AS table_name,
n_live_tup::bigint AS estimated_rows,
n_dead_tup::bigint AS estimated_dead_rows,
last_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC
LIMIT 20;
"""
)
row_estimates = [dict(row) for row in cur.fetchall()]
table_counts = {}
for table_name in [
"miners",
"location_positions",
"miner_location_states",
"miner_maintenance_events",
"miner_hashboards",
"miner_network_identities",
"minerops_agent_command_queue",
]:
try:
cur.execute(f"SELECT COUNT(*) AS row_count FROM {table_name};")
table_counts[table_name] = cur.fetchone()["row_count"]
except Exception as exc:
table_counts[table_name] = f"error: {exc}"
cur.execute(
"""
SELECT
COALESCE(status, 'unknown') AS status,
COUNT(*) AS count
FROM miners
GROUP BY COALESCE(status, 'unknown')
ORDER BY status;
"""
)
miner_status_counts = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
COALESCE(asset_state, 'unknown') AS asset_state,
COUNT(*) AS count
FROM miner_location_states
GROUP BY COALESCE(asset_state, 'unknown')
ORDER BY asset_state;
"""
)
location_state_counts = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
COALESCE(event_status, 'unknown') AS event_status,
COUNT(*) AS count
FROM miner_maintenance_events
WHERE event_type = 'repair'
GROUP BY COALESCE(event_status, 'unknown')
ORDER BY event_status;
"""
)
repair_status_counts = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT
COUNT(*) FILTER (WHERE api3_supported IS TRUE) AS api3_supported,
COUNT(*) FILTER (WHERE api3_supported IS FALSE) AS api3_failed,
COUNT(*) FILTER (WHERE api3_supported IS NULL) AS api3_unknown,
COUNT(*) FILTER (WHERE power_sn IS NOT NULL AND power_sn <> '') AS miners_with_power_sn,
COUNT(*) FILTER (WHERE miner_sn IS NOT NULL AND miner_sn <> '') AS miners_with_miner_sn
FROM miners;
"""
)
api3_summary = dict(cur.fetchone())
cur.execute(
"""
SELECT
COUNT(*) AS repair_queue_count
FROM miner_location_states
WHERE asset_state = 'repair';
"""
)
repair_queue_count = cur.fetchone()["repair_queue_count"]
cur.execute(
"""
SELECT
COUNT(*) AS raw_status_rows,
MIN(observed_at) AS raw_oldest,
MAX(observed_at) AS raw_newest,
COUNT(*) FILTER (WHERE observed_at < now() - interval '30 days') AS raw_older_than_30d
FROM miner_status_snapshots;
"""
)
raw_status_retention = dict(cur.fetchone())
cur.execute(
"""
SELECT
COUNT(*) AS hourly_rows,
MIN(bucket_at) AS hourly_oldest,
MAX(bucket_at) AS hourly_newest
FROM miner_status_hourly_rollups;
"""
)
hourly_retention = dict(cur.fetchone())
cur.execute(
"""
SELECT
COUNT(*) AS daily_rows,
MIN(bucket_date) AS daily_oldest,
MAX(bucket_date) AS daily_newest
FROM miner_status_daily_rollups;
"""
)
daily_retention = dict(cur.fetchone())
cur.execute(
"""
SELECT
mode,
raw_status_retention_days,
raw_status_rows_eligible_delete,
raw_status_rows_deleted,
hourly_rollup_rows_after,
daily_rollup_rows_after,
notes,
started_at,
finished_at
FROM minerops_retention_runs
ORDER BY started_at DESC
LIMIT 1;
"""
)
latest_retention_run = cur.fetchone()
latest_retention_run = dict(latest_retention_run) if latest_retention_run else None
return api_response(
data={
"app": {
"status": "ok",
"source_lines": len(source_lines),
"source_size_bytes": source_path.stat().st_size,
"route_count": len(routes),
"duplicate_route_count": len(duplicate_routes),
"middleware_count": len(middleware_lines),
"asset_route_count": len(asset_routes),
},
"routes": {
"duplicates": duplicate_routes,
"middleware_lines": middleware_lines,
"asset_routes": asset_routes,
"recent_patch_markers": patch_markers[-40:],
},
"database": {
"largest_tables": largest_tables,
"row_estimates": row_estimates,
"table_counts": table_counts,
"miner_status_counts": miner_status_counts,
"location_state_counts": location_state_counts,
"repair_status_counts": repair_status_counts,
"api3_summary": api3_summary,
"repair_queue_count": repair_queue_count,
"retention": {
"raw_status": raw_status_retention,
"hourly_rollups": hourly_retention,
"daily_rollups": daily_retention,
"latest_run": latest_retention_run,
},
},
}
)
finally:
if conn is not None:
conn.close()
MINEROPS_SYSTEM_HEALTH_HTML = r"""
<!DOCTYPE html>
<html>
<head>
<title>MinerOps System Health</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: Segoe UI, Arial, sans-serif;
background: #f4f7fb;
color: #102a43;
margin: 0;
padding: 22px;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.nav a {
border: 1px solid #bcccdc;
border-radius: 999px;
padding: 7px 11px;
background: #ffffff;
color: #0b63ce;
font-weight: 700;
text-decoration: none;
font-size: 13px;
}
.nav a.active {
background: #0f766e;
border-color: #0f766e;
color: #ffffff;
}
h1 {
margin: 0 0 4px 0;
font-size: 28px;
}
h2 {
margin-top: 0;
}
.muted {
color: #52606d;
font-size: 13px;
}
.card {
background: #ffffff;
border: 1px solid #d9e2ec;
border-radius: 12px;
padding: 14px;
margin-bottom: 14px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(145px, 1fr));
gap: 10px;
}
.metric {
border: 1px solid #d9e2ec;
border-radius: 10px;
background: #f8fafc;
padding: 11px;
}
.metric .value {
font-size: 24px;
font-weight: 800;
margin-top: 2px;
overflow-wrap: anywhere;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th, td {
border-bottom: 1px solid #d9e2ec;
padding: 7px;
text-align: left;
vertical-align: top;
}
th {
background: #f1f5f9;
}
.table-wrap {
overflow-x: auto;
}
button {
border: 1px solid #829ab1;
border-radius: 8px;
background: #f8fafc;
padding: 8px 10px;
font-weight: 700;
cursor: pointer;
}
button.primary {
background: #0f766e;
border-color: #0f766e;
color: #ffffff;
}
.status {
margin: 12px 0;
padding: 10px;
border-radius: 8px;
font-size: 13px;
}
.ok {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #166534;
}
.bad {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
}
.grid-two {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 14px;
}
code {
background: #e2e8f0;
padding: 1px 4px;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="nav">
<a href="/dashboard">Dashboard</a>
<a href="/repairs">Repairs</a>
<a href="/locations">Locations</a>
<a class="active" href="/system-health">System Health</a>
</div>
<div style="display:flex;justify-content:space-between;gap:12px;align-items:start;flex-wrap:wrap;margin-bottom:14px;">
<div>
<h1>System Health</h1>
<div class="muted">Quick operational view of MinerOps app size, routes, database tables, repairs, and location state.</div>
</div>
<button id="refreshBtn" type="button" class="primary">Refresh</button>
</div>
<div id="statusBox" class="status ok">Loading system health...</div>
<div class="card">
<h2>App Health</h2>
<div class="metrics">
<div class="metric"><div class="muted">Status</div><div id="appStatus" class="value">-</div></div>
<div class="metric"><div class="muted">main.py lines</div><div id="sourceLines" class="value">-</div></div>
<div class="metric"><div class="muted">Routes</div><div id="routeCount" class="value">-</div></div>
<div class="metric"><div class="muted">Duplicate routes</div><div id="duplicateRoutes" class="value">-</div></div>
<div class="metric"><div class="muted">Middleware</div><div id="middlewareCount" class="value">-</div></div>
<div class="metric"><div class="muted">Assets</div><div id="assetRouteCount" class="value">-</div></div>
</div>
</div>
<div class="card">
<h2>Fleet / Repair State</h2>
<div class="metrics">
<div class="metric"><div class="muted">Miners</div><div id="minerCount" class="value">-</div></div>
<div class="metric"><div class="muted">In Repair</div><div id="repairCount" class="value">-</div></div>
<div class="metric"><div class="muted">Shelved</div><div id="shelvedCount" class="value">-</div></div>
<div class="metric"><div class="muted">Waiting Placement</div><div id="waitingCount" class="value">-</div></div>
<div class="metric"><div class="muted">API3 Supported</div><div id="api3Supported" class="value">-</div></div>
<div class="metric"><div class="muted">API3 Failed</div><div id="api3Failed" class="value">-</div></div>
</div>
</div>
<div class="card">
<h2>Status Retention</h2>
<div class="metrics">
<div class="metric"><div class="muted">Raw Status Rows</div><div id="rawStatusRows" class="value">-</div></div>
<div class="metric"><div class="muted">Older Than 30 Days</div><div id="rawOlder30" class="value">-</div></div>
<div class="metric"><div class="muted">Hourly Rollups</div><div id="hourlyRollups" class="value">-</div></div>
<div class="metric"><div class="muted">Daily Rollups</div><div id="dailyRollups" class="value">-</div></div>
<div class="metric"><div class="muted">Last Retention Mode</div><div id="lastRetentionMode" class="value">-</div></div>
<div class="metric"><div class="muted">Last Deleted Rows</div><div id="lastDeletedRows" class="value">-</div></div>
</div>
<div id="retentionDetails" class="muted" style="margin-top:10px;">Loading retention details...</div>
</div>
<div class="grid-two">
<div class="card">
<h2>Largest Tables</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Table</th><th>Size</th></tr></thead>
<tbody id="largestTablesBody"><tr><td colspan="2">Loading...</td></tr></tbody>
</table>
</div>
</div>
<div class="card">
<h2>Repair Status Counts</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Status</th><th>Count</th></tr></thead>
<tbody id="repairStatusBody"><tr><td colspan="2">Loading...</td></tr></tbody>
</table>
</div>
</div>
</div>
<div class="grid-two">
<div class="card">
<h2>Duplicate Routes</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Method</th><th>Path</th><th>Count</th><th>Lines</th></tr></thead>
<tbody id="duplicateRoutesBody"><tr><td colspan="4">Loading...</td></tr></tbody>
</table>
</div>
</div>
<div class="card">
<h2>Recent Patch Markers</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Line</th><th>Marker</th></tr></thead>
<tbody id="patchMarkersBody"><tr><td colspan="2">Loading...</td></tr></tbody>
</table>
</div>
</div>
</div>
<script>
(function () {
"use strict";
function getStoredToken() {
const keys = [
"minerops_token",
"mineropsToken",
"authToken",
"access_token",
"token"
];
for (const storage of [window.localStorage, window.sessionStorage]) {
if (!storage) {
continue;
}
for (const key of keys) {
const value = storage.getItem(key);
if (value) {
return value;
}
}
}
return "";
}
function authHeaders(extra) {
const headers = Object.assign({}, extra || {});
const token = getStoredToken();
if (token && !headers.Authorization) {
headers.Authorization = "Bearer " + token;
}
return headers;
}
async function apiGet(url) {
const response = await fetch(url, {
method: "GET",
credentials: "same-origin",
headers: authHeaders({
"Accept": "application/json"
})
});
const payload = await response.json().catch(function () {
return null;
});
if (!response.ok || (payload && payload.error)) {
throw new Error((payload && payload.error) || "Request failed");
}
return payload ? payload.data : null;
}
function escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) {
el.textContent = value == null ? "-" : value;
}
}
function tableRows(rows, columns, emptyMessage) {
if (!rows || !rows.length) {
return '<tr><td colspan="' + columns.length + '">' + escapeHtml(emptyMessage || "No rows") + '</td></tr>';
}
return rows.map(function (row) {
return "<tr>" + columns.map(function (col) {
let value = row[col];
if (Array.isArray(value)) {
value = value.join(", ");
}
return "<td>" + escapeHtml(value) + "</td>";
}).join("") + "</tr>";
}).join("");
}
function countByKey(rows, keyName, valueName) {
const result = {};
for (const row of rows || []) {
result[row[keyName]] = row[valueName];
}
return result;
}
async function loadHealth() {
const statusBox = document.getElementById("statusBox");
try {
statusBox.className = "status ok";
statusBox.textContent = "Loading system health...";
const data = await apiGet("/api/system/health-summary");
const app = data.app || {};
const db = data.database || {};
const routes = data.routes || {};
setText("appStatus", app.status || "-");
setText("sourceLines", Number(app.source_lines || 0).toLocaleString());
setText("routeCount", app.route_count || 0);
setText("duplicateRoutes", app.duplicate_route_count || 0);
setText("middlewareCount", app.middleware_count || 0);
setText("assetRouteCount", app.asset_route_count || 0);
const locationCounts = countByKey(db.location_state_counts, "asset_state", "count");
const tableCounts = db.table_counts || {};
const api3 = db.api3_summary || {};
const retention = db.retention || {};
const rawStatus = retention.raw_status || {};
const hourly = retention.hourly_rollups || {};
const daily = retention.daily_rollups || {};
const latestRetention = retention.latest_run || {};
setText("minerCount", tableCounts.miners || 0);
setText("repairCount", db.repair_queue_count || 0);
setText("shelvedCount", locationCounts.shelved || 0);
setText("waitingCount", locationCounts.waiting_placement || 0);
setText("api3Supported", api3.api3_supported || 0);
setText("api3Failed", api3.api3_failed || 0);
setText("rawStatusRows", Number(rawStatus.raw_status_rows || 0).toLocaleString());
setText("rawOlder30", Number(rawStatus.raw_older_than_30d || 0).toLocaleString());
setText("hourlyRollups", Number(hourly.hourly_rows || 0).toLocaleString());
setText("dailyRollups", Number(daily.daily_rows || 0).toLocaleString());
setText("lastRetentionMode", latestRetention.mode || "-");
setText("lastDeletedRows", Number(latestRetention.raw_status_rows_deleted || 0).toLocaleString());
const retentionDetails = document.getElementById("retentionDetails");
if (retentionDetails) {
retentionDetails.innerHTML =
"Raw range: <code>" + escapeHtml(rawStatus.raw_oldest || "-") + "</code> to <code>" + escapeHtml(rawStatus.raw_newest || "-") + "</code><br>" +
"Hourly range: <code>" + escapeHtml(hourly.hourly_oldest || "-") + "</code> to <code>" + escapeHtml(hourly.hourly_newest || "-") + "</code><br>" +
"Daily range: <code>" + escapeHtml(daily.daily_oldest || "-") + "</code> to <code>" + escapeHtml(daily.daily_newest || "-") + "</code><br>" +
"Latest run: <code>" + escapeHtml(latestRetention.started_at || "-") + "</code> | " + escapeHtml(latestRetention.notes || "-");
}
document.getElementById("largestTablesBody").innerHTML = tableRows(
db.largest_tables || [],
["table_name", "total_size"],
"No table size data"
);
document.getElementById("repairStatusBody").innerHTML = tableRows(
db.repair_status_counts || [],
["event_status", "count"],
"No repair events"
);
document.getElementById("duplicateRoutesBody").innerHTML = tableRows(
routes.duplicates || [],
["method", "path", "count", "lines"],
"No duplicate routes"
);
document.getElementById("patchMarkersBody").innerHTML = tableRows(
routes.recent_patch_markers || [],
["line", "text"],
"No patch markers"
);
statusBox.className = "status ok";
statusBox.textContent = "System health loaded.";
} catch (error) {
statusBox.className = "status bad";
statusBox.textContent = "System health failed to load: " + (error.message || error);
}
}
document.getElementById("refreshBtn").addEventListener("click", loadHealth);
loadHealth();
})();
</script>
</body>
</html>
"""
@app.get("/system-health", response_class=MinerOpsSystemHealthHTMLResponse)
def minerops_system_health_page():
return MINEROPS_SYSTEM_HEALTH_HTML
# MINEROPS_SYSTEM_HEALTH_RETENTION_V1 # MINEROPS_SYSTEM_HEALTH_RETENTION_V1
# MINEROPS_REPAIR_READY_TO_SHELF_STATUS_V1 # MINEROPS_REPAIR_READY_TO_SHELF_STATUS_V1
+1
View File
@@ -0,0 +1 @@
"""System Health diagnostics domain."""
+29
View File
@@ -0,0 +1,29 @@
"""External service adapters for System Health."""
from collections.abc import Callable
import redis
from app.config import settings
RedisFactory = Callable[[], redis.Redis]
def default_redis_factory() -> redis.Redis:
return redis.Redis.from_url(
settings.redis_url,
socket_connect_timeout=2,
socket_timeout=2,
)
class RedisHealthAdapter:
def __init__(self, factory: RedisFactory | None = None):
self._factory = factory or default_redis_factory
def ping(self) -> bool:
try:
return bool(self._factory().ping())
except Exception:
return False
+42
View File
@@ -0,0 +1,42 @@
"""Startup-cached, non-secret application metadata."""
from dataclasses import dataclass
from pathlib import Path
from alembic.config import Config
from alembic.script import ScriptDirectory
from app.config import settings
def _clean_build_value(value: str | None) -> str | None:
if value is None:
return None
cleaned = value.strip()
return cleaned[:128] if cleaned else None
def _expected_migration_head() -> str | None:
try:
backend_root = Path(__file__).resolve().parents[2]
config = Config(str(backend_root / "alembic.ini"))
config.set_main_option("script_location", str(backend_root / "alembic"))
return ScriptDirectory.from_config(config).get_current_head()
except Exception:
return None
@dataclass(frozen=True)
class ApplicationMetadata:
environment: str
build_commit: str | None
build_version: str | None
expected_migration_head: str | None
APPLICATION_METADATA = ApplicationMetadata(
environment=settings.app_env,
build_commit=_clean_build_value(settings.minerops_build_commit),
build_version=_clean_build_value(settings.minerops_build_version),
expected_migration_head=_expected_migration_head(),
)
@@ -0,0 +1,9 @@
"""Dependency construction for System Health."""
from app.system_health.adapters import RedisHealthAdapter
from app.system_health.repository import SystemHealthRepository
from app.system_health.service import SystemHealthService
def get_system_health_service() -> SystemHealthService:
return SystemHealthService(SystemHealthRepository(), RedisHealthAdapter())
+35
View File
@@ -0,0 +1,35 @@
"""Authenticated frontend routes for System Health."""
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__).parents[1] / "frontend" / "static"
SystemHealthUser = Annotated[dict, Depends(require_permission("system_health.view"))]
@router.get("/assets/minerops-system-health.css", include_in_schema=False)
def system_health_styles() -> Response:
return Response((STATIC_ROOT / "system-health.css").read_text(encoding="utf-8"),
media_type="text/css", headers={"Cache-Control": "no-store"})
@router.get("/assets/minerops-system-health.js", include_in_schema=False)
def system_health_script() -> Response:
return Response((STATIC_ROOT / "system-health.js").read_text(encoding="utf-8"),
media_type="text/javascript", headers={"Cache-Control": "no-store"})
@router.get("/system-health", response_class=HTMLResponse, include_in_schema=False)
def system_health_page(current_user: SystemHealthUser) -> str:
return render_app_page(
"system_health", current_user,
lambda permission: user_has_permission(current_user, permission),
)
+124
View File
@@ -0,0 +1,124 @@
"""Typed contracts for the System Health overview."""
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, Field
Availability = Literal["available", "unavailable"]
MigrationState = Literal["current", "mismatch", "unavailable"]
class ApplicationDiagnostics(BaseModel):
status: Literal["available"] = "available"
environment: str
build_commit: str | None = None
build_version: str | None = None
server_time: datetime
expected_migration_head: str | None = None
class MigrationDiagnostics(BaseModel):
state: MigrationState
current_revision: str | None = None
expected_revision: str | None = None
class TableCount(BaseModel):
name: str
row_count: int
class TableSize(BaseModel):
name: str
total_bytes: int
class TelemetryDiagnostics(BaseModel):
snapshot_count: int
oldest_observed_at: datetime | None = None
latest_observed_at: datetime | None = None
class FleetIntegrityDiagnostics(BaseModel):
total_miners: int
miners_without_asset_id: int
unmapped_miners: int
miners_in_multiple_positions: int
duplicate_current_mac_groups: int
class LocationIntegrityDiagnostics(BaseModel):
sites: int
rooms: int
positions: int
occupied_positions: int
empty_positions: int
unscoped_legacy_positions: int
expected_mac_mismatches: int
class RepairIntegrityDiagnostics(BaseModel):
active_modular_repairs: int
duplicate_active_repair_groups: int
legacy_repair_only_miners: int
lifecycle_repair_without_active_repair: int
active_repair_lifecycle_mismatches: int
waiting_placement_records: int
repair_events: int
repair_parts: int
class RollupDiagnostics(BaseModel):
table_present: bool
row_count: int | None = None
latest_bucket: datetime | date | None = None
population: Literal["manual"] = "manual"
class RetentionDiagnostics(BaseModel):
raw_retention_enabled: Literal[False] = False
raw_retention_mode: Literal["not_enabled"] = "not_enabled"
hourly: RollupDiagnostics
daily: RollupDiagnostics
latest_recorded_run_at: datetime | None = None
latest_recorded_run_mode: str | None = None
completeness: Literal["not_guaranteed"] = "not_guaranteed"
class PostgreSQLDiagnostics(BaseModel):
status: Availability
migration: MigrationDiagnostics
table_counts: list[TableCount] = Field(default_factory=list)
largest_tables: list[TableSize] = Field(default_factory=list)
telemetry: TelemetryDiagnostics | None = None
fleet: FleetIntegrityDiagnostics | None = None
locations: LocationIntegrityDiagnostics | None = None
repairs: RepairIntegrityDiagnostics | None = None
retention: RetentionDiagnostics | None = None
class RedisDiagnostics(BaseModel):
status: Availability
class SystemHealthOverviewData(BaseModel):
application: ApplicationDiagnostics
postgresql: PostgreSQLDiagnostics
redis: RedisDiagnostics
class SystemHealthOverviewMeta(BaseModel):
generated_at: datetime
partial: bool
unavailable_sections: list[str] = Field(default_factory=list)
frontend_request_count: Literal[1] = 1
postgresql_statement_count: Literal[3] = 3
redis_call_count: Literal[1] = 1
class SystemHealthOverviewResponse(BaseModel):
data: SystemHealthOverviewData
meta: SystemHealthOverviewMeta
error: None = None
+179
View File
@@ -0,0 +1,179 @@
"""PostgreSQL diagnostics for System Health."""
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from typing import Any
import psycopg
from psycopg.rows import dict_row
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
def default_connection_factory() -> psycopg.Connection:
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
connect_timeout=3,
)
MIGRATION_SQL = """
SELECT clock_timestamp() AS database_time,
(SELECT version_num FROM alembic_version LIMIT 1) AS current_revision
"""
OPERATIONS_SQL = """
WITH telemetry AS (
SELECT count(*)::bigint AS snapshot_count,
min(observed_at) AS oldest_observed_at,
max(observed_at) AS latest_observed_at
FROM miner_status_snapshots
),
hourly AS (
SELECT count(*)::bigint AS row_count, max(bucket_at) AS latest_bucket
FROM miner_status_hourly_rollups
),
daily AS (
SELECT count(*)::bigint AS row_count, max(bucket_date) AS latest_bucket
FROM miner_status_daily_rollups
),
latest_retention AS (
SELECT mode, started_at FROM minerops_retention_runs
ORDER BY started_at DESC LIMIT 1
),
largest AS (
SELECT coalesce(jsonb_agg(to_jsonb(sized) ORDER BY sized.total_bytes DESC), '[]'::jsonb) AS rows
FROM (
SELECT relname AS name, pg_total_relation_size(relid)::bigint AS total_bytes
FROM pg_catalog.pg_statio_user_tables
WHERE relname = ANY(%s)
ORDER BY pg_total_relation_size(relid) DESC, relname
LIMIT 8
) AS sized
)
SELECT
(SELECT count(*) FROM miners)::bigint AS miners,
(SELECT count(*) FROM sites)::bigint AS sites,
(SELECT count(*) FROM rooms)::bigint AS rooms,
(SELECT count(*) FROM location_positions)::bigint AS location_positions,
(SELECT count(*) FROM repairs)::bigint AS repairs,
(SELECT count(*) FROM repair_events)::bigint AS repair_events,
(SELECT count(*) FROM repair_parts)::bigint AS repair_parts,
telemetry.snapshot_count, telemetry.oldest_observed_at,
telemetry.latest_observed_at,
to_regclass('public.miner_status_hourly_rollups') IS NOT NULL AS hourly_present,
hourly.row_count AS hourly_rows, hourly.latest_bucket AS hourly_latest,
to_regclass('public.miner_status_daily_rollups') IS NOT NULL AS daily_present,
daily.row_count AS daily_rows, daily.latest_bucket AS daily_latest,
latest_retention.mode AS retention_mode,
latest_retention.started_at AS retention_started_at,
largest.rows AS largest_tables
FROM telemetry CROSS JOIN hourly CROSS JOIN daily CROSS JOIN largest
LEFT JOIN latest_retention ON true
"""
INTEGRITY_SQL = """
WITH position_counts AS (
SELECT miner_id, count(*) AS position_count
FROM location_positions WHERE miner_id IS NOT NULL GROUP BY miner_id
),
active_repairs AS (
SELECT id, miner_id FROM repairs
WHERE status IN ('OPEN', 'IN_PROGRESS', 'WAITING_FOR_PARTS', 'READY_FOR_TEST')
),
active_repair_groups AS (
SELECT miner_id FROM active_repairs GROUP BY miner_id HAVING count(*) > 1
),
normalized_macs AS (
SELECT lower(regexp_replace(trim(current_mac), '[^0-9A-Fa-f]', '', 'g')) AS mac
FROM miners WHERE current_mac IS NOT NULL AND trim(current_mac) <> ''
GROUP BY lower(regexp_replace(trim(current_mac), '[^0-9A-Fa-f]', '', 'g'))
HAVING count(*) > 1
)
SELECT
(SELECT count(*) FROM miners WHERE asset_tag IS NULL OR trim(asset_tag) = '')::bigint
AS miners_without_asset_id,
(SELECT count(*) FROM miners m WHERE NOT EXISTS (
SELECT 1 FROM location_positions p WHERE p.miner_id = m.id
))::bigint AS unmapped_miners,
(SELECT count(*) FROM position_counts WHERE position_count > 1)::bigint
AS miners_in_multiple_positions,
(SELECT count(*) FROM normalized_macs)::bigint AS duplicate_current_mac_groups,
(SELECT count(*) FROM location_positions WHERE room_id IS NULL)::bigint
AS unscoped_legacy_positions,
(SELECT count(*) FROM location_positions p JOIN miners m ON m.id = p.miner_id
WHERE p.expected_mac IS NOT NULL AND trim(p.expected_mac) <> ''
AND m.current_mac IS NOT NULL AND trim(m.current_mac) <> ''
AND lower(regexp_replace(trim(p.expected_mac), '[^0-9A-Fa-f]', '', 'g'))
<> lower(regexp_replace(trim(m.current_mac), '[^0-9A-Fa-f]', '', 'g'))
)::bigint AS expected_mac_mismatches,
(SELECT count(*) FROM location_positions WHERE miner_id IS NOT NULL)::bigint
AS occupied_positions,
(SELECT count(*) FROM location_positions WHERE miner_id IS NULL)::bigint
AS empty_positions,
(SELECT count(*) FROM active_repairs)::bigint AS active_modular_repairs,
(SELECT count(*) FROM active_repair_groups)::bigint AS duplicate_active_repair_groups,
(SELECT count(*) FROM miner_location_states state
WHERE state.asset_state = 'repair' AND NOT EXISTS (
SELECT 1 FROM active_repairs repair WHERE repair.miner_id = state.miner_id
))::bigint AS legacy_repair_only_miners,
(SELECT count(*) FROM miners m WHERE m.lifecycle_status = 'in_repair'
AND NOT EXISTS (SELECT 1 FROM active_repairs r WHERE r.miner_id = m.id))::bigint
AS lifecycle_repair_without_active_repair,
(SELECT count(*) FROM active_repairs r JOIN miners m ON m.id = r.miner_id
WHERE m.lifecycle_status <> 'in_repair')::bigint
AS active_repair_lifecycle_mismatches,
(SELECT count(*) FROM miner_location_states WHERE asset_state = 'waiting_placement')::bigint
AS waiting_placement_records
"""
RELEVANT_TABLES = [
"miner_status_snapshots", "repair_events", "repair_parts", "repairs",
"location_positions", "miners", "miner_status_hourly_rollups",
"miner_status_daily_rollups",
]
class SystemHealthRepository:
def __init__(
self,
connection_factory: ConnectionFactory | None = None,
*,
connection: psycopg.Connection | None = None,
):
self._connection_factory = connection_factory or default_connection_factory
self._connection = connection
@contextmanager
def _connection_scope(self) -> Iterator[psycopg.Connection]:
if self._connection is not None:
yield self._connection
return
connection = self._connection_factory()
try:
yield connection
finally:
connection.close()
def read_overview(self) -> dict[str, Any]:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(MIGRATION_SQL)
migration = dict(cur.fetchone())
cur.execute(OPERATIONS_SQL, (RELEVANT_TABLES,))
operations = dict(cur.fetchone())
cur.execute(INTEGRITY_SQL)
integrity = dict(cur.fetchone())
return {
"migration": migration,
"operations": operations,
"integrity": integrity,
}
+27
View File
@@ -0,0 +1,27 @@
"""HTTP boundary for the System Health overview."""
from typing import Annotated
from fastapi import APIRouter, Depends, Response
from app.identity import require_permission
from app.system_health.dependencies import get_system_health_service
from app.system_health.models import SystemHealthOverviewResponse
from app.system_health.service import SystemHealthService
router = APIRouter(prefix="/api/system", tags=["system-health"])
SystemHealthUser = Annotated[dict, Depends(require_permission("system_health.view"))]
SystemHealthServiceDependency = Annotated[
SystemHealthService, Depends(get_system_health_service)
]
@router.get("/health-summary", response_model=SystemHealthOverviewResponse)
def get_system_health_overview(
current_user: SystemHealthUser,
service: SystemHealthServiceDependency,
response: Response,
) -> SystemHealthOverviewResponse:
response.headers["Cache-Control"] = "no-store"
return service.get_overview()
+136
View File
@@ -0,0 +1,136 @@
"""Composition and failure semantics for System Health diagnostics."""
from datetime import datetime, timezone
from app.system_health.adapters import RedisHealthAdapter
from app.system_health.application import APPLICATION_METADATA, ApplicationMetadata
from app.system_health.models import (
ApplicationDiagnostics, FleetIntegrityDiagnostics, LocationIntegrityDiagnostics,
MigrationDiagnostics, PostgreSQLDiagnostics, RedisDiagnostics,
RepairIntegrityDiagnostics, RetentionDiagnostics, RollupDiagnostics,
SystemHealthOverviewData, SystemHealthOverviewMeta,
SystemHealthOverviewResponse, TableCount, TableSize, TelemetryDiagnostics,
)
from app.system_health.repository import SystemHealthRepository
class SystemHealthService:
def __init__(self, repository: SystemHealthRepository,
redis_adapter: RedisHealthAdapter,
metadata: ApplicationMetadata = APPLICATION_METADATA):
self.repository = repository
self.redis_adapter = redis_adapter
self.metadata = metadata
def get_overview(self) -> SystemHealthOverviewResponse:
generated_at = datetime.now(timezone.utc)
unavailable = []
try:
postgresql = self._postgresql_diagnostics(
self.repository.read_overview()
)
except Exception:
unavailable.append("postgresql")
postgresql = PostgreSQLDiagnostics(
status="unavailable",
migration=MigrationDiagnostics(
state="unavailable",
expected_revision=self.metadata.expected_migration_head,
),
)
redis_available = self.redis_adapter.ping()
if not redis_available:
unavailable.append("redis")
return SystemHealthOverviewResponse(
data=SystemHealthOverviewData(
application=ApplicationDiagnostics(
environment=self.metadata.environment,
build_commit=self.metadata.build_commit,
build_version=self.metadata.build_version,
server_time=generated_at,
expected_migration_head=self.metadata.expected_migration_head,
),
postgresql=postgresql,
redis=RedisDiagnostics(
status="available" if redis_available else "unavailable"
),
),
meta=SystemHealthOverviewMeta(
generated_at=generated_at,
partial=bool(unavailable),
unavailable_sections=unavailable,
),
)
def _postgresql_diagnostics(self, payload: dict) -> PostgreSQLDiagnostics:
migration = payload["migration"]
operations = payload["operations"]
integrity = payload["integrity"]
current = migration.get("current_revision")
expected = self.metadata.expected_migration_head
state = (
"unavailable" if not current or not expected
else "current" if current == expected else "mismatch"
)
counts = [
TableCount(name=name, row_count=int(operations[name]))
for name in (
"miners", "sites", "rooms", "location_positions", "repairs",
"repair_events", "repair_parts", "snapshot_count",
)
]
return PostgreSQLDiagnostics(
status="available",
migration=MigrationDiagnostics(
state=state, current_revision=current, expected_revision=expected
),
table_counts=counts,
largest_tables=[
TableSize(name=row["name"], total_bytes=int(row["total_bytes"]))
for row in operations.get("largest_tables") or []
],
telemetry=TelemetryDiagnostics(
snapshot_count=int(operations["snapshot_count"]),
oldest_observed_at=operations.get("oldest_observed_at"),
latest_observed_at=operations.get("latest_observed_at"),
),
fleet=FleetIntegrityDiagnostics(
total_miners=int(operations["miners"]),
miners_without_asset_id=int(integrity["miners_without_asset_id"]),
unmapped_miners=int(integrity["unmapped_miners"]),
miners_in_multiple_positions=int(integrity["miners_in_multiple_positions"]),
duplicate_current_mac_groups=int(integrity["duplicate_current_mac_groups"]),
),
locations=LocationIntegrityDiagnostics(
sites=int(operations["sites"]), rooms=int(operations["rooms"]),
positions=int(operations["location_positions"]),
occupied_positions=int(integrity["occupied_positions"]),
empty_positions=int(integrity["empty_positions"]),
unscoped_legacy_positions=int(integrity["unscoped_legacy_positions"]),
expected_mac_mismatches=int(integrity["expected_mac_mismatches"]),
),
repairs=RepairIntegrityDiagnostics(
active_modular_repairs=int(integrity["active_modular_repairs"]),
duplicate_active_repair_groups=int(integrity["duplicate_active_repair_groups"]),
legacy_repair_only_miners=int(integrity["legacy_repair_only_miners"]),
lifecycle_repair_without_active_repair=int(integrity["lifecycle_repair_without_active_repair"]),
active_repair_lifecycle_mismatches=int(integrity["active_repair_lifecycle_mismatches"]),
waiting_placement_records=int(integrity["waiting_placement_records"]),
repair_events=int(operations["repair_events"]),
repair_parts=int(operations["repair_parts"]),
),
retention=RetentionDiagnostics(
hourly=RollupDiagnostics(
table_present=bool(operations["hourly_present"]),
row_count=int(operations["hourly_rows"]),
latest_bucket=operations.get("hourly_latest"),
),
daily=RollupDiagnostics(
table_present=bool(operations["daily_present"]),
row_count=int(operations["daily_rows"]),
latest_bucket=operations.get("daily_latest"),
),
latest_recorded_run_at=operations.get("retention_started_at"),
latest_recorded_run_mode=operations.get("retention_mode"),
),
)
+304
View File
@@ -0,0 +1,304 @@
import ast
import unittest
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
import psycopg
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.config import settings
from app.identity import get_current_user
from app.system_health.application import ApplicationMetadata
from app.system_health.dependencies import get_system_health_service
from app.system_health.frontend import router as frontend_router
from app.system_health.repository import SystemHealthRepository
from app.system_health.router import router as api_router
from app.system_health.service import SystemHealthService
APP_ROOT = Path(__file__).parents[1] / "app"
EXPECTED_HEAD = "0014_repair_parts_tracking"
def database_payload(revision=EXPECTED_HEAD):
return {
"migration": {"current_revision": revision},
"operations": {
"miners": 4, "sites": 1, "rooms": 2, "location_positions": 3,
"repairs": 1, "repair_events": 2, "repair_parts": 1,
"snapshot_count": 5,
"oldest_observed_at": datetime(2026, 8, 18, tzinfo=timezone.utc),
"latest_observed_at": datetime(2026, 8, 19, tzinfo=timezone.utc),
"hourly_present": True, "hourly_rows": 2,
"hourly_latest": datetime(2026, 8, 19, tzinfo=timezone.utc),
"daily_present": True, "daily_rows": 1,
"daily_latest": datetime(2026, 8, 19, tzinfo=timezone.utc).date(),
"retention_mode": "dry_run",
"retention_started_at": datetime(2026, 8, 19, tzinfo=timezone.utc),
"largest_tables": [{"name": "miners", "total_bytes": 8192}],
},
"integrity": {
"miners_without_asset_id": 1, "unmapped_miners": 1,
"miners_in_multiple_positions": 0, "duplicate_current_mac_groups": 0,
"unscoped_legacy_positions": 1, "expected_mac_mismatches": 1,
"occupied_positions": 2, "empty_positions": 1,
"active_modular_repairs": 1, "duplicate_active_repair_groups": 0,
"legacy_repair_only_miners": 0,
"lifecycle_repair_without_active_repair": 0,
"active_repair_lifecycle_mismatches": 0,
"waiting_placement_records": 1,
},
}
class FakeRepository:
def __init__(self, payload=None, error=None):
self.payload = payload or database_payload()
self.error = error
self.calls = 0
def read_overview(self):
self.calls += 1
if self.error:
raise self.error
return self.payload
class FakeRedis:
def __init__(self, available=True):
self.available = available
self.calls = 0
def ping(self):
self.calls += 1
return self.available
def metadata(head=EXPECTED_HEAD):
return ApplicationMetadata(
environment="test", build_commit=None, build_version="0.1-test",
expected_migration_head=head,
)
class SystemHealthServiceTests(unittest.TestCase):
def test_builds_typed_factual_overview_without_health_score(self):
repository, redis = FakeRepository(), FakeRedis()
result = SystemHealthService(repository, redis, metadata()).get_overview()
self.assertEqual(result.data.postgresql.migration.state, "current")
self.assertEqual(result.data.postgresql.telemetry.snapshot_count, 5)
self.assertEqual(result.data.postgresql.fleet.unmapped_miners, 1)
self.assertFalse(result.data.postgresql.retention.raw_retention_enabled)
self.assertEqual(result.data.postgresql.retention.hourly.population, "manual")
self.assertEqual(result.data.postgresql.retention.completeness, "not_guaranteed")
self.assertIsNone(result.data.application.build_commit)
self.assertFalse(result.meta.partial)
self.assertEqual((repository.calls, redis.calls), (1, 1))
self.assertNotIn("score", result.model_dump_json().lower())
def test_migration_mismatch_is_factual(self):
result = SystemHealthService(
FakeRepository(database_payload("0013_control_board_replacement")),
FakeRedis(), metadata(),
).get_overview()
self.assertEqual(result.data.postgresql.migration.state, "mismatch")
self.assertEqual(
result.data.postgresql.migration.current_revision,
"0013_control_board_replacement",
)
def test_postgresql_failure_is_sanitized_and_redis_remains_available(self):
secret = "postgresql://user:password@private-host/minerops"
result = SystemHealthService(
FakeRepository(error=RuntimeError(secret)), FakeRedis(), metadata()
).get_overview()
self.assertTrue(result.meta.partial)
self.assertEqual(result.meta.unavailable_sections, ["postgresql"])
self.assertEqual(result.data.postgresql.status, "unavailable")
self.assertEqual(result.data.redis.status, "available")
self.assertNotIn(secret, result.model_dump_json())
def test_redis_failure_preserves_database_diagnostics(self):
result = SystemHealthService(
FakeRepository(), FakeRedis(False), metadata()
).get_overview()
self.assertEqual(result.data.postgresql.status, "available")
self.assertEqual(result.data.redis.status, "unavailable")
self.assertEqual(result.meta.unavailable_sections, ["redis"])
class StubService:
def get_overview(self):
return SystemHealthService(
FakeRepository(), FakeRedis(), metadata()
).get_overview()
class SystemHealthRouterTests(unittest.TestCase):
def setUp(self):
self.app = FastAPI()
self.app.include_router(api_router)
self.app.dependency_overrides[get_system_health_service] = StubService
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "role": "Custom", "permissions": list(permissions)
}
def test_requires_authentication_and_system_health_permission(self):
self.assertEqual(self.client.get("/api/system/health-summary").status_code, 401)
self.authenticate(("dashboard.view",))
self.assertEqual(self.client.get("/api/system/health-summary").status_code, 403)
def test_returns_typed_no_store_envelope(self):
self.authenticate(("system_health.view",))
response = self.client.get("/api/system/health-summary")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["cache-control"], "no-store")
payload = response.json()
self.assertEqual(payload["data"]["postgresql"]["status"], "available")
self.assertEqual(payload["meta"]["postgresql_statement_count"], 3)
self.assertIsNone(payload["error"])
class CountingCursor(psycopg.Cursor):
statements = 0
def execute(self, query, params=None, *, prepare=None, binary=None):
type(self).statements += 1
return super().execute(
query, params, prepare=prepare, binary=binary
)
class SystemHealthRepositoryIntegrationTests(unittest.TestCase):
def setUp(self):
self.connection = psycopg.connect(
host=settings.postgres_host, port=settings.postgres_port,
dbname=settings.postgres_db, user=settings.postgres_user,
password=settings.postgres_password, cursor_factory=CountingCursor,
)
CountingCursor.statements = 0
self.repository = SystemHealthRepository(connection=self.connection)
def tearDown(self):
self.connection.rollback()
self.connection.close()
def test_real_postgresql_overview_uses_exactly_three_statements(self):
site_id, room_id, mapped_id, unmapped_id = (
uuid4(), uuid4(), uuid4(), uuid4()
)
suffix = uuid4().hex[:10]
with self.connection.cursor() as cur:
cur.execute("INSERT INTO sites (id, name) VALUES (%s, %s)",
(site_id, "Health Site " + suffix))
cur.execute("INSERT INTO rooms (id, site_id, name) VALUES (%s, %s, %s)",
(room_id, site_id, "Health Room " + suffix))
cur.execute(
"INSERT INTO miners (id, asset_tag, lifecycle_status, current_mac) "
"VALUES (%s, %s, 'active', %s), (%s, NULL, 'active', NULL)",
(mapped_id, "HEALTH-" + suffix, "02:00:00:00:00:01", unmapped_id),
)
cur.execute(
"INSERT INTO location_positions "
"(room_id, room_name, rack_name, shelf_name, position_index, "
"miner_id, expected_mac) VALUES (%s, %s, %s, '1', 1, %s, %s), "
"(NULL, %s, %s, '1', 2, NULL, NULL)",
(room_id, "Health Room " + suffix, "Rack " + suffix, mapped_id,
"02:00:00:00:00:02", "Legacy " + suffix, "Rack " + suffix),
)
cur.execute(
"INSERT INTO miner_status_snapshots (miner_id, observed_at, mhs_av) "
"VALUES (%s, %s, 0)",
(mapped_id, datetime(2026, 8, 19, 12, tzinfo=timezone.utc)),
)
CountingCursor.statements = 0
result = self.repository.read_overview()
self.assertEqual(CountingCursor.statements, 3)
self.assertGreaterEqual(result["operations"]["miners"], 2)
self.assertGreaterEqual(result["operations"]["snapshot_count"], 1)
self.assertEqual(result["migration"]["current_revision"], EXPECTED_HEAD)
self.assertGreaterEqual(result["integrity"]["unmapped_miners"], 1)
self.assertGreaterEqual(result["integrity"]["unscoped_legacy_positions"], 1)
self.assertGreaterEqual(result["integrity"]["expected_mac_mismatches"], 1)
self.assertTrue(result["operations"]["hourly_present"])
self.assertTrue(result["operations"]["daily_present"])
def test_existing_role_matrix_preserves_read_only_grant(self):
with self.connection.cursor() as cur:
cur.execute(
"SELECT roles.name FROM role_permissions "
"JOIN roles ON roles.id = role_permissions.role_id "
"JOIN permissions ON permissions.id = role_permissions.permission_id "
"WHERE permissions.key = 'system_health.view' ORDER BY roles.name"
)
roles = [row[0] for row in cur.fetchall()]
self.assertEqual(roles, ["Admin", "Manager", "Read Only"])
class SystemHealthFrontendAndArchitectureTests(unittest.TestCase):
def test_page_is_semantic_safe_and_one_request(self):
app = FastAPI()
app.include_router(frontend_router)
app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "display_name": "Health <Operator>",
"role": "Manager", "permissions": ["system_health.view"],
}
client = TestClient(app)
response = client.get("/system-health")
self.assertEqual(response.status_code, 200)
self.assertIn("System Health | MinerOps", response.text)
self.assertNotIn("Health <Operator>", response.text)
self.assertIn("aria-live=\"polite\"", response.text)
client.close()
script = (APP_ROOT / "frontend/static/system-health.js").read_text()
self.assertEqual(script.count('fetch("/api/system/health-summary"'), 1)
self.assertNotIn("setInterval", script)
self.assertNotIn("innerHTML", script)
self.assertIn("textContent", script)
def test_routes_are_unique_and_legacy_source_diagnostics_are_removed(self):
source = (APP_ROOT / "main.py").read_text()
self.assertNotIn("source_lines", source)
self.assertNotIn("recent_patch_markers", source)
from app.main import app
page = [r for r in app.routes if getattr(r, "path", None) == "/system-health"]
api = [r for r in app.routes if getattr(r, "path", None) == "/api/system/health-summary"]
self.assertEqual(len(page), 1)
self.assertEqual(len(api), 1)
self.assertEqual(page[0].methods, {"GET"})
self.assertEqual(api[0].methods, {"GET"})
def test_layers_and_security_boundaries(self):
router_source = (APP_ROOT / "system_health/router.py").read_text()
service_source = (APP_ROOT / "system_health/service.py").read_text()
repository_source = (APP_ROOT / "system_health/repository.py").read_text()
ast.parse(router_source)
ast.parse(service_source)
self.assertNotIn("SELECT ", router_source)
self.assertNotIn("SELECT ", service_source)
self.assertNotIn("fastapi", repository_source.lower())
self.assertNotIn("fastapi", service_source.lower())
static = "\n".join(
(APP_ROOT / path).read_text()
for path in (
"frontend/static/system-health.js",
"frontend/static/system-health.css",
)
).lower()
for forbidden in (
"postgres_password", "redis_url", "jwt_secret", "connection string",
"stack trace", "password hash", "api key",
):
self.assertNotIn(forbidden, static)
if __name__ == "__main__":
unittest.main()
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
readonly source_files=(
backend/app/config.py
backend/app/frontend/static/system-health.css
backend/app/frontend/static/system-health.js
backend/app/frontend/system_health_view.py
backend/app/frontend/views.py
backend/app/main.py
backend/app/system_health/__init__.py
backend/app/system_health/adapters.py
backend/app/system_health/application.py
backend/app/system_health/dependencies.py
backend/app/system_health/frontend.py
backend/app/system_health/models.py
backend/app/system_health/repository.py
backend/app/system_health/router.py
backend/app/system_health/service.py
backend/tests/__init__.py
backend/tests/test_frontend_shell.py
backend/tests/test_system_health.py
)
for file in "${source_files[@]}"; do
[[ -r "${file}" ]] || { echo "Unreadable System Health input: ${file}" >&2; exit 1; }
[[ "$(stat -c '%a' "${file}")" == "644" ]] || {
echo "System Health source/test mode must be 0644: ${file}" >&2; exit 1;
}
done
[[ "$(stat -c '%a' scripts/test-system-health.sh)" == "755" ]] || {
echo "scripts/test-system-health.sh must have mode 0755." >&2; exit 1;
}
echo "System Health file-mode preflight passed."
python3 -m py_compile backend/app/config.py backend/app/frontend/system_health_view.py backend/app/frontend/views.py backend/app/main.py backend/app/system_health/*.py backend/tests/__init__.py backend/tests/test_frontend_shell.py backend/tests/test_system_health.py
node --check backend/app/frontend/static/system-health.js
bash -n scripts/test-system-health.sh
[[ -f .env.dev ]] || { echo "Missing .env.dev." >&2; exit 1; }
project_name="${MINEROPS_SYSTEM_HEALTH_TEST_PROJECT:-minerops-system-health-$$}"
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
cleanup() {
status=$?
if (( status != 0 )); then
"${compose[@]}" logs --no-color --tail 100 postgres redis || true
fi
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
exit "${status}"
}
trap cleanup EXIT
"${compose[@]}" up --detach postgres redis
for attempt in $(seq 1 60); do
if "${compose[@]}" exec -T postgres sh -ec \
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 \
&& "${compose[@]}" exec -T redis redis-cli ping 2>/dev/null | grep -qx PONG; then
break
fi
(( attempt < 60 )) || { echo "Dependencies did not become ready." >&2; exit 1; }
sleep 1
done
"${compose[@]}" build backend
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
"${compose[@]}" run --rm --no-deps \
-v "${repo_root}/backend/tests:/app/tests:ro" \
backend python -m unittest -v tests.test_system_health tests.test_frontend_shell