Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9f0da694b | ||
|
|
82309db79a | ||
|
|
d8cd7ad058 | ||
|
|
f5965af914 | ||
|
|
6cd876b4b3 | ||
|
|
c3501b987e | ||
|
|
0dd26a3f24 | ||
|
|
4d360fac42 | ||
|
|
19fc29c72c | ||
|
|
b8b741ccee | ||
|
|
4a1959ecd6 | ||
|
|
786a1ab910 | ||
|
|
c6d01df31c | ||
|
|
06ed8248f5 | ||
|
|
d06ee7bcc6 | ||
|
|
60a831950c | ||
|
|
548051899e | ||
|
|
b741fd799b | ||
|
|
eeb3c7634e | ||
|
|
4656714837 | ||
|
|
39832fcf80 | ||
|
|
eab9646960 | ||
|
|
6e550c1c13 | ||
|
|
73f98685ce | ||
|
|
bcf1b4e3cf | ||
|
|
62c91a2c3e | ||
|
|
f2b736b08e | ||
|
|
72ad19a071 | ||
|
|
36a0caecd8 | ||
|
|
f6a2512e23 | ||
|
|
577226cb4e |
No files matched your search
@@ -1,198 +1,322 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="branding/wordmark.svg" alt="MinerOps" width="320">
|
<img src="branding/logo.png" alt="MinerOps Logo" width="180">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">MinerOps</h1>
|
<h1 align="center">MinerOps</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>The self-hosted operating system for cryptocurrency mining operations.</strong>
|
<strong>The self-hosted operating system for cryptocurrency mining operations.</strong>
|
||||||
<br>
|
|
||||||
Know every miner. Track every repair. Control every rack.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Know every miner • Track every repair • Control every rack
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/status-active%20development-orange" />
|
||||||
|
<img src="https://img.shields.io/badge/python-3.13-blue" />
|
||||||
|
<img src="https://img.shields.io/badge/license-TBD-lightgrey" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
MinerOps is a self-hosted platform intended to provide one authoritative source
|
MinerOps is an open-source operations platform built for cryptocurrency mining facilities.
|
||||||
of truth for cryptocurrency mining operations. It is being built to connect
|
|
||||||
asset records, network identity, repair history, physical location, telemetry,
|
|
||||||
and operational controls without depending on a hosted management service.
|
|
||||||
|
|
||||||
The repository contains a working early-stage application and the first
|
Rather than relying on spreadsheets, disconnected monitoring tools, and handwritten repair logs, MinerOps brings every operational workflow together into a single self-hosted application.
|
||||||
foundations of a more modular architecture. Current development is focused on
|
|
||||||
turning those foundations into well-defined product domains with tested service
|
|
||||||
and persistence boundaries.
|
|
||||||
|
|
||||||
The longer-term product direction combines:
|
MinerOps is designed to manage the complete lifecycle of a mining fleet, including inventory, physical locations, repairs, monitoring, maintenance, and secure remote management.
|
||||||
|
|
||||||
- Miner inventory
|
Originally developed for WhatsMiner fleets, the platform is being built around a modular architecture that will support additional miner platforms through protocol adapters in future releases.
|
||||||
- Network discovery and identity tracking
|
|
||||||
- Repairs and maintenance
|
|
||||||
- Locations and electrical infrastructure
|
|
||||||
- Monitoring and alerts
|
|
||||||
- Fleet control
|
|
||||||
- Scheduling and automation
|
|
||||||
- Historical records and reporting
|
|
||||||
|
|
||||||
These areas describe the product direction, not a claim that every workflow is
|
---
|
||||||
complete or ready for production use.
|
|
||||||
|
|
||||||
## Why MinerOps?
|
# Vision
|
||||||
|
|
||||||
Mining operations often accumulate several partial versions of the truth:
|
Running a mining operation requires far more than knowing whether a miner is online.
|
||||||
spreadsheets for assets, separate monitoring tools for performance, informal
|
|
||||||
repair notes, and location knowledge held by individual technicians.
|
|
||||||
|
|
||||||
That fragmentation becomes harder to manage when IP addresses change, control
|
MinerOps is designed to become the operational hub for an entire mining facility by combining:
|
||||||
boards or network interfaces are replaced, equipment moves between racks, and
|
|
||||||
repair history is incomplete. MinerOps is intended to give operators one
|
|
||||||
self-hosted operational record that follows each miner through its full
|
|
||||||
lifecycle.
|
|
||||||
|
|
||||||
## Core Capabilities
|
- Asset Inventory
|
||||||
|
- Physical Location Management
|
||||||
|
- Repair & Maintenance Tracking
|
||||||
|
- Live Fleet Monitoring
|
||||||
|
- Historical Reporting
|
||||||
|
- Secure Remote Management
|
||||||
|
- Operational Analytics
|
||||||
|
- Fleet Automation
|
||||||
|
|
||||||
### Asset and Inventory Management
|
into one cohesive platform.
|
||||||
|
|
||||||
Maintain stable asset records independently of a miner's current IP address.
|
The long-term goal is to replace multiple disconnected systems with a single source of truth for mining operations.
|
||||||
The inventory schema and backend domain foundation currently support Asset IDs,
|
|
||||||
hardware identifiers, lifecycle state, tags, notes, location references, and
|
|
||||||
seen timestamps. Inventory HTTP APIs and product workflows are the next
|
|
||||||
application milestone.
|
|
||||||
|
|
||||||
### Miner Identity and Discovery
|
---
|
||||||
|
|
||||||
Associate changing network information with durable miner records. Existing
|
# Current Status
|
||||||
code includes discovery and network identity foundations; formal identity
|
|
||||||
history and transfer workflows remain planned work.
|
|
||||||
|
|
||||||
### Repairs and Maintenance
|
⚠️ **MinerOps is under active development.**
|
||||||
|
|
||||||
Build a complete service history around each miner, including repair status,
|
The current focus is building a scalable, maintainable architecture before expanding user-facing functionality.
|
||||||
notes, parts, and return-to-service workflows. Operational repair functionality
|
|
||||||
exists today, while extraction into a dedicated domain architecture is still
|
|
||||||
planned.
|
|
||||||
|
|
||||||
### Locations and Infrastructure
|
## Completed
|
||||||
|
|
||||||
Track where equipment is installed and relate miners to sites, rooms, racks,
|
|
||||||
shelves, and operational infrastructure. Existing location workflows will be
|
|
||||||
refined as the modular architecture develops.
|
|
||||||
|
|
||||||
### Monitoring and Health
|
|
||||||
|
|
||||||
Collect miner status and telemetry, identify stale or unhealthy equipment, and
|
|
||||||
support operational health views. The repository contains current telemetry and
|
|
||||||
retention foundations; broader alerting remains planned.
|
|
||||||
|
|
||||||
### Fleet Operations
|
|
||||||
|
|
||||||
Provide controlled fleet actions while keeping command policy, authorization,
|
|
||||||
and site-local execution explicit. Existing command workflows are under active
|
|
||||||
development and should not be treated as a finished control platform.
|
|
||||||
|
|
||||||
### Automation and Scheduling
|
|
||||||
|
|
||||||
Coordinate recurring scans, scheduled actions, retention, and other repeatable
|
|
||||||
operations. Scheduling and automation will expand after the core inventory and
|
|
||||||
identity domains are established.
|
|
||||||
|
|
||||||
### Audit History and Reporting
|
|
||||||
|
|
||||||
Preserve important operational changes and support practical reporting across
|
|
||||||
assets, repairs, locations, and fleet activity. Audit-log and reporting
|
|
||||||
foundations exist; the complete reporting model remains planned.
|
|
||||||
|
|
||||||
## Current Status
|
|
||||||
|
|
||||||
MinerOps is under active development.
|
|
||||||
|
|
||||||
**Current development stage:** `v0.2 - Foundation`
|
|
||||||
|
|
||||||
Verified foundations in the repository include:
|
|
||||||
|
|
||||||
- Docker-based development environment
|
- Docker-based development environment
|
||||||
- PostgreSQL data store
|
- PostgreSQL + Redis backend
|
||||||
- Redis service
|
- Alembic migration framework
|
||||||
- Alembic schema migrations
|
- Authentication
|
||||||
- Session-based authentication and login gating
|
- Role-based access control
|
||||||
- Admin, Manager, Technician, and Read Only role foundations
|
- Manual inventory schema
|
||||||
- Manual miner inventory database schema
|
- Inventory domain architecture
|
||||||
- Inventory Pydantic models
|
- Automated migration testing
|
||||||
- Inventory repository and service domain foundation
|
- Disposable development environment
|
||||||
- Automated repository and service tests for the Inventory domain
|
|
||||||
- Disposable development baseline tests
|
|
||||||
|
|
||||||
**Next application milestone:** `v0.3 - Inventory`
|
## Currently In Progress
|
||||||
|
|
||||||
**Next technical feature:** Inventory API
|
- Inventory API
|
||||||
|
- Inventory user interface
|
||||||
|
- Domain-driven architecture migration
|
||||||
|
|
||||||
## Architecture
|
## Planned
|
||||||
|
|
||||||
MinerOps is moving toward a modular backend with deliberate separation between
|
- Asset identity & MAC history
|
||||||
transport, business rules, and persistence:
|
- Physical location management
|
||||||
|
- Repair & work order management
|
||||||
|
- Parts inventory
|
||||||
|
- Fleet monitoring
|
||||||
|
- Site agents
|
||||||
|
- Multi-version WhatsMiner API support
|
||||||
|
- Alerting & notifications
|
||||||
|
- Bulk operations
|
||||||
|
- Scheduled automation
|
||||||
|
- Reporting & analytics
|
||||||
|
- Production deployment tools
|
||||||
|
|
||||||
```text
|
---
|
||||||
Frontend
|
|
||||||
|
|
# Core Features
|
||||||
v
|
|
||||||
FastAPI routers
|
## 📦 Asset Inventory
|
||||||
|
|
|
||||||
v
|
Maintain a permanent identity for every miner independent of its network configuration.
|
||||||
Domain services
|
|
||||||
|
|
Planned capabilities include:
|
||||||
v
|
|
||||||
Repositories
|
- Asset IDs
|
||||||
|
|
- MAC history
|
||||||
v
|
- Control board tracking
|
||||||
|
- PSU serial tracking
|
||||||
|
- Firmware history
|
||||||
|
- Lifecycle management
|
||||||
|
- Tags
|
||||||
|
- Notes
|
||||||
|
- Asset movement history
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📍 Physical Location Tracking
|
||||||
|
|
||||||
|
Track exactly where every miner is installed.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
|
||||||
|
- Sites
|
||||||
|
- Rooms
|
||||||
|
- Rows
|
||||||
|
- Racks
|
||||||
|
- Shelves
|
||||||
|
- Positions
|
||||||
|
|
||||||
|
with complete movement history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Repair Management
|
||||||
|
|
||||||
|
Track every repair from diagnosis through return to production.
|
||||||
|
|
||||||
|
Features include:
|
||||||
|
|
||||||
|
- Work orders
|
||||||
|
- Technician notes
|
||||||
|
- Parts used
|
||||||
|
- Repair history
|
||||||
|
- Testing
|
||||||
|
- Attachments
|
||||||
|
- Maintenance mode
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Fleet Monitoring
|
||||||
|
|
||||||
|
Monitor operational health across the fleet.
|
||||||
|
|
||||||
|
Planned monitoring includes:
|
||||||
|
|
||||||
|
- Hashrate
|
||||||
|
- Temperature
|
||||||
|
- Fan status
|
||||||
|
- Hashboard health
|
||||||
|
- PSU status
|
||||||
|
- Pool connectivity
|
||||||
|
- Worker information
|
||||||
|
- Firmware
|
||||||
|
- Alerts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 Network Discovery
|
||||||
|
|
||||||
|
Automatically discover miners while maintaining stable asset identities.
|
||||||
|
|
||||||
|
Planned capabilities include:
|
||||||
|
|
||||||
|
- Configurable scan ranges
|
||||||
|
- Duplicate detection
|
||||||
|
- MAC history
|
||||||
|
- IP history
|
||||||
|
- Capability detection
|
||||||
|
- Discovery review workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎛 Secure Remote Control
|
||||||
|
|
||||||
|
Safely execute commands against supported miners.
|
||||||
|
|
||||||
|
Examples include:
|
||||||
|
|
||||||
|
- Reboot
|
||||||
|
- Shutdown
|
||||||
|
- Performance mode
|
||||||
|
- Pool configuration
|
||||||
|
- Firmware operations
|
||||||
|
|
||||||
|
All commands will be permission-aware, audited, and designed for safe execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛰 Site Agents
|
||||||
|
|
||||||
|
Future releases will include lightweight Site Agents responsible for:
|
||||||
|
|
||||||
|
- Network discovery
|
||||||
|
- Miner polling
|
||||||
|
- Local command execution
|
||||||
|
- Secure communication with the MinerOps server
|
||||||
|
|
||||||
|
This architecture allows mining networks to remain isolated while still enabling centralized fleet management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Architecture
|
||||||
|
|
||||||
|
MinerOps follows a layered architecture that separates business logic from persistence.
|
||||||
|
|
||||||
|
```
|
||||||
|
FastAPI Routers
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Service Layer
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Repository Layer
|
||||||
|
│
|
||||||
|
▼
|
||||||
PostgreSQL
|
PostgreSQL
|
||||||
```
|
```
|
||||||
|
|
||||||
FastAPI routers will own HTTP request and response concerns. Domain services
|
Business rules belong in Services.
|
||||||
will own operational rules and orchestration. Repositories will own database
|
|
||||||
access. The Inventory domain is the first backend area established with these
|
|
||||||
boundaries; other working application areas are still being migrated
|
|
||||||
incrementally.
|
|
||||||
|
|
||||||
## Technology Stack
|
Database access belongs in Repositories.
|
||||||
|
|
||||||
Current technologies:
|
Schema changes are managed exclusively through Alembic migrations.
|
||||||
|
|
||||||
|
This architecture keeps MinerOps modular, testable, and easy to extend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Technology Stack
|
||||||
|
|
||||||
|
Current
|
||||||
|
|
||||||
- Python
|
- Python
|
||||||
- FastAPI
|
- FastAPI
|
||||||
- Pydantic
|
|
||||||
- PostgreSQL
|
- PostgreSQL
|
||||||
- Alembic
|
|
||||||
- Redis
|
- Redis
|
||||||
- Docker and Docker Compose
|
- Alembic
|
||||||
- Nginx development gateway
|
- Docker Compose
|
||||||
|
- Pydantic
|
||||||
|
|
||||||
A typed, responsive frontend with a shared design system is planned. Its
|
Planned
|
||||||
technology selection has not yet been finalized in this repository.
|
|
||||||
|
|
||||||
## Roadmap
|
- React
|
||||||
|
- TypeScript
|
||||||
|
- Site Agents
|
||||||
|
- Protocol adapters
|
||||||
|
- Background task processing
|
||||||
|
|
||||||
- `v0.2 - Foundation`
|
---
|
||||||
- `v0.3 - Inventory`
|
|
||||||
- `v0.4 - Identity`
|
|
||||||
- `v0.5 - Operations`
|
|
||||||
- `v0.6 - Monitoring`
|
|
||||||
- `v0.7 - Automation`
|
|
||||||
- `v1.0 - Production Ready`
|
|
||||||
|
|
||||||
Roadmap scope and release boundaries may evolve as development continues and
|
# Development Philosophy
|
||||||
operational requirements become clearer.
|
|
||||||
|
|
||||||
## Documentation
|
MinerOps is built around a few guiding principles.
|
||||||
|
|
||||||
Detailed product, architecture, development, database, testing, and roadmap
|
- Self-hosted first
|
||||||
documentation will live under `docs/`. That documentation set is still being
|
- Reliability over unnecessary complexity
|
||||||
developed, so this README does not link to files that do not yet exist.
|
- Small, reviewable commits
|
||||||
|
- Test before commit
|
||||||
|
- Database changes managed through Alembic
|
||||||
|
- Business logic separated from persistence
|
||||||
|
- Secure by default
|
||||||
|
- Role-based authorization
|
||||||
|
- Comprehensive audit logging
|
||||||
|
- Incremental development over large rewrites
|
||||||
|
|
||||||
## Contributing
|
---
|
||||||
|
|
||||||
MinerOps is in early development. Contribution guidelines will be added as the
|
# Roadmap
|
||||||
architecture, development workflow, and review expectations stabilize.
|
|
||||||
|
|
||||||
## License
|
| Version | Focus |
|
||||||
|
|----------|---------------------------|
|
||||||
|
| ✅ v0.2 | Foundation |
|
||||||
|
| 🚧 v0.3 | Inventory |
|
||||||
|
| ⏳ v0.4 | Asset Identity |
|
||||||
|
| ⏳ v0.5 | Operations |
|
||||||
|
| ⏳ v0.6 | Monitoring |
|
||||||
|
| ⏳ v0.7 | Automation |
|
||||||
|
| 🎯 v1.0 | Production Release |
|
||||||
|
|
||||||
The MinerOps license has not yet been finalized. No license file is currently
|
Detailed planning will be maintained in **docs/ROADMAP.md**.
|
||||||
included in this repository.
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
|
||||||
|
Project documentation is maintained in the `docs/` directory.
|
||||||
|
|
||||||
|
Documentation includes:
|
||||||
|
|
||||||
|
- Architecture
|
||||||
|
- Roadmap
|
||||||
|
- Architecture Decisions
|
||||||
|
- Development Guide
|
||||||
|
- Coding Standards
|
||||||
|
- Database Design
|
||||||
|
- API Documentation
|
||||||
|
- Deployment Guide
|
||||||
|
- Testing Guide
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Contributing
|
||||||
|
|
||||||
|
MinerOps is currently under active development.
|
||||||
|
|
||||||
|
Contributions, ideas, bug reports, and feature requests are welcome as the project continues to evolve.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# License
|
||||||
|
|
||||||
|
License to be determined.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
*.ps1 -text
|
||||||
|
|
||||||
|
|
||||||
|
*.ps1 whitespace=cr-at-eol
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
agent-config.json
|
||||||
|
agent-state.json
|
||||||
|
logs/
|
||||||
|
*.bak
|
||||||
|
*.bak.*
|
||||||
|
*-probe-*.json
|
||||||
|
rollback/
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
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
|
||||||
|
$agentId = [string]$config.agent_id
|
||||||
|
|
||||||
|
$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-ID" = $agentId
|
||||||
|
"X-MinerOps-Agent-Secret" = $secret
|
||||||
|
} `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Body ($payload | ConvertTo-Json -Depth 80)
|
||||||
|
|
||||||
|
$response | ConvertTo-Json -Depth 20
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
@@ -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,247 @@
|
|||||||
|
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-ID" = $AgentId
|
||||||
|
"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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
$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-ID" = $AgentId
|
||||||
|
"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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# 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. `agent_zone_id` is the optional
|
||||||
|
durable network-reachability Zone UUID; older configurations may omit it only
|
||||||
|
when the Hub can preserve an established assignment or resolve exactly one
|
||||||
|
enabled Zone at the Site. `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.
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1.0.0-alpha
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"hub_url": "https://minerops.example.invalid",
|
||||||
|
"agent_id": "replace-with-agent-id",
|
||||||
|
"site_id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"agent_zone_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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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."
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
"""add agent zones
|
||||||
|
|
||||||
|
Revision ID: 0016_agent_zones
|
||||||
|
Revises: 0015_miner_control_foundation
|
||||||
|
Create Date: 2026-08-25
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0016_agent_zones"
|
||||||
|
down_revision: Union[str, None] = "0015_miner_control_foundation"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
DEFAULT_ZONE_NAME = "Default Zone"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_rows(rows: list[tuple]) -> str:
|
||||||
|
return ", ".join("/".join(str(value) for value in row) for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"agent_zones",
|
||||||
|
sa.Column(
|
||||||
|
"id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
server_default=sa.text("gen_random_uuid()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"site_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("sites.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"name = trim(name) AND trim(name) <> '' AND char_length(name) <= 120",
|
||||||
|
name="ck_agent_zones_name",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("id", "site_id", name="uq_agent_zones_id_site_id"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_agent_zones_site_id", "agent_zones", ["site_id"])
|
||||||
|
op.create_index(
|
||||||
|
"uq_agent_zones_active_site_name",
|
||||||
|
"agent_zones",
|
||||||
|
["site_id", sa.text("lower(name)")],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("is_enabled"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"agent_zone_network_ranges",
|
||||||
|
sa.Column(
|
||||||
|
"id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
server_default=sa.text("gen_random_uuid()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"agent_zone_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("agent_zones.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("network", postgresql.CIDR(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"agent_zone_id", "network", name="uq_agent_zone_network_ranges_zone_network"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_agent_zone_network_ranges_zone_id",
|
||||||
|
"agent_zone_network_ranges",
|
||||||
|
["agent_zone_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"miners",
|
||||||
|
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_miners_agent_zone_requires_site",
|
||||||
|
"miners",
|
||||||
|
"agent_zone_id IS NULL OR site_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_miners_agent_zone_site",
|
||||||
|
"miners",
|
||||||
|
"agent_zones",
|
||||||
|
["agent_zone_id", "site_id"],
|
||||||
|
["id", "site_id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
)
|
||||||
|
op.create_index("idx_miners_agent_zone_id", "miners", ["agent_zone_id"])
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"minerops_site_agents",
|
||||||
|
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_minerops_site_agents_zone_requires_site",
|
||||||
|
"minerops_site_agents",
|
||||||
|
"agent_zone_id IS NULL OR site_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_minerops_site_agents_zone_site",
|
||||||
|
"minerops_site_agents",
|
||||||
|
"agent_zones",
|
||||||
|
["agent_zone_id", "site_id"],
|
||||||
|
["id", "site_id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"minerops_agent_snapshots",
|
||||||
|
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_minerops_agent_snapshots_agent_zone_id",
|
||||||
|
"minerops_agent_snapshots",
|
||||||
|
"agent_zones",
|
||||||
|
["agent_zone_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_minerops_agent_snapshots_agent_zone_id",
|
||||||
|
"minerops_agent_snapshots",
|
||||||
|
["agent_zone_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"minerops_agent_discoveries",
|
||||||
|
sa.Column("agent_zone_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_minerops_agent_discoveries_agent_zone_id",
|
||||||
|
"minerops_agent_discoveries",
|
||||||
|
"agent_zones",
|
||||||
|
["agent_zone_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_minerops_agent_discoveries_agent_zone_id",
|
||||||
|
"minerops_agent_discoveries",
|
||||||
|
["agent_zone_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = op.get_bind()
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zones (site_id, name)
|
||||||
|
SELECT sites.id, :default_name
|
||||||
|
FROM sites
|
||||||
|
ORDER BY sites.id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"default_name": DEFAULT_ZONE_NAME},
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE miners AS miner
|
||||||
|
SET agent_zone_id = zone.id
|
||||||
|
FROM agent_zones AS zone
|
||||||
|
WHERE miner.site_id = zone.site_id
|
||||||
|
AND zone.name = :default_name
|
||||||
|
AND miner.agent_zone_id IS NULL
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"default_name": DEFAULT_ZONE_NAME},
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE minerops_site_agents AS agent
|
||||||
|
SET agent_zone_id = zone.id
|
||||||
|
FROM agent_zones AS zone
|
||||||
|
WHERE agent.site_id = zone.site_id
|
||||||
|
AND zone.name = :default_name
|
||||||
|
AND agent.agent_zone_id IS NULL
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"default_name": DEFAULT_ZONE_NAME},
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE minerops_agent_snapshots AS snapshot
|
||||||
|
SET agent_zone_id = agent.agent_zone_id
|
||||||
|
FROM minerops_site_agents AS agent
|
||||||
|
WHERE snapshot.agent_id = agent.agent_id
|
||||||
|
AND agent.agent_zone_id IS NOT NULL
|
||||||
|
AND snapshot.agent_zone_id IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE minerops_agent_discoveries AS discovery
|
||||||
|
SET agent_zone_id = agent.agent_zone_id
|
||||||
|
FROM minerops_site_agents AS agent
|
||||||
|
WHERE discovery.agent_id = agent.agent_id
|
||||||
|
AND agent.agent_zone_id IS NOT NULL
|
||||||
|
AND discovery.agent_zone_id IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
"uq_minerops_site_agents_agent_zone_id",
|
||||||
|
"minerops_site_agents",
|
||||||
|
["agent_zone_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("agent_zone_id IS NOT NULL"),
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"uq_minerops_site_agents_site_id", table_name="minerops_site_agents"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
duplicate_site_agents = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT site_id::text, count(*)
|
||||||
|
FROM minerops_site_agents
|
||||||
|
WHERE site_id IS NOT NULL
|
||||||
|
GROUP BY site_id
|
||||||
|
HAVING count(*) > 1
|
||||||
|
ORDER BY site_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if duplicate_site_agents:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot downgrade 0016 to 0015: multiple Agent installations are "
|
||||||
|
"assigned within Site(s): "
|
||||||
|
f"{_format_rows(list(duplicate_site_agents))}. Move or unassign Agents "
|
||||||
|
"until each Site has at most one Agent before retrying; no Agent Zone "
|
||||||
|
"data was changed."
|
||||||
|
)
|
||||||
|
|
||||||
|
non_reducible_zones = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT site_id::text, count(*)
|
||||||
|
FROM agent_zones
|
||||||
|
GROUP BY site_id
|
||||||
|
HAVING count(*) > 1
|
||||||
|
OR bool_or(name <> :default_name OR description IS NOT NULL OR NOT is_enabled)
|
||||||
|
ORDER BY site_id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"default_name": DEFAULT_ZONE_NAME},
|
||||||
|
).all()
|
||||||
|
range_count = connection.execute(
|
||||||
|
sa.text("SELECT count(*) FROM agent_zone_network_ranges")
|
||||||
|
).scalar_one()
|
||||||
|
if non_reducible_zones or range_count:
|
||||||
|
detail = _format_rows(list(non_reducible_zones)) or "network ranges exist"
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot downgrade 0016 to 0015: Agent Zone configuration cannot be "
|
||||||
|
f"represented by the one-Agent-per-Site model ({detail}). Remove "
|
||||||
|
"additional/custom Zones and Zone network ranges before retrying; no "
|
||||||
|
"Agent Zone data was changed."
|
||||||
|
)
|
||||||
|
|
||||||
|
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.drop_index(
|
||||||
|
"uq_minerops_site_agents_agent_zone_id",
|
||||||
|
table_name="minerops_site_agents",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_index(
|
||||||
|
"idx_minerops_agent_discoveries_agent_zone_id",
|
||||||
|
table_name="minerops_agent_discoveries",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_minerops_agent_discoveries_agent_zone_id",
|
||||||
|
"minerops_agent_discoveries",
|
||||||
|
type_="foreignkey",
|
||||||
|
)
|
||||||
|
op.drop_column("minerops_agent_discoveries", "agent_zone_id")
|
||||||
|
|
||||||
|
op.drop_index(
|
||||||
|
"idx_minerops_agent_snapshots_agent_zone_id",
|
||||||
|
table_name="minerops_agent_snapshots",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_minerops_agent_snapshots_agent_zone_id",
|
||||||
|
"minerops_agent_snapshots",
|
||||||
|
type_="foreignkey",
|
||||||
|
)
|
||||||
|
op.drop_column("minerops_agent_snapshots", "agent_zone_id")
|
||||||
|
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_minerops_site_agents_zone_site",
|
||||||
|
"minerops_site_agents",
|
||||||
|
type_="foreignkey",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_minerops_site_agents_zone_requires_site",
|
||||||
|
"minerops_site_agents",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_column("minerops_site_agents", "agent_zone_id")
|
||||||
|
|
||||||
|
op.drop_index("idx_miners_agent_zone_id", table_name="miners")
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_miners_agent_zone_site", "miners", type_="foreignkey"
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_miners_agent_zone_requires_site", "miners", type_="check"
|
||||||
|
)
|
||||||
|
op.drop_column("miners", "agent_zone_id")
|
||||||
|
|
||||||
|
op.drop_index(
|
||||||
|
"idx_agent_zone_network_ranges_zone_id",
|
||||||
|
table_name="agent_zone_network_ranges",
|
||||||
|
)
|
||||||
|
op.drop_table("agent_zone_network_ranges")
|
||||||
|
op.drop_index("uq_agent_zones_active_site_name", table_name="agent_zones")
|
||||||
|
op.drop_index("idx_agent_zones_site_id", table_name="agent_zones")
|
||||||
|
op.drop_table("agent_zones")
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"""add secure Agent enrollment and per-Agent credential storage
|
||||||
|
|
||||||
|
Revision ID: 0017_agent_enrollment
|
||||||
|
Revises: 0016_agent_zones
|
||||||
|
Create Date: 2026-08-26
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0017_agent_enrollment"
|
||||||
|
down_revision: Union[str, None] = "0016_agent_zones"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Per-Agent credentials are additive. Existing Agents retain NULL credential
|
||||||
|
# fields and therefore remain eligible for the temporary legacy shared-secret
|
||||||
|
# authentication path until explicitly migrated.
|
||||||
|
op.add_column(
|
||||||
|
"minerops_site_agents",
|
||||||
|
sa.Column("display_name", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"minerops_site_agents",
|
||||||
|
sa.Column("agent_secret_hash", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"minerops_site_agents",
|
||||||
|
sa.Column(
|
||||||
|
"credential_created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"minerops_site_agents",
|
||||||
|
sa.Column(
|
||||||
|
"credential_revoked_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_minerops_site_agents_display_name",
|
||||||
|
"minerops_site_agents",
|
||||||
|
"""
|
||||||
|
display_name IS NULL
|
||||||
|
OR (
|
||||||
|
btrim(display_name) <> ''
|
||||||
|
AND char_length(display_name) <= 120
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_minerops_site_agents_secret_hash",
|
||||||
|
"minerops_site_agents",
|
||||||
|
"""
|
||||||
|
agent_secret_hash IS NULL
|
||||||
|
OR agent_secret_hash ~ '^[0-9a-f]{64}$'
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_minerops_site_agents_credential_pair",
|
||||||
|
"minerops_site_agents",
|
||||||
|
"""
|
||||||
|
(
|
||||||
|
agent_secret_hash IS NULL
|
||||||
|
AND credential_created_at IS NULL
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
agent_secret_hash IS NOT NULL
|
||||||
|
AND credential_created_at IS NOT NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_minerops_site_agents_credential_revoked_time",
|
||||||
|
"minerops_site_agents",
|
||||||
|
"""
|
||||||
|
credential_revoked_at IS NULL
|
||||||
|
OR (
|
||||||
|
credential_created_at IS NOT NULL
|
||||||
|
AND credential_revoked_at >= credential_created_at
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
"uq_minerops_site_agents_agent_secret_hash",
|
||||||
|
"minerops_site_agents",
|
||||||
|
["agent_secret_hash"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("agent_secret_hash IS NOT NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"agent_enrollments",
|
||||||
|
sa.Column(
|
||||||
|
"id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
server_default=sa.text("gen_random_uuid()"),
|
||||||
|
),
|
||||||
|
sa.Column("token_hash", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"site_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"agent_zone_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("display_name", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_by_user_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"expires_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"used_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"revoked_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("enrolled_agent_id", sa.Text(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["agent_zone_id", "site_id"],
|
||||||
|
["agent_zones.id", "agent_zones.site_id"],
|
||||||
|
name="fk_agent_enrollments_zone_site",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["enrolled_agent_id"],
|
||||||
|
["minerops_site_agents.agent_id"],
|
||||||
|
name="fk_agent_enrollments_agent",
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"token_hash ~ '^[0-9a-f]{64}$'",
|
||||||
|
name="ck_agent_enrollments_token_hash",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"btrim(display_name) <> '' AND char_length(display_name) <= 120",
|
||||||
|
name="ck_agent_enrollments_display_name",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"expires_at > created_at",
|
||||||
|
name="ck_agent_enrollments_expiry",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"used_at IS NULL OR used_at >= created_at",
|
||||||
|
name="ck_agent_enrollments_used_time",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"used_at IS NULL OR used_at <= expires_at",
|
||||||
|
name="ck_agent_enrollments_used_before_expiry",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"revoked_at IS NULL OR revoked_at >= created_at",
|
||||||
|
name="ck_agent_enrollments_revoked_time",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"NOT (used_at IS NOT NULL AND revoked_at IS NOT NULL)",
|
||||||
|
name="ck_agent_enrollments_terminal_state",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"""
|
||||||
|
(
|
||||||
|
used_at IS NULL
|
||||||
|
AND enrolled_agent_id IS NULL
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
used_at IS NOT NULL
|
||||||
|
AND enrolled_agent_id IS NOT NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
name="ck_agent_enrollments_agent_when_used",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
"uq_agent_enrollments_token_hash",
|
||||||
|
"agent_enrollments",
|
||||||
|
["token_hash"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# PostgreSQL cannot use now() in a partial-index predicate because now() is
|
||||||
|
# not immutable. Therefore "open" means unused and unrevoked. Creation code
|
||||||
|
# will revoke an old expired/open enrollment before inserting a replacement.
|
||||||
|
op.create_index(
|
||||||
|
"uq_agent_enrollments_open_zone",
|
||||||
|
"agent_enrollments",
|
||||||
|
["agent_zone_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text(
|
||||||
|
"used_at IS NULL AND revoked_at IS NULL"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
"uq_agent_enrollments_enrolled_agent",
|
||||||
|
"agent_enrollments",
|
||||||
|
["enrolled_agent_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("enrolled_agent_id IS NOT NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
"idx_agent_enrollments_site_id",
|
||||||
|
"agent_enrollments",
|
||||||
|
["site_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
"idx_agent_enrollments_created_at",
|
||||||
|
"agent_enrollments",
|
||||||
|
["created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 0016 has nowhere to represent enrollment history or per-Agent
|
||||||
|
# credentials. Refuse before destructive DDL if either has been used.
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
enrollment_count = connection.execute(
|
||||||
|
sa.text("SELECT COUNT(*) FROM agent_enrollments")
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
credential_count = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM minerops_site_agents
|
||||||
|
WHERE display_name IS NOT NULL
|
||||||
|
OR agent_secret_hash IS NOT NULL
|
||||||
|
OR credential_created_at IS NOT NULL
|
||||||
|
OR credential_revoked_at IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
if enrollment_count:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot downgrade 0017: Agent enrollment history exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
if credential_count:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot downgrade 0017: per-Agent credentials exist"
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_index(
|
||||||
|
"idx_agent_enrollments_created_at",
|
||||||
|
table_name="agent_enrollments",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"idx_agent_enrollments_site_id",
|
||||||
|
table_name="agent_enrollments",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"uq_agent_enrollments_enrolled_agent",
|
||||||
|
table_name="agent_enrollments",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"uq_agent_enrollments_open_zone",
|
||||||
|
table_name="agent_enrollments",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"uq_agent_enrollments_token_hash",
|
||||||
|
table_name="agent_enrollments",
|
||||||
|
)
|
||||||
|
op.drop_table("agent_enrollments")
|
||||||
|
|
||||||
|
op.drop_index(
|
||||||
|
"uq_minerops_site_agents_agent_secret_hash",
|
||||||
|
table_name="minerops_site_agents",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_minerops_site_agents_credential_revoked_time",
|
||||||
|
"minerops_site_agents",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_minerops_site_agents_credential_pair",
|
||||||
|
"minerops_site_agents",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_minerops_site_agents_secret_hash",
|
||||||
|
"minerops_site_agents",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_minerops_site_agents_display_name",
|
||||||
|
"minerops_site_agents",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_column("minerops_site_agents", "credential_revoked_at")
|
||||||
|
op.drop_column("minerops_site_agents", "credential_created_at")
|
||||||
|
op.drop_column("minerops_site_agents", "agent_secret_hash")
|
||||||
|
op.drop_column("minerops_site_agents", "display_name")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Per-Agent authentication support."""
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Domain errors for Agent authentication."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAuthenticationError(Exception):
|
||||||
|
"""Base Agent authentication error."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAuthenticationUnavailable(AgentAuthenticationError):
|
||||||
|
"""Agent authentication cannot currently be performed."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAuthenticationFailed(AgentAuthenticationError):
|
||||||
|
"""The supplied Agent secret is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentIdentityUnauthorized(AgentAuthenticationError):
|
||||||
|
"""The supplied Agent identity is not authorized."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCredentialRevoked(AgentAuthenticationError):
|
||||||
|
"""The Agent's permanent credential has been revoked."""
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""PostgreSQL persistence for Agent authentication."""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAuthRepository:
|
||||||
|
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||||
|
self._connection_factory = connection_factory or default_connection_factory
|
||||||
|
|
||||||
|
def get_credential(self, agent_id: str) -> dict | None:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT agent_id,
|
||||||
|
agent_secret_hash,
|
||||||
|
credential_created_at,
|
||||||
|
credential_revoked_at
|
||||||
|
FROM minerops_site_agents
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
(agent_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Per-Agent authentication with bounded legacy compatibility."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
from app.agent_auth.exceptions import (
|
||||||
|
AgentAuthenticationFailed,
|
||||||
|
AgentAuthenticationUnavailable,
|
||||||
|
AgentCredentialRevoked,
|
||||||
|
AgentIdentityUnauthorized,
|
||||||
|
)
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def hash_agent_secret(secret: str) -> str:
|
||||||
|
"""Hash a high-entropy machine-generated Agent secret for persistence."""
|
||||||
|
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class AgentAuthService:
|
||||||
|
def __init__(self, repository):
|
||||||
|
self.repository = repository
|
||||||
|
|
||||||
|
def authenticate(self, agent_id: str | None, provided_secret: str | None) -> str:
|
||||||
|
identity = (agent_id or "").strip()
|
||||||
|
secret = (provided_secret or "").strip()
|
||||||
|
|
||||||
|
if not identity:
|
||||||
|
raise AgentAuthenticationFailed("Missing Agent identity")
|
||||||
|
if not secret:
|
||||||
|
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||||
|
|
||||||
|
credential = self.repository.get_credential(identity)
|
||||||
|
|
||||||
|
# Any Agent with a per-Agent credential is permanently on the new
|
||||||
|
# authentication path. The legacy shared secret must never authenticate
|
||||||
|
# that identity, even if the Agent later becomes revoked.
|
||||||
|
if credential and credential.get("agent_secret_hash"):
|
||||||
|
if credential.get("credential_revoked_at") is not None:
|
||||||
|
raise AgentCredentialRevoked("Agent credential has been revoked")
|
||||||
|
|
||||||
|
supplied_hash = hash_agent_secret(secret)
|
||||||
|
if not hmac.compare_digest(
|
||||||
|
supplied_hash,
|
||||||
|
str(credential["agent_secret_hash"]),
|
||||||
|
):
|
||||||
|
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||||
|
|
||||||
|
return identity
|
||||||
|
|
||||||
|
# Temporary compatibility for the single pre-enrollment Agent.
|
||||||
|
legacy_id = settings.minerops_agent_id.strip()
|
||||||
|
legacy_secret = settings.minerops_agent_shared_secret.strip()
|
||||||
|
|
||||||
|
if not legacy_id or not legacy_secret:
|
||||||
|
raise AgentAuthenticationUnavailable(
|
||||||
|
"Legacy Agent authentication is not configured"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hmac.compare_digest(identity, legacy_id):
|
||||||
|
raise AgentIdentityUnauthorized("Agent identity is not authorized")
|
||||||
|
|
||||||
|
if not hmac.compare_digest(secret, legacy_secret):
|
||||||
|
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||||
|
|
||||||
|
return identity
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Authenticated Agent command transport for MinerOps."""
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Dependencies for the authenticated Agent queue protocol."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException
|
||||||
|
|
||||||
|
from app.agent_auth.exceptions import (
|
||||||
|
AgentAuthenticationFailed,
|
||||||
|
AgentAuthenticationUnavailable,
|
||||||
|
AgentCredentialRevoked,
|
||||||
|
AgentIdentityUnauthorized,
|
||||||
|
)
|
||||||
|
from app.agent_auth.repository import AgentAuthRepository
|
||||||
|
from app.agent_auth.service import AgentAuthService
|
||||||
|
from app.agent_commands.repository import AgentCommandRepository
|
||||||
|
from app.agent_commands.service import AgentCommandService
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_command_service() -> AgentCommandService:
|
||||||
|
return AgentCommandService(AgentCommandRepository())
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_auth_service() -> AgentAuthService:
|
||||||
|
return AgentAuthService(AgentAuthRepository())
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
service: AgentAuthService = Depends(get_agent_auth_service),
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
return service.authenticate(agent_id, provided_secret)
|
||||||
|
except AgentAuthenticationUnavailable as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Agent authentication is not configured",
|
||||||
|
) from exc
|
||||||
|
except AgentAuthenticationFailed as exc:
|
||||||
|
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
||||||
|
except AgentCredentialRevoked as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||||
|
except AgentIdentityUnauthorized as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||||
@@ -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."""
|
||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Secure one-time Agent enrollment."""
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Dependency wiring for secure Agent enrollment."""
|
||||||
|
|
||||||
|
from app.agent_enrollment.repository import AgentEnrollmentRepository
|
||||||
|
from app.agent_enrollment.service import AgentEnrollmentService
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_enrollment_service() -> AgentEnrollmentService:
|
||||||
|
return AgentEnrollmentService(AgentEnrollmentRepository())
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Domain exceptions for secure Agent enrollment."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentError(Exception):
|
||||||
|
"""Base Agent enrollment error."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentNotFound(AgentEnrollmentError):
|
||||||
|
"""The requested Site or Agent Zone does not exist."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentConflict(AgentEnrollmentError):
|
||||||
|
"""The requested enrollment conflicts with current Agent routing state."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentTokenInvalid(AgentEnrollmentError):
|
||||||
|
"""The supplied bootstrap token cannot be redeemed."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentCredentialCollision(AgentEnrollmentError):
|
||||||
|
"""A generated machine credential collided with an existing credential."""
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Typed contracts for secure Agent enrollment."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
site_id: UUID
|
||||||
|
agent_zone_id: UUID
|
||||||
|
display_name: str = Field(min_length=1, max_length=120)
|
||||||
|
|
||||||
|
@field_validator("display_name")
|
||||||
|
@classmethod
|
||||||
|
def normalize_display_name(cls, value: str) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("display_name cannot be blank")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentCreated(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
site_id: UUID
|
||||||
|
site_name: str
|
||||||
|
agent_zone_id: UUID
|
||||||
|
agent_zone_name: str
|
||||||
|
display_name: str
|
||||||
|
created_at: datetime
|
||||||
|
expires_at: datetime
|
||||||
|
enrollment_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentCreateResponse(BaseModel):
|
||||||
|
data: AgentEnrollmentCreated
|
||||||
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
|
error: None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentSummary(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
site_id: UUID
|
||||||
|
site_name: str
|
||||||
|
agent_zone_id: UUID
|
||||||
|
agent_zone_name: str
|
||||||
|
display_name: str
|
||||||
|
created_by_user_id: UUID | None = None
|
||||||
|
created_at: datetime
|
||||||
|
expires_at: datetime
|
||||||
|
used_at: datetime | None = None
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
enrolled_agent_id: str | None = None
|
||||||
|
status: Literal["waiting", "used", "revoked", "expired"]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentListResponse(BaseModel):
|
||||||
|
data: list[AgentEnrollmentSummary]
|
||||||
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
|
error: None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
token: str = Field(min_length=32, max_length=256)
|
||||||
|
|
||||||
|
@field_validator("token")
|
||||||
|
@classmethod
|
||||||
|
def normalize_token(cls, value: str) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("token cannot be blank")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollCredentials(BaseModel):
|
||||||
|
agent_id: str
|
||||||
|
agent_secret: str
|
||||||
|
site_id: UUID
|
||||||
|
site_name: str
|
||||||
|
agent_zone_id: UUID
|
||||||
|
agent_zone_name: str
|
||||||
|
display_name: str
|
||||||
|
ip_ranges: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollResponse(BaseModel):
|
||||||
|
data: AgentEnrollCredentials
|
||||||
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
|
error: None = None
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
"""PostgreSQL persistence for secure Agent enrollment."""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
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.agent_enrollment.exceptions import (
|
||||||
|
AgentEnrollmentConflict,
|
||||||
|
AgentEnrollmentCredentialCollision,
|
||||||
|
AgentEnrollmentNotFound,
|
||||||
|
AgentEnrollmentTokenInvalid,
|
||||||
|
)
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||||
|
|
||||||
|
TOKEN_HASH_CONSTRAINT = "uq_agent_enrollments_token_hash"
|
||||||
|
OPEN_ZONE_CONSTRAINT = "uq_agent_enrollments_open_zone"
|
||||||
|
AGENT_SECRET_HASH_CONSTRAINT = "uq_minerops_site_agents_agent_secret_hash"
|
||||||
|
AGENT_ZONE_CONSTRAINT = "uq_minerops_site_agents_agent_zone_id"
|
||||||
|
AGENT_ID_CONSTRAINT = "minerops_site_agents_pkey"
|
||||||
|
|
||||||
|
|
||||||
|
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 AgentEnrollmentRepository:
|
||||||
|
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||||
|
self._connection_factory = connection_factory or default_connection_factory
|
||||||
|
|
||||||
|
def create_enrollment(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
token_hash: str,
|
||||||
|
site_id: UUID,
|
||||||
|
agent_zone_id: UUID,
|
||||||
|
display_name: str,
|
||||||
|
actor_user_id: UUID,
|
||||||
|
) -> dict:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT zone.id,
|
||||||
|
zone.site_id,
|
||||||
|
zone.name AS agent_zone_name,
|
||||||
|
zone.is_enabled,
|
||||||
|
site.name AS site_name,
|
||||||
|
agent.agent_id AS assigned_agent_id
|
||||||
|
FROM agent_zones AS zone
|
||||||
|
JOIN sites AS site ON site.id = zone.site_id
|
||||||
|
LEFT JOIN minerops_site_agents AS agent
|
||||||
|
ON agent.agent_zone_id = zone.id
|
||||||
|
WHERE zone.id = %s
|
||||||
|
AND zone.site_id = %s
|
||||||
|
FOR UPDATE OF zone
|
||||||
|
""",
|
||||||
|
(agent_zone_id, site_id),
|
||||||
|
)
|
||||||
|
zone = cur.fetchone()
|
||||||
|
|
||||||
|
if not zone:
|
||||||
|
raise AgentEnrollmentNotFound(
|
||||||
|
"Agent Zone was not found for the selected Site"
|
||||||
|
)
|
||||||
|
|
||||||
|
if zone["is_enabled"] is not True:
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Agent Zone is disabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
if zone["assigned_agent_id"]:
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Agent Zone already has an assigned Agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT clock_timestamp() AS operation_now"
|
||||||
|
)
|
||||||
|
operation_now = cur.fetchone()["operation_now"]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE agent_enrollments
|
||||||
|
SET revoked_at = %s
|
||||||
|
WHERE agent_zone_id = %s
|
||||||
|
AND used_at IS NULL
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(operation_now, agent_zone_id),
|
||||||
|
)
|
||||||
|
replaced_ids = [
|
||||||
|
str(row["id"]) for row in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_enrollments (
|
||||||
|
token_hash,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
display_name,
|
||||||
|
created_by_user_id,
|
||||||
|
created_at,
|
||||||
|
expires_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s + interval '30 minutes'
|
||||||
|
)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
display_name,
|
||||||
|
created_at,
|
||||||
|
expires_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
token_hash,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
display_name,
|
||||||
|
actor_user_id,
|
||||||
|
operation_now,
|
||||||
|
operation_now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
enrollment = dict(cur.fetchone())
|
||||||
|
|
||||||
|
enrollment["site_name"] = zone["site_name"]
|
||||||
|
enrollment["agent_zone_name"] = zone["agent_zone_name"]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
actor_user_id,
|
||||||
|
action,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
new_value_json,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s,
|
||||||
|
'agent.enrollment.created',
|
||||||
|
'agent_enrollment',
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
actor_user_id,
|
||||||
|
enrollment["id"],
|
||||||
|
Jsonb(
|
||||||
|
{
|
||||||
|
"site_id": str(site_id),
|
||||||
|
"agent_zone_id": str(agent_zone_id),
|
||||||
|
"display_name": display_name,
|
||||||
|
"expires_at": enrollment[
|
||||||
|
"expires_at"
|
||||||
|
].isoformat(),
|
||||||
|
"replaced_enrollment_ids": replaced_ids,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
operation_now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
return enrollment
|
||||||
|
|
||||||
|
except UniqueViolation as exc:
|
||||||
|
connection.rollback()
|
||||||
|
constraint = exc.diag.constraint_name
|
||||||
|
|
||||||
|
if constraint == TOKEN_HASH_CONSTRAINT:
|
||||||
|
raise AgentEnrollmentCredentialCollision(
|
||||||
|
"Generated enrollment token collided"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if constraint == OPEN_ZONE_CONSTRAINT:
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"An active enrollment already exists for this Agent Zone"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def list_enrollments(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
site_id: UUID | None = None,
|
||||||
|
agent_zone_id: UUID | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[dict]:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
enrollment.id,
|
||||||
|
enrollment.site_id,
|
||||||
|
site.name AS site_name,
|
||||||
|
enrollment.agent_zone_id,
|
||||||
|
zone.name AS agent_zone_name,
|
||||||
|
enrollment.display_name,
|
||||||
|
enrollment.created_by_user_id,
|
||||||
|
enrollment.created_at,
|
||||||
|
enrollment.expires_at,
|
||||||
|
enrollment.used_at,
|
||||||
|
enrollment.revoked_at,
|
||||||
|
enrollment.enrolled_agent_id,
|
||||||
|
CASE
|
||||||
|
WHEN enrollment.used_at IS NOT NULL
|
||||||
|
THEN 'used'
|
||||||
|
WHEN enrollment.revoked_at IS NOT NULL
|
||||||
|
THEN 'revoked'
|
||||||
|
WHEN enrollment.expires_at <= now()
|
||||||
|
THEN 'expired'
|
||||||
|
ELSE 'waiting'
|
||||||
|
END AS status
|
||||||
|
FROM agent_enrollments AS enrollment
|
||||||
|
JOIN sites AS site
|
||||||
|
ON site.id = enrollment.site_id
|
||||||
|
JOIN agent_zones AS zone
|
||||||
|
ON zone.id = enrollment.agent_zone_id
|
||||||
|
WHERE (%s::uuid IS NULL OR enrollment.site_id = %s::uuid)
|
||||||
|
AND (
|
||||||
|
%s::uuid IS NULL
|
||||||
|
OR enrollment.agent_zone_id = %s::uuid
|
||||||
|
)
|
||||||
|
ORDER BY enrollment.created_at DESC, enrollment.id DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
site_id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
agent_zone_id,
|
||||||
|
limit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def redeem_enrollment(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
token_hash: str,
|
||||||
|
agent_id: str,
|
||||||
|
agent_secret_hash: str,
|
||||||
|
) -> dict:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
display_name,
|
||||||
|
expires_at,
|
||||||
|
used_at,
|
||||||
|
revoked_at
|
||||||
|
FROM agent_enrollments
|
||||||
|
WHERE token_hash = %s
|
||||||
|
FOR UPDATE
|
||||||
|
""",
|
||||||
|
(token_hash,),
|
||||||
|
)
|
||||||
|
enrollment = cur.fetchone()
|
||||||
|
|
||||||
|
if not enrollment:
|
||||||
|
raise AgentEnrollmentTokenInvalid(
|
||||||
|
"Invalid or expired enrollment token"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
enrollment["used_at"] is not None
|
||||||
|
or enrollment["revoked_at"] is not None
|
||||||
|
):
|
||||||
|
raise AgentEnrollmentTokenInvalid(
|
||||||
|
"Invalid or expired enrollment token"
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
zone.id,
|
||||||
|
zone.site_id,
|
||||||
|
zone.name AS agent_zone_name,
|
||||||
|
zone.is_enabled,
|
||||||
|
site.name AS site_name
|
||||||
|
FROM agent_zones AS zone
|
||||||
|
JOIN sites AS site ON site.id = zone.site_id
|
||||||
|
WHERE zone.id = %s
|
||||||
|
AND zone.site_id = %s
|
||||||
|
FOR UPDATE OF zone
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
enrollment["agent_zone_id"],
|
||||||
|
enrollment["site_id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
zone = cur.fetchone()
|
||||||
|
|
||||||
|
if not zone:
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Enrollment Agent Zone no longer exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
if zone["is_enabled"] is not True:
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Agent Zone is disabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT agent_id
|
||||||
|
FROM minerops_site_agents
|
||||||
|
WHERE agent_zone_id = %s
|
||||||
|
FOR UPDATE
|
||||||
|
""",
|
||||||
|
(enrollment["agent_zone_id"],),
|
||||||
|
)
|
||||||
|
existing_agent = cur.fetchone()
|
||||||
|
|
||||||
|
if existing_agent:
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Agent Zone already has an assigned Agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT clock_timestamp() AS operation_now"
|
||||||
|
)
|
||||||
|
operation_now = cur.fetchone()["operation_now"]
|
||||||
|
|
||||||
|
if enrollment["expires_at"] <= operation_now:
|
||||||
|
raise AgentEnrollmentTokenInvalid(
|
||||||
|
"Invalid or expired enrollment token"
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
site_name,
|
||||||
|
display_name,
|
||||||
|
agent_secret_hash,
|
||||||
|
credential_created_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
agent_id,
|
||||||
|
enrollment["site_id"],
|
||||||
|
enrollment["agent_zone_id"],
|
||||||
|
zone["site_name"],
|
||||||
|
enrollment["display_name"],
|
||||||
|
agent_secret_hash,
|
||||||
|
operation_now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE agent_enrollments
|
||||||
|
SET used_at = %s,
|
||||||
|
enrolled_agent_id = %s
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING used_at
|
||||||
|
""",
|
||||||
|
(operation_now, agent_id, enrollment["id"]),
|
||||||
|
)
|
||||||
|
used_at = cur.fetchone()["used_at"]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT network::text AS network
|
||||||
|
FROM agent_zone_network_ranges
|
||||||
|
WHERE agent_zone_id = %s
|
||||||
|
ORDER BY network, id
|
||||||
|
""",
|
||||||
|
(enrollment["agent_zone_id"],),
|
||||||
|
)
|
||||||
|
networks = [
|
||||||
|
row["network"] for row in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
actor_user_id,
|
||||||
|
action,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
new_value_json,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
NULL,
|
||||||
|
'agent.enrollment.redeemed',
|
||||||
|
'agent_enrollment',
|
||||||
|
%s,
|
||||||
|
%s,
|
||||||
|
%s
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
enrollment["id"],
|
||||||
|
Jsonb(
|
||||||
|
{
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"site_id": str(enrollment["site_id"]),
|
||||||
|
"agent_zone_id": str(
|
||||||
|
enrollment["agent_zone_id"]
|
||||||
|
),
|
||||||
|
"display_name": enrollment[
|
||||||
|
"display_name"
|
||||||
|
],
|
||||||
|
"used_at": used_at.isoformat(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
operation_now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"site_id": enrollment["site_id"],
|
||||||
|
"site_name": zone["site_name"],
|
||||||
|
"agent_zone_id": enrollment["agent_zone_id"],
|
||||||
|
"agent_zone_name": zone["agent_zone_name"],
|
||||||
|
"display_name": enrollment["display_name"],
|
||||||
|
"ip_ranges": networks,
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except UniqueViolation as exc:
|
||||||
|
connection.rollback()
|
||||||
|
constraint = exc.diag.constraint_name
|
||||||
|
|
||||||
|
if constraint in {
|
||||||
|
AGENT_ID_CONSTRAINT,
|
||||||
|
AGENT_SECRET_HASH_CONSTRAINT,
|
||||||
|
}:
|
||||||
|
raise AgentEnrollmentCredentialCollision(
|
||||||
|
"Generated Agent credential collided"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if constraint == AGENT_ZONE_CONSTRAINT:
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Agent Zone already has an assigned Agent"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""HTTP routes for Agent enrollment and bootstrap."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
|
|
||||||
|
from app.agent_enrollment.dependencies import get_agent_enrollment_service
|
||||||
|
from app.agent_enrollment.exceptions import (
|
||||||
|
AgentEnrollmentConflict,
|
||||||
|
AgentEnrollmentNotFound,
|
||||||
|
AgentEnrollmentTokenInvalid,
|
||||||
|
)
|
||||||
|
from app.agent_enrollment.models import (
|
||||||
|
AgentEnrollmentCreateRequest,
|
||||||
|
AgentEnrollmentCreateResponse,
|
||||||
|
AgentEnrollmentListResponse,
|
||||||
|
AgentEnrollRequest,
|
||||||
|
AgentEnrollResponse,
|
||||||
|
)
|
||||||
|
from app.agent_enrollment.service import AgentEnrollmentService
|
||||||
|
from app.identity import require_permission
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["agent-enrollment"])
|
||||||
|
|
||||||
|
EnrollmentService = Annotated[
|
||||||
|
AgentEnrollmentService,
|
||||||
|
Depends(get_agent_enrollment_service),
|
||||||
|
]
|
||||||
|
EnrollmentViewer = Annotated[
|
||||||
|
dict,
|
||||||
|
Depends(require_permission("agents.view")),
|
||||||
|
]
|
||||||
|
EnrollmentManager = Annotated[
|
||||||
|
dict,
|
||||||
|
Depends(require_permission("agents.manage")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/agent-enrollments",
|
||||||
|
response_model=AgentEnrollmentCreateResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_agent_enrollment(
|
||||||
|
request: AgentEnrollmentCreateRequest,
|
||||||
|
response: Response,
|
||||||
|
current_user: EnrollmentManager,
|
||||||
|
service: EnrollmentService,
|
||||||
|
) -> AgentEnrollmentCreateResponse:
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
|
try:
|
||||||
|
enrollment = service.create_enrollment(
|
||||||
|
request,
|
||||||
|
actor_user_id=UUID(str(current_user["id"])),
|
||||||
|
)
|
||||||
|
return AgentEnrollmentCreateResponse(data=enrollment)
|
||||||
|
except AgentEnrollmentNotFound as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except AgentEnrollmentConflict as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/agent-enrollments",
|
||||||
|
response_model=AgentEnrollmentListResponse,
|
||||||
|
)
|
||||||
|
def list_agent_enrollments(
|
||||||
|
current_user: EnrollmentViewer,
|
||||||
|
service: EnrollmentService,
|
||||||
|
site_id: UUID | None = Query(default=None),
|
||||||
|
agent_zone_id: UUID | None = Query(default=None),
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
) -> AgentEnrollmentListResponse:
|
||||||
|
del current_user
|
||||||
|
|
||||||
|
return AgentEnrollmentListResponse(
|
||||||
|
data=service.list_enrollments(
|
||||||
|
site_id=site_id,
|
||||||
|
agent_zone_id=agent_zone_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/agent/enroll",
|
||||||
|
response_model=AgentEnrollResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def enroll_agent(
|
||||||
|
request: AgentEnrollRequest,
|
||||||
|
response: Response,
|
||||||
|
service: EnrollmentService,
|
||||||
|
) -> AgentEnrollResponse:
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
|
try:
|
||||||
|
return AgentEnrollResponse(data=service.redeem(request))
|
||||||
|
except AgentEnrollmentTokenInvalid as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Invalid or expired enrollment token",
|
||||||
|
) from exc
|
||||||
|
except AgentEnrollmentConflict as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Application service for secure one-time Agent enrollment."""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from app.agent_auth.service import hash_agent_secret
|
||||||
|
from app.agent_enrollment.exceptions import (
|
||||||
|
AgentEnrollmentConflict,
|
||||||
|
AgentEnrollmentCredentialCollision,
|
||||||
|
)
|
||||||
|
from app.agent_enrollment.models import (
|
||||||
|
AgentEnrollmentCreateRequest,
|
||||||
|
AgentEnrollRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MAX_GENERATION_ATTEMPTS = 3
|
||||||
|
|
||||||
|
|
||||||
|
def generate_enrollment_token() -> str:
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_agent_secret() -> str:
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_agent_id() -> str:
|
||||||
|
return f"agent-{uuid4().hex}"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEnrollmentService:
|
||||||
|
def __init__(self, repository):
|
||||||
|
self.repository = repository
|
||||||
|
|
||||||
|
def create_enrollment(
|
||||||
|
self,
|
||||||
|
request: AgentEnrollmentCreateRequest,
|
||||||
|
*,
|
||||||
|
actor_user_id: UUID,
|
||||||
|
) -> dict:
|
||||||
|
for _ in range(MAX_GENERATION_ATTEMPTS):
|
||||||
|
token = generate_enrollment_token()
|
||||||
|
try:
|
||||||
|
enrollment = self.repository.create_enrollment(
|
||||||
|
token_hash=hash_agent_secret(token),
|
||||||
|
site_id=request.site_id,
|
||||||
|
agent_zone_id=request.agent_zone_id,
|
||||||
|
display_name=request.display_name,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
**enrollment,
|
||||||
|
"enrollment_token": token,
|
||||||
|
}
|
||||||
|
except AgentEnrollmentCredentialCollision:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Unable to generate a unique enrollment token"
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_enrollments(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
site_id: UUID | None = None,
|
||||||
|
agent_zone_id: UUID | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[dict]:
|
||||||
|
return self.repository.list_enrollments(
|
||||||
|
site_id=site_id,
|
||||||
|
agent_zone_id=agent_zone_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
def redeem(self, request: AgentEnrollRequest) -> dict:
|
||||||
|
token_hash = hash_agent_secret(request.token)
|
||||||
|
|
||||||
|
for _ in range(MAX_GENERATION_ATTEMPTS):
|
||||||
|
agent_id = generate_agent_id()
|
||||||
|
agent_secret = generate_agent_secret()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.repository.redeem_enrollment(
|
||||||
|
token_hash=token_hash,
|
||||||
|
agent_id=agent_id,
|
||||||
|
agent_secret_hash=hash_agent_secret(agent_secret),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
**result,
|
||||||
|
"agent_secret": agent_secret,
|
||||||
|
}
|
||||||
|
except AgentEnrollmentCredentialCollision:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise AgentEnrollmentConflict(
|
||||||
|
"Unable to generate unique Agent credentials"
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Agent Zone routing domain."""
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Agent Zone dependency wiring."""
|
||||||
|
|
||||||
|
from app.agent_zones.repository import AgentZoneRepository
|
||||||
|
from app.agent_zones.service import AgentZoneService
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_zone_service() -> AgentZoneService:
|
||||||
|
return AgentZoneService(AgentZoneRepository())
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Agent Zone domain exceptions."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneError(Exception):
|
||||||
|
"""Base Agent Zone error."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneNotFound(AgentZoneError):
|
||||||
|
"""A Site or Agent Zone does not exist."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneValidationError(AgentZoneError):
|
||||||
|
"""Agent Zone input or assignment is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneConflict(AgentZoneError):
|
||||||
|
"""An Agent Zone uniqueness or assignment invariant conflicts."""
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Typed Agent Zone and check-in contracts."""
|
||||||
|
|
||||||
|
from ipaddress import ip_network
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
site_id: UUID
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
description: str | None = Field(default=None, max_length=1000)
|
||||||
|
networks: list[str] = Field(default_factory=list, max_length=64)
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def normalize_name(cls, value: str) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("name cannot be blank")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@field_validator("description")
|
||||||
|
@classmethod
|
||||||
|
def normalize_description(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.strip() or None
|
||||||
|
|
||||||
|
@field_validator("networks")
|
||||||
|
@classmethod
|
||||||
|
def normalize_networks(cls, values: list[str]) -> list[str]:
|
||||||
|
normalized: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
try:
|
||||||
|
network = str(ip_network(value.strip(), strict=True))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"invalid CIDR network: {value}") from exc
|
||||||
|
if network in normalized:
|
||||||
|
raise ValueError(f"duplicate CIDR network: {network}")
|
||||||
|
normalized.append(network)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneNetwork(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
network: str
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneSummary(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
site_id: UUID
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
is_enabled: bool
|
||||||
|
assigned_agent_id: str | None = None
|
||||||
|
networks: list[AgentZoneNetwork] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneListResponse(BaseModel):
|
||||||
|
data: list[AgentZoneSummary]
|
||||||
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
|
error: None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneCreateResponse(BaseModel):
|
||||||
|
data: AgentZoneSummary
|
||||||
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
|
error: None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCheckinRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
agent_id: str | None = Field(default=None, max_length=255)
|
||||||
|
site_id: UUID | None = None
|
||||||
|
agent_zone_id: UUID | None = None
|
||||||
|
site_name: str | None = Field(default=None, max_length=255)
|
||||||
|
hostname: str | None = Field(default=None, max_length=255)
|
||||||
|
version: str | None = Field(default=None, max_length=120)
|
||||||
|
ip_address: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def require_site_for_explicit_zone(self) -> "AgentCheckinRequest":
|
||||||
|
if self.agent_zone_id is not None and self.site_id is None:
|
||||||
|
raise ValueError("site_id is required when agent_zone_id is supplied")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCheckinResponse(BaseModel):
|
||||||
|
ok: bool = True
|
||||||
|
hub_mode: bool = True
|
||||||
|
agent_id: str
|
||||||
|
site_id: UUID | None = None
|
||||||
|
agent_zone_id: UUID | None = None
|
||||||
|
routing_available: bool
|
||||||
|
routing_reason: str | None = None
|
||||||
|
site_name: str
|
||||||
|
message: str = "Agent check-in received."
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneRoutingContext(BaseModel):
|
||||||
|
site_id: UUID | None = None
|
||||||
|
agent_zone_id: UUID | None = None
|
||||||
|
routing_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MinerZoneAssociationResult(BaseModel):
|
||||||
|
agent_zone_id: UUID | None = None
|
||||||
|
routing_reason: str | None = None
|
||||||
|
outcomes: dict[UUID, str] = Field(default_factory=dict)
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""PostgreSQL persistence for Agent Zones and ownership assignments."""
|
||||||
|
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.errors import ForeignKeyViolation, UniqueViolation
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from app.agent_zones.exceptions import (
|
||||||
|
AgentZoneConflict,
|
||||||
|
AgentZoneNotFound,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneRepository:
|
||||||
|
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||||
|
self._connection_factory = connection_factory or default_connection_factory
|
||||||
|
|
||||||
|
def get_agent(self, agent_id: str) -> dict | None:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT agent_id, site_id, agent_zone_id, site_name, hostname,
|
||||||
|
version, ip_address, last_checkin_at, last_payload
|
||||||
|
FROM minerops_site_agents
|
||||||
|
WHERE agent_id = %s
|
||||||
|
""",
|
||||||
|
(agent_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def get_zone(self, zone_id: UUID) -> dict | None:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, site_id, name, description, is_enabled,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM agent_zones
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(zone_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def list_usable_zones(self, site_id: UUID) -> list[dict]:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, site_id, name, description, is_enabled
|
||||||
|
FROM agent_zones
|
||||||
|
WHERE site_id = %s AND is_enabled
|
||||||
|
ORDER BY lower(name), id
|
||||||
|
""",
|
||||||
|
(site_id,),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def list_zones(self, site_id: UUID | None = None) -> list[dict]:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT zone.id, zone.site_id, zone.name, zone.description,
|
||||||
|
zone.is_enabled, agent.agent_id AS assigned_agent_id,
|
||||||
|
range.id AS range_id, range.network::text AS network
|
||||||
|
FROM agent_zones AS zone
|
||||||
|
LEFT JOIN minerops_site_agents AS agent
|
||||||
|
ON agent.agent_zone_id = zone.id
|
||||||
|
LEFT JOIN agent_zone_network_ranges AS range
|
||||||
|
ON range.agent_zone_id = zone.id
|
||||||
|
WHERE (%s::uuid IS NULL OR zone.site_id = %s::uuid)
|
||||||
|
ORDER BY lower(zone.name), zone.id, range.network, range.id
|
||||||
|
""",
|
||||||
|
(site_id, site_id),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
zones: dict[UUID, dict] = {}
|
||||||
|
for row in rows:
|
||||||
|
zone = zones.setdefault(
|
||||||
|
row["id"],
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"site_id": row["site_id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"description": row["description"],
|
||||||
|
"is_enabled": row["is_enabled"],
|
||||||
|
"assigned_agent_id": row["assigned_agent_id"],
|
||||||
|
"networks": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if row["range_id"] is not None:
|
||||||
|
zone["networks"].append(
|
||||||
|
{"id": row["range_id"], "network": row["network"]}
|
||||||
|
)
|
||||||
|
return list(zones.values())
|
||||||
|
|
||||||
|
def create_zone(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
site_id: UUID,
|
||||||
|
name: str,
|
||||||
|
description: str | None,
|
||||||
|
networks: list[str],
|
||||||
|
) -> UUID:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zones (site_id, name, description)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(site_id, name, description),
|
||||||
|
)
|
||||||
|
zone_id = cur.fetchone()["id"]
|
||||||
|
for network in networks:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO agent_zone_network_ranges (agent_zone_id, network)
|
||||||
|
VALUES (%s, %s::cidr)
|
||||||
|
""",
|
||||||
|
(zone_id, network),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
return zone_id
|
||||||
|
except ForeignKeyViolation as exc:
|
||||||
|
connection.rollback()
|
||||||
|
raise AgentZoneNotFound("Site not found") from exc
|
||||||
|
except UniqueViolation as exc:
|
||||||
|
connection.rollback()
|
||||||
|
raise AgentZoneConflict(
|
||||||
|
"An enabled Agent Zone with this name already exists at the Site"
|
||||||
|
) from exc
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def save_checkin(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
site_id: UUID | None,
|
||||||
|
agent_zone_id: UUID | None,
|
||||||
|
site_name: str,
|
||||||
|
hostname: str | None,
|
||||||
|
version: str | None,
|
||||||
|
ip_address: str | None,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO minerops_site_agents (
|
||||||
|
agent_id, site_id, agent_zone_id, site_name, hostname,
|
||||||
|
version, ip_address, last_checkin_at, last_payload
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, now(), %s)
|
||||||
|
ON CONFLICT (agent_id) DO UPDATE SET
|
||||||
|
site_id = EXCLUDED.site_id,
|
||||||
|
agent_zone_id = EXCLUDED.agent_zone_id,
|
||||||
|
site_name = EXCLUDED.site_name,
|
||||||
|
hostname = EXCLUDED.hostname,
|
||||||
|
version = EXCLUDED.version,
|
||||||
|
ip_address = EXCLUDED.ip_address,
|
||||||
|
last_checkin_at = now(),
|
||||||
|
last_payload = EXCLUDED.last_payload
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
agent_id,
|
||||||
|
site_id,
|
||||||
|
agent_zone_id,
|
||||||
|
site_name,
|
||||||
|
hostname,
|
||||||
|
version,
|
||||||
|
ip_address,
|
||||||
|
Jsonb(payload),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
except UniqueViolation as exc:
|
||||||
|
connection.rollback()
|
||||||
|
raise AgentZoneConflict(
|
||||||
|
"The Agent Zone already has an assigned Agent"
|
||||||
|
) from exc
|
||||||
|
except (ForeignKeyViolation, psycopg.errors.CheckViolation) as exc:
|
||||||
|
connection.rollback()
|
||||||
|
raise AgentZoneConflict("Agent Zone and Site assignment conflict") from exc
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def get_agent_assignment(self, agent_id: str) -> dict | None:
|
||||||
|
connection = self._connection_factory()
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT agent.agent_id, agent.site_id, agent.agent_zone_id,
|
||||||
|
zone.is_enabled AS zone_enabled
|
||||||
|
FROM minerops_site_agents AS agent
|
||||||
|
LEFT JOIN agent_zones AS zone ON zone.id = agent.agent_zone_id
|
||||||
|
WHERE agent.agent_id = %s
|
||||||
|
""",
|
||||||
|
(agent_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def assign_miners_to_zone(
|
||||||
|
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
|
||||||
|
) -> dict[UUID, str]:
|
||||||
|
unique_ids = list(dict.fromkeys(miner_ids))
|
||||||
|
if not unique_ids:
|
||||||
|
return {}
|
||||||
|
connection = self._connection_factory()
|
||||||
|
outcomes: dict[UUID, str] = {}
|
||||||
|
try:
|
||||||
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, site_id FROM agent_zones WHERE id = %s",
|
||||||
|
(agent_zone_id,),
|
||||||
|
)
|
||||||
|
zone = cur.fetchone()
|
||||||
|
if not zone:
|
||||||
|
raise AgentZoneNotFound("Agent Zone not found")
|
||||||
|
for miner_id in unique_ids:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, site_id, agent_zone_id FROM miners WHERE id = %s FOR UPDATE",
|
||||||
|
(miner_id,),
|
||||||
|
)
|
||||||
|
miner = cur.fetchone()
|
||||||
|
if not miner:
|
||||||
|
outcomes[miner_id] = "miner_not_found"
|
||||||
|
elif miner["agent_zone_id"] == agent_zone_id:
|
||||||
|
outcomes[miner_id] = "preserved"
|
||||||
|
elif miner["agent_zone_id"] is not None:
|
||||||
|
outcomes[miner_id] = "agent_zone_conflict"
|
||||||
|
elif miner["site_id"] not in {None, zone["site_id"]}:
|
||||||
|
outcomes[miner_id] = "site_conflict"
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE miners
|
||||||
|
SET site_id = %s, agent_zone_id = %s, updated_at = now()
|
||||||
|
WHERE id = %s AND agent_zone_id IS NULL
|
||||||
|
""",
|
||||||
|
(zone["site_id"], agent_zone_id, miner_id),
|
||||||
|
)
|
||||||
|
outcomes[miner_id] = "assigned"
|
||||||
|
connection.commit()
|
||||||
|
return outcomes
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""HTTP mapping for Agent Zones and Agent check-in."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
|
||||||
|
from app.agent_commands.dependencies import require_agent_identity
|
||||||
|
from app.agent_zones.dependencies import get_agent_zone_service
|
||||||
|
from app.agent_zones.exceptions import (
|
||||||
|
AgentZoneConflict,
|
||||||
|
AgentZoneNotFound,
|
||||||
|
AgentZoneValidationError,
|
||||||
|
)
|
||||||
|
from app.agent_zones.models import (
|
||||||
|
AgentCheckinRequest,
|
||||||
|
AgentCheckinResponse,
|
||||||
|
AgentZoneCreateRequest,
|
||||||
|
AgentZoneCreateResponse,
|
||||||
|
AgentZoneListResponse,
|
||||||
|
)
|
||||||
|
from app.agent_zones.service import AgentZoneService
|
||||||
|
from app.identity import require_permission
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["agent-zones"])
|
||||||
|
ZoneService = Annotated[AgentZoneService, Depends(get_agent_zone_service)]
|
||||||
|
AgentIdentity = Annotated[str, Depends(require_agent_identity)]
|
||||||
|
ZoneViewer = Annotated[dict, Depends(require_permission("agents.view"))]
|
||||||
|
ZoneManager = Annotated[dict, Depends(require_permission("agents.manage"))]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/agent-zones", response_model=AgentZoneListResponse)
|
||||||
|
def list_agent_zones(
|
||||||
|
current_user: ZoneViewer,
|
||||||
|
service: ZoneService,
|
||||||
|
site_id: UUID | None = Query(default=None),
|
||||||
|
) -> AgentZoneListResponse:
|
||||||
|
del current_user
|
||||||
|
return AgentZoneListResponse(data=service.list_zones(site_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/agent-zones",
|
||||||
|
response_model=AgentZoneCreateResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_agent_zone(
|
||||||
|
request: AgentZoneCreateRequest,
|
||||||
|
current_user: ZoneManager,
|
||||||
|
service: ZoneService,
|
||||||
|
) -> AgentZoneCreateResponse:
|
||||||
|
del current_user
|
||||||
|
try:
|
||||||
|
return AgentZoneCreateResponse(data=service.create_zone(request))
|
||||||
|
except AgentZoneNotFound as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except AgentZoneConflict as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/agent/checkin", response_model=AgentCheckinResponse)
|
||||||
|
def agent_checkin(
|
||||||
|
request: AgentCheckinRequest,
|
||||||
|
agent_id: AgentIdentity,
|
||||||
|
service: ZoneService,
|
||||||
|
) -> AgentCheckinResponse:
|
||||||
|
try:
|
||||||
|
return service.checkin(agent_id, request)
|
||||||
|
except AgentZoneNotFound as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except AgentZoneConflict as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
except AgentZoneValidationError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Business rules for Agent Zone routing and transitional check-in."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.agent_zones.exceptions import (
|
||||||
|
AgentZoneNotFound,
|
||||||
|
AgentZoneValidationError,
|
||||||
|
)
|
||||||
|
from app.agent_zones.models import (
|
||||||
|
AgentCheckinRequest,
|
||||||
|
AgentCheckinResponse,
|
||||||
|
AgentZoneRoutingContext,
|
||||||
|
AgentZoneCreateRequest,
|
||||||
|
AgentZoneSummary,
|
||||||
|
MinerZoneAssociationResult,
|
||||||
|
)
|
||||||
|
from app.agent_zones.repository import AgentZoneRepository
|
||||||
|
|
||||||
|
|
||||||
|
SENSITIVE_KEYS = {
|
||||||
|
"agent_secret",
|
||||||
|
"api3_account",
|
||||||
|
"api3_password",
|
||||||
|
"password",
|
||||||
|
"salt",
|
||||||
|
"token",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize(value: Any) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
key: _sanitize(item)
|
||||||
|
for key, item in value.items()
|
||||||
|
if key.lower() not in SENSITIVE_KEYS
|
||||||
|
and not any(
|
||||||
|
marker in key.lower()
|
||||||
|
for marker in ("secret", "password", "token", "salt")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_sanitize(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class AgentZoneService:
|
||||||
|
@staticmethod
|
||||||
|
def validate_agent_identity(
|
||||||
|
authenticated_agent_id: str, claimed_agent_id: str | None
|
||||||
|
) -> None:
|
||||||
|
if claimed_agent_id and claimed_agent_id.strip() != authenticated_agent_id:
|
||||||
|
raise AgentZoneValidationError(
|
||||||
|
"Payload agent_id does not match authenticated Agent identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, repository: AgentZoneRepository):
|
||||||
|
self.repository = repository
|
||||||
|
|
||||||
|
def list_zones(self, site_id: UUID | None = None) -> list[AgentZoneSummary]:
|
||||||
|
return [
|
||||||
|
AgentZoneSummary.model_validate(row)
|
||||||
|
for row in self.repository.list_zones(site_id)
|
||||||
|
]
|
||||||
|
|
||||||
|
def create_zone(self, request: AgentZoneCreateRequest) -> AgentZoneSummary:
|
||||||
|
zone_id = self.repository.create_zone(
|
||||||
|
site_id=request.site_id,
|
||||||
|
name=request.name,
|
||||||
|
description=request.description,
|
||||||
|
networks=request.networks,
|
||||||
|
)
|
||||||
|
for zone in self.list_zones(request.site_id):
|
||||||
|
if zone.id == zone_id:
|
||||||
|
return zone
|
||||||
|
raise AgentZoneNotFound("Created Agent Zone was not found")
|
||||||
|
|
||||||
|
def checkin(
|
||||||
|
self, authenticated_agent_id: str, request: AgentCheckinRequest
|
||||||
|
) -> AgentCheckinResponse:
|
||||||
|
self.validate_agent_identity(authenticated_agent_id, request.agent_id)
|
||||||
|
|
||||||
|
existing = self.repository.get_agent(authenticated_agent_id)
|
||||||
|
requested_site = request.site_id
|
||||||
|
requested_zone = request.agent_zone_id
|
||||||
|
final_site = requested_site or (existing or {}).get("site_id")
|
||||||
|
final_zone: UUID | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
established_zone = None
|
||||||
|
established_zone_id = (existing or {}).get("agent_zone_id")
|
||||||
|
if established_zone_id is not None:
|
||||||
|
established_zone = self.repository.get_zone(established_zone_id)
|
||||||
|
if not established_zone:
|
||||||
|
raise AgentZoneValidationError(
|
||||||
|
"Existing Agent Zone assignment is invalid"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
requested_site is not None
|
||||||
|
and established_zone["site_id"] != requested_site
|
||||||
|
):
|
||||||
|
raise AgentZoneValidationError(
|
||||||
|
"Agent cannot change Sites while assigned to an Agent Zone"
|
||||||
|
)
|
||||||
|
|
||||||
|
if requested_zone is not None:
|
||||||
|
zone = self.repository.get_zone(requested_zone)
|
||||||
|
if not zone:
|
||||||
|
raise AgentZoneNotFound("Agent Zone not found")
|
||||||
|
if zone["site_id"] != requested_site:
|
||||||
|
raise AgentZoneValidationError(
|
||||||
|
"Agent Zone does not belong to the supplied Site"
|
||||||
|
)
|
||||||
|
if not zone["is_enabled"]:
|
||||||
|
raise AgentZoneValidationError("Agent Zone is disabled")
|
||||||
|
if established_zone_id is not None and established_zone_id != requested_zone:
|
||||||
|
raise AgentZoneValidationError(
|
||||||
|
"Agent is already assigned to a different Agent Zone"
|
||||||
|
)
|
||||||
|
final_zone = requested_zone
|
||||||
|
elif established_zone is not None:
|
||||||
|
final_site = established_zone["site_id"]
|
||||||
|
final_zone = established_zone["id"]
|
||||||
|
if not established_zone["is_enabled"]:
|
||||||
|
reason = "agent_zone_disabled"
|
||||||
|
elif final_site is not None:
|
||||||
|
usable = self.repository.list_usable_zones(final_site)
|
||||||
|
if len(usable) == 1:
|
||||||
|
final_zone = usable[0]["id"]
|
||||||
|
elif not usable:
|
||||||
|
reason = "agent_zone_unavailable"
|
||||||
|
else:
|
||||||
|
reason = "agent_zone_ambiguous"
|
||||||
|
else:
|
||||||
|
reason = "site_unassigned"
|
||||||
|
|
||||||
|
site_name = (request.site_name or "").strip()
|
||||||
|
if not site_name:
|
||||||
|
site_name = str((existing or {}).get("site_name") or "Unassigned")
|
||||||
|
payload = _sanitize(request.model_dump(mode="json"))
|
||||||
|
self.repository.save_checkin(
|
||||||
|
agent_id=authenticated_agent_id,
|
||||||
|
site_id=final_site,
|
||||||
|
agent_zone_id=final_zone,
|
||||||
|
site_name=site_name,
|
||||||
|
hostname=(request.hostname or "").strip() or None,
|
||||||
|
version=(request.version or "").strip() or None,
|
||||||
|
ip_address=(request.ip_address or "").strip() or None,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
routing_available = final_zone is not None and reason is None
|
||||||
|
return AgentCheckinResponse(
|
||||||
|
agent_id=authenticated_agent_id,
|
||||||
|
site_id=final_site,
|
||||||
|
agent_zone_id=final_zone,
|
||||||
|
routing_available=routing_available,
|
||||||
|
routing_reason=reason,
|
||||||
|
site_name=site_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def routing_context(
|
||||||
|
self, agent_id: str, recorded_zone_id: UUID | None = None
|
||||||
|
) -> AgentZoneRoutingContext:
|
||||||
|
assignment = self.repository.get_agent_assignment(agent_id)
|
||||||
|
if not assignment or assignment.get("agent_zone_id") is None:
|
||||||
|
return AgentZoneRoutingContext(routing_reason="agent_zone_unassigned")
|
||||||
|
zone_id = assignment["agent_zone_id"]
|
||||||
|
if recorded_zone_id is not None and recorded_zone_id != zone_id:
|
||||||
|
return AgentZoneRoutingContext(routing_reason="agent_zone_conflict")
|
||||||
|
if assignment.get("zone_enabled") is not True:
|
||||||
|
return AgentZoneRoutingContext(
|
||||||
|
site_id=assignment.get("site_id"),
|
||||||
|
agent_zone_id=zone_id,
|
||||||
|
routing_reason="agent_zone_disabled",
|
||||||
|
)
|
||||||
|
return AgentZoneRoutingContext(
|
||||||
|
site_id=assignment.get("site_id"), agent_zone_id=zone_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def associate_miners_from_agent(
|
||||||
|
self, agent_id: str, miner_ids: list[UUID]
|
||||||
|
) -> MinerZoneAssociationResult:
|
||||||
|
routing = self.routing_context(agent_id)
|
||||||
|
if routing.routing_reason:
|
||||||
|
return MinerZoneAssociationResult(
|
||||||
|
agent_zone_id=routing.agent_zone_id,
|
||||||
|
routing_reason=routing.routing_reason,
|
||||||
|
)
|
||||||
|
return MinerZoneAssociationResult(
|
||||||
|
agent_zone_id=routing.agent_zone_id,
|
||||||
|
outcomes=self.repository.assign_miners_to_zone(
|
||||||
|
miner_ids, routing.agent_zone_id
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Minimal durable component registry for repair-part references."""
|
||||||
@@ -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())
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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,6 +5,8 @@ class Settings(BaseSettings):
|
|||||||
app_name: str = "MinerOps Console"
|
app_name: str = "MinerOps Console"
|
||||||
app_env: str = "development"
|
app_env: str = "development"
|
||||||
app_timezone: str = "America/Winnipeg"
|
app_timezone: str = "America/Winnipeg"
|
||||||
|
minerops_build_commit: str | None = None
|
||||||
|
minerops_build_version: str | None = None
|
||||||
|
|
||||||
postgres_db: str = "minerops"
|
postgres_db: str = "minerops"
|
||||||
postgres_user: str = "minerops"
|
postgres_user: str = "minerops"
|
||||||
@@ -14,6 +16,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
redis_url: str = "redis://redis:6379/0"
|
redis_url: str = "redis://redis:6379/0"
|
||||||
|
|
||||||
|
minerops_agent_id: str = "plexus-windows-agent"
|
||||||
|
minerops_agent_shared_secret: str = ""
|
||||||
|
|
||||||
jwt_secret_key: str
|
jwt_secret_key: str
|
||||||
jwt_access_token_minutes: int = 30
|
jwt_access_token_minutes: int = 30
|
||||||
jwt_refresh_token_days: int = 14
|
jwt_refresh_token_days: int = 14
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Permission-aware operational Dashboard read model."""
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Server-rendered frontend foundation for MinerOps."""
|
||||||
@@ -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>
|
||||||
|
"""
|
||||||
@@ -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
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
}());
|
||||||
@@ -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();
|
||||||
|
}());
|
||||||
@@ -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();
|
||||||
|
}());
|
||||||
@@ -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>
|
||||||
|
"""
|
||||||
@@ -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>"""
|
||||||
+149
-14
@@ -1,7 +1,7 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from html import escape
|
from html import escape
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote, urlsplit
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import psycopg
|
import psycopg
|
||||||
@@ -633,20 +633,63 @@ def reset_password(
|
|||||||
|
|
||||||
|
|
||||||
def protected_page(path: str) -> bool:
|
def protected_page(path: str) -> bool:
|
||||||
exact = {"/dashboard", "/repairs", "/locations", "/system-health", "/accounts"}
|
exact = {
|
||||||
|
"/dashboard",
|
||||||
|
"/repairs",
|
||||||
|
"/locations",
|
||||||
|
"/system-health",
|
||||||
|
"/accounts",
|
||||||
|
"/monitoring/map",
|
||||||
|
"/monitoring/analytics",
|
||||||
|
"/operations/repairs",
|
||||||
|
"/assets/miners",
|
||||||
|
}
|
||||||
prefixes = ("/miners/", "/agent/", "/tools/")
|
prefixes = ("/miners/", "/agent/", "/tools/")
|
||||||
return path in exact or path.startswith(prefixes)
|
return path in exact or path.startswith(prefixes)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_next_path(value: str | None) -> str:
|
||||||
|
"""Return a safe local application destination for login redirects."""
|
||||||
|
fallback = "/dashboard"
|
||||||
|
if not value or value != value.strip():
|
||||||
|
return fallback
|
||||||
|
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
try:
|
||||||
|
target = urlsplit(value)
|
||||||
|
except ValueError:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
path = target.path
|
||||||
|
if (
|
||||||
|
target.scheme
|
||||||
|
or target.netloc
|
||||||
|
or target.fragment
|
||||||
|
or not path.startswith("/")
|
||||||
|
or path.startswith("//")
|
||||||
|
or "\\" in path
|
||||||
|
or "%" in path
|
||||||
|
or any(segment in {".", ".."} for segment in path.split("/"))
|
||||||
|
or not protected_page(path)
|
||||||
|
):
|
||||||
|
return fallback
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def required_page_permission(path: str) -> str:
|
def required_page_permission(path: str) -> str:
|
||||||
if path == "/accounts":
|
if path == "/accounts":
|
||||||
return "users.manage"
|
return "users.manage"
|
||||||
if path == "/system-health":
|
if path == "/system-health":
|
||||||
return "system_health.view"
|
return "system_health.view"
|
||||||
if path == "/repairs":
|
if path in {"/repairs", "/operations/repairs"}:
|
||||||
return "repairs.view"
|
return "repairs.view"
|
||||||
if path == "/locations":
|
if path in {"/locations", "/monitoring/map"}:
|
||||||
return "locations.view"
|
return "locations.view"
|
||||||
|
if path == "/monitoring/analytics":
|
||||||
|
return "miners.view"
|
||||||
|
if path == "/assets/miners":
|
||||||
|
return "miners.view"
|
||||||
if path.startswith("/agent/"):
|
if path.startswith("/agent/"):
|
||||||
return "agents.view"
|
return "agents.view"
|
||||||
if path.startswith("/miners/"):
|
if path.startswith("/miners/"):
|
||||||
@@ -656,8 +699,10 @@ def required_page_permission(path: str) -> str:
|
|||||||
|
|
||||||
def machine_agent_request(path: str) -> bool:
|
def machine_agent_request(path: str) -> bool:
|
||||||
exact = {
|
exact = {
|
||||||
|
"/api/agent/enroll",
|
||||||
"/api/agent/checkin",
|
"/api/agent/checkin",
|
||||||
"/api/agent/config",
|
"/api/agent/config",
|
||||||
|
"/api/agent/known-miners",
|
||||||
"/api/agent/miner-snapshots",
|
"/api/agent/miner-snapshots",
|
||||||
"/api/agent/pool-snapshots",
|
"/api/agent/pool-snapshots",
|
||||||
"/api/agent/commands/pending",
|
"/api/agent/commands/pending",
|
||||||
@@ -671,9 +716,92 @@ def machine_agent_request(path: str) -> bool:
|
|||||||
return path in exact
|
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:
|
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 path == "/api/agent-zones":
|
||||||
|
return "agents.view" if method == "GET" else "agents.manage"
|
||||||
|
if path == "/api/agent-enrollments":
|
||||||
|
return "agents.view" if method == "GET" else "agents.manage"
|
||||||
|
if repair_parts_request(path):
|
||||||
|
return "repairs.read" if method == "GET" else "repairs.parts.manage"
|
||||||
if path.startswith("/api/admin/"):
|
if path.startswith("/api/admin/"):
|
||||||
return "users.manage"
|
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 method == "GET":
|
||||||
if path.startswith("/api/system/"):
|
if path.startswith("/api/system/"):
|
||||||
return "system_health.view"
|
return "system_health.view"
|
||||||
@@ -721,16 +849,19 @@ def install_identity_middleware(app) -> None:
|
|||||||
if path == "/login":
|
if path == "/login":
|
||||||
user = request_user_or_none(request)
|
user = request_user_or_none(request)
|
||||||
if user:
|
if user:
|
||||||
next_path = request.query_params.get("next") or "/dashboard"
|
return RedirectResponse(
|
||||||
if not next_path.startswith("/") or next_path.startswith("//"):
|
safe_next_path(request.query_params.get("next")),
|
||||||
next_path = "/dashboard"
|
status_code=303,
|
||||||
return RedirectResponse(next_path, status_code=303)
|
)
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
if protected_page(path):
|
if protected_page(path):
|
||||||
user = request_user_or_none(request)
|
user = request_user_or_none(request)
|
||||||
if not user:
|
if not user:
|
||||||
destination = "/login?next=" + quote(path, safe="/")
|
requested_path = path
|
||||||
|
if request.url.query:
|
||||||
|
requested_path += "?" + request.url.query
|
||||||
|
destination = "/login?next=" + quote(requested_path, safe="")
|
||||||
response = RedirectResponse(destination, status_code=303)
|
response = RedirectResponse(destination, status_code=303)
|
||||||
clear_session_cookie(response, request)
|
clear_session_cookie(response, request)
|
||||||
return response
|
return response
|
||||||
@@ -793,7 +924,8 @@ button:disabled { opacity: .6; cursor: wait; }
|
|||||||
<body>
|
<body>
|
||||||
<header><div class="brand">MinerOps</div></header>
|
<header><div class="brand">MinerOps</div></header>
|
||||||
<main>
|
<main>
|
||||||
<section class="login-panel" id="panel">
|
<section class="login-panel" id="panel"
|
||||||
|
data-next="__MINEROPS_SAFE_NEXT_PATH__">
|
||||||
<div class="panel-head"><h1 id="title">Sign in</h1><p class="subtitle" id="subtitle">Use your MinerOps account to continue.</p></div>
|
<div class="panel-head"><h1 id="title">Sign in</h1><p class="subtitle" id="subtitle">Use your MinerOps account to continue.</p></div>
|
||||||
<form id="authForm">
|
<form id="authForm">
|
||||||
<div class="field setup-only"><label for="displayName">Display name</label><input id="displayName" autocomplete="name" maxlength="120"></div>
|
<div class="field setup-only"><label for="displayName">Display name</label><input id="displayName" autocomplete="name" maxlength="120"></div>
|
||||||
@@ -815,8 +947,7 @@ button:disabled { opacity: .6; cursor: wait; }
|
|||||||
let setupRequired = false;
|
let setupRequired = false;
|
||||||
|
|
||||||
function destination() {
|
function destination() {
|
||||||
const value = new URLSearchParams(location.search).get("next") || "/dashboard";
|
return panel.dataset.next || "/dashboard";
|
||||||
return value.startsWith("/") && !value.startsWith("//") ? value : "/dashboard";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionHint() {
|
function sessionHint() {
|
||||||
@@ -1003,8 +1134,12 @@ dialog::backdrop { background: rgba(18, 27, 35, .48); }
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/login", response_class=HTMLResponse)
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
def login_page():
|
def login_page(request: Request):
|
||||||
return LOGIN_HTML
|
next_path = escape(
|
||||||
|
safe_next_path(request.query_params.get("next")),
|
||||||
|
quote=True,
|
||||||
|
)
|
||||||
|
return LOGIN_HTML.replace("__MINEROPS_SAFE_NEXT_PATH__", next_path)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/accounts", response_class=HTMLResponse)
|
@router.get("/accounts", response_class=HTMLResponse)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from uuid import UUID
|
|||||||
from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress
|
from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress
|
||||||
|
|
||||||
|
|
||||||
LifecycleStatus = Literal["active", "spare", "retired"]
|
LifecycleStatus = Literal["active", "spare", "retired", "in_repair"]
|
||||||
|
|
||||||
|
|
||||||
class InventoryTag(BaseModel):
|
class InventoryTag(BaseModel):
|
||||||
@@ -17,16 +17,36 @@ class InventoryTag(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryActiveRepair(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
status: str
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
class InventoryMinerSummary(BaseModel):
|
class InventoryMinerSummary(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
asset_id: str | None = None
|
asset_id: str | None = None
|
||||||
current_mac: str | None = None
|
current_mac: str | None = None
|
||||||
current_ip: str | None = None
|
current_ip: str | None = None
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
|
miner_sn: str | None = None
|
||||||
lifecycle_status: LifecycleStatus
|
lifecycle_status: LifecycleStatus
|
||||||
runtime_status: str
|
runtime_status: str
|
||||||
site_id: UUID | None = None
|
site_id: UUID | None = None
|
||||||
|
site_name: str | None = None
|
||||||
room_id: UUID | 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)
|
tags: list[InventoryTag] = Field(default_factory=list)
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -35,7 +55,6 @@ class InventoryMinerSummary(BaseModel):
|
|||||||
|
|
||||||
class InventoryMinerDetail(InventoryMinerSummary):
|
class InventoryMinerDetail(InventoryMinerSummary):
|
||||||
hostname: str | None = None
|
hostname: str | None = None
|
||||||
miner_sn: str | None = None
|
|
||||||
control_board_sn: str | None = None
|
control_board_sn: str | None = None
|
||||||
psu_serial: str | None = None
|
psu_serial: str | None = None
|
||||||
firmware_version: str | None = None
|
firmware_version: str | None = None
|
||||||
@@ -99,15 +118,41 @@ class InventorySearchFilters(BaseModel):
|
|||||||
query: str | None = None
|
query: str | None = None
|
||||||
asset_id: str | None = None
|
asset_id: str | None = None
|
||||||
lifecycle_status: 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)
|
tags: list[str] = Field(default_factory=list)
|
||||||
limit: int = Field(default=100, ge=1, le=500)
|
limit: int = Field(default=100, ge=1, le=500)
|
||||||
offset: int = Field(default=0, ge=0)
|
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):
|
class InventoryListMeta(BaseModel):
|
||||||
limit: int
|
limit: int
|
||||||
offset: int
|
offset: int
|
||||||
returned: int
|
returned: int
|
||||||
|
location_options: InventoryLocationOptions = Field(default_factory=InventoryLocationOptions)
|
||||||
|
|
||||||
|
|
||||||
class InventoryMinerListResponse(BaseModel):
|
class InventoryMinerListResponse(BaseModel):
|
||||||
@@ -126,3 +171,21 @@ class InventoryAssetIDHistoryResponse(BaseModel):
|
|||||||
data: list[InventoryAssetIDHistoryEntry]
|
data: list[InventoryAssetIDHistoryEntry]
|
||||||
meta: dict[str, object] = Field(default_factory=dict)
|
meta: dict[str, object] = Field(default_factory=dict)
|
||||||
error: None = None
|
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
|
||||||
@@ -59,6 +59,161 @@ MINER_SELECT = f"""
|
|||||||
) tag_data ON true
|
) 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 = {
|
MINER_WRITE_COLUMNS = {
|
||||||
"site_id",
|
"site_id",
|
||||||
"room_id",
|
"room_id",
|
||||||
@@ -165,6 +320,17 @@ class InventoryRepository:
|
|||||||
with connection.cursor(row_factory=dict_row) as cur:
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
return self._fetch_miner(cur, "miners.id = %s", (miner_id,))
|
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:
|
def get_miner_by_id_for_update(self, miner_id: UUID) -> dict | None:
|
||||||
with self._connection_scope() as connection:
|
with self._connection_scope() as connection:
|
||||||
with connection.cursor(row_factory=dict_row) as cur:
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
@@ -184,8 +350,82 @@ class InventoryRepository:
|
|||||||
(asset_id,),
|
(asset_id,),
|
||||||
)
|
)
|
||||||
|
|
||||||
def list_miners(self, *, limit: int = 100, offset: int = 0) -> list[dict]:
|
def list_miners(
|
||||||
return self.search_miners(limit=limit, offset=offset)
|
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(
|
def search_miners(
|
||||||
self,
|
self,
|
||||||
@@ -193,12 +433,20 @@ class InventoryRepository:
|
|||||||
query: str | None = None,
|
query: str | None = None,
|
||||||
asset_id: str | None = None,
|
asset_id: str | None = None,
|
||||||
lifecycle_status: 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] = (),
|
tag_names: Sequence[str] = (),
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
|
include_location_details: bool = False,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
conditions: list[str] = []
|
conditions: list[str] = []
|
||||||
params: list[Any] = []
|
params: list[Any] = [list(ACTIVE_REPAIR_STATUSES)]
|
||||||
|
|
||||||
if query:
|
if query:
|
||||||
pattern = f"%{query}%"
|
pattern = f"%{query}%"
|
||||||
@@ -217,15 +465,36 @@ class InventoryRepository:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
params.extend([pattern] * 8)
|
params.extend([pattern] * 8)
|
||||||
|
|
||||||
if asset_id:
|
if asset_id:
|
||||||
conditions.append("lower(trim(miners.asset_tag)) = lower(trim(%s))")
|
conditions.append("lower(trim(miners.asset_tag)) = lower(trim(%s))")
|
||||||
params.append(asset_id)
|
params.append(asset_id)
|
||||||
|
|
||||||
if lifecycle_status:
|
if lifecycle_status:
|
||||||
conditions.append("miners.lifecycle_status = %s")
|
conditions.append("miners.lifecycle_status = %s")
|
||||||
params.append(lifecycle_status)
|
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:
|
for tag_name in tag_names:
|
||||||
conditions.append(
|
conditions.append(
|
||||||
f"""
|
f"""
|
||||||
@@ -241,6 +510,14 @@ class InventoryRepository:
|
|||||||
)
|
)
|
||||||
params.append(tag_name)
|
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"
|
where_sql = " AND ".join(conditions) if conditions else "true"
|
||||||
params.extend([limit, offset])
|
params.extend([limit, offset])
|
||||||
|
|
||||||
@@ -248,18 +525,44 @@ class InventoryRepository:
|
|||||||
with connection.cursor(row_factory=dict_row) as cur:
|
with connection.cursor(row_factory=dict_row) as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
{MINER_SELECT}
|
{select_sql}
|
||||||
WHERE {where_sql}
|
WHERE {where_sql}
|
||||||
ORDER BY
|
ORDER BY lower(trim(miners.asset_tag)) NULLS LAST,
|
||||||
lower(trim(miners.asset_tag)) NULLS LAST,
|
miners.created_at, miners.id
|
||||||
miners.created_at,
|
|
||||||
miners.id
|
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
return [dict(row) for row in cur.fetchall()]
|
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:
|
def create_miner(self, values: Mapping[str, Any]) -> dict:
|
||||||
clean_values = {
|
clean_values = {
|
||||||
key: value for key, value in values.items() if key in MINER_WRITE_COLUMNS
|
key: value for key, value in values.items() if key in MINER_WRITE_COLUMNS
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
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.dependencies import get_inventory_service
|
||||||
from app.inventory.exceptions import (
|
from app.inventory.exceptions import (
|
||||||
DuplicateAssetID,
|
DuplicateAssetID,
|
||||||
@@ -16,6 +16,7 @@ from app.inventory.models import (
|
|||||||
InventoryAssetIDHistoryResponse,
|
InventoryAssetIDHistoryResponse,
|
||||||
InventoryCreateRequest,
|
InventoryCreateRequest,
|
||||||
InventoryListMeta,
|
InventoryListMeta,
|
||||||
|
InventoryLocationOptions,
|
||||||
InventoryMetadataUpdateRequest,
|
InventoryMetadataUpdateRequest,
|
||||||
InventoryMinerDetailResponse,
|
InventoryMinerDetailResponse,
|
||||||
InventoryMinerListResponse,
|
InventoryMinerListResponse,
|
||||||
@@ -102,21 +103,35 @@ def list_inventory_miners(
|
|||||||
service: InventoryServiceDependency,
|
service: InventoryServiceDependency,
|
||||||
current_user: InventoryReadUser,
|
current_user: InventoryReadUser,
|
||||||
) -> InventoryMinerListResponse:
|
) -> 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:
|
try:
|
||||||
miners = service.search_miners(filters)
|
miners = service.search_miners(
|
||||||
|
filters,
|
||||||
|
include_location_details=include_location_details,
|
||||||
|
)
|
||||||
except InventoryValidationError as exc:
|
except InventoryValidationError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
location_options = InventoryLocationOptions()
|
||||||
|
if include_location_details:
|
||||||
|
location_options = InventoryLocationOptions.model_validate(
|
||||||
|
service.list_location_filter_options()
|
||||||
|
)
|
||||||
return InventoryMinerListResponse(
|
return InventoryMinerListResponse(
|
||||||
data=miners,
|
data=miners,
|
||||||
meta=InventoryListMeta(
|
meta=InventoryListMeta(
|
||||||
limit=filters.limit,
|
limit=filters.limit,
|
||||||
offset=filters.offset,
|
offset=filters.offset,
|
||||||
returned=len(miners),
|
returned=len(miners),
|
||||||
|
location_options=location_options,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from app.inventory.exceptions import (
|
|||||||
from app.inventory.models import (
|
from app.inventory.models import (
|
||||||
InventoryAssetIDHistoryEntry,
|
InventoryAssetIDHistoryEntry,
|
||||||
InventoryCreateRequest,
|
InventoryCreateRequest,
|
||||||
|
InventoryFleetAttentionSummary,
|
||||||
|
InventoryFleetSummary,
|
||||||
InventoryMetadataUpdateRequest,
|
InventoryMetadataUpdateRequest,
|
||||||
InventoryMinerDetail,
|
InventoryMinerDetail,
|
||||||
InventoryMinerSummary,
|
InventoryMinerSummary,
|
||||||
@@ -24,7 +26,9 @@ from app.inventory.models import (
|
|||||||
from app.inventory.repository import InventoryRepository
|
from app.inventory.repository import InventoryRepository
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_LIFECYCLE_STATUSES = frozenset({"active", "spare", "retired"})
|
ALLOWED_LIFECYCLE_STATUSES = frozenset(
|
||||||
|
{"active", "spare", "retired", "in_repair"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InventoryService:
|
class InventoryService:
|
||||||
@@ -77,16 +81,37 @@ class InventoryService:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _summary_model(cls, row: dict) -> InventoryMinerSummary:
|
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(
|
return InventoryMinerSummary(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
asset_id=row.get("asset_tag"),
|
asset_id=row.get("asset_tag"),
|
||||||
current_mac=row.get("current_mac"),
|
current_mac=row.get("current_mac"),
|
||||||
current_ip=row.get("current_ip"),
|
current_ip=row.get("current_ip"),
|
||||||
model=row.get("model"),
|
model=row.get("model"),
|
||||||
|
miner_sn=row.get("miner_sn"),
|
||||||
lifecycle_status=row["lifecycle_status"],
|
lifecycle_status=row["lifecycle_status"],
|
||||||
runtime_status=row["status"],
|
runtime_status=row["status"],
|
||||||
site_id=row.get("site_id"),
|
site_id=row.get("site_id"),
|
||||||
|
site_name=row.get("site_name"),
|
||||||
room_id=row.get("room_id"),
|
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 []],
|
tags=[cls._tag_model(tag) for tag in row.get("tags") or []],
|
||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
updated_at=row["updated_at"],
|
updated_at=row["updated_at"],
|
||||||
@@ -99,7 +124,6 @@ class InventoryService:
|
|||||||
return InventoryMinerDetail(
|
return InventoryMinerDetail(
|
||||||
**summary.model_dump(),
|
**summary.model_dump(),
|
||||||
hostname=row.get("hostname"),
|
hostname=row.get("hostname"),
|
||||||
miner_sn=row.get("miner_sn"),
|
|
||||||
control_board_sn=row.get("control_board_sn"),
|
control_board_sn=row.get("control_board_sn"),
|
||||||
psu_serial=row.get("power_sn"),
|
psu_serial=row.get("power_sn"),
|
||||||
firmware_version=row.get("firmware_version"),
|
firmware_version=row.get("firmware_version"),
|
||||||
@@ -113,6 +137,13 @@ class InventoryService:
|
|||||||
raise MinerNotFound(miner_id)
|
raise MinerNotFound(miner_id)
|
||||||
return self._detail_model(row)
|
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(
|
def get_asset_id_history(
|
||||||
self,
|
self,
|
||||||
miner_id: UUID,
|
miner_id: UUID,
|
||||||
@@ -135,15 +166,39 @@ class InventoryService:
|
|||||||
for row in self.repository.list_miners(limit=limit, offset=offset)
|
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(
|
def search_miners(
|
||||||
self,
|
self,
|
||||||
filters: InventorySearchFilters,
|
filters: InventorySearchFilters,
|
||||||
|
*,
|
||||||
|
include_location_details: bool = False,
|
||||||
) -> list[InventoryMinerSummary]:
|
) -> list[InventoryMinerSummary]:
|
||||||
lifecycle_status = None
|
lifecycle_status = None
|
||||||
if filters.lifecycle_status:
|
if filters.lifecycle_status:
|
||||||
lifecycle_status = self.validate_lifecycle_status(
|
lifecycle_status = self.validate_lifecycle_status(
|
||||||
filters.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
|
asset_id = None
|
||||||
if filters.asset_id:
|
if filters.asset_id:
|
||||||
asset_id = self.normalize_asset_id(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),
|
query=self._clean_optional_text(filters.query),
|
||||||
asset_id=asset_id,
|
asset_id=asset_id,
|
||||||
lifecycle_status=lifecycle_status,
|
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,
|
tag_names=tag_names,
|
||||||
limit=filters.limit,
|
limit=filters.limit,
|
||||||
offset=filters.offset,
|
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(
|
def create_miner(
|
||||||
self,
|
self,
|
||||||
request: InventoryCreateRequest,
|
request: InventoryCreateRequest,
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
+533
-1548
File diff suppressed because it is too large.
Load diff
@@ -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())
|
||||||
@@ -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."""
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""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, miners.agent_zone_id,
|
||||||
|
zones.id IS NOT NULL AS agent_zone_exists,
|
||||||
|
zones.is_enabled AS agent_zone_enabled,
|
||||||
|
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 agent_zones AS zones
|
||||||
|
ON zones.id = miners.agent_zone_id
|
||||||
|
LEFT JOIN minerops_site_agents AS agents
|
||||||
|
ON agents.agent_zone_id = miners.agent_zone_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()
|
||||||
@@ -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)
|
||||||
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user