Author SHA1 Message Date
lussierj d8cd7ad058 fix: preserve Windows agent tasks during upgrades 2026-08-25 15:56:35 +00:00
lussierj f5965af914 feat: add safe miner control foundation 2026-08-24 20:49:28 +00:00
lussierj 6cd876b4b3 chore: add Windows agent source baseline 2026-08-24 20:40:20 +00:00
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 4d360fac42 feat: add monitoring trends 2026-08-19 20:00:52 +00:00
lussierj 19fc29c72c feat: add miner detail view 2026-08-17 18:43:20 +00:00
lussierj b8b741ccee feat: add operational Dashboard 2026-08-17 17:11:40 +00:00
lussierj 4a1959ecd6 feat: add Repair Center operations view 2026-08-11 20:17:16 +00:00
lussierj 786a1ab910 feat: add Fleet operations view 2026-08-11 17:37:36 +00:00
lussierj c6d01df31c fix: normalize Mine Map shelf labels 2026-08-11 15:31:01 +00:00
lussierj 06ed8248f5 feat: polish Mine Map UX 2026-08-11 14:27:09 +00:00
lussierj d06ee7bcc6 dev: add deterministic demo dataset 2026-08-10 21:33:50 +00:00
lussierj 60a831950c feat: add Mine Map v1 2026-08-10 19:55:20 +00:00
lussierj 548051899e docs: add Mine Map v1 design 2026-08-10 17:41:17 +00:00
lussierj b741fd799b feat: add internal alpha frontend shell 2026-08-10 17:26:07 +00:00
lussierj eeb3c7634e docs: add navigation design 2026-08-02 22:28:52 +00:00
lussierj 4656714837 docs: add UX and UI guide 2026-08-02 22:15:51 +00:00
lussierj 39832fcf80 docs: add project vision 2026-08-02 22:05:02 +00:00
lussierj eab9646960 feat: add repair parts tracking 2026-08-02 21:26:15 +00:00
lussierj 6e550c1c13 docs: finalize repair parts tracking task 2026-08-02 19:15:55 +00:00
lussierj 73f98685ce docs: add component domain model 2026-08-02 15:13:20 +00:00
lussierj bcf1b4e3cf feat: add control board replacement workflow 2026-08-02 14:34:16 +00:00
lussierj 62c91a2c3e docs: add repair domain design documents 2026-08-02 03:44:15 +00:00
lussierj f2b736b08e feat: add return to service workflow 2026-07-29 19:02:23 +00:00
lussierj 72ad19a071 docs: add development and release standards 2026-07-29 17:11:12 +00:00
lussierj 36a0caecd8 feat: add repair timeline read APIs 2026-07-29 16:28:44 +00:00
lussierj f6a2512e23 feat: add begin repair workflow 2026-07-29 14:50:46 +00:00
lussierj 577226cb4e Update README.md 2026-07-28 11:21:26 -05:00
175 changed files with 35566 additions and 1458 deletions

No files matched your search

+269 -145
View File
@@ -1,198 +1,322 @@
<p align="center">
<img src="branding/wordmark.svg" alt="MinerOps" width="320">
<img src="branding/logo.png" alt="MinerOps Logo" width="180">
</p>
<h1 align="center">MinerOps</h1>
<p align="center">
<strong>The self-hosted operating system for cryptocurrency mining operations.</strong>
<br>
Know every miner. Track every repair. Control every rack.
</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
MinerOps is a self-hosted platform intended to provide one authoritative source
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.
MinerOps is an open-source operations platform built for cryptocurrency mining facilities.
The repository contains a working early-stage application and the first
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.
Rather than relying on spreadsheets, disconnected monitoring tools, and handwritten repair logs, MinerOps brings every operational workflow together into a single self-hosted application.
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
- Network discovery and identity tracking
- Repairs and maintenance
- Locations and electrical infrastructure
- Monitoring and alerts
- Fleet control
- Scheduling and automation
- Historical records and reporting
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.
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:
spreadsheets for assets, separate monitoring tools for performance, informal
repair notes, and location knowledge held by individual technicians.
Running a mining operation requires far more than knowing whether a miner is online.
That fragmentation becomes harder to manage when IP addresses change, control
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.
MinerOps is designed to become the operational hub for an entire mining facility by combining:
## 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 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.
The long-term goal is to replace multiple disconnected systems with a single source of truth for mining operations.
### Miner Identity and Discovery
---
Associate changing network information with durable miner records. Existing
code includes discovery and network identity foundations; formal identity
history and transfer workflows remain planned work.
# Current Status
### Repairs and Maintenance
⚠️ **MinerOps is under active development.**
Build a complete service history around each miner, including repair status,
notes, parts, and return-to-service workflows. Operational repair functionality
exists today, while extraction into a dedicated domain architecture is still
planned.
The current focus is building a scalable, maintainable architecture before expanding user-facing functionality.
### Locations and Infrastructure
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:
## Completed
- Docker-based development environment
- PostgreSQL data store
- Redis service
- Alembic schema migrations
- Session-based authentication and login gating
- Admin, Manager, Technician, and Read Only role foundations
- Manual miner inventory database schema
- Inventory Pydantic models
- Inventory repository and service domain foundation
- Automated repository and service tests for the Inventory domain
- Disposable development baseline tests
- PostgreSQL + Redis backend
- Alembic migration framework
- Authentication
- Role-based access control
- Manual inventory schema
- Inventory domain architecture
- Automated migration testing
- Disposable development environment
**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
transport, business rules, and persistence:
- Asset identity & MAC history
- 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
|
v
FastAPI routers
|
v
Domain services
|
v
Repositories
|
v
---
# Core Features
## 📦 Asset Inventory
Maintain a permanent identity for every miner independent of its network configuration.
Planned capabilities include:
- Asset IDs
- MAC history
- 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
```
FastAPI routers will own HTTP request and response concerns. Domain 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.
Business rules belong in Services.
## 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
- FastAPI
- Pydantic
- PostgreSQL
- Alembic
- Redis
- Docker and Docker Compose
- Nginx development gateway
- Alembic
- Docker Compose
- Pydantic
A typed, responsive frontend with a shared design system is planned. Its
technology selection has not yet been finalized in this repository.
Planned
## 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
operational requirements become clearer.
# Development Philosophy
## Documentation
MinerOps is built around a few guiding principles.
Detailed product, architecture, development, database, testing, and roadmap
documentation will live under `docs/`. That documentation set is still being
developed, so this README does not link to files that do not yet exist.
- Self-hosted first
- Reliability over unnecessary complexity
- 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
architecture, development workflow, and review expectations stabilize.
# Roadmap
## 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
included in this repository.
Detailed planning will be maintained in **docs/ROADMAP.md**.
---
# 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.
+4
View File
@@ -0,0 +1,4 @@
*.ps1 -text
*.ps1 whitespace=cr-at-eol
+8
View File
@@ -0,0 +1,8 @@
agent-config.json
agent-state.json
logs/
*.bak
*.bak.*
*-probe-*.json
rollback/
+8
View File
@@ -0,0 +1,8 @@
d10c355772381fb0a3953bb8cf3af247be21659a7eee4c489438d30f8bdd2da8 EnrichOneMiner.ps1
ef56e1eca40aa838ff1e3826cdba33dc948064b8d685c6d1d4c365b049984d4e MinerOpsAgentGUI.ps1
51fbcec81331ae9b171783b55f589f7c3d4d0e872ae66611b918703ef2b8725d MinerOpsAgentService.ps1
bee0f9925e5ace27d8d7207d12af0ab7e5cab6b2a655654fca0ecf7dd71d7380 MinerOpsAgentTray.ps1
c68ad90566be4ff5b18142048cdc5186d6c71c900f45e5d15d07ef4ecfc25317 MinerOpsApi3EnrichmentPoller.ps1
90e4bbe7b1fc6467fc37da7c4e67adc01e74f332e9a2d14d1961f1488a984894 MinerOpsPoolPoller.ps1
18b36f45b966d1ff0c0cd008bb25567204e1cb90839b825e80671f1c2071d3e5 MinerOpsPoolPollerTask.ps1
+249
View File
@@ -0,0 +1,249 @@
param(
[Parameter(Mandatory=$true)]
[string]$IpAddress,
[string]$MinerId = ""
)
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
function Read-AgentConfig {
if (!(Test-Path $ConfigPath)) {
throw "Agent config not found: $ConfigPath"
}
return Get-Content $ConfigPath -Raw | ConvertFrom-Json
}
function Invoke-MinerCommandRaw {
param(
[string]$IpAddress,
[string]$Command,
[int]$Port = 4028,
[int]$TimeoutMs = 2500
)
$client = $null
try {
$client = New-Object System.Net.Sockets.TcpClient
$async = $client.BeginConnect($IpAddress, $Port, $null, $null)
if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) {
return ""
}
$client.EndConnect($async)
$client.ReceiveTimeout = $TimeoutMs
$client.SendTimeout = $TimeoutMs
$stream = $client.GetStream()
$payload = [System.Text.Encoding]::ASCII.GetBytes("{`"cmd`":`"$Command`"}`n")
$stream.Write($payload, 0, $payload.Length)
try {
$client.Client.Shutdown([System.Net.Sockets.SocketShutdown]::Send)
} catch {
}
$buffer = New-Object byte[] 262144
$chunks = New-Object System.Collections.ArrayList
$started = Get-Date
while (((Get-Date) - $started).TotalMilliseconds -lt $TimeoutMs) {
if (-not $stream.DataAvailable) {
Start-Sleep -Milliseconds 20
continue
}
$read = $stream.Read($buffer, 0, $buffer.Length)
if ($read -le 0) {
break
}
for ($i = 0; $i -lt $read; $i++) {
[void]$chunks.Add([byte]$buffer[$i])
}
if ($chunks.Count -gt 2097152) {
break
}
}
return [System.Text.Encoding]::UTF8.GetString([byte[]]$chunks.ToArray([byte])).Trim()
} catch {
return ""
} finally {
if ($client -ne $null) {
$client.Close()
}
}
}
function Try-ParseJson {
param([string]$Raw)
if (!$Raw -or $Raw.Trim().Length -eq 0) {
return $null
}
try {
return $Raw | ConvertFrom-Json
} catch {
return $null
}
}
function Find-FirstValue {
param(
[object]$Object,
[string[]]$Names
)
if ($null -eq $Object) {
return ""
}
$json = $Object | ConvertTo-Json -Depth 80 -Compress
foreach ($name in $Names) {
$escaped = [regex]::Escape($name)
$pattern = '"' + $escaped + '"\s*:\s*"([^"]+)"'
$m = [regex]::Match($json, $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
if ($m.Success) {
return $m.Groups[1].Value
}
$pattern2 = '"' + $escaped + '"\s*:\s*([0-9A-Za-z\.\-_:]+)'
$m2 = [regex]::Match($json, $pattern2, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
if ($m2.Success) {
return $m2.Groups[1].Value
}
}
return ""
}
function Find-MacAddress {
param([string]$Text)
if (!$Text) {
return ""
}
$m = [regex]::Match($Text, '([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}')
if ($m.Success) {
return $m.Value.Replace("-", ":").ToUpper()
}
return ""
}
$config = Read-AgentConfig
$hubUrl = ([string]$config.hub_url).TrimEnd("/")
$secret = [string]$config.agent_secret
$commands = @(
"summary",
"devs",
"edevs",
"stats",
"pools",
"devdetails",
"version",
"get_version",
"get_miner_info"
)
$raws = [ordered]@{}
$parsed = [ordered]@{}
foreach ($cmd in $commands) {
Write-Host "Querying $IpAddress command: $cmd"
$raw = Invoke-MinerCommandRaw -IpAddress $IpAddress -Command $cmd -TimeoutMs 2500
$raws[$cmd] = $raw
$parsed[$cmd] = Try-ParseJson -Raw $raw
Write-Host " bytes: $($raw.Length)"
}
$allText = ($raws.GetEnumerator() | ForEach-Object { $_.Value }) -join "`n"
$allParsed = [ordered]@{
summary = $parsed["summary"]
devs = $parsed["devs"]
edevs = $parsed["edevs"]
stats = $parsed["stats"]
pools = $parsed["pools"]
devdetails = $parsed["devdetails"]
version = $parsed["version"]
get_version = $parsed["get_version"]
get_miner_info = $parsed["get_miner_info"]
}
$mac = Find-FirstValue -Object $allParsed -Names @("MAC", "Mac", "mac", "macaddr", "mac_address", "Net MAC", "ethaddr", "Ethernet MAC")
if (!$mac) {
$mac = Find-MacAddress -Text $allText
}
$hostname = Find-FirstValue -Object $allParsed -Names @("Hostname", "hostname", "host", "Host")
$minerSn = Find-FirstValue -Object $allParsed -Names @("Miner SN", "miner_sn", "miner sn", "Serial", "serial", "SN", "sn", "M_Serial")
$firmware = Find-FirstValue -Object $allParsed -Names @("Firmware", "firmware", "firmware_version", "Soft Version", "Version", "CompileTime")
$apiVersion = Find-FirstValue -Object $allParsed -Names @("API", "api", "api_version", "Api Version")
$platform = Find-FirstValue -Object $allParsed -Names @("Platform", "platform", "Machine", "machine")
$model = Find-FirstValue -Object $allParsed -Names @("Model", "model", "Miner Type", "miner_type", "Type", "type")
if (!$model) {
$model = "WhatsMiner"
}
$payload = [ordered]@{
ip_address = $IpAddress
miner_id = $MinerId
mac_address = $mac
hostname = $hostname
miner_sn = $minerSn
model = $model
miner_type = $model
api_version = $apiVersion
firmware_version = $firmware
platform = $platform
raw_version = @{
version = $raws["version"]
get_version = $raws["get_version"]
}
raw_miner_info = @{
devs = $raws["devs"]
edevs = $raws["edevs"]
stats = $raws["stats"]
pools = $raws["pools"]
devdetails = $raws["devdetails"]
get_miner_info = $raws["get_miner_info"]
}
}
Write-Host ""
Write-Host "Detected:"
Write-Host " MAC: $mac"
Write-Host " Hostname: $hostname"
Write-Host " SN: $minerSn"
Write-Host " Model: $model"
Write-Host " Firmware: $firmware"
Write-Host " API: $apiVersion"
Write-Host " Platform: $platform"
Write-Host ""
$response = Invoke-RestMethod `
-Method Post `
-Uri "$hubUrl/api/agent/miner-enrichment-v3" `
-Headers @{
"X-MinerOps-Agent-Secret" = $secret
} `
-ContentType "application/json" `
-Body ($payload | ConvertTo-Json -Depth 80)
$response | ConvertTo-Json -Depth 20
+226
View File
@@ -0,0 +1,226 @@
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$BaseDir = "C:\MinerOpsAgent"
$ConfigPath = Join-Path $BaseDir "agent-config.json"
$StatePath = Join-Path $BaseDir "agent-state.json"
$LogPath = Join-Path $BaseDir "logs\agent.log"
$TaskName = "MinerOpsAgent"
function Read-Config {
if (Test-Path $ConfigPath) {
return Get-Content $ConfigPath -Raw | ConvertFrom-Json
}
return $null
}
function Read-ConfigText {
if (Test-Path $ConfigPath) {
return Get-Content $ConfigPath -Raw
}
return ""
}
function Save-ConfigObject {
param([object]$Config)
$Config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8
}
function Save-ConfigText {
param([string]$Text)
$Text | Set-Content -Path $ConfigPath -Encoding UTF8
}
function Get-AgentTaskText {
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if (!$task) { return "Scheduled task not installed" }
$info = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue
return "$($task.State) | Last result: $($info.LastTaskResult) | Last run: $($info.LastRunTime)"
}
$form = New-Object System.Windows.Forms.Form
$form.Text = "MinerOps Agent"
$form.Size = New-Object System.Drawing.Size(950, 780)
$form.StartPosition = "CenterScreen"
$statusLabel = New-Object System.Windows.Forms.Label
$statusLabel.Location = New-Object System.Drawing.Point(15, 15)
$statusLabel.Size = New-Object System.Drawing.Size(900, 24)
$statusLabel.Text = "Task: " + (Get-AgentTaskText)
$form.Controls.Add($statusLabel)
$startButton = New-Object System.Windows.Forms.Button
$startButton.Text = "Start"
$startButton.Location = New-Object System.Drawing.Point(15, 45)
$startButton.Size = New-Object System.Drawing.Size(100, 32)
$form.Controls.Add($startButton)
$stopButton = New-Object System.Windows.Forms.Button
$stopButton.Text = "Stop"
$stopButton.Location = New-Object System.Drawing.Point(125, 45)
$stopButton.Size = New-Object System.Drawing.Size(100, 32)
$form.Controls.Add($stopButton)
$restartButton = New-Object System.Windows.Forms.Button
$restartButton.Text = "Restart"
$restartButton.Location = New-Object System.Drawing.Point(235, 45)
$restartButton.Size = New-Object System.Drawing.Size(100, 32)
$form.Controls.Add($restartButton)
$testButton = New-Object System.Windows.Forms.Button
$testButton.Text = "Test Hub"
$testButton.Location = New-Object System.Drawing.Point(345, 45)
$testButton.Size = New-Object System.Drawing.Size(100, 32)
$form.Controls.Add($testButton)
$saveButton = New-Object System.Windows.Forms.Button
$saveButton.Text = "Save Config"
$saveButton.Location = New-Object System.Drawing.Point(455, 45)
$saveButton.Size = New-Object System.Drawing.Size(120, 32)
$form.Controls.Add($saveButton)
$secretLabel = New-Object System.Windows.Forms.Label
$secretLabel.Text = "Agent Secret:"
$secretLabel.Location = New-Object System.Drawing.Point(15, 90)
$secretLabel.Size = New-Object System.Drawing.Size(100, 22)
$form.Controls.Add($secretLabel)
$secretBox = New-Object System.Windows.Forms.TextBox
$secretBox.Location = New-Object System.Drawing.Point(115, 87)
$secretBox.Size = New-Object System.Drawing.Size(570, 24)
$secretBox.UseSystemPasswordChar = $true
$form.Controls.Add($secretBox)
$showSecretCheck = New-Object System.Windows.Forms.CheckBox
$showSecretCheck.Text = "Show"
$showSecretCheck.Location = New-Object System.Drawing.Point(695, 88)
$showSecretCheck.Size = New-Object System.Drawing.Size(70, 24)
$form.Controls.Add($showSecretCheck)
$saveSecretButton = New-Object System.Windows.Forms.Button
$saveSecretButton.Text = "Save Secret"
$saveSecretButton.Location = New-Object System.Drawing.Point(775, 84)
$saveSecretButton.Size = New-Object System.Drawing.Size(120, 30)
$form.Controls.Add($saveSecretButton)
$configBox = New-Object System.Windows.Forms.TextBox
$configBox.Location = New-Object System.Drawing.Point(15, 125)
$configBox.Size = New-Object System.Drawing.Size(900, 350)
$configBox.Multiline = $true
$configBox.ScrollBars = "Both"
$configBox.Font = New-Object System.Drawing.Font("Consolas", 10)
$configBox.Text = Read-ConfigText
$form.Controls.Add($configBox)
$stateBox = New-Object System.Windows.Forms.TextBox
$stateBox.Location = New-Object System.Drawing.Point(15, 490)
$stateBox.Size = New-Object System.Drawing.Size(900, 85)
$stateBox.Multiline = $true
$stateBox.ScrollBars = "Vertical"
$stateBox.Font = New-Object System.Drawing.Font("Consolas", 9)
$form.Controls.Add($stateBox)
$logBox = New-Object System.Windows.Forms.TextBox
$logBox.Location = New-Object System.Drawing.Point(15, 590)
$logBox.Size = New-Object System.Drawing.Size(900, 130)
$logBox.Multiline = $true
$logBox.ScrollBars = "Vertical"
$logBox.Font = New-Object System.Drawing.Font("Consolas", 9)
$form.Controls.Add($logBox)
function Refresh-Display {
$statusLabel.Text = "Task: " + (Get-AgentTaskText)
$config = Read-Config
if ($config -and $secretBox.Text.Length -eq 0) {
$secretBox.Text = [string]$config.agent_secret
}
if (Test-Path $StatePath) {
$stateBox.Text = Get-Content $StatePath -Raw
} else {
$stateBox.Text = "No state file yet."
}
if (Test-Path $LogPath) {
$logBox.Text = (Get-Content $LogPath -Tail 30) -join [Environment]::NewLine
} else {
$logBox.Text = "No log file yet."
}
}
$showSecretCheck.Add_CheckedChanged({
$secretBox.UseSystemPasswordChar = -not $showSecretCheck.Checked
})
$saveSecretButton.Add_Click({
try {
$config = $configBox.Text | ConvertFrom-Json
$config.agent_secret = $secretBox.Text
$config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8
$configBox.Text = Get-Content $ConfigPath -Raw
[System.Windows.Forms.MessageBox]::Show("Secret saved. Restart the agent to apply changes.")
} catch {
[System.Windows.Forms.MessageBox]::Show("Could not save secret: " + $_.Exception.Message)
}
})
$startButton.Add_Click({
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
Refresh-Display
})
$stopButton.Add_Click({
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
Refresh-Display
})
$restartButton.Add_Click({
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
Refresh-Display
})
$saveButton.Add_Click({
try {
$null = $configBox.Text | ConvertFrom-Json
Save-ConfigText -Text $configBox.Text
[System.Windows.Forms.MessageBox]::Show("Config saved. Restart the agent to apply changes.")
} catch {
[System.Windows.Forms.MessageBox]::Show("Invalid JSON: " + $_.Exception.Message)
}
})
$testButton.Add_Click({
try {
$config = $configBox.Text | ConvertFrom-Json
if ($secretBox.Text.Length -gt 0) {
$config.agent_secret = $secretBox.Text
}
$hub = [string]$config.hub_url
$secret = [string]$config.agent_secret
$headers = @{
"X-MinerOps-Agent-Secret" = $secret
}
$result = Invoke-RestMethod -Uri ($hub.TrimEnd("/") + "/api/agent/config") -Headers $headers -TimeoutSec 20
[System.Windows.Forms.MessageBox]::Show("Hub test OK.`nSite: " + $result.site_name)
} catch {
[System.Windows.Forms.MessageBox]::Show("Hub test failed: " + $_.Exception.Message)
}
})
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 3000
$timer.Add_Tick({ Refresh-Display })
$timer.Start()
Refresh-Display
[void]$form.ShowDialog()
File diff suppressed because it is too large. Load diff
+108
View File
@@ -0,0 +1,108 @@
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$TaskName = "MinerOpsAgent"
$GuiPath = "C:\MinerOpsAgent\MinerOpsAgentGUI.ps1"
$LogPath = "C:\MinerOpsAgent\logs\agent.log"
$DashboardUrl = "https://plexus.lussier.cc/dashboard"
function Start-Agent {
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
}
function Stop-Agent {
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
}
function Restart-Agent {
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
}
function Open-Gui {
Start-Process powershell.exe -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$GuiPath`""
}
function Open-Dashboard {
Start-Process $DashboardUrl
}
function Open-Log {
if (Test-Path $LogPath) {
Start-Process notepad.exe $LogPath
}
}
function Get-AgentStatusText {
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if (!$task) {
return "MinerOps Agent: task not installed"
}
return "MinerOps Agent: $($task.State)"
}
$notify = New-Object System.Windows.Forms.NotifyIcon
$notify.Icon = [System.Drawing.SystemIcons]::Application
$notify.Text = Get-AgentStatusText
$notify.Visible = $true
$menu = New-Object System.Windows.Forms.ContextMenuStrip
$itemOpen = $menu.Items.Add("Open Agent GUI")
$itemOpen.Add_Click({ Open-Gui })
$itemDashboard = $menu.Items.Add("Open Plexus Dashboard")
$itemDashboard.Add_Click({ Open-Dashboard })
$menu.Items.Add("-") | Out-Null
$itemStart = $menu.Items.Add("Start Agent")
$itemStart.Add_Click({
Start-Agent
Start-Sleep -Seconds 1
$notify.Text = Get-AgentStatusText
})
$itemStop = $menu.Items.Add("Stop Agent")
$itemStop.Add_Click({
Stop-Agent
Start-Sleep -Seconds 1
$notify.Text = Get-AgentStatusText
})
$itemRestart = $menu.Items.Add("Restart Agent")
$itemRestart.Add_Click({
Restart-Agent
Start-Sleep -Seconds 1
$notify.Text = Get-AgentStatusText
})
$menu.Items.Add("-") | Out-Null
$itemLog = $menu.Items.Add("Open Log")
$itemLog.Add_Click({ Open-Log })
$itemExit = $menu.Items.Add("Exit Tray Icon")
$itemExit.Add_Click({
$notify.Visible = $false
$notify.Dispose()
[System.Windows.Forms.Application]::Exit()
})
$notify.ContextMenuStrip = $menu
$notify.Add_DoubleClick({
Open-Gui
})
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 5000
$timer.Add_Tick({
$notify.Text = Get-AgentStatusText
})
$timer.Start()
[System.Windows.Forms.Application]::Run()
@@ -0,0 +1,246 @@
param(
[int] $Limit = 25,
[int] $TimeoutMs = 2500
)
$ErrorActionPreference = "Stop"
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
if (-not (Test-Path $ConfigPath)) {
throw "Missing config file: $ConfigPath"
}
$Config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$HubUrl = [string]$Config.hub_url
$HubUrl = $HubUrl.TrimEnd("/")
$AgentId = [string]$Config.agent_id
$SiteName = [string]$Config.site_name
$Secret = [string]$Config.agent_secret
$Headers = @{
"X-MinerOps-Agent-Secret" = $Secret
}
function Invoke-WhatsMinerApi3Read {
param(
[Parameter(Mandatory=$true)] [string] $Ip,
[Parameter(Mandatory=$true)] [hashtable] $Request,
[int] $TimeoutMs = 2500
)
$client = $null
$stream = $null
try {
$json = ($Request | ConvertTo-Json -Depth 20 -Compress)
$payload = [System.Text.Encoding]::ASCII.GetBytes($json)
$length = [BitConverter]::GetBytes([Int32]$payload.Length)
$client = New-Object System.Net.Sockets.TcpClient
$client.ReceiveTimeout = $TimeoutMs
$client.SendTimeout = $TimeoutMs
$connect = $client.BeginConnect($Ip, 4433, $null, $null)
if (-not $connect.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
try { $client.Close() } catch {}
throw "Timed out connecting to $Ip`:4433"
}
$client.EndConnect($connect)
$stream = $client.GetStream()
$stream.ReadTimeout = $TimeoutMs
$stream.WriteTimeout = $TimeoutMs
$stream.Write($length, 0, 4)
$stream.Write($payload, 0, $payload.Length)
$stream.Flush()
$lenBuf = New-Object byte[] 4
$offset = 0
while ($offset -lt 4) {
$read = $stream.Read($lenBuf, $offset, 4 - $offset)
if ($read -le 0) {
throw "No response length received."
}
$offset += $read
}
$responseLength = [BitConverter]::ToInt32($lenBuf, 0)
if ($responseLength -le 0 -or $responseLength -gt 1048576) {
throw "Invalid response length: $responseLength"
}
$buffer = New-Object byte[] $responseLength
$offset = 0
while ($offset -lt $responseLength) {
$read = $stream.Read($buffer, $offset, $responseLength - $offset)
if ($read -le 0) {
throw "Response ended early at $offset of $responseLength bytes."
}
$offset += $read
}
$raw = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $responseLength)
$parsed = $raw | ConvertFrom-Json
return [pscustomobject]@{
ok = $true
parsed = $parsed
error = $null
}
}
catch {
return [pscustomobject]@{
ok = $false
parsed = $null
error = $_.Exception.Message
}
}
finally {
if ($stream) { try { $stream.Close() } catch {} }
if ($client) { try { $client.Close() } catch {} }
}
}
function Invoke-Api3OkCommand {
param(
[string] $Ip,
[hashtable] $Request
)
$result = Invoke-WhatsMinerApi3Read -Ip $Ip -Request $Request -TimeoutMs $TimeoutMs
if (-not $result.ok) {
return $null
}
if ($result.parsed.code -ne 0) {
return $null
}
return $result.parsed
}
Write-Host "Fetching known miners from $HubUrl"
$known = Invoke-RestMethod `
-Uri "$HubUrl/api/agent/known-miners" `
-Headers $Headers `
-Method Get
$miners = @($known.miners)
if ($Limit -gt 0) {
$miners = @($miners | Select-Object -First $Limit)
}
Write-Host "API3 enrichment check count: $($miners.Count)"
$items = New-Object System.Collections.Generic.List[object]
$checked = 0
$api3Ok = 0
$api3Failed = 0
foreach ($miner in $miners) {
$checked += 1
$ip = [string]$miner.ip_address
$mac = [string]$miner.mac_address
if (-not $ip) {
continue
}
Write-Host "[$checked/$($miners.Count)] $ip $mac"
$deviceInfoResult = Invoke-WhatsMinerApi3Read `
-Ip $ip `
-Request @{ cmd = "get.device.info" } `
-TimeoutMs $TimeoutMs
if (-not $deviceInfoResult.ok -or $deviceInfoResult.parsed.code -ne 0) {
$api3Failed += 1
$err = $deviceInfoResult.error
if ($deviceInfoResult.ok) {
$err = "API3 code $($deviceInfoResult.parsed.code): $($deviceInfoResult.parsed.msg)"
}
$items.Add([pscustomobject]@{
ip = $ip
mac = $mac
api3_supported = $false
api3_error = $err
device_info = $null
miner_setting = $null
miner_status = $null
fan_setting = $null
custom_data = $null
})
continue
}
$api3Ok += 1
$minerSetting = Invoke-Api3OkCommand `
-Ip $ip `
-Request @{ cmd = "get.miner.setting" }
$minerStatus = Invoke-Api3OkCommand `
-Ip $ip `
-Request @{ cmd = "get.miner.status"; param = "summary+pools+edevs" }
$fanSetting = Invoke-Api3OkCommand `
-Ip $ip `
-Request @{ cmd = "get.fan.setting" }
$customData = Invoke-Api3OkCommand `
-Ip $ip `
-Request @{ cmd = "get.device.custom_data" }
$items.Add([pscustomobject]@{
ip = $ip
mac = $mac
api3_supported = $true
api3_error = $null
device_info = $deviceInfoResult.parsed
miner_setting = $minerSetting
miner_status = $minerStatus
fan_setting = $fanSetting
custom_data = $customData
})
}
$payload = @{
agent_id = $AgentId
site_name = $SiteName
items = @($items.ToArray())
}
$json = $payload | ConvertTo-Json -Depth 80
Write-Host "Posting API3 enrichment results to hub..."
$result = Invoke-RestMethod `
-Uri "$HubUrl/api/agent/api3-enrichment" `
-Method Post `
-Headers $Headers `
-ContentType "application/json" `
-Body $json
Write-Host ""
Write-Host "Local checked: $checked"
Write-Host "API3 OK: $api3Ok"
Write-Host "API3 failed: $api3Failed"
Write-Host ""
$result | ConvertTo-Json -Depth 20
+201
View File
@@ -0,0 +1,201 @@
# Typed MinerOps miner-control execution. Loaded by MinerOpsAgentService.ps1.
function Get-MinerOpsAgentVersion {
$versionPath = Join-Path $PSScriptRoot "VERSION"
if (Test-Path $versionPath) {
return ([string](Get-Content $versionPath -Raw)).Trim()
}
return "unknown"
}
function Get-MinerOpsControlCredentials {
$config = Read-AgentConfig
$account = ([string]$config.api3_account).Trim()
$password = [string]$config.api3_password
$port = 4433
if ($config.api3_port) {
try { $port = [int]$config.api3_port } catch { $port = 4433 }
}
return @{
available = (-not [string]::IsNullOrWhiteSpace($account)) -and
(-not [string]::IsNullOrWhiteSpace($password))
account = $account
password = $password
port = $port
}
}
function Invoke-MinerOpsControlApi3Command {
param(
[Parameter(Mandatory=$true)][string]$IpAddress,
[Parameter(Mandatory=$true)][string]$Command,
[object]$Param = $null
)
$credentials = Get-MinerOpsControlCredentials
if (-not $credentials.available) { throw "credentials_missing" }
$saltInfo = Get-MinerOpsApi3Salt -IpAddress $IpAddress -Port $credentials.port
$timestamp = [int64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$token = Get-MinerOpsApi3Token -Command $Command -Password $credentials.password `
-Salt ([string]$saltInfo.salt) -Timestamp $timestamp
$payload = @{
cmd = $Command
ts = $timestamp
token = $token
account = $credentials.account
}
if ($null -ne $Param) { $payload["param"] = $Param }
$response = Invoke-MinerOpsApi3Payload -IpAddress $IpAddress `
-Port $credentials.port -Payload $payload
if ([int]$response.response.code -ne 0) { throw "command_rejected" }
return $response
}
function New-MinerOpsControlResult {
param(
[bool]$Accepted,
[bool]$VerificationAttempted,
[string]$VerificationStatus,
[string]$ObservedMode = "",
[string]$FailureCode = "",
[string]$QueueStatus = "completed"
)
return [ordered]@{
schema_version = 1
command_accepted = $Accepted
verification_attempted = $VerificationAttempted
verification_status = $VerificationStatus
observed_service_state = $null
observed_performance_mode = if ($ObservedMode) { $ObservedMode } else { $null }
failure_code = if ($FailureCode) { $FailureCode } else { $null }
_queue_status = $QueueStatus
}
}
function Run-MinerControlCommand {
param([Parameter(Mandatory=$true)][object]$Payload)
try {
if ($Payload -is [string]) { $Payload = $Payload | ConvertFrom-Json }
$action = ([string]$Payload.action).Trim().ToLower()
$ipAddress = ([string]$Payload.ip_address).Trim()
$requested = ([string]$Payload.requested_value).Trim().ToLower()
if (-not $requested -and $Payload.performance_mode) {
$requested = ([string]$Payload.performance_mode).Trim().ToLower()
}
if ($action -notin @("suspend", "resume", "performance_mode")) {
return New-MinerOpsControlResult -Accepted $false `
-VerificationAttempted $false -VerificationStatus "unavailable" `
-FailureCode "invalid_action" -QueueStatus "failed"
}
if ([string]::IsNullOrWhiteSpace($ipAddress)) {
return New-MinerOpsControlResult -Accepted $false `
-VerificationAttempted $false -VerificationStatus "unavailable" `
-FailureCode "target_missing" -QueueStatus "failed"
}
$credentials = Get-MinerOpsControlCredentials
if (-not $credentials.available) {
return New-MinerOpsControlResult -Accepted $false `
-VerificationAttempted $false -VerificationStatus "unavailable" `
-FailureCode "credentials_missing" -QueueStatus "failed"
}
if ($action -eq "performance_mode") {
if ($requested -notin @("low", "normal", "high")) {
return New-MinerOpsControlResult -Accepted $false `
-VerificationAttempted $false -VerificationStatus "unavailable" `
-FailureCode "invalid_mode" -QueueStatus "failed"
}
Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress `
-Command "set.miner.power_mode" -Param $requested | Out-Null
$observed = ""
$readUnavailable = $false
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
$read = Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress `
-Command "get.miner.setting"
$candidate = ([string]$read.response.msg.'power-mode').Trim().ToLower()
if ($candidate -in @("low", "normal", "high")) {
$observed = $candidate
if ($observed -eq $requested) {
return New-MinerOpsControlResult -Accepted $true `
-VerificationAttempted $true -VerificationStatus "confirmed" `
-ObservedMode $observed
}
}
} catch {
if ($_.Exception.Message -match "unsupported|unknown_command") {
$readUnavailable = $true
break
}
}
if ($attempt -lt 3) { Start-Sleep -Milliseconds 750 }
}
if ($readUnavailable) {
return New-MinerOpsControlResult -Accepted $true `
-VerificationAttempted $true -VerificationStatus "unavailable"
}
if ($observed) {
return New-MinerOpsControlResult -Accepted $true `
-VerificationAttempted $true `
-VerificationStatus "verification_failed" -ObservedMode $observed
}
return New-MinerOpsControlResult -Accepted $true `
-VerificationAttempted $true -VerificationStatus "unconfirmed"
}
$serviceAction = if ($action -eq "suspend") { "stop" } else { "start" }
Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress `
-Command "set.miner.service" -Param $serviceAction | Out-Null
return New-MinerOpsControlResult -Accepted $true `
-VerificationAttempted $false -VerificationStatus "unconfirmed"
} catch {
$failure = if ($_.Exception.Message -eq "credentials_missing") {
"credentials_missing"
} else { "write_rejected" }
return New-MinerOpsControlResult -Accepted $false `
-VerificationAttempted $false -VerificationStatus "unavailable" `
-FailureCode $failure -QueueStatus "failed"
}
}
function Send-AgentCommandResult {
param(
[string]$CommandId,
[string]$Status,
[object]$ResultPayload = $null,
[string]$ErrorMessage = $null
)
if ($ResultPayload -and $ResultPayload._queue_status) {
$Status = [string]$ResultPayload._queue_status
$ResultPayload.Remove("_queue_status")
$ErrorMessage = if ($ResultPayload.failure_code) {
[string]$ResultPayload.failure_code
} else { $null }
}
$body = [ordered]@{
command_id = $CommandId
status = $Status
result_payload = $ResultPayload
error_message = $ErrorMessage
}
return Invoke-AgentApi -Method "POST" -Path "/api/agent/commands/result" -Body $body
}
function Send-AgentCheckin {
$config = Read-AgentConfig
$credentials = Get-MinerOpsControlCredentials
$body = [ordered]@{
agent_id = [string]$config.agent_id
site_id = [string]$config.site_id
site_name = [string]$config.site_name
hostname = $env:COMPUTERNAME
version = Get-MinerOpsAgentVersion
ip_address = ""
control_available = [bool]$credentials.available
control_unavailable_reason = if ($credentials.available) { $null } else { "credentials_missing" }
local_time = (Get-Date).ToString("o")
}
return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body
}
+146
View File
@@ -0,0 +1,146 @@
$ErrorActionPreference = "Stop"
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
$Config = Get-Content -Raw $ConfigPath | ConvertFrom-Json
$HubUrl = ($Config.hub_url + "").TrimEnd("/")
$AgentId = $Config.agent_id
$AgentSecret = $Config.agent_secret
$Headers = @{
"X-MinerOps-Agent-Secret" = $AgentSecret
}
function Normalize-MinerOpsIp {
param([string] $Ip)
if (-not $Ip) {
return ""
}
return ($Ip -replace "/32$", "").Trim()
}
function Invoke-MinerOps4028PoolsSimple {
param(
[Parameter(Mandatory=$true)] [string] $Ip,
[int] $Port = 4028,
[int] $TimeoutMs = 5000
)
$client = $null
$stream = $null
try {
$json = '{"command":"pools"}'
$client = New-Object System.Net.Sockets.TcpClient
$client.ReceiveTimeout = $TimeoutMs
$client.SendTimeout = $TimeoutMs
$client.Connect($Ip, $Port)
$stream = $client.GetStream()
$stream.ReadTimeout = $TimeoutMs
$stream.WriteTimeout = $TimeoutMs
$bytes = [System.Text.Encoding]::ASCII.GetBytes($json)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
Start-Sleep -Milliseconds 500
$buffer = New-Object byte[] 131072
$read = $stream.Read($buffer, 0, $buffer.Length)
if ($read -le 0) {
return $null
}
$raw = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $read).Trim([char]0).Trim()
if (-not $raw) {
return $null
}
$parsed = $raw | ConvertFrom-Json
if (-not $parsed.POOLS) {
return $null
}
return @($parsed.POOLS)
}
catch {
return $null
}
finally {
if ($stream) {
try { $stream.Close() } catch {}
}
if ($client) {
try { $client.Close() } catch {}
}
}
}
$response = Invoke-RestMethod -Method Get -Uri "$HubUrl/api/agent/known-miners" -Headers $Headers -TimeoutSec 60
$knownMiners = @()
if ($response.miners) {
$knownMiners = @($response.miners)
} elseif ($response.data -and $response.data.miners) {
$knownMiners = @($response.data.miners)
} elseif ($response.data -and $response.data.known_miners) {
$knownMiners = @($response.data.known_miners)
}
Write-Host "Known miners: $($knownMiners.Count)"
$poolSnapshots = New-Object System.Collections.Generic.List[object]
$count = 0
foreach ($miner in $knownMiners) {
$count++
$ip = Normalize-MinerOpsIp -Ip $miner.ip_address
if (-not $ip) {
continue
}
if (($count % 25) -eq 0) {
Write-Host "Checked $count / $($knownMiners.Count). Collected $($poolSnapshots.Count)."
}
$pools = @(Invoke-MinerOps4028PoolsSimple -Ip $ip)
if ($pools.Count -gt 0) {
$poolSnapshots.Add([pscustomobject]@{
miner_id = $miner.miner_id
ip_address = $ip
mac_address = $miner.mac_address
model = $miner.model
pools = @($pools)
})
}
}
Write-Host "Pool snapshots collected: $($poolSnapshots.Count)"
$body = @{
agent_id = $AgentId
pool_snapshots = $poolSnapshots
} | ConvertTo-Json -Depth 20
$result = Invoke-RestMethod `
-Method Post `
-Uri "$HubUrl/api/agent/pool-snapshots" `
-Headers $Headers `
-Body $body `
-ContentType "application/json" `
-TimeoutSec 180
$result | ConvertTo-Json -Depth 10
+28
View File
@@ -0,0 +1,28 @@
$LogDir = "C:\MinerOpsAgent\logs"
$LogFile = Join-Path $LogDir "pool-poller.log"
if (-not (Test-Path $LogDir)) {
New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
}
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
try {
Add-Content -Path $LogFile -Value "$stamp Starting MinerOps pool poller."
$output = powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\MinerOpsAgent\MinerOpsPoolPoller.ps1" 2>&1
foreach ($line in $output) {
Add-Content -Path $LogFile -Value "$line"
}
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Add-Content -Path $LogFile -Value "$stamp Pool poller finished."
Add-Content -Path $LogFile -Value ""
}
catch {
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Add-Content -Path $LogFile -Value "$stamp Pool poller failed: $($_.Exception.Message)"
Add-Content -Path $LogFile -Value ""
exit 1
}
+74
View File
@@ -0,0 +1,74 @@
# MinerOps Windows Agent
The Windows Agent runs at a mining site and performs local discovery, polling,
API3 enrichment, pool polling, and approved queued miner-control operations.
MinerOps does not send API3 credentials through the hub command queue; those
credentials remain in the Agent's local `agent-config.json`.
## Recovered Baseline
The seven runtime and support scripts in this directory were recovered from the
live `C:\MinerOpsAgent` installation. `BASELINE_SHA256SUMS` records their exact
pre-change hashes. Historical backups, live configuration, state, logs, and
probe output are intentionally excluded.
## Local Files
Create `agent-config.json` from `config.example.json` on the Agent host. Never
commit that file. `site_id` is the durable MinerOps site UUID; `site_name` is
retained only for transitional display compatibility.
The Agent writes runtime state to `agent-state.json` and logs under `logs/`.
Installation and upgrades preserve all three paths.
## Security
API3 account and password values are local secrets. Missing credentials make
miner control unavailable. Passwords, API tokens, salts, and the Agent secret
must not be written to normal logs or returned to MinerOps command history.
The Internal Alpha supports one Agent identity protected by the deployment
shared secret. A second active Agent requires per-Agent credentials before it
may be enabled.
## Legacy Compatibility
The older direct Hub API3, `/agent-control`, password-change, bulk-control, and
scheduling paths remain available for compatibility and are deprecated for the
Internal Alpha. The new Miner Detail controls use only the typed control API and
Agent queue; the modular control backend does not connect directly to miners.
## Deployment
Deploy Agent compatibility before enabling Hub enforcement of the
`X-MinerOps-Agent-ID` header:
1. Copy this directory to a temporary folder on the Windows Agent host.
2. Create or update `C:\MinerOpsAgent\agent-config.json` locally. Do not pass
secrets as installer or scheduled-task arguments.
3. Run `install.ps1` from an elevated Windows PowerShell session.
4. Confirm the four scheduled tasks are registered and that Agent check-in
reports the expected non-secret `VERSION`.
5. Enable the matching Agent ID and shared secret on the Hub.
The installer copies only managed runtime code to `C:\MinerOpsAgent` and
preserves `agent-config.json`, `agent-state.json`, `logs\`, backups, probe
output, and other runtime data. During an upgrade, existing definitions for
`MinerOpsAgent`, `MinerOpsAgentTray`, `MinerOpsApi3Enrichment`, and
`MinerOpsPoolPoller` are authoritative and are not rewritten. Missing tasks are
created for a fresh or incomplete installation without hard-coded user names.
If `MinerOpsAgent` was running before an upgrade, the installer stops only that
Scheduled Task, waits for it to stop, replaces the managed files, and starts it
again. A previously stopped Agent remains stopped. The tray and periodic tasks
are not unnecessarily restarted.
Before replacing existing runtime files, the installer copies them to a
timestamped folder under `C:\MinerOpsAgent\backups`. A failed deployment
automatically restores those managed files, removes tasks created by that
failed attempt, and restores the main Agent's prior running state. For a manual
rollback, stop `MinerOpsAgent`, restore the selected backup's runtime files,
and restart it only if it was previously running. `uninstall.ps1` unregisters
the four tasks and removes runtime code while preserving local configuration,
state, logs, and backups.
+1
View File
@@ -0,0 +1 @@
1.0.0-alpha
+21
View File
@@ -0,0 +1,21 @@
{
"hub_url": "https://minerops.example.invalid",
"agent_id": "replace-with-agent-id",
"site_id": "00000000-0000-0000-0000-000000000000",
"site_name": "Example Site",
"agent_secret": "replace-with-agent-secret",
"poll_interval_seconds": 120,
"ip_ranges": [
{
"name": "Example Range",
"cidr": "192.0.2.0/24",
"enabled": false,
"api_port": 4028,
"api3_port": 4433
}
],
"api3_account": "replace-with-api3-account",
"api3_password": "replace-with-api3-password",
"api3_port": 4433
}
+202
View File
@@ -0,0 +1,202 @@
#Requires -RunAsAdministrator
$ErrorActionPreference = "Stop"
$Target = "C:\MinerOpsAgent"
$BackupRoot = Join-Path $Target "backups"
$BackupTimestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$BackupSuffix = [guid]::NewGuid().ToString("N").Substring(0, 8)
$Backup = Join-Path $BackupRoot "$BackupTimestamp-$BackupSuffix"
$RuntimeFiles = @(
"MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1",
"MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1",
"MinerOpsPoolPollerTask.ps1", "EnrichOneMiner.ps1", "MinerOpsControl.ps1",
"VERSION"
)
function Get-MinerOpsScheduledTask {
param([Parameter(Mandatory)][string]$Name)
return Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
}
function Stop-MinerOpsScheduledTaskForUpgrade {
param(
[Parameter(Mandatory)][string]$Name,
[TimeSpan]$Timeout = [TimeSpan]::FromSeconds(30)
)
Stop-ScheduledTask -TaskName $Name -ErrorAction Stop
$deadline = [DateTime]::UtcNow.Add($Timeout)
do {
Start-Sleep -Milliseconds 500
$task = Get-MinerOpsScheduledTask -Name $Name
if ($null -eq $task) {
throw "Scheduled Task $Name disappeared while stopping it."
}
if ([string]$task.State -ne "Running") {
return
}
} while ([DateTime]::UtcNow -lt $deadline)
throw "Scheduled Task $Name did not stop within $($Timeout.TotalSeconds) seconds."
}
function Start-MinerOpsScheduledTask {
param([Parameter(Mandatory)][string]$Name)
Start-ScheduledTask -TaskName $Name -ErrorAction Stop
}
# Validate the complete source before creating directories, stopping tasks, or
# replacing any target file.
foreach ($name in $RuntimeFiles) {
$source = Join-Path $PSScriptRoot $name
if (!(Test-Path -LiteralPath $source -PathType Leaf)) {
throw "Deployment source is missing $name"
}
}
$mainTask = Get-MinerOpsScheduledTask -Name "MinerOpsAgent"
$mainTaskExisted = $null -ne $mainTask
$mainTaskWasRunning = $mainTaskExisted -and [string]$mainTask.State -eq "Running"
$mainTaskStoppedForUpgrade = $false
$deploymentStarted = $false
$existing = @()
$createdTaskNames = [System.Collections.Generic.List[string]]::new()
try {
New-Item -ItemType Directory -Force -Path $Target, $BackupRoot | Out-Null
$existing = @($RuntimeFiles | Where-Object {
Test-Path -LiteralPath (Join-Path $Target $_) -PathType Leaf
})
if ($existing.Count -gt 0) {
New-Item -ItemType Directory -Force -Path $Backup | Out-Null
foreach ($name in $existing) {
Copy-Item -LiteralPath (Join-Path $Target $name) `
-Destination (Join-Path $Backup $name) -Force
}
}
if ($mainTaskWasRunning) {
$mainTaskStoppedForUpgrade = $true
Stop-MinerOpsScheduledTaskForUpgrade -Name "MinerOpsAgent"
}
$deploymentStarted = $true
foreach ($name in $RuntimeFiles) {
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) `
-Destination (Join-Path $Target $name) -Force
}
$powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
$system = New-ScheduledTaskPrincipal -UserId "SYSTEM" `
-LogonType ServiceAccount -RunLevel Highest
$startup = New-ScheduledTaskTrigger -AtStartup
$fiveMinutes = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
-RepetitionInterval (New-TimeSpan -Minutes 5)
$commonSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew `
-StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit (New-TimeSpan -Hours 72)
$agentSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew `
-StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit (New-TimeSpan -Hours 72) -RestartCount 3 `
-RestartInterval (New-TimeSpan -Minutes 1)
$definitions = @(
@{
Name = "MinerOpsAgent"; Script = "MinerOpsAgentService.ps1"
Trigger = $startup; Principal = $system; Settings = $agentSettings
ExtraArguments = ""
},
@{
Name = "MinerOpsAgentTray"; Script = "MinerOpsAgentTray.ps1"
Trigger = (New-ScheduledTaskTrigger -AtLogOn); Principal = $null
Settings = $commonSettings; ExtraArguments = "-WindowStyle Hidden"
},
@{
Name = "MinerOpsApi3Enrichment"
Script = "MinerOpsApi3EnrichmentPoller.ps1"
Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings
ExtraArguments = "-Limit 0 -TimeoutMs 8000"
},
@{
Name = "MinerOpsPoolPoller"; Script = "MinerOpsPoolPollerTask.ps1"
Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings
ExtraArguments = ""
}
)
foreach ($definition in $definitions) {
if ($null -ne (Get-MinerOpsScheduledTask -Name $definition.Name)) {
Write-Host "Preserving existing Scheduled Task $($definition.Name)."
continue
}
$arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f `
(Join-Path $Target $definition.Script)
if ($definition.ExtraArguments) {
$arguments = "$arguments $($definition.ExtraArguments)"
}
$action = New-ScheduledTaskAction -Execute $powerShell -Argument $arguments
$registration = @{
TaskName = $definition.Name
Action = $action
Trigger = $definition.Trigger
Settings = $definition.Settings
}
if ($definition.Principal) {
$registration.Principal = $definition.Principal
}
Register-ScheduledTask @registration | Out-Null
$createdTaskNames.Add($definition.Name)
}
if ($mainTaskWasRunning) {
Start-MinerOpsScheduledTask -Name "MinerOpsAgent"
$mainTaskStoppedForUpgrade = $false
}
Write-Host "MinerOps Windows Agent installed. Existing task definitions, config, state, logs, and backups were preserved."
} catch {
$deploymentError = $_
$rollbackErrors = [System.Collections.Generic.List[string]]::new()
foreach ($taskName in $createdTaskNames) {
try {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false `
-ErrorAction Stop
} catch {
$rollbackErrors.Add("could not remove new task ${taskName}: $($_.Exception.Message)")
}
}
if ($deploymentStarted) {
foreach ($name in $RuntimeFiles) {
try {
$targetPath = Join-Path $Target $name
if ($existing -contains $name) {
Copy-Item -LiteralPath (Join-Path $Backup $name) `
-Destination $targetPath -Force
} elseif (Test-Path -LiteralPath $targetPath) {
Remove-Item -LiteralPath $targetPath -Force
}
} catch {
$rollbackErrors.Add("could not restore ${name}: $($_.Exception.Message)")
}
}
}
if ($mainTaskWasRunning -and $mainTaskStoppedForUpgrade) {
try {
Start-MinerOpsScheduledTask -Name "MinerOpsAgent"
$mainTaskStoppedForUpgrade = $false
} catch {
$rollbackErrors.Add("could not restart restored MinerOpsAgent: $($_.Exception.Message)")
}
}
if ($rollbackErrors.Count -gt 0) {
$rollbackSummary = $rollbackErrors -join "; "
throw "Deployment failed: $($deploymentError.Exception.Message). Rollback errors: $rollbackSummary"
}
throw $deploymentError
}
@@ -0,0 +1,84 @@
$ErrorActionPreference = "Stop"
$script:config = [pscustomobject]@{ api3_account = "operator"; api3_password = "secret"; api3_port = 4433 }
$script:calls = @()
$script:outcomes = @()
$script:outcomeIndex = 0
function Read-AgentConfig { return $script:config }
. (Join-Path (Split-Path $PSScriptRoot -Parent) "MinerOpsControl.ps1")
function Invoke-MinerOpsControlApi3Command {
param([string]$IpAddress, [string]$Command, [object]$Param = $null)
$script:calls += [pscustomobject]@{ command = $Command; param = $Param }
$outcome = $script:outcomes[$script:outcomeIndex]
$script:outcomeIndex += 1
if ($outcome -is [System.Exception]) { throw $outcome }
return $outcome
}
function Assert-Equal($Actual, $Expected, [string]$Message) {
if ($Actual -ne $Expected) { throw "$Message. Expected '$Expected', got '$Actual'." }
}
function Reset-Harness([object[]]$Outcomes) {
$script:calls = @()
$script:outcomes = @($Outcomes)
$script:outcomeIndex = 0
}
function Response([string]$Mode = "") {
return @{ response = @{ code = 0; msg = @{ "power-mode" = $Mode } } }
}
$payload = @{ action = "performance_mode"; requested_value = "normal"; ip_address = "192.0.2.1" }
$script:config = [pscustomobject]@{ api3_account = ""; api3_password = ""; api3_port = 4433 }
Reset-Harness @()
$result = Run-MinerControlCommand -Payload $payload
Assert-Equal $result.failure_code "credentials_missing" "Missing credentials code"
Assert-Equal $script:calls.Count 0 "Missing credentials must not contact API3"
$script:config = [pscustomobject]@{ api3_account = "operator"; api3_password = "secret"; api3_port = 4433 }
Reset-Harness @((Response), (Response "normal"))
$result = Run-MinerControlCommand -Payload $payload
Assert-Equal $result.verification_status "confirmed" "Performance confirmation"
Assert-Equal @($script:calls | Where-Object command -Like "set.*").Count 1 "Exactly one write"
Reset-Harness @((Response), (Response "low"), (Response "low"), (Response "low"))
$result = Run-MinerControlCommand -Payload $payload
Assert-Equal $result.verification_status "verification_failed" "Contradictory mode"
Assert-Equal $script:calls.Count 4 "One write and three bounded reads"
Reset-Harness @((Response), (Response), (Response), (Response))
$result = Run-MinerControlCommand -Payload $payload
Assert-Equal $result.verification_status "unconfirmed" "Ambiguous reads"
Reset-Harness @((Response), [System.Exception]::new("unknown_command"))
$result = Run-MinerControlCommand -Payload $payload
Assert-Equal $result.verification_status "unavailable" "Unavailable read path"
foreach ($action in @("suspend", "resume")) {
Reset-Harness @((Response))
$result = Run-MinerControlCommand -Payload @{
action = $action; requested_value = $null; ip_address = "192.0.2.1"
}
Assert-Equal $result.command_accepted $true "$action accepted"
Assert-Equal $result.verification_status "unconfirmed" "$action remains unconfirmed"
Assert-Equal $result.observed_service_state $null "$action has no invented state"
Assert-Equal @($script:calls | Where-Object command -Like "set.*").Count 1 "$action one write"
}
Reset-Harness @([System.Exception]::new("vendor response included secret"))
$result = Run-MinerControlCommand -Payload @{
action = "suspend"; requested_value = $null; ip_address = "192.0.2.1"
}
Assert-Equal $result.failure_code "write_rejected" "Write rejection is sanitized"
$json = $result | ConvertTo-Json -Compress
if ($json -match "vendor|secret|192\\.0\\.2\\.1") { throw "Typed result leaked raw data" }
Reset-Harness @()
$result = Run-MinerControlCommand -Payload @{
action = "performance_mode"; requested_value = "turbo"; ip_address = "192.0.2.1"
}
Assert-Equal $result.failure_code "invalid_mode" "Invalid mode code"
Assert-Equal $script:calls.Count 0 "Invalid mode must not contact API3"
Write-Host "MinerOps typed Agent control tests passed."
@@ -0,0 +1,60 @@
$ErrorActionPreference = "Stop"
$installer = Join-Path (Split-Path $PSScriptRoot -Parent) "install.ps1"
$tokens = $null
$parseErrors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
$installer, [ref]$tokens, [ref]$parseErrors
) | Out-Null
if ($parseErrors.Count -gt 0) {
throw "install.ps1 parser errors: $($parseErrors.Message -join '; ')"
}
$source = Get-Content -LiteralPath $installer -Raw
function Assert-SourceContains {
param([Parameter(Mandatory)][string]$Pattern, [Parameter(Mandatory)][string]$Message)
if ($source -notmatch $Pattern) { throw $Message }
}
$runtimeMatch = [regex]::Match(
$source,
'(?s)\$RuntimeFiles\s*=\s*@\((?<files>.*?)\)'
)
if (!$runtimeMatch.Success) { throw "Runtime file allowlist was not found." }
$runtimeFiles = $runtimeMatch.Groups["files"].Value
foreach ($forbidden in @("agent-config.json", "agent-state.json", "logs", "backups")) {
if ($runtimeFiles -match [regex]::Escape($forbidden)) {
throw "$forbidden must not be a managed deployment target."
}
}
$preflight = $source.IndexOf('# Validate the complete source')
$firstTargetChange = $source.IndexOf('New-Item -ItemType Directory')
if ($preflight -lt 0 -or $preflight -gt $firstTargetChange) {
throw "Source preflight must happen before target changes."
}
Assert-SourceContains 'if \(\$null -ne \(Get-MinerOpsScheduledTask' `
"Existing task preservation guard is missing."
Assert-SourceContains 'Preserving existing Scheduled Task' `
"Existing task preservation is not explicit."
Assert-SourceContains 'ExtraArguments\s*=\s*"-Limit 0 -TimeoutMs 8000"' `
"Fresh enrichment task arguments are missing."
Assert-SourceContains 'Stop-MinerOpsScheduledTaskForUpgrade' `
"Bounded main Agent stop behavior is missing."
Assert-SourceContains '\$mainTaskWasRunning' `
"Main Agent prior running state is not represented."
Assert-SourceContains 'Copy-Item[^\r\n]*\(Join-Path \$Backup \$name\)' `
"Rollback does not restore managed files from backup."
Assert-SourceContains 'Unregister-ScheduledTask' `
"Rollback does not remove tasks created by a failed install."
Assert-SourceContains 'could not restart restored MinerOpsAgent' `
"Rollback restart failure is not surfaced."
if ($source -match 'Register-ScheduledTask[^\r\n]*-Force') {
throw "Installer must not force-overwrite Scheduled Tasks."
}
if ($source -match '(?i)agent_secret|api3_password|api3_account') {
throw "Installer must not inspect or print credentials."
}
Write-Host "MinerOps installer source safety tests passed."
+21
View File
@@ -0,0 +1,21 @@
#Requires -RunAsAdministrator
$ErrorActionPreference = "Stop"
$Target = "C:\MinerOpsAgent"
$Tasks = @(
"MinerOpsAgent", "MinerOpsAgentTray", "MinerOpsApi3Enrichment", "MinerOpsPoolPoller"
)
foreach ($name in $Tasks) {
Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue
}
$RuntimeFiles = @(
"MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1",
"MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1",
"MinerOpsPoolPollerTask.ps1", "EnrichOneMiner.ps1", "MinerOpsControl.ps1",
"VERSION"
)
foreach ($name in $RuntimeFiles) {
Remove-Item (Join-Path $Target $name) -Force -ErrorAction SilentlyContinue
}
Write-Host "MinerOps Agent tasks and code removed. Config, state, logs, and backups were preserved."
@@ -0,0 +1,322 @@
"""add begin repair workflow
Revision ID: 0010_begin_repair
Revises: 0009_inventory_asset_id_history
Create Date: 2026-07-29
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0010_begin_repair"
down_revision: Union[str, None] = "0009_inventory_asset_id_history"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REPAIR_STATUSES = (
"OPEN",
"IN_PROGRESS",
"WAITING_FOR_PARTS",
"READY_FOR_TEST",
"COMPLETED",
"CANCELLED",
)
ACTIVE_REPAIR_STATUSES = (
"OPEN",
"IN_PROGRESS",
"WAITING_FOR_PARTS",
"READY_FOR_TEST",
)
REPAIR_REASONS = (
"HARDWARE_FAILURE",
"LOW_HASHRATE",
"CONTROL_BOARD",
"HASHBOARD",
"PSU",
"FAN",
"OVERHEATING",
"NETWORKING",
"PREVENTIVE_MAINTENANCE",
"INSPECTION",
"OTHER",
)
REPAIR_EVENT_TYPES = ("REPAIR_STARTED", "INITIAL_NOTE")
def _quoted(values: Sequence[str]) -> str:
return ", ".join(f"'{value}'" for value in values)
def upgrade() -> None:
connection = op.get_bind()
op.drop_constraint("ck_miners_lifecycle_status", "miners", type_="check")
op.create_check_constraint(
"ck_miners_lifecycle_status",
"miners",
"lifecycle_status IN ('active', 'spare', 'retired', 'in_repair')",
)
connection.exec_driver_sql(
"""
INSERT INTO permissions (key, description)
VALUES ('repairs.create', 'Begin repairs for miner assets')
ON CONFLICT (key) DO UPDATE
SET description = EXCLUDED.description
"""
)
connection.exec_driver_sql(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles
JOIN permissions ON permissions.key = 'repairs.create'
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
ON CONFLICT DO NOTHING
"""
)
op.create_table(
"repairs",
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"miner_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("miners.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column(
"status",
sa.Text(),
nullable=False,
server_default=sa.text("'OPEN'"),
),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column(
"opened_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"opened_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column(
"completed_at",
sa.DateTime(timezone=True),
nullable=True,
),
sa.Column(
"completed_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="RESTRICT"),
nullable=True,
),
sa.CheckConstraint(
f"status IN ({_quoted(REPAIR_STATUSES)})",
name="ck_repairs_status",
),
sa.CheckConstraint(
f"reason IN ({_quoted(REPAIR_REASONS)})",
name="ck_repairs_reason",
),
sa.CheckConstraint(
"(completed_at IS NULL AND completed_by IS NULL) OR "
"(completed_at IS NOT NULL AND completed_by IS NOT NULL)",
name="ck_repairs_completion_pair",
),
sa.CheckConstraint(
"(status = 'COMPLETED') = (completed_at IS NOT NULL)",
name="ck_repairs_completed_state",
),
)
op.create_index(
"idx_repairs_miner_opened_at",
"repairs",
["miner_id", "opened_at", "id"],
)
op.create_index("idx_repairs_opened_by", "repairs", ["opened_by"])
op.create_index("idx_repairs_completed_by", "repairs", ["completed_by"])
op.create_index(
"uq_repairs_one_active_per_miner",
"repairs",
["miner_id"],
unique=True,
postgresql_where=sa.text(
f"status IN ({_quoted(ACTIVE_REPAIR_STATUSES)})"
),
)
op.create_table(
"repair_events",
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"repair_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("repairs.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("event_type", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"created_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column(
"payload",
postgresql.JSONB(),
nullable=False,
),
sa.CheckConstraint(
f"event_type IN ({_quoted(REPAIR_EVENT_TYPES)})",
name="ck_repair_events_type",
),
sa.CheckConstraint(
"sequence > 0",
name="ck_repair_events_sequence_positive",
),
sa.CheckConstraint(
"jsonb_typeof(payload) = 'object'",
name="ck_repair_events_payload_object",
),
sa.CheckConstraint(
"("
"event_type = 'REPAIR_STARTED' "
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
"AND payload ->> 'to_lifecycle' = 'in_repair'"
") OR ("
"event_type = 'INITIAL_NOTE' "
"AND jsonb_typeof(payload -> 'note') = 'string' "
"AND trim(payload ->> 'note') <> ''"
")",
name="ck_repair_events_payload_shape",
),
sa.UniqueConstraint(
"repair_id",
"sequence",
name="uq_repair_events_repair_sequence",
),
)
op.create_index(
"idx_repair_events_repair_created",
"repair_events",
["repair_id", "created_at", "sequence", "id"],
)
op.create_index(
"idx_repair_events_created_by",
"repair_events",
["created_by"],
)
def downgrade() -> None:
connection = op.get_bind()
connection.exec_driver_sql(
f"""
DO $$
DECLARE
invalid_miner_ids TEXT;
BEGIN
SELECT string_agg(miner.id::text, ', ' ORDER BY miner.id::text)
INTO invalid_miner_ids
FROM miners AS miner
LEFT JOIN LATERAL (
SELECT count(*) AS started_event_count
FROM repairs AS repair
JOIN repair_events AS event
ON event.repair_id = repair.id
AND event.event_type = 'REPAIR_STARTED'
WHERE repair.miner_id = miner.id
AND repair.status IN ({_quoted(ACTIVE_REPAIR_STATUSES)})
) AS history ON TRUE
WHERE miner.lifecycle_status = 'in_repair'
AND history.started_event_count <> 1;
IF invalid_miner_ids IS NOT NULL THEN
RAISE EXCEPTION USING
MESSAGE = format(
'Cannot downgrade Begin Repair: miners %s do not have exactly one active REPAIR_STARTED event',
invalid_miner_ids
),
HINT = 'Repair the affected lifecycle history before downgrading; MinerOps will not invent a prior lifecycle value.';
END IF;
END
$$;
"""
)
connection.exec_driver_sql(
f"""
UPDATE miners AS miner
SET lifecycle_status = history.from_lifecycle,
updated_at = now()
FROM (
SELECT
repair.miner_id,
event.payload ->> 'from_lifecycle' AS from_lifecycle
FROM repairs AS repair
JOIN repair_events AS event
ON event.repair_id = repair.id
AND event.event_type = 'REPAIR_STARTED'
WHERE repair.status IN ({_quoted(ACTIVE_REPAIR_STATUSES)})
) AS history
WHERE miner.id = history.miner_id
AND miner.lifecycle_status = 'in_repair'
"""
)
op.drop_index("idx_repair_events_created_by", table_name="repair_events")
op.drop_index("idx_repair_events_repair_created", table_name="repair_events")
op.drop_table("repair_events")
op.drop_index("uq_repairs_one_active_per_miner", table_name="repairs")
op.drop_index("idx_repairs_completed_by", table_name="repairs")
op.drop_index("idx_repairs_opened_by", table_name="repairs")
op.drop_index("idx_repairs_miner_opened_at", table_name="repairs")
op.drop_table("repairs")
connection.exec_driver_sql(
"""
DELETE FROM role_permissions
WHERE permission_id = (
SELECT id FROM permissions WHERE key = 'repairs.create'
)
"""
)
connection.exec_driver_sql(
"DELETE FROM permissions WHERE key = 'repairs.create'"
)
op.drop_constraint("ck_miners_lifecycle_status", "miners", type_="check")
op.create_check_constraint(
"ck_miners_lifecycle_status",
"miners",
"lifecycle_status IN ('active', 'spare', 'retired')",
)
@@ -0,0 +1,55 @@
"""add repair timeline read permission
Revision ID: 0011_repair_timeline
Revises: 0010_begin_repair
Create Date: 2026-07-29
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0011_repair_timeline"
down_revision: Union[str, None] = "0010_begin_repair"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
connection = op.get_bind()
connection.exec_driver_sql(
"""
INSERT INTO permissions (key, description)
VALUES ('repairs.read', 'View repair details and timeline events')
ON CONFLICT (key) DO UPDATE
SET description = EXCLUDED.description
"""
)
connection.exec_driver_sql(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles
JOIN permissions ON permissions.key = 'repairs.read'
WHERE roles.name IN ('Admin', 'Manager', 'Technician', 'Read Only')
ON CONFLICT DO NOTHING
"""
)
def downgrade() -> None:
connection = op.get_bind()
connection.exec_driver_sql(
"""
DELETE FROM role_permissions
WHERE permission_id = (
SELECT id FROM permissions WHERE key = 'repairs.read'
)
"""
)
connection.exec_driver_sql(
"DELETE FROM permissions WHERE key = 'repairs.read'"
)
@@ -0,0 +1,253 @@
"""add return to service workflow
Revision ID: 0012_return_to_service
Revises: 0011_repair_timeline
Create Date: 2026-07-29
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0012_return_to_service"
down_revision: Union[str, None] = "0011_repair_timeline"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REPAIR_REASONS = (
"HARDWARE_FAILURE",
"LOW_HASHRATE",
"CONTROL_BOARD",
"HASHBOARD",
"PSU",
"FAN",
"OVERHEATING",
"NETWORKING",
"PREVENTIVE_MAINTENANCE",
"INSPECTION",
"OTHER",
)
def _quoted(values: Sequence[str]) -> str:
return ", ".join(f"'{value}'" for value in values)
def _create_event_constraints() -> None:
op.create_check_constraint(
"ck_repair_events_type",
"repair_events",
"event_type IN ('REPAIR_STARTED', 'INITIAL_NOTE', "
"'REPAIR_COMPLETED')",
)
op.create_check_constraint(
"ck_repair_events_payload_shape",
"repair_events",
"("
"event_type = 'REPAIR_STARTED' "
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
"AND payload ->> 'to_lifecycle' = 'in_repair'"
") OR ("
"event_type = 'INITIAL_NOTE' "
"AND jsonb_typeof(payload -> 'note') = 'string' "
"AND trim(payload ->> 'note') <> ''"
") OR ("
"event_type = 'REPAIR_COMPLETED' "
"AND payload ?& ARRAY["
"'previous_miner_lifecycle', 'resulting_miner_lifecycle'"
"] "
"AND payload ->> 'previous_miner_lifecycle' = 'in_repair' "
"AND payload ->> 'resulting_miner_lifecycle' "
"IN ('active', 'spare') "
"AND (NOT payload ? 'pre_repair_lifecycle' "
"OR payload ->> 'pre_repair_lifecycle' IN ('active', 'spare')) "
"AND (NOT payload ? 'completion_summary' OR ("
"jsonb_typeof(payload -> 'completion_summary') = 'string' "
"AND trim(payload ->> 'completion_summary') <> '' "
"AND char_length(payload ->> 'completion_summary') <= 4000"
"))"
")",
)
def _create_previous_event_constraints() -> None:
op.create_check_constraint(
"ck_repair_events_type",
"repair_events",
"event_type IN ('REPAIR_STARTED', 'INITIAL_NOTE')",
)
op.create_check_constraint(
"ck_repair_events_payload_shape",
"repair_events",
"("
"event_type = 'REPAIR_STARTED' "
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
"AND payload ->> 'to_lifecycle' = 'in_repair'"
") OR ("
"event_type = 'INITIAL_NOTE' "
"AND jsonb_typeof(payload -> 'note') = 'string' "
"AND trim(payload ->> 'note') <> ''"
")",
)
def upgrade() -> None:
connection = op.get_bind()
connection.exec_driver_sql(
"""
INSERT INTO permissions (key, description)
VALUES ('repairs.complete', 'Return repaired miners to service')
ON CONFLICT (key) DO UPDATE
SET description = EXCLUDED.description
"""
)
connection.exec_driver_sql(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles
JOIN permissions ON permissions.key = 'repairs.complete'
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
ON CONFLICT DO NOTHING
"""
)
op.add_column(
"repairs",
sa.Column("completion_summary", sa.Text(), nullable=True),
)
op.add_column(
"repairs",
sa.Column("resulting_lifecycle", sa.Text(), nullable=True),
)
op.create_check_constraint(
"ck_repairs_resulting_lifecycle",
"repairs",
"resulting_lifecycle IS NULL "
"OR resulting_lifecycle IN ('active', 'spare')",
)
op.create_check_constraint(
"ck_repairs_completion_summary",
"repairs",
"completion_summary IS NULL OR ("
"trim(completion_summary) <> '' "
"AND char_length(completion_summary) <= 4000"
")",
)
# NOT VALID preserves truthful legacy COMPLETED rows whose destination was
# never recorded, while PostgreSQL still enforces the check for every new
# or updated row after this migration.
connection.exec_driver_sql(
"""
ALTER TABLE repairs
ADD CONSTRAINT ck_repairs_completion_metadata_state
CHECK (
(
status = 'COMPLETED'
AND resulting_lifecycle IS NOT NULL
)
OR (
status <> 'COMPLETED'
AND completion_summary IS NULL
AND resulting_lifecycle IS NULL
)
)
NOT VALID
"""
)
op.drop_constraint(
"ck_repair_events_payload_shape",
"repair_events",
type_="check",
)
op.drop_constraint(
"ck_repair_events_type",
"repair_events",
type_="check",
)
_create_event_constraints()
def downgrade() -> None:
connection = op.get_bind()
connection.exec_driver_sql(
"""
DO $$
DECLARE
completion_count BIGINT;
BEGIN
SELECT count(*)
INTO completion_count
FROM repairs
WHERE resulting_lifecycle IS NOT NULL
OR completion_summary IS NOT NULL;
completion_count := completion_count + (
SELECT count(*)
FROM repair_events
WHERE event_type = 'REPAIR_COMPLETED'
);
IF completion_count > 0 THEN
RAISE EXCEPTION USING
MESSAGE = format(
'Cannot downgrade Return to Service: %s completion records or events would lose authoritative history',
completion_count
),
HINT = 'Preserve or explicitly migrate Return to Service history before downgrading; MinerOps will not invent or discard lifecycle, actor, summary, or event values.';
END IF;
END
$$;
"""
)
op.drop_constraint(
"ck_repair_events_payload_shape",
"repair_events",
type_="check",
)
op.drop_constraint(
"ck_repair_events_type",
"repair_events",
type_="check",
)
_create_previous_event_constraints()
op.drop_constraint(
"ck_repairs_completion_metadata_state",
"repairs",
type_="check",
)
op.drop_constraint(
"ck_repairs_completion_summary",
"repairs",
type_="check",
)
op.drop_constraint(
"ck_repairs_resulting_lifecycle",
"repairs",
type_="check",
)
op.drop_column("repairs", "resulting_lifecycle")
op.drop_column("repairs", "completion_summary")
connection.exec_driver_sql(
"""
DELETE FROM role_permissions
WHERE permission_id = (
SELECT id FROM permissions WHERE key = 'repairs.complete'
)
"""
)
connection.exec_driver_sql(
"DELETE FROM permissions WHERE key = 'repairs.complete'"
)
@@ -0,0 +1,436 @@
"""add control board replacement workflow
Revision ID: 0013_control_board_replacement
Revises: 0012_return_to_service
Create Date: 2026-08-02
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0013_control_board_replacement"
down_revision: Union[str, None] = "0012_return_to_service"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REPAIR_REASONS = (
"HARDWARE_FAILURE", "LOW_HASHRATE", "CONTROL_BOARD", "HASHBOARD",
"PSU", "FAN", "OVERHEATING", "NETWORKING",
"PREVENTIVE_MAINTENANCE", "INSPECTION", "OTHER",
)
REPLACEMENT_REASONS = (
"failed", "upgrade", "diagnostic_swap", "preventive_maintenance",
"temporary_donor", "unknown",
)
DISPOSITIONS = (
"repair_inventory", "sent_for_repair", "scrapped",
"retained_for_diagnosis", "returned_to_vendor", "unknown",
)
def _quoted(values: Sequence[str]) -> str:
return ", ".join(f"'{value}'" for value in values)
def _create_event_constraints(include_replacement: bool) -> None:
event_types = ["REPAIR_STARTED", "INITIAL_NOTE", "REPAIR_COMPLETED"]
if include_replacement:
event_types.append("CONTROL_BOARD_REPLACED")
op.create_check_constraint(
"ck_repair_events_type",
"repair_events",
f"event_type IN ({_quoted(event_types)})",
)
replacement_clause = ""
if include_replacement:
replacement_clause = (
") OR (event_type = 'CONTROL_BOARD_REPLACED' "
"AND payload ?& ARRAY['old_mac_address', 'new_mac_address', "
"'old_control_board_serial', 'new_control_board_serial', "
"'replacement_reason', 'removed_board_disposition'] "
"AND jsonb_typeof(payload -> 'old_mac_address') IN ('string', 'null') "
"AND jsonb_typeof(payload -> 'new_mac_address') = 'string' "
"AND payload ->> 'new_mac_address' ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' "
"AND regexp_replace(lower(payload ->> 'new_mac_address'), '[^0-9a-f]', '', 'g') "
"NOT IN ('000000000000', 'ffffffffffff') "
"AND get_byte(decode(regexp_replace(lower(payload ->> 'new_mac_address'), "
"'[^0-9a-f]', '', 'g'), 'hex'), 0) % 2 = 0 "
"AND (jsonb_typeof(payload -> 'old_control_board_serial') = 'null' OR ("
"jsonb_typeof(payload -> 'old_control_board_serial') = 'string' "
"AND trim(payload ->> 'old_control_board_serial') <> '' "
"AND char_length(payload ->> 'old_control_board_serial') <= 255)) "
"AND (jsonb_typeof(payload -> 'new_control_board_serial') = 'null' OR ("
"jsonb_typeof(payload -> 'new_control_board_serial') = 'string' "
"AND trim(payload ->> 'new_control_board_serial') <> '' "
"AND char_length(payload ->> 'new_control_board_serial') <= 255)) "
f"AND payload ->> 'replacement_reason' IN ({_quoted(REPLACEMENT_REASONS)}) "
f"AND payload ->> 'removed_board_disposition' IN ({_quoted(DISPOSITIONS)}) "
"AND (NOT payload ? 'notes' OR jsonb_typeof(payload -> 'notes') = 'null' OR ("
"jsonb_typeof(payload -> 'notes') = 'string' "
"AND trim(payload ->> 'notes') <> '' "
"AND char_length(payload ->> 'notes') <= 4000))"
)
op.create_check_constraint(
"ck_repair_events_payload_shape",
"repair_events",
"(event_type = 'REPAIR_STARTED' "
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
"AND payload ->> 'to_lifecycle' = 'in_repair') OR ("
"event_type = 'INITIAL_NOTE' "
"AND jsonb_typeof(payload -> 'note') = 'string' "
"AND trim(payload ->> 'note') <> '') OR ("
"event_type = 'REPAIR_COMPLETED' "
"AND payload ?& ARRAY['previous_miner_lifecycle', 'resulting_miner_lifecycle'] "
"AND payload ->> 'previous_miner_lifecycle' = 'in_repair' "
"AND payload ->> 'resulting_miner_lifecycle' IN ('active', 'spare') "
"AND (NOT payload ? 'pre_repair_lifecycle' "
"OR payload ->> 'pre_repair_lifecycle' IN ('active', 'spare')) "
"AND (NOT payload ? 'completion_summary' OR ("
"jsonb_typeof(payload -> 'completion_summary') = 'string' "
"AND trim(payload ->> 'completion_summary') <> '' "
"AND char_length(payload ->> 'completion_summary') <= 4000))"
f"{replacement_clause})",
)
def upgrade() -> None:
connection = op.get_bind()
connection.exec_driver_sql(
"""
DO $$
DECLARE conflicts TEXT;
BEGIN
WITH identities AS (
SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac,
miner_id
FROM miner_network_identities
WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12
UNION ALL
SELECT regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g'), id
FROM miners
WHERE length(regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')) = 12
), duplicate_owners AS (
SELECT mac
FROM identities
GROUP BY mac
HAVING count(DISTINCT miner_id) > 1
)
SELECT string_agg(mac, ', ' ORDER BY mac) INTO conflicts FROM duplicate_owners;
IF conflicts IS NOT NULL THEN
RAISE EXCEPTION USING
MESSAGE = 'Cannot upgrade Control Board Replacement: normalized MAC values have multiple miner owners: ' || conflicts,
HINT = 'Correct the conflicting miner and network-identity records before upgrading; MinerOps will not transfer, merge, or delete MAC history automatically.';
END IF;
SELECT string_agg(mac, ', ' ORDER BY mac) INTO conflicts
FROM (
SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac
FROM miner_network_identities
WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12
GROUP BY regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')
HAVING count(*) > 1
) AS duplicate_identity_mac;
IF conflicts IS NOT NULL THEN
RAISE EXCEPTION USING
MESSAGE = 'Cannot upgrade Control Board Replacement: duplicate normalized network-identity MAC values: ' || conflicts,
HINT = 'Correct duplicate identity rows before upgrading; MinerOps will not delete or merge history automatically.';
END IF;
SELECT string_agg(miner_id::text, ', ' ORDER BY miner_id::text)
INTO conflicts
FROM (
SELECT miner_id FROM miner_network_identities
WHERE is_current GROUP BY miner_id HAVING count(*) > 1
) AS duplicate_current;
IF conflicts IS NOT NULL THEN
RAISE EXCEPTION USING
MESSAGE = 'Cannot upgrade Control Board Replacement: miners have multiple current network identities: ' || conflicts,
HINT = 'Choose one current network identity for each listed miner before upgrading.';
END IF;
END $$;
"""
)
connection.exec_driver_sql(
"""
INSERT INTO permissions (key, description)
VALUES ('miners.replace_control_board', 'Replace miner control boards during repair')
ON CONFLICT (key) DO UPDATE SET description = EXCLUDED.description;
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id FROM roles
JOIN permissions ON permissions.key = 'miners.replace_control_board'
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
ON CONFLICT DO NOTHING;
"""
)
op.add_column(
"miner_network_identities",
sa.Column("associated_at", sa.DateTime(timezone=True), nullable=True),
)
op.alter_column("miner_network_identities", "first_seen_at", nullable=True)
op.alter_column("miner_network_identities", "last_seen_at", nullable=True)
connection.exec_driver_sql(
"""
CREATE UNIQUE INDEX uq_miner_network_identities_normalized_mac
ON miner_network_identities (
regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')
)
WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12;
CREATE UNIQUE INDEX uq_miner_network_identities_one_current_per_miner
ON miner_network_identities (miner_id) WHERE is_current;
CREATE UNIQUE INDEX uq_miners_current_normalized_mac
ON miners (regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g'))
WHERE length(regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')) = 12;
ALTER TABLE miner_network_identities
ADD CONSTRAINT ck_miner_network_identities_mac_valid
CHECK (
source NOT IN ('control_board_replacement', 'legacy_projection') OR (
mac_address ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$'
AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') NOT IN ('000000000000', 'ffffffffffff')
AND get_byte(decode(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g'), 'hex'), 0) %% 2 = 0
)
) NOT VALID;
"""
)
op.create_table(
"miner_mac_ownerships",
sa.Column("normalized_mac", sa.Text(), primary_key=True),
sa.Column(
"miner_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("miners.id", ondelete="RESTRICT"), nullable=False,
),
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"claimed_by", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True,
),
sa.CheckConstraint(
"normalized_mac ~ '^[0-9a-f]{12}$' "
"AND normalized_mac NOT IN ('000000000000', 'ffffffffffff') "
"AND get_byte(decode(normalized_mac, 'hex'), 0) % 2 = 0",
name="ck_miner_mac_ownerships_valid",
),
)
op.create_index("idx_miner_mac_ownerships_miner_id", "miner_mac_ownerships", ["miner_id"])
connection.exec_driver_sql(
"""
INSERT INTO miner_mac_ownerships (normalized_mac, miner_id)
SELECT mac, min(miner_id::text)::uuid FROM (
SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac, miner_id
FROM miner_network_identities
UNION ALL
SELECT regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g'), id FROM miners
) AS known
WHERE length(mac) = 12 AND mac NOT IN ('000000000000', 'ffffffffffff')
AND get_byte(decode(mac, 'hex'), 0) %% 2 = 0
GROUP BY mac;
"""
)
op.create_table(
"miner_control_board_installations",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("miner_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("miners.id", ondelete="RESTRICT"), nullable=False),
sa.Column("record_origin", sa.Text(), nullable=False),
sa.Column("installed_repair_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=True),
sa.Column("removed_repair_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=True),
sa.Column("mac_address", sa.Text(), nullable=True),
sa.Column("normalized_mac", sa.Text(), nullable=True),
sa.Column("board_serial", sa.Text(), nullable=True),
sa.Column("board_model", sa.Text(), nullable=True),
sa.Column("board_revision", sa.Text(), nullable=True),
sa.Column("firmware_version", sa.Text(), nullable=True),
sa.Column("replacement_reason", sa.Text(), nullable=True),
sa.Column("installed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("installed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True),
sa.Column("removed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("removed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True),
sa.Column("removed_disposition", sa.Text(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.CheckConstraint("(mac_address IS NULL) = (normalized_mac IS NULL)", name="ck_control_board_installations_mac_pair"),
sa.CheckConstraint(
"normalized_mac IS NULL OR ("
"mac_address ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' "
"AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = normalized_mac "
"AND normalized_mac ~ '^[0-9a-f]{12}$' "
"AND normalized_mac NOT IN ('000000000000', 'ffffffffffff') "
"AND get_byte(decode(normalized_mac, 'hex'), 0) % 2 = 0)",
name="ck_control_board_installations_mac_normalized",
),
sa.CheckConstraint("board_serial IS NULL OR (trim(board_serial) <> '' AND char_length(board_serial) <= 255)", name="ck_control_board_installations_serial"),
sa.CheckConstraint("board_model IS NULL OR (trim(board_model) <> '' AND char_length(board_model) <= 255)", name="ck_control_board_installations_model"),
sa.CheckConstraint("board_revision IS NULL OR (trim(board_revision) <> '' AND char_length(board_revision) <= 255)", name="ck_control_board_installations_revision"),
sa.CheckConstraint("firmware_version IS NULL OR (trim(firmware_version) <> '' AND char_length(firmware_version) <= 255)", name="ck_control_board_installations_firmware"),
sa.CheckConstraint(f"replacement_reason IS NULL OR replacement_reason IN ({_quoted(REPLACEMENT_REASONS)})", name="ck_control_board_installations_reason"),
sa.CheckConstraint(f"removed_disposition IS NULL OR removed_disposition IN ({_quoted(DISPOSITIONS)})", name="ck_control_board_installations_disposition"),
sa.CheckConstraint("notes IS NULL OR (trim(notes) <> '' AND char_length(notes) <= 4000)", name="ck_control_board_installations_notes"),
sa.CheckConstraint("(removed_at IS NULL AND removed_by IS NULL AND removed_repair_id IS NULL AND removed_disposition IS NULL) OR (removed_at IS NOT NULL AND removed_by IS NOT NULL AND removed_repair_id IS NOT NULL AND removed_disposition IS NOT NULL)", name="ck_control_board_installations_removed_state"),
sa.CheckConstraint("installed_at IS NULL OR removed_at IS NULL OR removed_at >= installed_at", name="ck_control_board_installations_chronology"),
sa.CheckConstraint(
"(record_origin = 'legacy_projection' AND installed_repair_id IS NULL "
"AND installed_at IS NULL AND installed_by IS NULL "
"AND replacement_reason IS NULL) OR "
"(record_origin = 'replacement' AND installed_repair_id IS NOT NULL "
"AND installed_at IS NOT NULL AND installed_by IS NOT NULL "
"AND replacement_reason IS NOT NULL)",
name="ck_control_board_installations_install_state",
),
)
op.create_index("uq_control_board_installations_current_miner", "miner_control_board_installations", ["miner_id"], unique=True, postgresql_where=sa.text("removed_at IS NULL"))
op.create_index("uq_control_board_installations_current_mac", "miner_control_board_installations", ["normalized_mac"], unique=True, postgresql_where=sa.text("removed_at IS NULL AND normalized_mac IS NOT NULL"))
op.create_index("idx_control_board_installations_miner_history", "miner_control_board_installations", ["miner_id", "installed_at", "created_at", "id"])
op.create_index("idx_control_board_installations_mac_history", "miner_control_board_installations", ["normalized_mac", "created_at", "id"])
op.create_index("idx_control_board_installations_installed_repair", "miner_control_board_installations", ["installed_repair_id"])
op.create_index("idx_control_board_installations_removed_repair", "miner_control_board_installations", ["removed_repair_id"])
connection.exec_driver_sql(
"""
CREATE FUNCTION minerops_claim_mac_owner() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE mac text; existing_owner uuid; target_owner uuid; row_data jsonb;
BEGIN
row_data := to_jsonb(NEW);
mac := regexp_replace(lower(CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'current_mac' ELSE row_data ->> 'mac_address' END), '[^0-9a-f]', '', 'g');
target_owner := (CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'id' ELSE row_data ->> 'miner_id' END)::uuid;
IF mac IS NULL OR length(mac) <> 12 OR mac IN ('000000000000', 'ffffffffffff') OR get_byte(decode(mac, 'hex'), 0) %% 2 <> 0 THEN RETURN NEW; END IF;
INSERT INTO miner_mac_ownerships (normalized_mac, miner_id, claimed_at)
VALUES (mac, target_owner, now())
ON CONFLICT (normalized_mac) DO NOTHING;
SELECT miner_id INTO existing_owner FROM miner_mac_ownerships WHERE normalized_mac = mac FOR UPDATE;
IF existing_owner <> (target_owner) THEN
RAISE EXCEPTION 'MAC %% is historically owned by miner %%', mac, existing_owner USING ERRCODE = '23505', CONSTRAINT = 'uq_miner_mac_ownerships_owner';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_miners_claim_mac_owner AFTER INSERT OR UPDATE OF current_mac ON miners FOR EACH ROW EXECUTE FUNCTION minerops_claim_mac_owner();
CREATE TRIGGER trg_network_identities_claim_mac_owner BEFORE INSERT OR UPDATE OF mac_address, miner_id ON miner_network_identities FOR EACH ROW EXECUTE FUNCTION minerops_claim_mac_owner();
CREATE FUNCTION minerops_protect_mac_ownership() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'Historical MAC ownership is immutable; use an explicit transfer workflow';
END $$;
CREATE TRIGGER trg_protect_mac_ownership BEFORE UPDATE OR DELETE ON miner_mac_ownerships FOR EACH ROW EXECUTE FUNCTION minerops_protect_mac_ownership();
CREATE FUNCTION minerops_protect_network_identity_history() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'Network identity history is immutable';
END IF;
IF NEW.miner_id <> OLD.miner_id
OR regexp_replace(lower(NEW.mac_address), '[^0-9a-f]', '', 'g')
<> regexp_replace(lower(OLD.mac_address), '[^0-9a-f]', '', 'g') THEN
RAISE EXCEPTION 'Network identity owner and normalized MAC are immutable';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_protect_network_identity_history BEFORE UPDATE OF miner_id, mac_address OR DELETE ON miner_network_identities FOR EACH ROW EXECUTE FUNCTION minerops_protect_network_identity_history();
CREATE FUNCTION minerops_check_current_board_projection() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE target_miner uuid; mismatch boolean; row_data jsonb;
BEGIN
IF TG_OP = 'DELETE' THEN
row_data := to_jsonb(OLD);
ELSE
row_data := to_jsonb(NEW);
END IF;
target_miner := (CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'id' ELSE row_data ->> 'miner_id' END)::uuid;
SELECT EXISTS (
SELECT 1 FROM miner_control_board_installations
WHERE miner_id = target_miner
) AND NOT EXISTS (
SELECT 1
FROM miner_control_board_installations board
JOIN miners miner ON miner.id = board.miner_id
JOIN miner_network_identities identity
ON identity.miner_id = board.miner_id AND identity.is_current
WHERE board.miner_id = target_miner AND board.removed_at IS NULL
AND regexp_replace(lower(COALESCE(miner.current_mac, '')), '[^0-9a-f]', '', 'g') = COALESCE(board.normalized_mac, '')
AND miner.control_board_sn IS NOT DISTINCT FROM board.board_serial
AND regexp_replace(lower(identity.mac_address), '[^0-9a-f]', '', 'g') = COALESCE(board.normalized_mac, '')
) INTO mismatch;
IF mismatch THEN RAISE EXCEPTION 'Current control-board projection does not match installation history for miner %%', target_miner USING ERRCODE = '23514', CONSTRAINT = 'ck_current_control_board_projection'; END IF;
RETURN NULL;
END $$;
CREATE CONSTRAINT TRIGGER trg_miners_check_current_board_projection AFTER INSERT OR UPDATE ON miners DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection();
CREATE CONSTRAINT TRIGGER trg_installations_check_current_board_projection AFTER INSERT OR UPDATE OR DELETE ON miner_control_board_installations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection();
CREATE CONSTRAINT TRIGGER trg_identities_check_current_board_projection AFTER INSERT OR UPDATE OR DELETE ON miner_network_identities DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection();
CREATE FUNCTION minerops_protect_control_board_history() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Control-board installation history is immutable'; END IF;
IF OLD.removed_at IS NOT NULL THEN RAISE EXCEPTION 'Removed control-board installation history is immutable'; END IF;
IF NEW.id <> OLD.id OR NEW.miner_id <> OLD.miner_id OR NEW.record_origin <> OLD.record_origin OR NEW.installed_repair_id IS DISTINCT FROM OLD.installed_repair_id OR NEW.mac_address IS DISTINCT FROM OLD.mac_address OR NEW.normalized_mac IS DISTINCT FROM OLD.normalized_mac OR NEW.board_serial IS DISTINCT FROM OLD.board_serial OR NEW.board_model IS DISTINCT FROM OLD.board_model OR NEW.board_revision IS DISTINCT FROM OLD.board_revision OR NEW.firmware_version IS DISTINCT FROM OLD.firmware_version OR NEW.replacement_reason IS DISTINCT FROM OLD.replacement_reason OR NEW.installed_at IS DISTINCT FROM OLD.installed_at OR NEW.installed_by IS DISTINCT FROM OLD.installed_by OR NEW.notes IS DISTINCT FROM OLD.notes OR NEW.created_at <> OLD.created_at THEN
RAISE EXCEPTION 'Control-board installation identity is immutable';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_protect_control_board_history BEFORE UPDATE OR DELETE ON miner_control_board_installations FOR EACH ROW EXECUTE FUNCTION minerops_protect_control_board_history();
"""
)
op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check")
op.drop_constraint("ck_repair_events_type", "repair_events", type_="check")
_create_event_constraints(True)
def downgrade() -> None:
connection = op.get_bind()
connection.exec_driver_sql(
"""
DO $$ DECLARE history_count bigint; BEGIN
SELECT count(*) INTO history_count FROM miner_control_board_installations;
history_count := history_count + (SELECT count(*) FROM repair_events WHERE event_type = 'CONTROL_BOARD_REPLACED');
IF history_count > 0 THEN
RAISE EXCEPTION USING MESSAGE = format('Cannot downgrade Control Board Replacement: %s authoritative installation records or events would be destroyed', history_count), HINT = 'Preserve or explicitly migrate control-board history before downgrading; MinerOps will not discard or invent component, MAC, actor, or timestamp history.';
END IF;
IF EXISTS (SELECT 1 FROM miner_network_identities WHERE first_seen_at IS NULL OR last_seen_at IS NULL) THEN
RAISE EXCEPTION USING MESSAGE = 'Cannot downgrade Control Board Replacement: network identities contain unknown observation timestamps', HINT = 'Do not invent discovery timestamps; preserve or explicitly migrate these associations before downgrading.';
END IF;
IF EXISTS (SELECT 1 FROM miner_network_identities WHERE associated_at IS NOT NULL)
OR EXISTS (SELECT 1 FROM miner_mac_ownerships WHERE claimed_at IS NOT NULL) THEN
RAISE EXCEPTION USING MESSAGE = 'Cannot downgrade Control Board Replacement: authoritative MAC association or ownership history would be destroyed', HINT = 'Preserve or explicitly migrate MAC association and ownership history before downgrading.';
END IF;
END $$;
"""
)
op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check")
op.drop_constraint("ck_repair_events_type", "repair_events", type_="check")
_create_event_constraints(False)
connection.exec_driver_sql(
"""
DROP TRIGGER trg_protect_control_board_history ON miner_control_board_installations;
DROP FUNCTION minerops_protect_control_board_history();
DROP TRIGGER trg_installations_check_current_board_projection ON miner_control_board_installations;
DROP TRIGGER trg_identities_check_current_board_projection ON miner_network_identities;
DROP TRIGGER trg_miners_check_current_board_projection ON miners;
DROP FUNCTION minerops_check_current_board_projection();
DROP TRIGGER trg_protect_mac_ownership ON miner_mac_ownerships;
DROP FUNCTION minerops_protect_mac_ownership();
DROP TRIGGER trg_protect_network_identity_history ON miner_network_identities;
DROP FUNCTION minerops_protect_network_identity_history();
DROP TRIGGER trg_network_identities_claim_mac_owner ON miner_network_identities;
DROP TRIGGER trg_miners_claim_mac_owner ON miners;
DROP FUNCTION minerops_claim_mac_owner();
"""
)
op.drop_table("miner_control_board_installations")
op.drop_index("idx_miner_mac_ownerships_miner_id", table_name="miner_mac_ownerships")
op.drop_table("miner_mac_ownerships")
op.drop_constraint("ck_miner_network_identities_mac_valid", "miner_network_identities", type_="check")
op.drop_index("uq_miners_current_normalized_mac", table_name="miners")
op.drop_index("uq_miner_network_identities_one_current_per_miner", table_name="miner_network_identities")
op.drop_index("uq_miner_network_identities_normalized_mac", table_name="miner_network_identities")
op.alter_column("miner_network_identities", "last_seen_at", nullable=False)
op.alter_column("miner_network_identities", "first_seen_at", nullable=False)
op.drop_column("miner_network_identities", "associated_at")
connection.exec_driver_sql("DELETE FROM role_permissions WHERE permission_id = (SELECT id FROM permissions WHERE key = 'miners.replace_control_board')")
connection.exec_driver_sql("DELETE FROM permissions WHERE key = 'miners.replace_control_board'")
@@ -0,0 +1,590 @@
"""add repair parts tracking
Revision ID: 0014_repair_parts_tracking
Revises: 0013_control_board_replacement
Create Date: 2026-08-02
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0014_repair_parts_tracking"
down_revision: Union[str, None] = "0013_control_board_replacement"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
COMPONENT_TYPES = ("control_board", "hashboard", "power_supply", "fan")
CONSUMABLE_UNITS = ("each", "gram", "millilitre", "metre", "centimetre")
REPAIR_REASONS = (
"HARDWARE_FAILURE", "LOW_HASHRATE", "CONTROL_BOARD", "HASHBOARD",
"PSU", "FAN", "OVERHEATING", "NETWORKING",
"PREVENTIVE_MAINTENANCE", "INSPECTION", "OTHER",
)
REPLACEMENT_REASONS = (
"failed", "upgrade", "diagnostic_swap", "preventive_maintenance",
"temporary_donor", "unknown",
)
DISPOSITIONS = (
"repair_inventory", "sent_for_repair", "scrapped",
"retained_for_diagnosis", "returned_to_vendor", "unknown",
)
def _quoted(values: Sequence[str]) -> str:
return ", ".join(f"'{value}'" for value in values)
def _create_event_constraints(include_parts: bool) -> None:
event_types = [
"REPAIR_STARTED",
"INITIAL_NOTE",
"REPAIR_COMPLETED",
"CONTROL_BOARD_REPLACED",
]
if include_parts:
event_types.extend(("REPAIR_PART_RECORDED", "REPAIR_PART_VOIDED"))
op.create_check_constraint(
"ck_repair_events_type",
"repair_events",
f"event_type IN ({_quoted(event_types)})",
)
parts_clause = ""
if include_parts:
parts_clause = (
") OR (event_type = 'REPAIR_PART_RECORDED' "
"AND payload ?& ARRAY['repair_part_id', 'entry_kind', 'notes'] "
"AND payload ->> 'repair_part_id' ~ "
"'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-"
"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' "
"AND (jsonb_typeof(payload -> 'notes') = 'null' OR ("
"jsonb_typeof(payload -> 'notes') = 'string' "
"AND trim(payload ->> 'notes') <> '' "
"AND char_length(payload ->> 'notes') <= 4000)) "
"AND ((payload ->> 'entry_kind' = 'consumable' "
"AND payload ?& ARRAY['description', 'quantity', 'unit'] "
"AND NOT payload ? 'component_id' "
"AND jsonb_typeof(payload -> 'description') = 'string' "
"AND trim(payload ->> 'description') <> '' "
"AND char_length(payload ->> 'description') <= 255 "
"AND jsonb_typeof(payload -> 'quantity') = 'string' "
"AND payload ->> 'quantity' ~ '^[0-9]{1,9}(\\.[0-9]{1,3})?$' "
"AND (payload ->> 'quantity')::numeric > 0 "
f"AND payload ->> 'unit' IN ({_quoted(CONSUMABLE_UNITS)}) "
"AND (payload ->> 'unit' <> 'each' OR "
"(payload ->> 'quantity')::numeric = "
"trunc((payload ->> 'quantity')::numeric))) OR ("
"payload ->> 'entry_kind' = 'tracked_component' "
"AND payload ?& ARRAY['component_id', 'component_type', "
"'component_asset_id', 'component_manufacturer', "
"'component_model', 'component_manufacturer_serial'] "
"AND NOT payload ? 'description' "
"AND NOT payload ? 'quantity' "
"AND NOT payload ? 'unit' "
"AND payload ->> 'component_id' ~ "
"'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-"
"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' "
f"AND payload ->> 'component_type' IN ({_quoted(COMPONENT_TYPES)}) "
"AND jsonb_typeof(payload -> 'component_asset_id') IN ('string', 'null') "
"AND jsonb_typeof(payload -> 'component_manufacturer') IN ('string', 'null') "
"AND jsonb_typeof(payload -> 'component_model') IN ('string', 'null') "
"AND jsonb_typeof(payload -> 'component_manufacturer_serial') "
"IN ('string', 'null'))))"
" OR (event_type = 'REPAIR_PART_VOIDED' "
"AND payload ?& ARRAY['repair_part_id', 'reason'] "
"AND payload ->> 'repair_part_id' ~ "
"'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-"
"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' "
"AND jsonb_typeof(payload -> 'reason') = 'string' "
"AND trim(payload ->> 'reason') <> '' "
"AND char_length(payload ->> 'reason') <= 1000"
)
op.create_check_constraint(
"ck_repair_events_payload_shape",
"repair_events",
"(event_type = 'REPAIR_STARTED' "
"AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] "
f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) "
"AND payload ->> 'from_lifecycle' IN ('active', 'spare') "
"AND payload ->> 'to_lifecycle' = 'in_repair') OR ("
"event_type = 'INITIAL_NOTE' "
"AND jsonb_typeof(payload -> 'note') = 'string' "
"AND trim(payload ->> 'note') <> '') OR ("
"event_type = 'REPAIR_COMPLETED' "
"AND payload ?& ARRAY['previous_miner_lifecycle', 'resulting_miner_lifecycle'] "
"AND payload ->> 'previous_miner_lifecycle' = 'in_repair' "
"AND payload ->> 'resulting_miner_lifecycle' IN ('active', 'spare') "
"AND (NOT payload ? 'pre_repair_lifecycle' "
"OR payload ->> 'pre_repair_lifecycle' IN ('active', 'spare')) "
"AND (NOT payload ? 'completion_summary' OR ("
"jsonb_typeof(payload -> 'completion_summary') = 'string' "
"AND trim(payload ->> 'completion_summary') <> '' "
"AND char_length(payload ->> 'completion_summary') <= 4000))) OR ("
"event_type = 'CONTROL_BOARD_REPLACED' "
"AND payload ?& ARRAY['old_mac_address', 'new_mac_address', "
"'old_control_board_serial', 'new_control_board_serial', "
"'replacement_reason', 'removed_board_disposition'] "
"AND jsonb_typeof(payload -> 'old_mac_address') IN ('string', 'null') "
"AND jsonb_typeof(payload -> 'new_mac_address') = 'string' "
"AND payload ->> 'new_mac_address' ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' "
"AND regexp_replace(lower(payload ->> 'new_mac_address'), '[^0-9a-f]', '', 'g') "
"NOT IN ('000000000000', 'ffffffffffff') "
"AND get_byte(decode(regexp_replace(lower(payload ->> 'new_mac_address'), "
"'[^0-9a-f]', '', 'g'), 'hex'), 0) % 2 = 0 "
"AND (jsonb_typeof(payload -> 'old_control_board_serial') = 'null' OR ("
"jsonb_typeof(payload -> 'old_control_board_serial') = 'string' "
"AND trim(payload ->> 'old_control_board_serial') <> '' "
"AND char_length(payload ->> 'old_control_board_serial') <= 255)) "
"AND (jsonb_typeof(payload -> 'new_control_board_serial') = 'null' OR ("
"jsonb_typeof(payload -> 'new_control_board_serial') = 'string' "
"AND trim(payload ->> 'new_control_board_serial') <> '' "
"AND char_length(payload ->> 'new_control_board_serial') <= 255)) "
f"AND payload ->> 'replacement_reason' IN ({_quoted(REPLACEMENT_REASONS)}) "
f"AND payload ->> 'removed_board_disposition' IN ({_quoted(DISPOSITIONS)}) "
"AND (NOT payload ? 'notes' OR jsonb_typeof(payload -> 'notes') = 'null' OR ("
"jsonb_typeof(payload -> 'notes') = 'string' "
"AND trim(payload ->> 'notes') <> '' "
"AND char_length(payload ->> 'notes') <= 4000))"
f"{parts_clause})",
)
def upgrade() -> None:
connection = op.get_bind()
connection.exec_driver_sql(
"""
INSERT INTO permissions (key, description)
VALUES
('components.read', 'View tracked component identity'),
('components.create', 'Register tracked component identity'),
('repairs.parts.manage', 'Record and void repair-part usage')
ON CONFLICT (key) DO UPDATE SET description = EXCLUDED.description;
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles
JOIN permissions ON permissions.key IN (
'components.read', 'components.create', 'repairs.parts.manage'
)
WHERE roles.name IN ('Admin', 'Manager', 'Technician')
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles
JOIN permissions ON permissions.key = 'components.read'
WHERE roles.name = 'Read Only'
ON CONFLICT DO NOTHING;
"""
)
op.create_table(
"components",
sa.Column(
"id", postgresql.UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column("component_type", sa.Text(), nullable=False),
sa.Column("asset_id", sa.Text(), nullable=True),
sa.Column("manufacturer", sa.Text(), nullable=True),
sa.Column("model", sa.Text(), nullable=True),
sa.Column("manufacturer_serial", sa.Text(), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"created_by", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False,
),
sa.CheckConstraint(
f"component_type IN ({_quoted(COMPONENT_TYPES)})",
name="ck_components_type",
),
sa.CheckConstraint(
"asset_id IS NULL OR (trim(asset_id) <> '' "
"AND asset_id = trim(asset_id) AND char_length(asset_id) <= 255)",
name="ck_components_asset_id",
),
sa.CheckConstraint(
"manufacturer IS NULL OR (trim(manufacturer) <> '' "
"AND manufacturer = trim(manufacturer) "
"AND char_length(manufacturer) <= 255)",
name="ck_components_manufacturer",
),
sa.CheckConstraint(
"model IS NULL OR (trim(model) <> '' AND model = trim(model) "
"AND char_length(model) <= 255)",
name="ck_components_model",
),
sa.CheckConstraint(
"manufacturer_serial IS NULL OR (trim(manufacturer_serial) <> '' "
"AND manufacturer_serial = trim(manufacturer_serial) "
"AND char_length(manufacturer_serial) <= 255)",
name="ck_components_manufacturer_serial",
),
)
op.create_index("idx_components_created_by", "components", ["created_by"])
op.create_index("idx_components_type", "components", ["component_type"])
op.create_index(
"uq_components_normalized_asset_id",
"components",
[sa.text("lower(trim(asset_id))")],
unique=True,
postgresql_where=sa.text("asset_id IS NOT NULL"),
)
op.create_table(
"repair_parts",
sa.Column(
"id", postgresql.UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"repair_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=False,
),
sa.Column("consumable_description", sa.Text(), nullable=True),
sa.Column("quantity", sa.Numeric(precision=12, scale=3), nullable=True),
sa.Column("unit", sa.Text(), nullable=True),
sa.Column(
"component_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("components.id", ondelete="RESTRICT"), nullable=True,
),
sa.Column("component_type_snapshot", sa.Text(), nullable=True),
sa.Column("component_asset_id_snapshot", sa.Text(), nullable=True),
sa.Column("component_manufacturer_snapshot", sa.Text(), nullable=True),
sa.Column("component_model_snapshot", sa.Text(), nullable=True),
sa.Column("component_manufacturer_serial_snapshot", sa.Text(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column(
"recorded_by", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False,
),
sa.Column(
"recorded_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"recorded_event_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("repair_events.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column(
"voided_by", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True,
),
sa.Column("voided_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("void_reason", sa.Text(), nullable=True),
sa.Column(
"void_event_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("repair_events.id", ondelete="RESTRICT"),
nullable=True,
),
sa.CheckConstraint(
"((consumable_description IS NOT NULL AND quantity IS NOT NULL "
"AND unit IS NOT NULL AND component_id IS NULL "
"AND component_type_snapshot IS NULL "
"AND component_asset_id_snapshot IS NULL "
"AND component_manufacturer_snapshot IS NULL "
"AND component_model_snapshot IS NULL "
"AND component_manufacturer_serial_snapshot IS NULL) OR "
"(consumable_description IS NULL AND quantity IS NULL "
"AND unit IS NULL AND component_id IS NOT NULL "
"AND component_type_snapshot IS NOT NULL))",
name="ck_repair_parts_entry_xor",
),
sa.CheckConstraint(
"quantity IS NULL OR quantity > 0",
name="ck_repair_parts_quantity_positive",
),
sa.CheckConstraint(
"unit IS NULL OR unit <> 'each' OR quantity = trunc(quantity)",
name="ck_repair_parts_each_whole",
),
sa.CheckConstraint(
f"unit IS NULL OR unit IN ({_quoted(CONSUMABLE_UNITS)})",
name="ck_repair_parts_unit",
),
sa.CheckConstraint(
f"component_type_snapshot IS NULL OR "
f"component_type_snapshot IN ({_quoted(COMPONENT_TYPES)})",
name="ck_repair_parts_component_type",
),
sa.CheckConstraint(
"consumable_description IS NULL OR ("
"consumable_description = trim(consumable_description) "
"AND trim(consumable_description) <> '' "
"AND char_length(consumable_description) <= 255)",
name="ck_repair_parts_description",
),
sa.CheckConstraint(
"component_asset_id_snapshot IS NULL OR ("
"component_asset_id_snapshot = trim(component_asset_id_snapshot) "
"AND trim(component_asset_id_snapshot) <> '' "
"AND char_length(component_asset_id_snapshot) <= 255)",
name="ck_repair_parts_asset_snapshot",
),
sa.CheckConstraint(
"component_manufacturer_snapshot IS NULL OR ("
"component_manufacturer_snapshot = trim(component_manufacturer_snapshot) "
"AND trim(component_manufacturer_snapshot) <> '' "
"AND char_length(component_manufacturer_snapshot) <= 255)",
name="ck_repair_parts_manufacturer_snapshot",
),
sa.CheckConstraint(
"component_model_snapshot IS NULL OR ("
"component_model_snapshot = trim(component_model_snapshot) "
"AND trim(component_model_snapshot) <> '' "
"AND char_length(component_model_snapshot) <= 255)",
name="ck_repair_parts_model_snapshot",
),
sa.CheckConstraint(
"component_manufacturer_serial_snapshot IS NULL OR ("
"component_manufacturer_serial_snapshot = "
"trim(component_manufacturer_serial_snapshot) "
"AND trim(component_manufacturer_serial_snapshot) <> '' "
"AND char_length(component_manufacturer_serial_snapshot) <= 255)",
name="ck_repair_parts_serial_snapshot",
),
sa.CheckConstraint(
"notes IS NULL OR (notes = trim(notes) AND trim(notes) <> '' "
"AND char_length(notes) <= 4000)",
name="ck_repair_parts_notes",
),
sa.CheckConstraint(
"(voided_by IS NULL AND voided_at IS NULL AND void_reason IS NULL "
"AND void_event_id IS NULL) OR "
"(voided_by IS NOT NULL AND voided_at IS NOT NULL "
"AND void_reason IS NOT NULL AND void_event_id IS NOT NULL)",
name="ck_repair_parts_void_state",
),
sa.CheckConstraint(
"void_reason IS NULL OR (void_reason = trim(void_reason) "
"AND trim(void_reason) <> '' AND char_length(void_reason) <= 1000)",
name="ck_repair_parts_void_reason",
),
sa.CheckConstraint(
"voided_at IS NULL OR voided_at >= recorded_at",
name="ck_repair_parts_void_chronology",
),
)
op.create_index(
"idx_repair_parts_repair",
"repair_parts",
["repair_id", "recorded_event_id", "id"],
)
op.create_index("idx_repair_parts_component", "repair_parts", ["component_id"])
op.create_index("idx_repair_parts_recorded_by", "repair_parts", ["recorded_by"])
op.create_index("idx_repair_parts_voided_by", "repair_parts", ["voided_by"])
op.create_index(
"uq_repair_parts_recorded_event",
"repair_parts",
["recorded_event_id"],
unique=True,
)
op.create_index(
"uq_repair_parts_void_event",
"repair_parts",
["void_event_id"],
unique=True,
postgresql_where=sa.text("void_event_id IS NOT NULL"),
)
op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check")
op.drop_constraint("ck_repair_events_type", "repair_events", type_="check")
_create_event_constraints(True)
connection.exec_driver_sql(
"""
CREATE FUNCTION minerops_protect_component_identity()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'Durable component identity cannot be deleted';
END IF;
IF NEW.id <> OLD.id
OR NEW.component_type <> OLD.component_type
OR NEW.created_at <> OLD.created_at
OR NEW.created_by <> OLD.created_by THEN
RAISE EXCEPTION 'Component identity, type, and creation provenance are immutable';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_protect_component_identity
BEFORE UPDATE OR DELETE ON components
FOR EACH ROW EXECUTE FUNCTION minerops_protect_component_identity();
CREATE FUNCTION minerops_protect_repair_event_history()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'Repair event history is immutable';
END $$;
CREATE TRIGGER trg_protect_repair_event_history
BEFORE UPDATE OR DELETE ON repair_events
FOR EACH ROW EXECUTE FUNCTION minerops_protect_repair_event_history();
CREATE FUNCTION minerops_protect_repair_part_history()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'Repair-part history is immutable';
END IF;
IF NEW.id <> OLD.id
OR NEW.repair_id <> OLD.repair_id
OR NEW.consumable_description IS DISTINCT FROM OLD.consumable_description
OR NEW.quantity IS DISTINCT FROM OLD.quantity
OR NEW.unit IS DISTINCT FROM OLD.unit
OR NEW.component_id IS DISTINCT FROM OLD.component_id
OR NEW.component_type_snapshot IS DISTINCT FROM OLD.component_type_snapshot
OR NEW.component_asset_id_snapshot IS DISTINCT FROM OLD.component_asset_id_snapshot
OR NEW.component_manufacturer_snapshot IS DISTINCT FROM OLD.component_manufacturer_snapshot
OR NEW.component_model_snapshot IS DISTINCT FROM OLD.component_model_snapshot
OR NEW.component_manufacturer_serial_snapshot IS DISTINCT FROM OLD.component_manufacturer_serial_snapshot
OR NEW.notes IS DISTINCT FROM OLD.notes
OR NEW.recorded_by <> OLD.recorded_by
OR NEW.recorded_at <> OLD.recorded_at
OR NEW.recorded_event_id <> OLD.recorded_event_id THEN
RAISE EXCEPTION 'Original repair-part fields are immutable';
END IF;
IF OLD.voided_by IS NOT NULL OR OLD.voided_at IS NOT NULL
OR OLD.void_reason IS NOT NULL OR OLD.void_event_id IS NOT NULL THEN
RAISE EXCEPTION 'Voided repair-part history is immutable';
END IF;
IF NEW.voided_by IS NULL OR NEW.voided_at IS NULL
OR NEW.void_reason IS NULL OR NEW.void_event_id IS NULL THEN
RAISE EXCEPTION 'Repair-part void metadata must be written atomically';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_protect_repair_part_history
BEFORE UPDATE OR DELETE ON repair_parts
FOR EACH ROW EXECUTE FUNCTION minerops_protect_repair_part_history();
CREATE FUNCTION minerops_validate_repair_part_history()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE recorded repair_events%%ROWTYPE;
DECLARE voided repair_events%%ROWTYPE;
DECLARE component components%%ROWTYPE;
BEGIN
IF TG_OP = 'INSERT' AND (
NEW.voided_by IS NOT NULL OR NEW.voided_at IS NOT NULL
OR NEW.void_reason IS NOT NULL OR NEW.void_event_id IS NOT NULL) THEN
RAISE EXCEPTION 'A repair part must be recorded before it can be voided';
END IF;
SELECT * INTO recorded FROM repair_events WHERE id = NEW.recorded_event_id;
IF recorded.id IS NULL
OR recorded.repair_id <> NEW.repair_id
OR recorded.event_type <> 'REPAIR_PART_RECORDED'
OR recorded.created_by <> NEW.recorded_by
OR recorded.created_at <> NEW.recorded_at
OR recorded.payload ->> 'repair_part_id' <> NEW.id::text THEN
RAISE EXCEPTION 'Recorded repair-part event does not match its entry';
END IF;
IF TG_OP = 'INSERT' AND NEW.component_id IS NOT NULL THEN
SELECT * INTO component FROM components WHERE id = NEW.component_id;
IF component.id IS NULL
OR component.component_type <> NEW.component_type_snapshot
OR component.asset_id IS DISTINCT FROM NEW.component_asset_id_snapshot
OR component.manufacturer IS DISTINCT FROM NEW.component_manufacturer_snapshot
OR component.model IS DISTINCT FROM NEW.component_model_snapshot
OR component.manufacturer_serial IS DISTINCT FROM NEW.component_manufacturer_serial_snapshot THEN
RAISE EXCEPTION 'Tracked-component snapshot does not match the component';
END IF;
END IF;
IF NEW.void_event_id IS NOT NULL THEN
SELECT * INTO voided FROM repair_events WHERE id = NEW.void_event_id;
IF voided.id IS NULL
OR voided.repair_id <> NEW.repair_id
OR voided.event_type <> 'REPAIR_PART_VOIDED'
OR voided.created_by <> NEW.voided_by
OR voided.created_at <> NEW.voided_at
OR voided.payload ->> 'repair_part_id' <> NEW.id::text
OR voided.payload ->> 'reason' <> NEW.void_reason THEN
RAISE EXCEPTION 'Voided repair-part event does not match its entry';
END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_validate_repair_part_history
BEFORE INSERT OR UPDATE ON repair_parts
FOR EACH ROW EXECUTE FUNCTION minerops_validate_repair_part_history();
"""
)
def downgrade() -> None:
connection = op.get_bind()
connection.exec_driver_sql(
"""
DO $$
DECLARE history_count bigint;
BEGIN
SELECT count(*) INTO history_count FROM components;
history_count := history_count + (SELECT count(*) FROM repair_parts);
history_count := history_count + (
SELECT count(*) FROM repair_events
WHERE event_type IN ('REPAIR_PART_RECORDED', 'REPAIR_PART_VOIDED')
);
IF history_count > 0 THEN
RAISE EXCEPTION USING
MESSAGE = format(
'Cannot downgrade Repair Parts Tracking: %s component, repair-part, or event records would be destroyed',
history_count
),
HINT = 'Preserve or explicitly migrate Task 005 identity and history before downgrading; MinerOps will not delete or invent component, actor, timestamp, snapshot, or repair-part history.';
END IF;
END $$;
"""
)
connection.exec_driver_sql(
"""
DROP TRIGGER trg_validate_repair_part_history ON repair_parts;
DROP FUNCTION minerops_validate_repair_part_history();
DROP TRIGGER trg_protect_repair_part_history ON repair_parts;
DROP FUNCTION minerops_protect_repair_part_history();
DROP TRIGGER trg_protect_repair_event_history ON repair_events;
DROP FUNCTION minerops_protect_repair_event_history();
DROP TRIGGER trg_protect_component_identity ON components;
DROP FUNCTION minerops_protect_component_identity();
"""
)
op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check")
op.drop_constraint("ck_repair_events_type", "repair_events", type_="check")
_create_event_constraints(False)
op.drop_index("uq_repair_parts_void_event", table_name="repair_parts")
op.drop_index("uq_repair_parts_recorded_event", table_name="repair_parts")
op.drop_index("idx_repair_parts_voided_by", table_name="repair_parts")
op.drop_index("idx_repair_parts_recorded_by", table_name="repair_parts")
op.drop_index("idx_repair_parts_component", table_name="repair_parts")
op.drop_index("idx_repair_parts_repair", table_name="repair_parts")
op.drop_table("repair_parts")
op.drop_index("uq_components_normalized_asset_id", table_name="components")
op.drop_index("idx_components_type", table_name="components")
op.drop_index("idx_components_created_by", table_name="components")
op.drop_table("components")
connection.exec_driver_sql(
"""
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions
WHERE key IN ('components.read', 'components.create', 'repairs.parts.manage')
);
DELETE FROM permissions
WHERE key IN ('components.read', 'components.create', 'repairs.parts.manage');
"""
)
@@ -0,0 +1,137 @@
"""add miner control foundation
Revision ID: 0015_miner_control_foundation
Revises: 0014_repair_parts_tracking
Create Date: 2026-08-24
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0015_miner_control_foundation"
down_revision: Union[str, None] = "0014_repair_parts_tracking"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
QUEUE_STATUSES = ("pending", "claimed", "completed", "failed", "cancelled")
def _format_values(values: object) -> str:
return ", ".join(str(value) for value in (values or []))
def upgrade() -> None:
connection = op.get_bind()
unknown_statuses = connection.execute(
sa.text(
"""
SELECT array_agg(DISTINCT command_status ORDER BY command_status)
FROM minerops_agent_command_queue
WHERE command_status NOT IN ('pending', 'claimed', 'completed', 'failed', 'cancelled')
"""
),
).scalar()
if unknown_statuses:
raise RuntimeError(
"Cannot upgrade to 0015: minerops_agent_command_queue contains "
f"unsupported command_status values: {_format_values(unknown_statuses)}. "
"Correct those rows to pending, claimed, completed, failed, or cancelled "
"before retrying the migration."
)
duplicate_live_miners = connection.execute(
sa.text(
"""
SELECT request_payload ->> 'miner_id' AS miner_id
FROM minerops_agent_command_queue
WHERE command_type = 'miner_control'
AND command_status IN ('pending', 'claimed')
AND nullif(trim(request_payload ->> 'miner_id'), '') IS NOT NULL
GROUP BY request_payload ->> 'miner_id'
HAVING count(*) > 1
ORDER BY request_payload ->> 'miner_id'
"""
)
).scalars().all()
if duplicate_live_miners:
raise RuntimeError(
"Cannot upgrade to 0015: more than one pending or claimed miner_control "
"command exists for miner UUID(s): "
f"{_format_values(duplicate_live_miners)}. Resolve or cancel the duplicate "
"live commands before retrying the migration; no queue history was changed."
)
op.add_column(
"minerops_site_agents",
sa.Column("site_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_minerops_site_agents_site_id",
"minerops_site_agents",
"sites",
["site_id"],
["id"],
ondelete="RESTRICT",
)
op.create_index(
"uq_minerops_site_agents_site_id",
"minerops_site_agents",
["site_id"],
unique=True,
postgresql_where=sa.text("site_id IS NOT NULL"),
)
op.create_check_constraint(
"ck_minerops_agent_command_queue_status",
"minerops_agent_command_queue",
"command_status IN ('pending', 'claimed', 'completed', 'failed', 'cancelled')",
)
op.create_index(
"uq_agent_queue_live_miner_control",
"minerops_agent_command_queue",
[sa.text("(request_payload ->> 'miner_id')")],
unique=True,
postgresql_where=sa.text(
"command_type = 'miner_control' "
"AND command_status IN ('pending', 'claimed')"
),
)
op.create_index(
"idx_agent_queue_miner_control_history",
"minerops_agent_command_queue",
[
sa.text("(request_payload ->> 'miner_id')"),
sa.text("created_at DESC"),
sa.text("id DESC"),
],
postgresql_where=sa.text("command_type = 'miner_control'"),
)
def downgrade() -> None:
op.drop_index(
"idx_agent_queue_miner_control_history",
table_name="minerops_agent_command_queue",
)
op.drop_index(
"uq_agent_queue_live_miner_control",
table_name="minerops_agent_command_queue",
)
op.drop_constraint(
"ck_minerops_agent_command_queue_status",
"minerops_agent_command_queue",
type_="check",
)
op.drop_index(
"uq_minerops_site_agents_site_id",
table_name="minerops_site_agents",
)
op.drop_constraint(
"fk_minerops_site_agents_site_id",
"minerops_site_agents",
type_="foreignkey",
)
op.drop_column("minerops_site_agents", "site_id")
+1
View File
@@ -0,0 +1 @@
"""Authenticated Agent command transport for MinerOps."""
@@ -0,0 +1,37 @@
"""Dependencies for the authenticated Agent queue protocol."""
import hmac
from typing import Annotated
from fastapi import Header, HTTPException
from app.agent_commands.repository import AgentCommandRepository
from app.agent_commands.service import AgentCommandService
from app.config import settings
def get_agent_command_service() -> AgentCommandService:
return AgentCommandService(AgentCommandRepository())
def require_agent_identity(
agent_id: Annotated[str | None, Header(alias="X-MinerOps-Agent-ID")] = None,
provided_secret: Annotated[
str | None, Header(alias="X-MinerOps-Agent-Secret")
] = None,
) -> str:
expected_id = settings.minerops_agent_id.strip()
expected_secret = settings.minerops_agent_shared_secret.strip()
if not expected_id or not expected_secret:
raise HTTPException(status_code=503, detail="Agent authentication is not configured")
secret = (provided_secret or "").strip()
if not secret or not hmac.compare_digest(secret, expected_secret):
raise HTTPException(status_code=401, detail="Invalid or missing Agent secret")
identity = (agent_id or "").strip()
if not identity:
raise HTTPException(status_code=401, detail="Missing Agent identity")
if not hmac.compare_digest(identity, expected_id):
raise HTTPException(status_code=403, detail="Agent identity is not authorized")
return identity
+17
View File
@@ -0,0 +1,17 @@
"""Domain-neutral Agent command protocol errors."""
class AgentCommandError(Exception):
"""Base Agent command protocol error."""
class AgentCommandNotFound(AgentCommandError):
"""The submitted command identifier is unknown."""
class AgentCommandConflict(AgentCommandError):
"""The queue row cannot accept the requested transition."""
class AgentResultValidationError(AgentCommandError):
"""The Agent result does not match its command contract."""
+57
View File
@@ -0,0 +1,57 @@
"""Typed contracts for the Agent queue protocol."""
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
class AgentCommand(BaseModel):
command_id: str
command_type: str
request_payload: dict[str, Any]
created_at: datetime
class AgentCommandListResponse(BaseModel):
ok: Literal[True] = True
commands: list[AgentCommand]
class MinerControlResultPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
schema_version: Literal[1]
command_accepted: bool
verification_attempted: bool
verification_status: Literal[
"confirmed", "verification_failed", "unconfirmed", "unavailable"
]
observed_service_state: None = None
observed_performance_mode: Literal["low", "normal", "high"] | None = None
failure_code: str | None = Field(
default=None, max_length=80, pattern=r"^[a-z0-9_]+$"
)
@model_validator(mode="after")
def validate_truthfulness(self) -> "MinerControlResultPayload":
if self.verification_status == "confirmed" and not self.verification_attempted:
raise ValueError("confirmed results require verification_attempted")
if not self.command_accepted and not self.failure_code:
raise ValueError("rejected commands require a failure_code")
return self
class AgentCommandResultRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
command_id: str = Field(min_length=1, max_length=128)
status: Literal["completed", "failed"]
result_payload: dict[str, Any] = Field(default_factory=dict)
error_message: str | None = Field(default=None, max_length=255)
class AgentCommandResultResponse(BaseModel):
ok: Literal[True] = True
command_id: str
status: Literal["completed", "failed"]
+130
View File
@@ -0,0 +1,130 @@
"""PostgreSQL persistence for Agent command transport."""
from collections.abc import Callable
from typing import Any
import psycopg
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.agent_commands.exceptions import AgentCommandConflict, AgentCommandNotFound
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
CLAIM_COMMANDS_SQL = """
WITH candidates AS (
SELECT id
FROM minerops_agent_command_queue
WHERE agent_id = %s
AND command_status = 'pending'
ORDER BY created_at, id
FOR UPDATE SKIP LOCKED
LIMIT %s
)
UPDATE minerops_agent_command_queue AS queue
SET command_status = 'claimed', claimed_at = now()
FROM candidates
WHERE queue.id = candidates.id
AND queue.command_status = 'pending'
RETURNING queue.id AS command_id, queue.command_type,
queue.request_payload, queue.created_at
"""
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=5,
)
class AgentCommandRepository:
"""Own queue locking and immutable terminal transitions."""
def __init__(self, connection_factory: ConnectionFactory | None = None):
self._connection_factory = connection_factory or default_connection_factory
def claim_pending(self, agent_id: str, limit: int = 5) -> list[dict]:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, limit))
rows = [dict(row) for row in cur.fetchall()]
connection.commit()
return rows
except Exception:
connection.rollback()
raise
finally:
connection.close()
def get_command(self, command_id: str) -> dict | None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, agent_id, command_type, command_status, request_payload
FROM minerops_agent_command_queue WHERE id = %s
""",
(command_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
def finish_command(
self,
command_id: str,
agent_id: str,
status: str,
result_payload: dict[str, Any],
error_message: str | None,
) -> None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
UPDATE minerops_agent_command_queue
SET command_status = %s, result_payload = %s,
error_message = %s, completed_at = now()
WHERE id = %s AND agent_id = %s AND command_status = 'claimed'
RETURNING id
""",
(status, Jsonb(result_payload), error_message, command_id, agent_id),
)
if cur.fetchone():
connection.commit()
return
cur.execute(
"""
SELECT agent_id, command_status
FROM minerops_agent_command_queue WHERE id = %s
""",
(command_id,),
)
row = cur.fetchone()
connection.rollback()
if not row:
raise AgentCommandNotFound("Agent command not found")
if row["agent_id"] != agent_id:
raise AgentCommandConflict("Agent command belongs to another Agent")
raise AgentCommandConflict(
f"Agent command is {row['command_status']}, not claimed"
)
except (AgentCommandNotFound, AgentCommandConflict):
raise
except Exception:
connection.rollback()
raise
finally:
connection.close()
+57
View File
@@ -0,0 +1,57 @@
"""HTTP boundary for authenticated Agent command transport."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from app.agent_commands.dependencies import get_agent_command_service, require_agent_identity
from app.agent_commands.exceptions import (
AgentCommandConflict,
AgentCommandNotFound,
AgentResultValidationError,
)
from app.agent_commands.models import (
AgentCommandListResponse,
AgentCommandResultRequest,
AgentCommandResultResponse,
)
from app.agent_commands.service import AgentCommandService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/agent/commands", tags=["agent-commands"])
AgentIdentity = Annotated[str, Depends(require_agent_identity)]
AgentService = Annotated[AgentCommandService, Depends(get_agent_command_service)]
@router.get("/pending", response_model=AgentCommandListResponse)
def claim_pending_commands(
request: Request, agent_id: AgentIdentity, service: AgentService
) -> AgentCommandListResponse:
before_claim = getattr(
request.app.state, "agent_command_before_claim", None
)
if callable(before_claim):
try:
before_claim()
except Exception:
logger.exception("Scheduled command materialization failed before Agent claim")
return AgentCommandListResponse(commands=service.claim(agent_id))
@router.post("/result", response_model=AgentCommandResultResponse)
def submit_command_result(
request: AgentCommandResultRequest,
agent_id: AgentIdentity,
service: AgentService,
) -> AgentCommandResultResponse:
try:
service.submit_result(agent_id, request)
except AgentCommandNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except AgentCommandConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except AgentResultValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return AgentCommandResultResponse(command_id=request.command_id, status=request.status)
+87
View File
@@ -0,0 +1,87 @@
"""Agent command transport coordination and result validation."""
import re
from pydantic import ValidationError
from app.agent_commands.exceptions import (
AgentCommandConflict,
AgentCommandNotFound,
AgentResultValidationError,
)
from app.agent_commands.models import (
AgentCommand,
AgentCommandResultRequest,
MinerControlResultPayload,
)
from app.agent_commands.repository import AgentCommandRepository
class AgentCommandService:
def __init__(self, repository: AgentCommandRepository):
self.repository = repository
def claim(self, agent_id: str) -> list[AgentCommand]:
return [
AgentCommand.model_validate(row)
for row in self.repository.claim_pending(agent_id)
]
@staticmethod
def _legacy_error(value: str | None) -> str | None:
if not value:
return None
sanitized = re.sub(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", "[address]", value)
return sanitized[:255]
def submit_result(
self, agent_id: str, request: AgentCommandResultRequest
) -> None:
command = self.repository.get_command(request.command_id)
if not command:
raise AgentCommandNotFound("Agent command not found")
if command["agent_id"] != agent_id:
raise AgentCommandConflict("Agent command belongs to another Agent")
if command["command_status"] != "claimed":
raise AgentCommandConflict(
f"Agent command is {command['command_status']}, not claimed"
)
result_payload = request.result_payload
error_message = self._legacy_error(request.error_message)
if command["command_type"] == "miner_control":
try:
result = MinerControlResultPayload.model_validate(request.result_payload)
except ValidationError as exc:
raise AgentResultValidationError(
"Invalid typed miner-control result"
) from exc
if request.status == "completed" and not result.command_accepted:
raise AgentResultValidationError(
"Completed miner-control results must be accepted"
)
if request.status == "failed" and result.command_accepted:
raise AgentResultValidationError(
"Failed miner-control results cannot be accepted"
)
action = (command.get("request_payload") or {}).get("action")
if (
action in {"suspend", "resume"}
and result.command_accepted
and result.verification_status != "unconfirmed"
):
raise AgentResultValidationError(
"Accepted suspend and resume results must remain unconfirmed"
)
result_payload = result.model_dump(mode="json")
error_message = (
result.failure_code.replace("_", " ") if result.failure_code else None
)
self.repository.finish_command(
request.command_id,
agent_id,
request.status,
result_payload,
error_message,
)
+1
View File
@@ -0,0 +1 @@
"""Minimal durable component registry for repair-part references."""
+8
View File
@@ -0,0 +1,8 @@
"""Dependency construction for the Components domain."""
from app.components.repository import ComponentRepository
from app.components.service import ComponentService
def get_component_service() -> ComponentService:
return ComponentService(ComponentRepository())
+32
View File
@@ -0,0 +1,32 @@
"""Domain exceptions for the minimal component registry."""
class ComponentError(Exception):
"""Base exception for component operations."""
class ComponentNotFound(ComponentError):
"""Raised when a component UUID does not exist."""
def __init__(self, component_id: object):
self.component_id = component_id
super().__init__(f"Component not found: {component_id}")
class DuplicateComponentAssetID(ComponentError):
"""Raised when a normalized component Asset ID already exists."""
def __init__(self, asset_id: str):
self.asset_id = asset_id
super().__init__(f"Component Asset ID already exists: {asset_id}")
class ComponentValidationError(ComponentError):
"""Raised when component metadata violates a domain rule."""
class ComponentActorRequired(ComponentError):
"""Raised when component creation lacks an authenticated actor."""
def __init__(self):
super().__init__("An authenticated actor is required to create a component")
+36
View File
@@ -0,0 +1,36 @@
"""Typed contracts for the minimal component registry."""
from typing import Literal
from uuid import UUID
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
ComponentType = Literal["control_board", "hashboard", "power_supply", "fan"]
class ComponentCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
component_type: ComponentType
asset_id: str | None = Field(default=None, max_length=255)
manufacturer: str | None = Field(default=None, max_length=255)
model: str | None = Field(default=None, max_length=255)
manufacturer_serial: str | None = Field(default=None, max_length=255)
class ComponentDetail(BaseModel):
component_id: UUID
component_type: ComponentType
asset_id: str | None = None
manufacturer: str | None = None
model: str | None = None
manufacturer_serial: str | None = None
created_at: AwareDatetime
created_by: UUID
class ComponentResponse(BaseModel):
data: ComponentDetail
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
+143
View File
@@ -0,0 +1,143 @@
"""PostgreSQL persistence for the minimal component registry."""
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from uuid import UUID
import psycopg
from psycopg.errors import UniqueViolation
from psycopg.rows import dict_row
from app.components.exceptions import DuplicateComponentAssetID
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
COMPONENT_ASSET_ID_CONSTRAINT = "uq_components_normalized_asset_id"
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=5,
)
class ComponentRepository:
"""Persistence boundary for minimal component identity."""
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 transaction(self) -> Iterator["ComponentRepository"]:
if self._connection is not None:
yield self
return
connection = self._connection_factory()
repository = ComponentRepository(
self._connection_factory,
connection=connection,
)
try:
yield repository
connection.commit()
except UniqueViolation as exc:
connection.rollback()
if exc.diag.constraint_name == COMPONENT_ASSET_ID_CONSTRAINT:
raise DuplicateComponentAssetID("normalized Asset ID") from exc
raise
except Exception:
connection.rollback()
raise
finally:
connection.close()
@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 create_component(
self,
*,
component_type: str,
asset_id: str | None,
manufacturer: str | None,
model: str | None,
manufacturer_serial: str | None,
created_by: UUID,
) -> dict:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO components (
component_type,
asset_id,
manufacturer,
model,
manufacturer_serial,
created_by
)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING
id AS component_id,
component_type,
asset_id,
manufacturer,
model,
manufacturer_serial,
created_at,
created_by
""",
(
component_type,
asset_id,
manufacturer,
model,
manufacturer_serial,
created_by,
),
)
return dict(cur.fetchone())
def get_component(self, component_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
id AS component_id,
component_type,
asset_id,
manufacturer,
model,
manufacturer_serial,
created_at,
created_by
FROM components
WHERE id = %s
""",
(component_id,),
)
row = cur.fetchone()
return dict(row) if row else None
+80
View File
@@ -0,0 +1,80 @@
"""HTTP mapping for the minimal component registry."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from app.components.dependencies import get_component_service
from app.components.exceptions import (
ComponentActorRequired,
ComponentNotFound,
ComponentValidationError,
DuplicateComponentAssetID,
)
from app.components.models import (
ComponentCreateRequest,
ComponentResponse,
)
from app.components.service import ComponentService
from app.identity import require_permission
router = APIRouter(prefix="/api/components", tags=["components"])
require_component_read = require_permission("components.read")
require_component_create = require_permission("components.create")
ComponentServiceDependency = Annotated[
ComponentService,
Depends(get_component_service),
]
ComponentReadUser = Annotated[dict, Depends(require_component_read)]
ComponentCreateUser = Annotated[dict, Depends(require_component_create)]
@router.post(
"",
response_model=ComponentResponse,
status_code=status.HTTP_201_CREATED,
)
def create_component(
request: ComponentCreateRequest,
service: ComponentServiceDependency,
current_user: ComponentCreateUser,
) -> ComponentResponse:
try:
component = service.create_component(
request,
actor_user_id=UUID(str(current_user["id"])),
)
except DuplicateComponentAssetID as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except (ComponentActorRequired, ComponentValidationError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return ComponentResponse(data=component)
@router.get(
"/{component_id}",
response_model=ComponentResponse,
)
def get_component(
component_id: UUID,
service: ComponentServiceDependency,
current_user: ComponentReadUser,
) -> ComponentResponse:
del current_user
try:
component = service.get_component(component_id)
except ComponentNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Component not found",
) from exc
return ComponentResponse(data=component)
+75
View File
@@ -0,0 +1,75 @@
"""Business rules for the minimal component registry."""
from uuid import UUID
from app.components.exceptions import (
ComponentActorRequired,
ComponentNotFound,
ComponentValidationError,
DuplicateComponentAssetID,
)
from app.components.models import ComponentCreateRequest, ComponentDetail
from app.components.repository import ComponentRepository
class ComponentService:
"""Minimal component workflows independent of FastAPI."""
def __init__(self, repository: ComponentRepository):
self.repository = repository
@staticmethod
def _normalize_optional_text(value: str | None, field_name: str) -> str | None:
if value is None:
return None
normalized = str(value).strip()
if not normalized:
raise ComponentValidationError(
f"{field_name} must not be blank when supplied"
)
if len(normalized) > 255:
raise ComponentValidationError(
f"{field_name} must not exceed 255 characters"
)
return normalized
@staticmethod
def _map_component(component: dict) -> ComponentDetail:
return ComponentDetail(**component)
def create_component(
self,
request: ComponentCreateRequest,
*,
actor_user_id: UUID | None,
) -> ComponentDetail:
if actor_user_id is None:
raise ComponentActorRequired()
asset_id = self._normalize_optional_text(request.asset_id, "asset_id")
values = {
"manufacturer": self._normalize_optional_text(
request.manufacturer, "manufacturer"
),
"model": self._normalize_optional_text(request.model, "model"),
"manufacturer_serial": self._normalize_optional_text(
request.manufacturer_serial, "manufacturer_serial"
),
}
try:
with self.repository.transaction() as repository:
component = repository.create_component(
component_type=request.component_type,
asset_id=asset_id,
created_by=actor_user_id,
**values,
)
except DuplicateComponentAssetID as exc:
raise DuplicateComponentAssetID(asset_id or "") from exc
return self._map_component(component)
def get_component(self, component_id: UUID) -> ComponentDetail:
component = self.repository.get_component(component_id)
if component is None:
raise ComponentNotFound(component_id)
return self._map_component(component)
+5
View File
@@ -5,6 +5,8 @@ class Settings(BaseSettings):
app_name: str = "MinerOps Console"
app_env: str = "development"
app_timezone: str = "America/Winnipeg"
minerops_build_commit: str | None = None
minerops_build_version: str | None = None
postgres_db: str = "minerops"
postgres_user: str = "minerops"
@@ -14,6 +16,9 @@ class Settings(BaseSettings):
redis_url: str = "redis://redis:6379/0"
minerops_agent_id: str = "plexus-windows-agent"
minerops_agent_shared_secret: str = ""
jwt_secret_key: str
jwt_access_token_minutes: int = 30
jwt_refresh_token_days: int = 14
+1
View File
@@ -0,0 +1 @@
"""Permission-aware operational Dashboard read model."""
+14
View File
@@ -0,0 +1,14 @@
"""Dependency construction for the Dashboard composition service."""
from app.dashboard.service import DashboardService
from app.inventory.dependencies import get_inventory_service
from app.locations.dependencies import get_location_map_service
from app.repairs.dependencies import get_repair_center_service
def get_dashboard_service() -> DashboardService:
return DashboardService(
inventory_service=get_inventory_service(),
repair_center_service=get_repair_center_service(),
location_service=get_location_map_service(),
)
+32
View File
@@ -0,0 +1,32 @@
"""Typed contracts for the operational Dashboard overview."""
from datetime import datetime
from pydantic import BaseModel, Field
from app.inventory.models import InventoryFleetSummary
from app.locations.models import LocationSummary
from app.repairs.models import ActiveRepairSummary
class DashboardAccess(BaseModel):
can_view_miners: bool = False
can_view_repairs: bool = False
can_view_locations: bool = False
class DashboardOverviewData(BaseModel):
fleet: InventoryFleetSummary | None = None
repairs: ActiveRepairSummary | None = None
locations: LocationSummary | None = None
generated_at: datetime
class DashboardOverviewMeta(BaseModel):
available_sections: list[str] = Field(default_factory=list)
class DashboardOverviewResponse(BaseModel):
data: DashboardOverviewData
meta: DashboardOverviewMeta = Field(default_factory=DashboardOverviewMeta)
error: None = None
+40
View File
@@ -0,0 +1,40 @@
"""HTTP boundary for the operational Dashboard overview."""
from typing import Annotated
from fastapi import APIRouter, Depends, Response
from app.dashboard.dependencies import get_dashboard_service
from app.dashboard.models import (
DashboardAccess,
DashboardOverviewResponse,
)
from app.dashboard.service import DashboardService
from app.identity import require_permission, user_has_permission
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))]
DashboardServiceDependency = Annotated[
DashboardService,
Depends(get_dashboard_service),
]
@router.get("/overview", response_model=DashboardOverviewResponse)
def get_dashboard_overview(
current_user: DashboardUser,
service: DashboardServiceDependency,
response: Response,
) -> DashboardOverviewResponse:
response.headers["Cache-Control"] = "no-store"
return service.get_overview(
DashboardAccess(
can_view_miners=user_has_permission(current_user, "miners.view"),
can_view_repairs=user_has_permission(current_user, "repairs.view"),
can_view_locations=user_has_permission(
current_user,
"locations.view",
),
)
)
+64
View File
@@ -0,0 +1,64 @@
"""Composition service for permission-aware Dashboard summaries."""
from datetime import datetime, timezone
from app.dashboard.models import (
DashboardAccess,
DashboardOverviewData,
DashboardOverviewMeta,
DashboardOverviewResponse,
)
from app.inventory.service import InventoryService
from app.locations.service import LocationMapService
from app.repairs.center_service import RepairCenterService
class DashboardService:
"""Coordinate domain-owned summaries without introducing new rules."""
def __init__(
self,
inventory_service: InventoryService,
repair_center_service: RepairCenterService,
location_service: LocationMapService,
):
self.inventory_service = inventory_service
self.repair_center_service = repair_center_service
self.location_service = location_service
def get_overview(self, access: DashboardAccess) -> DashboardOverviewResponse:
fleet = (
self.inventory_service.summarize_fleet()
if access.can_view_miners
else None
)
repairs = (
self.repair_center_service.summarize_active_repairs()
if access.can_view_repairs
else None
)
locations = (
self.location_service.summarize_locations(
include_miner_counts=access.can_view_miners
)
if access.can_view_locations
else None
)
available_sections = [
name
for name, value in (
("fleet", fleet),
("repairs", repairs),
("locations", locations),
)
if value is not None
]
return DashboardOverviewResponse(
data=DashboardOverviewData(
fleet=fleet,
repairs=repairs,
locations=locations,
generated_at=datetime.now(timezone.utc),
),
meta=DashboardOverviewMeta(available_sections=available_sections),
)
+1
View File
@@ -0,0 +1 @@
"""Server-rendered frontend foundation for MinerOps."""
+109
View File
@@ -0,0 +1,109 @@
"""Semantic HTML for the Internal Alpha Miner Detail page."""
def miner_detail_content() -> str:
return """
<div class="state-message" id="minerDetailLoading" role="status">
<strong>Loading miner</strong>
<span>Requesting current identity and operational context.</span>
</div>
<div class="state-message" id="minerDetailError" data-kind="error" role="alert" hidden>
<strong>Miner detail unavailable</strong>
<span id="minerDetailErrorText">The miner could not be loaded.</span>
<button class="command-button" id="minerDetailRetry" type="button">Retry</button>
</div>
<div id="minerDetailContent" hidden>
<section class="miner-detail-hero" aria-labelledby="minerIdentityTitle">
<div>
<p class="eyebrow">Durable miner identity</p>
<h2 id="minerIdentityTitle">Miner</h2>
<p id="minerDetailModel" class="panel-copy">Model unavailable</p>
<p id="minerDetailUuid" class="miner-detail-uuid"></p>
</div>
<div class="miner-detail-state">
<strong id="minerDetailOperational">Operational state unknown</strong>
<span id="minerDetailLifecycle">Lifecycle unavailable</span>
<span id="minerDetailLocationSummary">Location unavailable</span>
</div>
<nav class="miner-detail-actions" aria-label="Miner destinations">
<a class="quiet-link" id="minerDetailReturn" href="/assets/miners" hidden>Return</a>
<a class="quiet-link" href="/assets/miners">Back to Fleet</a>
<a class="quiet-link" id="minerDetailMap" href="/monitoring/map" hidden>View on Mine Map</a>
<a class="quiet-link" id="minerDetailTrends" href="/monitoring/analytics">View trends</a>
<a class="quiet-link" id="minerDetailRepairs" href="/operations/repairs" hidden>Open filtered Repair Center</a>
<button class="quiet-button" id="minerDetailRefresh" type="button">Refresh</button>
</nav>
</section>
<div class="miner-detail-grid">
<section class="panel" aria-labelledby="minerIdentitySectionTitle">
<div class="panel-head"><div><p class="eyebrow">Asset record</p>
<h2 id="minerIdentitySectionTitle">Identity</h2></div></div>
<dl class="definition-list" id="minerIdentityDetails"></dl>
<div id="minerTags" class="tag-list" aria-label="Miner tags"></div>
</section>
<section class="panel" aria-labelledby="minerCurrentStateTitle">
<div class="panel-head"><div><p class="eyebrow">Latest facts</p>
<h2 id="minerCurrentStateTitle">Current state</h2></div>
<span class="freshness" id="minerTelemetryAge">No observation</span></div>
<dl class="metric-grid" id="minerTelemetryDetails"></dl>
</section>
</div>
<section class="panel miner-controls" id="minerControlsSection"
aria-labelledby="minerControlsTitle">
<div class="panel-head"><div><p class="eyebrow">Agent-routed actions</p>
<h2 id="minerControlsTitle">Controls</h2></div>
<button class="quiet-button" id="minerControlsRefresh" type="button">Refresh controls</button>
</div>
<p class="panel-copy" id="minerControlsSummary" aria-live="polite">
Loading factual capability and recent operations.
</p>
<div id="minerControlCapabilities" class="control-capability-grid"></div>
<div id="minerControlActions" class="miner-control-actions" hidden>
<button class="command-button" id="minerServiceControl" type="button" hidden></button>
<form id="minerPerformanceControl" class="performance-control" hidden>
<label for="minerPerformanceMode">Performance mode</label>
<select id="minerPerformanceMode">
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
</select>
<button class="command-button" type="submit">Set mode</button>
</form>
</div>
<p id="minerControlPermission" class="permission-note" hidden>
Your account can inspect control capability and history but cannot queue controls.
</p>
<div id="minerControlResult" class="status-callout" role="status" hidden></div>
<div class="table-wrap compact-history-table">
<table><thead><tr><th>Action</th><th>Requested</th><th>Status</th>
<th>Verification</th><th>Created</th></tr></thead>
<tbody id="minerControlHistory"></tbody></table>
</div>
</section>
<section class="panel" id="minerLocationSection" aria-labelledby="minerLocationTitle">
<div class="panel-head"><div><p class="eyebrow">Physical context</p>
<h2 id="minerLocationTitle">Location</h2></div></div>
<div id="minerLocationBody"></div>
</section>
<section class="panel" id="minerRepairSection" aria-labelledby="minerRepairTitle">
<div class="panel-head"><div><p class="eyebrow">Work context</p>
<h2 id="minerRepairTitle">Repair context</h2></div></div>
<div id="minerRepairBody"></div>
<div id="minerRepairEpisodes" class="disclosure-list"></div>
</section>
<section class="panel" aria-labelledby="minerHistoryTitle">
<div class="panel-head"><div><p class="eyebrow">Recorded facts</p>
<h2 id="minerHistoryTitle">History</h2></div></div>
<div class="disclosure-list">
<details id="minerIdentityHistory">
<summary>Identity history</summary>
<div class="lazy-content" id="minerIdentityHistoryBody" aria-live="polite">Open to load Asset ID and network identity history.</div>
</details>
<details id="minerObservations">
<summary>Recent observations</summary>
<div class="lazy-content" id="minerObservationsBody" aria-live="polite">Open to load up to 100 recent factual observations.</div>
</details>
</div>
</section>
</div>
"""
@@ -0,0 +1,52 @@
"""Semantic HTML for the Internal Alpha Monitoring Trends page."""
def monitoring_trends_content(can_view_locations: bool) -> str:
location_options = (
'<option value="site">Site</option><option value="room">Room</option>'
if can_view_locations
else ""
)
return f"""
<section class="trend-controls panel" aria-labelledby="trendControlsTitle">
<div class="panel-head"><div><p class="eyebrow">Historical scope</p>
<h2 id="trendControlsTitle">Trend controls</h2></div>
<p class="freshness" id="trendFreshness">Not loaded</p></div>
<form id="trendControls">
<label for="trendScope">Scope
<select id="trendScope">
<option value="fleet">Fleet</option>{location_options}
</select>
</label>
<label for="trendSite" id="trendSiteLabel" hidden>Site
<select id="trendSite"></select>
</label>
<label for="trendRoom" id="trendRoomLabel" hidden>Room
<select id="trendRoom"></select>
</label>
<fieldset class="trend-ranges">
<legend>Time range</legend>
<label><input type="radio" name="trendRange" value="6h">6 hours</label>
<label><input type="radio" name="trendRange" value="24h" checked>24 hours</label>
<label><input type="radio" name="trendRange" value="7d">7 days</label>
</fieldset>
<button class="command-button" type="submit">Apply</button>
</form>
<p class="panel-copy" id="trendScopeNote">Fleet trends use current miner inventory.</p>
</section>
<div class="state-message" id="trendLoading" role="status">
<strong>Loading trends</strong><span>Aggregating bounded raw telemetry.</span>
</div>
<div class="state-message" id="trendError" data-kind="error" role="alert" hidden>
<strong>Trends unavailable</strong><span id="trendErrorText">The request failed.</span>
<button class="command-button" id="trendRetry" type="button">Retry</button>
</div>
<section id="trendContent" aria-label="Historical telemetry trends" hidden>
<div class="trend-summary" aria-live="polite">
<p><strong id="trendScopeLabel">Fleet</strong></p>
<p id="trendRangeSummary"></p>
<p id="trendTimezone"></p>
</div>
<div id="trendCharts" class="trend-charts"></div>
</section>
"""
+141
View File
@@ -0,0 +1,141 @@
"""HTTP routes for the MinerOps Internal Alpha application shell."""
from pathlib import Path
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi.responses import HTMLResponse, Response
from app.frontend.views import render_app_page
from app.identity import require_permission, safe_next_path, 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, **context) -> str:
return render_app_page(
view,
current_user,
lambda permission: user_has_permission(current_user, permission),
**context,
)
@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("/assets/minerops-dashboard.js", include_in_schema=False)
def dashboard_script() -> Response:
return Response(
(STATIC_ROOT / "dashboard.js").read_text(encoding="utf-8"),
media_type="text/javascript",
headers={"Cache-Control": "no-store"},
)
@router.get("/assets/minerops-mine-map.js", include_in_schema=False)
def mine_map_script() -> Response:
return Response(
(STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8"),
media_type="text/javascript",
headers={"Cache-Control": "no-store"},
)
@router.get("/assets/minerops-fleet.js", include_in_schema=False)
def fleet_script() -> Response:
return Response(
(STATIC_ROOT / "fleet.js").read_text(encoding="utf-8"),
media_type="text/javascript",
headers={"Cache-Control": "no-store"},
)
@router.get("/assets/minerops-repair-center.js", include_in_schema=False)
def repair_center_script() -> Response:
return Response(
(STATIC_ROOT / "repair-center.js").read_text(encoding="utf-8"),
media_type="text/javascript",
headers={"Cache-Control": "no-store"},
)
@router.get("/assets/minerops-miner-detail.js", include_in_schema=False)
def miner_detail_script() -> Response:
return Response(
(STATIC_ROOT / "miner-detail.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)
@router.get(
"/miners/{miner_id}/detail",
response_class=HTMLResponse,
include_in_schema=False,
)
def miner_detail_page(
miner_id: UUID,
current_user: MinerUser,
return_to: Annotated[str | None, Query(max_length=2048)] = None,
) -> str:
safe_return = safe_next_path(return_to) if return_to else None
if safe_return != return_to:
safe_return = None
return render_page("miner_detail", current_user, miner_id=str(miner_id),
return_to=safe_return)
File diff suppressed because it is too large. Load diff
+47
View File
@@ -0,0 +1,47 @@
(function () {
"use strict";
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");
}
});
}
}
setupShell();
})();
+155
View File
@@ -0,0 +1,155 @@
(function () {
"use strict";
if (document.body.dataset.view !== "dashboard") return;
const ids = [
"dashboardLoading", "dashboardError", "dashboardErrorDetail",
"dashboardRetry", "dashboardEmpty", "dashboardContent",
"dashboardLoadStatus", "dashboardFleetTotal", "dashboardRuntimeOnline",
"dashboardRuntimeOffline", "dashboardRuntimeUnknown",
"dashboardLifecycleRepair", "dashboardLifecycleSpare",
"dashboardLifecycleRetired", "dashboardAttentionOffline",
"dashboardAttentionUnknown", "dashboardAttentionUnstable",
"dashboardAttentionRepair", "dashboardActiveRepairs",
"dashboardRepairStatuses", "dashboardSites", "dashboardRooms",
"dashboardPositions", "dashboardOccupied", "dashboardEmptyPositions",
"dashboardMappedMiners", "dashboardUnmappedMiners"
];
const el = {};
for (const id of ids) el[id] = document.getElementById(id);
let controller = null;
const STATUS_LABELS = {
OPEN: "Open",
IN_PROGRESS: "In progress",
WAITING_FOR_PARTS: "Waiting for parts",
READY_FOR_TEST: "Ready for test",
open: "Open",
diagnosing: "Diagnosing",
waiting_parts: "Waiting parts",
in_progress: "In progress",
testing: "Testing",
ready_to_shelf: "Ready to shelf",
needs_followup: "Needs follow-up",
repair: "In repair",
unknown: "Unknown"
};
function setCount(node, value) {
if (node) node.textContent = Number.isInteger(value) ? String(value) : "Unavailable";
}
function show(name) {
el.dashboardLoading.hidden = name !== "loading";
el.dashboardError.hidden = name !== "error";
el.dashboardEmpty.hidden = name !== "empty";
el.dashboardContent.hidden = name !== "ready";
}
function statusLabel(value) {
if (Object.prototype.hasOwnProperty.call(STATUS_LABELS, value)) {
return STATUS_LABELS[value];
}
return String(value || "Unknown").replaceAll("_", " ");
}
function renderFleet(fleet) {
if (!fleet || !el.dashboardFleetTotal) return;
setCount(el.dashboardFleetTotal, fleet.total);
setCount(el.dashboardRuntimeOnline, fleet.runtime_online);
setCount(el.dashboardRuntimeOffline, fleet.runtime_offline);
setCount(el.dashboardRuntimeUnknown, fleet.runtime_unknown);
setCount(el.dashboardLifecycleRepair, fleet.lifecycle_in_repair);
setCount(el.dashboardLifecycleSpare, fleet.lifecycle_spare);
setCount(el.dashboardLifecycleRetired, fleet.lifecycle_retired);
const attention = fleet.attention || {};
setCount(el.dashboardAttentionOffline, attention.offline);
setCount(el.dashboardAttentionUnknown, attention.unknown);
setCount(el.dashboardAttentionUnstable, attention.unstable);
setCount(el.dashboardAttentionRepair, attention.repair);
}
function renderRepairs(repairs) {
if (!repairs || !el.dashboardActiveRepairs) return;
setCount(el.dashboardActiveRepairs, repairs.active);
const entries = Object.entries(repairs.status_counts || {}).sort(function (left, right) {
return statusLabel(left[0]).localeCompare(statusLabel(right[0]));
});
el.dashboardRepairStatuses.replaceChildren();
if (!entries.length) {
const row = document.createElement("div");
const term = document.createElement("dt");
const value = document.createElement("dd");
term.textContent = "No active repair statuses";
value.textContent = "0";
row.append(term, value);
el.dashboardRepairStatuses.appendChild(row);
return;
}
for (const [status, count] of entries) {
const row = document.createElement("div");
const term = document.createElement("dt");
const link = document.createElement("a");
const value = document.createElement("dd");
const params = new URLSearchParams({ scope: "active", status: status });
link.href = "/operations/repairs?" + params.toString();
link.textContent = statusLabel(status);
term.appendChild(link);
value.textContent = Number.isInteger(count) ? String(count) : "Unavailable";
row.append(term, value);
el.dashboardRepairStatuses.appendChild(row);
}
}
function renderLocations(locations) {
if (!locations || !el.dashboardSites) return;
setCount(el.dashboardSites, locations.sites);
setCount(el.dashboardRooms, locations.rooms);
setCount(el.dashboardPositions, locations.positions);
setCount(el.dashboardOccupied, locations.occupied_positions);
setCount(el.dashboardEmptyPositions, locations.empty_positions);
setCount(el.dashboardMappedMiners, locations.mapped_miners);
setCount(el.dashboardUnmappedMiners, locations.unmapped_miners);
}
async function loadOverview() {
if (controller) controller.abort();
controller = new AbortController();
show("loading");
try {
const response = await fetch("/api/dashboard/overview", {
credentials: "same-origin",
headers: { Accept: "application/json" },
signal: controller.signal
});
if (response.status === 401) {
window.location.replace("/login?next=" + encodeURIComponent("/dashboard"));
return;
}
if (!response.ok) throw new Error("The overview service returned " + response.status + ".");
const payload = await response.json();
const data = payload && payload.data ? payload.data : {};
const available = payload && payload.meta ? payload.meta.available_sections || [] : [];
if (!available.length) {
show("empty");
return;
}
renderFleet(data.fleet);
renderRepairs(data.repairs);
renderLocations(data.locations);
const generated = new Date(data.generated_at);
el.dashboardLoadStatus.textContent = Number.isNaN(generated.valueOf())
? "Operational summary loaded"
: "Operational summary generated " + generated.toLocaleString();
show("ready");
} catch (error) {
if (error.name === "AbortError") return;
el.dashboardErrorDetail.textContent = error.message || "The operational overview could not be loaded.";
show("error");
}
}
el.dashboardRetry.addEventListener("click", loadOverview);
loadOverview();
}());
+359
View File
@@ -0,0 +1,359 @@
(function () {
"use strict";
if (document.body.dataset.view !== "miners") return;
const canViewLocations = document.body.dataset.canViewLocations === "true";
const ids = [
"fleetFilters", "fleetQuery", "fleetLifecycle", "fleetModel",
"fleetRuntime", "fleetRepair", "fleetOperational", "fleetTags",
"fleetProblems", "fleetReset", "fleetRows", "fleetResultSummary",
"fleetFreshness", "fleetPrevious", "fleetNext", "fleetLimit"
];
const el = {};
for (const id of ids) el[id] = document.getElementById(id);
if (canViewLocations) {
el.fleetSite = document.getElementById("fleetSite");
el.fleetRoom = document.getElementById("fleetRoom");
}
const limits = new Set([25, 50, 100, 250]);
const state = { offset: 0, limit: 100, returned: 0, locationOptions: null,
siteId: "", roomId: "" };
function display(value, fallback) {
if (value === null || value === undefined || String(value).trim() === "") {
return fallback || "Unavailable";
}
return String(value);
}
function words(value) {
return display(value, "unknown").replaceAll("_", " ").toLowerCase();
}
function create(tag, className, value) {
const node = document.createElement(tag);
if (className) node.className = className;
if (value !== undefined) node.textContent = value;
return node;
}
function showState(name, message) {
for (const node of document.querySelectorAll("[data-view-state]")) {
node.hidden = node.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;
}
function finite(value) {
return typeof value === "number" && Number.isFinite(value);
}
function metric(value, suffix) {
return finite(value) ? String(value) + suffix : "Unavailable";
}
function age(value) {
if (!value) return "Never observed";
const time = new Date(value).valueOf();
if (Number.isNaN(time)) return "Observation time unavailable";
const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000));
if (seconds < 60) return seconds + "s ago";
if (seconds < 3600) return Math.floor(seconds / 60) + "m ago";
if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago";
return Math.floor(seconds / 86400) + "d ago";
}
function operational(miner) {
if (miner.active_repair || miner.lifecycle_status === "in_repair") {
return { key: "repair", label: "In repair", problem: true };
}
if (miner.lifecycle_status === "retired") {
return { key: "retired", label: "Retired", problem: false };
}
if (miner.lifecycle_status === "spare") {
return { key: "spare", label: "Spare", problem: false };
}
const runtime = words(miner.runtime_status);
if (runtime === "offline") {
return { key: "offline", label: "Offline", problem: true };
}
if (miner.hash_stable === false) {
return { key: "unstable", label: "Hash unstable", problem: true };
}
if (runtime === "online") {
return { key: "online", label: "Online", problem: false };
}
return { key: "unknown", label: "Operational state unknown", problem: true };
}
function prefixedLabel(value, prefix) {
const label = display(value, "");
if (!label) return "";
return new RegExp("^" + prefix + "\\b", "i").test(label)
? label : prefix + " " + label;
}
function locationText(miner) {
if (!canViewLocations || !miner.location_position_id) return "Not mapped";
const parts = [];
if (miner.site_name) parts.push(miner.site_name);
if (miner.room_name) parts.push(miner.room_name);
if (miner.rack_label) parts.push(prefixedLabel(miner.rack_label, "Rack"));
if (miner.shelf_label) parts.push(prefixedLabel(miner.shelf_label, "Shelf"));
parts.push(display(
miner.position_label,
miner.position_index === null ? "Position unavailable" : "Position " + miner.position_index
));
return parts.join(" / ");
}
function detailLink(miner) {
const returnTo = window.location.pathname + window.location.search;
const path = "/miners/" + encodeURIComponent(miner.id) + "/detail";
return path + "?return_to=" + encodeURIComponent(returnTo);
}
function mapLink(miner) {
if (!canViewLocations || !miner.location_position_id) return null;
const params = new URLSearchParams({ miner_id: miner.id });
if (miner.site_id && miner.room_id) {
params.set("site_id", miner.site_id);
params.set("room_id", miner.room_id);
}
return "/monitoring/map?" + params.toString();
}
function addCell(row, label, content, className) {
const cell = create("td", className || "");
cell.dataset.label = label;
if (content instanceof Node) cell.appendChild(content);
else cell.textContent = display(content);
row.appendChild(cell);
}
function stack(primary, secondary) {
const box = create("div", "fleet-stack");
box.append(create("span", "fleet-primary", display(primary)));
if (secondary) box.append(create("span", "secondary", secondary));
return box;
}
function renderRow(miner) {
const condition = operational(miner);
const row = create("tr", "fleet-row");
row.dataset.status = condition.key;
const identity = create("div", "fleet-stack");
const link = create("a", "object-link", display(miner.asset_id, "No Asset ID"));
link.href = detailLink(miner);
identity.append(link, create("span", "secondary fleet-uuid", miner.id));
addCell(row, "Miner", identity, "fleet-identity");
const status = create("div", "fleet-stack");
const marker = create("span", "fleet-status", condition.label);
marker.dataset.status = condition.key;
status.append(marker, create("span", "secondary", age(miner.observed_at)));
addCell(row, "Operational", status);
addCell(row, "Model / Serial", stack(miner.model, display(miner.miner_sn)));
addCell(row, "Network", stack(miner.current_mac, display(miner.current_ip)));
addCell(row, "Location", locationText(miner), "fleet-location");
addCell(row, "Hashrate", metric(miner.hashrate_ths, " TH/s"));
addCell(row, "Power", metric(miner.power_w, " W"));
addCell(row, "Max chip temp", metric(miner.chip_temp_max_c, " C"));
const repair = miner.active_repair
? words(miner.active_repair.status) + " / " + display(miner.active_repair.reason)
: "No active repair";
addCell(row, "Lifecycle / Repair", stack(words(miner.lifecycle_status), repair));
const actions = create("div", "fleet-actions");
const open = create("a", "command-link", "Open miner");
open.href = detailLink(miner);
actions.appendChild(open);
const mapHref = mapLink(miner);
if (mapHref) {
const map = create("a", "quiet-link", "View on Mine Map");
map.href = mapHref;
actions.appendChild(map);
}
addCell(row, "Actions", actions);
return row;
}
function option(value, label) {
const node = document.createElement("option");
node.value = value;
node.textContent = label;
return node;
}
function syncLocationOptions(selectedSite, selectedRoom) {
if (!canViewLocations || !state.locationOptions) return;
el.fleetSite.replaceChildren(option("", "All sites"));
for (const site of state.locationOptions.sites || []) {
el.fleetSite.appendChild(option(String(site.id), site.name));
}
el.fleetSite.value = Array.from(el.fleetSite.options).some(function (item) {
return item.value === selectedSite;
}) ? selectedSite : "";
el.fleetRoom.replaceChildren(option("", "All rooms"));
for (const room of state.locationOptions.rooms || []) {
if (!el.fleetSite.value || String(room.site_id) === el.fleetSite.value) {
el.fleetRoom.appendChild(option(String(room.id), room.name));
}
}
el.fleetRoom.value = Array.from(el.fleetRoom.options).some(function (item) {
return item.value === selectedRoom;
}) ? selectedRoom : "";
state.siteId = el.fleetSite.value;
state.roomId = el.fleetRoom.value;
}
function readUrl() {
const params = new URLSearchParams(window.location.search);
el.fleetQuery.value = (params.get("query") || "").slice(0, 255);
el.fleetLifecycle.value = params.get("lifecycle_status") || "";
el.fleetModel.value = (params.get("model") || "").slice(0, 255);
el.fleetRuntime.value = params.get("runtime_status") || "";
el.fleetRepair.value = params.get("repair") || "";
el.fleetOperational.value = params.get("operational_state") || "";
el.fleetTags.value = params.getAll("tags").join(", ");
el.fleetProblems.checked = params.get("problems") === "1";
const requestedLimit = Number(params.get("limit"));
state.limit = limits.has(requestedLimit) ? requestedLimit : 100;
state.offset = Math.max(0, Number.parseInt(params.get("offset") || "0", 10) || 0);
el.fleetLimit.value = String(state.limit);
if (canViewLocations) {
state.siteId = params.get("site_id") || "";
state.roomId = params.get("room_id") || "";
syncLocationOptions(state.siteId, state.roomId);
}
}
function requestParams() {
const params = new URLSearchParams();
const values = {
query: el.fleetQuery.value.trim(),
lifecycle_status: el.fleetLifecycle.value,
model: el.fleetModel.value.trim(),
runtime_status: el.fleetRuntime.value,
repair: el.fleetRepair.value,
operational_state: el.fleetOperational.value,
problems: el.fleetProblems.checked ? "1" : "",
site_id: canViewLocations ? state.siteId : "",
room_id: canViewLocations ? state.roomId : "",
limit: String(state.limit), offset: String(state.offset)
};
for (const pair of Object.entries(values)) {
if (pair[1]) params.set(pair[0], pair[1]);
}
for (const tag of el.fleetTags.value.split(",")) {
const clean = tag.trim();
if (clean) params.append("tags", clean);
}
return params;
}
function setUrl(mode) {
const params = requestParams();
window.history[mode === "push" ? "pushState" : "replaceState"](
{}, "", "/assets/miners?" + params.toString()
);
}
async function load() {
showState("loading");
const controller = new AbortController();
const timer = window.setTimeout(function () { controller.abort(); }, 15000);
try {
const response = await fetch("/api/inventory/miners?" + requestParams().toString(), {
credentials: "same-origin", headers: { Accept: "application/json" },
signal: controller.signal
});
if (response.status === 401) {
window.location.replace("/login?next=" + encodeURIComponent(
window.location.pathname + window.location.search
));
return;
}
if (response.status === 403) {
const denied = new Error("Your account cannot view this fleet information.");
denied.permissionDenied = true;
throw denied;
}
if (!response.ok) throw new Error("The fleet service returned " + response.status + ".");
const envelope = await response.json();
if (!envelope || !Array.isArray(envelope.data) || !envelope.meta) {
throw new Error("MinerOps returned an incomplete fleet response.");
}
state.returned = envelope.data.length;
state.locationOptions = envelope.meta.location_options || null;
const selectedSite = canViewLocations ? state.siteId : "";
const selectedRoom = canViewLocations ? state.roomId : "";
syncLocationOptions(selectedSite, selectedRoom);
el.fleetRows.replaceChildren();
for (const miner of envelope.data) el.fleetRows.appendChild(renderRow(miner));
if (!envelope.data.length) {
el.fleetPrevious.disabled = state.offset === 0;
el.fleetNext.disabled = true;
showState("empty", state.offset
? "No miners are present on this page. Return to the previous page."
: "No authorized miners match the current filters.");
return;
}
el.fleetPrevious.disabled = state.offset === 0;
el.fleetNext.disabled = state.returned < state.limit;
const first = state.offset + 1;
const last = state.offset + state.returned;
el.fleetResultSummary.textContent = "Showing miners " + first + "-" + last +
". Total fleet size is not calculated.";
el.fleetFreshness.textContent = state.returned + " miners on this page";
showState("ready");
} catch (error) {
showState(error.permissionDenied ? "denied" : "error",
error && error.name === "AbortError"
? "MinerOps took too long to respond. Retry in a moment."
: display(error && error.message, "The fleet could not be loaded."));
} finally {
window.clearTimeout(timer);
}
}
el.fleetFilters.addEventListener("submit", function (event) {
event.preventDefault();
if (canViewLocations) {
state.siteId = el.fleetSite.value; state.roomId = el.fleetRoom.value;
}
state.offset = 0; setUrl("push"); load();
});
el.fleetReset.addEventListener("click", function () {
el.fleetFilters.reset(); state.offset = 0; state.limit = 100;
state.siteId = ""; state.roomId = "";
syncLocationOptions("", ""); setUrl("push"); load();
});
el.fleetPrevious.addEventListener("click", function () {
state.offset = Math.max(0, state.offset - state.limit); setUrl("push"); load();
});
el.fleetNext.addEventListener("click", function () {
state.offset += state.limit; setUrl("push"); load();
});
el.fleetLimit.addEventListener("change", function () {
state.limit = Number(el.fleetLimit.value); state.offset = 0; setUrl("push"); load();
});
if (canViewLocations) {
el.fleetSite.addEventListener("change", function () {
state.siteId = el.fleetSite.value; state.roomId = "";
syncLocationOptions(state.siteId, "");
});
el.fleetRoom.addEventListener("change", function () {
state.roomId = el.fleetRoom.value;
});
}
window.addEventListener("popstate", function () { readUrl(); load(); });
readUrl();
load();
}());
+784
View File
@@ -0,0 +1,784 @@
(function () {
"use strict";
if (document.body.dataset.view !== "map") return;
const ids = [
"mapSite", "mapRoom", "mapLayer", "mapSearch", "mapLifecycle",
"mapModel", "mapRepair", "mapStatus", "mapClearFilters",
"mapProblemsOnly",
"mapFilterCount", "mapScope", "mapSummary", "mapFreshness",
"mapCriticalState", "mapCriticalTitle", "mapCriticalDetail",
"mapRetry", "mapLoading", "mapWorkspace", "mapLegend", "mapNotice",
"mapPermissionNote", "mapViewport", "mapLegacySection",
"mapLegacyPositions", "mapUnplacedSection", "mapUnplaced",
"mapInspector", "mapInspectorTitle", "mapInspectorStatus",
"mapInspectorDetails", "mapInspectorClose", "mapOpenMiner"
];
const el = {};
for (const id of ids) el[id] = document.getElementById(id);
const state = {
data: null,
siteId: "",
roomId: "",
layer: "status",
query: "",
lifecycle: "",
model: "",
repair: "",
status: "",
problemsOnly: false,
minerId: "",
minerDetailsIncluded: false
};
function create(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function display(value, fallback) {
if (value === null || value === undefined || String(value).trim() === "") {
return fallback || "Unavailable";
}
return String(value);
}
function shelfLabel(value) {
const label = display(value, "Unlabelled shelf");
return /^shelf\b/i.test(label) ? label : "Shelf " + label;
}
function words(value) {
return display(value, "Unknown").replaceAll("_", " ").toLowerCase();
}
function dateTime(value) {
if (!value) return "Unavailable";
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf()) ? "Unavailable" : parsed.toLocaleString();
}
function metric(value, suffix) {
return typeof value === "number" && Number.isFinite(value)
? value + suffix : "Unavailable";
}
function age(value) {
if (!value) return "Never observed";
const time = new Date(value).valueOf();
if (Number.isNaN(time)) return "Observation time unavailable";
const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000));
if (seconds < 60) return seconds + "s ago";
if (seconds < 3600) return Math.floor(seconds / 60) + "m ago";
if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago";
return Math.floor(seconds / 86400) + "d ago";
}
const PROBLEM_KEYS = new Set([
"offline", "unknown", "unstable", "repair", "conflict", "blocked",
"expected"
]);
const MEASUREMENT_LAYERS = {
temperature: { field: "chip_temp_max_c", suffix: " C max" },
hashrate: { field: "hashrate_ths", suffix: " TH/s" },
power: { field: "power_w", suffix: " W" }
};
function operational(position) {
const kind = words(position.position_kind);
const status = words(position.position_status);
if (position.conflict_reason || status === "conflict") {
return { key: "conflict", label: "Location conflict", mark: "Conflict" };
}
if (kind === "blocked" || status === "blocked") {
return { key: "blocked", label: "Blocked position", mark: "Blocked" };
}
if (kind === "switch" || status === "switch") {
return { key: "switch", label: "Switch / equipment", mark: "Equipment" };
}
if (!position.occupied) {
if (position.expected_mac) {
return { key: "expected", label: "Expected miner not resolved", mark: "Expected" };
}
if (kind === "miner slot" && status === "available") {
return { key: "empty", label: "Empty position", mark: "Empty" };
}
return { key: "unknown", label: "Unknown position state", mark: "Unknown" };
}
if (!state.minerDetailsIncluded) {
return { key: "restricted", label: "Occupied; miner details restricted", mark: "Restricted" };
}
if (position.active_repair || position.lifecycle_status === "in_repair") {
return { key: "repair", label: "In repair", mark: "Repair" };
}
if (position.lifecycle_status === "retired" || position.asset_state === "decommissioned") {
return { key: "retired", label: "Retired / decommissioned", mark: "Retired" };
}
if (position.lifecycle_status === "spare") {
return { key: "spare", label: "Spare inventory", mark: "Spare" };
}
const runtime = words(position.miner_status);
if (runtime === "offline") {
return { key: "offline", label: "Offline", mark: "Offline" };
}
if (position.hash_stable === false) {
return { key: "unstable", label: "Hash unstable", mark: "Unstable" };
}
if (runtime === "online") {
return { key: "online", label: "Online", mark: "Online" };
}
return { key: "unknown", label: "Operational state unknown", mark: "Unknown" };
}
function isProblem(position) {
return PROBLEM_KEYS.has(operational(position).key);
}
function measurementScale(positions) {
const config = MEASUREMENT_LAYERS[state.layer];
if (!config) return null;
const values = positions.filter(function (position) {
return position.occupied && matches(position) &&
(!state.problemsOnly || isProblem(position));
}).map(function (position) {
return position[config.field];
}).filter(function (value) {
return typeof value === "number" && Number.isFinite(value);
});
if (!values.length) return { config: config, min: null, max: null };
let min = values[0];
let max = values[0];
for (const value of values.slice(1)) {
min = Math.min(min, value);
max = Math.max(max, value);
}
return {
config: config,
min: min,
max: max
};
}
function relativePercent(value, scale) {
if (!scale || scale.min === null || scale.max === null ||
typeof value !== "number" || !Number.isFinite(value)) return null;
if (scale.min === scale.max) return 50;
return Math.max(0, Math.min(
100,
((value - scale.min) / (scale.max - scale.min)) * 100
));
}
function layerValue(position, condition) {
if (!position.occupied) return condition.label;
if (!state.minerDetailsIncluded) return condition.label;
if (state.layer === "temperature") {
return metric(position.chip_temp_max_c, " C max");
}
if (state.layer === "hashrate") {
return metric(position.hashrate_ths, " TH/s");
}
if (state.layer === "power") {
return metric(position.power_w, " W");
}
if (state.layer === "repair") {
if (position.active_repair) return words(position.active_repair.status);
if (position.lifecycle_status === "in_repair") return "In-repair lifecycle";
if (position.asset_state === "repair") return "Legacy repair location";
return "No active repair";
}
return condition.label;
}
function identity(position) {
if (!state.minerDetailsIncluded) return "Miner details restricted";
return display(position.asset_id, display(position.current_mac, "Unidentified miner"));
}
function matches(position) {
if (!position.occupied) return true;
if (!state.minerDetailsIncluded) return true;
const condition = operational(position);
const haystack = [position.asset_id, position.miner_sn, position.current_mac,
position.current_ip, position.model].map(function (value) {
return display(value, "").toLowerCase();
}).join(" ");
return (!state.query || haystack.includes(state.query.toLowerCase())) &&
(!state.lifecycle || position.lifecycle_status === state.lifecycle) &&
(!state.model || position.model === state.model) &&
(!state.status || condition.key === state.status) &&
(!state.repair ||
(state.repair === "active" && Boolean(position.active_repair)) ||
(state.repair === "none" && !position.active_repair) ||
(state.repair === "legacy" && position.asset_state === "repair" && !position.active_repair));
}
function option(value, label) {
const node = create("option", "", label);
node.value = value;
return node;
}
function unique(values) {
return Array.from(new Set(values.filter(Boolean))).sort(function (a, b) {
return String(a).localeCompare(String(b), undefined, { numeric: true });
});
}
function syncSelectors() {
const sites = state.data.sites;
if (!sites.some(function (site) { return String(site.id) === state.siteId; })) {
state.siteId = sites.length ? String(sites[0].id) : "";
}
el.mapSite.replaceChildren();
for (const site of sites) el.mapSite.appendChild(option(String(site.id), site.name));
el.mapSite.value = state.siteId;
const rooms = state.data.rooms.filter(function (room) {
return String(room.site_id) === state.siteId;
});
if (!rooms.some(function (room) { return String(room.id) === state.roomId; })) {
state.roomId = rooms.length ? String(rooms[0].id) : "";
}
el.mapRoom.replaceChildren();
if (!rooms.length) el.mapRoom.appendChild(option("", "No rooms at this site"));
for (const room of rooms) {
el.mapRoom.appendChild(option(String(room.id), room.name));
}
el.mapRoom.value = state.roomId;
el.mapRoom.disabled = !rooms.length;
const site = sites.find(function (item) { return String(item.id) === state.siteId; });
const room = rooms.find(function (item) { return String(item.id) === state.roomId; });
el.mapScope.textContent = site && room
? site.name + " / " + room.name
: site ? site.name + " / No configured rooms" : "No configured sites";
}
function syncPermissionControls() {
const allowed = state.minerDetailsIncluded;
if (!allowed) {
state.layer = "status";
state.query = state.lifecycle = state.model = state.repair = state.status = "";
state.problemsOnly = false;
el.mapSearch.value = "";
el.mapRepair.value = "";
el.mapProblemsOnly.checked = false;
}
for (const item of el.mapLayer.options) {
item.disabled = !allowed && item.value !== "status";
}
el.mapLayer.value = state.layer;
el.mapSearch.disabled = !allowed;
el.mapLifecycle.disabled = !allowed;
el.mapModel.disabled = !allowed;
el.mapRepair.disabled = !allowed;
el.mapStatus.disabled = !allowed;
el.mapClearFilters.disabled = !allowed;
el.mapProblemsOnly.disabled = !allowed;
el.mapSearch.closest(".field").hidden = !allowed;
document.getElementById("mapFilters").hidden = !allowed;
el.mapPermissionNote.hidden = allowed;
}
function syncFilterOptions() {
const positions = state.data.positions.filter(function (position) {
return String(position.room_id || "") === state.roomId && position.occupied;
});
const lifecycle = unique(positions.map(function (item) { return item.lifecycle_status; }));
const models = unique(positions.map(function (item) { return item.model; }));
const statuses = unique(positions.map(function (item) { return operational(item).key; }));
const selectedLifecycle = state.lifecycle;
const selectedModel = state.model;
const selectedStatus = state.status;
el.mapLifecycle.replaceChildren(option("", "All lifecycle states"));
lifecycle.forEach(function (value) { el.mapLifecycle.appendChild(option(value, words(value))); });
el.mapModel.replaceChildren(option("", "All models"));
models.forEach(function (value) { el.mapModel.appendChild(option(value, value)); });
el.mapStatus.replaceChildren(option("", "All operational states"));
statuses.forEach(function (value) { el.mapStatus.appendChild(option(value, words(value))); });
if (lifecycle.includes(selectedLifecycle)) el.mapLifecycle.value = selectedLifecycle;
else state.lifecycle = "";
if (models.includes(selectedModel)) el.mapModel.value = selectedModel;
else state.model = "";
if (statuses.includes(selectedStatus)) el.mapStatus.value = selectedStatus;
else state.status = "";
}
function stateUrl() {
const params = new URLSearchParams();
const values = {
site_id: state.siteId, room_id: state.roomId,
layer: state.layer === "status" ? "" : state.layer,
q: state.query, lifecycle: state.lifecycle, model: state.model,
repair: state.repair, status: state.status,
problems: state.problemsOnly ? "1" : "", miner_id: state.minerId
};
for (const pair of Object.entries(values)) {
if (pair[1]) params.set(pair[0], pair[1]);
}
return window.location.pathname +
(params.size ? "?" + params.toString() : "");
}
function setUrl(mode) {
window.history[mode === "push" ? "pushState" : "replaceState"](
{}, "", stateUrl()
);
}
function readUrl() {
const params = new URLSearchParams(window.location.search);
state.siteId = params.get("site_id") || "";
state.roomId = params.get("room_id") || "";
state.layer = ["status", "temperature", "hashrate", "power", "repair"]
.includes(params.get("layer")) ? params.get("layer") : "status";
state.query = (params.get("q") || "").slice(0, 160);
state.lifecycle = params.get("lifecycle") || "";
state.model = params.get("model") || "";
state.repair = ["active", "none", "legacy"].includes(params.get("repair"))
? params.get("repair") : "";
state.status = params.get("status") || "";
state.problemsOnly = params.get("problems") === "1";
state.minerId = params.get("miner_id") || "";
syncPermissionControls();
el.mapLayer.value = state.layer;
el.mapSearch.value = state.query;
el.mapRepair.value = state.repair;
el.mapProblemsOnly.checked = state.problemsOnly;
}
function appendLegendItem(key, label, mark) {
const item = create("span", "legend-item");
const marker = create("span", "legend-marker", mark);
marker.dataset.status = key;
marker.setAttribute("aria-hidden", "true");
item.append(marker, document.createTextNode(label));
el.mapLegend.appendChild(item);
}
function legend(scale) {
const entries = !state.minerDetailsIncluded
? [["restricted", "Miner details restricted", "Restricted"],
["empty", "Empty position", "Empty"],
["conflict", "Location conflict", "Conflict"],
["blocked", "Blocked position", "Blocked"],
["switch", "Switch or equipment", "Equipment"]]
: state.layer === "status"
? [["online", "Online", "Online"], ["offline", "Offline", "Offline"],
["unstable", "Hash unstable", "Unstable"],
["repair", "In repair", "Repair"], ["spare", "Spare", "Spare"],
["retired", "Retired", "Retired"],
["unknown", "Operational state unknown", "Unknown"],
["empty", "Empty position", "Empty"],
["conflict", "Location conflict", "Conflict"]]
: [["online", "Online marker", "Online"],
["offline", "Offline marker", "Offline"],
["unstable", "Hash unstable marker", "Unstable"],
["repair", "In-repair marker", "Repair"],
["unknown", "Unknown-state marker", "Unknown"],
["empty", "Empty position", "Empty"]];
el.mapLegend.replaceChildren();
el.mapLegend.appendChild(create(
"strong", "legend-title", el.mapLayer.options[el.mapLayer.selectedIndex].text
));
if (scale) {
const range = create("span", "relative-scale");
if (scale.min === null || scale.max === null) {
range.textContent = "Relative range unavailable: no valid displayed readings";
} else {
const low = metric(scale.min, scale.config.suffix);
const high = metric(scale.max, scale.config.suffix);
range.setAttribute(
"aria-label",
"Relative range across emphasized miners from " + low + " to " + high
);
range.append(
create("span", "relative-scale-label", "Relative range (emphasized)"),
create("span", "relative-scale-value", low),
create("span", "relative-scale-track"),
create("span", "relative-scale-value", high)
);
}
el.mapLegend.appendChild(range);
}
for (const entry of entries) {
appendLegendItem(entry[0], entry[1], entry[2]);
}
}
function positionTile(position, scale) {
const condition = operational(position);
const tile = create(position.occupied && position.miner_id ? "button" : "div", "map-position");
tile.dataset.status = condition.key;
tile.dataset.positionId = position.location_position_id;
tile.dataset.occupied = String(position.occupied);
tile.dataset.problem = String(isProblem(position));
if (position.miner_id) tile.dataset.minerId = position.miner_id;
if (position.occupied && !matches(position)) tile.dataset.filtered = "true";
if (state.problemsOnly && position.occupied && !isProblem(position)) {
tile.dataset.problemsMuted = "true";
}
if (String(position.miner_id) === state.minerId) tile.dataset.selected = "true";
const scaleEligible = position.occupied && matches(position) &&
(!state.problemsOnly || isProblem(position));
const relative = scale && scaleEligible
? relativePercent(position[scale.config.field], scale) : null;
if (relative !== null) {
tile.dataset.measurement = "true";
tile.style.setProperty("--relative-percent", relative.toFixed(2) + "%");
}
if (tile.tagName === "BUTTON") {
tile.type = "button";
tile.tabIndex = -1;
tile.setAttribute("aria-pressed", String(String(position.miner_id) === state.minerId));
tile.setAttribute("aria-label", display(position.position_label, "Position " + position.position_index) +
", " + identity(position) + ", " + condition.label + ", " + age(position.observed_at));
} else {
tile.setAttribute("role", "group");
tile.setAttribute("aria-label", display(position.position_label, "Position " + position.position_index) +
", " + condition.label);
}
const head = create("span", "position-head");
const marker = create("span", "status-marker", condition.mark);
marker.setAttribute("aria-hidden", "true");
head.append(create("strong", "position-label", display(position.position_label, "Position " + position.position_index)),
marker);
const name = create("span", "position-miner", position.occupied ? identity(position) : condition.label);
tile.append(head, name);
if (position.occupied) {
tile.append(
create("span", "position-value", layerValue(position, condition)),
create("span", "position-freshness", state.minerDetailsIncluded
? age(position.observed_at) : "Miner details restricted")
);
}
return tile;
}
function renderMap() {
syncSelectors();
const positions = state.data.positions.filter(function (position) {
return String(position.room_id || "") === state.roomId;
});
if (state.minerId && !state.data.positions.some(function (position) {
const visible = String(position.room_id || "") === state.roomId || !position.room_id;
return visible && position.miner_id && String(position.miner_id) === state.minerId;
})) state.minerId = "";
syncPermissionControls();
syncFilterOptions();
const scale = measurementScale(positions);
legend(scale);
el.mapViewport.replaceChildren();
if (!state.data.sites.length) {
el.mapNotice.hidden = false;
el.mapNotice.textContent = "No sites are configured. Use location management to create the physical hierarchy.";
} else if (!state.roomId) {
el.mapNotice.hidden = false;
el.mapNotice.textContent = "This site has no configured rooms.";
} else if (!positions.length) {
el.mapNotice.hidden = false;
el.mapNotice.textContent = "No positions are configured for this room.";
} else {
el.mapNotice.hidden = true;
}
const racks = new Map();
for (const position of positions) {
const rack = display(position.rack_name, "Unlabelled rack");
const shelf = display(position.shelf_name, "Unlabelled shelf");
if (!racks.has(rack)) racks.set(rack, new Map());
if (!racks.get(rack).has(shelf)) racks.get(rack).set(shelf, []);
racks.get(rack).get(shelf).push(position);
}
const fragment = document.createDocumentFragment();
for (const rackEntry of racks) {
const rack = create("section", "map-rack");
rack.setAttribute("aria-label", "Rack " + rackEntry[0]);
rack.appendChild(create("h2", "rack-title", "Rack " + rackEntry[0]));
for (const shelfEntry of rackEntry[1]) {
const shelf = create("section", "map-shelf");
shelf.setAttribute("aria-label", shelfLabel(shelfEntry[0]));
shelf.appendChild(create("h3", "shelf-title", shelfLabel(shelfEntry[0])));
const grid = create("div", "position-grid");
for (const position of shelfEntry[1]) {
grid.appendChild(positionTile(position, scale));
}
shelf.appendChild(grid);
rack.appendChild(shelf);
}
fragment.appendChild(rack);
}
el.mapViewport.appendChild(fragment);
renderSummary(positions);
renderLegacyPositions();
renderUnplaced();
renderInspector();
syncTileTabStops();
const count = [state.query, state.lifecycle, state.model, state.repair,
state.status, state.problemsOnly].filter(Boolean).length;
el.mapFilterCount.textContent = count ? "(" + count + " active)" : "";
el.mapFreshness.textContent = "Map generated " + dateTime(state.meta.generated_at) +
(state.minerDetailsIncluded ? ". Telemetry age is shown per miner."
: ". Miner details are restricted for this account.");
}
function renderSummary(positions) {
const facts = { occupied: 0, empty: 0, conflict: 0, offline: 0,
repair: 0, unknown: 0, restricted: 0, problems: 0 };
for (const position of positions) {
const condition = operational(position);
if (position.occupied) facts.occupied += 1;
if (condition.key in facts) facts[condition.key] += 1;
if (isProblem(position)) facts.problems += 1;
}
el.mapSummary.replaceChildren();
const pairs = [["Positions", positions.length], ["Occupied", facts.occupied],
["Empty", facts.empty], ["Conflicts", facts.conflict]];
if (state.minerDetailsIncluded) pairs.splice(3, 0,
["Problems", facts.problems], ["Offline", facts.offline],
["In repair", facts.repair], ["Unknown", facts.unknown]);
else pairs.splice(3, 0, ["Restricted", facts.restricted]);
for (const pair of pairs) {
const item = create("span", "summary-fact");
item.append(create("strong", "", String(pair[1])), document.createTextNode(pair[0]));
el.mapSummary.appendChild(item);
}
}
function renderLegacyPositions() {
const positions = state.data.positions.filter(function (position) {
return !position.room_id;
});
el.mapLegacySection.hidden = !positions.length;
el.mapLegacyPositions.replaceChildren();
for (const position of positions) {
const item = create("article", "legacy-position-entry");
item.append(create("strong", "", display(position.position_label,
"Position " + position.position_index)),
create("span", "legacy-position-path", "Unscoped / Room label " +
display(position.room_name, "Unavailable") + " / Rack " +
display(position.rack_name, "Unavailable") + " / Shelf " +
display(position.shelf_name, "Unavailable")),
positionTile(position));
el.mapLegacyPositions.appendChild(item);
}
}
function renderUnplaced() {
const miners = state.data.virtual_miners.filter(function (miner) {
return (!state.siteId || String(miner.site_id || "") === state.siteId) &&
(!state.roomId || !miner.room_id || String(miner.room_id) === state.roomId);
});
el.mapUnplacedSection.hidden = !miners.length;
el.mapUnplaced.replaceChildren();
for (const miner of miners) {
const item = create("div", "unplaced-miner");
item.append(create("strong", "", display(miner.asset_id, display(miner.current_mac, "Unidentified miner"))),
create("span", "", words(miner.asset_state) + " / " + words(miner.lifecycle_status)),
create("span", "", miner.active_repair
? "Active repair: " + words(miner.active_repair.status)
: "No active repair record"));
el.mapUnplaced.appendChild(item);
}
}
function detailRow(term, value) {
el.mapInspectorDetails.append(create("dt", "", term), create("dd", "", display(value)));
}
function renderInspector() {
const position = state.data.positions.find(function (item) {
return item.miner_id && String(item.miner_id) === state.minerId;
});
if (!position) {
el.mapInspector.hidden = true;
return;
}
const condition = operational(position);
el.mapInspector.hidden = false;
el.mapInspectorTitle.textContent = identity(position);
el.mapInspectorStatus.textContent = condition.label + " / " + age(position.observed_at);
el.mapInspectorStatus.dataset.status = condition.key;
el.mapInspectorDetails.replaceChildren();
detailRow("Asset ID", position.asset_id);
detailRow("Model", position.model || position.miner_type);
detailRow("Serial", position.miner_sn);
detailRow("Current MAC", position.current_mac);
detailRow("Current IP", position.current_ip);
const location = position.room_id
? [position.site_name, position.room_name, "Rack " + position.rack_name,
shelfLabel(position.shelf_name), display(position.position_label,
"Position " + position.position_index)].join(" / ")
: ["Unscoped", "Room label " + display(position.room_name),
"Rack " + display(position.rack_name), shelfLabel(position.shelf_name),
display(position.position_label, "Position " + position.position_index)].join(" / ");
detailRow("Location", location);
detailRow("Lifecycle", words(position.lifecycle_status));
detailRow("Runtime", words(position.miner_status));
detailRow("Repair", position.active_repair
? words(position.active_repair.status) + " / " + words(position.active_repair.reason)
: position.asset_state === "repair" ? "Legacy repair location state" : "No active repair");
detailRow("Hashrate", metric(position.hashrate_ths, " TH/s"));
detailRow("Power", metric(position.power_w, " W"));
detailRow("Chip temperature", metric(position.chip_temp_max_c, " C max"));
detailRow("Observed", dateTime(position.observed_at));
const returnTo = stateUrl();
el.mapOpenMiner.href = "/miners/" + encodeURIComponent(position.miner_id) +
"/detail?return_to=" + encodeURIComponent(returnTo);
}
function mapTiles() {
return Array.from(el.mapWorkspace.querySelectorAll("button.map-position[data-miner-id]"));
}
function syncTileTabStops() {
const tiles = mapTiles();
const current = tiles.find(function (tile) {
return tile.dataset.minerId === state.minerId;
});
for (const tile of tiles) tile.tabIndex = tile === current ? 0 : -1;
if (!current && tiles.length) tiles[0].tabIndex = 0;
}
function syncSelection() {
for (const tile of mapTiles()) {
const selected = tile.dataset.minerId === state.minerId;
tile.dataset.selected = String(selected);
tile.setAttribute("aria-pressed", String(selected));
}
renderInspector();
syncTileTabStops();
}
function bind() {
const controls = [el.mapLayer, el.mapSearch, el.mapLifecycle, el.mapModel, el.mapRepair, el.mapStatus];
let renderFrame = null;
function scheduleRender() {
if (renderFrame !== null) window.cancelAnimationFrame(renderFrame);
renderFrame = window.requestAnimationFrame(function () {
renderFrame = null;
renderMap();
setUrl("replace");
});
}
el.mapSite.addEventListener("change", function () {
state.siteId = el.mapSite.value; state.roomId = ""; state.minerId = "";
renderMap(); setUrl("push");
});
el.mapRoom.addEventListener("change", function () {
state.roomId = el.mapRoom.value; state.minerId = ""; renderMap(); setUrl("push");
});
for (const control of controls) {
control.addEventListener(control === el.mapSearch ? "input" : "change", function () {
state.layer = el.mapLayer.value; state.query = el.mapSearch.value.trim();
state.lifecycle = el.mapLifecycle.value; state.model = el.mapModel.value;
state.repair = el.mapRepair.value; state.status = el.mapStatus.value;
scheduleRender();
});
}
el.mapProblemsOnly.addEventListener("change", function () {
state.problemsOnly = el.mapProblemsOnly.checked;
scheduleRender();
});
el.mapClearFilters.addEventListener("click", function () {
state.query = state.lifecycle = state.model = state.repair = state.status = "";
state.problemsOnly = false;
el.mapSearch.value = ""; el.mapRepair.value = "";
el.mapProblemsOnly.checked = false;
renderMap(); setUrl("replace");
});
el.mapWorkspace.addEventListener("click", function (event) {
const tile = event.target.closest("button[data-miner-id]");
if (!tile || !el.mapWorkspace.contains(tile)) return;
state.minerId = tile.dataset.minerId;
syncSelection(); setUrl("push");
el.mapInspectorClose.focus();
});
el.mapWorkspace.addEventListener("keydown", function (event) {
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
const tile = event.target.closest("button.map-position[data-miner-id]");
if (!tile) return;
const tiles = mapTiles();
const current = tiles.indexOf(tile);
const delta = ["ArrowLeft", "ArrowUp"].includes(event.key) ? -1 : 1;
const next = tiles[(current + delta + tiles.length) % tiles.length];
event.preventDefault();
for (const item of tiles) item.tabIndex = item === next ? 0 : -1;
next.focus();
});
el.mapInspectorClose.addEventListener("click", function () {
const restoreId = state.minerId; state.minerId = "";
syncSelection(); setUrl("push");
const restore = mapTiles().find(function (tile) {
return tile.dataset.minerId === restoreId;
});
if (restore) {
for (const tile of mapTiles()) tile.tabIndex = -1;
restore.tabIndex = 0;
restore.focus();
}
});
el.mapRetry.addEventListener("click", load);
window.addEventListener("popstate", function () {
readUrl(); renderMap(); setUrl("replace");
});
}
function validate(envelope) {
return envelope && envelope.data && envelope.meta &&
Array.isArray(envelope.data.sites) && Array.isArray(envelope.data.rooms) &&
Array.isArray(envelope.data.positions) && Array.isArray(envelope.data.virtual_miners) &&
envelope.data.positions.every(function (item) {
return item && typeof item.location_position_id === "string" &&
typeof item.position_index === "number";
});
}
async function load() {
el.mapLoading.hidden = false;
el.mapCriticalState.hidden = true;
el.mapWorkspace.hidden = true;
const controller = new AbortController();
const timer = window.setTimeout(function () { controller.abort(); }, 15000);
try {
const response = await fetch("/api/location/map", {
credentials: "same-origin", headers: { Accept: "application/json" },
signal: controller.signal
});
if (response.status === 401) {
window.location.replace("/login?next=" + encodeURIComponent(window.location.pathname + window.location.search));
return;
}
if (response.status === 403) {
el.mapCriticalTitle.textContent = "Map access unavailable";
el.mapCriticalDetail.textContent = "Your account cannot view location data.";
el.mapCriticalState.hidden = false;
return;
}
if (!response.ok) throw new Error("The map service returned " + response.status + ".");
const envelope = await response.json();
if (!validate(envelope)) throw new Error("The map response was incomplete.");
state.data = envelope.data; state.meta = envelope.meta;
state.minerDetailsIncluded = envelope.meta.miner_details_included === true;
readUrl(); syncSelectors();
el.mapWorkspace.hidden = false;
renderMap(); setUrl("replace");
} catch (error) {
el.mapCriticalTitle.textContent = "Map unavailable";
el.mapCriticalDetail.textContent = error && error.name === "AbortError"
? "MinerOps took too long to respond. Retry in a moment."
: display(error && error.message, "The map could not be loaded.");
el.mapCriticalState.hidden = false;
} finally {
window.clearTimeout(timer); el.mapLoading.hidden = true;
}
}
bind();
load();
}());
+567
View File
@@ -0,0 +1,567 @@
(function () {
"use strict";
if (document.body.dataset.view !== "miner_detail") return;
const minerId = document.body.dataset.minerId;
const returnTo = document.body.dataset.returnTo || "";
const canControl = document.body.dataset.canControlMiners === "true";
const el = {
loading: document.getElementById("minerDetailLoading"),
error: document.getElementById("minerDetailError"),
errorText: document.getElementById("minerDetailErrorText"),
content: document.getElementById("minerDetailContent"),
retry: document.getElementById("minerDetailRetry"),
refresh: document.getElementById("minerDetailRefresh"),
title: document.getElementById("minerIdentityTitle"),
model: document.getElementById("minerDetailModel"),
uuid: document.getElementById("minerDetailUuid"),
operational: document.getElementById("minerDetailOperational"),
lifecycle: document.getElementById("minerDetailLifecycle"),
locationSummary: document.getElementById("minerDetailLocationSummary"),
returnLink: document.getElementById("minerDetailReturn"),
mapLink: document.getElementById("minerDetailMap"),
trendsLink: document.getElementById("minerDetailTrends"),
repairsLink: document.getElementById("minerDetailRepairs"),
identity: document.getElementById("minerIdentityDetails"),
tags: document.getElementById("minerTags"),
telemetry: document.getElementById("minerTelemetryDetails"),
telemetryAge: document.getElementById("minerTelemetryAge"),
location: document.getElementById("minerLocationBody"),
repair: document.getElementById("minerRepairBody"),
episodes: document.getElementById("minerRepairEpisodes"),
identityHistory: document.getElementById("minerIdentityHistory"),
identityHistoryBody: document.getElementById("minerIdentityHistoryBody"),
observations: document.getElementById("minerObservations"),
observationsBody: document.getElementById("minerObservationsBody"),
controlsRefresh: document.getElementById("minerControlsRefresh"),
controlsSummary: document.getElementById("minerControlsSummary"),
controlCapabilities: document.getElementById("minerControlCapabilities"),
controlActions: document.getElementById("minerControlActions"),
controlPermission: document.getElementById("minerControlPermission"),
controlResult: document.getElementById("minerControlResult"),
serviceControl: document.getElementById("minerServiceControl"),
performanceForm: document.getElementById("minerPerformanceControl"),
performanceMode: document.getElementById("minerPerformanceMode"),
controlHistory: document.getElementById("minerControlHistory")
};
function create(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function display(value, fallback) {
if (value === null || value === undefined || value === "") {
return fallback === undefined ? "Unavailable" : fallback;
}
return String(value);
}
function words(value) {
const text = display(value, "unknown").replaceAll("_", " ").toLowerCase();
return text.charAt(0).toUpperCase() + text.slice(1);
}
function dateTime(value) {
if (!value) return "Unavailable";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? "Unavailable" : parsed.toLocaleString();
}
function age(value) {
if (!value) return "No observation available";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "Observation time unavailable";
const seconds = Math.max(0, Math.floor((Date.now() - parsed.getTime()) / 1000));
if (seconds < 60) return seconds + " seconds ago";
if (seconds < 3600) return Math.floor(seconds / 60) + " minutes ago";
if (seconds < 86400) return Math.floor(seconds / 3600) + " hours ago";
return Math.floor(seconds / 86400) + " days ago";
}
function metric(value, suffix) {
if (value === null || value === undefined || value === "") return "Unavailable";
const number = Number(value);
if (!Number.isFinite(number)) return "Unavailable";
return String(number) + suffix;
}
function addDefinition(list, label, value) {
const row = create("div");
row.append(create("dt", "", label), create("dd", "", display(value)));
list.appendChild(row);
}
function prefixed(value, prefix) {
const label = display(value, "");
if (!label) return "";
return new RegExp("^" + prefix + "\\b", "i").test(label)
? label : prefix + " " + label;
}
function locationText(location) {
if (!location) return "Not mapped";
const parts = [location.site_name, location.room_name,
prefixed(location.rack_label, "Rack"),
prefixed(location.shelf_label, "Shelf"),
location.position_label || (location.position_index === null
? "Position unavailable" : "Position " + location.position_index)];
return parts.filter(Boolean).join(" / ");
}
async function api(path) {
const response = await fetch(path, {
credentials: "same-origin",
headers: { Accept: "application/json" }
});
if (response.status === 401) {
window.location.assign("/login?next=" + encodeURIComponent(
window.location.pathname + window.location.search
));
throw new Error("Login required");
}
let payload = null;
try { payload = await response.json(); } catch (_error) { payload = null; }
if (!response.ok) {
throw new Error(payload && payload.detail
? String(payload.detail) : "Request failed (" + response.status + ")");
}
return payload ? payload.data : null;
}
async function mutate(path, body) {
const response = await fetch(path, {
method: "POST",
credentials: "same-origin",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (response.status === 401) {
window.location.assign("/login?next=" + encodeURIComponent(
window.location.pathname + window.location.search
));
throw new Error("Login required");
}
let payload = null;
try { payload = await response.json(); } catch (_error) { payload = null; }
if (!response.ok) {
throw new Error(payload && payload.detail
? String(payload.detail) : "Request failed (" + response.status + ")");
}
return payload ? payload.data : null;
}
function capabilityText(capability) {
if (!capability) return "Unknown";
const reason = capability.reason ? " / " + words(capability.reason) : "";
return words(capability.state) + reason;
}
function operationStatus(operation) {
if (operation.status === "pending") return "Pending";
if (operation.status === "claimed") return "Running";
if (operation.status === "failed") return "Failed";
if (operation.status === "cancelled") return "Cancelled";
if (operation.verification_status === "confirmed") return "Confirmed";
if (operation.verification_status === "unconfirmed") return "Unconfirmed";
if (operation.verification_status === "verification_failed") return "Verification failed";
if (operation.verification_status === "unavailable") return "Verification unavailable";
return "Completed";
}
function renderControls(data) {
el.controlsSummary.textContent = "Runtime: " + words(data.runtime_state) +
" / Known mode: " + words(data.performance_mode);
el.controlCapabilities.replaceChildren();
[["Agent", data.agent], ["Service control", data.service_control],
["Performance mode", data.performance_mode_control]].forEach(function (item) {
const block = create("div");
block.append(create("span", "secondary", item[0]),
create("strong", "", capabilityText(item[1])));
el.controlCapabilities.appendChild(block);
});
el.controlPermission.hidden = canControl;
el.controlActions.hidden = !canControl;
el.serviceControl.hidden = true;
el.performanceForm.hidden = true;
if (canControl && data.available_service_action) {
el.serviceControl.dataset.action = data.available_service_action;
el.serviceControl.textContent = data.available_service_action === "suspend"
? "Suspend miner" : "Resume miner";
el.serviceControl.hidden = false;
}
if (canControl && data.performance_mode_control.state === "supported") {
if (data.performance_mode) el.performanceMode.value = data.performance_mode;
el.performanceForm.hidden = false;
}
el.controlHistory.replaceChildren();
(data.recent_operations || []).forEach(function (operation) {
const row = create("tr");
[words(operation.action), display(operation.requested_value, "None"),
operationStatus(operation), words(operation.verification_status),
dateTime(operation.created_at)].forEach(function (value) {
row.appendChild(create("td", "", value));
});
el.controlHistory.appendChild(row);
});
if (!(data.recent_operations || []).length) {
const row = create("tr");
const cell = create("td", "empty-copy", "No control operations recorded.");
cell.colSpan = 5;
row.appendChild(cell);
el.controlHistory.appendChild(row);
}
}
async function loadControls() {
el.controlsSummary.textContent = "Loading control capability and history.";
try {
const data = await api("/api/miners/" + encodeURIComponent(minerId) + "/controls");
renderControls(data);
} catch (error) {
el.controlsSummary.textContent = "Controls unavailable: " + error.message;
}
}
async function queueControl(action, requestedValue) {
const description = action === "performance_mode"
? "set performance mode to " + requestedValue : action + " this miner";
if (!window.confirm("Confirm: " + description + "?")) return;
const pathAction = action === "performance_mode" ? "performance-mode" : action;
const body = action === "performance_mode" ? { mode: requestedValue } : {};
el.controlResult.hidden = false;
el.controlResult.textContent = "Queueing control operation.";
try {
const operation = await mutate(
"/api/miners/" + encodeURIComponent(minerId) + "/controls/" + pathAction,
body
);
el.controlResult.textContent = "Pending: " + words(operation.action) +
" operation " + operation.operation_id + ".";
await loadControls();
} catch (error) {
el.controlResult.textContent = "Control was not queued: " + error.message;
}
}
function table(headers, rows) {
const wrap = create("div", "table-wrap compact-history-table");
const tableNode = create("table");
const head = create("thead");
const headRow = create("tr");
headers.forEach(function (header) { headRow.appendChild(create("th", "", header)); });
head.appendChild(headRow);
const body = create("tbody");
rows.forEach(function (values) {
const row = create("tr");
values.forEach(function (value) { row.appendChild(create("td", "", display(value))); });
body.appendChild(row);
});
tableNode.append(head, body);
wrap.appendChild(tableNode);
return wrap;
}
function renderIdentity(miner) {
const identity = miner.asset_id || miner.current_mac || "Miner " + miner.id.slice(0, 8);
el.title.textContent = identity;
el.model.textContent = display(miner.model, "Model unavailable");
el.uuid.textContent = "Miner UUID: " + miner.id;
const pageTitle = document.querySelector(".page-head h1");
if (pageTitle) pageTitle.textContent = identity;
document.title = identity + " | MinerOps";
el.identity.replaceChildren();
[
["Asset ID", miner.asset_id], ["Model", miner.model],
["Miner serial", miner.miner_sn], ["Control-board serial", miner.control_board_sn],
["PSU serial", miner.psu_serial], ["Current MAC", miner.current_mac],
["Current IP", miner.current_ip], ["Hostname", miner.hostname],
["Firmware", miner.firmware_version], ["Created", dateTime(miner.created_at)],
["First seen", dateTime(miner.first_seen_at)], ["Last seen", dateTime(miner.last_seen_at)]
].forEach(function (item) { addDefinition(el.identity, item[0], item[1]); });
el.tags.replaceChildren();
(miner.tags || []).forEach(function (tag) {
el.tags.appendChild(create("span", "tag", tag.name));
});
}
function renderTelemetry(miner) {
el.telemetry.replaceChildren();
[
["Runtime", words(miner.runtime_status)],
["Observed", dateTime(miner.observed_at)],
["Hashrate", metric(miner.hashrate_ths, " TH/s")],
["Power", metric(miner.power_w, " W")],
["Max chip temperature", metric(miner.chip_temp_max_c, " C")],
["Hash stability", miner.hash_stable === null || miner.hash_stable === undefined
? "Unavailable" : miner.hash_stable ? "Stable" : "Unstable"]
].forEach(function (item) { addDefinition(el.telemetry, item[0], item[1]); });
el.telemetryAge.textContent = age(miner.observed_at);
}
function renderLocation(section, minerIdValue, canViewMap) {
el.location.replaceChildren();
if (section.restricted) {
el.location.appendChild(create("p", "permission-note", "Location information restricted."));
el.locationSummary.textContent = "Location restricted";
return;
}
if (!section.location) {
el.location.appendChild(create("p", "empty-copy", "Not mapped"));
el.locationSummary.textContent = "Not mapped";
return;
}
const location = section.location;
const list = create("dl", "definition-list");
[["Site", location.site_name], ["Room", location.room_name],
["Rack", prefixed(location.rack_label, "Rack")],
["Shelf", prefixed(location.shelf_label, "Shelf")],
["Position", location.position_label || (location.position_index === null
? null : "Position " + location.position_index)]
].forEach(function (item) { addDefinition(list, item[0], item[1]); });
el.location.appendChild(list);
const summary = locationText(location);
el.locationSummary.textContent = summary;
if (canViewMap) {
const params = new URLSearchParams({ miner_id: minerIdValue });
params.set("site_id", location.site_id);
params.set("room_id", location.room_id);
el.mapLink.href = "/monitoring/map?" + params.toString();
el.mapLink.hidden = false;
}
}
function eventPayload(payload) {
const list = create("dl", "definition-list event-payload");
Object.entries(payload || {}).forEach(function (entry) {
const value = typeof entry[1] === "object"
? JSON.stringify(entry[1]) : entry[1];
addDefinition(list, words(entry[0]), value);
});
return list;
}
function renderRepairDetails(target, timeline, parts, canReadComponents) {
target.replaceChildren();
const events = timeline && timeline.events ? timeline.events : [];
const heading = create("h4", "", "Timeline");
const timelineList = create("ol", "repair-timeline-list");
events.forEach(function (event) {
const item = create("li");
item.append(create("strong", "", words(event.event_type)),
create("span", "secondary", dateTime(event.occurred_at) + " / " +
display(event.actor && event.actor.display_name, "Unknown actor")),
eventPayload(event.payload));
timelineList.appendChild(item);
});
if (!events.length) timelineList.appendChild(create("li", "empty-copy", "No timeline events recorded."));
target.append(heading, timelineList, create("h4", "", "Parts and components"));
const entries = parts && parts.entries ? parts.entries : [];
if (!entries.length) {
target.appendChild(create("p", "empty-copy", "No parts recorded for this repair."));
return;
}
const rows = entries.map(function (entry) {
let description = "Tracked component";
if (entry.consumable) {
description = entry.consumable.description + " / " +
entry.consumable.quantity + " " + entry.consumable.unit;
} else if (entry.component_snapshot) {
const snapshot = entry.component_snapshot;
description = [words(snapshot.component_type), snapshot.asset_id,
snapshot.manufacturer, snapshot.model, snapshot.manufacturer_serial]
.filter(Boolean).join(" / ");
if (canReadComponents && entry.component_id) {
description += " / Component UUID " + entry.component_id;
}
}
return [description, entry.notes, dateTime(entry.recorded_at),
entry.voided_at ? "Voided: " + display(entry.void_reason) : "Recorded"];
});
target.appendChild(table(["Part or component", "Notes", "Recorded", "State"], rows));
}
function repairEpisode(episode, canReadComponents) {
const details = create("details");
const summary = create("summary", "repair-episode-summary");
summary.append(create("strong", "", (episode.source === "legacy" ? "Legacy compatibility" : words(episode.status))),
create("span", "secondary", [words(episode.reason), dateTime(episode.opened_at)]
.filter(function (value) { return value !== "Unavailable"; }).join(" / ")));
details.appendChild(summary);
const overview = create("dl", "definition-list");
[["Status", words(episode.status)], ["Reason", words(episode.reason)],
["Opened", dateTime(episode.opened_at)], ["Closed", dateTime(episode.closed_at)],
["Latest context", episode.latest_context], ["Parts", episode.parts_count]
].forEach(function (item) { addDefinition(overview, item[0], item[1]); });
details.appendChild(overview);
if (episode.source === "modular" && episode.repair_id) {
const lazy = create("div", "lazy-content", "Open to load timeline and parts.");
details.appendChild(lazy);
details.addEventListener("toggle", async function () {
if (!details.open || details.dataset.loaded || details.dataset.loading) return;
details.dataset.loading = "true";
lazy.textContent = "Loading repair timeline and parts...";
try {
const results = await Promise.all([
api("/api/repairs/" + encodeURIComponent(episode.repair_id) + "/events"),
api("/api/repairs/" + encodeURIComponent(episode.repair_id) + "/parts")
]);
renderRepairDetails(lazy, results[0], results[1], canReadComponents);
details.dataset.loaded = "true";
} catch (error) {
lazy.textContent = "Repair history unavailable: " + error.message;
} finally { delete details.dataset.loading; }
});
} else {
details.appendChild(create("p", "permission-note",
"Legacy compatibility context has no modular repair timeline."));
}
return details;
}
function renderRepair(section, capabilities) {
el.repair.replaceChildren();
el.episodes.replaceChildren();
if (section.restricted) {
el.repair.appendChild(create("p", "permission-note", "Repair information restricted."));
return;
}
if (section.active_repair) {
const active = section.active_repair;
const list = create("dl", "definition-list");
[["Status", words(active.status)], ["Reason", words(active.reason)],
["Opened", dateTime(active.opened_at)], ["Source", "Modular repair workflow"],
["Latest context", active.latest_context], ["Parts", active.parts_count]
].forEach(function (item) { addDefinition(list, item[0], item[1]); });
el.repair.appendChild(list);
} else if ((section.episodes || []).some(function (item) { return item.source === "legacy" && item.active; })) {
el.repair.appendChild(create("p", "permission-note", "Legacy compatibility repair context is recorded. No modular repair UUID exists."));
} else {
el.repair.appendChild(create("p", "empty-copy", "No active repair"));
}
(section.episodes || []).forEach(function (episode) {
el.episodes.appendChild(repairEpisode(episode, capabilities.can_read_components));
});
}
async function loadIdentityHistory() {
if (el.identityHistory.dataset.loaded || el.identityHistory.dataset.loading) return;
el.identityHistory.dataset.loading = "true";
el.identityHistoryBody.textContent = "Loading identity history...";
try {
const results = await Promise.all([
api("/api/inventory/miners/" + encodeURIComponent(minerId) + "/asset-id-history"),
api("/api/miners/" + encodeURIComponent(minerId) + "/network-identities")
]);
el.identityHistoryBody.replaceChildren(create("h3", "", "Asset ID history"));
const assetRows = (results[0] || []).map(function (entry) {
return [entry.old_asset_id, entry.new_asset_id, dateTime(entry.changed_at),
entry.changed_by_display_name];
});
el.identityHistoryBody.appendChild(assetRows.length
? table(["Previous", "New", "Changed", "Actor"], assetRows)
: create("p", "empty-copy", "No Asset ID changes recorded."));
el.identityHistoryBody.appendChild(create("h3", "", "Network identity history"));
const networkRows = (results[1] || []).map(function (entry) {
return [entry.mac_address, entry.ip_address, entry.hostname,
entry.is_current ? "Current" : "Historical", dateTime(entry.last_seen_at)];
});
el.identityHistoryBody.appendChild(networkRows.length
? table(["MAC", "IP", "Hostname", "State", "Last seen"], networkRows)
: create("p", "empty-copy", "No network identity history recorded."));
el.identityHistory.dataset.loaded = "true";
} catch (error) {
el.identityHistoryBody.textContent = "Identity history unavailable: " + error.message;
} finally { delete el.identityHistory.dataset.loading; }
}
async function loadObservations() {
if (el.observations.dataset.loaded || el.observations.dataset.loading) return;
el.observations.dataset.loading = "true";
el.observationsBody.textContent = "Loading recent observations...";
try {
const observations = await api("/api/miners/" + encodeURIComponent(minerId) + "/status-snapshots");
const rows = (observations || []).map(function (item) {
return [dateTime(item.observed_at), metric(item.mhs_av === null ? null : Number(item.mhs_av) / 1000000, " TH/s"),
metric(item.power_w, " W"), metric(item.chip_temp_max_c, " C"),
item.hash_stable === null || item.hash_stable === undefined
? "Unavailable" : item.hash_stable ? "Stable" : "Unstable"];
});
el.observationsBody.replaceChildren(rows.length
? table(["Observed", "Hashrate", "Power", "Max chip temp", "Hash stability"], rows)
: create("p", "empty-copy", "No observations recorded."));
el.observations.dataset.loaded = "true";
} catch (error) {
el.observationsBody.textContent = "Recent observations unavailable: " + error.message;
} finally { delete el.observations.dataset.loading; }
}
function renderOverview(data) {
renderIdentity(data.miner);
renderTelemetry(data.miner);
el.operational.textContent = words(data.operational_state);
el.operational.dataset.status = data.operational_state;
el.lifecycle.textContent = "Lifecycle: " + words(data.miner.lifecycle_status);
renderLocation(data.location, data.miner.id, data.capabilities.can_view_map);
renderRepair(data.repair, data.capabilities);
el.trendsLink.href = "/monitoring/analytics?" + new URLSearchParams({
scope: "miner", scope_id: data.miner.id, range: "24h"
}).toString();
if (data.capabilities.can_open_repair_center) {
const params = new URLSearchParams({ scope: "all", q: data.miner.id });
el.repairsLink.href = "/operations/repairs?" + params.toString();
el.repairsLink.hidden = false;
}
if (returnTo) {
el.returnLink.href = returnTo;
el.returnLink.textContent = returnTo.startsWith("/monitoring/map")
? "Return to Mine Map" : "Return to previous view";
el.returnLink.hidden = false;
}
}
async function loadOverview() {
el.loading.hidden = false;
el.error.hidden = true;
el.content.hidden = true;
try {
const data = await api("/api/miners/" + encodeURIComponent(minerId) + "/overview");
renderOverview(data);
el.loading.hidden = true;
el.content.hidden = false;
} catch (error) {
el.loading.hidden = true;
el.error.hidden = false;
el.errorText.textContent = error.message;
}
}
function refreshAll() {
loadOverview();
loadControls();
}
el.retry.addEventListener("click", loadOverview);
el.refresh.addEventListener("click", refreshAll);
el.controlsRefresh.addEventListener("click", loadControls);
el.serviceControl.addEventListener("click", function () {
queueControl(el.serviceControl.dataset.action, null);
});
el.performanceForm.addEventListener("submit", function (event) {
event.preventDefault();
queueControl("performance_mode", el.performanceMode.value);
});
el.identityHistory.addEventListener("toggle", function () {
if (el.identityHistory.open) loadIdentityHistory();
});
el.observations.addEventListener("toggle", function () {
if (el.observations.open) loadObservations();
});
loadOverview();
loadControls();
})();
@@ -0,0 +1,54 @@
.trend-controls form {
display: grid;
grid-template-columns: minmax(140px, 0.7fr) minmax(160px, 1fr)
minmax(160px, 1fr) auto auto;
gap: 12px;
align-items: end;
}
.trend-controls form > label,
.trend-ranges {
display: grid;
gap: 5px;
margin: 0;
}
.trend-ranges {
grid-auto-flow: column;
justify-content: start;
border: 0;
padding: 0;
}
.trend-ranges legend { margin-bottom: 5px; font-weight: 700; }
.trend-ranges label { display: flex; gap: 6px; align-items: center; white-space: nowrap; }
.trend-summary { display: flex; flex-wrap: wrap; gap: 8px 24px; margin: 0 0 14px; }
.trend-summary p { margin: 0; }
.trend-charts { display: grid; gap: 16px; }
.trend-panel { min-width: 0; }
.trend-chart { width: 100%; min-height: 220px; overflow: hidden; }
.trend-chart svg { display: block; width: 100%; height: auto; min-height: 220px; }
.trend-grid { stroke: #cbd5e1; stroke-width: 1; }
.trend-line { fill: none; stroke: #c65312; stroke-width: 2.5; stroke-linejoin: round; }
.trend-point { fill: #ffffff; stroke: #0f172a; stroke-width: 2; }
.trend-point:focus { outline: none; stroke: #2563eb; stroke-width: 5; }
.trend-axis-label { fill: #475569; font-size: 11px; }
.trend-disclosure { color: var(--muted); font-size: 13px; }
.trend-data-table { margin-top: 12px; }
.trend-data-table summary { cursor: pointer; font-weight: 700; }
@media (max-width: 900px) {
.trend-controls form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.trend-ranges { grid-column: 1 / -1; }
}
@media (max-width: 560px) {
.trend-controls form { grid-template-columns: 1fr; }
.trend-ranges { grid-column: auto; grid-auto-flow: row; }
.trend-chart { min-height: 180px; }
.trend-chart svg { min-height: 180px; }
}
@media (prefers-reduced-motion: reduce) {
.trend-line, .trend-point { transition: none; animation: none; }
}
@@ -0,0 +1,344 @@
(function () {
"use strict";
if (document.body.dataset.view !== "analytics") return;
const canViewLocations = document.body.dataset.canViewLocations === "true";
const allowedScopes = new Set(["fleet", "site", "room", "miner"]);
const allowedRanges = new Set(["6h", "24h", "7d"]);
const params = new URLSearchParams(window.location.search);
const initialScope = allowedScopes.has(params.get("scope"))
? params.get("scope") : "fleet";
const state = {
scope: initialScope,
scopeId: params.get("scope_id") || "",
minerId: initialScope === "miner" ? (params.get("scope_id") || "") : "",
range: allowedRanges.has(params.get("range")) ? params.get("range") : "24h",
available: { sites: [], rooms: [] },
controller: null
};
const el = {
form: document.getElementById("trendControls"),
scope: document.getElementById("trendScope"),
site: document.getElementById("trendSite"),
siteLabel: document.getElementById("trendSiteLabel"),
room: document.getElementById("trendRoom"),
roomLabel: document.getElementById("trendRoomLabel"),
loading: document.getElementById("trendLoading"),
error: document.getElementById("trendError"),
errorText: document.getElementById("trendErrorText"),
retry: document.getElementById("trendRetry"),
content: document.getElementById("trendContent"),
freshness: document.getElementById("trendFreshness"),
scopeNote: document.getElementById("trendScopeNote"),
scopeLabel: document.getElementById("trendScopeLabel"),
rangeSummary: document.getElementById("trendRangeSummary"),
timezone: document.getElementById("trendTimezone"),
charts: document.getElementById("trendCharts")
};
function create(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function svgNode(tag, attributes) {
const node = document.createElementNS("http://www.w3.org/2000/svg", tag);
Object.entries(attributes || {}).forEach(function (entry) {
node.setAttribute(entry[0], String(entry[1]));
});
return node;
}
function dateTime(value) {
if (!value) return "Unavailable";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "Unavailable" : date.toLocaleString();
}
function compactTime(value) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "Unavailable" : date.toLocaleString([], {
month: "short", day: "numeric", hour: "2-digit", minute: "2-digit"
});
}
function number(value, unit) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) {
return "Unavailable";
}
return Number(value).toLocaleString([], { maximumFractionDigits: 2 }) + " " + unit;
}
async function api(url, signal) {
const response = await fetch(url, {
headers: { Accept: "application/json" }, signal: signal
});
const payload = await response.json().catch(function () { return {}; });
if (!response.ok) {
const detail = payload.detail;
throw new Error(typeof detail === "string" ? detail : "Request failed (" + response.status + ")");
}
if (!payload || !payload.data || !payload.meta) {
throw new Error("The trends response was incomplete.");
}
return payload;
}
function replaceOptions(select, options, selected, label) {
select.replaceChildren();
if (!options.length) {
const empty = create("option", "", "No " + label + " available");
empty.value = "";
select.appendChild(empty);
return;
}
options.forEach(function (item) {
const option = create("option", "", item.name);
option.value = item.id;
option.selected = String(item.id) === String(selected);
select.appendChild(option);
});
}
function ensureMinerScopeOption() {
if (state.scope !== "miner" || el.scope.querySelector('option[value="miner"]')) return;
const option = create("option", "", "Miner");
option.value = "miner";
el.scope.appendChild(option);
}
function renderScopeControls() {
ensureMinerScopeOption();
el.scope.value = state.scope;
const selectedSite = state.scope === "site" ? state.scopeId : el.site.value;
let rooms = state.available.rooms || [];
if (state.scope === "room") {
const selectedRoom = rooms.find(function (room) {
return String(room.id) === String(state.scopeId);
});
if (selectedRoom) replaceOptions(el.site, state.available.sites, selectedRoom.site_id, "sites");
} else {
replaceOptions(el.site, state.available.sites, selectedSite, "sites");
}
if (el.site.value) {
rooms = rooms.filter(function (room) {
return String(room.site_id) === String(el.site.value);
});
}
replaceOptions(el.room, rooms, state.scope === "room" ? state.scopeId : "", "rooms");
el.siteLabel.hidden = !canViewLocations || !["site", "room"].includes(state.scope);
el.roomLabel.hidden = !canViewLocations || state.scope !== "room";
const checked = el.form.querySelector('input[name="trendRange"][value="' + state.range + '"]');
if (checked) checked.checked = true;
}
function pointStatus(point) {
if (point.observation_count === 0) return "No observations";
if (point.metric_sample_count === 0) return "Metric unavailable";
return point.incomplete ? "Recorded; current bucket incomplete" : "Recorded";
}
function renderTable(series, tableId) {
const details = create("details", "trend-data-table");
details.appendChild(create("summary", "", "View " + series.metric + " data table"));
const wrapper = create("div", "table-scroll");
const table = create("table");
table.id = tableId;
const caption = create("caption", "", series.label + " bucket values and coverage");
const head = create("thead");
const headRow = create("tr");
["Bucket start", "Value", "Status", "Samples", "Contributors", "Observed miners", "Scoped miners"]
.forEach(function (label) { headRow.appendChild(create("th", "", label)); });
head.appendChild(headRow);
const body = create("tbody");
series.points.forEach(function (point) {
const row = create("tr");
[dateTime(point.bucket_start), number(point.value, series.unit), pointStatus(point),
point.metric_sample_count, point.contributing_miner_count,
point.observed_miner_count, point.scoped_miner_count]
.forEach(function (value) { row.appendChild(create("td", "", String(value))); });
body.appendChild(row);
});
table.append(caption, head, body);
wrapper.appendChild(table);
details.appendChild(wrapper);
return details;
}
function renderChart(series, index) {
const panel = create("section", "panel trend-panel");
const headingId = "trendSeriesTitle" + index;
const title = create("h2", "", series.label);
title.id = headingId;
const valid = series.points.filter(function (point) {
return point.value !== null && Number.isFinite(Number(point.value));
});
const rangeCopy = valid.length
? "Observed range " + number(Math.min.apply(null, valid.map(function (p) { return Number(p.value); })), series.unit) +
" to " + number(Math.max.apply(null, valid.map(function (p) { return Number(p.value); })), series.unit)
: "No valid values in this range";
const header = create("div", "panel-head");
const heading = create("div");
heading.append(create("p", "eyebrow", series.metric === "hashrate" ? "TH/s" : "Watts"), title);
header.append(heading, create("p", "freshness", rangeCopy));
panel.appendChild(header);
const chart = create("div", "trend-chart");
if (!valid.length) {
chart.appendChild(create("p", "empty-copy", "No valid " + series.metric + " observations were recorded."));
} else {
const width = 800;
const height = 250;
const left = 72;
const right = 20;
const top = 20;
const bottom = 48;
const plotWidth = width - left - right;
const plotHeight = height - top - bottom;
let minimum = Math.min.apply(null, valid.map(function (point) { return Number(point.value); }));
let maximum = Math.max.apply(null, valid.map(function (point) { return Number(point.value); }));
if (minimum === maximum) {
const padding = Math.max(Math.abs(minimum) * 0.05, 1);
minimum -= padding;
maximum += padding;
}
const svg = svgNode("svg", {
viewBox: "0 0 " + width + " " + height,
role: "img",
"aria-labelledby": headingId + " chartDescription" + index
});
const description = svgNode("desc", { id: "chartDescription" + index });
description.textContent = series.label + " in " + series.unit + ". Gaps represent missing values.";
svg.appendChild(description);
[0, 0.5, 1].forEach(function (ratio) {
const y = top + ratio * plotHeight;
svg.appendChild(svgNode("line", { x1: left, x2: width - right, y1: y, y2: y, class: "trend-grid" }));
const label = svgNode("text", { x: left - 8, y: y + 4, "text-anchor": "end", class: "trend-axis-label" });
label.textContent = number(maximum - ratio * (maximum - minimum), series.unit);
svg.appendChild(label);
});
let pathData = "";
series.points.forEach(function (point, pointIndex) {
const x = left + (series.points.length === 1 ? 0 : pointIndex / (series.points.length - 1)) * plotWidth;
if (point.value === null || !Number.isFinite(Number(point.value))) {
pathData += " M " + x + " " + (top + plotHeight);
return;
}
const y = top + (maximum - Number(point.value)) / (maximum - minimum) * plotHeight;
const previous = pointIndex > 0 ? series.points[pointIndex - 1] : null;
const command = previous && previous.value !== null && Number.isFinite(Number(previous.value)) ? " L " : " M ";
pathData += command + x + " " + y;
const marker = svgNode("circle", {
cx: x, cy: y, r: 3.5, class: "trend-point", tabindex: 0,
"aria-label": compactTime(point.bucket_start) + ", " + number(point.value, series.unit) +
", " + pointStatus(point)
});
const markerTitle = svgNode("title");
markerTitle.textContent = marker.getAttribute("aria-label");
marker.appendChild(markerTitle);
svg.appendChild(marker);
});
const path = svgNode("path", { d: pathData, class: "trend-line" });
svg.insertBefore(path, svg.querySelector("circle"));
const startLabel = svgNode("text", { x: left, y: height - 12, class: "trend-axis-label" });
startLabel.textContent = compactTime(series.points[0].bucket_start);
const endLabel = svgNode("text", { x: width - right, y: height - 12, "text-anchor": "end", class: "trend-axis-label" });
endLabel.textContent = compactTime(series.points[series.points.length - 1].bucket_start);
svg.append(startLabel, endLabel);
chart.appendChild(svg);
}
panel.append(chart, create("p", "trend-disclosure", "Gaps indicate no valid metric value. Recorded zero remains zero; values are not interpolated."),
renderTable(series, "trendTable" + index));
return panel;
}
function render(payload) {
state.available = payload.data.available_scopes || { sites: [], rooms: [] };
renderScopeControls();
el.scopeLabel.textContent = payload.data.scope.label;
el.rangeSummary.textContent = payload.meta.range + " range / " +
Math.round(payload.meta.bucket_seconds / 60) + " minute buckets / raw snapshots";
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || "browser local time";
el.timezone.textContent = "Times shown in " + zone + ". API timestamps use UTC.";
el.freshness.textContent = payload.meta.latest_observed_at
? "Latest observation in range: " + dateTime(payload.meta.latest_observed_at)
: "No observations in selected range";
el.scopeNote.textContent = payload.meta.attribution === "current_placement_cohort"
? "Site and room trends use miners currently assigned to this location. Historical placement is not tracked."
: payload.meta.attribution === "miner_identity"
? "Trend values follow this miner's durable UUID."
: "Fleet trends use the current miner inventory cohort.";
el.charts.replaceChildren();
payload.data.series.forEach(function (series, index) {
el.charts.appendChild(renderChart(series, index));
});
}
function selectedScopeId() {
if (state.scope === "fleet") return "";
if (state.scope === "site") return el.site.value || state.scopeId;
if (state.scope === "room") return el.room.value || state.scopeId;
return state.minerId || state.scopeId;
}
function requestUrl() {
const query = new URLSearchParams({ scope: state.scope, range: state.range });
const scopeId = selectedScopeId();
if (state.scope !== "fleet") query.set("scope_id", scopeId);
return "/api/monitoring/trends?" + query.toString();
}
async function load() {
const scopeId = selectedScopeId();
if (state.scope !== "fleet" && !scopeId) {
el.error.hidden = false;
el.errorText.textContent = "Choose a valid scope before applying filters.";
return;
}
if (state.controller) state.controller.abort();
state.controller = new AbortController();
el.loading.hidden = false;
el.error.hidden = true;
try {
const payload = await api(requestUrl(), state.controller.signal);
state.scopeId = payload.data.scope.id || "";
if (state.scope === "miner") state.minerId = state.scopeId;
render(payload);
const url = new URL(window.location.href);
url.search = new URL(requestUrl(), window.location.origin).search;
window.history.replaceState({}, "", url.pathname + url.search);
el.content.hidden = false;
} catch (error) {
if (error.name === "AbortError") return;
el.errorText.textContent = error.message;
el.error.hidden = false;
} finally {
el.loading.hidden = true;
}
}
el.scope.addEventListener("change", function () {
state.scope = el.scope.value;
if (state.scope !== "miner") state.scopeId = "";
renderScopeControls();
});
el.site.addEventListener("change", function () {
if (state.scope === "site") state.scopeId = el.site.value;
if (state.scope === "room") state.scopeId = "";
renderScopeControls();
});
el.form.addEventListener("submit", function (event) {
event.preventDefault();
state.scope = el.scope.value;
const selectedRange = el.form.querySelector('input[name="trendRange"]:checked');
state.range = selectedRange ? selectedRange.value : "24h";
state.scopeId = selectedScopeId();
load();
});
el.retry.addEventListener("click", load);
renderScopeControls();
load();
})();
@@ -0,0 +1,612 @@
(function () {
"use strict";
const optionalFieldIds = ["repairModel", "repairSite", "repairRoom"];
const ids = [
"repairFilters", "repairQuery", "repairScope", "repairStatus",
"repairLifecycle", "repairReset", "repairRows",
"repairTotal", "repairActive", "repairHistorical", "repairFreshness",
"repairResultSummary", "repairPrevious", "repairNext", "repairLimit",
"repairInspector", "repairInspectorTitle", "repairInspectorBody",
"repairInspectorClose"
];
const el = {};
for (const id of ids) el[id] = document.getElementById(id);
for (const id of optionalFieldIds) el[id] = document.getElementById(id);
if (!el.repairFilters || !el.repairRows) return;
const STATUS_LABELS = {
OPEN: "Open", IN_PROGRESS: "In progress",
WAITING_FOR_PARTS: "Waiting for parts", READY_FOR_TEST: "Ready for test",
COMPLETED: "Completed", CANCELLED: "Cancelled",
open: "Open", diagnosing: "Diagnosing", waiting_parts: "Waiting parts",
in_progress: "In progress", testing: "Testing",
ready_to_shelf: "Ready to shelf", needs_followup: "Needs follow-up",
completed: "Completed", failed: "Failed", cancelled: "Cancelled",
repair: "In repair", unknown: "Unknown"
};
const state = { offset: 0, limit: 50, items: new Map(), rooms: [], opener: null };
let controller = null;
function text(value, fallback) {
if (value === null || value === undefined || String(value).trim() === "") {
return fallback || "Not available";
}
return String(value);
}
function create(tag, className, value) {
const node = document.createElement(tag);
if (className) node.className = className;
if (value !== undefined) node.textContent = value;
return node;
}
function button(label, action, className) {
const node = create("button", className || "quiet-button", label);
node.type = "button";
node.dataset.action = action;
return node;
}
function showState(name, message) {
for (const node of document.querySelectorAll("[data-view-state]")) {
node.hidden = node.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;
}
function friendlyStatus(value) {
return STATUS_LABELS[value] || text(value, "Unknown").replaceAll("_", " ");
}
function dateTime(value) {
if (!value) return "Not available";
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf()) ? String(value) : parsed.toLocaleString();
}
function age(value) {
if (!value) return "Age unavailable";
const milliseconds = Date.now() - new Date(value).valueOf();
if (!Number.isFinite(milliseconds) || milliseconds < 0) return "Age unavailable";
const hours = Math.floor(milliseconds / 3600000);
if (hours < 24) return hours + "h open";
return Math.floor(hours / 24) + "d open";
}
function labelPart(prefix, value) {
if (!value) return null;
const clean = String(value).trim();
return clean.toLowerCase().startsWith(prefix.toLowerCase() + " ")
? clean : prefix + " " + clean;
}
function locationText(item) {
const parts = [item.site_name, item.room_name,
labelPart("Rack", item.rack_label), labelPart("Shelf", item.shelf_label),
item.position_label || (item.position_index !== null ? "Position " + item.position_index : null)];
return parts.filter(Boolean).join(" / ") || "Unplaced";
}
async function api(path, options) {
const response = await fetch(path, Object.assign({
credentials: "same-origin", headers: { Accept: "application/json" }
}, options || {}));
if (response.status === 401) {
window.location.replace("/login?next=" + encodeURIComponent(
window.location.pathname + window.location.search));
throw new Error("Your session expired.");
}
const payload = await response.json().catch(function () { return null; });
if (!response.ok) {
const error = new Error(payload && payload.detail
? String(payload.detail) : "The repair request failed.");
error.permissionDenied = response.status === 403;
throw error;
}
return payload;
}
function jsonOptions(method, body) {
return { method: method, headers: { Accept: "application/json",
"Content-Type": "application/json" }, body: JSON.stringify(body) };
}
function readUrl() {
const params = new URLSearchParams(window.location.search);
el.repairQuery.value = params.get("query") || "";
el.repairScope.value = params.get("scope") || "active";
el.repairStatus.value = params.get("status") || "";
if (el.repairModel) el.repairModel.value = params.get("model") || "";
el.repairLifecycle.value = params.get("lifecycle_status") || "";
if (el.repairSite) el.repairSite.value = params.get("site_id") || "";
state.pendingRoom = params.get("room_id") || "";
state.offset = Math.max(0, Number(params.get("offset")) || 0);
state.limit = [25, 50, 100, 250].includes(Number(params.get("limit")))
? Number(params.get("limit")) : 50;
el.repairLimit.value = String(state.limit);
}
function currentParams(includePaging) {
const params = new URLSearchParams();
const values = {
query: el.repairQuery.value.trim(), scope: el.repairScope.value,
status: el.repairStatus.value,
model: el.repairModel ? el.repairModel.value.trim() : "",
lifecycle_status: el.repairLifecycle.value,
site_id: el.repairSite ? el.repairSite.value : "",
room_id: el.repairRoom ? el.repairRoom.value : ""
};
for (const [key, value] of Object.entries(values)) {
if (value && !(key === "scope" && value === "active")) params.set(key, value);
}
if (includePaging) {
params.set("limit", String(state.limit));
if (state.offset) params.set("offset", String(state.offset));
}
return params;
}
function syncUrl(mode) {
const query = currentParams(true).toString();
const target = window.location.pathname + (query ? "?" + query : "");
window.history[mode === "replace" ? "replaceState" : "pushState"]({}, "", target);
}
function syncLocationOptions(options) {
if (!el.repairSite || !el.repairRoom) return;
const selectedSite = el.repairSite.value;
const selectedRoom = state.pendingRoom || el.repairRoom.value;
el.repairSite.replaceChildren(new Option("All sites", ""));
for (const site of options.sites || []) {
el.repairSite.appendChild(new Option(site.name, site.id));
}
if ([...el.repairSite.options].some(function (option) {
return option.value === selectedSite;
})) el.repairSite.value = selectedSite;
state.rooms = options.rooms || [];
el.repairRoom.replaceChildren(new Option("All rooms", ""));
for (const room of state.rooms) {
if (!el.repairSite.value || String(room.site_id) === el.repairSite.value) {
el.repairRoom.appendChild(new Option(room.name, room.id));
}
}
if ([...el.repairRoom.options].some(function (option) {
return option.value === selectedRoom;
})) el.repairRoom.value = selectedRoom;
state.pendingRoom = "";
}
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 identityCell(item) {
const wrap = create("div", "repair-miner");
const label = item.asset_id || item.current_mac || "Miner " + item.miner_id.slice(0, 8);
if (item.capabilities.can_open_miner) {
const link = create("a", "object-link", label);
const returnTo = window.location.pathname + window.location.search;
const path = "/miners/" + encodeURIComponent(item.miner_id) + "/detail";
link.href = path + "?return_to=" + encodeURIComponent(returnTo);
wrap.appendChild(link);
} else wrap.appendChild(create("strong", "", label));
const details = [item.model, item.miner_sn, item.current_mac, item.current_ip]
.filter(Boolean).join(" / ");
if (details) wrap.appendChild(create("span", "secondary", details));
wrap.appendChild(create("span", "source-label", item.source === "modular"
? "Repair domain" : "Legacy compatibility"));
return wrap;
}
function actionCell(item) {
const wrap = create("div", "repair-row-actions");
if (item.capabilities.can_open_map) {
const params = new URLSearchParams({ site_id: item.site_id,
room_id: item.room_id, miner_id: item.miner_id });
const link = create("a", "quiet-link", "Mine Map");
link.href = "/monitoring/map?" + params.toString();
wrap.appendChild(link);
}
if (Object.values(item.capabilities).some(Boolean)) {
const details = button("Details", "details");
details.dataset.key = item.source + ":" + (item.repair_id || item.miner_id);
wrap.appendChild(details);
}
return wrap;
}
function renderRows(items) {
el.repairRows.replaceChildren();
state.items.clear();
for (const item of items) {
const key = item.source + ":" + (item.repair_id || item.miner_id);
state.items.set(key, item);
const row = create("tr", "repair-row");
row.dataset.source = item.source;
row.dataset.active = String(item.active);
addCell(row, "Miner", identityCell(item));
const status = create("span", "repair-status", friendlyStatus(item.repair_status));
status.dataset.status = String(item.repair_status).toLowerCase();
const stateWrap = create("div");
stateWrap.append(status, create("span", "secondary", text(item.lifecycle_status)));
addCell(row, "Repair state", stateWrap);
addCell(row, "Reported problem", item.reported_problem || item.reason);
addCell(row, "Latest context", item.latest_context);
addCell(row, "Location", locationText(item));
const opened = create("div");
opened.append(create("span", "", dateTime(item.opened_at)),
create("span", "secondary", item.active ? age(item.opened_at) : "Historical"));
addCell(row, "Opened / age", opened);
addCell(row, "Parts", item.parts_count === null
? "Legacy record" : item.parts_count + " recorded");
addCell(row, "Actions", actionCell(item));
el.repairRows.appendChild(row);
}
}
function section(title) {
const node = create("section", "repair-detail-section");
node.appendChild(create("h3", "", title));
return node;
}
function renderTimeline(events, legacy) {
const node = section(legacy ? "Legacy repair history" : "Repair timeline");
if (!events.length) {
node.appendChild(create("p", "secondary", "No history entries recorded."));
return node;
}
const list = create("ol", "repair-timeline");
for (const event of events) {
const entry = create("li");
const title = legacy
? text(event.event_title, event.event_type)
: friendlyStatus(event.event_type);
entry.append(create("strong", "", title),
create("span", "secondary", dateTime(event.occurred_at || event.event_at || event.created_at)));
const payload = legacy ? event.description : Object.entries(event.payload || {})
.map(function (pair) { return pair[0].replaceAll("_", " ") + ": " + pair[1]; })
.join(" / ");
if (payload) entry.appendChild(create("p", "", String(payload)));
list.appendChild(entry);
}
node.appendChild(list);
return node;
}
function partLabel(part) {
if (part.consumable) {
return part.consumable.quantity + " " + part.consumable.unit + " " +
part.consumable.description;
}
const snapshot = part.component_snapshot || {};
return [snapshot.component_type, snapshot.asset_id,
snapshot.manufacturer_serial].filter(Boolean).join(" / ") || "Tracked component";
}
function renderParts(item, parts) {
const node = section("Parts used");
if (!parts.length) node.appendChild(create("p", "secondary", "No parts recorded."));
const list = create("ul", "repair-parts-list");
for (const part of parts) {
const entry = create("li");
entry.appendChild(create("span", part.voided_at ? "voided-part" : "", partLabel(part)));
if (part.notes) entry.appendChild(create("span", "secondary", part.notes));
if (part.voided_at) {
entry.appendChild(create("span", "secondary", "Voided: " + text(part.void_reason)));
} else if (item.capabilities.can_manage_modular_parts) {
const voidButton = button("Void entry", "void-part", "danger-button");
voidButton.dataset.partId = part.repair_part_id;
entry.appendChild(voidButton);
}
list.appendChild(entry);
}
node.appendChild(list);
if (item.capabilities.can_manage_modular_parts) {
const form = create("form", "repair-action-form");
form.dataset.action = "record-part";
const kind = create("select");
kind.name = "kind";
kind.append(new Option("Consumable", "consumable"),
new Option("Tracked component", "tracked_component"));
const description = create("input");
description.name = "description"; description.placeholder = "Consumable description";
const quantity = create("input");
quantity.name = "quantity"; quantity.type = "number";
quantity.min = "0.001"; quantity.step = "0.001"; quantity.value = "1";
const unit = create("select"); unit.name = "unit";
for (const value of ["each", "gram", "millilitre", "metre", "centimetre"]) {
unit.appendChild(new Option(value, value));
}
const component = create("input");
component.name = "component"; component.placeholder = "Tracked component UUID";
const notes = create("input"); notes.name = "notes"; notes.placeholder = "Optional notes";
form.append(kind, description, quantity, unit, component, notes,
create("button", "command-button", "Record part"));
form.lastChild.type = "submit";
node.appendChild(form);
}
return node;
}
function modularActions(item) {
const node = section("Next action");
if (item.capabilities.can_complete_modular_repair) {
const form = create("form", "repair-action-form");
form.dataset.action = "complete-repair";
const target = create("select"); target.name = "target";
target.append(new Option("Return active", "active"), new Option("Return as spare", "spare"));
const summary = create("textarea"); summary.name = "summary";
summary.placeholder = "Optional completion summary";
const submit = create("button", "command-button", "Complete repair");
submit.type = "submit";
form.append(target, summary, submit);
node.appendChild(form);
} else node.appendChild(create("p", "secondary", "No repair action is available."));
return node;
}
function legacyActions(item) {
const node = section("Legacy actions");
if (!item.capabilities.can_manage_legacy) {
node.appendChild(create("p", "secondary", "Legacy repair actions are read-only."));
return node;
}
const form = create("form", "repair-action-form");
form.dataset.action = "legacy-update";
const status = create("select"); status.name = "status";
for (const value of ["open", "diagnosing", "waiting_parts", "in_progress",
"testing", "ready_to_shelf", "needs_followup", "completed", "failed", "cancelled"]) {
const option = new Option(friendlyStatus(value), value);
option.selected = value === item.repair_status;
status.appendChild(option);
}
const note = create("textarea"); note.name = "note"; note.placeholder = "Repair note";
const noteButton = create("button", "quiet-button", "Add note");
noteButton.type = "submit"; noteButton.value = "note"; noteButton.name = "intent";
const statusButton = create("button", "quiet-button", "Save status");
statusButton.type = "submit"; statusButton.value = "status"; statusButton.name = "intent";
form.append(status, note, noteButton, statusButton);
node.append(form, button("Mark repaired", "legacy-repaired", "command-button"),
button("Decommission", "legacy-decommission", "danger-button"));
return node;
}
function closeInspector() {
el.repairInspector.hidden = true;
el.repairInspectorBody.replaceChildren();
if (state.opener) state.opener.focus();
}
async function openInspector(item, opener) {
state.currentItem = item;
state.opener = opener;
el.repairInspector.hidden = false;
el.repairInspectorTitle.textContent = item.asset_id || item.current_mac || "Repair details";
el.repairInspectorBody.replaceChildren();
const overview = section("Repair overview");
overview.append(
create("p", "", friendlyStatus(item.repair_status) + " / " + text(item.lifecycle_status)),
create("p", "", text(item.reported_problem, "No reported problem")),
create("p", "secondary", locationText(item)),
create("p", "secondary", "Opened " + dateTime(item.opened_at))
);
el.repairInspectorBody.appendChild(overview);
el.repairInspectorClose.focus();
try {
if (item.source === "modular") {
const canRead = item.capabilities.can_read_modular_timeline;
const requests = canRead ? [
api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/events"),
api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/parts")
] : [];
const results = await Promise.all(requests);
if (canRead) {
el.repairInspectorBody.append(
renderTimeline(results[0].data.events || [], false),
renderParts(item, results[1].data.entries || [])
);
}
el.repairInspectorBody.appendChild(modularActions(item));
} else {
if (item.capabilities.can_read_legacy_history) {
const history = await api("/api/miners/" +
encodeURIComponent(item.miner_id) + "/maintenance-events");
const events = (history.data || []).filter(function (event) {
return String(event.event_type || "").toLowerCase() === "repair";
});
el.repairInspectorBody.appendChild(renderTimeline(events, true));
}
el.repairInspectorBody.appendChild(legacyActions(item));
}
} catch (error) {
el.repairInspectorBody.appendChild(create("p", "state-inline-error", error.message));
}
}
function selectedItem(target) {
const keyNode = target.closest("[data-key]");
return keyNode ? state.items.get(keyNode.dataset.key) : null;
}
async function reloadAfterMutation(message) {
closeInspector();
await load();
el.repairResultSummary.textContent = message + " " + el.repairResultSummary.textContent;
}
async function handleInspectorSubmit(event) {
event.preventDefault();
const form = event.target;
const item = state.currentItem;
if (!item) return;
const data = new FormData(form);
try {
if (form.dataset.action === "complete-repair") {
if (!window.confirm("Complete this repair and change the miner lifecycle?")) return;
await api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/complete",
jsonOptions("POST", { target_lifecycle: data.get("target"),
completion_summary: String(data.get("summary") || "").trim() || null }));
await reloadAfterMutation("Repair completed.");
} else if (form.dataset.action === "record-part") {
const kind = data.get("kind");
const body = { notes: String(data.get("notes") || "").trim() || null };
if (kind === "consumable") {
body.consumable = { description: String(data.get("description") || "").trim(),
quantity: String(data.get("quantity") || ""), unit: data.get("unit") };
} else body.tracked_component_uuid = String(data.get("component") || "").trim();
await api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/parts",
jsonOptions("POST", body));
await openInspector(item, state.opener);
} else if (form.dataset.action === "legacy-update") {
const intent = event.submitter && event.submitter.value;
const note = String(data.get("note") || "").trim();
const path = "/api/repairs/" + encodeURIComponent(item.miner_id) +
(intent === "note" ? "/note" : "/status");
const body = intent === "note"
? { note: note, event_status: data.get("status") }
: { event_status: data.get("status"), note: note || null };
await api(path, jsonOptions("POST", body));
await reloadAfterMutation(intent === "note" ? "Repair note added." : "Repair status updated.");
}
} catch (error) {
form.prepend(create("p", "state-inline-error", error.message));
}
}
async function handleInspectorClick(event) {
const action = event.target.closest("[data-action]");
const item = state.currentItem;
if (!action || !item) return;
try {
if (action.dataset.action === "void-part") {
const reason = window.prompt("Reason for voiding this repair-part entry:", "");
if (!reason || !reason.trim() || !window.confirm("Void this repair-part entry? Its history will remain recorded.")) return;
await api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/parts/" +
encodeURIComponent(action.dataset.partId) + "/void",
jsonOptions("POST", { reason: reason.trim() }));
await openInspector(item, state.opener);
} else if (action.dataset.action === "legacy-repaired") {
if (!window.confirm("Mark this miner repaired and move it to Waiting Placement?")) return;
await api("/api/repairs/" + encodeURIComponent(item.miner_id) + "/state",
jsonOptions("POST", { asset_state: "waiting_placement",
note: "Repair marked complete. Miner is waiting for placement." }));
await reloadAfterMutation("Miner moved to Waiting Placement.");
} else if (action.dataset.action === "legacy-decommission") {
const typed = window.prompt("Type DECOMMISSION to confirm:", "");
if (typed !== "DECOMMISSION") return;
await api("/api/repairs/" + encodeURIComponent(item.miner_id) + "/state",
jsonOptions("POST", { asset_state: "decommissioned",
note: "Miner decommissioned from Repair Center." }));
await reloadAfterMutation("Miner decommissioned.");
}
} catch (error) {
el.repairInspectorBody.prepend(create("p", "state-inline-error", error.message));
}
}
function populateStatusOptions(counts) {
const selected = el.repairStatus.value;
const values = Object.keys(counts || {}).sort(function (left, right) {
return friendlyStatus(left).localeCompare(friendlyStatus(right));
});
el.repairStatus.replaceChildren(new Option("All statuses", ""));
for (const value of values) {
el.repairStatus.appendChild(new Option(
friendlyStatus(value) + " (" + counts[value] + ")", value));
}
if ([...el.repairStatus.options].some(function (option) {
return option.value === selected;
})) el.repairStatus.value = selected;
}
async function load() {
if (controller) controller.abort();
controller = new AbortController();
showState("loading");
el.repairFreshness.textContent = "Loading repair work";
try {
const params = currentParams(true);
const envelope = await api("/api/repairs/queue?" + params.toString(),
{ signal: controller.signal });
if (!envelope || !envelope.data || !Array.isArray(envelope.data.items) ||
!envelope.data.summary || !envelope.meta) {
throw new Error("MinerOps returned an invalid Repair Center response.");
}
const items = envelope.data.items;
if (!items.every(function (item) {
return item && item.miner_id && item.source && item.capabilities;
})) throw new Error("MinerOps returned incomplete repair records.");
const summary = envelope.data.summary;
el.repairTotal.textContent = String(summary.total);
el.repairActive.textContent = String(summary.active);
el.repairHistorical.textContent = String(summary.historical);
populateStatusOptions(summary.status_counts);
syncLocationOptions(envelope.meta.location_options || {});
renderRows(items);
const start = summary.total ? state.offset + 1 : 0;
const end = state.offset + items.length;
el.repairResultSummary.textContent = "Showing " + start + "-" + end +
" of " + summary.total + " matching repairs.";
el.repairPrevious.disabled = state.offset === 0;
el.repairNext.disabled = end >= summary.total;
el.repairFreshness.textContent = "Loaded " + new Date().toLocaleTimeString();
showState(items.length ? "ready" : "empty");
} catch (error) {
if (error.name === "AbortError") return;
showState(error.permissionDenied ? "denied" : "error", error.message);
el.repairFreshness.textContent = "Repair data unavailable";
}
}
el.repairRows.addEventListener("click", function (event) {
const action = event.target.closest('[data-action="details"]');
const item = selectedItem(event.target);
if (!action || !item) return;
state.currentItem = item;
openInspector(item, action);
});
el.repairInspectorClose.addEventListener("click", closeInspector);
el.repairInspector.addEventListener("submit", handleInspectorSubmit);
el.repairInspector.addEventListener("click", handleInspectorClick);
document.addEventListener("keydown", function (event) {
if (event.key === "Escape" && !el.repairInspector.hidden) closeInspector();
});
el.repairFilters.addEventListener("submit", function (event) {
event.preventDefault(); state.offset = 0; syncUrl("push"); load();
});
el.repairReset.addEventListener("click", function () {
el.repairFilters.reset(); state.offset = 0; state.limit = 50;
state.pendingRoom = ""; el.repairLimit.value = "50";
syncUrl("push"); load();
});
if (el.repairSite) el.repairSite.addEventListener("change", function () {
state.pendingRoom = "";
syncLocationOptions({ sites: [...el.repairSite.options].slice(1).map(function (option) {
return { id: option.value, name: option.textContent };
}), rooms: state.rooms });
});
el.repairPrevious.addEventListener("click", function () {
state.offset = Math.max(0, state.offset - state.limit); syncUrl("push"); load();
});
el.repairNext.addEventListener("click", function () {
state.offset += state.limit; syncUrl("push"); load();
});
el.repairLimit.addEventListener("change", function () {
state.limit = Number(el.repairLimit.value); state.offset = 0; syncUrl("push"); load();
});
window.addEventListener("popstate", function () { readUrl(); load(); });
readUrl();
syncUrl("replace");
load();
})();
@@ -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>
"""
+572
View File
@@ -0,0 +1,572 @@
"""Shared semantic HTML for the MinerOps Internal Alpha shell."""
from html import escape
from typing import Callable
from app.frontend.miner_detail_view import miner_detail_content
from app.frontend.monitoring_trends_view import monitoring_trends_content
from app.frontend.system_health_view import system_health_content
NAVIGATION = (
("dashboard", "Dashboard", "/dashboard", "dashboard.view"),
("map", "Mine Map", "/monitoring/map", "locations.view"),
("analytics", "Trends", "/monitoring/analytics", "miners.view"),
("repairs", "Repair Center", "/operations/repairs", "repairs.view"),
("miners", "Miners", "/assets/miners", "miners.view"),
("system_health", "System Health", "/system-health", "system_health.view"),
)
PAGE_DETAILS = {
"dashboard": (
"Operational 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.",
),
"analytics": (
"Monitoring / Analytics",
"Trends",
"Review bounded historical hashrate and power observations.",
),
"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.",
),
"miner_detail": (
"Assets / Miners",
"Miner Detail",
"Current identity, operating facts, location, and authorized history.",
),
"system_health": (
"Administration / Diagnostics",
"System Health",
"Inspect application services, operational data, and maintenance facts.",
),
}
def _dashboard_content(
can_view_miners: bool,
can_view_repairs: bool,
can_view_locations: bool,
) -> str:
fleet = """
<section class="dashboard-section dashboard-fleet" aria-labelledby="fleetOverviewTitle">
<div class="dashboard-section-head"><div><p class="eyebrow">Assets</p>
<h2 id="fleetOverviewTitle">Fleet overview</h2></div>
<a class="object-link" href="/assets/miners">Open Fleet</a></div>
<div class="dashboard-total"><span>Total miners</span>
<strong id="dashboardFleetTotal">-</strong></div>
<div class="dashboard-dimension-grid">
<section aria-labelledby="runtimeTitle"><h3 id="runtimeTitle">Runtime</h3>
<dl class="dashboard-count-list">
<div><dt>Online</dt><dd id="dashboardRuntimeOnline">-</dd></div>
<div><dt>Offline</dt><dd id="dashboardRuntimeOffline">-</dd></div>
<div><dt>Unknown</dt><dd id="dashboardRuntimeUnknown">-</dd></div>
</dl></section>
<section aria-labelledby="lifecycleTitle"><h3 id="lifecycleTitle">Lifecycle</h3>
<dl class="dashboard-count-list">
<div><dt>In repair</dt><dd id="dashboardLifecycleRepair">-</dd></div>
<div><dt>Spare</dt><dd id="dashboardLifecycleSpare">-</dd></div>
<div><dt>Retired</dt><dd id="dashboardLifecycleRetired">-</dd></div>
</dl></section>
</div>
<p class="dashboard-boundary">Runtime and lifecycle are separate dimensions and may overlap.</p>
</section>
<section class="dashboard-section dashboard-attention" aria-labelledby="attentionTitle">
<div class="dashboard-section-head"><div><p class="eyebrow">Current state</p>
<h2 id="attentionTitle">Needs attention</h2></div></div>
<nav class="attention-list" aria-label="Fleet conditions requiring attention">
<a href="/assets/miners?operational_state=offline" data-state="offline">
<span>Offline</span><strong id="dashboardAttentionOffline">-</strong></a>
<a href="/assets/miners?operational_state=unknown" data-state="unknown">
<span>Unknown</span><strong id="dashboardAttentionUnknown">-</strong></a>
<a href="/assets/miners?operational_state=unstable" data-state="unstable">
<span>Hash unstable</span><strong id="dashboardAttentionUnstable">-</strong></a>
<a href="/assets/miners?operational_state=repair" data-state="repair">
<span>In repair</span><strong id="dashboardAttentionRepair">-</strong></a>
</nav>
</section>
""" if can_view_miners else ""
repairs = """
<section class="dashboard-section" aria-labelledby="dashboardRepairsTitle">
<div class="dashboard-section-head"><div><p class="eyebrow">Operations</p>
<h2 id="dashboardRepairsTitle">Repairs</h2></div>
<a class="object-link" href="/operations/repairs?scope=active">Open Repair Center</a></div>
<div class="dashboard-total"><span>Active repair records</span>
<strong id="dashboardActiveRepairs">-</strong></div>
<dl class="dashboard-status-list" id="dashboardRepairStatuses"></dl>
</section>
""" if can_view_repairs else ""
mapped_metrics = """
<div><dt>Mapped miners</dt><dd id="dashboardMappedMiners">-</dd></div>
<div><dt>Unmapped miners</dt><dd id="dashboardUnmappedMiners">-</dd></div>
""" if can_view_miners else ""
locations = f"""
<section class="dashboard-section" aria-labelledby="dashboardLocationsTitle">
<div class="dashboard-section-head"><div><p class="eyebrow">Spatial operations</p>
<h2 id="dashboardLocationsTitle">Locations</h2></div>
<a class="object-link" href="/monitoring/map">Open Mine Map</a></div>
<dl class="dashboard-count-list dashboard-location-counts">
<div><dt>Sites</dt><dd id="dashboardSites">-</dd></div>
<div><dt>Rooms</dt><dd id="dashboardRooms">-</dd></div>
<div><dt>Positions</dt><dd id="dashboardPositions">-</dd></div>
<div><dt>Occupied</dt><dd id="dashboardOccupied">-</dd></div>
<div><dt>Empty</dt><dd id="dashboardEmptyPositions">-</dd></div>
{mapped_metrics}
</dl>
</section>
""" if can_view_locations else ""
available = sum((can_view_miners, can_view_repairs, can_view_locations))
partial = (
'<p class="dashboard-permission-note">This overview includes only the '
'operational areas available to your account.</p>'
if 0 < available < 3 else ""
)
return f"""
<div class="state-message" id="dashboardLoading" role="status">
<strong>Loading operational overview</strong>
<span>Requesting current fleet, repair, and location summaries.</span>
</div>
<div class="state-message" id="dashboardError" data-kind="error" role="alert" hidden>
<strong>Dashboard unavailable</strong>
<span id="dashboardErrorDetail">The operational overview could not be loaded.</span>
<button class="command-button" id="dashboardRetry" type="button">Retry</button>
</div>
<div class="state-message" id="dashboardEmpty" hidden>
<strong>No operational sections available</strong>
<span>Your account can open Dashboard but has no additional overview permissions.</span>
</div>
<div id="dashboardContent" hidden>
<div class="dashboard-status-band">
<p id="dashboardLoadStatus" class="freshness" aria-live="polite">Waiting for current summaries</p>
{partial}
</div>
<div class="dashboard-grid">{fleet}{repairs}{locations}</div>
</div>
"""
def _map_content(can_manage_locations: bool = False) -> str:
manage_link = (
'<a class="object-link" href="/locations">Manage locations</a>'
if can_manage_locations
else ""
)
return f"""
<section class="map-context" aria-labelledby="mapControlsTitle">
<div class="map-context-head">
<div>
<h2 id="mapControlsTitle">Map context</h2>
<p id="mapScope" class="panel-copy">Select a site and room.</p>
</div>
{manage_link}
</div>
<div class="map-control-grid">
<div class="field"><label for="mapSite">Site</label>
<select id="mapSite" aria-describedby="mapScope"><option>Loading sites...</option></select></div>
<div class="field"><label for="mapRoom">Room</label>
<select id="mapRoom"><option>Loading rooms...</option></select></div>
<div class="field"><label for="mapLayer">Layer</label>
<select id="mapLayer">
<option value="status">Operational Status</option>
<option value="temperature">Temperature</option>
<option value="hashrate">Hashrate</option>
<option value="power">Power</option>
<option value="repair">Repair State</option>
</select></div>
<div class="field map-search"><label for="mapSearch">Find miner</label>
<input id="mapSearch" type="search" autocomplete="off"
placeholder="Asset ID, serial, MAC, or IP"></div>
<label class="map-problems-control" for="mapProblemsOnly">
<input id="mapProblemsOnly" type="checkbox">
<span>Problems only</span>
</label>
</div>
<details class="map-filters" id="mapFilters">
<summary>Filters <span id="mapFilterCount" aria-live="polite"></span></summary>
<div class="map-filter-grid">
<div class="field"><label for="mapLifecycle">Lifecycle</label>
<select id="mapLifecycle"><option value="">All lifecycle states</option></select></div>
<div class="field"><label for="mapModel">Model</label>
<select id="mapModel"><option value="">All models</option></select></div>
<div class="field"><label for="mapRepair">Repair</label>
<select id="mapRepair"><option value="">All repair states</option><option value="active">Active repair</option><option value="none">No active repair</option><option value="legacy">Legacy repair location</option></select></div>
<div class="field"><label for="mapStatus">Operational status</label>
<select id="mapStatus"><option value="">All operational states</option></select></div>
<button id="mapClearFilters" class="quiet-button" type="button">Clear filters</button>
</div>
</details>
</section>
<section class="map-status-band" aria-label="Map summary">
<div id="mapSummary" class="map-summary" aria-live="polite"></div>
<div id="mapFreshness" class="freshness">Waiting for map data</div>
</section>
<div id="mapCriticalState" class="state-message" data-kind="error" role="alert" hidden>
<strong id="mapCriticalTitle">Map unavailable</strong>
<span id="mapCriticalDetail">The map could not be loaded.</span>
<button id="mapRetry" class="command-button" type="button">Retry</button>
</div>
<div id="mapLoading" class="state-message" role="status">
<strong>Loading Mine Map</strong>
<span>Requesting the current physical layout and operational observations.</span>
</div>
<section class="mine-map-workspace" id="mapWorkspace" hidden>
<div class="map-main-column">
<div id="mapLegend" class="map-legend" aria-label="Map legend"></div>
<p id="mapPermissionNote" class="map-notice" hidden>
Miner details require inventory access. Layout and occupancy remain visible.
</p>
<div id="mapNotice" class="map-notice" role="status" hidden></div>
<div id="mapViewport" class="map-viewport-v1"
aria-label="Room racks, shelves, and positions"></div>
<section id="mapLegacySection" class="unplaced-section" hidden
aria-labelledby="mapLegacyTitle">
<div class="panel-head"><div><h2 id="mapLegacyTitle">Unscoped legacy positions</h2>
<p class="panel-copy">Stored positions without a durable room relationship. They are not assigned to the selected site or room.</p></div></div>
<div id="mapLegacyPositions" class="legacy-position-list"></div>
</section>
<section id="mapUnplacedSection" class="unplaced-section" hidden
aria-labelledby="unplacedTitle">
<div class="panel-head"><div><h2 id="unplacedTitle">Unplaced and operational areas</h2>
<p class="panel-copy">Miner records without a configured rack position.</p></div></div>
<div id="mapUnplaced"></div>
</section>
</div>
<aside id="mapInspector" class="map-inspector-v1" aria-labelledby="mapInspectorTitle" hidden>
<div class="inspector-head">
<div><p class="eyebrow">Selected position</p><h2 id="mapInspectorTitle">Miner details</h2></div>
<button id="mapInspectorClose" class="icon-button" type="button" aria-label="Close miner details" title="Close miner details">X</button>
</div>
<p id="mapInspectorStatus" class="status-callout"></p>
<dl id="mapInspectorDetails" class="definition-list inspector-details"></dl>
<a id="mapOpenMiner" class="command-link" href="/assets/miners">Open Miner</a>
</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(can_view_locations: bool, can_view_miners: bool) -> str:
location_filters = """
<div class="field"><label for="repairSite">Site</label>
<select id="repairSite"><option value="">All sites</option></select></div>
<div class="field"><label for="repairRoom">Room</label>
<select id="repairRoom"><option value="">All rooms</option></select></div>
""" if can_view_locations else ""
search_placeholder = (
"Miner, model, status, reason, or context"
if can_view_miners else "Repair status, reason, or context"
)
model_filter = """
<div class="field"><label for="repairModel">Model</label>
<input id="repairModel" type="text" autocomplete="off" placeholder="Any model"></div>
""" if can_view_miners else ""
return f"""
<section class="panel repair-center" aria-labelledby="repairQueueTitle">
<div class="panel-head">
<div><h2 id="repairQueueTitle">Repair Center</h2>
<p class="panel-copy">Active work and historical modular repairs, with legacy queue records clearly identified.</p></div>
<span class="freshness" id="repairFreshness">Loading repair work</span>
</div>
<div class="repair-summary" aria-label="Repair summary">
<div><span>Total results</span><strong id="repairTotal">-</strong></div>
<div><span>Active</span><strong id="repairActive">-</strong></div>
<div><span>Historical</span><strong id="repairHistorical">-</strong></div>
</div>
<form class="repair-filters" id="repairFilters" role="search">
<div class="field repair-search"><label for="repairQuery">Search repairs</label>
<input id="repairQuery" type="search" autocomplete="off"
placeholder="{search_placeholder}"></div>
<div class="field"><label for="repairScope">Scope</label>
<select id="repairScope"><option value="active">Active</option>
<option value="historical">Historical</option><option value="all">All</option></select></div>
<div class="field"><label for="repairStatus">Repair status</label>
<select id="repairStatus"><option value="">All statuses</option></select></div>
{model_filter}
<div class="field"><label for="repairLifecycle">Miner lifecycle</label>
<select id="repairLifecycle"><option value="">All lifecycle states</option>
<option value="active">Active</option><option value="spare">Spare</option>
<option value="in_repair">In repair</option><option value="retired">Retired</option></select></div>
{location_filters}
<div class="repair-filter-actions">
<button class="command-button" type="submit">Apply</button>
<button class="quiet-button" id="repairReset" type="button">Reset</button>
</div>
</form>
{_state_messages("repair records")}
<div data-view-state="ready" hidden>
<p class="repair-result-summary" id="repairResultSummary" aria-live="polite"></p>
<div class="table-wrap repair-table-wrap">
<table class="repair-table">
<thead><tr><th>Miner</th><th>Repair state</th><th>Reported problem</th>
<th>Latest context</th><th>Location</th><th>Opened / age</th>
<th>Parts</th><th>Actions</th></tr></thead>
<tbody id="repairRows"></tbody>
</table>
</div>
</div>
<nav class="repair-pagination" aria-label="Repair result pages">
<button class="quiet-button" id="repairPrevious" type="button" disabled>Previous</button>
<label for="repairLimit">Rows per page</label>
<select id="repairLimit"><option value="25">25</option><option value="50">50</option>
<option value="100">100</option><option value="250">250</option></select>
<button class="quiet-button" id="repairNext" type="button" disabled>Next</button>
</nav>
</section>
<aside class="repair-inspector" id="repairInspector" aria-labelledby="repairInspectorTitle" hidden>
<div class="inspector-head"><div><span class="eyebrow">Repair context</span>
<h2 id="repairInspectorTitle">Repair details</h2></div>
<button class="icon-button" id="repairInspectorClose" type="button" aria-label="Close repair details">X</button></div>
<div id="repairInspectorBody"></div>
</aside>
"""
def _miners_content(can_view_locations: bool) -> str:
location_filters = """
<div class="field"><label for="fleetSite">Site</label>
<select id="fleetSite"><option value="">All sites</option></select></div>
<div class="field"><label for="fleetRoom">Room</label>
<select id="fleetRoom"><option value="">All rooms</option></select></div>
""" if can_view_locations else ""
return f"""
<section class="panel fleet-panel" aria-labelledby="minerListTitle">
<div class="panel-head">
<div><h2 id="minerListTitle">Fleet</h2>
<p class="panel-copy">Current miner identity, operating state, telemetry, and repair context.</p></div>
<span class="freshness" id="fleetFreshness">Loading current fleet</span>
</div>
<form class="fleet-filters" id="fleetFilters" role="search">
<div class="field fleet-search"><label for="fleetQuery">Search miners</label>
<input id="fleetQuery" type="search" autocomplete="off"
placeholder="Asset ID, MAC, IP, model, or serial"></div>
<div class="field"><label for="fleetLifecycle">Lifecycle</label>
<select id="fleetLifecycle"><option value="">All lifecycle states</option>
<option value="active">Active</option><option value="spare">Spare</option>
<option value="in_repair">In repair</option><option value="retired">Retired</option></select></div>
<div class="field"><label for="fleetModel">Model</label>
<input id="fleetModel" type="text" autocomplete="off" placeholder="Any model"></div>
<div class="field"><label for="fleetRuntime">Runtime</label>
<select id="fleetRuntime"><option value="">All runtime states</option>
<option value="online">Online</option><option value="offline">Offline</option>
<option value="unknown">Unknown</option></select></div>
<div class="field"><label for="fleetRepair">Repair</label>
<select id="fleetRepair"><option value="">Any repair state</option>
<option value="active">Active repair</option><option value="none">No active repair</option></select></div>
<div class="field"><label for="fleetOperational">Operational state</label>
<select id="fleetOperational"><option value="">All operational states</option>
<option value="online">Online</option><option value="offline">Offline</option>
<option value="unstable">Hash unstable</option><option value="repair">In repair</option>
<option value="spare">Spare</option><option value="retired">Retired</option>
<option value="unknown">Unknown</option></select></div>
{location_filters}
<div class="field"><label for="fleetTags">Tags</label>
<input id="fleetTags" type="text" autocomplete="off" placeholder="Comma separated"></div>
<label class="check-control" for="fleetProblems">
<input id="fleetProblems" type="checkbox"> Problems only</label>
<div class="fleet-filter-actions">
<button class="command-button" type="submit">Apply</button>
<button class="quiet-button" id="fleetReset" type="button">Reset</button>
</div>
</form>
{_state_messages("miners")}
<div data-view-state="ready" hidden>
<p class="fleet-result-summary" id="fleetResultSummary" aria-live="polite"></p>
<div class="table-wrap fleet-table-wrap">
<table class="fleet-table">
<thead><tr><th>Miner</th><th>Operational</th><th>Model / Serial</th>
<th>Network</th><th>Location</th><th>Hashrate</th><th>Power</th>
<th>Max chip temp</th><th>Lifecycle / Repair</th><th>Actions</th></tr></thead>
<tbody id="fleetRows"></tbody>
</table>
</div>
</div>
<nav class="fleet-pagination" aria-label="Fleet pages">
<button class="quiet-button" id="fleetPrevious" type="button" disabled>Previous</button>
<label for="fleetLimit">Rows per page</label>
<select id="fleetLimit"><option value="25">25</option><option value="50">50</option>
<option value="100">100</option><option value="250">250</option></select>
<button class="quiet-button" id="fleetNext" type="button" disabled>Next</button>
</nav>
</section>
"""
def render_app_page(
view: str,
current_user: dict,
has_permission: Callable[[str], bool],
*,
miner_id: str | None = None,
return_to: str | None = None,
) -> 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")
active_navigation = "miners" if view == "miner_detail" else view
navigation = "".join(
(
f'<li><a href="{href}" aria-current="page">{escape(label)}</a></li>'
if key == active_navigation
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 = (
system_health_content()
if view == "system_health"
else miner_detail_content()
if view == "miner_detail"
else monitoring_trends_content(has_permission("locations.view"))
if view == "analytics"
else _map_content(has_permission("locations.manage"))
if view == "map"
else _miners_content(has_permission("locations.view"))
if view == "miners"
else _repairs_content(
has_permission("locations.view"), has_permission("miners.view")
)
if view == "repairs"
else _dashboard_content(
has_permission("miners.view"),
has_permission("repairs.view"),
has_permission("locations.view"),
)
)
page_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"
else '<script src="/assets/minerops-monitoring-trends.js" defer></script>'
if view == "analytics"
else '<script src="/assets/minerops-mine-map.js" defer></script>'
if view == "map"
else '<script src="/assets/minerops-fleet.js" defer></script>'
if view == "miners"
else '<script src="/assets/minerops-repair-center.js" defer></script>'
if view == "repairs"
else '<script src="/assets/minerops-dashboard.js" defer></script>'
if view == "dashboard"
else ""
)
page_style = (
'<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 ""
)
object_attributes = ""
if miner_id is not None:
object_attributes += f' data-miner-id="{escape(miner_id)}"'
if return_to is not None:
object_attributes += f' data-return-to="{escape(return_to)}"'
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">
{page_style}
</head>
<body data-view="{escape(view)}"
data-can-view-miners="{str(can_view_miners).lower()}"
data-can-control-miners="{str(has_permission("miners.control")).lower()}"
data-can-view-locations="{str(has_permission("locations.view")).lower()}"{object_attributes}>
<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>
{page_script}
</body>
</html>"""
+143 -14
View File
@@ -1,7 +1,7 @@
from datetime import datetime, timedelta, timezone
from html import escape
from typing import Any, Callable
from urllib.parse import quote
from urllib.parse import quote, urlsplit
import jwt
import psycopg
@@ -633,20 +633,63 @@ def reset_password(
def protected_page(path: str) -> bool:
exact = {"/dashboard", "/repairs", "/locations", "/system-health", "/accounts"}
exact = {
"/dashboard",
"/repairs",
"/locations",
"/system-health",
"/accounts",
"/monitoring/map",
"/monitoring/analytics",
"/operations/repairs",
"/assets/miners",
}
prefixes = ("/miners/", "/agent/", "/tools/")
return path in exact or path.startswith(prefixes)
def safe_next_path(value: str | None) -> str:
"""Return a safe local application destination for login redirects."""
fallback = "/dashboard"
if not value or value != value.strip():
return fallback
if any(ord(character) < 32 or ord(character) == 127 for character in value):
return fallback
try:
target = urlsplit(value)
except ValueError:
return fallback
path = target.path
if (
target.scheme
or target.netloc
or target.fragment
or not path.startswith("/")
or path.startswith("//")
or "\\" in path
or "%" in path
or any(segment in {".", ".."} for segment in path.split("/"))
or not protected_page(path)
):
return fallback
return value
def required_page_permission(path: str) -> str:
if path == "/accounts":
return "users.manage"
if path == "/system-health":
return "system_health.view"
if path == "/repairs":
if path in {"/repairs", "/operations/repairs"}:
return "repairs.view"
if path == "/locations":
if path in {"/locations", "/monitoring/map"}:
return "locations.view"
if path == "/monitoring/analytics":
return "miners.view"
if path == "/assets/miners":
return "miners.view"
if path.startswith("/agent/"):
return "agents.view"
if path.startswith("/miners/"):
@@ -671,9 +714,88 @@ def machine_agent_request(path: str) -> bool:
return path in exact
def repair_timeline_request(path: str) -> bool:
parts = path.split("/")
if len(parts) == 4 and parts[:3] == ["", "api", "repairs"]:
return parts[3] not in {"queue", "parts-summary"}
return (
len(parts) == 5
and parts[:3] == ["", "api", "repairs"]
and parts[4] == "events"
)
def repair_completion_request(path: str) -> bool:
parts = path.split("/")
return (
len(parts) == 5
and parts[:3] == ["", "api", "repairs"]
and parts[4] == "complete"
)
def repair_parts_request(path: str) -> bool:
parts = path.split("/")
return (
len(parts) == 5
and parts[:3] == ["", "api", "repairs"]
and parts[4] == "parts"
) or (
len(parts) == 7
and parts[:3] == ["", "api", "repairs"]
and parts[4] == "parts"
and parts[6] == "void"
)
def component_request(path: str) -> bool:
parts = path.split("/")
return path == "/api/components" or (
len(parts) == 4 and parts[:3] == ["", "api", "components"]
)
def required_api_permission(method: str, path: str) -> str | None:
parts = path.split("/")
if (
len(parts) >= 5
and parts[:3] == ["", "api", "miners"]
and parts[4] == "controls"
):
if method == "GET" and len(parts) == 5:
return "miners.view"
if method == "POST" and len(parts) == 6 and parts[5] in {
"suspend", "resume", "performance-mode"
}:
return "miners.control"
if (
method == "GET"
and len(parts) == 5
and parts[:3] == ["", "api", "miners"]
and parts[4] == "maintenance-events"
):
return "repairs.read"
if component_request(path):
return "components.create" if method == "POST" else "components.read"
if repair_parts_request(path):
return "repairs.read" if method == "GET" else "repairs.parts.manage"
if path.startswith("/api/admin/"):
return "users.manage"
if method == "POST" and path.endswith("/change-mac") and path.startswith("/api/miners/"):
return "miners.replace_control_board"
if (
method == "POST"
and len(path.split("/")) == 5
and path.split("/")[:3] == ["", "api", "miners"]
and path.split("/")[4] == "replace-control-board"
):
return "miners.replace_control_board"
if method == "POST" and path == "/api/repairs":
return "repairs.create"
if method == "POST" and repair_completion_request(path):
return "repairs.complete"
if method == "GET" and repair_timeline_request(path):
return "repairs.read"
if method == "GET":
if path.startswith("/api/system/"):
return "system_health.view"
@@ -721,16 +843,19 @@ def install_identity_middleware(app) -> None:
if path == "/login":
user = request_user_or_none(request)
if user:
next_path = request.query_params.get("next") or "/dashboard"
if not next_path.startswith("/") or next_path.startswith("//"):
next_path = "/dashboard"
return RedirectResponse(next_path, status_code=303)
return RedirectResponse(
safe_next_path(request.query_params.get("next")),
status_code=303,
)
return await call_next(request)
if protected_page(path):
user = request_user_or_none(request)
if not user:
destination = "/login?next=" + quote(path, safe="/")
requested_path = path
if request.url.query:
requested_path += "?" + request.url.query
destination = "/login?next=" + quote(requested_path, safe="")
response = RedirectResponse(destination, status_code=303)
clear_session_cookie(response, request)
return response
@@ -793,7 +918,8 @@ button:disabled { opacity: .6; cursor: wait; }
<body>
<header><div class="brand">MinerOps</div></header>
<main>
<section class="login-panel" id="panel">
<section class="login-panel" id="panel"
data-next="__MINEROPS_SAFE_NEXT_PATH__">
<div class="panel-head"><h1 id="title">Sign in</h1><p class="subtitle" id="subtitle">Use your MinerOps account to continue.</p></div>
<form id="authForm">
<div class="field setup-only"><label for="displayName">Display name</label><input id="displayName" autocomplete="name" maxlength="120"></div>
@@ -815,8 +941,7 @@ button:disabled { opacity: .6; cursor: wait; }
let setupRequired = false;
function destination() {
const value = new URLSearchParams(location.search).get("next") || "/dashboard";
return value.startsWith("/") && !value.startsWith("//") ? value : "/dashboard";
return panel.dataset.next || "/dashboard";
}
function sessionHint() {
@@ -1003,8 +1128,12 @@ dialog::backdrop { background: rgba(18, 27, 35, .48); }
@router.get("/login", response_class=HTMLResponse)
def login_page():
return LOGIN_HTML
def login_page(request: Request):
next_path = escape(
safe_next_path(request.query_params.get("next")),
quote=True,
)
return LOGIN_HTML.replace("__MINEROPS_SAFE_NEXT_PATH__", next_path)
@router.get("/accounts", response_class=HTMLResponse)
+65 -2
View File
@@ -7,7 +7,7 @@ from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress
LifecycleStatus = Literal["active", "spare", "retired"]
LifecycleStatus = Literal["active", "spare", "retired", "in_repair"]
class InventoryTag(BaseModel):
@@ -17,16 +17,36 @@ class InventoryTag(BaseModel):
updated_at: datetime
class InventoryActiveRepair(BaseModel):
id: UUID
status: str
reason: str
class InventoryMinerSummary(BaseModel):
id: UUID
asset_id: str | None = None
current_mac: str | None = None
current_ip: str | None = None
model: str | None = None
miner_sn: str | None = None
lifecycle_status: LifecycleStatus
runtime_status: str
site_id: UUID | None = None
site_name: str | None = None
room_id: UUID | None = None
room_name: str | None = None
location_position_id: UUID | None = None
rack_label: str | None = None
shelf_label: str | None = None
position_label: str | None = None
position_index: int | None = None
observed_at: datetime | None = None
hashrate_ths: float | None = None
power_w: float | None = None
chip_temp_max_c: float | None = None
hash_stable: bool | None = None
active_repair: InventoryActiveRepair | None = None
tags: list[InventoryTag] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
@@ -35,7 +55,6 @@ class InventoryMinerSummary(BaseModel):
class InventoryMinerDetail(InventoryMinerSummary):
hostname: str | None = None
miner_sn: str | None = None
control_board_sn: str | None = None
psu_serial: str | None = None
firmware_version: str | None = None
@@ -99,15 +118,41 @@ class InventorySearchFilters(BaseModel):
query: str | None = None
asset_id: str | None = None
lifecycle_status: str | None = None
model: str | None = None
runtime_status: str | None = None
repair: Literal["active", "none"] | None = None
operational_state: Literal[
"online", "offline", "unknown", "unstable", "repair", "spare", "retired"
] | None = None
problems: bool = False
site_id: UUID | None = None
room_id: UUID | None = None
tags: list[str] = Field(default_factory=list)
limit: int = Field(default=100, ge=1, le=500)
offset: int = Field(default=0, ge=0)
class InventorySiteOption(BaseModel):
id: UUID
name: str
class InventoryRoomOption(BaseModel):
id: UUID
site_id: UUID
name: str
class InventoryLocationOptions(BaseModel):
sites: list[InventorySiteOption] = Field(default_factory=list)
rooms: list[InventoryRoomOption] = Field(default_factory=list)
class InventoryListMeta(BaseModel):
limit: int
offset: int
returned: int
location_options: InventoryLocationOptions = Field(default_factory=InventoryLocationOptions)
class InventoryMinerListResponse(BaseModel):
@@ -126,3 +171,21 @@ class InventoryAssetIDHistoryResponse(BaseModel):
data: list[InventoryAssetIDHistoryEntry]
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class InventoryFleetAttentionSummary(BaseModel):
offline: int
unknown: int
unstable: int
repair: int
class InventoryFleetSummary(BaseModel):
total: int
runtime_online: int
runtime_offline: int
runtime_unknown: int
lifecycle_in_repair: int
lifecycle_spare: int
lifecycle_retired: int
attention: InventoryFleetAttentionSummary
+314 -11
View File
@@ -59,6 +59,161 @@ MINER_SELECT = f"""
) tag_data ON true
"""
MINER_CURRENT_SELECT = f"""
SELECT
miners.id,
miners.site_id,
miners.room_id,
miners.asset_tag,
miners.current_mac,
host(miners.current_ip) AS current_ip,
miners.hostname,
miners.miner_sn,
miners.control_board_sn,
miners.power_sn,
miners.model,
miners.firmware_version,
miners.lifecycle_status,
miners.status,
miners.notes,
miners.first_seen_at,
miners.last_seen_at,
miners.created_at,
miners.updated_at,
tag_data.tags,
latest.observed_at,
latest.hashrate_ths,
latest.power_w,
latest.chip_temp_max_c,
latest.hash_stable
FROM miners
LEFT JOIN LATERAL (
SELECT COALESCE(
jsonb_agg(
jsonb_build_object(
'id', tags.id,
'name', tags.name,
'created_at', tags.created_at,
'updated_at', tags.updated_at
)
ORDER BY lower({TAG_TRIMMED_NAME_SQL}), tags.id
),
'[]'::jsonb
) AS tags
FROM miner_tags
JOIN tags ON tags.id = miner_tags.tag_id
WHERE miner_tags.miner_id = miners.id
) tag_data ON true
LEFT JOIN LATERAL (
SELECT snapshots.observed_at,
ROUND((snapshots.mhs_av / 1000000.0)::numeric, 2)::float
AS hashrate_ths,
ROUND(snapshots.power_w::numeric, 2)::float AS power_w,
ROUND(snapshots.chip_temp_max_c::numeric, 2)::float
AS chip_temp_max_c,
snapshots.hash_stable
FROM miner_status_snapshots AS snapshots
WHERE snapshots.miner_id = miners.id
ORDER BY snapshots.observed_at DESC, snapshots.id DESC
LIMIT 1
) latest ON true
"""
ACTIVE_REPAIR_STATUSES = (
"OPEN",
"IN_PROGRESS",
"WAITING_FOR_PARTS",
"READY_FOR_TEST",
)
OPERATIONAL_STATE_SQL = """
CASE
WHEN active_repair.id IS NOT NULL OR miners.lifecycle_status = 'in_repair'
THEN 'repair'
WHEN miners.lifecycle_status = 'retired' THEN 'retired'
WHEN miners.lifecycle_status = 'spare' THEN 'spare'
WHEN lower(trim(miners.status)) = 'offline' THEN 'offline'
WHEN latest.hash_stable = false THEN 'unstable'
WHEN lower(trim(miners.status)) = 'online' THEN 'online'
ELSE 'unknown'
END
"""
FLEET_SELECT = f"""
SELECT
miners.id, miners.asset_tag, miners.current_mac,
host(miners.current_ip) AS current_ip, miners.model, miners.miner_sn,
miners.lifecycle_status, miners.status, miners.created_at,
miners.updated_at, miners.last_seen_at, tag_data.tags,
latest.observed_at, latest.hashrate_ths, latest.power_w,
latest.chip_temp_max_c, latest.hash_stable,
active_repair.id AS active_repair_id,
active_repair.status AS active_repair_status,
active_repair.reason AS active_repair_reason,
{{location_fields}}
FROM miners
LEFT JOIN LATERAL (
SELECT COALESCE(
jsonb_agg(
jsonb_build_object(
'id', tags.id, 'name', tags.name,
'created_at', tags.created_at, 'updated_at', tags.updated_at
) ORDER BY lower({TAG_TRIMMED_NAME_SQL}), tags.id
), '[]'::jsonb
) AS tags
FROM miner_tags JOIN tags ON tags.id = miner_tags.tag_id
WHERE miner_tags.miner_id = miners.id
) tag_data ON true
LEFT JOIN LATERAL (
SELECT snapshots.observed_at,
ROUND((snapshots.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
ROUND(snapshots.power_w::numeric, 2)::float AS power_w,
ROUND(snapshots.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
snapshots.hash_stable
FROM miner_status_snapshots snapshots
WHERE snapshots.miner_id = miners.id
ORDER BY snapshots.observed_at DESC, snapshots.id DESC
LIMIT 1
) latest ON true
LEFT JOIN LATERAL (
SELECT repairs.id, repairs.status, repairs.reason
FROM repairs
WHERE repairs.miner_id = miners.id AND repairs.status = ANY(%s)
ORDER BY repairs.opened_at, repairs.id
LIMIT 1
) active_repair ON true
{{location_join}}
"""
LOCATION_FIELDS = """
mapped.site_id, mapped.site_name, mapped.room_id, mapped.room_name,
mapped.location_position_id, mapped.rack_label, mapped.shelf_label,
mapped.position_label, mapped.position_index
"""
REDACTED_LOCATION_FIELDS = """
NULL::uuid AS site_id, NULL::text AS site_name,
NULL::uuid AS room_id, NULL::text AS room_name,
NULL::uuid AS location_position_id, NULL::text AS rack_label,
NULL::text AS shelf_label, NULL::text AS position_label,
NULL::integer AS position_index
"""
LOCATION_JOIN = """
LEFT JOIN LATERAL (
SELECT sites.id AS site_id, sites.name AS site_name, rooms.id AS room_id,
COALESCE(rooms.name, lp.room_name) AS room_name,
lp.id AS location_position_id, lp.rack_name AS rack_label,
lp.shelf_name AS shelf_label, lp.position_label, lp.position_index
FROM location_positions lp
LEFT JOIN rooms ON rooms.id = lp.room_id
LEFT JOIN sites ON sites.id = rooms.site_id
WHERE lp.miner_id = miners.id
ORDER BY lp.id
LIMIT 1
) mapped ON true
"""
MINER_WRITE_COLUMNS = {
"site_id",
"room_id",
@@ -165,6 +320,17 @@ class InventoryRepository:
with connection.cursor(row_factory=dict_row) as cur:
return self._fetch_miner(cur, "miners.id = %s", (miner_id,))
def get_current_miner_by_id(self, miner_id: UUID) -> dict | None:
"""Return Inventory identity with its latest factual telemetry."""
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"{MINER_CURRENT_SELECT} WHERE miners.id = %s",
(miner_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def get_miner_by_id_for_update(self, miner_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
@@ -184,8 +350,82 @@ class InventoryRepository:
(asset_id,),
)
def list_miners(self, *, limit: int = 100, offset: int = 0) -> list[dict]:
return self.search_miners(limit=limit, offset=offset)
def list_miners(
self,
*,
limit: int = 100,
offset: int = 0,
include_location_details: bool = False,
) -> list[dict]:
return self.search_miners(
limit=limit,
offset=offset,
include_location_details=include_location_details,
)
def summarize_fleet(self) -> dict[str, Any]:
"""Return exact fleet-wide counts using Inventory state semantics."""
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
WITH fleet_state AS (
SELECT miners.status, miners.lifecycle_status,
({OPERATIONAL_STATE_SQL}) AS operational_state
FROM miners
LEFT JOIN LATERAL (
SELECT snapshots.hash_stable
FROM miner_status_snapshots AS snapshots
WHERE snapshots.miner_id = miners.id
ORDER BY snapshots.observed_at DESC, snapshots.id DESC
LIMIT 1
) AS latest ON true
LEFT JOIN LATERAL (
SELECT repairs.id
FROM repairs
WHERE repairs.miner_id = miners.id
AND repairs.status = ANY(%s)
ORDER BY repairs.opened_at, repairs.id
LIMIT 1
) AS active_repair ON true
)
SELECT count(*)::integer AS total,
count(*) FILTER (
WHERE lower(trim(status)) = 'online'
)::integer AS runtime_online,
count(*) FILTER (
WHERE lower(trim(status)) = 'offline'
)::integer AS runtime_offline,
count(*) FILTER (
WHERE lower(trim(status)) NOT IN ('online', 'offline')
OR status IS NULL
)::integer AS runtime_unknown,
count(*) FILTER (
WHERE lifecycle_status = 'in_repair'
)::integer AS lifecycle_in_repair,
count(*) FILTER (
WHERE lifecycle_status = 'spare'
)::integer AS lifecycle_spare,
count(*) FILTER (
WHERE lifecycle_status = 'retired'
)::integer AS lifecycle_retired,
count(*) FILTER (
WHERE operational_state = 'offline'
)::integer AS attention_offline,
count(*) FILTER (
WHERE operational_state = 'unknown'
)::integer AS attention_unknown,
count(*) FILTER (
WHERE operational_state = 'unstable'
)::integer AS attention_unstable,
count(*) FILTER (
WHERE operational_state = 'repair'
)::integer AS attention_repair
FROM fleet_state
""",
(list(ACTIVE_REPAIR_STATUSES),),
)
return dict(cur.fetchone())
def search_miners(
self,
@@ -193,12 +433,20 @@ class InventoryRepository:
query: str | None = None,
asset_id: str | None = None,
lifecycle_status: str | None = None,
model: str | None = None,
runtime_status: str | None = None,
repair: str | None = None,
operational_state: str | None = None,
problems: bool = False,
site_id: UUID | None = None,
room_id: UUID | None = None,
tag_names: Sequence[str] = (),
limit: int = 100,
offset: int = 0,
include_location_details: bool = False,
) -> list[dict]:
conditions: list[str] = []
params: list[Any] = []
params: list[Any] = [list(ACTIVE_REPAIR_STATUSES)]
if query:
pattern = f"%{query}%"
@@ -217,15 +465,36 @@ class InventoryRepository:
"""
)
params.extend([pattern] * 8)
if asset_id:
conditions.append("lower(trim(miners.asset_tag)) = lower(trim(%s))")
params.append(asset_id)
if lifecycle_status:
conditions.append("miners.lifecycle_status = %s")
params.append(lifecycle_status)
if model:
conditions.append("lower(trim(miners.model)) = lower(trim(%s))")
params.append(model)
if runtime_status:
conditions.append("lower(trim(miners.status)) = lower(trim(%s))")
params.append(runtime_status)
if repair == "active":
conditions.append("active_repair.id IS NOT NULL")
elif repair == "none":
conditions.append("active_repair.id IS NULL")
if operational_state:
conditions.append(f"({OPERATIONAL_STATE_SQL}) = %s")
params.append(operational_state)
if problems:
conditions.append(
f"({OPERATIONAL_STATE_SQL}) = ANY(%s)"
)
params.append(["offline", "unknown", "unstable", "repair"])
if site_id:
conditions.append("mapped.site_id = %s")
params.append(site_id)
if room_id:
conditions.append("mapped.room_id = %s")
params.append(room_id)
for tag_name in tag_names:
conditions.append(
f"""
@@ -241,6 +510,14 @@ class InventoryRepository:
)
params.append(tag_name)
location_fields = (
LOCATION_FIELDS if include_location_details else REDACTED_LOCATION_FIELDS
)
location_join = LOCATION_JOIN if include_location_details else ""
select_sql = FLEET_SELECT.format(
location_fields=location_fields,
location_join=location_join,
)
where_sql = " AND ".join(conditions) if conditions else "true"
params.extend([limit, offset])
@@ -248,18 +525,44 @@ class InventoryRepository:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
{MINER_SELECT}
{select_sql}
WHERE {where_sql}
ORDER BY
lower(trim(miners.asset_tag)) NULLS LAST,
miners.created_at,
miners.id
ORDER BY lower(trim(miners.asset_tag)) NULLS LAST,
miners.created_at, miners.id
LIMIT %s OFFSET %s
""",
params,
)
return [dict(row) for row in cur.fetchall()]
def list_location_filter_options(self) -> dict[str, list[dict]]:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT sites.id, sites.name
FROM location_positions lp
JOIN miners ON miners.id = lp.miner_id
JOIN rooms ON rooms.id = lp.room_id
JOIN sites ON sites.id = rooms.site_id
GROUP BY sites.id, sites.name
ORDER BY lower(sites.name), sites.id
"""
)
sites = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT rooms.id, rooms.site_id, rooms.name
FROM location_positions lp
JOIN miners ON miners.id = lp.miner_id
JOIN rooms ON rooms.id = lp.room_id
GROUP BY rooms.id, rooms.site_id, rooms.name
ORDER BY lower(rooms.name), rooms.id
"""
)
rooms = [dict(row) for row in cur.fetchall()]
return {"sites": sites, "rooms": rooms}
def create_miner(self, values: Mapping[str, Any]) -> dict:
clean_values = {
key: value for key, value in values.items() if key in MINER_WRITE_COLUMNS
+18 -3
View File
@@ -5,7 +5,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.identity import require_permission
from app.identity import require_permission, user_has_permission
from app.inventory.dependencies import get_inventory_service
from app.inventory.exceptions import (
DuplicateAssetID,
@@ -16,6 +16,7 @@ from app.inventory.models import (
InventoryAssetIDHistoryResponse,
InventoryCreateRequest,
InventoryListMeta,
InventoryLocationOptions,
InventoryMetadataUpdateRequest,
InventoryMinerDetailResponse,
InventoryMinerListResponse,
@@ -102,21 +103,35 @@ def list_inventory_miners(
service: InventoryServiceDependency,
current_user: InventoryReadUser,
) -> InventoryMinerListResponse:
del current_user
include_location_details = user_has_permission(current_user, "locations.view")
if (filters.site_id or filters.room_id) and not include_location_details:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Location viewing permission is required for location filters",
)
try:
miners = service.search_miners(filters)
miners = service.search_miners(
filters,
include_location_details=include_location_details,
)
except InventoryValidationError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
location_options = InventoryLocationOptions()
if include_location_details:
location_options = InventoryLocationOptions.model_validate(
service.list_location_filter_options()
)
return InventoryMinerListResponse(
data=miners,
meta=InventoryListMeta(
limit=filters.limit,
offset=filters.offset,
returned=len(miners),
location_options=location_options,
),
)
+68 -2
View File
@@ -14,6 +14,8 @@ from app.inventory.exceptions import (
from app.inventory.models import (
InventoryAssetIDHistoryEntry,
InventoryCreateRequest,
InventoryFleetAttentionSummary,
InventoryFleetSummary,
InventoryMetadataUpdateRequest,
InventoryMinerDetail,
InventoryMinerSummary,
@@ -24,7 +26,9 @@ from app.inventory.models import (
from app.inventory.repository import InventoryRepository
ALLOWED_LIFECYCLE_STATUSES = frozenset({"active", "spare", "retired"})
ALLOWED_LIFECYCLE_STATUSES = frozenset(
{"active", "spare", "retired", "in_repair"}
)
class InventoryService:
@@ -77,16 +81,37 @@ class InventoryService:
@classmethod
def _summary_model(cls, row: dict) -> InventoryMinerSummary:
active_repair = None
if row.get("active_repair_id") is not None:
active_repair = {
"id": row["active_repair_id"],
"status": row["active_repair_status"],
"reason": row["active_repair_reason"],
}
return InventoryMinerSummary(
id=row["id"],
asset_id=row.get("asset_tag"),
current_mac=row.get("current_mac"),
current_ip=row.get("current_ip"),
model=row.get("model"),
miner_sn=row.get("miner_sn"),
lifecycle_status=row["lifecycle_status"],
runtime_status=row["status"],
site_id=row.get("site_id"),
site_name=row.get("site_name"),
room_id=row.get("room_id"),
room_name=row.get("room_name"),
location_position_id=row.get("location_position_id"),
rack_label=row.get("rack_label"),
shelf_label=row.get("shelf_label"),
position_label=row.get("position_label"),
position_index=row.get("position_index"),
observed_at=row.get("observed_at"),
hashrate_ths=row.get("hashrate_ths"),
power_w=row.get("power_w"),
chip_temp_max_c=row.get("chip_temp_max_c"),
hash_stable=row.get("hash_stable"),
active_repair=active_repair,
tags=[cls._tag_model(tag) for tag in row.get("tags") or []],
created_at=row["created_at"],
updated_at=row["updated_at"],
@@ -99,7 +124,6 @@ class InventoryService:
return InventoryMinerDetail(
**summary.model_dump(),
hostname=row.get("hostname"),
miner_sn=row.get("miner_sn"),
control_board_sn=row.get("control_board_sn"),
psu_serial=row.get("power_sn"),
firmware_version=row.get("firmware_version"),
@@ -113,6 +137,13 @@ class InventoryService:
raise MinerNotFound(miner_id)
return self._detail_model(row)
def get_current_miner(self, miner_id: UUID) -> InventoryMinerDetail:
"""Return current Inventory identity and the latest observation."""
row = self.repository.get_current_miner_by_id(miner_id)
if not row:
raise MinerNotFound(miner_id)
return self._detail_model(row)
def get_asset_id_history(
self,
miner_id: UUID,
@@ -135,15 +166,39 @@ class InventoryService:
for row in self.repository.list_miners(limit=limit, offset=offset)
]
def summarize_fleet(self) -> InventoryFleetSummary:
row = self.repository.summarize_fleet()
return InventoryFleetSummary(
total=row["total"],
runtime_online=row["runtime_online"],
runtime_offline=row["runtime_offline"],
runtime_unknown=row["runtime_unknown"],
lifecycle_in_repair=row["lifecycle_in_repair"],
lifecycle_spare=row["lifecycle_spare"],
lifecycle_retired=row["lifecycle_retired"],
attention=InventoryFleetAttentionSummary(
offline=row["attention_offline"],
unknown=row["attention_unknown"],
unstable=row["attention_unstable"],
repair=row["attention_repair"],
),
)
def search_miners(
self,
filters: InventorySearchFilters,
*,
include_location_details: bool = False,
) -> list[InventoryMinerSummary]:
lifecycle_status = None
if filters.lifecycle_status:
lifecycle_status = self.validate_lifecycle_status(
filters.lifecycle_status
)
if (filters.site_id or filters.room_id) and not include_location_details:
raise InventoryValidationError(
"Location filters require location viewing permission"
)
asset_id = None
if filters.asset_id:
asset_id = self.normalize_asset_id(filters.asset_id)
@@ -156,12 +211,23 @@ class InventoryService:
query=self._clean_optional_text(filters.query),
asset_id=asset_id,
lifecycle_status=lifecycle_status,
model=self._clean_optional_text(filters.model),
runtime_status=self._clean_optional_text(filters.runtime_status),
repair=filters.repair,
operational_state=filters.operational_state,
problems=filters.problems,
site_id=filters.site_id,
room_id=filters.room_id,
tag_names=tag_names,
limit=filters.limit,
offset=filters.offset,
include_location_details=include_location_details,
)
]
def list_location_filter_options(self) -> dict[str, list[dict]]:
return self.repository.list_location_filter_options()
def create_miner(
self,
request: InventoryCreateRequest,
+7
View File
@@ -0,0 +1,7 @@
"""Mine Map location read domain."""
from app.locations.models import MineMapResponse
from app.locations.repository import LocationMapRepository
from app.locations.service import LocationMapService
__all__ = ["LocationMapRepository", "LocationMapService", "MineMapResponse"]
+8
View File
@@ -0,0 +1,8 @@
"""Dependency construction for the location read domain."""
from app.locations.repository import LocationMapRepository
from app.locations.service import LocationMapService
def get_location_map_service() -> LocationMapService:
return LocationMapService(LocationMapRepository())
+140
View File
@@ -0,0 +1,140 @@
"""Typed contracts for the read-only Mine Map."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
class MapSite(BaseModel):
id: UUID
name: str
class MapRoom(BaseModel):
id: UUID
site_id: UUID
site_name: str
name: str
sort_order: int
class MapActiveRepair(BaseModel):
id: UUID
status: str
reason: str
class MapPosition(BaseModel):
location_position_id: UUID
site_id: UUID | None = None
site_name: str | None = None
room_id: UUID | None = None
room_name: str
rack_name: str
shelf_name: str
shelf_index: int | None = None
position_index: int
position_label: str | None = None
position_kind: str
position_status: str
occupied: bool
expected_mac: str | None = None
equipment_label: str | None = None
notes: str | None = None
source_file: str | None = None
source_cell: str | None = None
conflict_reason: str | None = None
miner_id: UUID | None = None
asset_id: str | None = None
current_mac: str | None = None
current_ip: str | None = None
miner_status: str | None = None
hostname: str | None = None
miner_sn: str | None = None
power_sn: str | None = None
model: str | None = None
miner_type: str | None = None
firmware_version: str | None = None
lifecycle_status: str | None = None
last_seen_at: datetime | None = None
asset_state: str | None = None
observed_at: datetime | None = None
hashrate_ths: float | None = None
power_w: float | None = None
chip_temp_max_c: float | None = None
chip_temp_avg_c: float | None = None
hash_stable: bool | None = None
pool_rejected_percent: float | None = None
active_repair: MapActiveRepair | None = None
class MapVirtualMiner(BaseModel):
miner_id: UUID | None = None
site_id: UUID | None = None
room_id: UUID | None = None
asset_id: str | None = None
asset_state: str
current_mac: str | None = None
current_ip: str | None = None
miner_status: str | None = None
status: str | None = None
hostname: str | None = None
miner_sn: str | None = None
power_sn: str | None = None
model: str | None = None
miner_type: str | None = None
firmware_version: str | None = None
lifecycle_status: str | None = None
last_seen_at: datetime | None = None
observed_at: datetime | None = None
hashrate_ths: float | None = None
power_w: float | None = None
chip_temp_max_c: float | None = None
chip_temp_avg_c: float | None = None
hash_stable: bool | None = None
active_repair: MapActiveRepair | None = None
class MineMapData(BaseModel):
sites: list[MapSite]
rooms: list[MapRoom]
positions: list[MapPosition]
virtual_miners: list[MapVirtualMiner]
class MineMapMeta(BaseModel):
generated_at: datetime
site_id: UUID | None = None
room_id: UUID | None = None
position_count: int
virtual_miner_count: int
miner_details_included: bool
class MineMapResponse(BaseModel):
data: MineMapData
meta: MineMapMeta
error: None = None
class MinerLocationDetail(BaseModel):
site_id: UUID | None = None
site_name: str | None = None
room_id: UUID | None = None
room_name: str | None = None
position_id: UUID
rack_label: str | None = None
shelf_label: str | None = None
position_label: str | None = None
position_index: int | None = None
class LocationSummary(BaseModel):
sites: int
rooms: int
positions: int
occupied_positions: int
empty_positions: int
mapped_miners: int | None = None
unmapped_miners: int | None = None
+257
View File
@@ -0,0 +1,257 @@
"""Set-based PostgreSQL reads for Mine Map v1."""
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from typing import Any
from uuid import UUID
import psycopg
from psycopg.rows import dict_row
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
ACTIVE_REPAIR_STATUSES = (
"OPEN",
"IN_PROGRESS",
"WAITING_FOR_PARTS",
"READY_FOR_TEST",
)
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=5,
)
LATEST_TELEMETRY_SQL = """
LEFT JOIN LATERAL (
SELECT snapshots.observed_at,
ROUND((snapshots.mhs_av / 1000000.0)::numeric, 2)::float
AS hashrate_ths,
ROUND(snapshots.power_w::numeric, 2)::float AS power_w,
ROUND(snapshots.chip_temp_max_c::numeric, 2)::float
AS chip_temp_max_c,
ROUND(snapshots.chip_temp_avg_c::numeric, 2)::float
AS chip_temp_avg_c,
snapshots.hash_stable, snapshots.pool_rejected_percent
FROM miner_status_snapshots snapshots
WHERE snapshots.miner_id = miners.id
ORDER BY snapshots.observed_at DESC, snapshots.id DESC
LIMIT 1
) latest ON true
"""
ACTIVE_REPAIR_SQL = """
LEFT JOIN LATERAL (
SELECT repairs.id, repairs.status, repairs.reason
FROM repairs
WHERE repairs.miner_id = miners.id AND repairs.status = ANY(%s)
ORDER BY repairs.opened_at, repairs.id
LIMIT 1
) active_repair ON true
"""
class LocationMapRepository:
"""Own all persistence used by the Mine Map read model."""
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_map(
self,
*,
site_id: UUID | None = None,
room_id: UUID | None = None,
) -> dict[str, list[dict[str, Any]]]:
position_where = []
position_params: list[Any] = [list(ACTIVE_REPAIR_STATUSES)]
virtual_where = [
"COALESCE(mls.asset_state, 'waiting_placement') = ANY(%s)",
"NOT EXISTS (SELECT 1 FROM location_positions assigned "
"WHERE assigned.miner_id = miners.id)",
]
virtual_params: list[Any] = [
list(ACTIVE_REPAIR_STATUSES),
["waiting_placement", "storage", "repair", "decommissioned"],
]
if site_id is not None:
position_where.append("rooms.site_id = %s")
position_params.append(site_id)
virtual_where.append("miners.site_id = %s")
virtual_params.append(site_id)
if room_id is not None:
position_where.append("lp.room_id = %s")
position_params.append(room_id)
virtual_where.append("miners.room_id = %s")
virtual_params.append(room_id)
position_filter = (
"WHERE " + " AND ".join(position_where) if position_where else ""
)
virtual_filter = "WHERE " + " AND ".join(virtual_where)
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute("SELECT id, name FROM sites ORDER BY lower(name), id")
sites = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT rooms.id, rooms.site_id, sites.name AS site_name,
rooms.name, rooms.sort_order
FROM rooms JOIN sites ON sites.id = rooms.site_id
ORDER BY lower(sites.name), rooms.sort_order,
lower(rooms.name), rooms.id
"""
)
rooms = [dict(row) for row in cur.fetchall()]
cur.execute(
f"""
SELECT lp.id AS location_position_id, sites.id AS site_id,
sites.name AS site_name, rooms.id AS room_id,
COALESCE(rooms.name, lp.room_name) AS room_name,
lp.rack_name, lp.shelf_name, lp.shelf_index,
lp.position_index, lp.position_label, lp.position_kind,
lp.position_status, (miners.id IS NOT NULL) AS occupied,
lp.expected_mac, lp.equipment_label, lp.notes,
lp.source_file, lp.source_cell, lp.conflict_reason,
miners.id AS miner_id, miners.asset_tag AS asset_id,
miners.current_mac, host(miners.current_ip) AS current_ip,
miners.status AS miner_status, miners.hostname,
miners.miner_sn, miners.power_sn, miners.model,
miners.miner_type, miners.firmware_version,
miners.lifecycle_status, miners.last_seen_at,
COALESCE(mls.asset_state, 'unassigned') AS asset_state,
latest.observed_at, latest.hashrate_ths, latest.power_w,
latest.chip_temp_max_c, latest.chip_temp_avg_c,
latest.hash_stable, latest.pool_rejected_percent,
active_repair.id AS active_repair_id,
active_repair.status AS active_repair_status,
active_repair.reason AS active_repair_reason
FROM location_positions lp
LEFT JOIN rooms ON rooms.id = lp.room_id
LEFT JOIN sites ON sites.id = rooms.site_id
LEFT JOIN miners ON miners.id = lp.miner_id
LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id
{LATEST_TELEMETRY_SQL}
{ACTIVE_REPAIR_SQL}
{position_filter}
ORDER BY lower(sites.name) NULLS LAST,
rooms.sort_order NULLS LAST,
lower(COALESCE(rooms.name, lp.room_name)),
lower(lp.rack_name), lp.shelf_index NULLS LAST,
lower(lp.shelf_name), lp.position_index, lp.id
""",
position_params,
)
positions = [dict(row) for row in cur.fetchall()]
cur.execute(
f"""
SELECT miners.id AS miner_id, miners.site_id, miners.room_id,
miners.asset_tag AS asset_id,
COALESCE(mls.asset_state, 'waiting_placement') AS asset_state,
miners.current_mac, host(miners.current_ip) AS current_ip,
miners.status AS miner_status, miners.status, miners.hostname,
miners.miner_sn, miners.power_sn, miners.model,
miners.miner_type, miners.firmware_version,
miners.lifecycle_status, miners.last_seen_at,
latest.observed_at, latest.hashrate_ths, latest.power_w,
latest.chip_temp_max_c, latest.chip_temp_avg_c,
latest.hash_stable,
active_repair.id AS active_repair_id,
active_repair.status AS active_repair_status,
active_repair.reason AS active_repair_reason
FROM miners
LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id
{LATEST_TELEMETRY_SQL}
{ACTIVE_REPAIR_SQL}
{virtual_filter}
ORDER BY asset_state, lower(miners.asset_tag), miners.id
""",
virtual_params,
)
virtual_miners = [dict(row) for row in cur.fetchall()]
return {
"sites": sites,
"rooms": rooms,
"positions": positions,
"virtual_miners": virtual_miners,
}
def summarize_locations(self, *, include_miner_counts: bool) -> dict[str, Any]:
miner_counts = (
"count(DISTINCT lp.miner_id)::integer AS mapped_miners, "
"(SELECT count(*)::integer FROM miners WHERE NOT EXISTS ("
"SELECT 1 FROM location_positions assigned "
"WHERE assigned.miner_id = miners.id)) AS unmapped_miners"
if include_miner_counts
else "NULL::integer AS mapped_miners, NULL::integer AS unmapped_miners"
)
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
SELECT (SELECT count(*)::integer FROM sites) AS sites,
(SELECT count(*)::integer FROM rooms) AS rooms,
count(lp.id)::integer AS positions,
count(lp.id) FILTER (
WHERE lp.miner_id IS NOT NULL
)::integer AS occupied_positions,
count(lp.id) FILTER (
WHERE lp.miner_id IS NULL
)::integer AS empty_positions,
{miner_counts}
FROM location_positions AS lp
"""
)
return dict(cur.fetchone())
def get_miner_location(self, miner_id: UUID) -> dict[str, Any] | None:
"""Return the one current physical position assigned to a miner."""
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT sites.id AS site_id, sites.name AS site_name,
rooms.id AS room_id,
COALESCE(rooms.name, position.room_name) AS room_name,
position.id AS position_id,
position.rack_name AS rack_label,
position.shelf_name AS shelf_label,
position.position_label,
position.position_index
FROM location_positions AS position
LEFT JOIN rooms ON rooms.id = position.room_id
LEFT JOIN sites ON sites.id = rooms.site_id
WHERE position.miner_id = %s
ORDER BY position.id
LIMIT 1
""",
(miner_id,),
)
row = cur.fetchone()
return dict(row) if row else None
+30
View File
@@ -0,0 +1,30 @@
"""HTTP boundary for the read-only Mine Map API."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from app.identity import require_permission, user_has_permission
from app.locations.dependencies import get_location_map_service
from app.locations.models import MineMapResponse
from app.locations.service import LocationMapService
router = APIRouter(prefix="/api/location", tags=["locations"])
MapUser = Annotated[dict, Depends(require_permission("locations.view"))]
MapService = Annotated[LocationMapService, Depends(get_location_map_service)]
@router.get("/map", response_model=MineMapResponse)
def get_mine_map(
current_user: MapUser,
service: MapService,
site_id: Annotated[UUID | None, Query()] = None,
room_id: Annotated[UUID | None, Query()] = None,
) -> MineMapResponse:
return service.get_map(
site_id=site_id,
room_id=room_id,
include_miner_details=user_has_permission(current_user, "miners.view"),
)
+102
View File
@@ -0,0 +1,102 @@
"""Mine Map read-model composition independent of HTTP."""
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from app.locations.models import (
LocationSummary,
MapActiveRepair,
MapPosition,
MapRoom,
MapSite,
MapVirtualMiner,
MineMapData,
MineMapMeta,
MineMapResponse,
MinerLocationDetail,
)
from app.locations.repository import LocationMapRepository
MINER_FIELDS = {
"miner_id", "asset_id", "current_mac", "expected_mac", "current_ip", "miner_status",
"status",
"hostname", "miner_sn", "power_sn", "model", "miner_type",
"firmware_version", "lifecycle_status", "last_seen_at", "asset_state",
"observed_at", "hashrate_ths", "power_w", "chip_temp_max_c",
"chip_temp_avg_c", "hash_stable", "pool_rejected_percent",
}
class LocationMapService:
def __init__(self, repository: LocationMapRepository):
self.repository = repository
@staticmethod
def _repair(row: dict[str, Any]) -> MapActiveRepair | None:
repair_id = row.pop("active_repair_id", None)
status = row.pop("active_repair_status", None)
reason = row.pop("active_repair_reason", None)
if repair_id is None:
return None
return MapActiveRepair(id=repair_id, status=status, reason=reason)
@classmethod
def _redact(cls, row: dict[str, Any]) -> dict[str, Any]:
redacted = dict(row)
for field in MINER_FIELDS:
redacted[field] = None
redacted["active_repair_id"] = None
redacted["active_repair_status"] = None
redacted["active_repair_reason"] = None
return redacted
def get_map(
self,
*,
site_id: UUID | None = None,
room_id: UUID | None = None,
include_miner_details: bool,
) -> MineMapResponse:
rows = self.repository.read_map(site_id=site_id, room_id=room_id)
positions = []
for source in rows["positions"]:
row = dict(source) if include_miner_details else self._redact(source)
row["active_repair"] = self._repair(row)
positions.append(MapPosition.model_validate(row))
virtual_miners = []
if include_miner_details:
for source in rows["virtual_miners"]:
row = dict(source)
row["active_repair"] = self._repair(row)
virtual_miners.append(MapVirtualMiner.model_validate(row))
return MineMapResponse(
data=MineMapData(
sites=[MapSite.model_validate(row) for row in rows["sites"]],
rooms=[MapRoom.model_validate(row) for row in rows["rooms"]],
positions=positions,
virtual_miners=virtual_miners,
),
meta=MineMapMeta(
generated_at=datetime.now(timezone.utc),
site_id=site_id,
room_id=room_id,
position_count=len(positions),
virtual_miner_count=len(virtual_miners),
miner_details_included=include_miner_details,
),
)
def get_miner_location(self, miner_id: UUID) -> MinerLocationDetail | None:
row = self.repository.get_miner_location(miner_id)
if row is None:
return None
return MinerLocationDetail.model_validate(row)
def summarize_locations(self, *, include_miner_counts: bool) -> LocationSummary:
return LocationSummary.model_validate(
self.repository.summarize_locations(
include_miner_counts=include_miner_counts
)
)
+238 -1271
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
"""Typed miner control domain."""
@@ -0,0 +1,8 @@
"""Dependency construction for miner control."""
from app.miner_control.repository import MinerControlRepository
from app.miner_control.service import MinerControlService
def get_miner_control_service() -> MinerControlService:
return MinerControlService(MinerControlRepository())
+21
View File
@@ -0,0 +1,21 @@
"""Miner control domain exceptions."""
class MinerControlError(Exception):
"""Base miner control error."""
class ControlledMinerNotFound(MinerControlError):
"""The durable miner UUID is unknown."""
class MinerControlUnavailable(MinerControlError):
"""The requested action cannot be safely queued."""
class MinerControlConflict(MinerControlError):
"""Another live control operation already exists."""
class MinerControlActorRequired(MinerControlError):
"""An authenticated actor UUID is required."""
+85
View File
@@ -0,0 +1,85 @@
"""Typed contracts for miner control capabilities and operations."""
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
ControlAction = Literal["suspend", "resume", "performance_mode"]
CapabilityState = Literal["supported", "unavailable", "unknown"]
QueueStatus = Literal["pending", "claimed", "completed", "failed", "cancelled"]
VerificationStatus = Literal[
"confirmed", "verification_failed", "unconfirmed", "unavailable"
]
PerformanceMode = Literal["low", "normal", "high"]
class EmptyControlRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
class PerformanceModeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
mode: PerformanceMode
class ControlCapability(BaseModel):
state: CapabilityState
reason: str | None = None
class ControlAgentAvailability(BaseModel):
state: CapabilityState
reason: str | None = None
last_checkin_at: AwareDatetime | None = None
version: str | None = None
class MinerControlOperation(BaseModel):
operation_id: str
miner_id: UUID
action: ControlAction
requested_value: str | None = None
status: QueueStatus
verification_status: VerificationStatus | None = None
command_accepted: bool | None = None
observed_performance_mode: PerformanceMode | None = None
failure_code: str | None = None
created_at: AwareDatetime
claimed_at: AwareDatetime | None = None
completed_at: AwareDatetime | None = None
class MinerControlProjection(BaseModel):
miner_id: UUID
runtime_state: str | None = None
performance_mode: PerformanceMode | None = None
agent: ControlAgentAvailability
service_control: ControlCapability
performance_mode_control: ControlCapability
available_service_action: Literal["suspend", "resume"] | None = None
recent_operations: list[MinerControlOperation]
class MinerControlResponse(BaseModel):
data: MinerControlProjection
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class MinerControlAccepted(BaseModel):
operation_id: str
miner_id: UUID
action: ControlAction
requested_value: str | None = None
status: Literal["pending"]
created_at: datetime
class MinerControlAcceptedResponse(BaseModel):
data: MinerControlAccepted
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
+135
View File
@@ -0,0 +1,135 @@
"""PostgreSQL persistence for typed miner control operations."""
from collections.abc import Callable
from typing import Any
from uuid import UUID
import psycopg
from psycopg.errors import UniqueViolation
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.config import settings
from app.miner_control.exceptions import MinerControlConflict
ConnectionFactory = Callable[[], psycopg.Connection]
LIVE_CONTROL_CONSTRAINT = "uq_agent_queue_live_miner_control"
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=5,
)
class MinerControlRepository:
"""Resolve control facts and persist queue/audit rows atomically."""
def __init__(self, connection_factory: ConnectionFactory | None = None):
self._connection_factory = connection_factory or default_connection_factory
def get_control_context(self, miner_id: UUID) -> dict | None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT miners.id, miners.site_id, host(miners.current_ip) AS current_ip,
miners.status AS runtime_state,
miners.configured_performance_mode,
miners.api3_supported, miners.api3_api_switch,
agents.agent_id, agents.last_checkin_at,
agents.version AS agent_version, agents.last_payload
FROM miners
LEFT JOIN minerops_site_agents AS agents
ON agents.site_id = miners.site_id
WHERE miners.id = %s
""",
(miner_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
def list_recent(self, miner_id: UUID, limit: int = 5) -> list[dict]:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id AS operation_id, command_status AS status,
request_payload, result_payload, created_at,
claimed_at, completed_at
FROM minerops_agent_command_queue
WHERE command_type = 'miner_control'
AND request_payload ->> 'miner_id' = %s
AND request_payload ->> 'schema_version' = '1'
ORDER BY created_at DESC, id DESC
LIMIT %s
""",
(str(miner_id), limit),
)
return [dict(row) for row in cur.fetchall()]
finally:
connection.close()
def enqueue_with_audit(
self,
*,
operation_id: str,
miner_id: UUID,
agent_id: str,
actor_user_id: UUID,
actor_username: str,
request_payload: dict[str, Any],
audit_payload: dict[str, Any],
) -> dict:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO minerops_agent_command_queue (
id, agent_id, command_type, command_status,
requested_by_user_id, requested_by_username, request_payload
)
VALUES (%s, %s, 'miner_control', 'pending', %s, %s, %s)
RETURNING id AS operation_id, command_status AS status, created_at
""",
(
operation_id, agent_id, str(actor_user_id), actor_username,
Jsonb(request_payload),
),
)
operation = dict(cur.fetchone())
cur.execute(
"""
INSERT INTO audit_logs (
actor_user_id, action, entity_type, entity_id,
new_value_json, related_miner_asset_id
)
VALUES (%s, 'miner.control.queued', 'miner', %s, %s, %s)
""",
(actor_user_id, miner_id, Jsonb(audit_payload), miner_id),
)
connection.commit()
return operation
except UniqueViolation as exc:
connection.rollback()
if exc.diag.constraint_name == LIVE_CONTROL_CONSTRAINT:
raise MinerControlConflict(
"A pending or running control operation already exists for this miner"
) from exc
raise
except Exception:
connection.rollback()
raise
finally:
connection.close()
+129
View File
@@ -0,0 +1,129 @@
"""HTTP mapping for typed miner controls."""
from typing import Annotated
from urllib.parse import urlsplit
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.identity import require_permission
from app.miner_control.dependencies import get_miner_control_service
from app.miner_control.exceptions import (
ControlledMinerNotFound,
MinerControlConflict,
MinerControlUnavailable,
)
from app.miner_control.models import (
EmptyControlRequest,
MinerControlAcceptedResponse,
MinerControlResponse,
PerformanceModeRequest,
)
from app.miner_control.service import MinerControlService
router = APIRouter(prefix="/api/miners", tags=["miner-controls"])
ControlService = Annotated[MinerControlService, Depends(get_miner_control_service)]
ControlViewer = Annotated[dict, Depends(require_permission("miners.view"))]
ControlUser = Annotated[dict, Depends(require_permission("miners.control"))]
def require_same_origin(request: Request) -> None:
if request.headers.get("sec-fetch-site", "").lower() == "cross-site":
raise HTTPException(status_code=403, detail="Cross-site control request rejected")
origin = request.headers.get("origin")
if origin:
parsed = urlsplit(origin)
if parsed.scheme not in {"http", "https"} or parsed.netloc != request.url.netloc:
raise HTTPException(status_code=403, detail="Cross-site control request rejected")
def _actor(current_user: dict) -> tuple[UUID, str]:
return UUID(str(current_user["id"])), str(
current_user.get("username") or current_user.get("email") or "unknown"
)
def _queue(
service: MinerControlService,
miner_id: UUID,
action: str,
requested_value: str | None,
current_user: dict,
) -> MinerControlAcceptedResponse:
actor_id, username = _actor(current_user)
try:
operation = service.queue_control(
miner_id,
action,
requested_value=requested_value,
actor_user_id=actor_id,
actor_username=username,
)
except ControlledMinerNotFound as exc:
raise HTTPException(status_code=404, detail="Miner not found") from exc
except MinerControlUnavailable as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except MinerControlConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return MinerControlAcceptedResponse(data=operation)
@router.get("/{miner_id}/controls", response_model=MinerControlResponse)
def get_miner_controls(
miner_id: UUID,
current_user: ControlViewer,
service: ControlService,
) -> MinerControlResponse:
del current_user
try:
return MinerControlResponse(data=service.get_controls(miner_id))
except ControlledMinerNotFound as exc:
raise HTTPException(status_code=404, detail="Miner not found") from exc
@router.post(
"/{miner_id}/controls/suspend",
response_model=MinerControlAcceptedResponse,
status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_same_origin)],
)
def suspend_miner(
miner_id: UUID,
payload: EmptyControlRequest,
current_user: ControlUser,
service: ControlService,
) -> MinerControlAcceptedResponse:
del payload
return _queue(service, miner_id, "suspend", None, current_user)
@router.post(
"/{miner_id}/controls/resume",
response_model=MinerControlAcceptedResponse,
status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_same_origin)],
)
def resume_miner(
miner_id: UUID,
payload: EmptyControlRequest,
current_user: ControlUser,
service: ControlService,
) -> MinerControlAcceptedResponse:
del payload
return _queue(service, miner_id, "resume", None, current_user)
@router.post(
"/{miner_id}/controls/performance-mode",
response_model=MinerControlAcceptedResponse,
status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_same_origin)],
)
def set_performance_mode(
miner_id: UUID,
payload: PerformanceModeRequest,
current_user: ControlUser,
service: ControlService,
) -> MinerControlAcceptedResponse:
return _queue(service, miner_id, "performance_mode", payload.mode, current_user)
+165
View File
@@ -0,0 +1,165 @@
"""Business rules for typed, Agent-routed miner controls."""
from typing import Any
from uuid import UUID, uuid4
from app.miner_control.exceptions import (
ControlledMinerNotFound,
MinerControlActorRequired,
MinerControlUnavailable,
)
from app.miner_control.models import (
ControlAgentAvailability,
ControlCapability,
MinerControlAccepted,
MinerControlOperation,
MinerControlProjection,
)
from app.miner_control.repository import MinerControlRepository
KNOWN_MODES = {"low", "normal", "high"}
RUNNING_STATES = {"online", "running"}
STOPPED_STATES = {"suspended", "stopped"}
class MinerControlService:
def __init__(self, repository: MinerControlRepository):
self.repository = repository
@staticmethod
def _capability(context: dict) -> ControlCapability:
if not context.get("site_id"):
return ControlCapability(state="unavailable", reason="site_unassigned")
if not context.get("agent_id"):
return ControlCapability(state="unavailable", reason="agent_unassigned")
if not context.get("current_ip"):
return ControlCapability(state="unavailable", reason="current_ip_missing")
if context.get("api3_supported") is False:
return ControlCapability(state="unavailable", reason="api3_unsupported")
if context.get("api3_supported") is not True:
return ControlCapability(state="unknown", reason="api3_support_unknown")
payload = context.get("last_payload") or {}
if payload.get("control_available") is False:
reason = payload.get("control_unavailable_reason")
return ControlCapability(
state="unavailable",
reason=reason if reason == "credentials_missing" else "agent_unavailable",
)
if payload.get("control_available") is not True:
return ControlCapability(
state="unknown", reason="agent_capability_unknown"
)
return ControlCapability(state="supported")
@staticmethod
def _operation(miner_id: UUID, row: dict) -> MinerControlOperation:
request = row.get("request_payload") or {}
result = row.get("result_payload") or {}
mode = result.get("observed_performance_mode")
return MinerControlOperation(
operation_id=row["operation_id"],
miner_id=miner_id,
action=request.get("action"),
requested_value=request.get("requested_value"),
status=row["status"],
verification_status=result.get("verification_status"),
command_accepted=result.get("command_accepted"),
observed_performance_mode=mode if mode in KNOWN_MODES else None,
failure_code=result.get("failure_code"),
created_at=row["created_at"],
claimed_at=row.get("claimed_at"),
completed_at=row.get("completed_at"),
)
def get_controls(self, miner_id: UUID) -> MinerControlProjection:
context = self.repository.get_control_context(miner_id)
if not context:
raise ControlledMinerNotFound("Miner not found")
capability = self._capability(context)
runtime = str(context.get("runtime_state") or "").strip().lower() or None
service_action = None
if capability.state == "supported":
if runtime in RUNNING_STATES:
service_action = "suspend"
elif runtime in STOPPED_STATES:
service_action = "resume"
configured_mode = str(context.get("configured_performance_mode") or "").lower()
return MinerControlProjection(
miner_id=miner_id,
runtime_state=runtime,
performance_mode=(configured_mode if configured_mode in KNOWN_MODES else None),
agent=ControlAgentAvailability(
state=capability.state,
reason=capability.reason,
last_checkin_at=context.get("last_checkin_at"),
version=context.get("agent_version"),
),
service_control=capability,
performance_mode_control=capability,
available_service_action=service_action,
recent_operations=[
self._operation(miner_id, row)
for row in self.repository.list_recent(miner_id, limit=5)
],
)
def queue_control(
self,
miner_id: UUID,
action: str,
*,
requested_value: str | None,
actor_user_id: UUID,
actor_username: str,
) -> MinerControlAccepted:
if not actor_user_id:
raise MinerControlActorRequired("Authenticated actor UUID is required")
context = self.repository.get_control_context(miner_id)
if not context:
raise ControlledMinerNotFound("Miner not found")
capability = self._capability(context)
if capability.state != "supported":
raise MinerControlUnavailable(
capability.reason or "control_capability_not_supported"
)
runtime = str(context.get("runtime_state") or "").strip().lower()
if action == "suspend" and runtime not in RUNNING_STATES:
raise MinerControlUnavailable("miner_not_factually_running")
if action == "resume" and runtime not in STOPPED_STATES:
raise MinerControlUnavailable("miner_not_factually_stopped")
if action == "performance_mode" and requested_value not in KNOWN_MODES:
raise MinerControlUnavailable("invalid_performance_mode")
operation_id = str(uuid4())
payload: dict[str, Any] = {
"schema_version": 1,
"miner_id": str(miner_id),
"action": action,
"requested_value": requested_value,
"ip_address": context["current_ip"],
}
operation = self.repository.enqueue_with_audit(
operation_id=operation_id,
miner_id=miner_id,
agent_id=context["agent_id"],
actor_user_id=actor_user_id,
actor_username=actor_username,
request_payload=payload,
audit_payload={
"operation_id": operation_id,
"miner_id": str(miner_id),
"action": action,
"requested_value": requested_value,
},
)
return MinerControlAccepted(
operation_id=operation["operation_id"],
miner_id=miner_id,
action=action,
requested_value=requested_value,
status=operation["status"],
created_at=operation["created_at"],
)
+1
View File
@@ -0,0 +1 @@
"""Permission-aware Miner Detail read model."""
+14
View File
@@ -0,0 +1,14 @@
"""Dependency construction for the Miner Detail composition service."""
from app.inventory.dependencies import get_inventory_service
from app.locations.dependencies import get_location_map_service
from app.miner_detail.service import MinerDetailService
from app.repairs.dependencies import get_repair_center_service
def get_miner_detail_service() -> MinerDetailService:
return MinerDetailService(
inventory_service=get_inventory_service(),
location_service=get_location_map_service(),
repair_service=get_repair_center_service(),
)
+76
View File
@@ -0,0 +1,76 @@
"""Typed contracts for the Internal Alpha Miner Detail overview."""
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field
from app.inventory.models import InventoryMinerDetail
from app.locations.models import MinerLocationDetail
OperationalState = Literal[
"online", "offline", "unknown", "unstable", "repair", "spare", "retired"
]
class MinerDetailAccess(BaseModel):
can_view_locations: bool = False
can_read_repairs: bool = False
can_view_repairs: bool = False
can_read_components: bool = False
class MinerDetailLocationSection(BaseModel):
available: bool
restricted: bool
location: MinerLocationDetail | None = None
class MinerDetailRepairEpisode(BaseModel):
repair_id: UUID | None = None
source: Literal["modular", "legacy"]
active: bool
status: str
reason: str | None = None
opened_at: datetime | None = None
closed_at: datetime | None = None
latest_context: str | None = None
latest_context_at: datetime | None = None
parts_count: int | None = None
parts_summary: list[str] = Field(default_factory=list)
class MinerDetailRepairSection(BaseModel):
available: bool
restricted: bool
active_repair: MinerDetailRepairEpisode | None = None
episodes: list[MinerDetailRepairEpisode] = Field(default_factory=list)
class MinerDetailCapabilities(BaseModel):
can_view_map: bool = False
can_open_repair_center: bool = False
can_read_repair_history: bool = False
can_read_components: bool = False
class MinerDetailOverviewData(BaseModel):
miner: InventoryMinerDetail
operational_state: OperationalState
location: MinerDetailLocationSection
repair: MinerDetailRepairSection
capabilities: MinerDetailCapabilities
class MinerDetailOverviewMeta(BaseModel):
generated_at: datetime
database_query_count: int = Field(ge=1, le=3)
class MinerDetailOverviewResponse(BaseModel):
data: MinerDetailOverviewData
meta: MinerDetailOverviewMeta
error: None = None
+59
View File
@@ -0,0 +1,59 @@
"""HTTP boundary for the Internal Alpha Miner Detail overview."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Response, status
from app.identity import require_permission, user_has_permission
from app.inventory.exceptions import MinerNotFound
from app.miner_detail.dependencies import get_miner_detail_service
from app.miner_detail.models import (
MinerDetailAccess,
MinerDetailOverviewResponse,
)
from app.miner_detail.service import MinerDetailService
router = APIRouter(prefix="/api/miners", tags=["miners"])
MinerDetailUser = Annotated[dict, Depends(require_permission("miners.view"))]
MinerDetailServiceDependency = Annotated[
MinerDetailService,
Depends(get_miner_detail_service),
]
@router.get(
"/{miner_id}/overview",
response_model=MinerDetailOverviewResponse,
)
def get_miner_detail_overview(
miner_id: UUID,
current_user: MinerDetailUser,
service: MinerDetailServiceDependency,
response: Response,
) -> MinerDetailOverviewResponse:
response.headers["Cache-Control"] = "no-store"
try:
return service.get_overview(
miner_id,
MinerDetailAccess(
can_view_locations=user_has_permission(
current_user, "locations.view"
),
can_read_repairs=user_has_permission(
current_user, "repairs.read"
),
can_view_repairs=user_has_permission(
current_user, "repairs.view"
),
can_read_components=user_has_permission(
current_user, "components.read"
),
),
)
except MinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Miner not found",
) from exc
+152
View File
@@ -0,0 +1,152 @@
"""Composition service for the Internal Alpha Miner Detail overview."""
from datetime import datetime, timezone
from math import isfinite
from uuid import UUID
from app.inventory.models import InventoryMinerDetail
from app.inventory.service import InventoryService
from app.locations.service import LocationMapService
from app.miner_detail.models import (
MinerDetailAccess,
MinerDetailCapabilities,
MinerDetailLocationSection,
MinerDetailOverviewData,
MinerDetailOverviewMeta,
MinerDetailOverviewResponse,
MinerDetailRepairEpisode,
MinerDetailRepairSection,
OperationalState,
)
from app.repairs.center_service import RepairCenterService
from app.repairs.models import RepairCenterAccess, RepairCenterItem
class MinerDetailService:
"""Coordinate domain-owned current projections without transport logic."""
def __init__(
self,
inventory_service: InventoryService,
location_service: LocationMapService,
repair_service: RepairCenterService,
):
self.inventory_service = inventory_service
self.location_service = location_service
self.repair_service = repair_service
@staticmethod
def _episode(item: RepairCenterItem) -> MinerDetailRepairEpisode:
return MinerDetailRepairEpisode(
repair_id=item.repair_id,
source=item.source,
active=item.active,
status=item.repair_status,
reason=item.reason,
opened_at=item.opened_at,
closed_at=item.closed_at,
latest_context=item.latest_context,
latest_context_at=item.latest_context_at,
parts_count=item.parts_count,
parts_summary=item.parts_summary,
)
@staticmethod
def _operational_state(
miner: InventoryMinerDetail,
active_repair: MinerDetailRepairEpisode | None,
) -> OperationalState:
if active_repair is not None or miner.lifecycle_status == "in_repair":
return "repair"
if miner.lifecycle_status == "retired":
return "retired"
if miner.lifecycle_status == "spare":
return "spare"
runtime = (miner.runtime_status or "").strip().lower()
if runtime == "offline":
return "offline"
if miner.hash_stable is False:
return "unstable"
if runtime == "online":
return "online"
return "unknown"
def get_overview(
self,
miner_id: UUID,
access: MinerDetailAccess,
) -> MinerDetailOverviewResponse:
miner = self.inventory_service.get_current_miner(miner_id)
# Location is owned by the permission-aware section below. Keep the
# shared projection from leaking location identifiers. Non-finite
# telemetry is not a factual display value and remains unavailable.
redacted = {
field: None
for field in (
"site_id", "site_name", "room_id", "room_name",
"location_position_id", "rack_label", "shelf_label",
"position_label", "position_index",
)
}
for field in ("hashrate_ths", "power_w", "chip_temp_max_c"):
value = getattr(miner, field)
if value is not None and not isfinite(value):
redacted[field] = None
miner = miner.model_copy(
update=redacted
)
location = (
self.location_service.get_miner_location(miner_id)
if access.can_view_locations
else None
)
repair_items = (
self.repair_service.list_miner_repairs(
miner_id,
RepairCenterAccess(
can_read_repairs=True,
can_view_miners=True,
),
)
if access.can_read_repairs
else []
)
episodes = [self._episode(item) for item in repair_items]
active_repair = next(
(
episode for episode in episodes
if episode.active and episode.source == "modular"
),
None,
)
query_count = 1 + int(access.can_view_locations) + int(access.can_read_repairs)
return MinerDetailOverviewResponse(
data=MinerDetailOverviewData(
miner=miner,
operational_state=self._operational_state(miner, active_repair),
location=MinerDetailLocationSection(
available=access.can_view_locations,
restricted=not access.can_view_locations,
location=location,
),
repair=MinerDetailRepairSection(
available=access.can_read_repairs,
restricted=not access.can_read_repairs,
active_repair=active_repair,
episodes=episodes,
),
capabilities=MinerDetailCapabilities(
can_view_map=bool(
access.can_view_locations and location is not None
and location.site_id and location.room_id
),
can_open_repair_center=access.can_view_repairs,
can_read_repair_history=access.can_read_repairs,
can_read_components=access.can_read_components,
),
),
meta=MinerDetailOverviewMeta(
generated_at=datetime.now(timezone.utc),
database_query_count=query_count,
),
)
+1
View File
@@ -0,0 +1 @@
"""Historical monitoring read models."""
+8
View File
@@ -0,0 +1,8 @@
"""Dependency construction for the Monitoring domain."""
from app.monitoring.repository import MonitoringRepository
from app.monitoring.service import MonitoringService
def get_monitoring_service() -> MonitoringService:
return MonitoringService(MonitoringRepository())
+13
View File
@@ -0,0 +1,13 @@
"""Monitoring domain exceptions."""
class MonitoringError(Exception):
"""Base monitoring-domain error."""
class MonitoringValidationError(MonitoringError):
"""The requested trend scope or range is invalid."""
class MonitoringScopeNotFound(MonitoringError):
"""The requested durable scope does not exist."""
+70
View File
@@ -0,0 +1,70 @@
"""Authenticated frontend routes for Monitoring Trends."""
from pathlib import Path
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import HTMLResponse, Response
from app.frontend.views import render_app_page
from app.identity import require_permission, user_has_permission
from app.monitoring.models import (
MonitoringTrendQuery,
TrendRange,
TrendScopeType,
)
router = APIRouter(tags=["frontend"])
STATIC_ROOT = Path(__file__).parents[1] / "frontend" / "static"
MonitoringUser = Annotated[dict, Depends(require_permission("miners.view"))]
@router.get("/assets/minerops-monitoring-trends.css", include_in_schema=False)
def monitoring_trends_styles() -> Response:
return Response(
(STATIC_ROOT / "monitoring-trends.css").read_text(encoding="utf-8"),
media_type="text/css",
headers={"Cache-Control": "no-store"},
)
@router.get("/assets/minerops-monitoring-trends.js", include_in_schema=False)
def monitoring_trends_script() -> Response:
return Response(
(STATIC_ROOT / "monitoring-trends.js").read_text(encoding="utf-8"),
media_type="text/javascript",
headers={"Cache-Control": "no-store"},
)
@router.get(
"/monitoring/analytics",
response_class=HTMLResponse,
include_in_schema=False,
)
def monitoring_analytics_page(
current_user: MonitoringUser,
scope: Annotated[TrendScopeType, Query()] = "fleet",
scope_id: Annotated[UUID | None, Query()] = None,
time_range: Annotated[TrendRange, Query(alias="range")] = "24h",
) -> str:
try:
query = MonitoringTrendQuery(
scope=scope, scope_id=scope_id, range=time_range
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if query.scope in {"site", "room"} and not user_has_permission(
current_user, "locations.view"
):
raise HTTPException(
status_code=403,
detail="Location viewing permission is required for this scope",
)
return render_app_page(
"analytics",
current_user,
lambda permission: user_has_permission(current_user, permission),
)
+98
View File
@@ -0,0 +1,98 @@
"""Typed contracts for bounded historical telemetry trends."""
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator
TrendScopeType = Literal["fleet", "site", "room", "miner"]
TrendRange = Literal["6h", "24h", "7d"]
TrendMetric = Literal["hashrate", "power"]
TrendAttribution = Literal["current_placement_cohort", "miner_identity", "fleet"]
class MonitoringTrendQuery(BaseModel):
model_config = ConfigDict(populate_by_name=True)
scope: TrendScopeType = "fleet"
scope_id: UUID | None = None
time_range: TrendRange = Field(default="24h", alias="range")
@model_validator(mode="after")
def validate_scope_identity(self) -> "MonitoringTrendQuery":
if self.scope == "fleet" and self.scope_id is not None:
raise ValueError("scope_id is not allowed for fleet scope")
if self.scope != "fleet" and self.scope_id is None:
raise ValueError("scope_id is required for this scope")
return self
class MonitoringScope(BaseModel):
type: TrendScopeType
id: UUID | None = None
label: str
class MonitoringSiteOption(BaseModel):
id: UUID
name: str
class MonitoringRoomOption(BaseModel):
id: UUID
site_id: UUID
name: str
class MonitoringAvailableScopes(BaseModel):
fleet: bool = True
sites: list[MonitoringSiteOption] = Field(default_factory=list)
rooms: list[MonitoringRoomOption] = Field(default_factory=list)
class MonitoringTrendPoint(BaseModel):
bucket_start: datetime
bucket_end: datetime
value: float | None = None
observation_count: int
metric_sample_count: int
observed_miner_count: int
contributing_miner_count: int
scoped_miner_count: int
incomplete: bool = False
class MonitoringTrendSeries(BaseModel):
metric: TrendMetric
unit: Literal["TH/s", "W"]
label: str
points: list[MonitoringTrendPoint]
class MonitoringTrendData(BaseModel):
scope: MonitoringScope
series: list[MonitoringTrendSeries]
available_scopes: MonitoringAvailableScopes
class MonitoringTrendMeta(BaseModel):
generated_at: datetime
latest_observed_at: datetime | None = None
source: Literal["raw_snapshots"] = "raw_snapshots"
attribution: TrendAttribution
time_range: TrendRange = Field(alias="range")
bucket_seconds: int
maximum_points: int = 200
completeness: Literal["not_guaranteed"] = "not_guaranteed"
current_bucket_incomplete: bool
database_query_count: int = Field(ge=1, le=2)
model_config = ConfigDict(populate_by_name=True)
class MonitoringTrendResponse(BaseModel):
data: MonitoringTrendData
meta: MonitoringTrendMeta
error: None = None
+237
View File
@@ -0,0 +1,237 @@
"""PostgreSQL persistence for bounded monitoring trends."""
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from datetime import datetime, timedelta
from typing import Any
from uuid import UUID
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=5,
)
TREND_SQL = """
WITH requested AS (
SELECT %s::text AS scope_type, %s::uuid AS scope_id,
%s::timestamptz AS window_start, %s::timestamptz AS window_end,
%s::interval AS bucket_width
),
current_locations AS (
SELECT miners.id AS miner_id,
COALESCE(position.site_id, miners.site_id) AS site_id,
COALESCE(position.room_id, miners.room_id) AS room_id
FROM miners
LEFT JOIN LATERAL (
SELECT rooms.site_id, location_positions.room_id
FROM location_positions
LEFT JOIN rooms ON rooms.id = location_positions.room_id
WHERE location_positions.miner_id = miners.id
ORDER BY location_positions.id
LIMIT 1
) AS position ON true
),
scope_meta AS (
SELECT requested.scope_type, requested.scope_id,
CASE requested.scope_type
WHEN 'fleet' THEN true
WHEN 'miner' THEN EXISTS (
SELECT 1 FROM miners WHERE miners.id = requested.scope_id
)
WHEN 'site' THEN EXISTS (
SELECT 1 FROM sites WHERE sites.id = requested.scope_id
)
WHEN 'room' THEN EXISTS (
SELECT 1 FROM rooms WHERE rooms.id = requested.scope_id
)
ELSE false
END AS scope_exists,
CASE requested.scope_type
WHEN 'fleet' THEN 'Fleet'
WHEN 'miner' THEN (
SELECT COALESCE(NULLIF(trim(miners.asset_tag), ''), miners.id::text)
FROM miners WHERE miners.id = requested.scope_id
)
WHEN 'site' THEN (
SELECT sites.name FROM sites WHERE sites.id = requested.scope_id
)
WHEN 'room' THEN (
SELECT rooms.name FROM rooms WHERE rooms.id = requested.scope_id
)
END AS scope_label
FROM requested
),
scoped_miners AS (
SELECT miners.id
FROM miners
JOIN current_locations ON current_locations.miner_id = miners.id
CROSS JOIN requested
WHERE requested.scope_type = 'fleet'
OR (requested.scope_type = 'miner' AND miners.id = requested.scope_id)
OR (requested.scope_type = 'site'
AND current_locations.site_id = requested.scope_id)
OR (requested.scope_type = 'room'
AND current_locations.room_id = requested.scope_id)
),
scope_count AS (
SELECT count(*)::integer AS scoped_miner_count FROM scoped_miners
),
buckets AS (
SELECT bucket_start,
bucket_start + requested.bucket_width AS bucket_end
FROM requested
CROSS JOIN LATERAL generate_series(
requested.window_start,
requested.window_end - requested.bucket_width,
requested.bucket_width
) AS bucket_start
),
per_miner_bucket AS (
SELECT date_bin(
requested.bucket_width,
snapshots.observed_at,
'1970-01-01 00:00:00+00'::timestamptz
) AS bucket_start,
snapshots.miner_id,
count(*)::integer AS observation_count,
count(snapshots.mhs_av)::integer AS hashrate_sample_count,
count(snapshots.power_w)::integer AS power_sample_count,
avg(snapshots.mhs_av) AS average_mhs_av,
avg(snapshots.power_w) AS average_power_w
FROM miner_status_snapshots AS snapshots
JOIN scoped_miners ON scoped_miners.id = snapshots.miner_id
CROSS JOIN requested
WHERE snapshots.observed_at >= requested.window_start
AND snapshots.observed_at < requested.window_end
GROUP BY bucket_start, snapshots.miner_id
),
bucket_totals AS (
SELECT per_miner_bucket.bucket_start,
sum(per_miner_bucket.observation_count)::integer AS observation_count,
sum(per_miner_bucket.hashrate_sample_count)::integer
AS hashrate_sample_count,
sum(per_miner_bucket.power_sample_count)::integer AS power_sample_count,
count(*)::integer AS observed_miner_count,
count(*) FILTER (
WHERE per_miner_bucket.hashrate_sample_count > 0
)::integer AS hashrate_contributing_miner_count,
count(*) FILTER (
WHERE per_miner_bucket.power_sample_count > 0
)::integer AS power_contributing_miner_count,
CASE WHEN sum(per_miner_bucket.hashrate_sample_count) > 0
THEN sum(per_miner_bucket.average_mhs_av) FILTER (
WHERE per_miner_bucket.hashrate_sample_count > 0
) / 1000000.0
END AS hashrate_ths,
CASE WHEN sum(per_miner_bucket.power_sample_count) > 0
THEN sum(per_miner_bucket.average_power_w) FILTER (
WHERE per_miner_bucket.power_sample_count > 0
)
END AS power_w
FROM per_miner_bucket
GROUP BY per_miner_bucket.bucket_start
),
latest AS (
SELECT max(snapshots.observed_at) AS latest_observed_at
FROM miner_status_snapshots AS snapshots
JOIN scoped_miners ON scoped_miners.id = snapshots.miner_id
CROSS JOIN requested
WHERE snapshots.observed_at >= requested.window_start
AND snapshots.observed_at < requested.window_end
)
SELECT scope_meta.scope_exists, scope_meta.scope_label,
buckets.bucket_start, buckets.bucket_end,
bucket_totals.hashrate_ths, bucket_totals.power_w,
COALESCE(bucket_totals.observation_count, 0) AS observation_count,
COALESCE(bucket_totals.hashrate_sample_count, 0) AS hashrate_sample_count,
COALESCE(bucket_totals.power_sample_count, 0) AS power_sample_count,
COALESCE(bucket_totals.observed_miner_count, 0) AS observed_miner_count,
COALESCE(bucket_totals.hashrate_contributing_miner_count, 0)
AS hashrate_contributing_miner_count,
COALESCE(bucket_totals.power_contributing_miner_count, 0)
AS power_contributing_miner_count,
scope_count.scoped_miner_count, latest.latest_observed_at
FROM buckets
CROSS JOIN scope_meta
CROSS JOIN scope_count
CROSS JOIN latest
LEFT JOIN bucket_totals ON bucket_totals.bucket_start = buckets.bucket_start
ORDER BY buckets.bucket_start
"""
SCOPE_OPTIONS_SQL = """
SELECT option_type, id, site_id, name
FROM (
SELECT 'site'::text AS option_type, sites.id,
NULL::uuid AS site_id, sites.name
FROM sites
UNION ALL
SELECT 'room'::text AS option_type, rooms.id,
rooms.site_id, rooms.name
FROM rooms
) AS options
ORDER BY option_type DESC, lower(name), id
"""
class MonitoringRepository:
"""Own the set-based raw-snapshot monitoring read path."""
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_trends(
self,
*,
scope: str,
scope_id: UUID | None,
window_start: datetime,
window_end: datetime,
bucket_width: timedelta,
) -> list[dict[str, Any]]:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
TREND_SQL,
(scope, scope_id, window_start, window_end, bucket_width),
)
return [dict(row) for row in cur.fetchall()]
def list_location_scope_options(self) -> list[dict[str, Any]]:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(SCOPE_OPTIONS_SQL)
return [dict(row) for row in cur.fetchall()]
+61
View File
@@ -0,0 +1,61 @@
"""HTTP boundary for bounded historical monitoring trends."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from app.identity import require_permission, user_has_permission
from app.monitoring.dependencies import get_monitoring_service
from app.monitoring.exceptions import MonitoringScopeNotFound
from app.monitoring.models import (
MonitoringTrendQuery,
MonitoringTrendResponse,
TrendRange,
TrendScopeType,
)
from app.monitoring.service import MonitoringService
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
MonitoringUser = Annotated[dict, Depends(require_permission("miners.view"))]
MonitoringServiceDependency = Annotated[
MonitoringService,
Depends(get_monitoring_service),
]
@router.get("/trends", response_model=MonitoringTrendResponse)
def get_monitoring_trends(
current_user: MonitoringUser,
service: MonitoringServiceDependency,
response: Response,
scope: Annotated[TrendScopeType, Query()] = "fleet",
scope_id: Annotated[UUID | None, Query()] = None,
time_range: Annotated[TrendRange, Query(alias="range")] = "24h",
) -> MonitoringTrendResponse:
try:
query = MonitoringTrendQuery(
scope=scope, scope_id=scope_id, range=time_range
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
) from exc
can_view_locations = user_has_permission(current_user, "locations.view")
if query.scope in {"site", "room"} and not can_view_locations:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Location viewing permission is required for this scope",
)
response.headers["Cache-Control"] = "no-store"
try:
return service.get_trends(
query,
include_location_scopes=can_view_locations,
)
except MonitoringScopeNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
+173
View File
@@ -0,0 +1,173 @@
"""Business semantics for bounded historical monitoring trends."""
from datetime import datetime, timedelta, timezone
from uuid import UUID
from app.monitoring.exceptions import MonitoringScopeNotFound
from app.monitoring.models import (
MonitoringAvailableScopes,
MonitoringRoomOption,
MonitoringScope,
MonitoringSiteOption,
MonitoringTrendData,
MonitoringTrendMeta,
MonitoringTrendPoint,
MonitoringTrendQuery,
MonitoringTrendResponse,
MonitoringTrendSeries,
)
from app.monitoring.repository import MonitoringRepository
RANGE_CONFIGURATION = {
"6h": (timedelta(hours=6), timedelta(minutes=5)),
"24h": (timedelta(hours=24), timedelta(minutes=15)),
"7d": (timedelta(days=7), timedelta(hours=1)),
}
MAXIMUM_POINTS = 200
class MonitoringService:
"""Coordinate factual telemetry aggregation without HTTP dependencies."""
def __init__(self, repository: MonitoringRepository):
self.repository = repository
@staticmethod
def _window(
query: MonitoringTrendQuery,
generated_at: datetime,
) -> tuple[datetime, datetime, timedelta]:
duration, bucket_width = RANGE_CONFIGURATION[query.time_range]
generated_at = generated_at.astimezone(timezone.utc)
bucket_seconds = int(bucket_width.total_seconds())
current_bucket_epoch = (
int(generated_at.timestamp()) // bucket_seconds * bucket_seconds
)
current_bucket_start = datetime.fromtimestamp(
current_bucket_epoch, tz=timezone.utc
)
window_end = current_bucket_start + bucket_width
return window_end - duration, window_end, bucket_width
def get_trends(
self,
query: MonitoringTrendQuery,
*,
include_location_scopes: bool,
generated_at: datetime | None = None,
) -> MonitoringTrendResponse:
generated_at = generated_at or datetime.now(timezone.utc)
window_start, window_end, bucket_width = self._window(query, generated_at)
rows = self.repository.read_trends(
scope=query.scope,
scope_id=query.scope_id,
window_start=window_start,
window_end=window_end,
bucket_width=bucket_width,
)
if not rows or not rows[0]["scope_exists"]:
raise MonitoringScopeNotFound(f"{query.scope.title()} not found")
options = MonitoringAvailableScopes()
query_count = 1
if include_location_scopes:
option_rows = self.repository.list_location_scope_options()
query_count += 1
options = MonitoringAvailableScopes(
sites=[
MonitoringSiteOption.model_validate(row)
for row in option_rows
if row["option_type"] == "site"
],
rooms=[
MonitoringRoomOption.model_validate(row)
for row in option_rows
if row["option_type"] == "room"
],
)
generated_at = generated_at.astimezone(timezone.utc)
hashrate_points = []
power_points = []
for row in rows:
shared = {
"bucket_start": row["bucket_start"],
"bucket_end": row["bucket_end"],
"observation_count": row["observation_count"],
"observed_miner_count": row["observed_miner_count"],
"scoped_miner_count": row["scoped_miner_count"],
"incomplete": row["bucket_end"] > generated_at,
}
hashrate_points.append(
MonitoringTrendPoint(
**shared,
value=(
float(row["hashrate_ths"])
if row["hashrate_ths"] is not None
else None
),
metric_sample_count=row["hashrate_sample_count"],
contributing_miner_count=(
row["hashrate_contributing_miner_count"]
),
)
)
power_points.append(
MonitoringTrendPoint(
**shared,
value=(
float(row["power_w"])
if row["power_w"] is not None
else None
),
metric_sample_count=row["power_sample_count"],
contributing_miner_count=(
row["power_contributing_miner_count"]
),
)
)
if len(rows) > MAXIMUM_POINTS:
raise RuntimeError("Monitoring query exceeded the fixed point bound")
attribution = (
"current_placement_cohort"
if query.scope in {"site", "room"}
else "miner_identity"
if query.scope == "miner"
else "fleet"
)
latest_observed_at = rows[0]["latest_observed_at"]
return MonitoringTrendResponse(
data=MonitoringTrendData(
scope=MonitoringScope(
type=query.scope,
id=query.scope_id,
label=rows[0]["scope_label"],
),
series=[
MonitoringTrendSeries(
metric="hashrate",
unit="TH/s",
label="Average recorded hashrate",
points=hashrate_points,
),
MonitoringTrendSeries(
metric="power",
unit="W",
label="Average observed power",
points=power_points,
),
],
available_scopes=options,
),
meta=MonitoringTrendMeta(
generated_at=generated_at,
latest_observed_at=latest_observed_at,
attribution=attribution,
range=query.time_range,
bucket_seconds=int(bucket_width.total_seconds()),
current_bucket_incomplete=window_end > generated_at,
database_query_count=query_count,
),
)
+61
View File
@@ -0,0 +1,61 @@
"""Repair workflow models, persistence, and business services."""
from app.repairs.exceptions import (
ActiveRepairConflict,
IncompatibleMinerLifecycle,
InconsistentRepairState,
RepairActiveMismatch,
RepairActorRequired,
RepairCompletionActorRequired,
RepairConflict,
RepairError,
RepairMinerNotFound,
RepairMinerNotInRepair,
RepairNotActive,
RepairNotFound,
RepairPersistenceError,
)
from app.repairs.models import (
BeginRepairRequest,
BeginRepairResult,
RepairActor,
RepairCompleteRequest,
RepairCompletedPayload,
RepairDetail,
RepairPostLifecycle,
RepairReason,
RepairStatus,
RepairTimeline,
RepairTimelineEvent,
)
from app.repairs.repository import RepairRepository
from app.repairs.service import RepairService
__all__ = [
"ActiveRepairConflict",
"BeginRepairRequest",
"BeginRepairResult",
"IncompatibleMinerLifecycle",
"InconsistentRepairState",
"RepairActiveMismatch",
"RepairActor",
"RepairActorRequired",
"RepairCompleteRequest",
"RepairCompletedPayload",
"RepairCompletionActorRequired",
"RepairConflict",
"RepairDetail",
"RepairError",
"RepairMinerNotFound",
"RepairMinerNotInRepair",
"RepairNotActive",
"RepairNotFound",
"RepairPersistenceError",
"RepairPostLifecycle",
"RepairReason",
"RepairRepository",
"RepairService",
"RepairStatus",
"RepairTimeline",
"RepairTimelineEvent",
]
+366
View File
@@ -0,0 +1,366 @@
"""Set-based PostgreSQL read model for the Repair Center."""
from collections.abc import Callable, Mapping
from typing import Any
import psycopg
from psycopg.rows import dict_row
from app.repairs.repository import default_connection_factory
ConnectionFactory = Callable[[], psycopg.Connection]
class RepairCenterRepository:
"""Read-only persistence boundary for consolidated repair operations."""
def __init__(self, connection_factory: ConnectionFactory | None = None):
self._connection_factory = connection_factory or default_connection_factory
@staticmethod
def _ctes(*, show_miners: bool, show_locations: bool) -> str:
miner_fields = (
"miner.asset_tag AS asset_id, COALESCE(miner.model, miner.miner_type) AS model, miner.miner_sn, miner.power_sn, "
"miner.current_mac, host(miner.current_ip) AS current_ip, "
"miner.status AS runtime_status"
if show_miners else
"NULL::text AS asset_id, NULL::text AS model, NULL::text AS miner_sn, NULL::text AS power_sn, "
"NULL::text AS current_mac, NULL::text AS current_ip, "
"NULL::text AS runtime_status"
)
location_fields = (
"placement.site_id, placement.site_name, placement.room_id, "
"placement.room_name, placement.rack_label, placement.shelf_label, "
"placement.position_id, placement.position_label, placement.position_index"
if show_locations else
"NULL::uuid AS site_id, NULL::text AS site_name, NULL::uuid AS room_id, "
"NULL::text AS room_name, NULL::text AS rack_label, "
"NULL::text AS shelf_label, NULL::uuid AS position_id, "
"NULL::text AS position_label, NULL::integer AS position_index"
)
return f"""
WITH latest_legacy AS (
SELECT DISTINCT ON (event.miner_id)
event.miner_id, event.event_status, event.event_title,
event.description, event.event_at, event.created_at
FROM miner_maintenance_events AS event
WHERE event.event_type = 'repair'
ORDER BY event.miner_id, event.event_at DESC,
event.created_at DESC, event.id DESC
),
legacy_first AS (
SELECT event.miner_id,
min(COALESCE(event.event_at, event.created_at)) AS opened_at
FROM miner_maintenance_events AS event
WHERE event.event_type = 'repair'
GROUP BY event.miner_id
),
legacy_counts AS (
SELECT event.miner_id,
count(*)::integer AS repair_event_count,
count(*) FILTER (WHERE event.event_status IN (
'open', 'diagnosing', 'waiting_parts', 'in_progress',
'testing', 'ready_to_shelf', 'needs_followup'
))::integer AS open_repair_event_count
FROM miner_maintenance_events AS event
WHERE event.event_type = 'repair'
GROUP BY event.miner_id
),
latest_modular AS (
SELECT DISTINCT ON (event.repair_id)
event.repair_id, event.payload, event.created_at
FROM repair_events AS event
ORDER BY event.repair_id, event.sequence DESC, event.id DESC
),
initial_note AS (
SELECT DISTINCT ON (event.repair_id)
event.repair_id, event.payload ->> 'note' AS note
FROM repair_events AS event
WHERE event.event_type = 'INITIAL_NOTE'
ORDER BY event.repair_id, event.sequence, event.id
),
part_rollup AS (
SELECT part.repair_id,
count(*) FILTER (WHERE part.voided_at IS NULL)::integer AS parts_count,
COALESCE((array_agg(
CASE WHEN part.consumable_description IS NOT NULL
THEN part.consumable_description
ELSE concat_ws(' ', part.component_type_snapshot,
part.component_asset_id_snapshot,
part.component_manufacturer_serial_snapshot)
END ORDER BY part.recorded_at, part.id
) FILTER (WHERE part.voided_at IS NULL))[1:3],
ARRAY[]::text[]) AS parts_summary
FROM repair_parts AS part GROUP BY part.repair_id
),
placement AS (
SELECT DISTINCT ON (position.miner_id)
position.miner_id, site.id AS site_id, site.name AS site_name,
room.id AS room_id, COALESCE(room.name, position.room_name) AS room_name,
position.rack_name AS rack_label, position.shelf_name AS shelf_label,
position.id AS position_id, position.position_label,
position.position_index
FROM location_positions AS position
LEFT JOIN rooms AS room ON room.id = position.room_id
LEFT JOIN sites AS site ON site.id = room.site_id
WHERE position.miner_id IS NOT NULL
ORDER BY position.miner_id, position.id
),
modular_rows AS (
SELECT repair.id AS repair_id, 'modular'::text AS source,
repair.status = ANY(ARRAY['OPEN', 'IN_PROGRESS',
'WAITING_FOR_PARTS', 'READY_FOR_TEST']) AS active,
repair.opened_at, repair.completed_at AS closed_at,
repair.status AS repair_status, repair.reason,
COALESCE(NULLIF(trim(miner.manual_details ->
'office_repair_import' ->> 'reported_error'), ''),
NULLIF(trim(initial_note.note), ''),
replace(repair.reason, '_', ' ')) AS reported_problem,
COALESCE(NULLIF(trim(latest_modular.payload ->> 'note'), ''),
NULLIF(trim(latest_modular.payload ->> 'notes'), ''),
NULLIF(trim(latest_modular.payload ->> 'completion_summary'), ''),
NULLIF(trim(initial_note.note), ''),
NULLIF(trim(latest_legacy.description), ''),
NULLIF(trim(location_state.state_notes), '')) AS latest_context,
COALESCE(latest_modular.created_at, latest_legacy.event_at,
latest_legacy.created_at, repair.opened_at) AS latest_context_at,
opened_actor.display_name AS opened_by_display_name,
miner.id AS miner_id, {miner_fields}, miner.lifecycle_status,
{location_fields},
COALESCE(part_rollup.parts_count, 0) AS parts_count,
COALESCE(part_rollup.parts_summary, ARRAY[]::text[]) AS parts_summary,
latest_legacy.event_status AS legacy_event_status,
location_state.asset_state AS legacy_asset_state,
latest_legacy.event_title AS latest_event_title,
COALESCE(legacy_counts.repair_event_count, 0) AS repair_event_count,
COALESCE(legacy_counts.open_repair_event_count, 0) AS open_repair_event_count,
miner.manual_details -> 'office_repair_import' ->> 'reported_hashrate_ths' AS reported_hashrate_ths,
miner.manual_details -> 'office_repair_import' ->> 'reported_voltage' AS reported_voltage,
miner.manual_details -> 'office_repair_import' ->> 'location_note' AS location_note,
miner.manual_details -> 'office_repair_import' ->> 'source' AS import_source,
location_state.updated_at AS state_updated_at
FROM repairs AS repair
JOIN miners AS miner ON miner.id = repair.miner_id
JOIN users AS opened_actor ON opened_actor.id = repair.opened_by
LEFT JOIN latest_modular ON latest_modular.repair_id = repair.id
LEFT JOIN initial_note ON initial_note.repair_id = repair.id
LEFT JOIN part_rollup ON part_rollup.repair_id = repair.id
LEFT JOIN latest_legacy ON latest_legacy.miner_id = miner.id
LEFT JOIN legacy_counts ON legacy_counts.miner_id = miner.id
LEFT JOIN miner_location_states AS location_state
ON location_state.miner_id = miner.id
LEFT JOIN placement ON placement.miner_id = miner.id
),
legacy_rows AS (
SELECT NULL::uuid AS repair_id, 'legacy'::text AS source,
true AS active, legacy_first.opened_at, NULL::timestamptz AS closed_at,
COALESCE(latest_legacy.event_status,
location_state.asset_state, 'unknown') AS repair_status,
NULL::text AS reason,
COALESCE(NULLIF(trim(miner.manual_details ->
'office_repair_import' ->> 'reported_error'), ''),
NULLIF(trim(latest_legacy.event_title), '')) AS reported_problem,
COALESCE(NULLIF(trim(latest_legacy.description), ''),
NULLIF(trim(location_state.state_notes), '')) AS latest_context,
COALESCE(latest_legacy.event_at, latest_legacy.created_at,
location_state.updated_at) AS latest_context_at,
NULL::text AS opened_by_display_name,
miner.id AS miner_id, {miner_fields}, miner.lifecycle_status,
{location_fields}, NULL::integer AS parts_count,
ARRAY[]::text[] AS parts_summary,
latest_legacy.event_status AS legacy_event_status,
location_state.asset_state AS legacy_asset_state,
latest_legacy.event_title AS latest_event_title,
COALESCE(legacy_counts.repair_event_count, 0) AS repair_event_count,
COALESCE(legacy_counts.open_repair_event_count, 0) AS open_repair_event_count,
miner.manual_details -> 'office_repair_import' ->> 'reported_hashrate_ths' AS reported_hashrate_ths,
miner.manual_details -> 'office_repair_import' ->> 'reported_voltage' AS reported_voltage,
miner.manual_details -> 'office_repair_import' ->> 'location_note' AS location_note,
miner.manual_details -> 'office_repair_import' ->> 'source' AS import_source,
location_state.updated_at AS state_updated_at
FROM miner_location_states AS location_state
JOIN miners AS miner ON miner.id = location_state.miner_id
LEFT JOIN latest_legacy ON latest_legacy.miner_id = miner.id
LEFT JOIN legacy_counts ON legacy_counts.miner_id = miner.id
LEFT JOIN legacy_first ON legacy_first.miner_id = miner.id
LEFT JOIN placement ON placement.miner_id = miner.id
WHERE location_state.asset_state = 'repair'
AND NOT EXISTS (
SELECT 1 FROM repairs AS active_repair
WHERE active_repair.miner_id = miner.id
AND active_repair.status = ANY(ARRAY['OPEN', 'IN_PROGRESS',
'WAITING_FOR_PARTS', 'READY_FOR_TEST'])
)
),
candidates AS (
SELECT * FROM modular_rows
UNION ALL
SELECT * FROM legacy_rows
)
"""
@staticmethod
def _where(filters: Mapping[str, Any]) -> tuple[str, list[Any]]:
conditions: list[str] = []
params: list[Any] = []
scope = filters.get("scope", "active")
if scope == "active":
conditions.append("candidate.active")
elif scope == "historical":
conditions.append("NOT candidate.active")
for key, column in (
("status", "repair_status"),
("lifecycle_status", "lifecycle_status"),
("site_id", "site_id"),
("room_id", "room_id"),
):
if filters.get(key):
conditions.append(f"candidate.{column} = %s")
params.append(filters[key])
if filters.get("model"):
conditions.append("lower(trim(candidate.model)) = lower(trim(%s))")
params.append(filters["model"])
if filters.get("query"):
conditions.append(
"concat_ws(' ', candidate.repair_id, candidate.miner_id, "
"candidate.asset_id, candidate.model, candidate.miner_sn, "
"candidate.current_mac, candidate.current_ip, "
"candidate.repair_status, candidate.reason, "
"candidate.reported_problem, candidate.latest_context) ILIKE %s"
)
params.append(f"%{filters['query']}%")
return " AND ".join(conditions) if conditions else "true", params
def list_repair_center(
self,
filters: Mapping[str, Any],
*,
include_miner_details: bool,
include_location_details: bool,
) -> dict[str, Any]:
ctes = self._ctes(
show_miners=include_miner_details,
show_locations=include_location_details,
)
where_sql, params = self._where(filters)
item_sql = f"""
{ctes}
SELECT candidate.* FROM candidates AS candidate
WHERE {where_sql}
ORDER BY candidate.active DESC,
CASE WHEN candidate.active THEN candidate.opened_at END ASC NULLS LAST,
candidate.opened_at DESC NULLS LAST,
candidate.repair_id NULLS LAST, candidate.miner_id
LIMIT %s OFFSET %s
"""
summary_sql = f"""
{ctes}, filtered AS (
SELECT * FROM candidates AS candidate WHERE {where_sql}
)
SELECT count(*)::integer AS total,
count(*) FILTER (WHERE active)::integer AS active,
count(*) FILTER (WHERE NOT active)::integer AS historical,
COALESCE((SELECT jsonb_object_agg(status_rows.repair_status,
status_rows.status_count) FROM (
SELECT repair_status, count(*)::integer AS status_count
FROM filtered GROUP BY repair_status
) AS status_rows), '{{}}'::jsonb) AS status_counts,
COALESCE((SELECT jsonb_object_agg(model_rows.model,
model_rows.model_count) FROM (
SELECT model, count(*)::integer AS model_count
FROM filtered WHERE model IS NOT NULL GROUP BY model
) AS model_rows), '{{}}'::jsonb) AS model_counts,
COALESCE((SELECT jsonb_object_agg(error_rows.error,
error_rows.error_count) FROM (
SELECT COALESCE(reported_problem, 'No reported error') AS error,
count(*)::integer AS error_count
FROM filtered
GROUP BY COALESCE(reported_problem, 'No reported error')
) AS error_rows), '{{}}'::jsonb) AS error_counts
FROM filtered
"""
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
item_sql,
[*params, int(filters.get("limit", 50)),
int(filters.get("offset", 0))],
)
items = [dict(row) for row in cur.fetchall()]
cur.execute(summary_sql, params)
summary = dict(cur.fetchone())
options = {"sites": [], "rooms": []}
if include_location_details:
cur.execute(
f"""
{ctes}
SELECT jsonb_build_object(
'sites', COALESCE((SELECT jsonb_agg(site_rows
ORDER BY lower(site_rows.name), site_rows.id)
FROM (SELECT DISTINCT site_id AS id, site_name AS name
FROM candidates WHERE site_id IS NOT NULL) site_rows),
'[]'::jsonb),
'rooms', COALESCE((SELECT jsonb_agg(room_rows
ORDER BY lower(room_rows.name), room_rows.id)
FROM (SELECT DISTINCT room_id AS id, site_id,
room_name AS name FROM candidates
WHERE room_id IS NOT NULL) room_rows), '[]'::jsonb)
) AS options
"""
)
options = cur.fetchone()["options"]
finally:
connection.close()
return {"items": items, "summary": summary, "location_options": options}
def list_miner_repairs(self, miner_id) -> list[dict[str, Any]]:
"""Return a bounded, exact repair history for one durable miner."""
ctes = self._ctes(show_miners=False, show_locations=False)
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
{ctes}
SELECT candidate.* FROM candidates AS candidate
WHERE candidate.miner_id = %s
ORDER BY candidate.active DESC,
candidate.opened_at DESC NULLS LAST,
candidate.repair_id NULLS LAST
LIMIT 50
""",
(miner_id,),
)
return [dict(row) for row in cur.fetchall()]
finally:
connection.close()
def summarize_active_repairs(self) -> dict[str, Any]:
"""Return exact active counts without loading Repair Center rows."""
ctes = self._ctes(show_miners=False, show_locations=False)
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
{ctes}, active_rows AS (
SELECT repair_status FROM candidates WHERE active
)
SELECT count(*)::integer AS active,
COALESCE((SELECT jsonb_object_agg(
status_rows.repair_status,
status_rows.status_count
) FROM (
SELECT repair_status,
count(*)::integer AS status_count
FROM active_rows
GROUP BY repair_status
) AS status_rows), '{{}}'::jsonb) AS status_counts
FROM active_rows
"""
)
return dict(cur.fetchone())
finally:
connection.close()
+124
View File
@@ -0,0 +1,124 @@
"""Application service for the consolidated Repair Center read model."""
from app.repairs.center_repository import RepairCenterRepository
from app.repairs.models import (
ActiveRepairSummary,
RepairCenterAccess,
RepairCenterCapabilities,
RepairCenterFilters,
RepairCenterItem,
RepairCenterList,
RepairCenterLocationOptions,
RepairCenterMeta,
RepairCenterResponse,
RepairCenterSummary,
)
class RepairCenterService:
"""Build typed, permission-aware Repair Center projections."""
def __init__(self, repository: RepairCenterRepository):
self.repository = repository
@staticmethod
def _capabilities(
item: RepairCenterItem,
access: RepairCenterAccess,
) -> RepairCenterCapabilities:
modular = item.source == "modular" and item.repair_id is not None
legacy = item.source == "legacy"
placed = bool(item.site_id and item.room_id and item.position_id)
return RepairCenterCapabilities(
can_open_miner=access.can_view_miners,
can_open_map=access.can_view_locations and placed,
can_read_modular_timeline=modular and access.can_read_repairs,
can_read_modular_parts=modular and access.can_read_repairs,
can_manage_modular_parts=(
modular and item.active and access.can_read_repairs
and access.can_manage_parts
),
can_complete_modular_repair=(
modular and item.active and access.can_complete_repairs
),
can_read_legacy_history=legacy and access.can_read_repairs,
can_manage_legacy=legacy and access.can_manage_legacy,
can_mark_legacy_repaired=legacy and access.can_manage_legacy,
can_decommission_legacy=legacy and access.can_manage_legacy,
)
def list_repair_center(
self,
filters: RepairCenterFilters,
access: RepairCenterAccess,
) -> RepairCenterResponse:
query = filters.query.strip() if filters.query else None
model = filters.model.strip() if filters.model else None
status = filters.status.strip() if filters.status else None
repository_filters = filters.model_dump()
repository_filters.update(query=query or None, model=model or None,
status=status or None)
projection = self.repository.list_repair_center(
repository_filters,
include_miner_details=access.can_view_miners,
include_location_details=access.can_view_locations,
)
items: list[RepairCenterItem] = []
for row in projection["items"]:
row = {
**row,
"miner_status": row.get("runtime_status"),
"latest_event_status": row.get("legacy_event_status"),
"latest_event_description": row.get("latest_context"),
"latest_event_at": row.get("latest_context_at"),
"asset_state": row.get("legacy_asset_state"),
"state_notes": row.get("latest_context"),
"reported_error": row.get("reported_problem"),
}
item = RepairCenterItem.model_validate(
{**row, "capabilities": RepairCenterCapabilities()}
)
item.capabilities = self._capabilities(item, access)
items.append(item)
summary = RepairCenterSummary.model_validate(projection["summary"])
location_options = RepairCenterLocationOptions.model_validate(
projection.get("location_options") or {}
)
return RepairCenterResponse(
data=RepairCenterList(items=items, summary=summary),
meta=RepairCenterMeta(
limit=filters.limit,
offset=filters.offset,
returned=len(items),
total=summary.total,
location_options=location_options,
),
)
def list_miner_repairs(
self, miner_id, access: RepairCenterAccess
) -> list[RepairCenterItem]:
items = []
for row in self.repository.list_miner_repairs(miner_id):
mapped = {
**row,
"miner_status": row.get("runtime_status"),
"latest_event_status": row.get("legacy_event_status"),
"latest_event_description": row.get("latest_context"),
"latest_event_at": row.get("latest_context_at"),
"asset_state": row.get("legacy_asset_state"),
"state_notes": row.get("latest_context"),
"reported_error": row.get("reported_problem"),
}
item = RepairCenterItem.model_validate(
{**mapped, "capabilities": RepairCenterCapabilities()}
)
item.capabilities = self._capabilities(item, access)
items.append(item)
return items
def summarize_active_repairs(self) -> ActiveRepairSummary:
return ActiveRepairSummary.model_validate(
self.repository.summarize_active_repairs()
)
+14
View File
@@ -0,0 +1,14 @@
"""Dependency construction for the Repairs domain."""
from app.repairs.repository import RepairRepository
from app.repairs.center_repository import RepairCenterRepository
from app.repairs.center_service import RepairCenterService
from app.repairs.service import RepairService
def get_repair_service() -> RepairService:
return RepairService(RepairRepository())
def get_repair_center_service() -> RepairCenterService:
return RepairCenterService(RepairCenterRepository())
+172
View File
@@ -0,0 +1,172 @@
"""Domain exceptions for repair workflows."""
class RepairError(Exception):
"""Base exception for repair operations."""
class RepairMinerNotFound(RepairError):
"""Raised when the requested miner does not exist."""
def __init__(self, miner_id: object):
self.miner_id = miner_id
super().__init__(f"Miner not found: {miner_id}")
class RepairNotFound(RepairError):
"""Raised when the requested repair does not exist."""
def __init__(self, repair_id: object):
self.repair_id = repair_id
super().__init__(f"Repair not found: {repair_id}")
class RepairConflict(RepairError):
"""Base exception for a conflicting repair state."""
class ActiveRepairConflict(RepairConflict):
"""Raised when a miner already has an active repair."""
def __init__(self, miner_id: object):
self.miner_id = miner_id
super().__init__(f"Miner already has an active repair: {miner_id}")
class IncompatibleMinerLifecycle(RepairConflict):
"""Raised when the miner lifecycle cannot enter repair."""
def __init__(self, miner_id: object, lifecycle_status: object):
self.miner_id = miner_id
self.lifecycle_status = lifecycle_status
super().__init__(
"Miner lifecycle is incompatible with beginning repair: "
f"{lifecycle_status}"
)
class InconsistentRepairState(RepairConflict):
"""Raised when lifecycle state and repair records disagree."""
def __init__(self, miner_id: object):
self.miner_id = miner_id
super().__init__(
"Miner is marked in repair but has no active repair record"
)
class RepairActorRequired(RepairError):
"""Raised when an operational workflow lacks an authenticated actor."""
def __init__(self):
super().__init__("An authenticated actor is required to begin repair")
class RepairCompletionActorRequired(RepairError):
"""Raised when Return to Service lacks an authenticated actor."""
def __init__(self):
super().__init__(
"An authenticated actor is required to return a miner to service"
)
class RepairNotActive(RepairConflict):
"""Raised when a repair is no longer active."""
def __init__(self, repair_id: object, repair_status: object):
self.repair_id = repair_id
self.repair_status = repair_status
super().__init__(
f"Repair is not active: {repair_id} ({repair_status})"
)
class RepairMinerNotInRepair(RepairConflict):
"""Raised when a repair's miner has already left repair lifecycle."""
def __init__(self, miner_id: object, lifecycle_status: object):
self.miner_id = miner_id
self.lifecycle_status = lifecycle_status
super().__init__(
"Miner is not in repair lifecycle: "
f"{miner_id} ({lifecycle_status})"
)
class RepairActiveMismatch(RepairConflict):
"""Raised when the requested repair is not the miner's active repair."""
def __init__(self, repair_id: object, miner_id: object):
self.repair_id = repair_id
self.miner_id = miner_id
super().__init__(
f"Repair is not the active repair for miner: {repair_id}"
)
class RepairPersistenceError(RepairError):
"""Raised when a required repair persistence operation fails."""
class ControlBoardReplacementActorRequired(RepairError):
"""Raised when a board replacement lacks an authenticated actor."""
def __init__(self):
super().__init__("An authenticated actor is required to replace a control board")
class ControlBoardReplacementConflict(RepairConflict):
"""Raised when current repair, component, or MAC state blocks replacement."""
class InvalidMacAddress(RepairError):
"""Raised when a replacement MAC is malformed or prohibited."""
def __init__(self, message: str = "New MAC address is invalid"):
super().__init__(message)
class MacOwnershipConflict(ControlBoardReplacementConflict):
"""Raised when a MAC belongs to another durable miner."""
def __init__(self, normalized_mac: str):
self.normalized_mac = normalized_mac
super().__init__(
"MAC address has current or historical ownership on another miner"
)
class RepairPartNotFound(RepairError):
"""Raised when a nested repair-part UUID does not exist for a repair."""
def __init__(self, repair_part_id: object):
self.repair_part_id = repair_part_id
super().__init__(f"Repair part not found: {repair_part_id}")
class RepairPartComponentNotFound(RepairError):
"""Raised when a tracked component UUID does not exist."""
def __init__(self, component_id: object):
self.component_id = component_id
super().__init__(f"Component not found: {component_id}")
class RepairPartValidationError(RepairError):
"""Raised when repair-part input violates a domain rule."""
class RepairPartActorRequired(RepairError):
"""Raised when a repair-part action lacks an authenticated actor."""
def __init__(self):
super().__init__("An authenticated actor is required for repair-part actions")
class RepairPartAlreadyVoided(RepairConflict):
"""Raised when an immutable repair-part entry was already voided."""
def __init__(self, repair_part_id: object):
self.repair_part_id = repair_part_id
super().__init__(f"Repair part is already voided: {repair_part_id}")
+429
View File
@@ -0,0 +1,429 @@
"""Typed contracts for the Repairs domain."""
from datetime import datetime
from decimal import Decimal
from typing import Any, Literal
from uuid import UUID
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator
from app.components.models import ComponentType
RepairReason = Literal[
"HARDWARE_FAILURE",
"LOW_HASHRATE",
"CONTROL_BOARD",
"HASHBOARD",
"PSU",
"FAN",
"OVERHEATING",
"NETWORKING",
"PREVENTIVE_MAINTENANCE",
"INSPECTION",
"OTHER",
]
RepairStatus = Literal[
"OPEN",
"IN_PROGRESS",
"WAITING_FOR_PARTS",
"READY_FOR_TEST",
"COMPLETED",
"CANCELLED",
]
RepairEventType = Literal[
"REPAIR_STARTED",
"INITIAL_NOTE",
"REPAIR_COMPLETED",
"CONTROL_BOARD_REPLACED",
"REPAIR_PART_RECORDED",
"REPAIR_PART_VOIDED",
]
RepairEligibleLifecycle = Literal["active", "spare"]
RepairPostLifecycle = Literal["active", "spare"]
RepairMinerLifecycle = Literal["active", "spare", "retired", "in_repair"]
class BeginRepairRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
miner_id: UUID
reason: RepairReason
notes: str | None = Field(default=None, max_length=4000)
class RepairCompleteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
target_lifecycle: RepairPostLifecycle
completion_summary: str | None = Field(default=None, max_length=4000)
ControlBoardReplacementReason = Literal[
"failed",
"upgrade",
"diagnostic_swap",
"preventive_maintenance",
"temporary_donor",
"unknown",
]
RemovedBoardDisposition = Literal[
"repair_inventory",
"sent_for_repair",
"scrapped",
"retained_for_diagnosis",
"returned_to_vendor",
"unknown",
]
class ControlBoardReplacementRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
new_mac_address: str = Field(min_length=1, max_length=64)
new_control_board_serial: str | None = Field(..., max_length=255)
board_model: str | None = Field(default=None, max_length=255)
board_revision: str | None = Field(default=None, max_length=255)
firmware_version: str | None = Field(default=None, max_length=255)
removed_board_disposition: RemovedBoardDisposition
replacement_reason: ControlBoardReplacementReason
notes: str | None = Field(default=None, max_length=4000)
class RepairStartedPayload(BaseModel):
reason: RepairReason
from_lifecycle: RepairEligibleLifecycle
to_lifecycle: Literal["in_repair"] = "in_repair"
class InitialRepairNotePayload(BaseModel):
note: str = Field(min_length=1, max_length=4000)
class RepairCompletedPayload(BaseModel):
previous_miner_lifecycle: Literal["in_repair"] = "in_repair"
resulting_miner_lifecycle: RepairPostLifecycle
pre_repair_lifecycle: RepairEligibleLifecycle | None = None
completion_summary: str | None = Field(default=None, max_length=4000)
class ControlBoardReplacedPayload(BaseModel):
old_mac_address: str | None
new_mac_address: str
old_control_board_serial: str | None
new_control_board_serial: str | None
old_board_model: str | None = None
new_board_model: str | None = None
old_board_revision: str | None = None
new_board_revision: str | None = None
new_firmware_version: str | None = None
removed_board_disposition: RemovedBoardDisposition
replacement_reason: ControlBoardReplacementReason
notes: str | None = Field(default=None, max_length=4000)
class ControlBoardReplacementResult(BaseModel):
miner_id: UUID
asset_id: str | None = None
repair_id: UUID
installation_id: UUID
event_id: UUID
event_sequence: int = Field(ge=1)
current_mac: str
control_board_serial: str | None
replaced_at: AwareDatetime
replaced_by: UUID
class ControlBoardReplacementResponse(BaseModel):
data: ControlBoardReplacementResult
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class BeginRepairResult(BaseModel):
repair_id: UUID
miner_id: UUID
status: Literal["OPEN"]
reason: RepairReason
opened_at: datetime
opened_by: UUID
class BeginRepairResponse(BaseModel):
data: BeginRepairResult
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class RepairActor(BaseModel):
user_id: UUID
display_name: str
class RepairDetail(BaseModel):
repair_id: UUID
miner_id: UUID
miner_asset_id: str | None = None
status: RepairStatus
reason: RepairReason
opened_at: AwareDatetime
opened_by: RepairActor
closed_at: AwareDatetime | None = None
closed_by: RepairActor | None = None
miner_lifecycle: RepairMinerLifecycle
latest_event_sequence: int | None = Field(default=None, ge=1)
class RepairTimelineEvent(BaseModel):
event_id: UUID
sequence: int = Field(ge=1)
event_type: RepairEventType
actor: RepairActor
occurred_at: AwareDatetime
payload: dict[str, Any]
class RepairTimeline(BaseModel):
repair_id: UUID
events: list[RepairTimelineEvent] = Field(default_factory=list)
class RepairDetailResponse(BaseModel):
data: RepairDetail
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class RepairTimelineResponse(BaseModel):
data: RepairTimeline
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
ConsumableUnit = Literal["each", "gram", "millilitre", "metre", "centimetre"]
RepairPartEntryKind = Literal["consumable", "tracked_component"]
class ConsumableInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
description: str = Field(min_length=1, max_length=255)
quantity: Decimal = Field(gt=0, max_digits=12, decimal_places=3)
unit: ConsumableUnit
@model_validator(mode="after")
def validate_each_quantity(self) -> "ConsumableInput":
if self.unit == "each" and self.quantity != self.quantity.to_integral_value():
raise ValueError("quantity for unit 'each' must be a whole number")
return self
class RepairPartRecordRequest(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
consumable: ConsumableInput | None = None
tracked_component_uuid: UUID | None = None
notes: str | None = Field(default=None, max_length=4000)
@model_validator(mode="after")
def validate_entry_shape(self) -> "RepairPartRecordRequest":
if (self.consumable is None) == (self.tracked_component_uuid is None):
raise ValueError(
"exactly one of consumable and tracked_component_uuid is required"
)
return self
class RepairPartVoidRequest(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
reason: str = Field(min_length=1, max_length=1000)
class RepairPartConsumable(BaseModel):
description: str
quantity: Decimal
unit: ConsumableUnit
class RepairPartComponentSnapshot(BaseModel):
component_type: ComponentType
asset_id: str | None = None
manufacturer: str | None = None
model: str | None = None
manufacturer_serial: str | None = None
class RepairPartDetail(BaseModel):
repair_part_id: UUID
repair_id: UUID
entry_kind: RepairPartEntryKind
consumable: RepairPartConsumable | None = None
component_id: UUID | None = None
component_snapshot: RepairPartComponentSnapshot | None = None
notes: str | None = None
recorded_by: UUID
recorded_at: AwareDatetime
recorded_event_id: UUID
voided_by: UUID | None = None
voided_at: AwareDatetime | None = None
void_reason: str | None = None
void_event_id: UUID | None = None
class RepairPartList(BaseModel):
repair_id: UUID
entries: list[RepairPartDetail] = Field(default_factory=list)
class RepairPartResponse(BaseModel):
data: RepairPartDetail
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class RepairPartListResponse(BaseModel):
data: RepairPartList
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
RepairCenterSource = Literal["modular", "legacy"]
RepairCenterScope = Literal["active", "historical", "all"]
class RepairCenterAccess(BaseModel):
can_read_repairs: bool = False
can_view_miners: bool = False
can_view_locations: bool = False
can_complete_repairs: bool = False
can_manage_parts: bool = False
can_manage_legacy: bool = False
class RepairCenterFilters(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
query: str | None = Field(default=None, max_length=255)
scope: RepairCenterScope = "active"
status: str | None = Field(default=None, max_length=64)
model: str | None = Field(default=None, max_length=255)
lifecycle_status: RepairMinerLifecycle | None = None
site_id: UUID | None = None
room_id: UUID | None = None
limit: int = Field(default=50, ge=1, le=250)
offset: int = Field(default=0, ge=0)
class RepairCenterCapabilities(BaseModel):
can_open_miner: bool = False
can_open_map: bool = False
can_read_modular_timeline: bool = False
can_read_modular_parts: bool = False
can_manage_modular_parts: bool = False
can_complete_modular_repair: bool = False
can_read_legacy_history: bool = False
can_manage_legacy: bool = False
can_mark_legacy_repaired: bool = False
can_decommission_legacy: bool = False
class RepairCenterItem(BaseModel):
repair_id: UUID | None = None
source: RepairCenterSource
active: bool
opened_at: AwareDatetime | None = None
closed_at: AwareDatetime | None = None
repair_status: str
reason: str | None = None
reported_problem: str | None = None
latest_context: str | None = None
latest_context_at: AwareDatetime | None = None
opened_by_display_name: str | None = None
miner_id: UUID
asset_id: str | None = None
model: str | None = None
miner_sn: str | None = None
power_sn: str | None = None
current_mac: str | None = None
current_ip: str | None = None
lifecycle_status: RepairMinerLifecycle
runtime_status: str | None = None
miner_status: str | None = None
site_id: UUID | None = None
site_name: str | None = None
room_id: UUID | None = None
room_name: str | None = None
rack_label: str | None = None
shelf_label: str | None = None
position_id: UUID | None = None
position_label: str | None = None
position_index: int | None = None
parts_count: int | None = None
parts_summary: list[str] = Field(default_factory=list)
legacy_event_status: str | None = None
legacy_asset_state: str | None = None
latest_event_status: str | None = None
latest_event_title: str | None = None
latest_event_description: str | None = None
latest_event_at: AwareDatetime | None = None
asset_state: str | None = None
state_notes: str | None = None
repair_event_count: int = 0
open_repair_event_count: int = 0
state_updated_at: AwareDatetime | None = None
reported_error: str | None = None
reported_hashrate_ths: str | None = None
reported_voltage: str | None = None
location_note: str | None = None
import_source: str | None = None
capabilities: RepairCenterCapabilities
class RepairCenterSummary(BaseModel):
total: int
active: int
historical: int
status_counts: dict[str, int] = Field(default_factory=dict)
model_counts: dict[str, int] = Field(default_factory=dict)
error_counts: dict[str, int] = Field(default_factory=dict)
class ActiveRepairSummary(BaseModel):
active: int
status_counts: dict[str, int] = Field(default_factory=dict)
class RepairCenterLocationOption(BaseModel):
id: UUID
name: str
class RepairCenterRoomOption(RepairCenterLocationOption):
site_id: UUID
class RepairCenterLocationOptions(BaseModel):
sites: list[RepairCenterLocationOption] = Field(default_factory=list)
rooms: list[RepairCenterRoomOption] = Field(default_factory=list)
class RepairCenterList(BaseModel):
items: list[RepairCenterItem]
summary: RepairCenterSummary
class RepairCenterMeta(BaseModel):
limit: int
offset: int
returned: int
total: int
location_options: RepairCenterLocationOptions = Field(
default_factory=RepairCenterLocationOptions
)
class RepairCenterResponse(BaseModel):
data: RepairCenterList
meta: RepairCenterMeta
error: None = None
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more