26 Commits
Author SHA1 Message Date
lussierj 390e73d950 merge: promote Internal Alpha 2026-08-19 20:16:17 +00:00
lussierj 0dd26a3f24 merge: incorporate current main 2026-08-19 20:15:40 +00:00
lussierj 4d360fac42 feat: add monitoring trends 2026-08-19 20:00:52 +00:00
lussierj 19fc29c72c feat: add miner detail view 2026-08-17 18:43:20 +00:00
lussierj b8b741ccee feat: add operational Dashboard 2026-08-17 17:11:40 +00:00
lussierj 4a1959ecd6 feat: add Repair Center operations view 2026-08-11 20:17:16 +00:00
lussierj 786a1ab910 feat: add Fleet operations view 2026-08-11 17:37:36 +00:00
lussierj c6d01df31c fix: normalize Mine Map shelf labels 2026-08-11 15:31:01 +00:00
lussierj 06ed8248f5 feat: polish Mine Map UX 2026-08-11 14:27:09 +00:00
lussierj d06ee7bcc6 dev: add deterministic demo dataset 2026-08-10 21:33:50 +00:00
lussierj 60a831950c feat: add Mine Map v1 2026-08-10 19:55:20 +00:00
lussierj 548051899e docs: add Mine Map v1 design 2026-08-10 17:41:17 +00:00
lussierj b741fd799b feat: add internal alpha frontend shell 2026-08-10 17:26:07 +00:00
lussierj eeb3c7634e docs: add navigation design 2026-08-02 22:28:52 +00:00
lussierj 4656714837 docs: add UX and UI guide 2026-08-02 22:15:51 +00:00
lussierj 39832fcf80 docs: add project vision 2026-08-02 22:05:02 +00:00
lussierj eab9646960 feat: add repair parts tracking 2026-08-02 21:26:15 +00:00
lussierj 6e550c1c13 docs: finalize repair parts tracking task 2026-08-02 19:15:55 +00:00
lussierj 73f98685ce docs: add component domain model 2026-08-02 15:13:20 +00:00
lussierj bcf1b4e3cf feat: add control board replacement workflow 2026-08-02 14:34:16 +00:00
lussierj 62c91a2c3e docs: add repair domain design documents 2026-08-02 03:44:15 +00:00
lussierj f2b736b08e feat: add return to service workflow 2026-07-29 19:02:23 +00:00
lussierj 72ad19a071 docs: add development and release standards 2026-07-29 17:11:12 +00:00
lussierj 36a0caecd8 feat: add repair timeline read APIs 2026-07-29 16:28:44 +00:00
lussierj f6a2512e23 feat: add begin repair workflow 2026-07-29 14:50:46 +00:00
lussierj 577226cb4e Update README.md 2026-07-28 11:21:26 -05:00
121 changed files with 28822 additions and 564 deletions

No files matched your search

+269 -145
View File
@@ -1,198 +1,322 @@
<p align="center">
<img src="branding/wordmark.svg" alt="MinerOps" width="320">
<img src="branding/logo.png" alt="MinerOps Logo" width="180">
</p>
<h1 align="center">MinerOps</h1>
<p align="center">
<strong>The self-hosted operating system for cryptocurrency mining operations.</strong>
<br>
Know every miner. Track every repair. Control every rack.
</p>
<p align="center">
Know every miner • Track every repair • Control every rack
</p>
<p align="center">
<img src="https://img.shields.io/badge/status-active%20development-orange" />
<img src="https://img.shields.io/badge/python-3.13-blue" />
<img src="https://img.shields.io/badge/license-TBD-lightgrey" />
</p>
---
## Overview
MinerOps is a self-hosted platform intended to provide one authoritative source
of truth for cryptocurrency mining operations. It is being built to connect
asset records, network identity, repair history, physical location, telemetry,
and operational controls without depending on a hosted management service.
MinerOps is an open-source operations platform built for cryptocurrency mining facilities.
The repository contains a working early-stage application and the first
foundations of a more modular architecture. Current development is focused on
turning those foundations into well-defined product domains with tested service
and persistence boundaries.
Rather than relying on spreadsheets, disconnected monitoring tools, and handwritten repair logs, MinerOps brings every operational workflow together into a single self-hosted application.
The longer-term product direction combines:
MinerOps is designed to manage the complete lifecycle of a mining fleet, including inventory, physical locations, repairs, monitoring, maintenance, and secure remote management.
- Miner inventory
- Network discovery and identity tracking
- Repairs and maintenance
- Locations and electrical infrastructure
- Monitoring and alerts
- Fleet control
- Scheduling and automation
- Historical records and reporting
Originally developed for WhatsMiner fleets, the platform is being built around a modular architecture that will support additional miner platforms through protocol adapters in future releases.
These areas describe the product direction, not a claim that every workflow is
complete or ready for production use.
---
## Why MinerOps?
# Vision
Mining operations often accumulate several partial versions of the truth:
spreadsheets for assets, separate monitoring tools for performance, informal
repair notes, and location knowledge held by individual technicians.
Running a mining operation requires far more than knowing whether a miner is online.
That fragmentation becomes harder to manage when IP addresses change, control
boards or network interfaces are replaced, equipment moves between racks, and
repair history is incomplete. MinerOps is intended to give operators one
self-hosted operational record that follows each miner through its full
lifecycle.
MinerOps is designed to become the operational hub for an entire mining facility by combining:
## Core Capabilities
- Asset Inventory
- Physical Location Management
- Repair & Maintenance Tracking
- Live Fleet Monitoring
- Historical Reporting
- Secure Remote Management
- Operational Analytics
- Fleet Automation
### Asset and Inventory Management
into one cohesive platform.
Maintain stable asset records independently of a miner's current IP address.
The inventory schema and backend domain foundation currently support Asset IDs,
hardware identifiers, lifecycle state, tags, notes, location references, and
seen timestamps. Inventory HTTP APIs and product workflows are the next
application milestone.
The long-term goal is to replace multiple disconnected systems with a single source of truth for mining operations.
### Miner Identity and Discovery
---
Associate changing network information with durable miner records. Existing
code includes discovery and network identity foundations; formal identity
history and transfer workflows remain planned work.
# Current Status
### Repairs and Maintenance
⚠️ **MinerOps is under active development.**
Build a complete service history around each miner, including repair status,
notes, parts, and return-to-service workflows. Operational repair functionality
exists today, while extraction into a dedicated domain architecture is still
planned.
The current focus is building a scalable, maintainable architecture before expanding user-facing functionality.
### Locations and Infrastructure
Track where equipment is installed and relate miners to sites, rooms, racks,
shelves, and operational infrastructure. Existing location workflows will be
refined as the modular architecture develops.
### Monitoring and Health
Collect miner status and telemetry, identify stale or unhealthy equipment, and
support operational health views. The repository contains current telemetry and
retention foundations; broader alerting remains planned.
### Fleet Operations
Provide controlled fleet actions while keeping command policy, authorization,
and site-local execution explicit. Existing command workflows are under active
development and should not be treated as a finished control platform.
### Automation and Scheduling
Coordinate recurring scans, scheduled actions, retention, and other repeatable
operations. Scheduling and automation will expand after the core inventory and
identity domains are established.
### Audit History and Reporting
Preserve important operational changes and support practical reporting across
assets, repairs, locations, and fleet activity. Audit-log and reporting
foundations exist; the complete reporting model remains planned.
## Current Status
MinerOps is under active development.
**Current development stage:** `v0.2 - Foundation`
Verified foundations in the repository include:
## Completed
- Docker-based development environment
- PostgreSQL data store
- Redis service
- Alembic schema migrations
- Session-based authentication and login gating
- Admin, Manager, Technician, and Read Only role foundations
- Manual miner inventory database schema
- Inventory Pydantic models
- Inventory repository and service domain foundation
- Automated repository and service tests for the Inventory domain
- Disposable development baseline tests
- PostgreSQL + Redis backend
- Alembic migration framework
- Authentication
- Role-based access control
- Manual inventory schema
- Inventory domain architecture
- Automated migration testing
- Disposable development environment
**Next application milestone:** `v0.3 - Inventory`
## Currently In Progress
**Next technical feature:** Inventory API
- Inventory API
- Inventory user interface
- Domain-driven architecture migration
## Architecture
## Planned
MinerOps is moving toward a modular backend with deliberate separation between
transport, business rules, and persistence:
- Asset identity & MAC history
- Physical location management
- Repair & work order management
- Parts inventory
- Fleet monitoring
- Site agents
- Multi-version WhatsMiner API support
- Alerting & notifications
- Bulk operations
- Scheduled automation
- Reporting & analytics
- Production deployment tools
```text
Frontend
|
v
FastAPI routers
|
v
Domain services
|
v
Repositories
|
v
---
# Core Features
## 📦 Asset Inventory
Maintain a permanent identity for every miner independent of its network configuration.
Planned capabilities include:
- Asset IDs
- MAC history
- Control board tracking
- PSU serial tracking
- Firmware history
- Lifecycle management
- Tags
- Notes
- Asset movement history
---
## 📍 Physical Location Tracking
Track exactly where every miner is installed.
Supports:
- Sites
- Rooms
- Rows
- Racks
- Shelves
- Positions
with complete movement history.
---
## 🔧 Repair Management
Track every repair from diagnosis through return to production.
Features include:
- Work orders
- Technician notes
- Parts used
- Repair history
- Testing
- Attachments
- Maintenance mode
---
## 📊 Fleet Monitoring
Monitor operational health across the fleet.
Planned monitoring includes:
- Hashrate
- Temperature
- Fan status
- Hashboard health
- PSU status
- Pool connectivity
- Worker information
- Firmware
- Alerts
---
## 🌐 Network Discovery
Automatically discover miners while maintaining stable asset identities.
Planned capabilities include:
- Configurable scan ranges
- Duplicate detection
- MAC history
- IP history
- Capability detection
- Discovery review workflow
---
## 🎛 Secure Remote Control
Safely execute commands against supported miners.
Examples include:
- Reboot
- Shutdown
- Performance mode
- Pool configuration
- Firmware operations
All commands will be permission-aware, audited, and designed for safe execution.
---
## 🛰 Site Agents
Future releases will include lightweight Site Agents responsible for:
- Network discovery
- Miner polling
- Local command execution
- Secure communication with the MinerOps server
This architecture allows mining networks to remain isolated while still enabling centralized fleet management.
---
# Architecture
MinerOps follows a layered architecture that separates business logic from persistence.
```
FastAPI Routers
│
▼
Service Layer
│
▼
Repository Layer
│
▼
PostgreSQL
```
FastAPI routers will own HTTP request and response concerns. Domain services
will own operational rules and orchestration. Repositories will own database
access. The Inventory domain is the first backend area established with these
boundaries; other working application areas are still being migrated
incrementally.
Business rules belong in Services.
## Technology Stack
Database access belongs in Repositories.
Current technologies:
Schema changes are managed exclusively through Alembic migrations.
This architecture keeps MinerOps modular, testable, and easy to extend.
---
# Technology Stack
Current
- Python
- FastAPI
- Pydantic
- PostgreSQL
- Alembic
- Redis
- Docker and Docker Compose
- Nginx development gateway
- Alembic
- Docker Compose
- Pydantic
A typed, responsive frontend with a shared design system is planned. Its
technology selection has not yet been finalized in this repository.
Planned
## Roadmap
- React
- TypeScript
- Site Agents
- Protocol adapters
- Background task processing
- `v0.2 - Foundation`
- `v0.3 - Inventory`
- `v0.4 - Identity`
- `v0.5 - Operations`
- `v0.6 - Monitoring`
- `v0.7 - Automation`
- `v1.0 - Production Ready`
---
Roadmap scope and release boundaries may evolve as development continues and
operational requirements become clearer.
# Development Philosophy
## Documentation
MinerOps is built around a few guiding principles.
Detailed product, architecture, development, database, testing, and roadmap
documentation will live under `docs/`. That documentation set is still being
developed, so this README does not link to files that do not yet exist.
- Self-hosted first
- Reliability over unnecessary complexity
- Small, reviewable commits
- Test before commit
- Database changes managed through Alembic
- Business logic separated from persistence
- Secure by default
- Role-based authorization
- Comprehensive audit logging
- Incremental development over large rewrites
## Contributing
---
MinerOps is in early development. Contribution guidelines will be added as the
architecture, development workflow, and review expectations stabilize.
# Roadmap
## License
| Version | Focus |
|----------|---------------------------|
| ✅ v0.2 | Foundation |
| 🚧 v0.3 | Inventory |
| ⏳ v0.4 | Asset Identity |
| ⏳ v0.5 | Operations |
| ⏳ v0.6 | Monitoring |
| ⏳ v0.7 | Automation |
| 🎯 v1.0 | Production Release |
The MinerOps license has not yet been finalized. No license file is currently
included in this repository.
Detailed planning will be maintained in **docs/ROADMAP.md**.
---
# Documentation
Project documentation is maintained in the `docs/` directory.
Documentation includes:
- Architecture
- Roadmap
- Architecture Decisions
- Development Guide
- Coding Standards
- Database Design
- API Documentation
- Deployment Guide
- Testing Guide
---
# Contributing
MinerOps is currently under active development.
Contributions, ideas, bug reports, and feature requests are welcome as the project continues to evolve.
---
# License
License to be determined.
@@ -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');
"""
)
+1
View File
@@ -0,0 +1 @@
"""Minimal durable component registry for repair-part references."""
+8
View File
@@ -0,0 +1,8 @@
"""Dependency construction for the Components domain."""
from app.components.repository import ComponentRepository
from app.components.service import ComponentService
def get_component_service() -> ComponentService:
return ComponentService(ComponentRepository())
+32
View File
@@ -0,0 +1,32 @@
"""Domain exceptions for the minimal component registry."""
class ComponentError(Exception):
"""Base exception for component operations."""
class ComponentNotFound(ComponentError):
"""Raised when a component UUID does not exist."""
def __init__(self, component_id: object):
self.component_id = component_id
super().__init__(f"Component not found: {component_id}")
class DuplicateComponentAssetID(ComponentError):
"""Raised when a normalized component Asset ID already exists."""
def __init__(self, asset_id: str):
self.asset_id = asset_id
super().__init__(f"Component Asset ID already exists: {asset_id}")
class ComponentValidationError(ComponentError):
"""Raised when component metadata violates a domain rule."""
class ComponentActorRequired(ComponentError):
"""Raised when component creation lacks an authenticated actor."""
def __init__(self):
super().__init__("An authenticated actor is required to create a component")
+36
View File
@@ -0,0 +1,36 @@
"""Typed contracts for the minimal component registry."""
from typing import Literal
from uuid import UUID
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
ComponentType = Literal["control_board", "hashboard", "power_supply", "fan"]
class ComponentCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
component_type: ComponentType
asset_id: str | None = Field(default=None, max_length=255)
manufacturer: str | None = Field(default=None, max_length=255)
model: str | None = Field(default=None, max_length=255)
manufacturer_serial: str | None = Field(default=None, max_length=255)
class ComponentDetail(BaseModel):
component_id: UUID
component_type: ComponentType
asset_id: str | None = None
manufacturer: str | None = None
model: str | None = None
manufacturer_serial: str | None = None
created_at: AwareDatetime
created_by: UUID
class ComponentResponse(BaseModel):
data: ComponentDetail
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
+143
View File
@@ -0,0 +1,143 @@
"""PostgreSQL persistence for the minimal component registry."""
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from uuid import UUID
import psycopg
from psycopg.errors import UniqueViolation
from psycopg.rows import dict_row
from app.components.exceptions import DuplicateComponentAssetID
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
COMPONENT_ASSET_ID_CONSTRAINT = "uq_components_normalized_asset_id"
def default_connection_factory() -> psycopg.Connection:
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
connect_timeout=5,
)
class ComponentRepository:
"""Persistence boundary for minimal component identity."""
def __init__(
self,
connection_factory: ConnectionFactory | None = None,
*,
connection: psycopg.Connection | None = None,
):
self._connection_factory = connection_factory or default_connection_factory
self._connection = connection
@contextmanager
def transaction(self) -> Iterator["ComponentRepository"]:
if self._connection is not None:
yield self
return
connection = self._connection_factory()
repository = ComponentRepository(
self._connection_factory,
connection=connection,
)
try:
yield repository
connection.commit()
except UniqueViolation as exc:
connection.rollback()
if exc.diag.constraint_name == COMPONENT_ASSET_ID_CONSTRAINT:
raise DuplicateComponentAssetID("normalized Asset ID") from exc
raise
except Exception:
connection.rollback()
raise
finally:
connection.close()
@contextmanager
def _connection_scope(self) -> Iterator[psycopg.Connection]:
if self._connection is not None:
yield self._connection
return
connection = self._connection_factory()
try:
yield connection
finally:
connection.close()
def create_component(
self,
*,
component_type: str,
asset_id: str | None,
manufacturer: str | None,
model: str | None,
manufacturer_serial: str | None,
created_by: UUID,
) -> dict:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO components (
component_type,
asset_id,
manufacturer,
model,
manufacturer_serial,
created_by
)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING
id AS component_id,
component_type,
asset_id,
manufacturer,
model,
manufacturer_serial,
created_at,
created_by
""",
(
component_type,
asset_id,
manufacturer,
model,
manufacturer_serial,
created_by,
),
)
return dict(cur.fetchone())
def get_component(self, component_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
id AS component_id,
component_type,
asset_id,
manufacturer,
model,
manufacturer_serial,
created_at,
created_by
FROM components
WHERE id = %s
""",
(component_id,),
)
row = cur.fetchone()
return dict(row) if row else None
+80
View File
@@ -0,0 +1,80 @@
"""HTTP mapping for the minimal component registry."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from app.components.dependencies import get_component_service
from app.components.exceptions import (
ComponentActorRequired,
ComponentNotFound,
ComponentValidationError,
DuplicateComponentAssetID,
)
from app.components.models import (
ComponentCreateRequest,
ComponentResponse,
)
from app.components.service import ComponentService
from app.identity import require_permission
router = APIRouter(prefix="/api/components", tags=["components"])
require_component_read = require_permission("components.read")
require_component_create = require_permission("components.create")
ComponentServiceDependency = Annotated[
ComponentService,
Depends(get_component_service),
]
ComponentReadUser = Annotated[dict, Depends(require_component_read)]
ComponentCreateUser = Annotated[dict, Depends(require_component_create)]
@router.post(
"",
response_model=ComponentResponse,
status_code=status.HTTP_201_CREATED,
)
def create_component(
request: ComponentCreateRequest,
service: ComponentServiceDependency,
current_user: ComponentCreateUser,
) -> ComponentResponse:
try:
component = service.create_component(
request,
actor_user_id=UUID(str(current_user["id"])),
)
except DuplicateComponentAssetID as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except (ComponentActorRequired, ComponentValidationError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return ComponentResponse(data=component)
@router.get(
"/{component_id}",
response_model=ComponentResponse,
)
def get_component(
component_id: UUID,
service: ComponentServiceDependency,
current_user: ComponentReadUser,
) -> ComponentResponse:
del current_user
try:
component = service.get_component(component_id)
except ComponentNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Component not found",
) from exc
return ComponentResponse(data=component)
+75
View File
@@ -0,0 +1,75 @@
"""Business rules for the minimal component registry."""
from uuid import UUID
from app.components.exceptions import (
ComponentActorRequired,
ComponentNotFound,
ComponentValidationError,
DuplicateComponentAssetID,
)
from app.components.models import ComponentCreateRequest, ComponentDetail
from app.components.repository import ComponentRepository
class ComponentService:
"""Minimal component workflows independent of FastAPI."""
def __init__(self, repository: ComponentRepository):
self.repository = repository
@staticmethod
def _normalize_optional_text(value: str | None, field_name: str) -> str | None:
if value is None:
return None
normalized = str(value).strip()
if not normalized:
raise ComponentValidationError(
f"{field_name} must not be blank when supplied"
)
if len(normalized) > 255:
raise ComponentValidationError(
f"{field_name} must not exceed 255 characters"
)
return normalized
@staticmethod
def _map_component(component: dict) -> ComponentDetail:
return ComponentDetail(**component)
def create_component(
self,
request: ComponentCreateRequest,
*,
actor_user_id: UUID | None,
) -> ComponentDetail:
if actor_user_id is None:
raise ComponentActorRequired()
asset_id = self._normalize_optional_text(request.asset_id, "asset_id")
values = {
"manufacturer": self._normalize_optional_text(
request.manufacturer, "manufacturer"
),
"model": self._normalize_optional_text(request.model, "model"),
"manufacturer_serial": self._normalize_optional_text(
request.manufacturer_serial, "manufacturer_serial"
),
}
try:
with self.repository.transaction() as repository:
component = repository.create_component(
component_type=request.component_type,
asset_id=asset_id,
created_by=actor_user_id,
**values,
)
except DuplicateComponentAssetID as exc:
raise DuplicateComponentAssetID(asset_id or "") from exc
return self._map_component(component)
def get_component(self, component_id: UUID) -> ComponentDetail:
component = self.repository.get_component(component_id)
if component is None:
raise ComponentNotFound(component_id)
return self._map_component(component)
+1
View File
@@ -0,0 +1 @@
"""Permission-aware operational Dashboard read model."""
+14
View File
@@ -0,0 +1,14 @@
"""Dependency construction for the Dashboard composition service."""
from app.dashboard.service import DashboardService
from app.inventory.dependencies import get_inventory_service
from app.locations.dependencies import get_location_map_service
from app.repairs.dependencies import get_repair_center_service
def get_dashboard_service() -> DashboardService:
return DashboardService(
inventory_service=get_inventory_service(),
repair_center_service=get_repair_center_service(),
location_service=get_location_map_service(),
)
+32
View File
@@ -0,0 +1,32 @@
"""Typed contracts for the operational Dashboard overview."""
from datetime import datetime
from pydantic import BaseModel, Field
from app.inventory.models import InventoryFleetSummary
from app.locations.models import LocationSummary
from app.repairs.models import ActiveRepairSummary
class DashboardAccess(BaseModel):
can_view_miners: bool = False
can_view_repairs: bool = False
can_view_locations: bool = False
class DashboardOverviewData(BaseModel):
fleet: InventoryFleetSummary | None = None
repairs: ActiveRepairSummary | None = None
locations: LocationSummary | None = None
generated_at: datetime
class DashboardOverviewMeta(BaseModel):
available_sections: list[str] = Field(default_factory=list)
class DashboardOverviewResponse(BaseModel):
data: DashboardOverviewData
meta: DashboardOverviewMeta = Field(default_factory=DashboardOverviewMeta)
error: None = None
+40
View File
@@ -0,0 +1,40 @@
"""HTTP boundary for the operational Dashboard overview."""
from typing import Annotated
from fastapi import APIRouter, Depends, Response
from app.dashboard.dependencies import get_dashboard_service
from app.dashboard.models import (
DashboardAccess,
DashboardOverviewResponse,
)
from app.dashboard.service import DashboardService
from app.identity import require_permission, user_has_permission
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))]
DashboardServiceDependency = Annotated[
DashboardService,
Depends(get_dashboard_service),
]
@router.get("/overview", response_model=DashboardOverviewResponse)
def get_dashboard_overview(
current_user: DashboardUser,
service: DashboardServiceDependency,
response: Response,
) -> DashboardOverviewResponse:
response.headers["Cache-Control"] = "no-store"
return service.get_overview(
DashboardAccess(
can_view_miners=user_has_permission(current_user, "miners.view"),
can_view_repairs=user_has_permission(current_user, "repairs.view"),
can_view_locations=user_has_permission(
current_user,
"locations.view",
),
)
)
+64
View File
@@ -0,0 +1,64 @@
"""Composition service for permission-aware Dashboard summaries."""
from datetime import datetime, timezone
from app.dashboard.models import (
DashboardAccess,
DashboardOverviewData,
DashboardOverviewMeta,
DashboardOverviewResponse,
)
from app.inventory.service import InventoryService
from app.locations.service import LocationMapService
from app.repairs.center_service import RepairCenterService
class DashboardService:
"""Coordinate domain-owned summaries without introducing new rules."""
def __init__(
self,
inventory_service: InventoryService,
repair_center_service: RepairCenterService,
location_service: LocationMapService,
):
self.inventory_service = inventory_service
self.repair_center_service = repair_center_service
self.location_service = location_service
def get_overview(self, access: DashboardAccess) -> DashboardOverviewResponse:
fleet = (
self.inventory_service.summarize_fleet()
if access.can_view_miners
else None
)
repairs = (
self.repair_center_service.summarize_active_repairs()
if access.can_view_repairs
else None
)
locations = (
self.location_service.summarize_locations(
include_miner_counts=access.can_view_miners
)
if access.can_view_locations
else None
)
available_sections = [
name
for name, value in (
("fleet", fleet),
("repairs", repairs),
("locations", locations),
)
if value is not None
]
return DashboardOverviewResponse(
data=DashboardOverviewData(
fleet=fleet,
repairs=repairs,
locations=locations,
generated_at=datetime.now(timezone.utc),
),
meta=DashboardOverviewMeta(available_sections=available_sections),
)
+1
View File
@@ -0,0 +1 @@
"""Server-rendered frontend foundation for MinerOps."""
+77
View File
@@ -0,0 +1,77 @@
"""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" id="minerLocationSection" aria-labelledby="minerLocationTitle">
<div class="panel-head"><div><p class="eyebrow">Physical context</p>
<h2 id="minerLocationTitle">Location</h2></div></div>
<div id="minerLocationBody"></div>
</section>
<section class="panel" id="minerRepairSection" aria-labelledby="minerRepairTitle">
<div class="panel-head"><div><p class="eyebrow">Work context</p>
<h2 id="minerRepairTitle">Repair context</h2></div></div>
<div id="minerRepairBody"></div>
<div id="minerRepairEpisodes" class="disclosure-list"></div>
</section>
<section class="panel" aria-labelledby="minerHistoryTitle">
<div class="panel-head"><div><p class="eyebrow">Recorded facts</p>
<h2 id="minerHistoryTitle">History</h2></div></div>
<div class="disclosure-list">
<details id="minerIdentityHistory">
<summary>Identity history</summary>
<div class="lazy-content" id="minerIdentityHistoryBody" aria-live="polite">Open to load Asset ID and network identity history.</div>
</details>
<details id="minerObservations">
<summary>Recent observations</summary>
<div class="lazy-content" id="minerObservationsBody" aria-live="polite">Open to load up to 100 recent factual observations.</div>
</details>
</div>
</section>
</div>
"""
@@ -0,0 +1,52 @@
"""Semantic HTML for the Internal Alpha Monitoring Trends page."""
def monitoring_trends_content(can_view_locations: bool) -> str:
location_options = (
'<option value="site">Site</option><option value="room">Room</option>'
if can_view_locations
else ""
)
return f"""
<section class="trend-controls panel" aria-labelledby="trendControlsTitle">
<div class="panel-head"><div><p class="eyebrow">Historical scope</p>
<h2 id="trendControlsTitle">Trend controls</h2></div>
<p class="freshness" id="trendFreshness">Not loaded</p></div>
<form id="trendControls">
<label for="trendScope">Scope
<select id="trendScope">
<option value="fleet">Fleet</option>{location_options}
</select>
</label>
<label for="trendSite" id="trendSiteLabel" hidden>Site
<select id="trendSite"></select>
</label>
<label for="trendRoom" id="trendRoomLabel" hidden>Room
<select id="trendRoom"></select>
</label>
<fieldset class="trend-ranges">
<legend>Time range</legend>
<label><input type="radio" name="trendRange" value="6h">6 hours</label>
<label><input type="radio" name="trendRange" value="24h" checked>24 hours</label>
<label><input type="radio" name="trendRange" value="7d">7 days</label>
</fieldset>
<button class="command-button" type="submit">Apply</button>
</form>
<p class="panel-copy" id="trendScopeNote">Fleet trends use current miner inventory.</p>
</section>
<div class="state-message" id="trendLoading" role="status">
<strong>Loading trends</strong><span>Aggregating bounded raw telemetry.</span>
</div>
<div class="state-message" id="trendError" data-kind="error" role="alert" hidden>
<strong>Trends unavailable</strong><span id="trendErrorText">The request failed.</span>
<button class="command-button" id="trendRetry" type="button">Retry</button>
</div>
<section id="trendContent" aria-label="Historical telemetry trends" hidden>
<div class="trend-summary" aria-live="polite">
<p><strong id="trendScopeLabel">Fleet</strong></p>
<p id="trendRangeSummary"></p>
<p id="trendTimezone"></p>
</div>
<div id="trendCharts" class="trend-charts"></div>
</section>
"""
+141
View File
@@ -0,0 +1,141 @@
"""HTTP routes for the MinerOps Internal Alpha application shell."""
from pathlib import Path
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi.responses import HTMLResponse, Response
from app.frontend.views import render_app_page
from app.identity import require_permission, safe_next_path, user_has_permission
router = APIRouter(tags=["frontend"])
STATIC_ROOT = Path(__file__).with_name("static")
DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))]
MapUser = Annotated[dict, Depends(require_permission("locations.view"))]
RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))]
MinerUser = Annotated[dict, Depends(require_permission("miners.view"))]
def render_page(view: str, current_user: dict, **context) -> str:
return render_app_page(
view,
current_user,
lambda permission: user_has_permission(current_user, permission),
**context,
)
@router.get("/assets/minerops-app.css", include_in_schema=False)
def frontend_styles() -> Response:
return Response(
(STATIC_ROOT / "app.css").read_text(encoding="utf-8"),
media_type="text/css",
headers={"Cache-Control": "no-store"},
)
@router.get("/assets/minerops-app.js", include_in_schema=False)
def frontend_script() -> Response:
return Response(
(STATIC_ROOT / "app.js").read_text(encoding="utf-8"),
media_type="text/javascript",
headers={"Cache-Control": "no-store"},
)
@router.get("/assets/minerops-dashboard.js", include_in_schema=False)
def dashboard_script() -> Response:
return Response(
(STATIC_ROOT / "dashboard.js").read_text(encoding="utf-8"),
media_type="text/javascript",
headers={"Cache-Control": "no-store"},
)
@router.get("/assets/minerops-mine-map.js", include_in_schema=False)
def mine_map_script() -> Response:
return Response(
(STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8"),
media_type="text/javascript",
headers={"Cache-Control": "no-store"},
)
@router.get("/assets/minerops-fleet.js", include_in_schema=False)
def fleet_script() -> Response:
return Response(
(STATIC_ROOT / "fleet.js").read_text(encoding="utf-8"),
media_type="text/javascript",
headers={"Cache-Control": "no-store"},
)
@router.get("/assets/minerops-repair-center.js", include_in_schema=False)
def repair_center_script() -> Response:
return Response(
(STATIC_ROOT / "repair-center.js").read_text(encoding="utf-8"),
media_type="text/javascript",
headers={"Cache-Control": "no-store"},
)
@router.get("/assets/minerops-miner-detail.js", include_in_schema=False)
def miner_detail_script() -> Response:
return Response(
(STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8"),
media_type="text/javascript",
headers={"Cache-Control": "no-store"},
)
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
def dashboard_page(current_user: DashboardUser) -> str:
return render_page("dashboard", current_user)
@router.get(
"/monitoring/map",
response_class=HTMLResponse,
include_in_schema=False,
)
def mine_map_page(current_user: MapUser) -> str:
return render_page("map", current_user)
@router.get(
"/operations/repairs",
response_class=HTMLResponse,
include_in_schema=False,
)
def repair_center_page(current_user: RepairUser) -> str:
return render_page("repairs", current_user)
@router.get(
"/assets/miners",
response_class=HTMLResponse,
include_in_schema=False,
)
def miners_page(current_user: MinerUser) -> str:
return render_page("miners", current_user)
@router.get(
"/miners/{miner_id}/detail",
response_class=HTMLResponse,
include_in_schema=False,
)
def miner_detail_page(
miner_id: UUID,
current_user: MinerUser,
return_to: Annotated[str | None, Query(max_length=2048)] = None,
) -> str:
safe_return = safe_next_path(return_to) if return_to else None
if safe_return != return_to:
safe_return = None
return render_page("miner_detail", current_user, miner_id=str(miner_id),
return_to=safe_return)
File diff suppressed because it is too large. Load diff
+47
View File
@@ -0,0 +1,47 @@
(function () {
"use strict";
function setupShell() {
const menu = document.getElementById("menuButton");
const nav = document.getElementById("primaryNav");
if (menu && nav) {
menu.addEventListener("click", function () {
const open = nav.dataset.open !== "true";
nav.dataset.open = String(open);
menu.setAttribute("aria-expanded", String(open));
});
}
const search = document.getElementById("globalSearchForm");
const searchInput = document.getElementById("globalSearchInput");
if (search && searchInput) {
search.addEventListener("submit", function (event) {
event.preventDefault();
const query = searchInput.value.trim();
const target = query
? "/assets/miners?query=" + encodeURIComponent(query)
: "/assets/miners";
window.location.assign(target);
});
}
const logout = document.getElementById("logoutButton");
if (logout) {
logout.addEventListener("click", async function () {
logout.disabled = true;
try {
await fetch("/api/auth/logout", {
method: "POST",
credentials: "same-origin"
});
} finally {
window.location.replace("/login");
}
});
}
}
setupShell();
})();
+155
View File
@@ -0,0 +1,155 @@
(function () {
"use strict";
if (document.body.dataset.view !== "dashboard") return;
const ids = [
"dashboardLoading", "dashboardError", "dashboardErrorDetail",
"dashboardRetry", "dashboardEmpty", "dashboardContent",
"dashboardLoadStatus", "dashboardFleetTotal", "dashboardRuntimeOnline",
"dashboardRuntimeOffline", "dashboardRuntimeUnknown",
"dashboardLifecycleRepair", "dashboardLifecycleSpare",
"dashboardLifecycleRetired", "dashboardAttentionOffline",
"dashboardAttentionUnknown", "dashboardAttentionUnstable",
"dashboardAttentionRepair", "dashboardActiveRepairs",
"dashboardRepairStatuses", "dashboardSites", "dashboardRooms",
"dashboardPositions", "dashboardOccupied", "dashboardEmptyPositions",
"dashboardMappedMiners", "dashboardUnmappedMiners"
];
const el = {};
for (const id of ids) el[id] = document.getElementById(id);
let controller = null;
const STATUS_LABELS = {
OPEN: "Open",
IN_PROGRESS: "In progress",
WAITING_FOR_PARTS: "Waiting for parts",
READY_FOR_TEST: "Ready for test",
open: "Open",
diagnosing: "Diagnosing",
waiting_parts: "Waiting parts",
in_progress: "In progress",
testing: "Testing",
ready_to_shelf: "Ready to shelf",
needs_followup: "Needs follow-up",
repair: "In repair",
unknown: "Unknown"
};
function setCount(node, value) {
if (node) node.textContent = Number.isInteger(value) ? String(value) : "Unavailable";
}
function show(name) {
el.dashboardLoading.hidden = name !== "loading";
el.dashboardError.hidden = name !== "error";
el.dashboardEmpty.hidden = name !== "empty";
el.dashboardContent.hidden = name !== "ready";
}
function statusLabel(value) {
if (Object.prototype.hasOwnProperty.call(STATUS_LABELS, value)) {
return STATUS_LABELS[value];
}
return String(value || "Unknown").replaceAll("_", " ");
}
function renderFleet(fleet) {
if (!fleet || !el.dashboardFleetTotal) return;
setCount(el.dashboardFleetTotal, fleet.total);
setCount(el.dashboardRuntimeOnline, fleet.runtime_online);
setCount(el.dashboardRuntimeOffline, fleet.runtime_offline);
setCount(el.dashboardRuntimeUnknown, fleet.runtime_unknown);
setCount(el.dashboardLifecycleRepair, fleet.lifecycle_in_repair);
setCount(el.dashboardLifecycleSpare, fleet.lifecycle_spare);
setCount(el.dashboardLifecycleRetired, fleet.lifecycle_retired);
const attention = fleet.attention || {};
setCount(el.dashboardAttentionOffline, attention.offline);
setCount(el.dashboardAttentionUnknown, attention.unknown);
setCount(el.dashboardAttentionUnstable, attention.unstable);
setCount(el.dashboardAttentionRepair, attention.repair);
}
function renderRepairs(repairs) {
if (!repairs || !el.dashboardActiveRepairs) return;
setCount(el.dashboardActiveRepairs, repairs.active);
const entries = Object.entries(repairs.status_counts || {}).sort(function (left, right) {
return statusLabel(left[0]).localeCompare(statusLabel(right[0]));
});
el.dashboardRepairStatuses.replaceChildren();
if (!entries.length) {
const row = document.createElement("div");
const term = document.createElement("dt");
const value = document.createElement("dd");
term.textContent = "No active repair statuses";
value.textContent = "0";
row.append(term, value);
el.dashboardRepairStatuses.appendChild(row);
return;
}
for (const [status, count] of entries) {
const row = document.createElement("div");
const term = document.createElement("dt");
const link = document.createElement("a");
const value = document.createElement("dd");
const params = new URLSearchParams({ scope: "active", status: status });
link.href = "/operations/repairs?" + params.toString();
link.textContent = statusLabel(status);
term.appendChild(link);
value.textContent = Number.isInteger(count) ? String(count) : "Unavailable";
row.append(term, value);
el.dashboardRepairStatuses.appendChild(row);
}
}
function renderLocations(locations) {
if (!locations || !el.dashboardSites) return;
setCount(el.dashboardSites, locations.sites);
setCount(el.dashboardRooms, locations.rooms);
setCount(el.dashboardPositions, locations.positions);
setCount(el.dashboardOccupied, locations.occupied_positions);
setCount(el.dashboardEmptyPositions, locations.empty_positions);
setCount(el.dashboardMappedMiners, locations.mapped_miners);
setCount(el.dashboardUnmappedMiners, locations.unmapped_miners);
}
async function loadOverview() {
if (controller) controller.abort();
controller = new AbortController();
show("loading");
try {
const response = await fetch("/api/dashboard/overview", {
credentials: "same-origin",
headers: { Accept: "application/json" },
signal: controller.signal
});
if (response.status === 401) {
window.location.replace("/login?next=" + encodeURIComponent("/dashboard"));
return;
}
if (!response.ok) throw new Error("The overview service returned " + response.status + ".");
const payload = await response.json();
const data = payload && payload.data ? payload.data : {};
const available = payload && payload.meta ? payload.meta.available_sections || [] : [];
if (!available.length) {
show("empty");
return;
}
renderFleet(data.fleet);
renderRepairs(data.repairs);
renderLocations(data.locations);
const generated = new Date(data.generated_at);
el.dashboardLoadStatus.textContent = Number.isNaN(generated.valueOf())
? "Operational summary loaded"
: "Operational summary generated " + generated.toLocaleString();
show("ready");
} catch (error) {
if (error.name === "AbortError") return;
el.dashboardErrorDetail.textContent = error.message || "The operational overview could not be loaded.";
show("error");
}
}
el.dashboardRetry.addEventListener("click", loadOverview);
loadOverview();
}());
+359
View File
@@ -0,0 +1,359 @@
(function () {
"use strict";
if (document.body.dataset.view !== "miners") return;
const canViewLocations = document.body.dataset.canViewLocations === "true";
const ids = [
"fleetFilters", "fleetQuery", "fleetLifecycle", "fleetModel",
"fleetRuntime", "fleetRepair", "fleetOperational", "fleetTags",
"fleetProblems", "fleetReset", "fleetRows", "fleetResultSummary",
"fleetFreshness", "fleetPrevious", "fleetNext", "fleetLimit"
];
const el = {};
for (const id of ids) el[id] = document.getElementById(id);
if (canViewLocations) {
el.fleetSite = document.getElementById("fleetSite");
el.fleetRoom = document.getElementById("fleetRoom");
}
const limits = new Set([25, 50, 100, 250]);
const state = { offset: 0, limit: 100, returned: 0, locationOptions: null,
siteId: "", roomId: "" };
function display(value, fallback) {
if (value === null || value === undefined || String(value).trim() === "") {
return fallback || "Unavailable";
}
return String(value);
}
function words(value) {
return display(value, "unknown").replaceAll("_", " ").toLowerCase();
}
function create(tag, className, value) {
const node = document.createElement(tag);
if (className) node.className = className;
if (value !== undefined) node.textContent = value;
return node;
}
function showState(name, message) {
for (const node of document.querySelectorAll("[data-view-state]")) {
node.hidden = node.dataset.viewState !== name;
}
const active = document.querySelector('[data-view-state="' + name + '"]');
const detail = active && active.querySelector("[data-state-detail]");
if (detail && message) detail.textContent = message;
}
function finite(value) {
return typeof value === "number" && Number.isFinite(value);
}
function metric(value, suffix) {
return finite(value) ? String(value) + suffix : "Unavailable";
}
function age(value) {
if (!value) return "Never observed";
const time = new Date(value).valueOf();
if (Number.isNaN(time)) return "Observation time unavailable";
const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000));
if (seconds < 60) return seconds + "s ago";
if (seconds < 3600) return Math.floor(seconds / 60) + "m ago";
if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago";
return Math.floor(seconds / 86400) + "d ago";
}
function operational(miner) {
if (miner.active_repair || miner.lifecycle_status === "in_repair") {
return { key: "repair", label: "In repair", problem: true };
}
if (miner.lifecycle_status === "retired") {
return { key: "retired", label: "Retired", problem: false };
}
if (miner.lifecycle_status === "spare") {
return { key: "spare", label: "Spare", problem: false };
}
const runtime = words(miner.runtime_status);
if (runtime === "offline") {
return { key: "offline", label: "Offline", problem: true };
}
if (miner.hash_stable === false) {
return { key: "unstable", label: "Hash unstable", problem: true };
}
if (runtime === "online") {
return { key: "online", label: "Online", problem: false };
}
return { key: "unknown", label: "Operational state unknown", problem: true };
}
function prefixedLabel(value, prefix) {
const label = display(value, "");
if (!label) return "";
return new RegExp("^" + prefix + "\\b", "i").test(label)
? label : prefix + " " + label;
}
function locationText(miner) {
if (!canViewLocations || !miner.location_position_id) return "Not mapped";
const parts = [];
if (miner.site_name) parts.push(miner.site_name);
if (miner.room_name) parts.push(miner.room_name);
if (miner.rack_label) parts.push(prefixedLabel(miner.rack_label, "Rack"));
if (miner.shelf_label) parts.push(prefixedLabel(miner.shelf_label, "Shelf"));
parts.push(display(
miner.position_label,
miner.position_index === null ? "Position unavailable" : "Position " + miner.position_index
));
return parts.join(" / ");
}
function detailLink(miner) {
const returnTo = window.location.pathname + window.location.search;
const path = "/miners/" + encodeURIComponent(miner.id) + "/detail";
return path + "?return_to=" + encodeURIComponent(returnTo);
}
function mapLink(miner) {
if (!canViewLocations || !miner.location_position_id) return null;
const params = new URLSearchParams({ miner_id: miner.id });
if (miner.site_id && miner.room_id) {
params.set("site_id", miner.site_id);
params.set("room_id", miner.room_id);
}
return "/monitoring/map?" + params.toString();
}
function addCell(row, label, content, className) {
const cell = create("td", className || "");
cell.dataset.label = label;
if (content instanceof Node) cell.appendChild(content);
else cell.textContent = display(content);
row.appendChild(cell);
}
function stack(primary, secondary) {
const box = create("div", "fleet-stack");
box.append(create("span", "fleet-primary", display(primary)));
if (secondary) box.append(create("span", "secondary", secondary));
return box;
}
function renderRow(miner) {
const condition = operational(miner);
const row = create("tr", "fleet-row");
row.dataset.status = condition.key;
const identity = create("div", "fleet-stack");
const link = create("a", "object-link", display(miner.asset_id, "No Asset ID"));
link.href = detailLink(miner);
identity.append(link, create("span", "secondary fleet-uuid", miner.id));
addCell(row, "Miner", identity, "fleet-identity");
const status = create("div", "fleet-stack");
const marker = create("span", "fleet-status", condition.label);
marker.dataset.status = condition.key;
status.append(marker, create("span", "secondary", age(miner.observed_at)));
addCell(row, "Operational", status);
addCell(row, "Model / Serial", stack(miner.model, display(miner.miner_sn)));
addCell(row, "Network", stack(miner.current_mac, display(miner.current_ip)));
addCell(row, "Location", locationText(miner), "fleet-location");
addCell(row, "Hashrate", metric(miner.hashrate_ths, " TH/s"));
addCell(row, "Power", metric(miner.power_w, " W"));
addCell(row, "Max chip temp", metric(miner.chip_temp_max_c, " C"));
const repair = miner.active_repair
? words(miner.active_repair.status) + " / " + display(miner.active_repair.reason)
: "No active repair";
addCell(row, "Lifecycle / Repair", stack(words(miner.lifecycle_status), repair));
const actions = create("div", "fleet-actions");
const open = create("a", "command-link", "Open miner");
open.href = detailLink(miner);
actions.appendChild(open);
const mapHref = mapLink(miner);
if (mapHref) {
const map = create("a", "quiet-link", "View on Mine Map");
map.href = mapHref;
actions.appendChild(map);
}
addCell(row, "Actions", actions);
return row;
}
function option(value, label) {
const node = document.createElement("option");
node.value = value;
node.textContent = label;
return node;
}
function syncLocationOptions(selectedSite, selectedRoom) {
if (!canViewLocations || !state.locationOptions) return;
el.fleetSite.replaceChildren(option("", "All sites"));
for (const site of state.locationOptions.sites || []) {
el.fleetSite.appendChild(option(String(site.id), site.name));
}
el.fleetSite.value = Array.from(el.fleetSite.options).some(function (item) {
return item.value === selectedSite;
}) ? selectedSite : "";
el.fleetRoom.replaceChildren(option("", "All rooms"));
for (const room of state.locationOptions.rooms || []) {
if (!el.fleetSite.value || String(room.site_id) === el.fleetSite.value) {
el.fleetRoom.appendChild(option(String(room.id), room.name));
}
}
el.fleetRoom.value = Array.from(el.fleetRoom.options).some(function (item) {
return item.value === selectedRoom;
}) ? selectedRoom : "";
state.siteId = el.fleetSite.value;
state.roomId = el.fleetRoom.value;
}
function readUrl() {
const params = new URLSearchParams(window.location.search);
el.fleetQuery.value = (params.get("query") || "").slice(0, 255);
el.fleetLifecycle.value = params.get("lifecycle_status") || "";
el.fleetModel.value = (params.get("model") || "").slice(0, 255);
el.fleetRuntime.value = params.get("runtime_status") || "";
el.fleetRepair.value = params.get("repair") || "";
el.fleetOperational.value = params.get("operational_state") || "";
el.fleetTags.value = params.getAll("tags").join(", ");
el.fleetProblems.checked = params.get("problems") === "1";
const requestedLimit = Number(params.get("limit"));
state.limit = limits.has(requestedLimit) ? requestedLimit : 100;
state.offset = Math.max(0, Number.parseInt(params.get("offset") || "0", 10) || 0);
el.fleetLimit.value = String(state.limit);
if (canViewLocations) {
state.siteId = params.get("site_id") || "";
state.roomId = params.get("room_id") || "";
syncLocationOptions(state.siteId, state.roomId);
}
}
function requestParams() {
const params = new URLSearchParams();
const values = {
query: el.fleetQuery.value.trim(),
lifecycle_status: el.fleetLifecycle.value,
model: el.fleetModel.value.trim(),
runtime_status: el.fleetRuntime.value,
repair: el.fleetRepair.value,
operational_state: el.fleetOperational.value,
problems: el.fleetProblems.checked ? "1" : "",
site_id: canViewLocations ? state.siteId : "",
room_id: canViewLocations ? state.roomId : "",
limit: String(state.limit), offset: String(state.offset)
};
for (const pair of Object.entries(values)) {
if (pair[1]) params.set(pair[0], pair[1]);
}
for (const tag of el.fleetTags.value.split(",")) {
const clean = tag.trim();
if (clean) params.append("tags", clean);
}
return params;
}
function setUrl(mode) {
const params = requestParams();
window.history[mode === "push" ? "pushState" : "replaceState"](
{}, "", "/assets/miners?" + params.toString()
);
}
async function load() {
showState("loading");
const controller = new AbortController();
const timer = window.setTimeout(function () { controller.abort(); }, 15000);
try {
const response = await fetch("/api/inventory/miners?" + requestParams().toString(), {
credentials: "same-origin", headers: { Accept: "application/json" },
signal: controller.signal
});
if (response.status === 401) {
window.location.replace("/login?next=" + encodeURIComponent(
window.location.pathname + window.location.search
));
return;
}
if (response.status === 403) {
const denied = new Error("Your account cannot view this fleet information.");
denied.permissionDenied = true;
throw denied;
}
if (!response.ok) throw new Error("The fleet service returned " + response.status + ".");
const envelope = await response.json();
if (!envelope || !Array.isArray(envelope.data) || !envelope.meta) {
throw new Error("MinerOps returned an incomplete fleet response.");
}
state.returned = envelope.data.length;
state.locationOptions = envelope.meta.location_options || null;
const selectedSite = canViewLocations ? state.siteId : "";
const selectedRoom = canViewLocations ? state.roomId : "";
syncLocationOptions(selectedSite, selectedRoom);
el.fleetRows.replaceChildren();
for (const miner of envelope.data) el.fleetRows.appendChild(renderRow(miner));
if (!envelope.data.length) {
el.fleetPrevious.disabled = state.offset === 0;
el.fleetNext.disabled = true;
showState("empty", state.offset
? "No miners are present on this page. Return to the previous page."
: "No authorized miners match the current filters.");
return;
}
el.fleetPrevious.disabled = state.offset === 0;
el.fleetNext.disabled = state.returned < state.limit;
const first = state.offset + 1;
const last = state.offset + state.returned;
el.fleetResultSummary.textContent = "Showing miners " + first + "-" + last +
". Total fleet size is not calculated.";
el.fleetFreshness.textContent = state.returned + " miners on this page";
showState("ready");
} catch (error) {
showState(error.permissionDenied ? "denied" : "error",
error && error.name === "AbortError"
? "MinerOps took too long to respond. Retry in a moment."
: display(error && error.message, "The fleet could not be loaded."));
} finally {
window.clearTimeout(timer);
}
}
el.fleetFilters.addEventListener("submit", function (event) {
event.preventDefault();
if (canViewLocations) {
state.siteId = el.fleetSite.value; state.roomId = el.fleetRoom.value;
}
state.offset = 0; setUrl("push"); load();
});
el.fleetReset.addEventListener("click", function () {
el.fleetFilters.reset(); state.offset = 0; state.limit = 100;
state.siteId = ""; state.roomId = "";
syncLocationOptions("", ""); setUrl("push"); load();
});
el.fleetPrevious.addEventListener("click", function () {
state.offset = Math.max(0, state.offset - state.limit); setUrl("push"); load();
});
el.fleetNext.addEventListener("click", function () {
state.offset += state.limit; setUrl("push"); load();
});
el.fleetLimit.addEventListener("change", function () {
state.limit = Number(el.fleetLimit.value); state.offset = 0; setUrl("push"); load();
});
if (canViewLocations) {
el.fleetSite.addEventListener("change", function () {
state.siteId = el.fleetSite.value; state.roomId = "";
syncLocationOptions(state.siteId, "");
});
el.fleetRoom.addEventListener("change", function () {
state.roomId = el.fleetRoom.value;
});
}
window.addEventListener("popstate", function () { readUrl(); load(); });
readUrl();
load();
}());
+784
View File
@@ -0,0 +1,784 @@
(function () {
"use strict";
if (document.body.dataset.view !== "map") return;
const ids = [
"mapSite", "mapRoom", "mapLayer", "mapSearch", "mapLifecycle",
"mapModel", "mapRepair", "mapStatus", "mapClearFilters",
"mapProblemsOnly",
"mapFilterCount", "mapScope", "mapSummary", "mapFreshness",
"mapCriticalState", "mapCriticalTitle", "mapCriticalDetail",
"mapRetry", "mapLoading", "mapWorkspace", "mapLegend", "mapNotice",
"mapPermissionNote", "mapViewport", "mapLegacySection",
"mapLegacyPositions", "mapUnplacedSection", "mapUnplaced",
"mapInspector", "mapInspectorTitle", "mapInspectorStatus",
"mapInspectorDetails", "mapInspectorClose", "mapOpenMiner"
];
const el = {};
for (const id of ids) el[id] = document.getElementById(id);
const state = {
data: null,
siteId: "",
roomId: "",
layer: "status",
query: "",
lifecycle: "",
model: "",
repair: "",
status: "",
problemsOnly: false,
minerId: "",
minerDetailsIncluded: false
};
function create(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function display(value, fallback) {
if (value === null || value === undefined || String(value).trim() === "") {
return fallback || "Unavailable";
}
return String(value);
}
function shelfLabel(value) {
const label = display(value, "Unlabelled shelf");
return /^shelf\b/i.test(label) ? label : "Shelf " + label;
}
function words(value) {
return display(value, "Unknown").replaceAll("_", " ").toLowerCase();
}
function dateTime(value) {
if (!value) return "Unavailable";
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf()) ? "Unavailable" : parsed.toLocaleString();
}
function metric(value, suffix) {
return typeof value === "number" && Number.isFinite(value)
? value + suffix : "Unavailable";
}
function age(value) {
if (!value) return "Never observed";
const time = new Date(value).valueOf();
if (Number.isNaN(time)) return "Observation time unavailable";
const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000));
if (seconds < 60) return seconds + "s ago";
if (seconds < 3600) return Math.floor(seconds / 60) + "m ago";
if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago";
return Math.floor(seconds / 86400) + "d ago";
}
const PROBLEM_KEYS = new Set([
"offline", "unknown", "unstable", "repair", "conflict", "blocked",
"expected"
]);
const MEASUREMENT_LAYERS = {
temperature: { field: "chip_temp_max_c", suffix: " C max" },
hashrate: { field: "hashrate_ths", suffix: " TH/s" },
power: { field: "power_w", suffix: " W" }
};
function operational(position) {
const kind = words(position.position_kind);
const status = words(position.position_status);
if (position.conflict_reason || status === "conflict") {
return { key: "conflict", label: "Location conflict", mark: "Conflict" };
}
if (kind === "blocked" || status === "blocked") {
return { key: "blocked", label: "Blocked position", mark: "Blocked" };
}
if (kind === "switch" || status === "switch") {
return { key: "switch", label: "Switch / equipment", mark: "Equipment" };
}
if (!position.occupied) {
if (position.expected_mac) {
return { key: "expected", label: "Expected miner not resolved", mark: "Expected" };
}
if (kind === "miner slot" && status === "available") {
return { key: "empty", label: "Empty position", mark: "Empty" };
}
return { key: "unknown", label: "Unknown position state", mark: "Unknown" };
}
if (!state.minerDetailsIncluded) {
return { key: "restricted", label: "Occupied; miner details restricted", mark: "Restricted" };
}
if (position.active_repair || position.lifecycle_status === "in_repair") {
return { key: "repair", label: "In repair", mark: "Repair" };
}
if (position.lifecycle_status === "retired" || position.asset_state === "decommissioned") {
return { key: "retired", label: "Retired / decommissioned", mark: "Retired" };
}
if (position.lifecycle_status === "spare") {
return { key: "spare", label: "Spare inventory", mark: "Spare" };
}
const runtime = words(position.miner_status);
if (runtime === "offline") {
return { key: "offline", label: "Offline", mark: "Offline" };
}
if (position.hash_stable === false) {
return { key: "unstable", label: "Hash unstable", mark: "Unstable" };
}
if (runtime === "online") {
return { key: "online", label: "Online", mark: "Online" };
}
return { key: "unknown", label: "Operational state unknown", mark: "Unknown" };
}
function isProblem(position) {
return PROBLEM_KEYS.has(operational(position).key);
}
function measurementScale(positions) {
const config = MEASUREMENT_LAYERS[state.layer];
if (!config) return null;
const values = positions.filter(function (position) {
return position.occupied && matches(position) &&
(!state.problemsOnly || isProblem(position));
}).map(function (position) {
return position[config.field];
}).filter(function (value) {
return typeof value === "number" && Number.isFinite(value);
});
if (!values.length) return { config: config, min: null, max: null };
let min = values[0];
let max = values[0];
for (const value of values.slice(1)) {
min = Math.min(min, value);
max = Math.max(max, value);
}
return {
config: config,
min: min,
max: max
};
}
function relativePercent(value, scale) {
if (!scale || scale.min === null || scale.max === null ||
typeof value !== "number" || !Number.isFinite(value)) return null;
if (scale.min === scale.max) return 50;
return Math.max(0, Math.min(
100,
((value - scale.min) / (scale.max - scale.min)) * 100
));
}
function layerValue(position, condition) {
if (!position.occupied) return condition.label;
if (!state.minerDetailsIncluded) return condition.label;
if (state.layer === "temperature") {
return metric(position.chip_temp_max_c, " C max");
}
if (state.layer === "hashrate") {
return metric(position.hashrate_ths, " TH/s");
}
if (state.layer === "power") {
return metric(position.power_w, " W");
}
if (state.layer === "repair") {
if (position.active_repair) return words(position.active_repair.status);
if (position.lifecycle_status === "in_repair") return "In-repair lifecycle";
if (position.asset_state === "repair") return "Legacy repair location";
return "No active repair";
}
return condition.label;
}
function identity(position) {
if (!state.minerDetailsIncluded) return "Miner details restricted";
return display(position.asset_id, display(position.current_mac, "Unidentified miner"));
}
function matches(position) {
if (!position.occupied) return true;
if (!state.minerDetailsIncluded) return true;
const condition = operational(position);
const haystack = [position.asset_id, position.miner_sn, position.current_mac,
position.current_ip, position.model].map(function (value) {
return display(value, "").toLowerCase();
}).join(" ");
return (!state.query || haystack.includes(state.query.toLowerCase())) &&
(!state.lifecycle || position.lifecycle_status === state.lifecycle) &&
(!state.model || position.model === state.model) &&
(!state.status || condition.key === state.status) &&
(!state.repair ||
(state.repair === "active" && Boolean(position.active_repair)) ||
(state.repair === "none" && !position.active_repair) ||
(state.repair === "legacy" && position.asset_state === "repair" && !position.active_repair));
}
function option(value, label) {
const node = create("option", "", label);
node.value = value;
return node;
}
function unique(values) {
return Array.from(new Set(values.filter(Boolean))).sort(function (a, b) {
return String(a).localeCompare(String(b), undefined, { numeric: true });
});
}
function syncSelectors() {
const sites = state.data.sites;
if (!sites.some(function (site) { return String(site.id) === state.siteId; })) {
state.siteId = sites.length ? String(sites[0].id) : "";
}
el.mapSite.replaceChildren();
for (const site of sites) el.mapSite.appendChild(option(String(site.id), site.name));
el.mapSite.value = state.siteId;
const rooms = state.data.rooms.filter(function (room) {
return String(room.site_id) === state.siteId;
});
if (!rooms.some(function (room) { return String(room.id) === state.roomId; })) {
state.roomId = rooms.length ? String(rooms[0].id) : "";
}
el.mapRoom.replaceChildren();
if (!rooms.length) el.mapRoom.appendChild(option("", "No rooms at this site"));
for (const room of rooms) {
el.mapRoom.appendChild(option(String(room.id), room.name));
}
el.mapRoom.value = state.roomId;
el.mapRoom.disabled = !rooms.length;
const site = sites.find(function (item) { return String(item.id) === state.siteId; });
const room = rooms.find(function (item) { return String(item.id) === state.roomId; });
el.mapScope.textContent = site && room
? site.name + " / " + room.name
: site ? site.name + " / No configured rooms" : "No configured sites";
}
function syncPermissionControls() {
const allowed = state.minerDetailsIncluded;
if (!allowed) {
state.layer = "status";
state.query = state.lifecycle = state.model = state.repair = state.status = "";
state.problemsOnly = false;
el.mapSearch.value = "";
el.mapRepair.value = "";
el.mapProblemsOnly.checked = false;
}
for (const item of el.mapLayer.options) {
item.disabled = !allowed && item.value !== "status";
}
el.mapLayer.value = state.layer;
el.mapSearch.disabled = !allowed;
el.mapLifecycle.disabled = !allowed;
el.mapModel.disabled = !allowed;
el.mapRepair.disabled = !allowed;
el.mapStatus.disabled = !allowed;
el.mapClearFilters.disabled = !allowed;
el.mapProblemsOnly.disabled = !allowed;
el.mapSearch.closest(".field").hidden = !allowed;
document.getElementById("mapFilters").hidden = !allowed;
el.mapPermissionNote.hidden = allowed;
}
function syncFilterOptions() {
const positions = state.data.positions.filter(function (position) {
return String(position.room_id || "") === state.roomId && position.occupied;
});
const lifecycle = unique(positions.map(function (item) { return item.lifecycle_status; }));
const models = unique(positions.map(function (item) { return item.model; }));
const statuses = unique(positions.map(function (item) { return operational(item).key; }));
const selectedLifecycle = state.lifecycle;
const selectedModel = state.model;
const selectedStatus = state.status;
el.mapLifecycle.replaceChildren(option("", "All lifecycle states"));
lifecycle.forEach(function (value) { el.mapLifecycle.appendChild(option(value, words(value))); });
el.mapModel.replaceChildren(option("", "All models"));
models.forEach(function (value) { el.mapModel.appendChild(option(value, value)); });
el.mapStatus.replaceChildren(option("", "All operational states"));
statuses.forEach(function (value) { el.mapStatus.appendChild(option(value, words(value))); });
if (lifecycle.includes(selectedLifecycle)) el.mapLifecycle.value = selectedLifecycle;
else state.lifecycle = "";
if (models.includes(selectedModel)) el.mapModel.value = selectedModel;
else state.model = "";
if (statuses.includes(selectedStatus)) el.mapStatus.value = selectedStatus;
else state.status = "";
}
function stateUrl() {
const params = new URLSearchParams();
const values = {
site_id: state.siteId, room_id: state.roomId,
layer: state.layer === "status" ? "" : state.layer,
q: state.query, lifecycle: state.lifecycle, model: state.model,
repair: state.repair, status: state.status,
problems: state.problemsOnly ? "1" : "", miner_id: state.minerId
};
for (const pair of Object.entries(values)) {
if (pair[1]) params.set(pair[0], pair[1]);
}
return window.location.pathname +
(params.size ? "?" + params.toString() : "");
}
function setUrl(mode) {
window.history[mode === "push" ? "pushState" : "replaceState"](
{}, "", stateUrl()
);
}
function readUrl() {
const params = new URLSearchParams(window.location.search);
state.siteId = params.get("site_id") || "";
state.roomId = params.get("room_id") || "";
state.layer = ["status", "temperature", "hashrate", "power", "repair"]
.includes(params.get("layer")) ? params.get("layer") : "status";
state.query = (params.get("q") || "").slice(0, 160);
state.lifecycle = params.get("lifecycle") || "";
state.model = params.get("model") || "";
state.repair = ["active", "none", "legacy"].includes(params.get("repair"))
? params.get("repair") : "";
state.status = params.get("status") || "";
state.problemsOnly = params.get("problems") === "1";
state.minerId = params.get("miner_id") || "";
syncPermissionControls();
el.mapLayer.value = state.layer;
el.mapSearch.value = state.query;
el.mapRepair.value = state.repair;
el.mapProblemsOnly.checked = state.problemsOnly;
}
function appendLegendItem(key, label, mark) {
const item = create("span", "legend-item");
const marker = create("span", "legend-marker", mark);
marker.dataset.status = key;
marker.setAttribute("aria-hidden", "true");
item.append(marker, document.createTextNode(label));
el.mapLegend.appendChild(item);
}
function legend(scale) {
const entries = !state.minerDetailsIncluded
? [["restricted", "Miner details restricted", "Restricted"],
["empty", "Empty position", "Empty"],
["conflict", "Location conflict", "Conflict"],
["blocked", "Blocked position", "Blocked"],
["switch", "Switch or equipment", "Equipment"]]
: state.layer === "status"
? [["online", "Online", "Online"], ["offline", "Offline", "Offline"],
["unstable", "Hash unstable", "Unstable"],
["repair", "In repair", "Repair"], ["spare", "Spare", "Spare"],
["retired", "Retired", "Retired"],
["unknown", "Operational state unknown", "Unknown"],
["empty", "Empty position", "Empty"],
["conflict", "Location conflict", "Conflict"]]
: [["online", "Online marker", "Online"],
["offline", "Offline marker", "Offline"],
["unstable", "Hash unstable marker", "Unstable"],
["repair", "In-repair marker", "Repair"],
["unknown", "Unknown-state marker", "Unknown"],
["empty", "Empty position", "Empty"]];
el.mapLegend.replaceChildren();
el.mapLegend.appendChild(create(
"strong", "legend-title", el.mapLayer.options[el.mapLayer.selectedIndex].text
));
if (scale) {
const range = create("span", "relative-scale");
if (scale.min === null || scale.max === null) {
range.textContent = "Relative range unavailable: no valid displayed readings";
} else {
const low = metric(scale.min, scale.config.suffix);
const high = metric(scale.max, scale.config.suffix);
range.setAttribute(
"aria-label",
"Relative range across emphasized miners from " + low + " to " + high
);
range.append(
create("span", "relative-scale-label", "Relative range (emphasized)"),
create("span", "relative-scale-value", low),
create("span", "relative-scale-track"),
create("span", "relative-scale-value", high)
);
}
el.mapLegend.appendChild(range);
}
for (const entry of entries) {
appendLegendItem(entry[0], entry[1], entry[2]);
}
}
function positionTile(position, scale) {
const condition = operational(position);
const tile = create(position.occupied && position.miner_id ? "button" : "div", "map-position");
tile.dataset.status = condition.key;
tile.dataset.positionId = position.location_position_id;
tile.dataset.occupied = String(position.occupied);
tile.dataset.problem = String(isProblem(position));
if (position.miner_id) tile.dataset.minerId = position.miner_id;
if (position.occupied && !matches(position)) tile.dataset.filtered = "true";
if (state.problemsOnly && position.occupied && !isProblem(position)) {
tile.dataset.problemsMuted = "true";
}
if (String(position.miner_id) === state.minerId) tile.dataset.selected = "true";
const scaleEligible = position.occupied && matches(position) &&
(!state.problemsOnly || isProblem(position));
const relative = scale && scaleEligible
? relativePercent(position[scale.config.field], scale) : null;
if (relative !== null) {
tile.dataset.measurement = "true";
tile.style.setProperty("--relative-percent", relative.toFixed(2) + "%");
}
if (tile.tagName === "BUTTON") {
tile.type = "button";
tile.tabIndex = -1;
tile.setAttribute("aria-pressed", String(String(position.miner_id) === state.minerId));
tile.setAttribute("aria-label", display(position.position_label, "Position " + position.position_index) +
", " + identity(position) + ", " + condition.label + ", " + age(position.observed_at));
} else {
tile.setAttribute("role", "group");
tile.setAttribute("aria-label", display(position.position_label, "Position " + position.position_index) +
", " + condition.label);
}
const head = create("span", "position-head");
const marker = create("span", "status-marker", condition.mark);
marker.setAttribute("aria-hidden", "true");
head.append(create("strong", "position-label", display(position.position_label, "Position " + position.position_index)),
marker);
const name = create("span", "position-miner", position.occupied ? identity(position) : condition.label);
tile.append(head, name);
if (position.occupied) {
tile.append(
create("span", "position-value", layerValue(position, condition)),
create("span", "position-freshness", state.minerDetailsIncluded
? age(position.observed_at) : "Miner details restricted")
);
}
return tile;
}
function renderMap() {
syncSelectors();
const positions = state.data.positions.filter(function (position) {
return String(position.room_id || "") === state.roomId;
});
if (state.minerId && !state.data.positions.some(function (position) {
const visible = String(position.room_id || "") === state.roomId || !position.room_id;
return visible && position.miner_id && String(position.miner_id) === state.minerId;
})) state.minerId = "";
syncPermissionControls();
syncFilterOptions();
const scale = measurementScale(positions);
legend(scale);
el.mapViewport.replaceChildren();
if (!state.data.sites.length) {
el.mapNotice.hidden = false;
el.mapNotice.textContent = "No sites are configured. Use location management to create the physical hierarchy.";
} else if (!state.roomId) {
el.mapNotice.hidden = false;
el.mapNotice.textContent = "This site has no configured rooms.";
} else if (!positions.length) {
el.mapNotice.hidden = false;
el.mapNotice.textContent = "No positions are configured for this room.";
} else {
el.mapNotice.hidden = true;
}
const racks = new Map();
for (const position of positions) {
const rack = display(position.rack_name, "Unlabelled rack");
const shelf = display(position.shelf_name, "Unlabelled shelf");
if (!racks.has(rack)) racks.set(rack, new Map());
if (!racks.get(rack).has(shelf)) racks.get(rack).set(shelf, []);
racks.get(rack).get(shelf).push(position);
}
const fragment = document.createDocumentFragment();
for (const rackEntry of racks) {
const rack = create("section", "map-rack");
rack.setAttribute("aria-label", "Rack " + rackEntry[0]);
rack.appendChild(create("h2", "rack-title", "Rack " + rackEntry[0]));
for (const shelfEntry of rackEntry[1]) {
const shelf = create("section", "map-shelf");
shelf.setAttribute("aria-label", shelfLabel(shelfEntry[0]));
shelf.appendChild(create("h3", "shelf-title", shelfLabel(shelfEntry[0])));
const grid = create("div", "position-grid");
for (const position of shelfEntry[1]) {
grid.appendChild(positionTile(position, scale));
}
shelf.appendChild(grid);
rack.appendChild(shelf);
}
fragment.appendChild(rack);
}
el.mapViewport.appendChild(fragment);
renderSummary(positions);
renderLegacyPositions();
renderUnplaced();
renderInspector();
syncTileTabStops();
const count = [state.query, state.lifecycle, state.model, state.repair,
state.status, state.problemsOnly].filter(Boolean).length;
el.mapFilterCount.textContent = count ? "(" + count + " active)" : "";
el.mapFreshness.textContent = "Map generated " + dateTime(state.meta.generated_at) +
(state.minerDetailsIncluded ? ". Telemetry age is shown per miner."
: ". Miner details are restricted for this account.");
}
function renderSummary(positions) {
const facts = { occupied: 0, empty: 0, conflict: 0, offline: 0,
repair: 0, unknown: 0, restricted: 0, problems: 0 };
for (const position of positions) {
const condition = operational(position);
if (position.occupied) facts.occupied += 1;
if (condition.key in facts) facts[condition.key] += 1;
if (isProblem(position)) facts.problems += 1;
}
el.mapSummary.replaceChildren();
const pairs = [["Positions", positions.length], ["Occupied", facts.occupied],
["Empty", facts.empty], ["Conflicts", facts.conflict]];
if (state.minerDetailsIncluded) pairs.splice(3, 0,
["Problems", facts.problems], ["Offline", facts.offline],
["In repair", facts.repair], ["Unknown", facts.unknown]);
else pairs.splice(3, 0, ["Restricted", facts.restricted]);
for (const pair of pairs) {
const item = create("span", "summary-fact");
item.append(create("strong", "", String(pair[1])), document.createTextNode(pair[0]));
el.mapSummary.appendChild(item);
}
}
function renderLegacyPositions() {
const positions = state.data.positions.filter(function (position) {
return !position.room_id;
});
el.mapLegacySection.hidden = !positions.length;
el.mapLegacyPositions.replaceChildren();
for (const position of positions) {
const item = create("article", "legacy-position-entry");
item.append(create("strong", "", display(position.position_label,
"Position " + position.position_index)),
create("span", "legacy-position-path", "Unscoped / Room label " +
display(position.room_name, "Unavailable") + " / Rack " +
display(position.rack_name, "Unavailable") + " / Shelf " +
display(position.shelf_name, "Unavailable")),
positionTile(position));
el.mapLegacyPositions.appendChild(item);
}
}
function renderUnplaced() {
const miners = state.data.virtual_miners.filter(function (miner) {
return (!state.siteId || String(miner.site_id || "") === state.siteId) &&
(!state.roomId || !miner.room_id || String(miner.room_id) === state.roomId);
});
el.mapUnplacedSection.hidden = !miners.length;
el.mapUnplaced.replaceChildren();
for (const miner of miners) {
const item = create("div", "unplaced-miner");
item.append(create("strong", "", display(miner.asset_id, display(miner.current_mac, "Unidentified miner"))),
create("span", "", words(miner.asset_state) + " / " + words(miner.lifecycle_status)),
create("span", "", miner.active_repair
? "Active repair: " + words(miner.active_repair.status)
: "No active repair record"));
el.mapUnplaced.appendChild(item);
}
}
function detailRow(term, value) {
el.mapInspectorDetails.append(create("dt", "", term), create("dd", "", display(value)));
}
function renderInspector() {
const position = state.data.positions.find(function (item) {
return item.miner_id && String(item.miner_id) === state.minerId;
});
if (!position) {
el.mapInspector.hidden = true;
return;
}
const condition = operational(position);
el.mapInspector.hidden = false;
el.mapInspectorTitle.textContent = identity(position);
el.mapInspectorStatus.textContent = condition.label + " / " + age(position.observed_at);
el.mapInspectorStatus.dataset.status = condition.key;
el.mapInspectorDetails.replaceChildren();
detailRow("Asset ID", position.asset_id);
detailRow("Model", position.model || position.miner_type);
detailRow("Serial", position.miner_sn);
detailRow("Current MAC", position.current_mac);
detailRow("Current IP", position.current_ip);
const location = position.room_id
? [position.site_name, position.room_name, "Rack " + position.rack_name,
shelfLabel(position.shelf_name), display(position.position_label,
"Position " + position.position_index)].join(" / ")
: ["Unscoped", "Room label " + display(position.room_name),
"Rack " + display(position.rack_name), shelfLabel(position.shelf_name),
display(position.position_label, "Position " + position.position_index)].join(" / ");
detailRow("Location", location);
detailRow("Lifecycle", words(position.lifecycle_status));
detailRow("Runtime", words(position.miner_status));
detailRow("Repair", position.active_repair
? words(position.active_repair.status) + " / " + words(position.active_repair.reason)
: position.asset_state === "repair" ? "Legacy repair location state" : "No active repair");
detailRow("Hashrate", metric(position.hashrate_ths, " TH/s"));
detailRow("Power", metric(position.power_w, " W"));
detailRow("Chip temperature", metric(position.chip_temp_max_c, " C max"));
detailRow("Observed", dateTime(position.observed_at));
const returnTo = stateUrl();
el.mapOpenMiner.href = "/miners/" + encodeURIComponent(position.miner_id) +
"/detail?return_to=" + encodeURIComponent(returnTo);
}
function mapTiles() {
return Array.from(el.mapWorkspace.querySelectorAll("button.map-position[data-miner-id]"));
}
function syncTileTabStops() {
const tiles = mapTiles();
const current = tiles.find(function (tile) {
return tile.dataset.minerId === state.minerId;
});
for (const tile of tiles) tile.tabIndex = tile === current ? 0 : -1;
if (!current && tiles.length) tiles[0].tabIndex = 0;
}
function syncSelection() {
for (const tile of mapTiles()) {
const selected = tile.dataset.minerId === state.minerId;
tile.dataset.selected = String(selected);
tile.setAttribute("aria-pressed", String(selected));
}
renderInspector();
syncTileTabStops();
}
function bind() {
const controls = [el.mapLayer, el.mapSearch, el.mapLifecycle, el.mapModel, el.mapRepair, el.mapStatus];
let renderFrame = null;
function scheduleRender() {
if (renderFrame !== null) window.cancelAnimationFrame(renderFrame);
renderFrame = window.requestAnimationFrame(function () {
renderFrame = null;
renderMap();
setUrl("replace");
});
}
el.mapSite.addEventListener("change", function () {
state.siteId = el.mapSite.value; state.roomId = ""; state.minerId = "";
renderMap(); setUrl("push");
});
el.mapRoom.addEventListener("change", function () {
state.roomId = el.mapRoom.value; state.minerId = ""; renderMap(); setUrl("push");
});
for (const control of controls) {
control.addEventListener(control === el.mapSearch ? "input" : "change", function () {
state.layer = el.mapLayer.value; state.query = el.mapSearch.value.trim();
state.lifecycle = el.mapLifecycle.value; state.model = el.mapModel.value;
state.repair = el.mapRepair.value; state.status = el.mapStatus.value;
scheduleRender();
});
}
el.mapProblemsOnly.addEventListener("change", function () {
state.problemsOnly = el.mapProblemsOnly.checked;
scheduleRender();
});
el.mapClearFilters.addEventListener("click", function () {
state.query = state.lifecycle = state.model = state.repair = state.status = "";
state.problemsOnly = false;
el.mapSearch.value = ""; el.mapRepair.value = "";
el.mapProblemsOnly.checked = false;
renderMap(); setUrl("replace");
});
el.mapWorkspace.addEventListener("click", function (event) {
const tile = event.target.closest("button[data-miner-id]");
if (!tile || !el.mapWorkspace.contains(tile)) return;
state.minerId = tile.dataset.minerId;
syncSelection(); setUrl("push");
el.mapInspectorClose.focus();
});
el.mapWorkspace.addEventListener("keydown", function (event) {
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
const tile = event.target.closest("button.map-position[data-miner-id]");
if (!tile) return;
const tiles = mapTiles();
const current = tiles.indexOf(tile);
const delta = ["ArrowLeft", "ArrowUp"].includes(event.key) ? -1 : 1;
const next = tiles[(current + delta + tiles.length) % tiles.length];
event.preventDefault();
for (const item of tiles) item.tabIndex = item === next ? 0 : -1;
next.focus();
});
el.mapInspectorClose.addEventListener("click", function () {
const restoreId = state.minerId; state.minerId = "";
syncSelection(); setUrl("push");
const restore = mapTiles().find(function (tile) {
return tile.dataset.minerId === restoreId;
});
if (restore) {
for (const tile of mapTiles()) tile.tabIndex = -1;
restore.tabIndex = 0;
restore.focus();
}
});
el.mapRetry.addEventListener("click", load);
window.addEventListener("popstate", function () {
readUrl(); renderMap(); setUrl("replace");
});
}
function validate(envelope) {
return envelope && envelope.data && envelope.meta &&
Array.isArray(envelope.data.sites) && Array.isArray(envelope.data.rooms) &&
Array.isArray(envelope.data.positions) && Array.isArray(envelope.data.virtual_miners) &&
envelope.data.positions.every(function (item) {
return item && typeof item.location_position_id === "string" &&
typeof item.position_index === "number";
});
}
async function load() {
el.mapLoading.hidden = false;
el.mapCriticalState.hidden = true;
el.mapWorkspace.hidden = true;
const controller = new AbortController();
const timer = window.setTimeout(function () { controller.abort(); }, 15000);
try {
const response = await fetch("/api/location/map", {
credentials: "same-origin", headers: { Accept: "application/json" },
signal: controller.signal
});
if (response.status === 401) {
window.location.replace("/login?next=" + encodeURIComponent(window.location.pathname + window.location.search));
return;
}
if (response.status === 403) {
el.mapCriticalTitle.textContent = "Map access unavailable";
el.mapCriticalDetail.textContent = "Your account cannot view location data.";
el.mapCriticalState.hidden = false;
return;
}
if (!response.ok) throw new Error("The map service returned " + response.status + ".");
const envelope = await response.json();
if (!validate(envelope)) throw new Error("The map response was incomplete.");
state.data = envelope.data; state.meta = envelope.meta;
state.minerDetailsIncluded = envelope.meta.miner_details_included === true;
readUrl(); syncSelectors();
el.mapWorkspace.hidden = false;
renderMap(); setUrl("replace");
} catch (error) {
el.mapCriticalTitle.textContent = "Map unavailable";
el.mapCriticalDetail.textContent = error && error.name === "AbortError"
? "MinerOps took too long to respond. Retry in a moment."
: display(error && error.message, "The map could not be loaded.");
el.mapCriticalState.hidden = false;
} finally {
window.clearTimeout(timer); el.mapLoading.hidden = true;
}
}
bind();
load();
}());
+425
View File
@@ -0,0 +1,425 @@
(function () {
"use strict";
if (document.body.dataset.view !== "miner_detail") return;
const minerId = document.body.dataset.minerId;
const returnTo = document.body.dataset.returnTo || "";
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")
};
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;
}
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;
}
}
el.retry.addEventListener("click", loadOverview);
el.refresh.addEventListener("click", loadOverview);
el.identityHistory.addEventListener("toggle", function () {
if (el.identityHistory.open) loadIdentityHistory();
});
el.observations.addEventListener("toggle", function () {
if (el.observations.open) loadObservations();
});
loadOverview();
})();
@@ -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();
})();
+558
View File
@@ -0,0 +1,558 @@
"""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
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"),
)
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.",
),
}
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 = (
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-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-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-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>"""
+132 -14
View File
@@ -1,7 +1,7 @@
from datetime import datetime, timedelta, timezone
from html import escape
from typing import Any, Callable
from urllib.parse import quote
from urllib.parse import quote, urlsplit
import jwt
import psycopg
@@ -633,20 +633,63 @@ def reset_password(
def protected_page(path: str) -> bool:
exact = {"/dashboard", "/repairs", "/locations", "/system-health", "/accounts"}
exact = {
"/dashboard",
"/repairs",
"/locations",
"/system-health",
"/accounts",
"/monitoring/map",
"/monitoring/analytics",
"/operations/repairs",
"/assets/miners",
}
prefixes = ("/miners/", "/agent/", "/tools/")
return path in exact or path.startswith(prefixes)
def safe_next_path(value: str | None) -> str:
"""Return a safe local application destination for login redirects."""
fallback = "/dashboard"
if not value or value != value.strip():
return fallback
if any(ord(character) < 32 or ord(character) == 127 for character in value):
return fallback
try:
target = urlsplit(value)
except ValueError:
return fallback
path = target.path
if (
target.scheme
or target.netloc
or target.fragment
or not path.startswith("/")
or path.startswith("//")
or "\\" in path
or "%" in path
or any(segment in {".", ".."} for segment in path.split("/"))
or not protected_page(path)
):
return fallback
return value
def required_page_permission(path: str) -> str:
if path == "/accounts":
return "users.manage"
if path == "/system-health":
return "system_health.view"
if path == "/repairs":
if path in {"/repairs", "/operations/repairs"}:
return "repairs.view"
if path == "/locations":
if path in {"/locations", "/monitoring/map"}:
return "locations.view"
if path == "/monitoring/analytics":
return "miners.view"
if path == "/assets/miners":
return "miners.view"
if path.startswith("/agent/"):
return "agents.view"
if path.startswith("/miners/"):
@@ -671,9 +714,77 @@ def machine_agent_request(path: str) -> bool:
return path in exact
def repair_timeline_request(path: str) -> bool:
parts = path.split("/")
if len(parts) == 4 and parts[:3] == ["", "api", "repairs"]:
return parts[3] not in {"queue", "parts-summary"}
return (
len(parts) == 5
and parts[:3] == ["", "api", "repairs"]
and parts[4] == "events"
)
def repair_completion_request(path: str) -> bool:
parts = path.split("/")
return (
len(parts) == 5
and parts[:3] == ["", "api", "repairs"]
and parts[4] == "complete"
)
def repair_parts_request(path: str) -> bool:
parts = path.split("/")
return (
len(parts) == 5
and parts[:3] == ["", "api", "repairs"]
and parts[4] == "parts"
) or (
len(parts) == 7
and parts[:3] == ["", "api", "repairs"]
and parts[4] == "parts"
and parts[6] == "void"
)
def component_request(path: str) -> bool:
parts = path.split("/")
return path == "/api/components" or (
len(parts) == 4 and parts[:3] == ["", "api", "components"]
)
def required_api_permission(method: str, path: str) -> str | None:
parts = path.split("/")
if (
method == "GET"
and len(parts) == 5
and parts[:3] == ["", "api", "miners"]
and parts[4] == "maintenance-events"
):
return "repairs.read"
if component_request(path):
return "components.create" if method == "POST" else "components.read"
if repair_parts_request(path):
return "repairs.read" if method == "GET" else "repairs.parts.manage"
if path.startswith("/api/admin/"):
return "users.manage"
if method == "POST" and path.endswith("/change-mac") and path.startswith("/api/miners/"):
return "miners.replace_control_board"
if (
method == "POST"
and len(path.split("/")) == 5
and path.split("/")[:3] == ["", "api", "miners"]
and path.split("/")[4] == "replace-control-board"
):
return "miners.replace_control_board"
if method == "POST" and path == "/api/repairs":
return "repairs.create"
if method == "POST" and repair_completion_request(path):
return "repairs.complete"
if method == "GET" and repair_timeline_request(path):
return "repairs.read"
if method == "GET":
if path.startswith("/api/system/"):
return "system_health.view"
@@ -721,16 +832,19 @@ def install_identity_middleware(app) -> None:
if path == "/login":
user = request_user_or_none(request)
if user:
next_path = request.query_params.get("next") or "/dashboard"
if not next_path.startswith("/") or next_path.startswith("//"):
next_path = "/dashboard"
return RedirectResponse(next_path, status_code=303)
return RedirectResponse(
safe_next_path(request.query_params.get("next")),
status_code=303,
)
return await call_next(request)
if protected_page(path):
user = request_user_or_none(request)
if not user:
destination = "/login?next=" + quote(path, safe="/")
requested_path = path
if request.url.query:
requested_path += "?" + request.url.query
destination = "/login?next=" + quote(requested_path, safe="")
response = RedirectResponse(destination, status_code=303)
clear_session_cookie(response, request)
return response
@@ -793,7 +907,8 @@ button:disabled { opacity: .6; cursor: wait; }
<body>
<header><div class="brand">MinerOps</div></header>
<main>
<section class="login-panel" id="panel">
<section class="login-panel" id="panel"
data-next="__MINEROPS_SAFE_NEXT_PATH__">
<div class="panel-head"><h1 id="title">Sign in</h1><p class="subtitle" id="subtitle">Use your MinerOps account to continue.</p></div>
<form id="authForm">
<div class="field setup-only"><label for="displayName">Display name</label><input id="displayName" autocomplete="name" maxlength="120"></div>
@@ -815,8 +930,7 @@ button:disabled { opacity: .6; cursor: wait; }
let setupRequired = false;
function destination() {
const value = new URLSearchParams(location.search).get("next") || "/dashboard";
return value.startsWith("/") && !value.startsWith("//") ? value : "/dashboard";
return panel.dataset.next || "/dashboard";
}
function sessionHint() {
@@ -1003,8 +1117,12 @@ dialog::backdrop { background: rgba(18, 27, 35, .48); }
@router.get("/login", response_class=HTMLResponse)
def login_page():
return LOGIN_HTML
def login_page(request: Request):
next_path = escape(
safe_next_path(request.query_params.get("next")),
quote=True,
)
return LOGIN_HTML.replace("__MINEROPS_SAFE_NEXT_PATH__", next_path)
@router.get("/accounts", response_class=HTMLResponse)
+65 -2
View File
@@ -7,7 +7,7 @@ from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress
LifecycleStatus = Literal["active", "spare", "retired"]
LifecycleStatus = Literal["active", "spare", "retired", "in_repair"]
class InventoryTag(BaseModel):
@@ -17,16 +17,36 @@ class InventoryTag(BaseModel):
updated_at: datetime
class InventoryActiveRepair(BaseModel):
id: UUID
status: str
reason: str
class InventoryMinerSummary(BaseModel):
id: UUID
asset_id: str | None = None
current_mac: str | None = None
current_ip: str | None = None
model: str | None = None
miner_sn: str | None = None
lifecycle_status: LifecycleStatus
runtime_status: str
site_id: UUID | None = None
site_name: str | None = None
room_id: UUID | None = None
room_name: str | None = None
location_position_id: UUID | None = None
rack_label: str | None = None
shelf_label: str | None = None
position_label: str | None = None
position_index: int | None = None
observed_at: datetime | None = None
hashrate_ths: float | None = None
power_w: float | None = None
chip_temp_max_c: float | None = None
hash_stable: bool | None = None
active_repair: InventoryActiveRepair | None = None
tags: list[InventoryTag] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
@@ -35,7 +55,6 @@ class InventoryMinerSummary(BaseModel):
class InventoryMinerDetail(InventoryMinerSummary):
hostname: str | None = None
miner_sn: str | None = None
control_board_sn: str | None = None
psu_serial: str | None = None
firmware_version: str | None = None
@@ -99,15 +118,41 @@ class InventorySearchFilters(BaseModel):
query: str | None = None
asset_id: str | None = None
lifecycle_status: str | None = None
model: str | None = None
runtime_status: str | None = None
repair: Literal["active", "none"] | None = None
operational_state: Literal[
"online", "offline", "unknown", "unstable", "repair", "spare", "retired"
] | None = None
problems: bool = False
site_id: UUID | None = None
room_id: UUID | None = None
tags: list[str] = Field(default_factory=list)
limit: int = Field(default=100, ge=1, le=500)
offset: int = Field(default=0, ge=0)
class InventorySiteOption(BaseModel):
id: UUID
name: str
class InventoryRoomOption(BaseModel):
id: UUID
site_id: UUID
name: str
class InventoryLocationOptions(BaseModel):
sites: list[InventorySiteOption] = Field(default_factory=list)
rooms: list[InventoryRoomOption] = Field(default_factory=list)
class InventoryListMeta(BaseModel):
limit: int
offset: int
returned: int
location_options: InventoryLocationOptions = Field(default_factory=InventoryLocationOptions)
class InventoryMinerListResponse(BaseModel):
@@ -126,3 +171,21 @@ class InventoryAssetIDHistoryResponse(BaseModel):
data: list[InventoryAssetIDHistoryEntry]
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class InventoryFleetAttentionSummary(BaseModel):
offline: int
unknown: int
unstable: int
repair: int
class InventoryFleetSummary(BaseModel):
total: int
runtime_online: int
runtime_offline: int
runtime_unknown: int
lifecycle_in_repair: int
lifecycle_spare: int
lifecycle_retired: int
attention: InventoryFleetAttentionSummary
+314 -11
View File
@@ -59,6 +59,161 @@ MINER_SELECT = f"""
) tag_data ON true
"""
MINER_CURRENT_SELECT = f"""
SELECT
miners.id,
miners.site_id,
miners.room_id,
miners.asset_tag,
miners.current_mac,
host(miners.current_ip) AS current_ip,
miners.hostname,
miners.miner_sn,
miners.control_board_sn,
miners.power_sn,
miners.model,
miners.firmware_version,
miners.lifecycle_status,
miners.status,
miners.notes,
miners.first_seen_at,
miners.last_seen_at,
miners.created_at,
miners.updated_at,
tag_data.tags,
latest.observed_at,
latest.hashrate_ths,
latest.power_w,
latest.chip_temp_max_c,
latest.hash_stable
FROM miners
LEFT JOIN LATERAL (
SELECT COALESCE(
jsonb_agg(
jsonb_build_object(
'id', tags.id,
'name', tags.name,
'created_at', tags.created_at,
'updated_at', tags.updated_at
)
ORDER BY lower({TAG_TRIMMED_NAME_SQL}), tags.id
),
'[]'::jsonb
) AS tags
FROM miner_tags
JOIN tags ON tags.id = miner_tags.tag_id
WHERE miner_tags.miner_id = miners.id
) tag_data ON true
LEFT JOIN LATERAL (
SELECT snapshots.observed_at,
ROUND((snapshots.mhs_av / 1000000.0)::numeric, 2)::float
AS hashrate_ths,
ROUND(snapshots.power_w::numeric, 2)::float AS power_w,
ROUND(snapshots.chip_temp_max_c::numeric, 2)::float
AS chip_temp_max_c,
snapshots.hash_stable
FROM miner_status_snapshots AS snapshots
WHERE snapshots.miner_id = miners.id
ORDER BY snapshots.observed_at DESC, snapshots.id DESC
LIMIT 1
) latest ON true
"""
ACTIVE_REPAIR_STATUSES = (
"OPEN",
"IN_PROGRESS",
"WAITING_FOR_PARTS",
"READY_FOR_TEST",
)
OPERATIONAL_STATE_SQL = """
CASE
WHEN active_repair.id IS NOT NULL OR miners.lifecycle_status = 'in_repair'
THEN 'repair'
WHEN miners.lifecycle_status = 'retired' THEN 'retired'
WHEN miners.lifecycle_status = 'spare' THEN 'spare'
WHEN lower(trim(miners.status)) = 'offline' THEN 'offline'
WHEN latest.hash_stable = false THEN 'unstable'
WHEN lower(trim(miners.status)) = 'online' THEN 'online'
ELSE 'unknown'
END
"""
FLEET_SELECT = f"""
SELECT
miners.id, miners.asset_tag, miners.current_mac,
host(miners.current_ip) AS current_ip, miners.model, miners.miner_sn,
miners.lifecycle_status, miners.status, miners.created_at,
miners.updated_at, miners.last_seen_at, tag_data.tags,
latest.observed_at, latest.hashrate_ths, latest.power_w,
latest.chip_temp_max_c, latest.hash_stable,
active_repair.id AS active_repair_id,
active_repair.status AS active_repair_status,
active_repair.reason AS active_repair_reason,
{{location_fields}}
FROM miners
LEFT JOIN LATERAL (
SELECT COALESCE(
jsonb_agg(
jsonb_build_object(
'id', tags.id, 'name', tags.name,
'created_at', tags.created_at, 'updated_at', tags.updated_at
) ORDER BY lower({TAG_TRIMMED_NAME_SQL}), tags.id
), '[]'::jsonb
) AS tags
FROM miner_tags JOIN tags ON tags.id = miner_tags.tag_id
WHERE miner_tags.miner_id = miners.id
) tag_data ON true
LEFT JOIN LATERAL (
SELECT snapshots.observed_at,
ROUND((snapshots.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths,
ROUND(snapshots.power_w::numeric, 2)::float AS power_w,
ROUND(snapshots.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c,
snapshots.hash_stable
FROM miner_status_snapshots snapshots
WHERE snapshots.miner_id = miners.id
ORDER BY snapshots.observed_at DESC, snapshots.id DESC
LIMIT 1
) latest ON true
LEFT JOIN LATERAL (
SELECT repairs.id, repairs.status, repairs.reason
FROM repairs
WHERE repairs.miner_id = miners.id AND repairs.status = ANY(%s)
ORDER BY repairs.opened_at, repairs.id
LIMIT 1
) active_repair ON true
{{location_join}}
"""
LOCATION_FIELDS = """
mapped.site_id, mapped.site_name, mapped.room_id, mapped.room_name,
mapped.location_position_id, mapped.rack_label, mapped.shelf_label,
mapped.position_label, mapped.position_index
"""
REDACTED_LOCATION_FIELDS = """
NULL::uuid AS site_id, NULL::text AS site_name,
NULL::uuid AS room_id, NULL::text AS room_name,
NULL::uuid AS location_position_id, NULL::text AS rack_label,
NULL::text AS shelf_label, NULL::text AS position_label,
NULL::integer AS position_index
"""
LOCATION_JOIN = """
LEFT JOIN LATERAL (
SELECT sites.id AS site_id, sites.name AS site_name, rooms.id AS room_id,
COALESCE(rooms.name, lp.room_name) AS room_name,
lp.id AS location_position_id, lp.rack_name AS rack_label,
lp.shelf_name AS shelf_label, lp.position_label, lp.position_index
FROM location_positions lp
LEFT JOIN rooms ON rooms.id = lp.room_id
LEFT JOIN sites ON sites.id = rooms.site_id
WHERE lp.miner_id = miners.id
ORDER BY lp.id
LIMIT 1
) mapped ON true
"""
MINER_WRITE_COLUMNS = {
"site_id",
"room_id",
@@ -165,6 +320,17 @@ class InventoryRepository:
with connection.cursor(row_factory=dict_row) as cur:
return self._fetch_miner(cur, "miners.id = %s", (miner_id,))
def get_current_miner_by_id(self, miner_id: UUID) -> dict | None:
"""Return Inventory identity with its latest factual telemetry."""
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"{MINER_CURRENT_SELECT} WHERE miners.id = %s",
(miner_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def get_miner_by_id_for_update(self, miner_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
@@ -184,8 +350,82 @@ class InventoryRepository:
(asset_id,),
)
def list_miners(self, *, limit: int = 100, offset: int = 0) -> list[dict]:
return self.search_miners(limit=limit, offset=offset)
def list_miners(
self,
*,
limit: int = 100,
offset: int = 0,
include_location_details: bool = False,
) -> list[dict]:
return self.search_miners(
limit=limit,
offset=offset,
include_location_details=include_location_details,
)
def summarize_fleet(self) -> dict[str, Any]:
"""Return exact fleet-wide counts using Inventory state semantics."""
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
WITH fleet_state AS (
SELECT miners.status, miners.lifecycle_status,
({OPERATIONAL_STATE_SQL}) AS operational_state
FROM miners
LEFT JOIN LATERAL (
SELECT snapshots.hash_stable
FROM miner_status_snapshots AS snapshots
WHERE snapshots.miner_id = miners.id
ORDER BY snapshots.observed_at DESC, snapshots.id DESC
LIMIT 1
) AS latest ON true
LEFT JOIN LATERAL (
SELECT repairs.id
FROM repairs
WHERE repairs.miner_id = miners.id
AND repairs.status = ANY(%s)
ORDER BY repairs.opened_at, repairs.id
LIMIT 1
) AS active_repair ON true
)
SELECT count(*)::integer AS total,
count(*) FILTER (
WHERE lower(trim(status)) = 'online'
)::integer AS runtime_online,
count(*) FILTER (
WHERE lower(trim(status)) = 'offline'
)::integer AS runtime_offline,
count(*) FILTER (
WHERE lower(trim(status)) NOT IN ('online', 'offline')
OR status IS NULL
)::integer AS runtime_unknown,
count(*) FILTER (
WHERE lifecycle_status = 'in_repair'
)::integer AS lifecycle_in_repair,
count(*) FILTER (
WHERE lifecycle_status = 'spare'
)::integer AS lifecycle_spare,
count(*) FILTER (
WHERE lifecycle_status = 'retired'
)::integer AS lifecycle_retired,
count(*) FILTER (
WHERE operational_state = 'offline'
)::integer AS attention_offline,
count(*) FILTER (
WHERE operational_state = 'unknown'
)::integer AS attention_unknown,
count(*) FILTER (
WHERE operational_state = 'unstable'
)::integer AS attention_unstable,
count(*) FILTER (
WHERE operational_state = 'repair'
)::integer AS attention_repair
FROM fleet_state
""",
(list(ACTIVE_REPAIR_STATUSES),),
)
return dict(cur.fetchone())
def search_miners(
self,
@@ -193,12 +433,20 @@ class InventoryRepository:
query: str | None = None,
asset_id: str | None = None,
lifecycle_status: str | None = None,
model: str | None = None,
runtime_status: str | None = None,
repair: str | None = None,
operational_state: str | None = None,
problems: bool = False,
site_id: UUID | None = None,
room_id: UUID | None = None,
tag_names: Sequence[str] = (),
limit: int = 100,
offset: int = 0,
include_location_details: bool = False,
) -> list[dict]:
conditions: list[str] = []
params: list[Any] = []
params: list[Any] = [list(ACTIVE_REPAIR_STATUSES)]
if query:
pattern = f"%{query}%"
@@ -217,15 +465,36 @@ class InventoryRepository:
"""
)
params.extend([pattern] * 8)
if asset_id:
conditions.append("lower(trim(miners.asset_tag)) = lower(trim(%s))")
params.append(asset_id)
if lifecycle_status:
conditions.append("miners.lifecycle_status = %s")
params.append(lifecycle_status)
if model:
conditions.append("lower(trim(miners.model)) = lower(trim(%s))")
params.append(model)
if runtime_status:
conditions.append("lower(trim(miners.status)) = lower(trim(%s))")
params.append(runtime_status)
if repair == "active":
conditions.append("active_repair.id IS NOT NULL")
elif repair == "none":
conditions.append("active_repair.id IS NULL")
if operational_state:
conditions.append(f"({OPERATIONAL_STATE_SQL}) = %s")
params.append(operational_state)
if problems:
conditions.append(
f"({OPERATIONAL_STATE_SQL}) = ANY(%s)"
)
params.append(["offline", "unknown", "unstable", "repair"])
if site_id:
conditions.append("mapped.site_id = %s")
params.append(site_id)
if room_id:
conditions.append("mapped.room_id = %s")
params.append(room_id)
for tag_name in tag_names:
conditions.append(
f"""
@@ -241,6 +510,14 @@ class InventoryRepository:
)
params.append(tag_name)
location_fields = (
LOCATION_FIELDS if include_location_details else REDACTED_LOCATION_FIELDS
)
location_join = LOCATION_JOIN if include_location_details else ""
select_sql = FLEET_SELECT.format(
location_fields=location_fields,
location_join=location_join,
)
where_sql = " AND ".join(conditions) if conditions else "true"
params.extend([limit, offset])
@@ -248,18 +525,44 @@ class InventoryRepository:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
{MINER_SELECT}
{select_sql}
WHERE {where_sql}
ORDER BY
lower(trim(miners.asset_tag)) NULLS LAST,
miners.created_at,
miners.id
ORDER BY lower(trim(miners.asset_tag)) NULLS LAST,
miners.created_at, miners.id
LIMIT %s OFFSET %s
""",
params,
)
return [dict(row) for row in cur.fetchall()]
def list_location_filter_options(self) -> dict[str, list[dict]]:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT sites.id, sites.name
FROM location_positions lp
JOIN miners ON miners.id = lp.miner_id
JOIN rooms ON rooms.id = lp.room_id
JOIN sites ON sites.id = rooms.site_id
GROUP BY sites.id, sites.name
ORDER BY lower(sites.name), sites.id
"""
)
sites = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT rooms.id, rooms.site_id, rooms.name
FROM location_positions lp
JOIN miners ON miners.id = lp.miner_id
JOIN rooms ON rooms.id = lp.room_id
GROUP BY rooms.id, rooms.site_id, rooms.name
ORDER BY lower(rooms.name), rooms.id
"""
)
rooms = [dict(row) for row in cur.fetchall()]
return {"sites": sites, "rooms": rooms}
def create_miner(self, values: Mapping[str, Any]) -> dict:
clean_values = {
key: value for key, value in values.items() if key in MINER_WRITE_COLUMNS
+18 -3
View File
@@ -5,7 +5,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.identity import require_permission
from app.identity import require_permission, user_has_permission
from app.inventory.dependencies import get_inventory_service
from app.inventory.exceptions import (
DuplicateAssetID,
@@ -16,6 +16,7 @@ from app.inventory.models import (
InventoryAssetIDHistoryResponse,
InventoryCreateRequest,
InventoryListMeta,
InventoryLocationOptions,
InventoryMetadataUpdateRequest,
InventoryMinerDetailResponse,
InventoryMinerListResponse,
@@ -102,21 +103,35 @@ def list_inventory_miners(
service: InventoryServiceDependency,
current_user: InventoryReadUser,
) -> InventoryMinerListResponse:
del current_user
include_location_details = user_has_permission(current_user, "locations.view")
if (filters.site_id or filters.room_id) and not include_location_details:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Location viewing permission is required for location filters",
)
try:
miners = service.search_miners(filters)
miners = service.search_miners(
filters,
include_location_details=include_location_details,
)
except InventoryValidationError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
location_options = InventoryLocationOptions()
if include_location_details:
location_options = InventoryLocationOptions.model_validate(
service.list_location_filter_options()
)
return InventoryMinerListResponse(
data=miners,
meta=InventoryListMeta(
limit=filters.limit,
offset=filters.offset,
returned=len(miners),
location_options=location_options,
),
)
+68 -2
View File
@@ -14,6 +14,8 @@ from app.inventory.exceptions import (
from app.inventory.models import (
InventoryAssetIDHistoryEntry,
InventoryCreateRequest,
InventoryFleetAttentionSummary,
InventoryFleetSummary,
InventoryMetadataUpdateRequest,
InventoryMinerDetail,
InventoryMinerSummary,
@@ -24,7 +26,9 @@ from app.inventory.models import (
from app.inventory.repository import InventoryRepository
ALLOWED_LIFECYCLE_STATUSES = frozenset({"active", "spare", "retired"})
ALLOWED_LIFECYCLE_STATUSES = frozenset(
{"active", "spare", "retired", "in_repair"}
)
class InventoryService:
@@ -77,16 +81,37 @@ class InventoryService:
@classmethod
def _summary_model(cls, row: dict) -> InventoryMinerSummary:
active_repair = None
if row.get("active_repair_id") is not None:
active_repair = {
"id": row["active_repair_id"],
"status": row["active_repair_status"],
"reason": row["active_repair_reason"],
}
return InventoryMinerSummary(
id=row["id"],
asset_id=row.get("asset_tag"),
current_mac=row.get("current_mac"),
current_ip=row.get("current_ip"),
model=row.get("model"),
miner_sn=row.get("miner_sn"),
lifecycle_status=row["lifecycle_status"],
runtime_status=row["status"],
site_id=row.get("site_id"),
site_name=row.get("site_name"),
room_id=row.get("room_id"),
room_name=row.get("room_name"),
location_position_id=row.get("location_position_id"),
rack_label=row.get("rack_label"),
shelf_label=row.get("shelf_label"),
position_label=row.get("position_label"),
position_index=row.get("position_index"),
observed_at=row.get("observed_at"),
hashrate_ths=row.get("hashrate_ths"),
power_w=row.get("power_w"),
chip_temp_max_c=row.get("chip_temp_max_c"),
hash_stable=row.get("hash_stable"),
active_repair=active_repair,
tags=[cls._tag_model(tag) for tag in row.get("tags") or []],
created_at=row["created_at"],
updated_at=row["updated_at"],
@@ -99,7 +124,6 @@ class InventoryService:
return InventoryMinerDetail(
**summary.model_dump(),
hostname=row.get("hostname"),
miner_sn=row.get("miner_sn"),
control_board_sn=row.get("control_board_sn"),
psu_serial=row.get("power_sn"),
firmware_version=row.get("firmware_version"),
@@ -113,6 +137,13 @@ class InventoryService:
raise MinerNotFound(miner_id)
return self._detail_model(row)
def get_current_miner(self, miner_id: UUID) -> InventoryMinerDetail:
"""Return current Inventory identity and the latest observation."""
row = self.repository.get_current_miner_by_id(miner_id)
if not row:
raise MinerNotFound(miner_id)
return self._detail_model(row)
def get_asset_id_history(
self,
miner_id: UUID,
@@ -135,15 +166,39 @@ class InventoryService:
for row in self.repository.list_miners(limit=limit, offset=offset)
]
def summarize_fleet(self) -> InventoryFleetSummary:
row = self.repository.summarize_fleet()
return InventoryFleetSummary(
total=row["total"],
runtime_online=row["runtime_online"],
runtime_offline=row["runtime_offline"],
runtime_unknown=row["runtime_unknown"],
lifecycle_in_repair=row["lifecycle_in_repair"],
lifecycle_spare=row["lifecycle_spare"],
lifecycle_retired=row["lifecycle_retired"],
attention=InventoryFleetAttentionSummary(
offline=row["attention_offline"],
unknown=row["attention_unknown"],
unstable=row["attention_unstable"],
repair=row["attention_repair"],
),
)
def search_miners(
self,
filters: InventorySearchFilters,
*,
include_location_details: bool = False,
) -> list[InventoryMinerSummary]:
lifecycle_status = None
if filters.lifecycle_status:
lifecycle_status = self.validate_lifecycle_status(
filters.lifecycle_status
)
if (filters.site_id or filters.room_id) and not include_location_details:
raise InventoryValidationError(
"Location filters require location viewing permission"
)
asset_id = None
if filters.asset_id:
asset_id = self.normalize_asset_id(filters.asset_id)
@@ -156,12 +211,23 @@ class InventoryService:
query=self._clean_optional_text(filters.query),
asset_id=asset_id,
lifecycle_status=lifecycle_status,
model=self._clean_optional_text(filters.model),
runtime_status=self._clean_optional_text(filters.runtime_status),
repair=filters.repair,
operational_state=filters.operational_state,
problems=filters.problems,
site_id=filters.site_id,
room_id=filters.room_id,
tag_names=tag_names,
limit=filters.limit,
offset=filters.offset,
include_location_details=include_location_details,
)
]
def list_location_filter_options(self) -> dict[str, list[dict]]:
return self.repository.list_location_filter_options()
def create_miner(
self,
request: InventoryCreateRequest,
+7
View File
@@ -0,0 +1,7 @@
"""Mine Map location read domain."""
from app.locations.models import MineMapResponse
from app.locations.repository import LocationMapRepository
from app.locations.service import LocationMapService
__all__ = ["LocationMapRepository", "LocationMapService", "MineMapResponse"]
+8
View File
@@ -0,0 +1,8 @@
"""Dependency construction for the location read domain."""
from app.locations.repository import LocationMapRepository
from app.locations.service import LocationMapService
def get_location_map_service() -> LocationMapService:
return LocationMapService(LocationMapRepository())
+140
View File
@@ -0,0 +1,140 @@
"""Typed contracts for the read-only Mine Map."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
class MapSite(BaseModel):
id: UUID
name: str
class MapRoom(BaseModel):
id: UUID
site_id: UUID
site_name: str
name: str
sort_order: int
class MapActiveRepair(BaseModel):
id: UUID
status: str
reason: str
class MapPosition(BaseModel):
location_position_id: UUID
site_id: UUID | None = None
site_name: str | None = None
room_id: UUID | None = None
room_name: str
rack_name: str
shelf_name: str
shelf_index: int | None = None
position_index: int
position_label: str | None = None
position_kind: str
position_status: str
occupied: bool
expected_mac: str | None = None
equipment_label: str | None = None
notes: str | None = None
source_file: str | None = None
source_cell: str | None = None
conflict_reason: str | None = None
miner_id: UUID | None = None
asset_id: str | None = None
current_mac: str | None = None
current_ip: str | None = None
miner_status: str | None = None
hostname: str | None = None
miner_sn: str | None = None
power_sn: str | None = None
model: str | None = None
miner_type: str | None = None
firmware_version: str | None = None
lifecycle_status: str | None = None
last_seen_at: datetime | None = None
asset_state: str | None = None
observed_at: datetime | None = None
hashrate_ths: float | None = None
power_w: float | None = None
chip_temp_max_c: float | None = None
chip_temp_avg_c: float | None = None
hash_stable: bool | None = None
pool_rejected_percent: float | None = None
active_repair: MapActiveRepair | None = None
class MapVirtualMiner(BaseModel):
miner_id: UUID | None = None
site_id: UUID | None = None
room_id: UUID | None = None
asset_id: str | None = None
asset_state: str
current_mac: str | None = None
current_ip: str | None = None
miner_status: str | None = None
status: str | None = None
hostname: str | None = None
miner_sn: str | None = None
power_sn: str | None = None
model: str | None = None
miner_type: str | None = None
firmware_version: str | None = None
lifecycle_status: str | None = None
last_seen_at: datetime | None = None
observed_at: datetime | None = None
hashrate_ths: float | None = None
power_w: float | None = None
chip_temp_max_c: float | None = None
chip_temp_avg_c: float | None = None
hash_stable: bool | None = None
active_repair: MapActiveRepair | None = None
class MineMapData(BaseModel):
sites: list[MapSite]
rooms: list[MapRoom]
positions: list[MapPosition]
virtual_miners: list[MapVirtualMiner]
class MineMapMeta(BaseModel):
generated_at: datetime
site_id: UUID | None = None
room_id: UUID | None = None
position_count: int
virtual_miner_count: int
miner_details_included: bool
class MineMapResponse(BaseModel):
data: MineMapData
meta: MineMapMeta
error: None = None
class MinerLocationDetail(BaseModel):
site_id: UUID | None = None
site_name: str | None = None
room_id: UUID | None = None
room_name: str | None = None
position_id: UUID
rack_label: str | None = None
shelf_label: str | None = None
position_label: str | None = None
position_index: int | None = None
class LocationSummary(BaseModel):
sites: int
rooms: int
positions: int
occupied_positions: int
empty_positions: int
mapped_miners: int | None = None
unmapped_miners: int | None = None
+257
View File
@@ -0,0 +1,257 @@
"""Set-based PostgreSQL reads for Mine Map v1."""
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from typing import Any
from uuid import UUID
import psycopg
from psycopg.rows import dict_row
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
ACTIVE_REPAIR_STATUSES = (
"OPEN",
"IN_PROGRESS",
"WAITING_FOR_PARTS",
"READY_FOR_TEST",
)
def default_connection_factory() -> psycopg.Connection:
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
connect_timeout=5,
)
LATEST_TELEMETRY_SQL = """
LEFT JOIN LATERAL (
SELECT snapshots.observed_at,
ROUND((snapshots.mhs_av / 1000000.0)::numeric, 2)::float
AS hashrate_ths,
ROUND(snapshots.power_w::numeric, 2)::float AS power_w,
ROUND(snapshots.chip_temp_max_c::numeric, 2)::float
AS chip_temp_max_c,
ROUND(snapshots.chip_temp_avg_c::numeric, 2)::float
AS chip_temp_avg_c,
snapshots.hash_stable, snapshots.pool_rejected_percent
FROM miner_status_snapshots snapshots
WHERE snapshots.miner_id = miners.id
ORDER BY snapshots.observed_at DESC, snapshots.id DESC
LIMIT 1
) latest ON true
"""
ACTIVE_REPAIR_SQL = """
LEFT JOIN LATERAL (
SELECT repairs.id, repairs.status, repairs.reason
FROM repairs
WHERE repairs.miner_id = miners.id AND repairs.status = ANY(%s)
ORDER BY repairs.opened_at, repairs.id
LIMIT 1
) active_repair ON true
"""
class LocationMapRepository:
"""Own all persistence used by the Mine Map read model."""
def __init__(
self,
connection_factory: ConnectionFactory | None = None,
*,
connection: psycopg.Connection | None = None,
):
self._connection_factory = connection_factory or default_connection_factory
self._connection = connection
@contextmanager
def _connection_scope(self) -> Iterator[psycopg.Connection]:
if self._connection is not None:
yield self._connection
return
connection = self._connection_factory()
try:
yield connection
finally:
connection.close()
def read_map(
self,
*,
site_id: UUID | None = None,
room_id: UUID | None = None,
) -> dict[str, list[dict[str, Any]]]:
position_where = []
position_params: list[Any] = [list(ACTIVE_REPAIR_STATUSES)]
virtual_where = [
"COALESCE(mls.asset_state, 'waiting_placement') = ANY(%s)",
"NOT EXISTS (SELECT 1 FROM location_positions assigned "
"WHERE assigned.miner_id = miners.id)",
]
virtual_params: list[Any] = [
list(ACTIVE_REPAIR_STATUSES),
["waiting_placement", "storage", "repair", "decommissioned"],
]
if site_id is not None:
position_where.append("rooms.site_id = %s")
position_params.append(site_id)
virtual_where.append("miners.site_id = %s")
virtual_params.append(site_id)
if room_id is not None:
position_where.append("lp.room_id = %s")
position_params.append(room_id)
virtual_where.append("miners.room_id = %s")
virtual_params.append(room_id)
position_filter = (
"WHERE " + " AND ".join(position_where) if position_where else ""
)
virtual_filter = "WHERE " + " AND ".join(virtual_where)
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute("SELECT id, name FROM sites ORDER BY lower(name), id")
sites = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT rooms.id, rooms.site_id, sites.name AS site_name,
rooms.name, rooms.sort_order
FROM rooms JOIN sites ON sites.id = rooms.site_id
ORDER BY lower(sites.name), rooms.sort_order,
lower(rooms.name), rooms.id
"""
)
rooms = [dict(row) for row in cur.fetchall()]
cur.execute(
f"""
SELECT lp.id AS location_position_id, sites.id AS site_id,
sites.name AS site_name, rooms.id AS room_id,
COALESCE(rooms.name, lp.room_name) AS room_name,
lp.rack_name, lp.shelf_name, lp.shelf_index,
lp.position_index, lp.position_label, lp.position_kind,
lp.position_status, (miners.id IS NOT NULL) AS occupied,
lp.expected_mac, lp.equipment_label, lp.notes,
lp.source_file, lp.source_cell, lp.conflict_reason,
miners.id AS miner_id, miners.asset_tag AS asset_id,
miners.current_mac, host(miners.current_ip) AS current_ip,
miners.status AS miner_status, miners.hostname,
miners.miner_sn, miners.power_sn, miners.model,
miners.miner_type, miners.firmware_version,
miners.lifecycle_status, miners.last_seen_at,
COALESCE(mls.asset_state, 'unassigned') AS asset_state,
latest.observed_at, latest.hashrate_ths, latest.power_w,
latest.chip_temp_max_c, latest.chip_temp_avg_c,
latest.hash_stable, latest.pool_rejected_percent,
active_repair.id AS active_repair_id,
active_repair.status AS active_repair_status,
active_repair.reason AS active_repair_reason
FROM location_positions lp
LEFT JOIN rooms ON rooms.id = lp.room_id
LEFT JOIN sites ON sites.id = rooms.site_id
LEFT JOIN miners ON miners.id = lp.miner_id
LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id
{LATEST_TELEMETRY_SQL}
{ACTIVE_REPAIR_SQL}
{position_filter}
ORDER BY lower(sites.name) NULLS LAST,
rooms.sort_order NULLS LAST,
lower(COALESCE(rooms.name, lp.room_name)),
lower(lp.rack_name), lp.shelf_index NULLS LAST,
lower(lp.shelf_name), lp.position_index, lp.id
""",
position_params,
)
positions = [dict(row) for row in cur.fetchall()]
cur.execute(
f"""
SELECT miners.id AS miner_id, miners.site_id, miners.room_id,
miners.asset_tag AS asset_id,
COALESCE(mls.asset_state, 'waiting_placement') AS asset_state,
miners.current_mac, host(miners.current_ip) AS current_ip,
miners.status AS miner_status, miners.status, miners.hostname,
miners.miner_sn, miners.power_sn, miners.model,
miners.miner_type, miners.firmware_version,
miners.lifecycle_status, miners.last_seen_at,
latest.observed_at, latest.hashrate_ths, latest.power_w,
latest.chip_temp_max_c, latest.chip_temp_avg_c,
latest.hash_stable,
active_repair.id AS active_repair_id,
active_repair.status AS active_repair_status,
active_repair.reason AS active_repair_reason
FROM miners
LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id
{LATEST_TELEMETRY_SQL}
{ACTIVE_REPAIR_SQL}
{virtual_filter}
ORDER BY asset_state, lower(miners.asset_tag), miners.id
""",
virtual_params,
)
virtual_miners = [dict(row) for row in cur.fetchall()]
return {
"sites": sites,
"rooms": rooms,
"positions": positions,
"virtual_miners": virtual_miners,
}
def summarize_locations(self, *, include_miner_counts: bool) -> dict[str, Any]:
miner_counts = (
"count(DISTINCT lp.miner_id)::integer AS mapped_miners, "
"(SELECT count(*)::integer FROM miners WHERE NOT EXISTS ("
"SELECT 1 FROM location_positions assigned "
"WHERE assigned.miner_id = miners.id)) AS unmapped_miners"
if include_miner_counts
else "NULL::integer AS mapped_miners, NULL::integer AS unmapped_miners"
)
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
SELECT (SELECT count(*)::integer FROM sites) AS sites,
(SELECT count(*)::integer FROM rooms) AS rooms,
count(lp.id)::integer AS positions,
count(lp.id) FILTER (
WHERE lp.miner_id IS NOT NULL
)::integer AS occupied_positions,
count(lp.id) FILTER (
WHERE lp.miner_id IS NULL
)::integer AS empty_positions,
{miner_counts}
FROM location_positions AS lp
"""
)
return dict(cur.fetchone())
def get_miner_location(self, miner_id: UUID) -> dict[str, Any] | None:
"""Return the one current physical position assigned to a miner."""
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT sites.id AS site_id, sites.name AS site_name,
rooms.id AS room_id,
COALESCE(rooms.name, position.room_name) AS room_name,
position.id AS position_id,
position.rack_name AS rack_label,
position.shelf_name AS shelf_label,
position.position_label,
position.position_index
FROM location_positions AS position
LEFT JOIN rooms ON rooms.id = position.room_id
LEFT JOIN sites ON sites.id = rooms.site_id
WHERE position.miner_id = %s
ORDER BY position.id
LIMIT 1
""",
(miner_id,),
)
row = cur.fetchone()
return dict(row) if row else None
+30
View File
@@ -0,0 +1,30 @@
"""HTTP boundary for the read-only Mine Map API."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from app.identity import require_permission, user_has_permission
from app.locations.dependencies import get_location_map_service
from app.locations.models import MineMapResponse
from app.locations.service import LocationMapService
router = APIRouter(prefix="/api/location", tags=["locations"])
MapUser = Annotated[dict, Depends(require_permission("locations.view"))]
MapService = Annotated[LocationMapService, Depends(get_location_map_service)]
@router.get("/map", response_model=MineMapResponse)
def get_mine_map(
current_user: MapUser,
service: MapService,
site_id: Annotated[UUID | None, Query()] = None,
room_id: Annotated[UUID | None, Query()] = None,
) -> MineMapResponse:
return service.get_map(
site_id=site_id,
room_id=room_id,
include_miner_details=user_has_permission(current_user, "miners.view"),
)
+102
View File
@@ -0,0 +1,102 @@
"""Mine Map read-model composition independent of HTTP."""
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from app.locations.models import (
LocationSummary,
MapActiveRepair,
MapPosition,
MapRoom,
MapSite,
MapVirtualMiner,
MineMapData,
MineMapMeta,
MineMapResponse,
MinerLocationDetail,
)
from app.locations.repository import LocationMapRepository
MINER_FIELDS = {
"miner_id", "asset_id", "current_mac", "expected_mac", "current_ip", "miner_status",
"status",
"hostname", "miner_sn", "power_sn", "model", "miner_type",
"firmware_version", "lifecycle_status", "last_seen_at", "asset_state",
"observed_at", "hashrate_ths", "power_w", "chip_temp_max_c",
"chip_temp_avg_c", "hash_stable", "pool_rejected_percent",
}
class LocationMapService:
def __init__(self, repository: LocationMapRepository):
self.repository = repository
@staticmethod
def _repair(row: dict[str, Any]) -> MapActiveRepair | None:
repair_id = row.pop("active_repair_id", None)
status = row.pop("active_repair_status", None)
reason = row.pop("active_repair_reason", None)
if repair_id is None:
return None
return MapActiveRepair(id=repair_id, status=status, reason=reason)
@classmethod
def _redact(cls, row: dict[str, Any]) -> dict[str, Any]:
redacted = dict(row)
for field in MINER_FIELDS:
redacted[field] = None
redacted["active_repair_id"] = None
redacted["active_repair_status"] = None
redacted["active_repair_reason"] = None
return redacted
def get_map(
self,
*,
site_id: UUID | None = None,
room_id: UUID | None = None,
include_miner_details: bool,
) -> MineMapResponse:
rows = self.repository.read_map(site_id=site_id, room_id=room_id)
positions = []
for source in rows["positions"]:
row = dict(source) if include_miner_details else self._redact(source)
row["active_repair"] = self._repair(row)
positions.append(MapPosition.model_validate(row))
virtual_miners = []
if include_miner_details:
for source in rows["virtual_miners"]:
row = dict(source)
row["active_repair"] = self._repair(row)
virtual_miners.append(MapVirtualMiner.model_validate(row))
return MineMapResponse(
data=MineMapData(
sites=[MapSite.model_validate(row) for row in rows["sites"]],
rooms=[MapRoom.model_validate(row) for row in rows["rooms"]],
positions=positions,
virtual_miners=virtual_miners,
),
meta=MineMapMeta(
generated_at=datetime.now(timezone.utc),
site_id=site_id,
room_id=room_id,
position_count=len(positions),
virtual_miner_count=len(virtual_miners),
miner_details_included=include_miner_details,
),
)
def get_miner_location(self, miner_id: UUID) -> MinerLocationDetail | None:
row = self.repository.get_miner_location(miner_id)
if row is None:
return None
return MinerLocationDetail.model_validate(row)
def summarize_locations(self, *, include_miner_counts: bool) -> LocationSummary:
return LocationSummary.model_validate(
self.repository.summarize_locations(
include_miner_counts=include_miner_counts
)
)
+223 -377
View File
@@ -1,28 +1,72 @@
from datetime import datetime, timedelta, timezone
from typing import Any
from uuid import UUID
import jwt
import psycopg
import redis
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import Request, Depends, FastAPI, Header, HTTPException, status
from pydantic import BaseModel, EmailStr, Field
from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError
from app.config import settings
from app.dashboard.router import router as dashboard_router
from app.identity import (
COOKIE_TOKEN_HINT,
SESSION_COOKIE,
get_current_user,
install_identity_middleware,
require_permission,
user_has_permission,
router as identity_router,
)
from app.frontend.router import (
dashboard_page as internal_alpha_dashboard_page,
miner_detail_page as internal_alpha_miner_detail_page,
router as frontend_router,
)
from app.inventory.router import router as inventory_router
from app.locations.router import router as locations_router
from app.components.router import router as components_router
from app.miner_detail.router import router as miner_detail_router
from app.monitoring.router import router as monitoring_router
from app.monitoring.frontend import router as monitoring_frontend_router
from app.repairs.exceptions import (
ControlBoardReplacementActorRequired,
InvalidMacAddress,
RepairConflict,
RepairMinerNotFound,
)
from app.repairs.models import (
RepairCenterAccess,
RepairCenterFilters,
RepairCenterScope,
RepairMinerLifecycle,
ControlBoardReplacementReason,
ControlBoardReplacementRequest,
RemovedBoardDisposition,
)
from app.repairs.repository import RepairRepository
from app.repairs.center_repository import RepairCenterRepository
from app.repairs.center_service import RepairCenterService
from app.repairs.service import RepairService
from app.repairs.router import (
control_board_router,
router as repairs_router,
)
app = FastAPI(title=settings.app_name)
app.include_router(identity_router)
app.include_router(frontend_router)
app.include_router(inventory_router)
app.include_router(components_router)
app.include_router(locations_router)
app.include_router(dashboard_router)
app.include_router(miner_detail_router)
app.include_router(monitoring_frontend_router)
app.include_router(monitoring_router)
install_identity_middleware(app)
@@ -1777,7 +1821,7 @@ def get_miner(miner_id: str, current_user: dict = Depends(get_current_user)):
@app.get("/api/miners/{miner_id}/network-identities")
def list_miner_network_identities(miner_id: str, current_user: dict = Depends(get_current_user)):
def list_miner_network_identities(miner_id: str, current_user: dict = Depends(require_permission("miners.view"))):
conn = None
try:
conn = get_db_connection()
@@ -1851,7 +1895,10 @@ def list_miner_hashboards(miner_id: str, current_user: dict = Depends(get_curren
@app.get("/api/miners/{miner_id}/status-snapshots")
def list_miner_status_snapshots(miner_id: str, current_user: dict = Depends(get_current_user)):
def list_miner_status_snapshots(
miner_id: str,
current_user: dict = Depends(require_permission("miners.view")),
):
conn = None
try:
conn = get_db_connection()
@@ -15429,33 +15476,17 @@ def delete_miner_maintenance_event(
conn.close()
class MinerMacChangeRequest(BaseModel):
new_mac: str
model_config = ConfigDict(extra="forbid")
new_mac: str = Field(min_length=1, max_length=64)
old_mac: str | None = None
new_ip: str | None = None
event_title: str | None = None
description: str | None = None
component_serial: str | None = None
downtime_minutes: int | None = None
def normalize_mac_display(mac: str | None):
if mac is None:
return None
cleaned = "".join(ch for ch in str(mac).strip().lower() if ch in "0123456789abcdef")
if len(cleaned) != 12:
return str(mac).strip().upper()
return ":".join(cleaned[i:i + 2] for i in range(0, 12, 2)).upper()
def normalize_mac_key_for_change(mac: str | None):
if mac is None:
return None
return "".join(ch for ch in str(mac).strip().lower() if ch in "0123456789abcdef")
description: str | None = Field(default=None, max_length=4000)
component_serial: str | None = Field(default=None, max_length=255)
downtime_minutes: int | None = Field(default=None, ge=0)
removed_board_disposition: RemovedBoardDisposition | None = None
replacement_reason: ControlBoardReplacementReason | None = None
@app.post("/api/miners/{miner_id}/change-mac")
def change_miner_mac_address(
@@ -15463,244 +15494,66 @@ def change_miner_mac_address(
request: MinerMacChangeRequest,
current_user: dict = Depends(get_current_user),
):
ensure_maintenance_tables()
new_mac = normalize_mac_display(request.new_mac)
new_mac_key = normalize_mac_key_for_change(new_mac)
if not new_mac_key or len(new_mac_key) != 12:
raise HTTPException(status_code=400, detail="New MAC address is invalid")
conn = None
"""Compatibility alias for the repair-scoped replacement workflow."""
if request.replacement_reason is None or request.removed_board_disposition is None:
raise HTTPException(
status_code=422,
detail=(
"The compatibility route requires replacement_reason and "
"removed_board_disposition; use the repair-scoped replacement fields"
),
)
if request.new_ip is not None or request.downtime_minutes is not None:
raise HTTPException(
status_code=422,
detail=(
"The repair-scoped compatibility route does not change IP addresses "
"or record mutable downtime values"
),
)
try:
parsed_miner_id = UUID(miner_id)
except ValueError as exc:
raise HTTPException(status_code=422, detail="Invalid miner UUID") from exc
try:
conn = get_db_connection()
user_id = get_current_user_id_for_maintenance(current_user)
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
id::text,
current_mac,
host(current_ip) AS current_ip,
hostname
FROM miners
WHERE id = %s::uuid;
""",
(miner_id,),
)
miner = cur.fetchone()
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
miner = dict(miner)
old_mac = normalize_mac_display(request.old_mac or miner.get("current_mac"))
old_ip = miner.get("current_ip")
new_ip = request.new_ip or old_ip
if normalize_mac_key_for_change(old_mac) == new_mac_key:
raise HTTPException(status_code=400, detail="New MAC is the same as the current/old MAC")
cur.execute(
"""
UPDATE miners
SET
current_mac = %s,
current_ip = COALESCE(NULLIF(%s, '')::inet, current_ip)
WHERE id = %s::uuid
RETURNING
id::text,
current_mac,
host(current_ip) AS current_ip;
""",
(
new_mac,
new_ip,
miner_id,
),
)
updated_miner = dict(cur.fetchone())
# Mark all known identities for this miner as not current.
cur.execute(
"""
UPDATE miner_network_identities
SET is_current = false
WHERE miner_id = %s::uuid;
""",
(miner_id,),
)
# If this MAC already exists in the identity history, mark it current.
cur.execute(
"""
UPDATE miner_network_identities
SET
is_current = true,
ip_address = COALESCE(NULLIF(%s, '')::inet, ip_address),
hostname = COALESCE(NULLIF(%s, ''), hostname),
last_seen_at = now()
WHERE miner_id = %s::uuid
AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = %s
RETURNING id::text;
""",
(
new_ip,
miner.get("hostname"),
miner_id,
new_mac_key,
),
)
existing_identity = cur.fetchone()
if not existing_identity:
cur.execute(
"""
INSERT INTO miner_network_identities (
id,
miner_id,
mac_address,
ip_address,
hostname,
first_seen_at,
last_seen_at,
is_current
)
VALUES (
%s::uuid,
%s::uuid,
%s,
NULLIF(%s, '')::inet,
%s,
now(),
now(),
true
);
""",
(
str(uuid4()),
miner_id,
new_mac,
new_ip,
miner.get("hostname"),
),
)
event_id = str(uuid4())
event_title = request.event_title or "Control board / MAC address changed"
description_parts = []
if request.description:
description_parts.append(request.description)
description_parts.append(f"MAC changed from {old_mac or 'unknown'} to {new_mac}.")
if old_ip or new_ip:
description_parts.append(f"IP changed/tracked from {old_ip or 'unknown'} to {new_ip or 'unknown'}.")
event_description = " ".join(description_parts)
cur.execute(
"""
INSERT INTO miner_maintenance_events (
id,
miner_id,
event_type,
event_status,
event_title,
description,
event_at,
old_mac,
new_mac,
old_ip,
new_ip,
component_type,
component_slot,
component_serial,
downtime_minutes,
created_by_user_id
)
VALUES (
%s::uuid,
%s::uuid,
'control_board_changed',
'completed',
%s,
%s,
now(),
%s,
%s,
NULLIF(%s, '')::inet,
NULLIF(%s, '')::inet,
'control_board',
'control_board',
%s,
%s,
%s::uuid
)
RETURNING
id::text,
miner_id::text,
event_type,
event_status,
event_title,
description,
event_at,
old_mac,
new_mac,
host(old_ip) AS old_ip,
host(new_ip) AS new_ip,
component_type,
component_slot,
component_serial,
downtime_minutes,
created_by_user_id::text,
created_at,
updated_at;
""",
(
event_id,
miner_id,
event_title,
event_description,
old_mac,
new_mac,
old_ip,
new_ip,
request.component_serial,
request.downtime_minutes,
user_id,
),
)
maintenance_event = dict(cur.fetchone())
conn.commit()
return api_response(
data={
"miner": updated_miner,
"maintenance_event": maintenance_event,
}
replacement = ControlBoardReplacementRequest(
new_mac_address=request.new_mac,
new_control_board_serial=request.component_serial,
removed_board_disposition=request.removed_board_disposition,
replacement_reason=request.replacement_reason,
notes=request.description,
)
except ValidationError as exc:
raise HTTPException(
status_code=422, detail="Legacy change-mac request is invalid"
) from exc
except Exception:
if conn is not None:
conn.rollback()
try:
result = RepairService(RepairRepository()).replace_control_board(
parsed_miner_id,
replacement,
actor_user_id=UUID(str(current_user["id"])),
)
except RepairMinerNotFound as exc:
raise HTTPException(status_code=404, detail="Miner not found") from exc
except RepairConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except (InvalidMacAddress, ControlBoardReplacementActorRequired) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
raise
replacement_data = result.model_dump(mode="json")
return api_response(
data={
"miner": {
"id": replacement_data["miner_id"],
"current_mac": replacement_data["current_mac"],
"control_board_sn": replacement_data["control_board_serial"],
},
"control_board_replacement": replacement_data,
}
)
finally:
if conn is not None:
conn.close()
@app.get("/api/maintenance/events")
def list_all_maintenance_events(
@@ -16654,8 +16507,7 @@ def list_location_import_issues(
conn.close()
@app.get("/api/location/map")
def get_location_map(
def get_legacy_location_map(
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
@@ -29242,6 +29094,9 @@ async def minerops_inject_detail_extra_v2(request, call_next):
response = await call_next(request)
try:
if request.scope.get("endpoint") is internal_alpha_miner_detail_page:
return response
path_value = request.url.path or ""
if not (path_value.startswith("/miners/") and path_value.endswith("/detail")):
@@ -29838,6 +29693,9 @@ async def minerops_inject_clean_detail_v3(request, call_next):
response = await call_next(request)
try:
if request.scope.get("endpoint") is internal_alpha_miner_detail_page:
return response
path_value = request.url.path or ""
if not (path_value.startswith("/miners/") and path_value.endswith("/detail")):
@@ -31076,6 +30934,9 @@ async def minerops_inject_location_management(request, call_next):
response = await call_next(request)
try:
if request.scope.get("endpoint") is internal_alpha_miner_detail_page:
return response
path = request.url.path or ""
if not (path.startswith("/miners/") and path.endswith("/detail")):
@@ -31899,122 +31760,72 @@ def minerops_repair_allowed_status(value):
@app.get("/api/repairs/queue")
def minerops_repair_queue_api(
query: str | None = Query(default=None, max_length=255),
scope: RepairCenterScope = "active",
status_filter: str | None = Query(default=None, alias="status", max_length=64),
model: str | None = Query(default=None, max_length=255),
lifecycle_status: RepairMinerLifecycle | None = None,
site_id: UUID | None = None,
room_id: UUID | None = None,
limit: int = Query(default=250, ge=1, le=250),
offset: int = Query(default=0, ge=0),
current_user: dict = Depends(get_current_user),
):
ensure_location_tables()
ensure_maintenance_tables()
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
WITH latest_repair_event AS (
SELECT DISTINCT ON (miner_id)
id::text AS event_id,
miner_id,
event_status,
event_title,
description,
event_at,
created_at
FROM miner_maintenance_events
WHERE event_type = 'repair'
ORDER BY miner_id, event_at DESC, created_at DESC
),
event_counts AS (
SELECT
miner_id,
COUNT(*) AS repair_event_count,
COUNT(*) FILTER (
WHERE event_status IN (
'open',
'diagnosing',
'waiting_parts',
'in_progress',
'testing',
'needs_followup'
)
) AS open_repair_event_count
FROM miner_maintenance_events
WHERE event_type = 'repair'
GROUP BY miner_id
)
SELECT
m.id::text AS miner_id,
m.current_mac,
host(m.current_ip) AS current_ip,
COALESCE(m.miner_type, m.model) AS model,
m.miner_sn,
m.power_sn,
m.status AS miner_status,
m.created_at AS miner_created_at,
m.updated_at AS miner_updated_at,
m.manual_details,
mls.asset_state,
mls.state_notes,
mls.updated_at AS state_updated_at,
lre.event_id AS latest_event_id,
lre.event_status AS latest_event_status,
lre.event_title AS latest_event_title,
lre.description AS latest_event_description,
lre.event_at AS latest_event_at,
COALESCE(ec.repair_event_count, 0) AS repair_event_count,
COALESCE(ec.open_repair_event_count, 0) AS open_repair_event_count,
m.manual_details -> 'office_repair_import' ->> 'reported_error' AS reported_error,
m.manual_details -> 'office_repair_import' ->> 'reported_hashrate_ths' AS reported_hashrate_ths,
m.manual_details -> 'office_repair_import' ->> 'reported_voltage' AS reported_voltage,
m.manual_details -> 'office_repair_import' ->> 'location_note' AS location_note,
m.manual_details -> 'office_repair_import' ->> 'source' AS import_source
FROM miner_location_states mls
JOIN miners m ON m.id = mls.miner_id
LEFT JOIN latest_repair_event lre ON lre.miner_id = m.id
LEFT JOIN event_counts ec ON ec.miner_id = m.id
WHERE mls.asset_state = 'repair'
ORDER BY
COALESCE(mls.updated_at, m.updated_at) DESC,
m.current_mac;
"""
)
rows = [dict(row) for row in cur.fetchall()]
status_counts = {}
model_counts = {}
error_counts = {}
for row in rows:
status = row.get("latest_event_status") or "unknown"
model = row.get("model") or "unknown"
error = row.get("reported_error") or "No reported error"
status_counts[status] = status_counts.get(status, 0) + 1
model_counts[model] = model_counts.get(model, 0) + 1
error_counts[error] = error_counts.get(error, 0) + 1
return api_response(
data={
"items": rows,
"summary": {
"total": len(rows),
"status_counts": status_counts,
"model_counts": model_counts,
"error_counts": error_counts,
},
}
can_view_miners = user_has_permission(current_user, "miners.view")
can_view_locations = user_has_permission(current_user, "locations.view")
if (site_id or room_id) and not can_view_locations:
raise HTTPException(
status_code=403,
detail="Location viewing permission is required for location filters",
)
if model and not can_view_miners:
raise HTTPException(
status_code=403,
detail="Miner viewing permission is required for model filters",
)
filters = RepairCenterFilters(
query=query,
scope=scope,
status=status_filter,
model=model,
lifecycle_status=lifecycle_status,
site_id=site_id,
room_id=room_id,
limit=limit,
offset=offset,
)
access = RepairCenterAccess(
can_read_repairs=user_has_permission(current_user, "repairs.read"),
can_view_miners=can_view_miners,
can_view_locations=can_view_locations,
can_complete_repairs=user_has_permission(current_user, "repairs.complete"),
can_manage_parts=user_has_permission(current_user, "repairs.parts.manage"),
can_manage_legacy=user_has_permission(current_user, "repairs.manage"),
)
result = RepairCenterService(RepairCenterRepository()).list_repair_center(
filters, access
)
return result.model_dump(mode="json")
finally:
if conn is not None:
conn.close()
def minerops_repair_reject_modular_mutation(cur, miner_id: str) -> None:
cur.execute(
"""
SELECT 1
FROM repairs
WHERE miner_id = %s::uuid
AND status IN ('OPEN', 'IN_PROGRESS', 'WAITING_FOR_PARTS', 'READY_FOR_TEST')
LIMIT 1
""",
(miner_id,),
)
if cur.fetchone():
raise HTTPException(
status_code=409,
detail="This miner has an active modular repair; use the modular repair workflow",
)
@app.post("/api/repairs/{miner_id}/note")
def minerops_repair_add_note_api(
miner_id: str,
@@ -32041,7 +31852,8 @@ def minerops_repair_add_note_api(
SELECT id::text, current_mac
FROM miners
WHERE id = %s::uuid
LIMIT 1;
LIMIT 1
FOR UPDATE;
""",
(miner_id,),
)
@@ -32051,6 +31863,8 @@ def minerops_repair_add_note_api(
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
minerops_repair_reject_modular_mutation(cur, miner_id)
cur.execute(
"""
INSERT INTO miner_maintenance_events (
@@ -32133,7 +31947,8 @@ def minerops_repair_update_status_api(
SELECT id::text
FROM miners
WHERE id = %s::uuid
LIMIT 1;
LIMIT 1
FOR UPDATE;
""",
(miner_id,),
)
@@ -32143,6 +31958,8 @@ def minerops_repair_update_status_api(
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
minerops_repair_reject_modular_mutation(cur, miner_id)
cur.execute(
"""
SELECT id::text
@@ -32288,7 +32105,8 @@ def minerops_repair_update_state_api(
SELECT id::text, current_mac
FROM miners
WHERE id = %s::uuid
LIMIT 1;
LIMIT 1
FOR UPDATE;
""",
(miner_id,),
)
@@ -32298,6 +32116,8 @@ def minerops_repair_update_state_api(
if not miner:
raise HTTPException(status_code=404, detail="Miner not found")
minerops_repair_reject_modular_mutation(cur, miner_id)
cur.execute(
"""
UPDATE location_positions
@@ -33104,8 +32924,23 @@ MINEROPS_REPAIR_QUEUE_HTML = r"""
async function loadRepairs() {
try {
const data = await apiGet("/api/repairs/queue");
repairItems = data.items || [];
updateMetrics(data.summary || {});
repairItems = (data.items || []).filter(function (item) {
return !item.source || item.source === "legacy";
});
const legacySummary = {
total: repairItems.length,
status_counts: {},
model_counts: {}
};
for (const item of repairItems) {
const status = item.latest_event_status || item.asset_state || "unknown";
const model = item.model || "unknown";
legacySummary.status_counts[status] =
(legacySummary.status_counts[status] || 0) + 1;
legacySummary.model_counts[model] =
(legacySummary.model_counts[model] || 0) + 1;
}
updateMetrics(legacySummary);
buildModelFilter();
renderTable();
showStatus("ok", "Repair Queue loaded. " + repairItems.length + " miners in repair.");
@@ -33630,6 +33465,9 @@ async def minerops_inject_repair_polish_assets(request, call_next):
try:
path = request.url.path or ""
if request.scope.get("endpoint") is internal_alpha_dashboard_page:
return response
if path not in ("/dashboard", "/locations", "/repairs"):
return response
@@ -33985,6 +33823,9 @@ async def minerops_inject_dashboard_repair_summary(request, call_next):
response = await call_next(request)
try:
if request.scope.get("endpoint") is internal_alpha_dashboard_page:
return response
path = request.url.path or ""
if path != "/dashboard":
@@ -35669,3 +35510,8 @@ def minerops_repair_parts_summary_api(
# MINEROPS_REPAIR_HISTORY_API_HELPERS_SCOPE_FIX_V1
# MINEROPS_REPAIR_HISTORY_DELEGATED_ACTIONS_V1
# Register UUID-based Repair routes after legacy static Repair paths.
app.include_router(repairs_router)
app.include_router(control_board_router)
+1
View File
@@ -0,0 +1 @@
"""Permission-aware Miner Detail read model."""
+14
View File
@@ -0,0 +1,14 @@
"""Dependency construction for the Miner Detail composition service."""
from app.inventory.dependencies import get_inventory_service
from app.locations.dependencies import get_location_map_service
from app.miner_detail.service import MinerDetailService
from app.repairs.dependencies import get_repair_center_service
def get_miner_detail_service() -> MinerDetailService:
return MinerDetailService(
inventory_service=get_inventory_service(),
location_service=get_location_map_service(),
repair_service=get_repair_center_service(),
)
+76
View File
@@ -0,0 +1,76 @@
"""Typed contracts for the Internal Alpha Miner Detail overview."""
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field
from app.inventory.models import InventoryMinerDetail
from app.locations.models import MinerLocationDetail
OperationalState = Literal[
"online", "offline", "unknown", "unstable", "repair", "spare", "retired"
]
class MinerDetailAccess(BaseModel):
can_view_locations: bool = False
can_read_repairs: bool = False
can_view_repairs: bool = False
can_read_components: bool = False
class MinerDetailLocationSection(BaseModel):
available: bool
restricted: bool
location: MinerLocationDetail | None = None
class MinerDetailRepairEpisode(BaseModel):
repair_id: UUID | None = None
source: Literal["modular", "legacy"]
active: bool
status: str
reason: str | None = None
opened_at: datetime | None = None
closed_at: datetime | None = None
latest_context: str | None = None
latest_context_at: datetime | None = None
parts_count: int | None = None
parts_summary: list[str] = Field(default_factory=list)
class MinerDetailRepairSection(BaseModel):
available: bool
restricted: bool
active_repair: MinerDetailRepairEpisode | None = None
episodes: list[MinerDetailRepairEpisode] = Field(default_factory=list)
class MinerDetailCapabilities(BaseModel):
can_view_map: bool = False
can_open_repair_center: bool = False
can_read_repair_history: bool = False
can_read_components: bool = False
class MinerDetailOverviewData(BaseModel):
miner: InventoryMinerDetail
operational_state: OperationalState
location: MinerDetailLocationSection
repair: MinerDetailRepairSection
capabilities: MinerDetailCapabilities
class MinerDetailOverviewMeta(BaseModel):
generated_at: datetime
database_query_count: int = Field(ge=1, le=3)
class MinerDetailOverviewResponse(BaseModel):
data: MinerDetailOverviewData
meta: MinerDetailOverviewMeta
error: None = None
+59
View File
@@ -0,0 +1,59 @@
"""HTTP boundary for the Internal Alpha Miner Detail overview."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Response, status
from app.identity import require_permission, user_has_permission
from app.inventory.exceptions import MinerNotFound
from app.miner_detail.dependencies import get_miner_detail_service
from app.miner_detail.models import (
MinerDetailAccess,
MinerDetailOverviewResponse,
)
from app.miner_detail.service import MinerDetailService
router = APIRouter(prefix="/api/miners", tags=["miners"])
MinerDetailUser = Annotated[dict, Depends(require_permission("miners.view"))]
MinerDetailServiceDependency = Annotated[
MinerDetailService,
Depends(get_miner_detail_service),
]
@router.get(
"/{miner_id}/overview",
response_model=MinerDetailOverviewResponse,
)
def get_miner_detail_overview(
miner_id: UUID,
current_user: MinerDetailUser,
service: MinerDetailServiceDependency,
response: Response,
) -> MinerDetailOverviewResponse:
response.headers["Cache-Control"] = "no-store"
try:
return service.get_overview(
miner_id,
MinerDetailAccess(
can_view_locations=user_has_permission(
current_user, "locations.view"
),
can_read_repairs=user_has_permission(
current_user, "repairs.read"
),
can_view_repairs=user_has_permission(
current_user, "repairs.view"
),
can_read_components=user_has_permission(
current_user, "components.read"
),
),
)
except MinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Miner not found",
) from exc
+152
View File
@@ -0,0 +1,152 @@
"""Composition service for the Internal Alpha Miner Detail overview."""
from datetime import datetime, timezone
from math import isfinite
from uuid import UUID
from app.inventory.models import InventoryMinerDetail
from app.inventory.service import InventoryService
from app.locations.service import LocationMapService
from app.miner_detail.models import (
MinerDetailAccess,
MinerDetailCapabilities,
MinerDetailLocationSection,
MinerDetailOverviewData,
MinerDetailOverviewMeta,
MinerDetailOverviewResponse,
MinerDetailRepairEpisode,
MinerDetailRepairSection,
OperationalState,
)
from app.repairs.center_service import RepairCenterService
from app.repairs.models import RepairCenterAccess, RepairCenterItem
class MinerDetailService:
"""Coordinate domain-owned current projections without transport logic."""
def __init__(
self,
inventory_service: InventoryService,
location_service: LocationMapService,
repair_service: RepairCenterService,
):
self.inventory_service = inventory_service
self.location_service = location_service
self.repair_service = repair_service
@staticmethod
def _episode(item: RepairCenterItem) -> MinerDetailRepairEpisode:
return MinerDetailRepairEpisode(
repair_id=item.repair_id,
source=item.source,
active=item.active,
status=item.repair_status,
reason=item.reason,
opened_at=item.opened_at,
closed_at=item.closed_at,
latest_context=item.latest_context,
latest_context_at=item.latest_context_at,
parts_count=item.parts_count,
parts_summary=item.parts_summary,
)
@staticmethod
def _operational_state(
miner: InventoryMinerDetail,
active_repair: MinerDetailRepairEpisode | None,
) -> OperationalState:
if active_repair is not None or miner.lifecycle_status == "in_repair":
return "repair"
if miner.lifecycle_status == "retired":
return "retired"
if miner.lifecycle_status == "spare":
return "spare"
runtime = (miner.runtime_status or "").strip().lower()
if runtime == "offline":
return "offline"
if miner.hash_stable is False:
return "unstable"
if runtime == "online":
return "online"
return "unknown"
def get_overview(
self,
miner_id: UUID,
access: MinerDetailAccess,
) -> MinerDetailOverviewResponse:
miner = self.inventory_service.get_current_miner(miner_id)
# Location is owned by the permission-aware section below. Keep the
# shared projection from leaking location identifiers. Non-finite
# telemetry is not a factual display value and remains unavailable.
redacted = {
field: None
for field in (
"site_id", "site_name", "room_id", "room_name",
"location_position_id", "rack_label", "shelf_label",
"position_label", "position_index",
)
}
for field in ("hashrate_ths", "power_w", "chip_temp_max_c"):
value = getattr(miner, field)
if value is not None and not isfinite(value):
redacted[field] = None
miner = miner.model_copy(
update=redacted
)
location = (
self.location_service.get_miner_location(miner_id)
if access.can_view_locations
else None
)
repair_items = (
self.repair_service.list_miner_repairs(
miner_id,
RepairCenterAccess(
can_read_repairs=True,
can_view_miners=True,
),
)
if access.can_read_repairs
else []
)
episodes = [self._episode(item) for item in repair_items]
active_repair = next(
(
episode for episode in episodes
if episode.active and episode.source == "modular"
),
None,
)
query_count = 1 + int(access.can_view_locations) + int(access.can_read_repairs)
return MinerDetailOverviewResponse(
data=MinerDetailOverviewData(
miner=miner,
operational_state=self._operational_state(miner, active_repair),
location=MinerDetailLocationSection(
available=access.can_view_locations,
restricted=not access.can_view_locations,
location=location,
),
repair=MinerDetailRepairSection(
available=access.can_read_repairs,
restricted=not access.can_read_repairs,
active_repair=active_repair,
episodes=episodes,
),
capabilities=MinerDetailCapabilities(
can_view_map=bool(
access.can_view_locations and location is not None
and location.site_id and location.room_id
),
can_open_repair_center=access.can_view_repairs,
can_read_repair_history=access.can_read_repairs,
can_read_components=access.can_read_components,
),
),
meta=MinerDetailOverviewMeta(
generated_at=datetime.now(timezone.utc),
database_query_count=query_count,
),
)
+1
View File
@@ -0,0 +1 @@
"""Historical monitoring read models."""
+8
View File
@@ -0,0 +1,8 @@
"""Dependency construction for the Monitoring domain."""
from app.monitoring.repository import MonitoringRepository
from app.monitoring.service import MonitoringService
def get_monitoring_service() -> MonitoringService:
return MonitoringService(MonitoringRepository())
+13
View File
@@ -0,0 +1,13 @@
"""Monitoring domain exceptions."""
class MonitoringError(Exception):
"""Base monitoring-domain error."""
class MonitoringValidationError(MonitoringError):
"""The requested trend scope or range is invalid."""
class MonitoringScopeNotFound(MonitoringError):
"""The requested durable scope does not exist."""
+70
View File
@@ -0,0 +1,70 @@
"""Authenticated frontend routes for Monitoring Trends."""
from pathlib import Path
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import HTMLResponse, Response
from app.frontend.views import render_app_page
from app.identity import require_permission, user_has_permission
from app.monitoring.models import (
MonitoringTrendQuery,
TrendRange,
TrendScopeType,
)
router = APIRouter(tags=["frontend"])
STATIC_ROOT = Path(__file__).parents[1] / "frontend" / "static"
MonitoringUser = Annotated[dict, Depends(require_permission("miners.view"))]
@router.get("/assets/minerops-monitoring-trends.css", include_in_schema=False)
def monitoring_trends_styles() -> Response:
return Response(
(STATIC_ROOT / "monitoring-trends.css").read_text(encoding="utf-8"),
media_type="text/css",
headers={"Cache-Control": "no-store"},
)
@router.get("/assets/minerops-monitoring-trends.js", include_in_schema=False)
def monitoring_trends_script() -> Response:
return Response(
(STATIC_ROOT / "monitoring-trends.js").read_text(encoding="utf-8"),
media_type="text/javascript",
headers={"Cache-Control": "no-store"},
)
@router.get(
"/monitoring/analytics",
response_class=HTMLResponse,
include_in_schema=False,
)
def monitoring_analytics_page(
current_user: MonitoringUser,
scope: Annotated[TrendScopeType, Query()] = "fleet",
scope_id: Annotated[UUID | None, Query()] = None,
time_range: Annotated[TrendRange, Query(alias="range")] = "24h",
) -> str:
try:
query = MonitoringTrendQuery(
scope=scope, scope_id=scope_id, range=time_range
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if query.scope in {"site", "room"} and not user_has_permission(
current_user, "locations.view"
):
raise HTTPException(
status_code=403,
detail="Location viewing permission is required for this scope",
)
return render_app_page(
"analytics",
current_user,
lambda permission: user_has_permission(current_user, permission),
)
+98
View File
@@ -0,0 +1,98 @@
"""Typed contracts for bounded historical telemetry trends."""
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator
TrendScopeType = Literal["fleet", "site", "room", "miner"]
TrendRange = Literal["6h", "24h", "7d"]
TrendMetric = Literal["hashrate", "power"]
TrendAttribution = Literal["current_placement_cohort", "miner_identity", "fleet"]
class MonitoringTrendQuery(BaseModel):
model_config = ConfigDict(populate_by_name=True)
scope: TrendScopeType = "fleet"
scope_id: UUID | None = None
time_range: TrendRange = Field(default="24h", alias="range")
@model_validator(mode="after")
def validate_scope_identity(self) -> "MonitoringTrendQuery":
if self.scope == "fleet" and self.scope_id is not None:
raise ValueError("scope_id is not allowed for fleet scope")
if self.scope != "fleet" and self.scope_id is None:
raise ValueError("scope_id is required for this scope")
return self
class MonitoringScope(BaseModel):
type: TrendScopeType
id: UUID | None = None
label: str
class MonitoringSiteOption(BaseModel):
id: UUID
name: str
class MonitoringRoomOption(BaseModel):
id: UUID
site_id: UUID
name: str
class MonitoringAvailableScopes(BaseModel):
fleet: bool = True
sites: list[MonitoringSiteOption] = Field(default_factory=list)
rooms: list[MonitoringRoomOption] = Field(default_factory=list)
class MonitoringTrendPoint(BaseModel):
bucket_start: datetime
bucket_end: datetime
value: float | None = None
observation_count: int
metric_sample_count: int
observed_miner_count: int
contributing_miner_count: int
scoped_miner_count: int
incomplete: bool = False
class MonitoringTrendSeries(BaseModel):
metric: TrendMetric
unit: Literal["TH/s", "W"]
label: str
points: list[MonitoringTrendPoint]
class MonitoringTrendData(BaseModel):
scope: MonitoringScope
series: list[MonitoringTrendSeries]
available_scopes: MonitoringAvailableScopes
class MonitoringTrendMeta(BaseModel):
generated_at: datetime
latest_observed_at: datetime | None = None
source: Literal["raw_snapshots"] = "raw_snapshots"
attribution: TrendAttribution
time_range: TrendRange = Field(alias="range")
bucket_seconds: int
maximum_points: int = 200
completeness: Literal["not_guaranteed"] = "not_guaranteed"
current_bucket_incomplete: bool
database_query_count: int = Field(ge=1, le=2)
model_config = ConfigDict(populate_by_name=True)
class MonitoringTrendResponse(BaseModel):
data: MonitoringTrendData
meta: MonitoringTrendMeta
error: None = None
+237
View File
@@ -0,0 +1,237 @@
"""PostgreSQL persistence for bounded monitoring trends."""
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from datetime import datetime, timedelta
from typing import Any
from uuid import UUID
import psycopg
from psycopg.rows import dict_row
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
def default_connection_factory() -> psycopg.Connection:
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
connect_timeout=5,
)
TREND_SQL = """
WITH requested AS (
SELECT %s::text AS scope_type, %s::uuid AS scope_id,
%s::timestamptz AS window_start, %s::timestamptz AS window_end,
%s::interval AS bucket_width
),
current_locations AS (
SELECT miners.id AS miner_id,
COALESCE(position.site_id, miners.site_id) AS site_id,
COALESCE(position.room_id, miners.room_id) AS room_id
FROM miners
LEFT JOIN LATERAL (
SELECT rooms.site_id, location_positions.room_id
FROM location_positions
LEFT JOIN rooms ON rooms.id = location_positions.room_id
WHERE location_positions.miner_id = miners.id
ORDER BY location_positions.id
LIMIT 1
) AS position ON true
),
scope_meta AS (
SELECT requested.scope_type, requested.scope_id,
CASE requested.scope_type
WHEN 'fleet' THEN true
WHEN 'miner' THEN EXISTS (
SELECT 1 FROM miners WHERE miners.id = requested.scope_id
)
WHEN 'site' THEN EXISTS (
SELECT 1 FROM sites WHERE sites.id = requested.scope_id
)
WHEN 'room' THEN EXISTS (
SELECT 1 FROM rooms WHERE rooms.id = requested.scope_id
)
ELSE false
END AS scope_exists,
CASE requested.scope_type
WHEN 'fleet' THEN 'Fleet'
WHEN 'miner' THEN (
SELECT COALESCE(NULLIF(trim(miners.asset_tag), ''), miners.id::text)
FROM miners WHERE miners.id = requested.scope_id
)
WHEN 'site' THEN (
SELECT sites.name FROM sites WHERE sites.id = requested.scope_id
)
WHEN 'room' THEN (
SELECT rooms.name FROM rooms WHERE rooms.id = requested.scope_id
)
END AS scope_label
FROM requested
),
scoped_miners AS (
SELECT miners.id
FROM miners
JOIN current_locations ON current_locations.miner_id = miners.id
CROSS JOIN requested
WHERE requested.scope_type = 'fleet'
OR (requested.scope_type = 'miner' AND miners.id = requested.scope_id)
OR (requested.scope_type = 'site'
AND current_locations.site_id = requested.scope_id)
OR (requested.scope_type = 'room'
AND current_locations.room_id = requested.scope_id)
),
scope_count AS (
SELECT count(*)::integer AS scoped_miner_count FROM scoped_miners
),
buckets AS (
SELECT bucket_start,
bucket_start + requested.bucket_width AS bucket_end
FROM requested
CROSS JOIN LATERAL generate_series(
requested.window_start,
requested.window_end - requested.bucket_width,
requested.bucket_width
) AS bucket_start
),
per_miner_bucket AS (
SELECT date_bin(
requested.bucket_width,
snapshots.observed_at,
'1970-01-01 00:00:00+00'::timestamptz
) AS bucket_start,
snapshots.miner_id,
count(*)::integer AS observation_count,
count(snapshots.mhs_av)::integer AS hashrate_sample_count,
count(snapshots.power_w)::integer AS power_sample_count,
avg(snapshots.mhs_av) AS average_mhs_av,
avg(snapshots.power_w) AS average_power_w
FROM miner_status_snapshots AS snapshots
JOIN scoped_miners ON scoped_miners.id = snapshots.miner_id
CROSS JOIN requested
WHERE snapshots.observed_at >= requested.window_start
AND snapshots.observed_at < requested.window_end
GROUP BY bucket_start, snapshots.miner_id
),
bucket_totals AS (
SELECT per_miner_bucket.bucket_start,
sum(per_miner_bucket.observation_count)::integer AS observation_count,
sum(per_miner_bucket.hashrate_sample_count)::integer
AS hashrate_sample_count,
sum(per_miner_bucket.power_sample_count)::integer AS power_sample_count,
count(*)::integer AS observed_miner_count,
count(*) FILTER (
WHERE per_miner_bucket.hashrate_sample_count > 0
)::integer AS hashrate_contributing_miner_count,
count(*) FILTER (
WHERE per_miner_bucket.power_sample_count > 0
)::integer AS power_contributing_miner_count,
CASE WHEN sum(per_miner_bucket.hashrate_sample_count) > 0
THEN sum(per_miner_bucket.average_mhs_av) FILTER (
WHERE per_miner_bucket.hashrate_sample_count > 0
) / 1000000.0
END AS hashrate_ths,
CASE WHEN sum(per_miner_bucket.power_sample_count) > 0
THEN sum(per_miner_bucket.average_power_w) FILTER (
WHERE per_miner_bucket.power_sample_count > 0
)
END AS power_w
FROM per_miner_bucket
GROUP BY per_miner_bucket.bucket_start
),
latest AS (
SELECT max(snapshots.observed_at) AS latest_observed_at
FROM miner_status_snapshots AS snapshots
JOIN scoped_miners ON scoped_miners.id = snapshots.miner_id
CROSS JOIN requested
WHERE snapshots.observed_at >= requested.window_start
AND snapshots.observed_at < requested.window_end
)
SELECT scope_meta.scope_exists, scope_meta.scope_label,
buckets.bucket_start, buckets.bucket_end,
bucket_totals.hashrate_ths, bucket_totals.power_w,
COALESCE(bucket_totals.observation_count, 0) AS observation_count,
COALESCE(bucket_totals.hashrate_sample_count, 0) AS hashrate_sample_count,
COALESCE(bucket_totals.power_sample_count, 0) AS power_sample_count,
COALESCE(bucket_totals.observed_miner_count, 0) AS observed_miner_count,
COALESCE(bucket_totals.hashrate_contributing_miner_count, 0)
AS hashrate_contributing_miner_count,
COALESCE(bucket_totals.power_contributing_miner_count, 0)
AS power_contributing_miner_count,
scope_count.scoped_miner_count, latest.latest_observed_at
FROM buckets
CROSS JOIN scope_meta
CROSS JOIN scope_count
CROSS JOIN latest
LEFT JOIN bucket_totals ON bucket_totals.bucket_start = buckets.bucket_start
ORDER BY buckets.bucket_start
"""
SCOPE_OPTIONS_SQL = """
SELECT option_type, id, site_id, name
FROM (
SELECT 'site'::text AS option_type, sites.id,
NULL::uuid AS site_id, sites.name
FROM sites
UNION ALL
SELECT 'room'::text AS option_type, rooms.id,
rooms.site_id, rooms.name
FROM rooms
) AS options
ORDER BY option_type DESC, lower(name), id
"""
class MonitoringRepository:
"""Own the set-based raw-snapshot monitoring read path."""
def __init__(
self,
connection_factory: ConnectionFactory | None = None,
*,
connection: psycopg.Connection | None = None,
):
self._connection_factory = connection_factory or default_connection_factory
self._connection = connection
@contextmanager
def _connection_scope(self) -> Iterator[psycopg.Connection]:
if self._connection is not None:
yield self._connection
return
connection = self._connection_factory()
try:
yield connection
finally:
connection.close()
def read_trends(
self,
*,
scope: str,
scope_id: UUID | None,
window_start: datetime,
window_end: datetime,
bucket_width: timedelta,
) -> list[dict[str, Any]]:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
TREND_SQL,
(scope, scope_id, window_start, window_end, bucket_width),
)
return [dict(row) for row in cur.fetchall()]
def list_location_scope_options(self) -> list[dict[str, Any]]:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(SCOPE_OPTIONS_SQL)
return [dict(row) for row in cur.fetchall()]
+61
View File
@@ -0,0 +1,61 @@
"""HTTP boundary for bounded historical monitoring trends."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from app.identity import require_permission, user_has_permission
from app.monitoring.dependencies import get_monitoring_service
from app.monitoring.exceptions import MonitoringScopeNotFound
from app.monitoring.models import (
MonitoringTrendQuery,
MonitoringTrendResponse,
TrendRange,
TrendScopeType,
)
from app.monitoring.service import MonitoringService
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
MonitoringUser = Annotated[dict, Depends(require_permission("miners.view"))]
MonitoringServiceDependency = Annotated[
MonitoringService,
Depends(get_monitoring_service),
]
@router.get("/trends", response_model=MonitoringTrendResponse)
def get_monitoring_trends(
current_user: MonitoringUser,
service: MonitoringServiceDependency,
response: Response,
scope: Annotated[TrendScopeType, Query()] = "fleet",
scope_id: Annotated[UUID | None, Query()] = None,
time_range: Annotated[TrendRange, Query(alias="range")] = "24h",
) -> MonitoringTrendResponse:
try:
query = MonitoringTrendQuery(
scope=scope, scope_id=scope_id, range=time_range
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
) from exc
can_view_locations = user_has_permission(current_user, "locations.view")
if query.scope in {"site", "room"} and not can_view_locations:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Location viewing permission is required for this scope",
)
response.headers["Cache-Control"] = "no-store"
try:
return service.get_trends(
query,
include_location_scopes=can_view_locations,
)
except MonitoringScopeNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
+173
View File
@@ -0,0 +1,173 @@
"""Business semantics for bounded historical monitoring trends."""
from datetime import datetime, timedelta, timezone
from uuid import UUID
from app.monitoring.exceptions import MonitoringScopeNotFound
from app.monitoring.models import (
MonitoringAvailableScopes,
MonitoringRoomOption,
MonitoringScope,
MonitoringSiteOption,
MonitoringTrendData,
MonitoringTrendMeta,
MonitoringTrendPoint,
MonitoringTrendQuery,
MonitoringTrendResponse,
MonitoringTrendSeries,
)
from app.monitoring.repository import MonitoringRepository
RANGE_CONFIGURATION = {
"6h": (timedelta(hours=6), timedelta(minutes=5)),
"24h": (timedelta(hours=24), timedelta(minutes=15)),
"7d": (timedelta(days=7), timedelta(hours=1)),
}
MAXIMUM_POINTS = 200
class MonitoringService:
"""Coordinate factual telemetry aggregation without HTTP dependencies."""
def __init__(self, repository: MonitoringRepository):
self.repository = repository
@staticmethod
def _window(
query: MonitoringTrendQuery,
generated_at: datetime,
) -> tuple[datetime, datetime, timedelta]:
duration, bucket_width = RANGE_CONFIGURATION[query.time_range]
generated_at = generated_at.astimezone(timezone.utc)
bucket_seconds = int(bucket_width.total_seconds())
current_bucket_epoch = (
int(generated_at.timestamp()) // bucket_seconds * bucket_seconds
)
current_bucket_start = datetime.fromtimestamp(
current_bucket_epoch, tz=timezone.utc
)
window_end = current_bucket_start + bucket_width
return window_end - duration, window_end, bucket_width
def get_trends(
self,
query: MonitoringTrendQuery,
*,
include_location_scopes: bool,
generated_at: datetime | None = None,
) -> MonitoringTrendResponse:
generated_at = generated_at or datetime.now(timezone.utc)
window_start, window_end, bucket_width = self._window(query, generated_at)
rows = self.repository.read_trends(
scope=query.scope,
scope_id=query.scope_id,
window_start=window_start,
window_end=window_end,
bucket_width=bucket_width,
)
if not rows or not rows[0]["scope_exists"]:
raise MonitoringScopeNotFound(f"{query.scope.title()} not found")
options = MonitoringAvailableScopes()
query_count = 1
if include_location_scopes:
option_rows = self.repository.list_location_scope_options()
query_count += 1
options = MonitoringAvailableScopes(
sites=[
MonitoringSiteOption.model_validate(row)
for row in option_rows
if row["option_type"] == "site"
],
rooms=[
MonitoringRoomOption.model_validate(row)
for row in option_rows
if row["option_type"] == "room"
],
)
generated_at = generated_at.astimezone(timezone.utc)
hashrate_points = []
power_points = []
for row in rows:
shared = {
"bucket_start": row["bucket_start"],
"bucket_end": row["bucket_end"],
"observation_count": row["observation_count"],
"observed_miner_count": row["observed_miner_count"],
"scoped_miner_count": row["scoped_miner_count"],
"incomplete": row["bucket_end"] > generated_at,
}
hashrate_points.append(
MonitoringTrendPoint(
**shared,
value=(
float(row["hashrate_ths"])
if row["hashrate_ths"] is not None
else None
),
metric_sample_count=row["hashrate_sample_count"],
contributing_miner_count=(
row["hashrate_contributing_miner_count"]
),
)
)
power_points.append(
MonitoringTrendPoint(
**shared,
value=(
float(row["power_w"])
if row["power_w"] is not None
else None
),
metric_sample_count=row["power_sample_count"],
contributing_miner_count=(
row["power_contributing_miner_count"]
),
)
)
if len(rows) > MAXIMUM_POINTS:
raise RuntimeError("Monitoring query exceeded the fixed point bound")
attribution = (
"current_placement_cohort"
if query.scope in {"site", "room"}
else "miner_identity"
if query.scope == "miner"
else "fleet"
)
latest_observed_at = rows[0]["latest_observed_at"]
return MonitoringTrendResponse(
data=MonitoringTrendData(
scope=MonitoringScope(
type=query.scope,
id=query.scope_id,
label=rows[0]["scope_label"],
),
series=[
MonitoringTrendSeries(
metric="hashrate",
unit="TH/s",
label="Average recorded hashrate",
points=hashrate_points,
),
MonitoringTrendSeries(
metric="power",
unit="W",
label="Average observed power",
points=power_points,
),
],
available_scopes=options,
),
meta=MonitoringTrendMeta(
generated_at=generated_at,
latest_observed_at=latest_observed_at,
attribution=attribution,
range=query.time_range,
bucket_seconds=int(bucket_width.total_seconds()),
current_bucket_incomplete=window_end > generated_at,
database_query_count=query_count,
),
)
+61
View File
@@ -0,0 +1,61 @@
"""Repair workflow models, persistence, and business services."""
from app.repairs.exceptions import (
ActiveRepairConflict,
IncompatibleMinerLifecycle,
InconsistentRepairState,
RepairActiveMismatch,
RepairActorRequired,
RepairCompletionActorRequired,
RepairConflict,
RepairError,
RepairMinerNotFound,
RepairMinerNotInRepair,
RepairNotActive,
RepairNotFound,
RepairPersistenceError,
)
from app.repairs.models import (
BeginRepairRequest,
BeginRepairResult,
RepairActor,
RepairCompleteRequest,
RepairCompletedPayload,
RepairDetail,
RepairPostLifecycle,
RepairReason,
RepairStatus,
RepairTimeline,
RepairTimelineEvent,
)
from app.repairs.repository import RepairRepository
from app.repairs.service import RepairService
__all__ = [
"ActiveRepairConflict",
"BeginRepairRequest",
"BeginRepairResult",
"IncompatibleMinerLifecycle",
"InconsistentRepairState",
"RepairActiveMismatch",
"RepairActor",
"RepairActorRequired",
"RepairCompleteRequest",
"RepairCompletedPayload",
"RepairCompletionActorRequired",
"RepairConflict",
"RepairDetail",
"RepairError",
"RepairMinerNotFound",
"RepairMinerNotInRepair",
"RepairNotActive",
"RepairNotFound",
"RepairPersistenceError",
"RepairPostLifecycle",
"RepairReason",
"RepairRepository",
"RepairService",
"RepairStatus",
"RepairTimeline",
"RepairTimelineEvent",
]
+366
View File
@@ -0,0 +1,366 @@
"""Set-based PostgreSQL read model for the Repair Center."""
from collections.abc import Callable, Mapping
from typing import Any
import psycopg
from psycopg.rows import dict_row
from app.repairs.repository import default_connection_factory
ConnectionFactory = Callable[[], psycopg.Connection]
class RepairCenterRepository:
"""Read-only persistence boundary for consolidated repair operations."""
def __init__(self, connection_factory: ConnectionFactory | None = None):
self._connection_factory = connection_factory or default_connection_factory
@staticmethod
def _ctes(*, show_miners: bool, show_locations: bool) -> str:
miner_fields = (
"miner.asset_tag AS asset_id, COALESCE(miner.model, miner.miner_type) AS model, miner.miner_sn, miner.power_sn, "
"miner.current_mac, host(miner.current_ip) AS current_ip, "
"miner.status AS runtime_status"
if show_miners else
"NULL::text AS asset_id, NULL::text AS model, NULL::text AS miner_sn, NULL::text AS power_sn, "
"NULL::text AS current_mac, NULL::text AS current_ip, "
"NULL::text AS runtime_status"
)
location_fields = (
"placement.site_id, placement.site_name, placement.room_id, "
"placement.room_name, placement.rack_label, placement.shelf_label, "
"placement.position_id, placement.position_label, placement.position_index"
if show_locations else
"NULL::uuid AS site_id, NULL::text AS site_name, NULL::uuid AS room_id, "
"NULL::text AS room_name, NULL::text AS rack_label, "
"NULL::text AS shelf_label, NULL::uuid AS position_id, "
"NULL::text AS position_label, NULL::integer AS position_index"
)
return f"""
WITH latest_legacy AS (
SELECT DISTINCT ON (event.miner_id)
event.miner_id, event.event_status, event.event_title,
event.description, event.event_at, event.created_at
FROM miner_maintenance_events AS event
WHERE event.event_type = 'repair'
ORDER BY event.miner_id, event.event_at DESC,
event.created_at DESC, event.id DESC
),
legacy_first AS (
SELECT event.miner_id,
min(COALESCE(event.event_at, event.created_at)) AS opened_at
FROM miner_maintenance_events AS event
WHERE event.event_type = 'repair'
GROUP BY event.miner_id
),
legacy_counts AS (
SELECT event.miner_id,
count(*)::integer AS repair_event_count,
count(*) FILTER (WHERE event.event_status IN (
'open', 'diagnosing', 'waiting_parts', 'in_progress',
'testing', 'ready_to_shelf', 'needs_followup'
))::integer AS open_repair_event_count
FROM miner_maintenance_events AS event
WHERE event.event_type = 'repair'
GROUP BY event.miner_id
),
latest_modular AS (
SELECT DISTINCT ON (event.repair_id)
event.repair_id, event.payload, event.created_at
FROM repair_events AS event
ORDER BY event.repair_id, event.sequence DESC, event.id DESC
),
initial_note AS (
SELECT DISTINCT ON (event.repair_id)
event.repair_id, event.payload ->> 'note' AS note
FROM repair_events AS event
WHERE event.event_type = 'INITIAL_NOTE'
ORDER BY event.repair_id, event.sequence, event.id
),
part_rollup AS (
SELECT part.repair_id,
count(*) FILTER (WHERE part.voided_at IS NULL)::integer AS parts_count,
COALESCE((array_agg(
CASE WHEN part.consumable_description IS NOT NULL
THEN part.consumable_description
ELSE concat_ws(' ', part.component_type_snapshot,
part.component_asset_id_snapshot,
part.component_manufacturer_serial_snapshot)
END ORDER BY part.recorded_at, part.id
) FILTER (WHERE part.voided_at IS NULL))[1:3],
ARRAY[]::text[]) AS parts_summary
FROM repair_parts AS part GROUP BY part.repair_id
),
placement AS (
SELECT DISTINCT ON (position.miner_id)
position.miner_id, site.id AS site_id, site.name AS site_name,
room.id AS room_id, COALESCE(room.name, position.room_name) AS room_name,
position.rack_name AS rack_label, position.shelf_name AS shelf_label,
position.id AS position_id, position.position_label,
position.position_index
FROM location_positions AS position
LEFT JOIN rooms AS room ON room.id = position.room_id
LEFT JOIN sites AS site ON site.id = room.site_id
WHERE position.miner_id IS NOT NULL
ORDER BY position.miner_id, position.id
),
modular_rows AS (
SELECT repair.id AS repair_id, 'modular'::text AS source,
repair.status = ANY(ARRAY['OPEN', 'IN_PROGRESS',
'WAITING_FOR_PARTS', 'READY_FOR_TEST']) AS active,
repair.opened_at, repair.completed_at AS closed_at,
repair.status AS repair_status, repair.reason,
COALESCE(NULLIF(trim(miner.manual_details ->
'office_repair_import' ->> 'reported_error'), ''),
NULLIF(trim(initial_note.note), ''),
replace(repair.reason, '_', ' ')) AS reported_problem,
COALESCE(NULLIF(trim(latest_modular.payload ->> 'note'), ''),
NULLIF(trim(latest_modular.payload ->> 'notes'), ''),
NULLIF(trim(latest_modular.payload ->> 'completion_summary'), ''),
NULLIF(trim(initial_note.note), ''),
NULLIF(trim(latest_legacy.description), ''),
NULLIF(trim(location_state.state_notes), '')) AS latest_context,
COALESCE(latest_modular.created_at, latest_legacy.event_at,
latest_legacy.created_at, repair.opened_at) AS latest_context_at,
opened_actor.display_name AS opened_by_display_name,
miner.id AS miner_id, {miner_fields}, miner.lifecycle_status,
{location_fields},
COALESCE(part_rollup.parts_count, 0) AS parts_count,
COALESCE(part_rollup.parts_summary, ARRAY[]::text[]) AS parts_summary,
latest_legacy.event_status AS legacy_event_status,
location_state.asset_state AS legacy_asset_state,
latest_legacy.event_title AS latest_event_title,
COALESCE(legacy_counts.repair_event_count, 0) AS repair_event_count,
COALESCE(legacy_counts.open_repair_event_count, 0) AS open_repair_event_count,
miner.manual_details -> 'office_repair_import' ->> 'reported_hashrate_ths' AS reported_hashrate_ths,
miner.manual_details -> 'office_repair_import' ->> 'reported_voltage' AS reported_voltage,
miner.manual_details -> 'office_repair_import' ->> 'location_note' AS location_note,
miner.manual_details -> 'office_repair_import' ->> 'source' AS import_source,
location_state.updated_at AS state_updated_at
FROM repairs AS repair
JOIN miners AS miner ON miner.id = repair.miner_id
JOIN users AS opened_actor ON opened_actor.id = repair.opened_by
LEFT JOIN latest_modular ON latest_modular.repair_id = repair.id
LEFT JOIN initial_note ON initial_note.repair_id = repair.id
LEFT JOIN part_rollup ON part_rollup.repair_id = repair.id
LEFT JOIN latest_legacy ON latest_legacy.miner_id = miner.id
LEFT JOIN legacy_counts ON legacy_counts.miner_id = miner.id
LEFT JOIN miner_location_states AS location_state
ON location_state.miner_id = miner.id
LEFT JOIN placement ON placement.miner_id = miner.id
),
legacy_rows AS (
SELECT NULL::uuid AS repair_id, 'legacy'::text AS source,
true AS active, legacy_first.opened_at, NULL::timestamptz AS closed_at,
COALESCE(latest_legacy.event_status,
location_state.asset_state, 'unknown') AS repair_status,
NULL::text AS reason,
COALESCE(NULLIF(trim(miner.manual_details ->
'office_repair_import' ->> 'reported_error'), ''),
NULLIF(trim(latest_legacy.event_title), '')) AS reported_problem,
COALESCE(NULLIF(trim(latest_legacy.description), ''),
NULLIF(trim(location_state.state_notes), '')) AS latest_context,
COALESCE(latest_legacy.event_at, latest_legacy.created_at,
location_state.updated_at) AS latest_context_at,
NULL::text AS opened_by_display_name,
miner.id AS miner_id, {miner_fields}, miner.lifecycle_status,
{location_fields}, NULL::integer AS parts_count,
ARRAY[]::text[] AS parts_summary,
latest_legacy.event_status AS legacy_event_status,
location_state.asset_state AS legacy_asset_state,
latest_legacy.event_title AS latest_event_title,
COALESCE(legacy_counts.repair_event_count, 0) AS repair_event_count,
COALESCE(legacy_counts.open_repair_event_count, 0) AS open_repair_event_count,
miner.manual_details -> 'office_repair_import' ->> 'reported_hashrate_ths' AS reported_hashrate_ths,
miner.manual_details -> 'office_repair_import' ->> 'reported_voltage' AS reported_voltage,
miner.manual_details -> 'office_repair_import' ->> 'location_note' AS location_note,
miner.manual_details -> 'office_repair_import' ->> 'source' AS import_source,
location_state.updated_at AS state_updated_at
FROM miner_location_states AS location_state
JOIN miners AS miner ON miner.id = location_state.miner_id
LEFT JOIN latest_legacy ON latest_legacy.miner_id = miner.id
LEFT JOIN legacy_counts ON legacy_counts.miner_id = miner.id
LEFT JOIN legacy_first ON legacy_first.miner_id = miner.id
LEFT JOIN placement ON placement.miner_id = miner.id
WHERE location_state.asset_state = 'repair'
AND NOT EXISTS (
SELECT 1 FROM repairs AS active_repair
WHERE active_repair.miner_id = miner.id
AND active_repair.status = ANY(ARRAY['OPEN', 'IN_PROGRESS',
'WAITING_FOR_PARTS', 'READY_FOR_TEST'])
)
),
candidates AS (
SELECT * FROM modular_rows
UNION ALL
SELECT * FROM legacy_rows
)
"""
@staticmethod
def _where(filters: Mapping[str, Any]) -> tuple[str, list[Any]]:
conditions: list[str] = []
params: list[Any] = []
scope = filters.get("scope", "active")
if scope == "active":
conditions.append("candidate.active")
elif scope == "historical":
conditions.append("NOT candidate.active")
for key, column in (
("status", "repair_status"),
("lifecycle_status", "lifecycle_status"),
("site_id", "site_id"),
("room_id", "room_id"),
):
if filters.get(key):
conditions.append(f"candidate.{column} = %s")
params.append(filters[key])
if filters.get("model"):
conditions.append("lower(trim(candidate.model)) = lower(trim(%s))")
params.append(filters["model"])
if filters.get("query"):
conditions.append(
"concat_ws(' ', candidate.repair_id, candidate.miner_id, "
"candidate.asset_id, candidate.model, candidate.miner_sn, "
"candidate.current_mac, candidate.current_ip, "
"candidate.repair_status, candidate.reason, "
"candidate.reported_problem, candidate.latest_context) ILIKE %s"
)
params.append(f"%{filters['query']}%")
return " AND ".join(conditions) if conditions else "true", params
def list_repair_center(
self,
filters: Mapping[str, Any],
*,
include_miner_details: bool,
include_location_details: bool,
) -> dict[str, Any]:
ctes = self._ctes(
show_miners=include_miner_details,
show_locations=include_location_details,
)
where_sql, params = self._where(filters)
item_sql = f"""
{ctes}
SELECT candidate.* FROM candidates AS candidate
WHERE {where_sql}
ORDER BY candidate.active DESC,
CASE WHEN candidate.active THEN candidate.opened_at END ASC NULLS LAST,
candidate.opened_at DESC NULLS LAST,
candidate.repair_id NULLS LAST, candidate.miner_id
LIMIT %s OFFSET %s
"""
summary_sql = f"""
{ctes}, filtered AS (
SELECT * FROM candidates AS candidate WHERE {where_sql}
)
SELECT count(*)::integer AS total,
count(*) FILTER (WHERE active)::integer AS active,
count(*) FILTER (WHERE NOT active)::integer AS historical,
COALESCE((SELECT jsonb_object_agg(status_rows.repair_status,
status_rows.status_count) FROM (
SELECT repair_status, count(*)::integer AS status_count
FROM filtered GROUP BY repair_status
) AS status_rows), '{{}}'::jsonb) AS status_counts,
COALESCE((SELECT jsonb_object_agg(model_rows.model,
model_rows.model_count) FROM (
SELECT model, count(*)::integer AS model_count
FROM filtered WHERE model IS NOT NULL GROUP BY model
) AS model_rows), '{{}}'::jsonb) AS model_counts,
COALESCE((SELECT jsonb_object_agg(error_rows.error,
error_rows.error_count) FROM (
SELECT COALESCE(reported_problem, 'No reported error') AS error,
count(*)::integer AS error_count
FROM filtered
GROUP BY COALESCE(reported_problem, 'No reported error')
) AS error_rows), '{{}}'::jsonb) AS error_counts
FROM filtered
"""
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
item_sql,
[*params, int(filters.get("limit", 50)),
int(filters.get("offset", 0))],
)
items = [dict(row) for row in cur.fetchall()]
cur.execute(summary_sql, params)
summary = dict(cur.fetchone())
options = {"sites": [], "rooms": []}
if include_location_details:
cur.execute(
f"""
{ctes}
SELECT jsonb_build_object(
'sites', COALESCE((SELECT jsonb_agg(site_rows
ORDER BY lower(site_rows.name), site_rows.id)
FROM (SELECT DISTINCT site_id AS id, site_name AS name
FROM candidates WHERE site_id IS NOT NULL) site_rows),
'[]'::jsonb),
'rooms', COALESCE((SELECT jsonb_agg(room_rows
ORDER BY lower(room_rows.name), room_rows.id)
FROM (SELECT DISTINCT room_id AS id, site_id,
room_name AS name FROM candidates
WHERE room_id IS NOT NULL) room_rows), '[]'::jsonb)
) AS options
"""
)
options = cur.fetchone()["options"]
finally:
connection.close()
return {"items": items, "summary": summary, "location_options": options}
def list_miner_repairs(self, miner_id) -> list[dict[str, Any]]:
"""Return a bounded, exact repair history for one durable miner."""
ctes = self._ctes(show_miners=False, show_locations=False)
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
{ctes}
SELECT candidate.* FROM candidates AS candidate
WHERE candidate.miner_id = %s
ORDER BY candidate.active DESC,
candidate.opened_at DESC NULLS LAST,
candidate.repair_id NULLS LAST
LIMIT 50
""",
(miner_id,),
)
return [dict(row) for row in cur.fetchall()]
finally:
connection.close()
def summarize_active_repairs(self) -> dict[str, Any]:
"""Return exact active counts without loading Repair Center rows."""
ctes = self._ctes(show_miners=False, show_locations=False)
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
{ctes}, active_rows AS (
SELECT repair_status FROM candidates WHERE active
)
SELECT count(*)::integer AS active,
COALESCE((SELECT jsonb_object_agg(
status_rows.repair_status,
status_rows.status_count
) FROM (
SELECT repair_status,
count(*)::integer AS status_count
FROM active_rows
GROUP BY repair_status
) AS status_rows), '{{}}'::jsonb) AS status_counts
FROM active_rows
"""
)
return dict(cur.fetchone())
finally:
connection.close()
+124
View File
@@ -0,0 +1,124 @@
"""Application service for the consolidated Repair Center read model."""
from app.repairs.center_repository import RepairCenterRepository
from app.repairs.models import (
ActiveRepairSummary,
RepairCenterAccess,
RepairCenterCapabilities,
RepairCenterFilters,
RepairCenterItem,
RepairCenterList,
RepairCenterLocationOptions,
RepairCenterMeta,
RepairCenterResponse,
RepairCenterSummary,
)
class RepairCenterService:
"""Build typed, permission-aware Repair Center projections."""
def __init__(self, repository: RepairCenterRepository):
self.repository = repository
@staticmethod
def _capabilities(
item: RepairCenterItem,
access: RepairCenterAccess,
) -> RepairCenterCapabilities:
modular = item.source == "modular" and item.repair_id is not None
legacy = item.source == "legacy"
placed = bool(item.site_id and item.room_id and item.position_id)
return RepairCenterCapabilities(
can_open_miner=access.can_view_miners,
can_open_map=access.can_view_locations and placed,
can_read_modular_timeline=modular and access.can_read_repairs,
can_read_modular_parts=modular and access.can_read_repairs,
can_manage_modular_parts=(
modular and item.active and access.can_read_repairs
and access.can_manage_parts
),
can_complete_modular_repair=(
modular and item.active and access.can_complete_repairs
),
can_read_legacy_history=legacy and access.can_read_repairs,
can_manage_legacy=legacy and access.can_manage_legacy,
can_mark_legacy_repaired=legacy and access.can_manage_legacy,
can_decommission_legacy=legacy and access.can_manage_legacy,
)
def list_repair_center(
self,
filters: RepairCenterFilters,
access: RepairCenterAccess,
) -> RepairCenterResponse:
query = filters.query.strip() if filters.query else None
model = filters.model.strip() if filters.model else None
status = filters.status.strip() if filters.status else None
repository_filters = filters.model_dump()
repository_filters.update(query=query or None, model=model or None,
status=status or None)
projection = self.repository.list_repair_center(
repository_filters,
include_miner_details=access.can_view_miners,
include_location_details=access.can_view_locations,
)
items: list[RepairCenterItem] = []
for row in projection["items"]:
row = {
**row,
"miner_status": row.get("runtime_status"),
"latest_event_status": row.get("legacy_event_status"),
"latest_event_description": row.get("latest_context"),
"latest_event_at": row.get("latest_context_at"),
"asset_state": row.get("legacy_asset_state"),
"state_notes": row.get("latest_context"),
"reported_error": row.get("reported_problem"),
}
item = RepairCenterItem.model_validate(
{**row, "capabilities": RepairCenterCapabilities()}
)
item.capabilities = self._capabilities(item, access)
items.append(item)
summary = RepairCenterSummary.model_validate(projection["summary"])
location_options = RepairCenterLocationOptions.model_validate(
projection.get("location_options") or {}
)
return RepairCenterResponse(
data=RepairCenterList(items=items, summary=summary),
meta=RepairCenterMeta(
limit=filters.limit,
offset=filters.offset,
returned=len(items),
total=summary.total,
location_options=location_options,
),
)
def list_miner_repairs(
self, miner_id, access: RepairCenterAccess
) -> list[RepairCenterItem]:
items = []
for row in self.repository.list_miner_repairs(miner_id):
mapped = {
**row,
"miner_status": row.get("runtime_status"),
"latest_event_status": row.get("legacy_event_status"),
"latest_event_description": row.get("latest_context"),
"latest_event_at": row.get("latest_context_at"),
"asset_state": row.get("legacy_asset_state"),
"state_notes": row.get("latest_context"),
"reported_error": row.get("reported_problem"),
}
item = RepairCenterItem.model_validate(
{**mapped, "capabilities": RepairCenterCapabilities()}
)
item.capabilities = self._capabilities(item, access)
items.append(item)
return items
def summarize_active_repairs(self) -> ActiveRepairSummary:
return ActiveRepairSummary.model_validate(
self.repository.summarize_active_repairs()
)
+14
View File
@@ -0,0 +1,14 @@
"""Dependency construction for the Repairs domain."""
from app.repairs.repository import RepairRepository
from app.repairs.center_repository import RepairCenterRepository
from app.repairs.center_service import RepairCenterService
from app.repairs.service import RepairService
def get_repair_service() -> RepairService:
return RepairService(RepairRepository())
def get_repair_center_service() -> RepairCenterService:
return RepairCenterService(RepairCenterRepository())
+172
View File
@@ -0,0 +1,172 @@
"""Domain exceptions for repair workflows."""
class RepairError(Exception):
"""Base exception for repair operations."""
class RepairMinerNotFound(RepairError):
"""Raised when the requested miner does not exist."""
def __init__(self, miner_id: object):
self.miner_id = miner_id
super().__init__(f"Miner not found: {miner_id}")
class RepairNotFound(RepairError):
"""Raised when the requested repair does not exist."""
def __init__(self, repair_id: object):
self.repair_id = repair_id
super().__init__(f"Repair not found: {repair_id}")
class RepairConflict(RepairError):
"""Base exception for a conflicting repair state."""
class ActiveRepairConflict(RepairConflict):
"""Raised when a miner already has an active repair."""
def __init__(self, miner_id: object):
self.miner_id = miner_id
super().__init__(f"Miner already has an active repair: {miner_id}")
class IncompatibleMinerLifecycle(RepairConflict):
"""Raised when the miner lifecycle cannot enter repair."""
def __init__(self, miner_id: object, lifecycle_status: object):
self.miner_id = miner_id
self.lifecycle_status = lifecycle_status
super().__init__(
"Miner lifecycle is incompatible with beginning repair: "
f"{lifecycle_status}"
)
class InconsistentRepairState(RepairConflict):
"""Raised when lifecycle state and repair records disagree."""
def __init__(self, miner_id: object):
self.miner_id = miner_id
super().__init__(
"Miner is marked in repair but has no active repair record"
)
class RepairActorRequired(RepairError):
"""Raised when an operational workflow lacks an authenticated actor."""
def __init__(self):
super().__init__("An authenticated actor is required to begin repair")
class RepairCompletionActorRequired(RepairError):
"""Raised when Return to Service lacks an authenticated actor."""
def __init__(self):
super().__init__(
"An authenticated actor is required to return a miner to service"
)
class RepairNotActive(RepairConflict):
"""Raised when a repair is no longer active."""
def __init__(self, repair_id: object, repair_status: object):
self.repair_id = repair_id
self.repair_status = repair_status
super().__init__(
f"Repair is not active: {repair_id} ({repair_status})"
)
class RepairMinerNotInRepair(RepairConflict):
"""Raised when a repair's miner has already left repair lifecycle."""
def __init__(self, miner_id: object, lifecycle_status: object):
self.miner_id = miner_id
self.lifecycle_status = lifecycle_status
super().__init__(
"Miner is not in repair lifecycle: "
f"{miner_id} ({lifecycle_status})"
)
class RepairActiveMismatch(RepairConflict):
"""Raised when the requested repair is not the miner's active repair."""
def __init__(self, repair_id: object, miner_id: object):
self.repair_id = repair_id
self.miner_id = miner_id
super().__init__(
f"Repair is not the active repair for miner: {repair_id}"
)
class RepairPersistenceError(RepairError):
"""Raised when a required repair persistence operation fails."""
class ControlBoardReplacementActorRequired(RepairError):
"""Raised when a board replacement lacks an authenticated actor."""
def __init__(self):
super().__init__("An authenticated actor is required to replace a control board")
class ControlBoardReplacementConflict(RepairConflict):
"""Raised when current repair, component, or MAC state blocks replacement."""
class InvalidMacAddress(RepairError):
"""Raised when a replacement MAC is malformed or prohibited."""
def __init__(self, message: str = "New MAC address is invalid"):
super().__init__(message)
class MacOwnershipConflict(ControlBoardReplacementConflict):
"""Raised when a MAC belongs to another durable miner."""
def __init__(self, normalized_mac: str):
self.normalized_mac = normalized_mac
super().__init__(
"MAC address has current or historical ownership on another miner"
)
class RepairPartNotFound(RepairError):
"""Raised when a nested repair-part UUID does not exist for a repair."""
def __init__(self, repair_part_id: object):
self.repair_part_id = repair_part_id
super().__init__(f"Repair part not found: {repair_part_id}")
class RepairPartComponentNotFound(RepairError):
"""Raised when a tracked component UUID does not exist."""
def __init__(self, component_id: object):
self.component_id = component_id
super().__init__(f"Component not found: {component_id}")
class RepairPartValidationError(RepairError):
"""Raised when repair-part input violates a domain rule."""
class RepairPartActorRequired(RepairError):
"""Raised when a repair-part action lacks an authenticated actor."""
def __init__(self):
super().__init__("An authenticated actor is required for repair-part actions")
class RepairPartAlreadyVoided(RepairConflict):
"""Raised when an immutable repair-part entry was already voided."""
def __init__(self, repair_part_id: object):
self.repair_part_id = repair_part_id
super().__init__(f"Repair part is already voided: {repair_part_id}")
+429
View File
@@ -0,0 +1,429 @@
"""Typed contracts for the Repairs domain."""
from datetime import datetime
from decimal import Decimal
from typing import Any, Literal
from uuid import UUID
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator
from app.components.models import ComponentType
RepairReason = Literal[
"HARDWARE_FAILURE",
"LOW_HASHRATE",
"CONTROL_BOARD",
"HASHBOARD",
"PSU",
"FAN",
"OVERHEATING",
"NETWORKING",
"PREVENTIVE_MAINTENANCE",
"INSPECTION",
"OTHER",
]
RepairStatus = Literal[
"OPEN",
"IN_PROGRESS",
"WAITING_FOR_PARTS",
"READY_FOR_TEST",
"COMPLETED",
"CANCELLED",
]
RepairEventType = Literal[
"REPAIR_STARTED",
"INITIAL_NOTE",
"REPAIR_COMPLETED",
"CONTROL_BOARD_REPLACED",
"REPAIR_PART_RECORDED",
"REPAIR_PART_VOIDED",
]
RepairEligibleLifecycle = Literal["active", "spare"]
RepairPostLifecycle = Literal["active", "spare"]
RepairMinerLifecycle = Literal["active", "spare", "retired", "in_repair"]
class BeginRepairRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
miner_id: UUID
reason: RepairReason
notes: str | None = Field(default=None, max_length=4000)
class RepairCompleteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
target_lifecycle: RepairPostLifecycle
completion_summary: str | None = Field(default=None, max_length=4000)
ControlBoardReplacementReason = Literal[
"failed",
"upgrade",
"diagnostic_swap",
"preventive_maintenance",
"temporary_donor",
"unknown",
]
RemovedBoardDisposition = Literal[
"repair_inventory",
"sent_for_repair",
"scrapped",
"retained_for_diagnosis",
"returned_to_vendor",
"unknown",
]
class ControlBoardReplacementRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
new_mac_address: str = Field(min_length=1, max_length=64)
new_control_board_serial: str | None = Field(..., max_length=255)
board_model: str | None = Field(default=None, max_length=255)
board_revision: str | None = Field(default=None, max_length=255)
firmware_version: str | None = Field(default=None, max_length=255)
removed_board_disposition: RemovedBoardDisposition
replacement_reason: ControlBoardReplacementReason
notes: str | None = Field(default=None, max_length=4000)
class RepairStartedPayload(BaseModel):
reason: RepairReason
from_lifecycle: RepairEligibleLifecycle
to_lifecycle: Literal["in_repair"] = "in_repair"
class InitialRepairNotePayload(BaseModel):
note: str = Field(min_length=1, max_length=4000)
class RepairCompletedPayload(BaseModel):
previous_miner_lifecycle: Literal["in_repair"] = "in_repair"
resulting_miner_lifecycle: RepairPostLifecycle
pre_repair_lifecycle: RepairEligibleLifecycle | None = None
completion_summary: str | None = Field(default=None, max_length=4000)
class ControlBoardReplacedPayload(BaseModel):
old_mac_address: str | None
new_mac_address: str
old_control_board_serial: str | None
new_control_board_serial: str | None
old_board_model: str | None = None
new_board_model: str | None = None
old_board_revision: str | None = None
new_board_revision: str | None = None
new_firmware_version: str | None = None
removed_board_disposition: RemovedBoardDisposition
replacement_reason: ControlBoardReplacementReason
notes: str | None = Field(default=None, max_length=4000)
class ControlBoardReplacementResult(BaseModel):
miner_id: UUID
asset_id: str | None = None
repair_id: UUID
installation_id: UUID
event_id: UUID
event_sequence: int = Field(ge=1)
current_mac: str
control_board_serial: str | None
replaced_at: AwareDatetime
replaced_by: UUID
class ControlBoardReplacementResponse(BaseModel):
data: ControlBoardReplacementResult
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class BeginRepairResult(BaseModel):
repair_id: UUID
miner_id: UUID
status: Literal["OPEN"]
reason: RepairReason
opened_at: datetime
opened_by: UUID
class BeginRepairResponse(BaseModel):
data: BeginRepairResult
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class RepairActor(BaseModel):
user_id: UUID
display_name: str
class RepairDetail(BaseModel):
repair_id: UUID
miner_id: UUID
miner_asset_id: str | None = None
status: RepairStatus
reason: RepairReason
opened_at: AwareDatetime
opened_by: RepairActor
closed_at: AwareDatetime | None = None
closed_by: RepairActor | None = None
miner_lifecycle: RepairMinerLifecycle
latest_event_sequence: int | None = Field(default=None, ge=1)
class RepairTimelineEvent(BaseModel):
event_id: UUID
sequence: int = Field(ge=1)
event_type: RepairEventType
actor: RepairActor
occurred_at: AwareDatetime
payload: dict[str, Any]
class RepairTimeline(BaseModel):
repair_id: UUID
events: list[RepairTimelineEvent] = Field(default_factory=list)
class RepairDetailResponse(BaseModel):
data: RepairDetail
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class RepairTimelineResponse(BaseModel):
data: RepairTimeline
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
ConsumableUnit = Literal["each", "gram", "millilitre", "metre", "centimetre"]
RepairPartEntryKind = Literal["consumable", "tracked_component"]
class ConsumableInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
description: str = Field(min_length=1, max_length=255)
quantity: Decimal = Field(gt=0, max_digits=12, decimal_places=3)
unit: ConsumableUnit
@model_validator(mode="after")
def validate_each_quantity(self) -> "ConsumableInput":
if self.unit == "each" and self.quantity != self.quantity.to_integral_value():
raise ValueError("quantity for unit 'each' must be a whole number")
return self
class RepairPartRecordRequest(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
consumable: ConsumableInput | None = None
tracked_component_uuid: UUID | None = None
notes: str | None = Field(default=None, max_length=4000)
@model_validator(mode="after")
def validate_entry_shape(self) -> "RepairPartRecordRequest":
if (self.consumable is None) == (self.tracked_component_uuid is None):
raise ValueError(
"exactly one of consumable and tracked_component_uuid is required"
)
return self
class RepairPartVoidRequest(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
reason: str = Field(min_length=1, max_length=1000)
class RepairPartConsumable(BaseModel):
description: str
quantity: Decimal
unit: ConsumableUnit
class RepairPartComponentSnapshot(BaseModel):
component_type: ComponentType
asset_id: str | None = None
manufacturer: str | None = None
model: str | None = None
manufacturer_serial: str | None = None
class RepairPartDetail(BaseModel):
repair_part_id: UUID
repair_id: UUID
entry_kind: RepairPartEntryKind
consumable: RepairPartConsumable | None = None
component_id: UUID | None = None
component_snapshot: RepairPartComponentSnapshot | None = None
notes: str | None = None
recorded_by: UUID
recorded_at: AwareDatetime
recorded_event_id: UUID
voided_by: UUID | None = None
voided_at: AwareDatetime | None = None
void_reason: str | None = None
void_event_id: UUID | None = None
class RepairPartList(BaseModel):
repair_id: UUID
entries: list[RepairPartDetail] = Field(default_factory=list)
class RepairPartResponse(BaseModel):
data: RepairPartDetail
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class RepairPartListResponse(BaseModel):
data: RepairPartList
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
RepairCenterSource = Literal["modular", "legacy"]
RepairCenterScope = Literal["active", "historical", "all"]
class RepairCenterAccess(BaseModel):
can_read_repairs: bool = False
can_view_miners: bool = False
can_view_locations: bool = False
can_complete_repairs: bool = False
can_manage_parts: bool = False
can_manage_legacy: bool = False
class RepairCenterFilters(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
query: str | None = Field(default=None, max_length=255)
scope: RepairCenterScope = "active"
status: str | None = Field(default=None, max_length=64)
model: str | None = Field(default=None, max_length=255)
lifecycle_status: RepairMinerLifecycle | None = None
site_id: UUID | None = None
room_id: UUID | None = None
limit: int = Field(default=50, ge=1, le=250)
offset: int = Field(default=0, ge=0)
class RepairCenterCapabilities(BaseModel):
can_open_miner: bool = False
can_open_map: bool = False
can_read_modular_timeline: bool = False
can_read_modular_parts: bool = False
can_manage_modular_parts: bool = False
can_complete_modular_repair: bool = False
can_read_legacy_history: bool = False
can_manage_legacy: bool = False
can_mark_legacy_repaired: bool = False
can_decommission_legacy: bool = False
class RepairCenterItem(BaseModel):
repair_id: UUID | None = None
source: RepairCenterSource
active: bool
opened_at: AwareDatetime | None = None
closed_at: AwareDatetime | None = None
repair_status: str
reason: str | None = None
reported_problem: str | None = None
latest_context: str | None = None
latest_context_at: AwareDatetime | None = None
opened_by_display_name: str | None = None
miner_id: UUID
asset_id: str | None = None
model: str | None = None
miner_sn: str | None = None
power_sn: str | None = None
current_mac: str | None = None
current_ip: str | None = None
lifecycle_status: RepairMinerLifecycle
runtime_status: str | None = None
miner_status: str | None = None
site_id: UUID | None = None
site_name: str | None = None
room_id: UUID | None = None
room_name: str | None = None
rack_label: str | None = None
shelf_label: str | None = None
position_id: UUID | None = None
position_label: str | None = None
position_index: int | None = None
parts_count: int | None = None
parts_summary: list[str] = Field(default_factory=list)
legacy_event_status: str | None = None
legacy_asset_state: str | None = None
latest_event_status: str | None = None
latest_event_title: str | None = None
latest_event_description: str | None = None
latest_event_at: AwareDatetime | None = None
asset_state: str | None = None
state_notes: str | None = None
repair_event_count: int = 0
open_repair_event_count: int = 0
state_updated_at: AwareDatetime | None = None
reported_error: str | None = None
reported_hashrate_ths: str | None = None
reported_voltage: str | None = None
location_note: str | None = None
import_source: str | None = None
capabilities: RepairCenterCapabilities
class RepairCenterSummary(BaseModel):
total: int
active: int
historical: int
status_counts: dict[str, int] = Field(default_factory=dict)
model_counts: dict[str, int] = Field(default_factory=dict)
error_counts: dict[str, int] = Field(default_factory=dict)
class ActiveRepairSummary(BaseModel):
active: int
status_counts: dict[str, int] = Field(default_factory=dict)
class RepairCenterLocationOption(BaseModel):
id: UUID
name: str
class RepairCenterRoomOption(RepairCenterLocationOption):
site_id: UUID
class RepairCenterLocationOptions(BaseModel):
sites: list[RepairCenterLocationOption] = Field(default_factory=list)
rooms: list[RepairCenterRoomOption] = Field(default_factory=list)
class RepairCenterList(BaseModel):
items: list[RepairCenterItem]
summary: RepairCenterSummary
class RepairCenterMeta(BaseModel):
limit: int
offset: int
returned: int
total: int
location_options: RepairCenterLocationOptions = Field(
default_factory=RepairCenterLocationOptions
)
class RepairCenterResponse(BaseModel):
data: RepairCenterList
meta: RepairCenterMeta
error: None = None
+846
View File
@@ -0,0 +1,846 @@
"""PostgreSQL persistence for repair workflows."""
from collections.abc import Callable, Iterator, Mapping
from contextlib import contextmanager
from typing import Any
from uuid import UUID
import psycopg
from psycopg.errors import CheckViolation, LockNotAvailable, UniqueViolation
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.config import settings
from app.repairs.exceptions import (
ActiveRepairConflict,
ControlBoardReplacementConflict,
MacOwnershipConflict,
RepairPersistenceError,
)
ConnectionFactory = Callable[[], psycopg.Connection]
ACTIVE_REPAIR_STATUSES = (
"OPEN",
"IN_PROGRESS",
"WAITING_FOR_PARTS",
"READY_FOR_TEST",
)
ACTIVE_REPAIR_CONSTRAINT = "uq_repairs_one_active_per_miner"
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 RepairRepository:
"""Persistence boundary for explicit atomic repair workflows."""
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["RepairRepository"]:
if self._connection is not None:
yield self
return
connection = self._connection_factory()
transaction_repository = RepairRepository(
self._connection_factory,
connection=connection,
)
try:
yield transaction_repository
connection.commit()
except UniqueViolation as exc:
connection.rollback()
if exc.diag.constraint_name in {
"uq_miner_mac_ownerships_owner",
"uq_miner_network_identities_normalized_mac",
"uq_miners_current_normalized_mac",
"uq_control_board_installations_current_miner",
"uq_control_board_installations_current_mac",
}:
raise ControlBoardReplacementConflict(
"Concurrent control-board replacement or MAC ownership conflict"
) from exc
raise
except CheckViolation as exc:
connection.rollback()
if exc.diag.constraint_name == "ck_current_control_board_projection":
raise ControlBoardReplacementConflict(
"Control-board projection would contradict authoritative history"
) from exc
raise
except Exception:
connection.rollback()
raise
finally:
connection.close()
@contextmanager
def _connection_scope(
self,
*,
write: bool = False,
) -> Iterator[psycopg.Connection]:
if self._connection is not None:
yield self._connection
return
connection = self._connection_factory()
try:
yield connection
if write:
connection.commit()
except Exception:
if write:
connection.rollback()
raise
finally:
connection.close()
def lock_miner_for_update(self, miner_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, asset_tag, lifecycle_status, current_mac, control_board_sn,
firmware_version
FROM miners
WHERE id = %s
FOR UPDATE
""",
(miner_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def lock_miner_for_control_board_replacement(
self, miner_id: UUID
) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
try:
cur.execute(
"""
SELECT id, asset_tag, lifecycle_status, current_mac,
control_board_sn, firmware_version
FROM miners WHERE id = %s FOR UPDATE NOWAIT
""",
(miner_id,),
)
except LockNotAvailable as exc:
raise ControlBoardReplacementConflict(
"A control-board replacement is already in progress"
) from exc
row = cur.fetchone()
return dict(row) if row else None
def get_active_repair(self, miner_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
id,
miner_id,
status,
reason,
opened_at,
opened_by,
completed_at,
completed_by
FROM repairs
WHERE miner_id = %s
AND status = ANY(%s)
ORDER BY opened_at, id
LIMIT 1
""",
(miner_id, list(ACTIVE_REPAIR_STATUSES)),
)
row = cur.fetchone()
return dict(row) if row else None
def create_repair(
self,
miner_id: UUID,
reason: str,
opened_by: UUID,
) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
try:
cur.execute(
"""
INSERT INTO repairs (miner_id, reason, opened_by)
VALUES (%s, %s, %s)
RETURNING
id,
miner_id,
status,
reason,
opened_at,
opened_by,
completed_at,
completed_by
""",
(miner_id, reason, opened_by),
)
except UniqueViolation as exc:
if exc.diag.constraint_name == ACTIVE_REPAIR_CONSTRAINT:
raise ActiveRepairConflict(miner_id) from exc
raise
return dict(cur.fetchone())
def create_event(
self,
repair_id: UUID,
*,
sequence: int,
event_type: str,
created_by: UUID,
payload: Mapping[str, Any],
) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO repair_events (
repair_id,
sequence,
event_type,
created_by,
payload
)
VALUES (%s, %s, %s, %s, %s)
RETURNING
id,
repair_id,
sequence,
event_type,
created_at,
created_by,
payload
""",
(
repair_id,
sequence,
event_type,
created_by,
Jsonb(dict(payload)),
),
)
return dict(cur.fetchone())
def set_miner_lifecycle(
self,
miner_id: UUID,
lifecycle_status: str,
) -> None:
with self._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
UPDATE miners
SET lifecycle_status = %s,
updated_at = now()
WHERE id = %s
""",
(lifecycle_status, miner_id),
)
if cur.rowcount != 1:
raise RepairPersistenceError(
"Miner lifecycle transition did not update one record"
)
def get_repair_identity(self, repair_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, miner_id
FROM repairs
WHERE id = %s
""",
(repair_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def lock_repair_for_update(self, repair_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
id,
miner_id,
status,
reason,
opened_at,
opened_by,
completed_at,
completed_by,
completion_summary,
resulting_lifecycle
FROM repairs
WHERE id = %s
FOR UPDATE
""",
(repair_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def get_next_event_sequence(self, repair_id: UUID) -> int:
with self._connection_scope() as connection:
with connection.cursor() as cur:
cur.execute(
"""
SELECT COALESCE(max(sequence), 0) + 1
FROM repair_events
WHERE repair_id = %s
""",
(repair_id,),
)
return int(cur.fetchone()[0])
def get_pre_repair_lifecycle(self, repair_id: UUID) -> str | None:
with self._connection_scope() as connection:
with connection.cursor() as cur:
cur.execute(
"""
SELECT event.payload ->> 'from_lifecycle'
FROM repair_events AS event
WHERE event.repair_id = %s
AND event.event_type = 'REPAIR_STARTED'
ORDER BY event.sequence
LIMIT 2
""",
(repair_id,),
)
rows = cur.fetchall()
if len(rows) != 1:
return None
lifecycle_status = rows[0][0]
if lifecycle_status not in {"active", "spare"}:
return None
return str(lifecycle_status)
def complete_repair(
self,
repair_id: UUID,
*,
completed_by: UUID,
completion_summary: str | None,
resulting_lifecycle: str,
) -> dict | None:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
UPDATE repairs
SET status = 'COMPLETED',
completed_at = now(),
completed_by = %s,
completion_summary = %s,
resulting_lifecycle = %s
WHERE id = %s
AND status = ANY(%s)
RETURNING
id,
miner_id,
status,
reason,
opened_at,
opened_by,
completed_at,
completed_by,
completion_summary,
resulting_lifecycle
""",
(
completed_by,
completion_summary,
resulting_lifecycle,
repair_id,
list(ACTIVE_REPAIR_STATUSES),
),
)
row = cur.fetchone()
return dict(row) if row else None
def lock_active_repair_for_miner(self, miner_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, miner_id, status, reason, opened_at, opened_by
FROM repairs
WHERE miner_id = %s AND status = ANY(%s)
ORDER BY opened_at, id
LIMIT 1
FOR UPDATE
""",
(miner_id, list(ACTIVE_REPAIR_STATUSES)),
)
row = cur.fetchone()
return dict(row) if row else None
def lock_current_control_board(self, miner_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT * FROM miner_control_board_installations
WHERE miner_id = %s AND removed_at IS NULL
FOR UPDATE
""",
(miner_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def create_legacy_control_board(self, miner: Mapping[str, Any]) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
mac_address = miner.get("current_mac")
normalized_mac = (
"".join(ch for ch in str(mac_address).lower() if ch in "0123456789abcdef")
if mac_address else None
)
if normalized_mac and len(normalized_mac) == 12:
mac_address = ":".join(
normalized_mac[index:index + 2].upper()
for index in range(0, 12, 2)
)
elif normalized_mac:
normalized_mac = None
mac_address = None
cur.execute(
"""
INSERT INTO miner_control_board_installations (
miner_id, record_origin, mac_address, normalized_mac,
board_serial, firmware_version
)
VALUES (%s, 'legacy_projection', %s, %s, %s, %s)
RETURNING *
""",
(miner["id"], mac_address, normalized_mac,
miner.get("control_board_sn"), miner.get("firmware_version")),
)
return dict(cur.fetchone())
def claim_mac_ownership(
self, normalized_mac: str, miner_id: UUID, actor_user_id: UUID
) -> None:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))",
(normalized_mac,),
)
cur.execute(
"SELECT miner_id FROM miner_mac_ownerships "
"WHERE normalized_mac = %s FOR UPDATE",
(normalized_mac,),
)
row = cur.fetchone()
if row and row["miner_id"] != miner_id:
raise MacOwnershipConflict(normalized_mac)
if row is None:
cur.execute(
"""
INSERT INTO miner_mac_ownerships (
normalized_mac, miner_id, claimed_at, claimed_by
) VALUES (%s, %s, now(), %s)
""",
(normalized_mac, miner_id, actor_user_id),
)
def close_control_board(
self, installation_id: UUID, *, repair_id: UUID, actor_user_id: UUID,
disposition: str
) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
UPDATE miner_control_board_installations
SET removed_at = now(), removed_by = %s,
removed_repair_id = %s, removed_disposition = %s
WHERE id = %s AND removed_at IS NULL
RETURNING *
""",
(actor_user_id, repair_id, disposition, installation_id),
)
row = cur.fetchone()
if row is None:
raise RepairPersistenceError(
"Current control-board installation was not closed"
)
return dict(row)
def create_control_board_installation(
self, *, miner_id: UUID, repair_id: UUID, mac_address: str,
normalized_mac: str, board_serial: str | None, board_model: str | None,
board_revision: str | None, firmware_version: str | None,
replacement_reason: str, notes: str | None, actor_user_id: UUID
) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO miner_control_board_installations (
miner_id, record_origin, installed_repair_id, mac_address,
normalized_mac, board_serial, board_model, board_revision,
firmware_version, replacement_reason, installed_at,
installed_by, notes
) VALUES (
%s, 'replacement', %s, %s, %s, %s, %s, %s, %s, %s,
now(), %s, %s
) RETURNING *
""",
(miner_id, repair_id, mac_address, normalized_mac, board_serial,
board_model, board_revision, firmware_version,
replacement_reason, actor_user_id, notes),
)
return dict(cur.fetchone())
def set_current_control_board(
self, miner_id: UUID, *, mac_address: str, board_serial: str | None
) -> None:
with self._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
UPDATE miners SET current_mac = %s, control_board_sn = %s,
updated_at = now() WHERE id = %s
""",
(mac_address, board_serial, miner_id),
)
if cur.rowcount != 1:
raise RepairPersistenceError(
"Current miner control-board projection was not updated"
)
def ensure_network_identity_history(
self, miner_id: UUID, *, mac_address: str, normalized_mac: str
) -> None:
with self._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO miner_network_identities (
miner_id, mac_address, source, is_current,
first_seen_at, last_seen_at, associated_at
)
SELECT %s, %s, 'legacy_projection', false, NULL, NULL, now()
WHERE NOT EXISTS (
SELECT 1 FROM miner_network_identities
WHERE miner_id = %s
AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = %s
)
""",
(miner_id, mac_address, miner_id, normalized_mac),
)
def set_current_network_identity(
self, miner_id: UUID, *, mac_address: str, normalized_mac: str
) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"UPDATE miner_network_identities SET is_current = false, "
"updated_at = now() WHERE miner_id = %s AND is_current",
(miner_id,),
)
cur.execute(
"""
UPDATE miner_network_identities
SET is_current = true, associated_at = now(), updated_at = now()
WHERE miner_id = %s
AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = %s
RETURNING *
""",
(miner_id, normalized_mac),
)
row = cur.fetchone()
if row is None:
cur.execute(
"""
INSERT INTO miner_network_identities (
miner_id, mac_address, source, is_current,
first_seen_at, last_seen_at, associated_at
) VALUES (%s, %s, 'control_board_replacement', true,
NULL, NULL, now())
RETURNING *
""",
(miner_id, mac_address),
)
row = cur.fetchone()
return dict(row)
def lock_component_for_update(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,
component_type,
asset_id,
manufacturer,
model,
manufacturer_serial
FROM components
WHERE id = %s
FOR UPDATE
""",
(component_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def create_repair_part(
self,
*,
repair_part_id: UUID,
repair_id: UUID,
consumable_description: str | None,
quantity: object | None,
unit: str | None,
component: Mapping[str, Any] | None,
notes: str | None,
recorded_event: Mapping[str, Any],
) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO repair_parts (
id,
repair_id,
consumable_description,
quantity,
unit,
component_id,
component_type_snapshot,
component_asset_id_snapshot,
component_manufacturer_snapshot,
component_model_snapshot,
component_manufacturer_serial_snapshot,
notes,
recorded_by,
recorded_at,
recorded_event_id
)
VALUES (
%s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s
)
RETURNING *
""",
(
repair_part_id,
repair_id,
consumable_description,
quantity,
unit,
component.get("id") if component else None,
component.get("component_type") if component else None,
component.get("asset_id") if component else None,
component.get("manufacturer") if component else None,
component.get("model") if component else None,
component.get("manufacturer_serial") if component else None,
notes,
recorded_event["created_by"],
recorded_event["created_at"],
recorded_event["id"],
),
)
return dict(cur.fetchone())
def get_repair_part(
self,
repair_id: UUID,
repair_part_id: UUID,
) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT part.*, event.sequence AS recorded_event_sequence
FROM repair_parts AS part
JOIN repair_events AS event
ON event.id = part.recorded_event_id
WHERE part.repair_id = %s AND part.id = %s
""",
(repair_id, repair_part_id),
)
row = cur.fetchone()
return dict(row) if row else None
def get_repair_parts(self, repair_id: UUID) -> list[dict] | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
repair.id AS existing_repair_id,
part.*,
event.sequence AS recorded_event_sequence
FROM repairs AS repair
LEFT JOIN repair_parts AS part
ON part.repair_id = repair.id
LEFT JOIN repair_events AS event
ON event.id = part.recorded_event_id
WHERE repair.id = %s
ORDER BY event.sequence ASC, part.id ASC
""",
(repair_id,),
)
rows = [dict(row) for row in cur.fetchall()]
if not rows:
return None
return [row for row in rows if row.get("id") is not None]
def lock_repair_part_for_update(
self,
repair_id: UUID,
repair_part_id: UUID,
) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT *
FROM repair_parts
WHERE repair_id = %s AND id = %s
FOR UPDATE
""",
(repair_id, repair_part_id),
)
row = cur.fetchone()
return dict(row) if row else None
def void_repair_part(
self,
repair_id: UUID,
repair_part_id: UUID,
*,
reason: str,
void_event: Mapping[str, Any],
) -> dict | None:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
UPDATE repair_parts
SET voided_by = %s,
voided_at = %s,
void_reason = %s,
void_event_id = %s
WHERE repair_id = %s
AND id = %s
AND voided_at IS NULL
RETURNING *
""",
(
void_event["created_by"],
void_event["created_at"],
reason,
void_event["id"],
repair_id,
repair_part_id,
),
)
row = cur.fetchone()
return dict(row) if row else None
def get_repair_detail(self, repair_id: UUID) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
repair.id AS repair_id,
repair.miner_id,
miner.asset_tag AS miner_asset_id,
repair.status,
repair.reason,
repair.opened_at,
opened_actor.id AS opened_by_user_id,
opened_actor.display_name AS opened_by_display_name,
repair.completed_at AS closed_at,
closed_actor.id AS closed_by_user_id,
closed_actor.display_name AS closed_by_display_name,
miner.lifecycle_status AS miner_lifecycle,
(
SELECT max(event.sequence)
FROM repair_events AS event
WHERE event.repair_id = repair.id
) AS latest_event_sequence
FROM repairs AS repair
JOIN miners AS miner ON miner.id = repair.miner_id
JOIN users AS opened_actor
ON opened_actor.id = repair.opened_by
LEFT JOIN users AS closed_actor
ON closed_actor.id = repair.completed_by
WHERE repair.id = %s
""",
(repair_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def get_repair_events(
self,
repair_id: UUID,
) -> list[dict] | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
repair.id AS repair_id,
event.id AS event_id,
event.sequence,
event.event_type,
actor.id AS actor_user_id,
actor.display_name AS actor_display_name,
event.created_at AS occurred_at,
event.payload
FROM repairs AS repair
LEFT JOIN (
repair_events AS event
JOIN users AS actor
ON actor.id = event.created_by
) ON event.repair_id = repair.id
WHERE repair.id = %s
ORDER BY event.sequence ASC
""",
(repair_id,),
)
rows = [dict(row) for row in cur.fetchall()]
if not rows:
return None
return [row for row in rows if row["event_id"] is not None]
+300
View File
@@ -0,0 +1,300 @@
"""HTTP mapping for explicit repair workflows."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from app.identity import require_permission
from app.repairs.dependencies import get_repair_service
from app.repairs.exceptions import (
ControlBoardReplacementActorRequired,
InvalidMacAddress,
RepairActorRequired,
RepairCompletionActorRequired,
RepairConflict,
RepairMinerNotFound,
RepairNotFound,
RepairPartActorRequired,
RepairPartComponentNotFound,
RepairPartNotFound,
RepairPartValidationError,
)
from app.repairs.models import (
BeginRepairRequest,
ControlBoardReplacementRequest,
ControlBoardReplacementResponse,
BeginRepairResponse,
RepairCompleteRequest,
RepairDetailResponse,
RepairTimelineResponse,
RepairPartListResponse,
RepairPartRecordRequest,
RepairPartResponse,
RepairPartVoidRequest,
)
from app.repairs.service import RepairService
router = APIRouter(prefix="/api/repairs", tags=["repairs"])
control_board_router = APIRouter(prefix="/api/miners", tags=["miners"])
require_repair_create = require_permission("repairs.create")
require_repair_read = require_permission("repairs.read")
require_repair_complete = require_permission("repairs.complete")
require_repair_parts_manage = require_permission("repairs.parts.manage")
require_control_board_replace = require_permission("miners.replace_control_board")
RepairServiceDependency = Annotated[
RepairService,
Depends(get_repair_service),
]
RepairCreateUser = Annotated[dict, Depends(require_repair_create)]
RepairReadUser = Annotated[dict, Depends(require_repair_read)]
RepairCompleteUser = Annotated[dict, Depends(require_repair_complete)]
RepairPartsManageUser = Annotated[dict, Depends(require_repair_parts_manage)]
ControlBoardReplaceUser = Annotated[dict, Depends(require_control_board_replace)]
@control_board_router.post(
"/{miner_id}/replace-control-board",
response_model=ControlBoardReplacementResponse,
)
def replace_control_board(
miner_id: UUID,
request: ControlBoardReplacementRequest,
service: RepairServiceDependency,
current_user: ControlBoardReplaceUser,
) -> ControlBoardReplacementResponse:
try:
result = service.replace_control_board(
miner_id, request,
actor_user_id=UUID(str(current_user["id"])),
)
except RepairMinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Miner not found"
) from exc
except RepairConflict as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
except (InvalidMacAddress, ControlBoardReplacementActorRequired) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
) from exc
return ControlBoardReplacementResponse(data=result)
@router.post(
"",
response_model=BeginRepairResponse,
status_code=status.HTTP_201_CREATED,
)
def begin_repair(
request: BeginRepairRequest,
service: RepairServiceDependency,
current_user: RepairCreateUser,
) -> BeginRepairResponse:
try:
repair = service.begin_repair(
request,
actor_user_id=UUID(str(current_user["id"])),
)
except RepairMinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Miner not found",
) from exc
except RepairConflict as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except RepairActorRequired as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return BeginRepairResponse(data=repair)
@router.post(
"/{repair_id}/complete",
response_model=RepairDetailResponse,
)
def complete_repair(
repair_id: UUID,
request: RepairCompleteRequest,
service: RepairServiceDependency,
current_user: RepairCompleteUser,
) -> RepairDetailResponse:
try:
repair = service.complete_repair(
repair_id,
request,
actor_user_id=UUID(str(current_user["id"])),
)
except RepairNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Repair not found",
) from exc
except RepairMinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Repair miner no longer exists",
) from exc
except RepairConflict as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except RepairCompletionActorRequired as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return RepairDetailResponse(data=repair)
@router.get(
"/{repair_id}/parts",
response_model=RepairPartListResponse,
)
def list_repair_parts(
repair_id: UUID,
service: RepairServiceDependency,
current_user: RepairReadUser,
) -> RepairPartListResponse:
del current_user
try:
parts = service.list_repair_parts(repair_id)
except RepairNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Repair not found",
) from exc
return RepairPartListResponse(data=parts)
@router.post(
"/{repair_id}/parts",
response_model=RepairPartResponse,
status_code=status.HTTP_201_CREATED,
)
def record_repair_part(
repair_id: UUID,
request: RepairPartRecordRequest,
service: RepairServiceDependency,
current_user: RepairPartsManageUser,
) -> RepairPartResponse:
try:
part = service.record_repair_part(
repair_id,
request,
actor_user_id=UUID(str(current_user["id"])),
)
except (RepairNotFound, RepairPartComponentNotFound) as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
except RepairMinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Repair miner no longer exists",
) from exc
except RepairConflict as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except (RepairPartActorRequired, RepairPartValidationError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return RepairPartResponse(data=part)
@router.post(
"/{repair_id}/parts/{repair_part_id}/void",
response_model=RepairPartResponse,
)
def void_repair_part(
repair_id: UUID,
repair_part_id: UUID,
request: RepairPartVoidRequest,
service: RepairServiceDependency,
current_user: RepairPartsManageUser,
) -> RepairPartResponse:
try:
part = service.void_repair_part(
repair_id,
repair_part_id,
request,
actor_user_id=UUID(str(current_user["id"])),
)
except (RepairNotFound, RepairPartNotFound) as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
except RepairMinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Repair miner no longer exists",
) from exc
except RepairConflict as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except (RepairPartActorRequired, RepairPartValidationError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return RepairPartResponse(data=part)
@router.get(
"/{repair_id}",
response_model=RepairDetailResponse,
)
def get_repair(
repair_id: UUID,
service: RepairServiceDependency,
current_user: RepairReadUser,
) -> RepairDetailResponse:
del current_user
try:
repair = service.get_repair(repair_id)
except RepairNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Repair not found",
) from exc
return RepairDetailResponse(data=repair)
@router.get(
"/{repair_id}/events",
response_model=RepairTimelineResponse,
)
def get_repair_events(
repair_id: UUID,
service: RepairServiceDependency,
current_user: RepairReadUser,
) -> RepairTimelineResponse:
del current_user
try:
timeline = service.get_repair_timeline(repair_id)
except RepairNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Repair not found",
) from exc
return RepairTimelineResponse(data=timeline)
+684
View File
@@ -0,0 +1,684 @@
"""Business rules and orchestration for repair workflows."""
from decimal import Decimal
from uuid import UUID, uuid4
import re
from app.repairs.exceptions import (
ActiveRepairConflict,
ControlBoardReplacementActorRequired,
ControlBoardReplacementConflict,
InvalidMacAddress,
IncompatibleMinerLifecycle,
InconsistentRepairState,
RepairActiveMismatch,
RepairActorRequired,
RepairCompletionActorRequired,
RepairMinerNotFound,
RepairMinerNotInRepair,
RepairNotActive,
RepairNotFound,
RepairPersistenceError,
RepairPartActorRequired,
RepairPartAlreadyVoided,
RepairPartComponentNotFound,
RepairPartNotFound,
RepairPartValidationError,
)
from app.repairs.models import (
BeginRepairRequest,
ControlBoardReplacedPayload,
ControlBoardReplacementRequest,
ControlBoardReplacementResult,
BeginRepairResult,
InitialRepairNotePayload,
RepairActor,
RepairCompleteRequest,
RepairCompletedPayload,
RepairPartComponentSnapshot,
RepairPartConsumable,
RepairPartDetail,
RepairPartList,
RepairPartRecordRequest,
RepairPartVoidRequest,
RepairDetail,
RepairTimeline,
RepairTimelineEvent,
RepairStartedPayload,
)
from app.repairs.repository import RepairRepository
ELIGIBLE_LIFECYCLE_STATUSES = frozenset({"active", "spare"})
ACTIVE_REPAIR_STATUSES = frozenset(
{"OPEN", "IN_PROGRESS", "WAITING_FOR_PARTS", "READY_FOR_TEST"}
)
REPAIR_LIFECYCLE_STATUS = "in_repair"
class RepairService:
"""Explicit operational repair workflows independent of FastAPI."""
def __init__(self, repository: RepairRepository):
self.repository = repository
@staticmethod
def normalize_note(note: str | None) -> str | None:
if note is None:
return None
normalized = str(note).strip()
return normalized or None
@staticmethod
def _map_repair_detail(repair: dict) -> RepairDetail:
closed_by = None
if repair["closed_by_user_id"] is not None:
closed_by = RepairActor(
user_id=repair["closed_by_user_id"],
display_name=repair["closed_by_display_name"],
)
return RepairDetail(
repair_id=repair["repair_id"],
miner_id=repair["miner_id"],
miner_asset_id=repair["miner_asset_id"],
status=repair["status"],
reason=repair["reason"],
opened_at=repair["opened_at"],
opened_by=RepairActor(
user_id=repair["opened_by_user_id"],
display_name=repair["opened_by_display_name"],
),
closed_at=repair["closed_at"],
closed_by=closed_by,
miner_lifecycle=repair["miner_lifecycle"],
latest_event_sequence=repair["latest_event_sequence"],
)
def begin_repair(
self,
request: BeginRepairRequest,
*,
actor_user_id: UUID | None,
) -> BeginRepairResult:
if actor_user_id is None:
raise RepairActorRequired()
note = self.normalize_note(request.notes)
with self.repository.transaction() as repository:
miner = repository.lock_miner_for_update(request.miner_id)
if not miner:
raise RepairMinerNotFound(request.miner_id)
lifecycle_status = str(miner["lifecycle_status"])
active_repair = repository.get_active_repair(request.miner_id)
if active_repair:
raise ActiveRepairConflict(request.miner_id)
if lifecycle_status == REPAIR_LIFECYCLE_STATUS:
raise InconsistentRepairState(request.miner_id)
if lifecycle_status not in ELIGIBLE_LIFECYCLE_STATUSES:
raise IncompatibleMinerLifecycle(
request.miner_id,
lifecycle_status,
)
repair = repository.create_repair(
request.miner_id,
request.reason,
actor_user_id,
)
started_payload = RepairStartedPayload(
reason=request.reason,
from_lifecycle=lifecycle_status,
)
repository.create_event(
repair["id"],
sequence=1,
event_type="REPAIR_STARTED",
created_by=actor_user_id,
payload=started_payload.model_dump(mode="json"),
)
if note is not None:
note_payload = InitialRepairNotePayload(note=note)
repository.create_event(
repair["id"],
sequence=2,
event_type="INITIAL_NOTE",
created_by=actor_user_id,
payload=note_payload.model_dump(mode="json"),
)
repository.set_miner_lifecycle(
request.miner_id,
REPAIR_LIFECYCLE_STATUS,
)
return BeginRepairResult(
repair_id=repair["id"],
miner_id=repair["miner_id"],
status=repair["status"],
reason=repair["reason"],
opened_at=repair["opened_at"],
opened_by=repair["opened_by"],
)
@staticmethod
def normalize_optional_text(value: str | None) -> str | None:
if value is None:
return None
normalized = str(value).strip()
return normalized or None
@staticmethod
def normalize_mac_address(value: str) -> tuple[str, str]:
raw = str(value).strip()
if not raw or re.fullmatch(r"[0-9A-Fa-f:\-.\s]+", raw) is None:
raise InvalidMacAddress()
normalized = re.sub(r"[^0-9A-Fa-f]", "", raw).lower()
if len(normalized) != 12:
raise InvalidMacAddress()
first_octet = int(normalized[:2], 16)
if normalized == "000000000000":
raise InvalidMacAddress("All-zero MAC addresses are not allowed")
if normalized == "ffffffffffff":
raise InvalidMacAddress("Broadcast MAC addresses are not allowed")
if first_octet & 1:
raise InvalidMacAddress("Multicast MAC addresses are not allowed")
canonical = ":".join(
normalized[index:index + 2].upper()
for index in range(0, 12, 2)
)
return canonical, normalized
def replace_control_board(
self, miner_id: UUID, request: ControlBoardReplacementRequest, *,
actor_user_id: UUID | None
) -> ControlBoardReplacementResult:
if actor_user_id is None:
raise ControlBoardReplacementActorRequired()
new_mac, new_mac_normalized = self.normalize_mac_address(
request.new_mac_address
)
new_serial = self.normalize_optional_text(
request.new_control_board_serial
)
board_model = self.normalize_optional_text(request.board_model)
board_revision = self.normalize_optional_text(request.board_revision)
firmware_version = self.normalize_optional_text(request.firmware_version)
notes = self.normalize_note(request.notes)
with self.repository.transaction() as repository:
miner = repository.lock_miner_for_control_board_replacement(miner_id)
if miner is None:
raise RepairMinerNotFound(miner_id)
if str(miner["lifecycle_status"]) != REPAIR_LIFECYCLE_STATUS:
raise RepairMinerNotInRepair(
miner_id, miner["lifecycle_status"]
)
repair = repository.lock_active_repair_for_miner(miner_id)
if repair is None:
raise ControlBoardReplacementConflict(
"Miner has no active repair"
)
current_board = repository.lock_current_control_board(miner_id)
if current_board is None:
if not miner.get("current_mac") and not miner.get("control_board_sn"):
raise ControlBoardReplacementConflict(
"Miner has no current control-board identity to replace"
)
current_board = repository.create_legacy_control_board(miner)
old_mac = current_board.get("mac_address") or miner.get("current_mac")
old_mac_canonical = None
old_mac_normalized = None
if old_mac:
try:
old_mac_canonical, old_mac_normalized = (
self.normalize_mac_address(str(old_mac))
)
except InvalidMacAddress:
old_mac_canonical = str(old_mac)
if old_mac_normalized == new_mac_normalized:
raise ControlBoardReplacementConflict(
"New MAC address is the same as the current MAC address"
)
if old_mac_normalized is not None:
repository.ensure_network_identity_history(
miner_id, mac_address=old_mac_canonical,
normalized_mac=old_mac_normalized
)
repository.claim_mac_ownership(
new_mac_normalized, miner_id, actor_user_id
)
previous_board = repository.close_control_board(
current_board["id"],
repair_id=repair["id"],
actor_user_id=actor_user_id,
disposition=request.removed_board_disposition,
)
installation = repository.create_control_board_installation(
miner_id=miner_id,
repair_id=repair["id"],
mac_address=new_mac,
normalized_mac=new_mac_normalized,
board_serial=new_serial,
board_model=board_model,
board_revision=board_revision,
firmware_version=firmware_version,
replacement_reason=request.replacement_reason,
notes=notes,
actor_user_id=actor_user_id,
)
repository.set_current_network_identity(
miner_id, mac_address=new_mac,
normalized_mac=new_mac_normalized
)
repository.set_current_control_board(
miner_id, mac_address=new_mac, board_serial=new_serial
)
next_sequence = repository.get_next_event_sequence(repair["id"])
payload = ControlBoardReplacedPayload(
old_mac_address=old_mac_canonical,
new_mac_address=new_mac,
old_control_board_serial=previous_board.get("board_serial"),
new_control_board_serial=new_serial,
old_board_model=previous_board.get("board_model"),
new_board_model=board_model,
old_board_revision=previous_board.get("board_revision"),
new_board_revision=board_revision,
new_firmware_version=firmware_version,
removed_board_disposition=request.removed_board_disposition,
replacement_reason=request.replacement_reason,
notes=notes,
)
event = repository.create_event(
repair["id"], sequence=next_sequence,
event_type="CONTROL_BOARD_REPLACED",
created_by=actor_user_id,
payload=payload.model_dump(mode="json", exclude_none=False),
)
return ControlBoardReplacementResult(
miner_id=miner_id, asset_id=miner.get("asset_tag"),
repair_id=repair["id"], installation_id=installation["id"],
event_id=event["id"], event_sequence=event["sequence"],
current_mac=new_mac, control_board_serial=new_serial,
replaced_at=event["created_at"], replaced_by=actor_user_id,
)
def complete_repair(
self,
repair_id: UUID,
request: RepairCompleteRequest,
*,
actor_user_id: UUID | None,
) -> RepairDetail:
if actor_user_id is None:
raise RepairCompletionActorRequired()
completion_summary = self.normalize_note(request.completion_summary)
with self.repository.transaction() as repository:
repair_identity = repository.get_repair_identity(repair_id)
if repair_identity is None:
raise RepairNotFound(repair_id)
miner_id = repair_identity["miner_id"]
# Begin Repair also locks the miner first. Keeping this order makes
# competing lifecycle workflows serialize on the durable asset.
miner = repository.lock_miner_for_update(miner_id)
if miner is None:
raise RepairMinerNotFound(miner_id)
repair = repository.lock_repair_for_update(repair_id)
if repair is None:
raise RepairNotFound(repair_id)
if repair["miner_id"] != miner_id:
raise RepairActiveMismatch(repair_id, miner_id)
if repair["status"] not in ACTIVE_REPAIR_STATUSES:
raise RepairNotActive(repair_id, repair["status"])
active_repair = repository.get_active_repair(miner_id)
if active_repair is None or active_repair["id"] != repair_id:
raise RepairActiveMismatch(repair_id, miner_id)
lifecycle_status = str(miner["lifecycle_status"])
if lifecycle_status != REPAIR_LIFECYCLE_STATUS:
raise RepairMinerNotInRepair(miner_id, lifecycle_status)
next_sequence = repository.get_next_event_sequence(repair_id)
pre_repair_lifecycle = repository.get_pre_repair_lifecycle(
repair_id
)
completed = repository.complete_repair(
repair_id,
completed_by=actor_user_id,
completion_summary=completion_summary,
resulting_lifecycle=request.target_lifecycle,
)
if completed is None:
raise RepairNotActive(repair_id, repair["status"])
repository.set_miner_lifecycle(
miner_id,
request.target_lifecycle,
)
payload = RepairCompletedPayload(
resulting_miner_lifecycle=request.target_lifecycle,
pre_repair_lifecycle=pre_repair_lifecycle,
completion_summary=completion_summary,
)
repository.create_event(
repair_id,
sequence=next_sequence,
event_type="REPAIR_COMPLETED",
created_by=actor_user_id,
payload=payload.model_dump(mode="json", exclude_none=True),
)
detail = repository.get_repair_detail(repair_id)
if detail is None:
raise RepairPersistenceError(
"Completed repair could not be read in its transaction"
)
return self._map_repair_detail(detail)
@staticmethod
def _normalize_repair_part_text(
value: str | None,
*,
field_name: str,
max_length: int,
) -> str | None:
if value is None:
return None
normalized = str(value).strip()
if not normalized:
raise RepairPartValidationError(
f"{field_name} must not be blank when supplied"
)
if len(normalized) > max_length:
raise RepairPartValidationError(
f"{field_name} must not exceed {max_length} characters"
)
return normalized
@staticmethod
def _validate_consumable_quantity(quantity: Decimal, unit: str) -> Decimal:
normalized = Decimal(quantity)
if normalized <= 0:
raise RepairPartValidationError("quantity must be greater than zero")
significant = normalized.normalize()
fractional_places = max(-significant.as_tuple().exponent, 0)
if fractional_places > 3 or normalized >= Decimal("1000000000"):
raise RepairPartValidationError(
"quantity must fit NUMERIC(12,3)"
)
if unit == "each" and normalized != normalized.to_integral_value():
raise RepairPartValidationError(
"quantity for unit 'each' must be a whole number"
)
return normalized
@staticmethod
def _map_repair_part(part: dict) -> RepairPartDetail:
if part["consumable_description"] is not None:
consumable = RepairPartConsumable(
description=part["consumable_description"],
quantity=part["quantity"],
unit=part["unit"],
)
component_snapshot = None
entry_kind = "consumable"
else:
consumable = None
component_snapshot = RepairPartComponentSnapshot(
component_type=part["component_type_snapshot"],
asset_id=part["component_asset_id_snapshot"],
manufacturer=part["component_manufacturer_snapshot"],
model=part["component_model_snapshot"],
manufacturer_serial=(
part["component_manufacturer_serial_snapshot"]
),
)
entry_kind = "tracked_component"
return RepairPartDetail(
repair_part_id=part["id"],
repair_id=part["repair_id"],
entry_kind=entry_kind,
consumable=consumable,
component_id=part["component_id"],
component_snapshot=component_snapshot,
notes=part["notes"],
recorded_by=part["recorded_by"],
recorded_at=part["recorded_at"],
recorded_event_id=part["recorded_event_id"],
voided_by=part["voided_by"],
voided_at=part["voided_at"],
void_reason=part["void_reason"],
void_event_id=part["void_event_id"],
)
@staticmethod
def _lock_active_repair(
repository: RepairRepository,
repair_id: UUID,
) -> tuple[dict, dict]:
identity = repository.get_repair_identity(repair_id)
if identity is None:
raise RepairNotFound(repair_id)
miner_id = identity["miner_id"]
miner = repository.lock_miner_for_update(miner_id)
if miner is None:
raise RepairMinerNotFound(miner_id)
repair = repository.lock_repair_for_update(repair_id)
if repair is None:
raise RepairNotFound(repair_id)
if repair["miner_id"] != miner_id:
raise RepairActiveMismatch(repair_id, miner_id)
if repair["status"] not in ACTIVE_REPAIR_STATUSES:
raise RepairNotActive(repair_id, repair["status"])
active_repair = repository.get_active_repair(miner_id)
if active_repair is None or active_repair["id"] != repair_id:
raise RepairActiveMismatch(repair_id, miner_id)
lifecycle_status = str(miner["lifecycle_status"])
if lifecycle_status != REPAIR_LIFECYCLE_STATUS:
raise RepairMinerNotInRepair(miner_id, lifecycle_status)
return miner, repair
def record_repair_part(
self,
repair_id: UUID,
request: RepairPartRecordRequest,
*,
actor_user_id: UUID | None,
) -> RepairPartDetail:
if actor_user_id is None:
raise RepairPartActorRequired()
notes = self._normalize_repair_part_text(
request.notes,
field_name="notes",
max_length=4000,
)
description = None
quantity = None
unit = None
if request.consumable is not None:
description = self._normalize_repair_part_text(
request.consumable.description,
field_name="consumable.description",
max_length=255,
)
quantity = self._validate_consumable_quantity(
request.consumable.quantity,
request.consumable.unit,
)
unit = request.consumable.unit
with self.repository.transaction() as repository:
self._lock_active_repair(repository, repair_id)
component = None
if request.tracked_component_uuid is not None:
component = repository.lock_component_for_update(
request.tracked_component_uuid
)
if component is None:
raise RepairPartComponentNotFound(
request.tracked_component_uuid
)
repair_part_id = uuid4()
next_sequence = repository.get_next_event_sequence(repair_id)
if component is None:
payload = {
"repair_part_id": str(repair_part_id),
"entry_kind": "consumable",
"description": description,
"quantity": format(quantity, "f"),
"unit": unit,
"notes": notes,
}
else:
payload = {
"repair_part_id": str(repair_part_id),
"entry_kind": "tracked_component",
"component_id": str(component["id"]),
"component_type": component["component_type"],
"component_asset_id": component["asset_id"],
"component_manufacturer": component["manufacturer"],
"component_model": component["model"],
"component_manufacturer_serial": (
component["manufacturer_serial"]
),
"notes": notes,
}
event = repository.create_event(
repair_id,
sequence=next_sequence,
event_type="REPAIR_PART_RECORDED",
created_by=actor_user_id,
payload=payload,
)
repository.create_repair_part(
repair_part_id=repair_part_id,
repair_id=repair_id,
consumable_description=description,
quantity=quantity,
unit=unit,
component=component,
notes=notes,
recorded_event=event,
)
part = repository.get_repair_part(repair_id, repair_part_id)
if part is None:
raise RepairPersistenceError(
"Recorded repair part could not be read in its transaction"
)
return self._map_repair_part(part)
def list_repair_parts(self, repair_id: UUID) -> RepairPartList:
parts = self.repository.get_repair_parts(repair_id)
if parts is None:
raise RepairNotFound(repair_id)
return RepairPartList(
repair_id=repair_id,
entries=[self._map_repair_part(part) for part in parts],
)
def void_repair_part(
self,
repair_id: UUID,
repair_part_id: UUID,
request: RepairPartVoidRequest,
*,
actor_user_id: UUID | None,
) -> RepairPartDetail:
if actor_user_id is None:
raise RepairPartActorRequired()
reason = self._normalize_repair_part_text(
request.reason,
field_name="reason",
max_length=1000,
)
assert reason is not None
with self.repository.transaction() as repository:
self._lock_active_repair(repository, repair_id)
part = repository.lock_repair_part_for_update(
repair_id,
repair_part_id,
)
if part is None:
raise RepairPartNotFound(repair_part_id)
if part["voided_at"] is not None:
raise RepairPartAlreadyVoided(repair_part_id)
next_sequence = repository.get_next_event_sequence(repair_id)
event = repository.create_event(
repair_id,
sequence=next_sequence,
event_type="REPAIR_PART_VOIDED",
created_by=actor_user_id,
payload={
"repair_part_id": str(repair_part_id),
"reason": reason,
},
)
voided = repository.void_repair_part(
repair_id,
repair_part_id,
reason=reason,
void_event=event,
)
if voided is None:
raise RepairPartAlreadyVoided(repair_part_id)
part = repository.get_repair_part(repair_id, repair_part_id)
if part is None:
raise RepairPersistenceError(
"Voided repair part could not be read in its transaction"
)
return self._map_repair_part(part)
def get_repair(self, repair_id: UUID) -> RepairDetail:
repair = self.repository.get_repair_detail(repair_id)
if repair is None:
raise RepairNotFound(repair_id)
return self._map_repair_detail(repair)
def get_repair_timeline(self, repair_id: UUID) -> RepairTimeline:
events = self.repository.get_repair_events(repair_id)
if events is None:
raise RepairNotFound(repair_id)
return RepairTimeline(
repair_id=repair_id,
events=[
RepairTimelineEvent(
event_id=event["event_id"],
sequence=event["sequence"],
event_type=event["event_type"],
actor=RepairActor(
user_id=event["actor_user_id"],
display_name=event["actor_display_name"],
),
occurred_at=event["occurred_at"],
payload=event["payload"],
)
for event in events
],
)
@@ -0,0 +1,808 @@
import ast
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from unittest.mock import patch
import unittest
from contextlib import contextmanager
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID, uuid4
from psycopg.errors import CheckViolation, RaiseException, UniqueViolation
from psycopg.rows import dict_row
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from app.identity import get_current_user, required_api_permission
from app.repairs.dependencies import get_repair_service
from app.repairs.exceptions import (
ControlBoardReplacementConflict,
InvalidMacAddress,
MacOwnershipConflict,
RepairMinerNotFound,
RepairMinerNotInRepair,
)
from app.repairs.models import (
BeginRepairRequest,
ControlBoardReplacementRequest,
ControlBoardReplacementResult,
)
from app.repairs.repository import RepairRepository
from app.repairs.router import control_board_router
from app.repairs.service import RepairService
NOW = datetime(2026, 8, 2, 12, 0, tzinfo=timezone.utc)
def request(**overrides):
values = {
"new_mac_address": "02:AA:BB:CC:DD:02",
"new_control_board_serial": " CB-NEW ",
"board_model": " Model X ",
"board_revision": " Rev B ",
"firmware_version": " 1.2.3 ",
"removed_board_disposition": "sent_for_repair",
"replacement_reason": "failed",
"notes": " Board would not boot. ",
}
values.update(overrides)
return ControlBoardReplacementRequest(**values)
class FakeReplacementRepository:
def __init__(self):
self.miner_id = uuid4()
self.repair_id = uuid4()
self.actor_id = uuid4()
self.miner = {
"id": self.miner_id,
"asset_tag": "MINER-004",
"lifecycle_status": "in_repair",
"current_mac": "02:AA:BB:CC:DD:01",
"control_board_sn": "CB-OLD",
"firmware_version": "1.0.0",
}
self.repair = {"id": self.repair_id, "miner_id": self.miner_id}
self.installations = []
self.events = [{"sequence": 1}]
self.identities = []
self.calls = []
self.fail_at = None
self.owner = None
@contextmanager
def transaction(self):
self.calls.append("transaction")
snapshot = deepcopy(
(self.miner, self.installations, self.events, self.identities)
)
try:
yield self
except Exception:
self.miner, self.installations, self.events, self.identities = snapshot
raise
def _fail(self, name):
self.calls.append(name)
if self.fail_at == name:
raise RuntimeError(name)
def lock_miner_for_control_board_replacement(self, miner_id):
self._fail("lock_miner")
return self.miner if miner_id == self.miner_id else None
def lock_active_repair_for_miner(self, miner_id):
self._fail("lock_repair")
return self.repair if miner_id == self.miner_id else None
def lock_current_control_board(self, miner_id):
self._fail("lock_board")
current = [row for row in self.installations if row["removed_at"] is None]
return current[0] if current else None
def create_legacy_control_board(self, miner):
self._fail("create_legacy_board")
row = {
"id": uuid4(), "miner_id": miner["id"],
"mac_address": miner["current_mac"],
"board_serial": miner["control_board_sn"],
"board_model": None, "board_revision": None,
"removed_at": None,
}
self.installations.append(row)
return row
def ensure_network_identity_history(self, miner_id, **values):
self._fail("ensure_old_identity")
self.identities.append({"miner_id": miner_id, "is_current": False, **values})
def claim_mac_ownership(self, normalized_mac, miner_id, actor_user_id):
self._fail("claim_mac")
if self.owner is not None and self.owner != miner_id:
raise MacOwnershipConflict(normalized_mac)
self.owner = miner_id
def close_control_board(self, installation_id, **values):
self._fail("close_board")
row = next(row for row in self.installations if row["id"] == installation_id)
row.update(removed_at=NOW, **values)
return row
def create_control_board_installation(self, **values):
self._fail("create_board")
row = {"id": uuid4(), "removed_at": None, **values, "installed_at": NOW}
self.installations.append(row)
return row
def set_current_network_identity(self, miner_id, **values):
self._fail("set_identity")
self.identities.append({"miner_id": miner_id, **values})
return self.identities[-1]
def set_current_control_board(self, miner_id, *, mac_address, board_serial):
self._fail("set_projection")
self.miner.update(current_mac=mac_address, control_board_sn=board_serial)
def get_next_event_sequence(self, repair_id):
self._fail("next_sequence")
return max(event["sequence"] for event in self.events) + 1
def create_event(self, repair_id, **values):
self._fail("create_event")
row = {"id": uuid4(), "repair_id": repair_id, "created_at": NOW, **values}
self.events.append(row)
return row
class ControlBoardReplacementServiceTests(unittest.TestCase):
def setUp(self):
self.repository = FakeReplacementRepository()
self.service = RepairService(self.repository)
def replace(self, replacement=None):
return self.service.replace_control_board(
self.repository.miner_id,
replacement or request(),
actor_user_id=self.repository.actor_id,
)
def test_replacement_is_atomic_and_preserves_durable_identity(self):
miner_id = self.repository.miner["id"]
asset_id = self.repository.miner["asset_tag"]
result = self.replace()
self.assertEqual(result.miner_id, miner_id)
self.assertEqual(result.asset_id, asset_id)
self.assertEqual(result.current_mac, "02:AA:BB:CC:DD:02")
self.assertEqual(result.control_board_serial, "CB-NEW")
self.assertEqual(self.repository.miner["id"], miner_id)
self.assertEqual(self.repository.miner["asset_tag"], asset_id)
self.assertEqual(len([x for x in self.repository.installations if x["removed_at"] is None]), 1)
event = self.repository.events[-1]
self.assertEqual(event["event_type"], "CONTROL_BOARD_REPLACED")
self.assertEqual(event["sequence"], 2)
self.assertEqual(event["created_by"], self.repository.actor_id)
self.assertEqual(event["payload"]["old_mac_address"], "02:AA:BB:CC:DD:01")
self.assertEqual(event["payload"]["old_control_board_serial"], "CB-OLD")
self.assertEqual(event["payload"]["notes"], "Board would not boot.")
def test_mac_normalization_and_prohibited_values(self):
self.assertEqual(
self.service.normalize_mac_address("02-aA-bb-cc-dd-02"),
("02:AA:BB:CC:DD:02", "02aabbccdd02"),
)
for value in (
"bad", "00:00:00:00:00:00", "FF:FF:FF:FF:FF:FF",
"01:00:5E:00:00:01",
):
with self.subTest(value=value), self.assertRaises(InvalidMacAddress):
self.service.normalize_mac_address(value)
def test_conflicting_states_do_not_write(self):
cases = ("active", "spare", "retired")
for lifecycle in cases:
with self.subTest(lifecycle=lifecycle):
repository = FakeReplacementRepository()
repository.miner["lifecycle_status"] = lifecycle
with self.assertRaises(RepairMinerNotInRepair):
RepairService(repository).replace_control_board(
repository.miner_id, request(),
actor_user_id=repository.actor_id,
)
self.assertEqual(repository.events, [{"sequence": 1}])
def test_same_and_historically_owned_mac_conflict(self):
with self.assertRaises(ControlBoardReplacementConflict):
self.replace(request(new_mac_address="02:AA:BB:CC:DD:01"))
repository = FakeReplacementRepository()
repository.owner = uuid4()
with self.assertRaises(MacOwnershipConflict):
RepairService(repository).replace_control_board(
repository.miner_id, request(), actor_user_id=repository.actor_id
)
def test_missing_miner_and_active_repair_conflict(self):
with self.assertRaises(RepairMinerNotFound):
self.service.replace_control_board(
uuid4(), request(), actor_user_id=self.repository.actor_id
)
self.repository.repair = None
with self.assertRaises(ControlBoardReplacementConflict):
self.replace()
def test_each_meaningful_write_failure_rolls_back(self):
for step in (
"claim_mac", "close_board", "create_board", "set_identity",
"set_projection", "create_event",
):
with self.subTest(step=step):
repository = FakeReplacementRepository()
repository.fail_at = step
original = deepcopy(repository.miner)
with self.assertRaises(RuntimeError):
RepairService(repository).replace_control_board(
repository.miner_id, request(),
actor_user_id=repository.actor_id,
)
self.assertEqual(repository.miner, original)
self.assertEqual(repository.installations, [])
self.assertEqual(repository.events, [{"sequence": 1}])
class StubReplacementService:
def __init__(self):
self.error = None
self.result = None
self.request = None
self.actor = None
def replace_control_board(self, miner_id, request, *, actor_user_id):
self.request = request
self.actor = actor_user_id
if self.error:
raise self.error
return self.result
class ControlBoardReplacementRouterTests(unittest.TestCase):
def setUp(self):
self.service = StubReplacementService()
self.app = FastAPI()
self.app.include_router(control_board_router)
self.app.dependency_overrides[get_repair_service] = lambda: self.service
self.client = TestClient(self.app)
self.actor = uuid4()
self.miner = uuid4()
def tearDown(self):
self.client.close()
def authenticate(self, permissions=("miners.replace_control_board",), role="Technician"):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(self.actor), "role": role,
"permissions": list(permissions),
}
def post(self, body=None):
return self.client.post(
f"/api/miners/{self.miner}/replace-control-board",
json=(request().model_dump(mode="json") if body is None else body),
)
def test_authentication_and_permission_are_required(self):
self.assertEqual(self.post().status_code, 401)
self.authenticate(permissions=("miners.update",))
self.assertEqual(self.post().status_code, 403)
def test_success_uses_typed_envelope_and_authenticated_actor(self):
self.authenticate()
self.service.result = ControlBoardReplacementResult(
miner_id=self.miner, asset_id="MINER-004", repair_id=uuid4(),
installation_id=uuid4(), event_id=uuid4(), event_sequence=4,
current_mac="02:AA:BB:CC:DD:02", control_board_serial="CB-NEW",
replaced_at=NOW, replaced_by=self.actor,
)
response = self.post()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["miner_id"], str(self.miner))
self.assertEqual(response.json()["meta"], {})
self.assertIsNone(response.json()["error"])
self.assertEqual(self.service.actor, self.actor)
def test_validation_and_domain_error_mapping(self):
self.authenticate()
self.assertEqual(self.post({"new_mac_address": "bad"}).status_code, 422)
self.service.error = RepairMinerNotFound(self.miner)
self.assertEqual(self.post().status_code, 404)
self.service.error = ControlBoardReplacementConflict("conflict")
self.assertEqual(self.post().status_code, 409)
self.service.error = InvalidMacAddress()
self.assertEqual(self.post().status_code, 422)
def test_route_and_permission_mapping_are_exact(self):
routes = [
(route.path, route.methods) for route in control_board_router.routes
if hasattr(route, "methods")
]
self.assertEqual(routes, [
("/api/miners/{miner_id}/replace-control-board", {"POST"})
])
path = f"/api/miners/{self.miner}/replace-control-board"
self.assertEqual(
required_api_permission("POST", path),
"miners.replace_control_board",
)
def test_allowed_roles_and_read_only_matrix(self):
self.service.result = ControlBoardReplacementResult(
miner_id=self.miner, asset_id="MINER-004", repair_id=uuid4(),
installation_id=uuid4(), event_id=uuid4(), event_sequence=2,
current_mac="02:AA:BB:CC:DD:02", control_board_serial=None,
replaced_at=NOW, replaced_by=self.actor,
)
for role in ("Admin", "Manager", "Technician"):
with self.subTest(role=role):
self.authenticate(role=role)
self.assertEqual(self.post().status_code, 200)
self.authenticate(permissions=(), role="Read Only")
self.assertEqual(self.post().status_code, 403)
def test_request_contract_rejects_invalid_shapes(self):
self.authenticate()
valid = request().model_dump(mode="json")
invalid_bodies = []
missing_serial = dict(valid)
missing_serial.pop("new_control_board_serial")
invalid_bodies.append(missing_serial)
for field, value in (
("new_mac_address", ""),
("notes", "x" * 4001),
("replacement_reason", "invented"),
("removed_board_disposition", "invented"),
):
body = dict(valid)
body[field] = value
invalid_bodies.append(body)
unknown = dict(valid)
unknown["created_by"] = str(self.actor)
invalid_bodies.append(unknown)
for body in invalid_bodies:
with self.subTest(body=body):
self.assertEqual(self.post(body).status_code, 422)
def test_router_has_no_sql_driver_or_transaction_management(self):
path = Path(__file__).parents[1] / "app" / "repairs" / "router.py"
source = path.read_text(encoding="utf-8")
tree = ast.parse(source)
modules = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
modules.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
modules.append(node.module)
self.assertFalse(any(name.startswith(("psycopg", "sqlalchemy", "asyncpg")) for name in modules))
for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE ", ".COMMIT(", ".ROLLBACK("):
self.assertNotIn(keyword, source.upper())
class LegacyChangeMacCompatibilityTests(unittest.TestCase):
def setUp(self):
from app import main as main_module
self.main = main_module
self.service = StubReplacementService()
self.actor = uuid4()
self.miner = uuid4()
self.service.result = ControlBoardReplacementResult(
miner_id=self.miner, asset_id="MINER-004", repair_id=uuid4(),
installation_id=uuid4(), event_id=uuid4(), event_sequence=3,
current_mac="02:AA:BB:CC:DD:02", control_board_serial="CB-NEW",
replaced_at=NOW, replaced_by=self.actor,
)
def legacy_request(self, **overrides):
values = {
"new_mac": "02:AA:BB:CC:DD:02",
"component_serial": "CB-NEW",
"description": "Replaced during repair.",
"replacement_reason": "failed",
"removed_board_disposition": "sent_for_repair",
}
values.update(overrides)
return self.main.MinerMacChangeRequest(**values)
def call(self, legacy_request):
with patch.object(self.main, "RepairRepository"), patch.object(
self.main, "RepairService", return_value=self.service
):
return self.main.change_miner_mac_address(
str(self.miner), legacy_request, {"id": str(self.actor)}
)
def test_alias_uses_same_permission_and_domain_workflow(self):
path = f"/api/miners/{self.miner}/change-mac"
self.assertEqual(
required_api_permission("POST", path),
"miners.replace_control_board",
)
response = self.call(self.legacy_request())
self.assertEqual(self.service.actor, self.actor)
self.assertEqual(self.service.request.replacement_reason, "failed")
self.assertEqual(
self.service.request.removed_board_disposition, "sent_for_repair"
)
self.assertEqual(
response["data"]["miner"]["current_mac"],
"02:AA:BB:CC:DD:02",
)
self.assertIn("control_board_replacement", response["data"])
self.assertNotIn("maintenance_event", response["data"])
def test_alias_does_not_fabricate_required_workflow_fields(self):
for changes in (
{"replacement_reason": None},
{"removed_board_disposition": None},
{"new_ip": "192.0.2.4"},
{"downtime_minutes": 10},
):
with self.subTest(changes=changes), self.assertRaises(HTTPException) as caught:
self.call(self.legacy_request(**changes))
self.assertEqual(caught.exception.status_code, 422)
def test_alias_maps_domain_conflicts_without_internal_error(self):
self.service.error = ControlBoardReplacementConflict("Miner has no active repair")
with self.assertRaises(HTTPException) as caught:
self.call(self.legacy_request())
self.assertEqual(caught.exception.status_code, 409)
class ControlBoardReplacementDatabaseTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.repository = RepairRepository()
def setUp(self):
self.actor = uuid4()
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute("DROP TRIGGER IF EXISTS test_board_event_failure ON repair_events")
cur.execute("DROP FUNCTION IF EXISTS test_fail_board_event()")
cur.execute(
"TRUNCATE miner_control_board_installations, "
"miner_network_identities, miner_mac_ownerships"
)
cur.execute("ALTER TABLE repair_events DISABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repair_events")
cur.execute("ALTER TABLE repair_events ENABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repairs")
cur.execute("DELETE FROM miner_tags")
cur.execute("DELETE FROM miner_asset_id_history")
cur.execute("DELETE FROM miners")
cur.execute("DELETE FROM users WHERE username LIKE 'board-test-%'")
cur.execute(
"""
INSERT INTO users (
id, username, display_name, password_hash, role_id
)
SELECT %s, %s, 'Board Test Actor', 'unused', id
FROM roles WHERE name = 'Admin'
""",
(self.actor, f"board-test-{self.actor}"),
)
def tearDown(self):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute("DROP TRIGGER IF EXISTS test_board_event_failure ON repair_events")
cur.execute("DROP FUNCTION IF EXISTS test_fail_board_event()")
cur.execute(
"TRUNCATE miner_control_board_installations, "
"miner_network_identities, miner_mac_ownerships"
)
cur.execute("ALTER TABLE repair_events DISABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repair_events")
cur.execute("ALTER TABLE repair_events ENABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repairs")
cur.execute("DELETE FROM miner_tags")
cur.execute("DELETE FROM miner_asset_id_history")
cur.execute("DELETE FROM miners")
cur.execute("DELETE FROM users WHERE username LIKE 'board-test-%'")
def create_repairing_miner(self, mac_address, serial):
asset_id = f"BOARD-{uuid4()}"
with self.repository._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO miners (
asset_tag, current_mac, control_board_sn, status,
lifecycle_status, first_seen_at, last_seen_at
) VALUES (%s, %s, %s, 'unknown', 'active', NULL, NULL)
RETURNING id
""",
(asset_id, mac_address, serial),
)
miner_id = cur.fetchone()["id"]
repair = RepairService(self.repository).begin_repair(
BeginRepairRequest(miner_id=miner_id, reason="CONTROL_BOARD"),
actor_user_id=self.actor,
)
return miner_id, asset_id, repair.repair_id
def replace(self, miner_id, mac_address="02:AA:BB:CC:EE:02"):
return RepairService(self.repository).replace_control_board(
miner_id, request(new_mac_address=mac_address),
actor_user_id=self.actor,
)
def fetchall(self, sql, values=()):
with self.repository._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(sql, values)
return [dict(row) for row in cur.fetchall()]
def test_database_success_preserves_identity_and_authoritative_history(self):
miner_id, asset_id, repair_id = self.create_repairing_miner(
"02:AA:BB:CC:EE:01", "CB-OLD"
)
result = self.replace(miner_id)
miner = self.fetchall(
"SELECT * FROM miners WHERE id = %s", (miner_id,)
)[0]
boards = self.fetchall(
"SELECT * FROM miner_control_board_installations "
"WHERE miner_id = %s ORDER BY created_at, id", (miner_id,)
)
identities = self.fetchall(
"SELECT * FROM miner_network_identities "
"WHERE miner_id = %s ORDER BY is_current, mac_address", (miner_id,)
)
events = self.fetchall(
"SELECT * FROM repair_events WHERE repair_id = %s "
"AND event_type = 'CONTROL_BOARD_REPLACED'", (repair_id,)
)
self.assertEqual(result.miner_id, miner_id)
self.assertEqual(miner["asset_tag"], asset_id)
self.assertEqual(miner["current_mac"], "02:AA:BB:CC:EE:02")
self.assertEqual(len(boards), 2)
self.assertEqual(sum(row["removed_at"] is None for row in boards), 1)
legacy = next(row for row in boards if row["record_origin"] == "legacy_projection")
current = next(row for row in boards if row["removed_at"] is None)
self.assertIsNone(legacy["installed_at"])
self.assertIsNone(legacy["installed_by"])
self.assertEqual(current["record_origin"], "replacement")
self.assertEqual(current["installed_by"], self.actor)
self.assertEqual(current["normalized_mac"], "02aabbccee02")
self.assertEqual(len(identities), 2)
self.assertEqual(sum(row["is_current"] for row in identities), 1)
self.assertEqual(
next(row for row in identities if row["is_current"])["mac_address"],
"02:AA:BB:CC:EE:02",
)
self.assertTrue(any(row["mac_address"] == "02:AA:BB:CC:EE:01" for row in identities))
self.assertEqual(len(events), 1)
self.assertEqual(events[0]["created_by"], self.actor)
self.assertEqual(events[0]["sequence"], result.event_sequence)
def test_database_allows_omitted_optional_notes(self):
miner_id, _, repair_id = self.create_repairing_miner(
"02:AA:BB:CC:EA:01", "CB-OLD"
)
RepairService(self.repository).replace_control_board(
miner_id,
request(new_mac_address="02:AA:BB:CC:EA:02", notes=None),
actor_user_id=self.actor,
)
event = self.fetchall(
"SELECT payload FROM repair_events WHERE repair_id = %s "
"AND event_type = 'CONTROL_BOARD_REPLACED'",
(repair_id,),
)[0]
self.assertIn("notes", event["payload"])
self.assertIsNone(event["payload"]["notes"])
def test_database_constraints_prevent_drift_and_history_mutation(self):
miner_id, _, repair_id = self.create_repairing_miner(
"02:AA:BB:CC:ED:01", "CB-OLD"
)
self.replace(miner_id, "02:AA:BB:CC:ED:02")
with self.assertRaises(UniqueViolation):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO miner_control_board_installations (
miner_id, record_origin, board_serial
) VALUES (%s, 'legacy_projection', 'SECOND')
""",
(miner_id,),
)
with self.assertRaises(CheckViolation):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"UPDATE miners SET control_board_sn = 'DRIFT' "
"WHERE id = %s",
(miner_id,),
)
with self.assertRaises(CheckViolation):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"UPDATE miner_network_identities SET is_current = false "
"WHERE miner_id = %s AND is_current",
(miner_id,),
)
with self.assertRaises(RaiseException):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"DELETE FROM miner_network_identities "
"WHERE miner_id = %s",
(miner_id,),
)
with self.assertRaises(RaiseException):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"DELETE FROM miner_control_board_installations "
"WHERE miner_id = %s",
(miner_id,),
)
for statement in (
"UPDATE miner_mac_ownerships SET miner_id = gen_random_uuid() "
"WHERE normalized_mac = '02aabbcced02'",
"DELETE FROM miner_mac_ownerships "
"WHERE normalized_mac = '02aabbcced02'",
):
with self.assertRaises(RaiseException):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(statement)
with self.assertRaises(UniqueViolation):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO repair_events (
repair_id, sequence, event_type, created_by, payload
)
SELECT repair_id, sequence, event_type, created_by, payload
FROM repair_events
WHERE repair_id = %s ORDER BY sequence LIMIT 1
""",
(repair_id,),
)
def test_database_rejects_prohibited_component_mac(self):
miner_id, _, _ = self.create_repairing_miner(
"02:AA:BB:CC:EC:01", "CB-OLD"
)
for mac_address, normalized_mac in (
("01:00:5E:00:00:01", "01005e000001"),
("00:00:00:00:00:00", "000000000000"),
("FF:FF:FF:FF:FF:FF", "ffffffffffff"),
("02-AA-BB-CC-EC-02", "02aabbccec02"),
):
with self.subTest(mac_address=mac_address), self.assertRaises(CheckViolation):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO miner_control_board_installations (
miner_id, record_origin, mac_address, normalized_mac
) VALUES (%s, 'legacy_projection', %s, %s)
""",
(miner_id, mac_address, normalized_mac),
)
def test_event_failure_rolls_back_every_replacement_write(self):
miner_id, _, repair_id = self.create_repairing_miner(
"02:AA:BB:CC:EF:01", "CB-OLD"
)
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
CREATE FUNCTION test_fail_board_event() RETURNS trigger AS $$
BEGIN
IF NEW.event_type = 'CONTROL_BOARD_REPLACED' THEN
RAISE EXCEPTION 'forced replacement event failure';
END IF;
RETURN NEW;
END $$ LANGUAGE plpgsql
"""
)
cur.execute(
"CREATE TRIGGER test_board_event_failure BEFORE INSERT ON "
"repair_events FOR EACH ROW EXECUTE FUNCTION test_fail_board_event()"
)
with self.assertRaises(RaiseException):
self.replace(miner_id, "02:AA:BB:CC:EF:02")
miner = self.fetchall("SELECT * FROM miners WHERE id = %s", (miner_id,))[0]
self.assertEqual(miner["current_mac"], "02:AA:BB:CC:EF:01")
self.assertEqual(self.fetchall(
"SELECT * FROM miner_control_board_installations WHERE miner_id = %s",
(miner_id,),
), [])
self.assertEqual(self.fetchall(
"SELECT * FROM miner_network_identities WHERE miner_id = %s",
(miner_id,),
), [])
self.assertEqual(len(self.fetchall(
"SELECT * FROM repair_events WHERE repair_id = %s", (repair_id,)
)), 1)
owners = self.fetchall(
"SELECT normalized_mac FROM miner_mac_ownerships WHERE miner_id = %s",
(miner_id,),
)
self.assertEqual(owners, [{"normalized_mac": "02aabbccef01"}])
def test_concurrent_replacements_on_one_miner_succeed_once(self):
miner_id, _, repair_id = self.create_repairing_miner(
"02:AA:BB:CC:F0:01", "CB-OLD"
)
barrier = Barrier(2)
def replace(mac_address):
barrier.wait()
return self.replace(miner_id, mac_address)
outcomes = []
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(replace, "02:AA:BB:CC:F0:02"),
executor.submit(replace, "02:AA:BB:CC:F0:04"),
]
for future in futures:
try:
outcomes.append(future.result(timeout=15))
except ControlBoardReplacementConflict as exc:
outcomes.append(exc)
self.assertEqual(sum(not isinstance(item, Exception) for item in outcomes), 1)
self.assertEqual(sum(isinstance(item, Exception) for item in outcomes), 1)
self.assertEqual(len(self.fetchall(
"SELECT * FROM miner_control_board_installations "
"WHERE miner_id = %s AND removed_at IS NULL", (miner_id,)
)), 1)
self.assertEqual(len(self.fetchall(
"SELECT * FROM repair_events WHERE repair_id = %s "
"AND event_type = 'CONTROL_BOARD_REPLACED'", (repair_id,)
)), 1)
def test_two_miners_cannot_concurrently_claim_one_mac(self):
first, _, _ = self.create_repairing_miner("02:AA:BB:CC:F1:01", "CB-1")
second, _, _ = self.create_repairing_miner("02:AA:BB:CC:F1:02", "CB-2")
barrier = Barrier(2)
def replace(miner_id):
barrier.wait()
return self.replace(miner_id, "02:AA:BB:CC:F1:04")
outcomes = []
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [executor.submit(replace, first), executor.submit(replace, second)]
for future in futures:
try:
outcomes.append(future.result(timeout=15))
except ControlBoardReplacementConflict as exc:
outcomes.append(exc)
self.assertEqual(sum(not isinstance(item, Exception) for item in outcomes), 1)
self.assertEqual(sum(isinstance(item, Exception) for item in outcomes), 1)
owner = self.fetchall(
"SELECT miner_id FROM miner_mac_ownerships "
"WHERE normalized_mac = '02aabbccf104'"
)
self.assertEqual(len(owner), 1)
if __name__ == "__main__":
unittest.main()
+347
View File
@@ -0,0 +1,347 @@
import ast
import unittest
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
import psycopg
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.config import settings
from app.dashboard.dependencies import get_dashboard_service
from app.dashboard.models import (
DashboardAccess,
DashboardOverviewData,
DashboardOverviewResponse,
)
from app.dashboard.router import router
from app.dashboard.service import DashboardService
from app.identity import get_current_user
from app.inventory.models import (
InventoryFleetAttentionSummary,
InventoryFleetSummary,
)
from app.inventory.repository import InventoryRepository
from app.locations.models import LocationSummary
from app.locations.repository import LocationMapRepository
from app.repairs.center_repository import RepairCenterRepository
from app.repairs.models import ActiveRepairSummary
NOW = datetime(2026, 8, 11, 12, 0, tzinfo=timezone.utc)
APP_ROOT = Path(__file__).parents[1] / "app"
def fleet_summary() -> InventoryFleetSummary:
return InventoryFleetSummary(
total=12,
runtime_online=7,
runtime_offline=3,
runtime_unknown=2,
lifecycle_in_repair=2,
lifecycle_spare=1,
lifecycle_retired=1,
attention=InventoryFleetAttentionSummary(
offline=2,
unknown=1,
unstable=1,
repair=2,
),
)
class FakeInventoryService:
def __init__(self):
self.calls = 0
def summarize_fleet(self):
self.calls += 1
return fleet_summary()
class FakeRepairService:
def __init__(self):
self.calls = 0
def summarize_active_repairs(self):
self.calls += 1
return ActiveRepairSummary(
active=3,
status_counts={"OPEN": 2, "waiting_parts": 1},
)
class FakeLocationService:
def __init__(self):
self.calls = []
def summarize_locations(self, *, include_miner_counts):
self.calls.append(include_miner_counts)
return LocationSummary(
sites=2,
rooms=4,
positions=20,
occupied_positions=12,
empty_positions=8,
mapped_miners=12 if include_miner_counts else None,
unmapped_miners=0 if include_miner_counts else None,
)
class DashboardServiceTests(unittest.TestCase):
def setUp(self):
self.inventory = FakeInventoryService()
self.repairs = FakeRepairService()
self.locations = FakeLocationService()
self.service = DashboardService(
self.inventory,
self.repairs,
self.locations,
)
def test_composes_each_authorized_domain_once(self):
result = self.service.get_overview(
DashboardAccess(
can_view_miners=True,
can_view_repairs=True,
can_view_locations=True,
)
)
self.assertEqual(result.data.fleet.total, 12)
self.assertEqual(result.data.repairs.active, 3)
self.assertEqual(result.data.locations.mapped_miners, 12)
self.assertEqual(self.inventory.calls, 1)
self.assertEqual(self.repairs.calls, 1)
self.assertEqual(self.locations.calls, [True])
self.assertEqual(
result.meta.available_sections,
["fleet", "repairs", "locations"],
)
def test_unauthorized_domains_are_not_called_or_returned(self):
result = self.service.get_overview(DashboardAccess())
self.assertIsNone(result.data.fleet)
self.assertIsNone(result.data.repairs)
self.assertIsNone(result.data.locations)
self.assertEqual(result.meta.available_sections, [])
self.assertEqual(self.inventory.calls, 0)
self.assertEqual(self.repairs.calls, 0)
self.assertEqual(self.locations.calls, [])
def test_location_only_summary_omits_miner_placement_counts(self):
result = self.service.get_overview(
DashboardAccess(can_view_locations=True)
)
self.assertEqual(self.locations.calls, [False])
self.assertIsNone(result.data.locations.mapped_miners)
self.assertIsNone(result.data.locations.unmapped_miners)
self.assertIsNone(result.data.fleet)
class StubDashboardService:
def __init__(self):
self.access = None
def get_overview(self, access):
self.access = access
return DashboardOverviewResponse(
data=DashboardOverviewData(
fleet=fleet_summary() if access.can_view_miners else None,
repairs=(
ActiveRepairSummary(active=1, status_counts={"OPEN": 1})
if access.can_view_repairs else None
),
locations=(
LocationSummary(
sites=1,
rooms=1,
positions=2,
occupied_positions=1,
empty_positions=1,
mapped_miners=1 if access.can_view_miners else None,
unmapped_miners=11 if access.can_view_miners else None,
)
if access.can_view_locations else None
),
generated_at=NOW,
),
meta={
"available_sections": [
name
for name, allowed in (
("fleet", access.can_view_miners),
("repairs", access.can_view_repairs),
("locations", access.can_view_locations),
)
if allowed
]
},
)
class DashboardRouterTests(unittest.TestCase):
def setUp(self):
self.service = StubDashboardService()
self.app = FastAPI()
self.app.include_router(router)
self.app.dependency_overrides[get_dashboard_service] = lambda: self.service
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()),
"role": "Custom",
"permissions": list(permissions),
}
def test_requires_authentication_and_dashboard_permission(self):
self.assertEqual(self.client.get("/api/dashboard/overview").status_code, 401)
self.authenticate(("miners.view",))
self.assertEqual(self.client.get("/api/dashboard/overview").status_code, 403)
def test_returns_one_typed_permission_aware_envelope(self):
self.authenticate(("dashboard.view", "miners.view", "locations.view"))
response = self.client.get("/api/dashboard/overview")
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["data"]["fleet"]["total"], 12)
self.assertIsNone(payload["data"]["repairs"])
self.assertEqual(payload["data"]["locations"]["mapped_miners"], 1)
self.assertEqual(
payload["meta"]["available_sections"],
["fleet", "locations"],
)
self.assertEqual(response.headers["cache-control"], "no-store")
self.assertFalse(self.service.access.can_view_repairs)
def test_location_only_response_contains_no_miner_counts(self):
self.authenticate(("dashboard.view", "locations.view"))
payload = self.client.get("/api/dashboard/overview").json()
self.assertIsNone(payload["data"]["fleet"])
self.assertIsNone(payload["data"]["locations"]["mapped_miners"])
self.assertIsNone(payload["data"]["locations"]["unmapped_miners"])
class DashboardRepositoryIntegrationTests(unittest.TestCase):
def setUp(self):
self.connection = psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
)
def tearDown(self):
self.connection.rollback()
self.connection.close()
def test_fleet_counts_are_exact_unbounded_and_preserve_overlap(self):
repository = InventoryRepository(connection=self.connection)
before = repository.summarize_fleet()
rows = []
states = (
("offline", "in_repair"),
("online", "spare"),
("maintenance", "retired"),
("online", "active"),
("offline", "active"),
("unknown", "active"),
)
for runtime, lifecycle in states:
rows.append((uuid4(), runtime, lifecycle))
bulk = [(uuid4(), "online", "active") for _ in range(501)]
with self.connection.cursor() as cur:
cur.executemany(
"INSERT INTO miners (id, status, lifecycle_status, first_seen_at, last_seen_at) "
"VALUES (%s, %s, %s, NULL, NULL)",
[*rows, *bulk],
)
cur.execute(
"INSERT INTO miner_status_snapshots (miner_id, hash_stable) VALUES (%s, false)",
(rows[3][0],),
)
after = repository.summarize_fleet()
self.assertEqual(after["total"] - before["total"], 507)
self.assertEqual(after["runtime_online"] - before["runtime_online"], 503)
self.assertEqual(after["runtime_offline"] - before["runtime_offline"], 2)
self.assertEqual(after["runtime_unknown"] - before["runtime_unknown"], 2)
self.assertEqual(after["lifecycle_in_repair"] - before["lifecycle_in_repair"], 1)
self.assertEqual(after["lifecycle_spare"] - before["lifecycle_spare"], 1)
self.assertEqual(after["lifecycle_retired"] - before["lifecycle_retired"], 1)
self.assertEqual(after["attention_repair"] - before["attention_repair"], 1)
self.assertEqual(after["attention_offline"] - before["attention_offline"], 1)
self.assertEqual(after["attention_unknown"] - before["attention_unknown"], 1)
self.assertEqual(after["attention_unstable"] - before["attention_unstable"], 1)
def test_location_summary_uses_current_positions_not_virtual_map_subset(self):
repository = LocationMapRepository(connection=self.connection)
before = repository.summarize_locations(include_miner_counts=True)
site_id, room_id = uuid4(), uuid4()
mapped_id, unmapped_id = uuid4(), uuid4()
with self.connection.cursor() as cur:
cur.execute("INSERT INTO sites (id, name) VALUES (%s, %s)", (site_id, str(site_id)))
cur.execute(
"INSERT INTO rooms (id, site_id, name) VALUES (%s, %s, %s)",
(room_id, site_id, str(room_id)),
)
cur.executemany(
"INSERT INTO miners (id, lifecycle_status, first_seen_at, last_seen_at) "
"VALUES (%s, 'active', NULL, NULL)",
[(mapped_id,), (unmapped_id,)],
)
cur.execute(
"INSERT INTO location_positions (room_id, room_name, rack_name, shelf_name, "
"position_index, miner_id) VALUES (%s, 'Room', 'A', '1', 1, %s), "
"(%s, 'Room', 'A', '1', 2, NULL)",
(room_id, mapped_id, room_id),
)
after = repository.summarize_locations(include_miner_counts=True)
self.assertEqual(after["sites"] - before["sites"], 1)
self.assertEqual(after["rooms"] - before["rooms"], 1)
self.assertEqual(after["positions"] - before["positions"], 2)
self.assertEqual(after["occupied_positions"] - before["occupied_positions"], 1)
self.assertEqual(after["empty_positions"] - before["empty_positions"], 1)
self.assertEqual(after["mapped_miners"] - before["mapped_miners"], 1)
self.assertEqual(after["unmapped_miners"] - before["unmapped_miners"], 1)
def test_repair_summary_executes_without_loading_queue_items(self):
summary = RepairCenterRepository().summarize_active_repairs()
self.assertIn("active", summary)
self.assertIn("status_counts", summary)
class DashboardArchitectureTests(unittest.TestCase):
def test_router_and_service_have_no_sql_or_database_driver_imports(self):
for relative in ("dashboard/router.py", "dashboard/service.py"):
source = (APP_ROOT / relative).read_text(encoding="utf-8")
modules = []
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Import):
modules.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
modules.append(node.module)
self.assertFalse(
any(name.startswith(("psycopg", "sqlalchemy", "asyncpg")) for name in modules)
)
for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE "):
self.assertNotIn(keyword, source.upper())
def test_dashboard_frontend_has_one_read_only_request_and_safe_dom_writes(self):
source = (APP_ROOT / "frontend" / "static" / "dashboard.js").read_text(
encoding="utf-8"
)
self.assertEqual(source.count('fetch("/api/dashboard/overview"'), 1)
self.assertNotIn("innerHTML", source)
self.assertNotIn("setInterval", source)
self.assertNotIn('method: "POST"', source)
self.assertNotIn('method: "PATCH"', source)
self.assertNotIn('method: "DELETE"', source)
if __name__ == "__main__":
unittest.main()
+572
View File
@@ -0,0 +1,572 @@
import re
import unittest
from pathlib import Path
from unittest.mock import patch
from urllib.parse import parse_qs, urlsplit
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.frontend.router import router
from app.identity import (
get_current_user,
router as identity_router,
safe_next_path,
install_identity_middleware,
protected_page,
required_page_permission,
)
ROUTES = (
"/dashboard",
"/monitoring/map",
"/operations/repairs",
"/assets/miners",
)
STATIC_ROOT = Path(__file__).parents[1] / "app" / "frontend" / "static"
class FrontendShellTests(unittest.TestCase):
def setUp(self):
self.app = FastAPI()
self.app.include_router(router)
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions=None, role="Read Only"):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": "00000000-0000-0000-0000-000000000321",
"username": "operator",
"display_name": "Floor Operator",
"role": role,
"permissions": list(
permissions
or (
"dashboard.view",
"locations.view",
"repairs.view",
"miners.view",
)
),
}
def test_routes_require_authentication(self):
for path in ROUTES:
with self.subTest(path=path):
self.assertEqual(self.client.get(path).status_code, 401)
def test_all_direct_routes_render_authenticated_shell(self):
self.authenticate()
for path in ROUTES:
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
self.assertIn("MinerOps", response.text)
self.assertIn(
"Operational Intelligence for Mining",
response.text,
)
self.assertIn("Floor Operator", response.text)
self.assertIn(
'aria-label="Primary navigation"',
response.text,
)
def test_navigation_connects_all_internal_alpha_views(self):
self.authenticate()
response = self.client.get("/dashboard")
for path in ROUTES:
self.assertIn(f'href="{path}"', response.text)
self.assertIn('aria-current="page"', response.text)
def test_each_route_requires_its_specific_permission(self):
cases = (
("/dashboard", "dashboard.view"),
("/monitoring/map", "locations.view"),
("/operations/repairs", "repairs.view"),
("/assets/miners", "miners.view"),
)
for path, permission in cases:
with self.subTest(path=path):
self.authenticate(permissions=("unrelated.permission",))
self.assertEqual(self.client.get(path).status_code, 403)
self.authenticate(permissions=(permission,))
self.assertEqual(self.client.get(path).status_code, 200)
def test_main_authentication_gate_redirects_to_existing_login(self):
app = FastAPI()
app.include_router(router)
install_identity_middleware(app)
with TestClient(app) as client, patch(
"app.identity.request_user_or_none",
return_value=None,
):
for path in ROUTES:
with self.subTest(path=path):
response = client.get(path, follow_redirects=False)
self.assertEqual(response.status_code, 303)
self.assertTrue(
response.headers["location"].startswith(
"/login?next="
)
)
def test_page_permission_mapping_covers_new_routes(self):
expected = {
"/dashboard": "dashboard.view",
"/monitoring/map": "locations.view",
"/operations/repairs": "repairs.view",
"/assets/miners": "miners.view",
}
for path, permission in expected.items():
with self.subTest(path=path):
self.assertTrue(protected_page(path))
self.assertEqual(
required_page_permission(path),
permission,
)
def test_dashboard_does_not_fabricate_operational_values(self):
self.authenticate()
response = self.client.get("/dashboard")
for value in (
'id="dashboardFleetTotal"',
'id="dashboardActiveRepairs"',
'id="dashboardSites"',
"/assets/minerops-dashboard.js",
"Runtime and lifecycle are separate dimensions",
):
self.assertIn(value, response.text)
self.assertIn("Loading operational overview", response.text)
self.assertNotIn("Live health summary not yet connected", response.text)
self.assertNotIn("100 TH/s", response.text)
def test_dashboard_html_omits_unauthorized_sections_and_links(self):
self.authenticate(permissions=("dashboard.view", "locations.view"))
response = self.client.get("/dashboard")
self.assertIn("Locations", response.text)
self.assertIn("Open Mine Map", response.text)
self.assertNotIn("Fleet overview", response.text)
self.assertNotIn("Needs attention", response.text)
self.assertNotIn("Open Fleet", response.text)
self.assertNotIn("Active repair records", response.text)
self.assertNotIn("Open Repair Center", response.text)
self.assertNotIn("Mapped miners", response.text)
self.assertNotIn("Unmapped miners", response.text)
def test_dashboard_script_dom_contract_matches_authorized_page(self):
self.authenticate()
response = self.client.get("/dashboard")
javascript = (STATIC_ROOT / "dashboard.js").read_text(encoding="utf-8")
declared_ids = re.search(r"const ids = \[(.*?)\];", javascript, re.DOTALL)
self.assertIsNotNone(declared_ids)
required = set(re.findall(r'"([A-Za-z][A-Za-z0-9]+)"', declared_ids.group(1)))
rendered = set(re.findall(r'id="([A-Za-z][A-Za-z0-9]+)"', response.text))
self.assertEqual(required - rendered, set())
def test_map_is_a_structured_operational_view(self):
self.authenticate()
response = self.client.get("/monitoring/map")
for label in (
"Site",
"Room",
"Operational Status",
"Miner details",
"Find miner",
):
self.assertIn(label, response.text)
for value in (
'id="mapSite"', 'id="mapRoom"', 'id="mapLayer"',
'id="mapProblemsOnly"',
'id="mapViewport"', 'id="mapInspector"',
'/assets/minerops-mine-map.js',
):
self.assertIn(value, response.text)
self.assertNotIn("Live layout and telemetry data are not", response.text)
def test_map_script_dom_contract_matches_rendered_page(self):
self.authenticate()
response = self.client.get("/monitoring/map")
javascript = (STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8")
declared_ids = re.search(
r"const ids = \[(.*?)\];",
javascript,
re.DOTALL,
)
self.assertIsNotNone(declared_ids)
required = set(
re.findall(
r'"([A-Za-z][A-Za-z0-9]+)"',
declared_ids.group(1),
)
)
required.update(
re.findall(
r'document\.getElementById\("([^"]+)"\)',
javascript,
)
)
rendered = set(re.findall(r'\bid="([^"]+)"', response.text))
self.assertEqual(required - rendered, set())
def test_data_views_include_all_request_states(self):
self.authenticate()
for path in ("/operations/repairs", "/assets/miners"):
with self.subTest(path=path):
response_text = self.client.get(path).text
for state in (
"loading",
"empty",
"error",
"denied",
"ready",
):
self.assertIn(
f'data-view-state="{state}"',
response_text,
)
def test_frontend_assets_are_responsive_and_accessible(self):
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
javascript = (STATIC_ROOT / "app.js").read_text(
encoding="utf-8"
)
self.assertIn("@media (max-width: 560px)", css)
self.assertIn(":focus-visible", css)
self.assertIn(".skip-link", css)
repair_center = (STATIC_ROOT / "repair-center.js").read_text(
encoding="utf-8"
)
self.assertIn('response.status === 401', repair_center)
self.assertIn('response.status === 403', repair_center)
self.assertIn('credentials: "same-origin"', javascript)
def test_static_assets_are_served_without_an_api_route(self):
css_response = self.client.get("/assets/minerops-app.css")
script_response = self.client.get("/assets/minerops-app.js")
self.assertEqual(css_response.status_code, 200)
self.assertEqual(
css_response.headers["content-type"],
"text/css; charset=utf-8",
)
self.assertEqual(script_response.status_code, 200)
paths = {route.path for route in router.routes}
self.assertFalse(any(path.startswith("/api/") for path in paths))
def test_live_app_prefers_new_shell_and_preserves_legacy_pages(self):
from app.main import app
from app.frontend.router import dashboard_page
first_handlers = {}
for route in app.routes:
if route.path not in first_handlers:
first_handlers[route.path] = route.endpoint
for path in ROUTES:
with self.subTest(path=path):
self.assertEqual(
first_handlers[path].__module__,
"app.frontend.router",
)
for path in (
"/repairs",
"/locations",
"/miners/{miner_id}/detail",
):
with self.subTest(legacy_path=path):
self.assertIn(path, first_handlers)
dashboard_handlers = [
route.endpoint
for route in app.routes
if route.path == "/dashboard" and "GET" in (route.methods or set())
]
self.assertIs(dashboard_handlers[0], dashboard_page)
self.assertEqual(dashboard_handlers.count(dashboard_page), 1)
def test_live_dashboard_excludes_legacy_dashboard_injections(self):
from app.main import app
current_user = {
"id": "00000000-0000-0000-0000-000000000654",
"username": "dashboard-reader",
"display_name": "Dashboard Reader",
"role": "Read Only",
"permissions": [
"dashboard.view",
"miners.view",
"locations.view",
"repairs.view",
],
}
app.dependency_overrides[get_current_user] = lambda: current_user
try:
with patch(
"app.identity.request_user_or_none",
return_value=current_user,
):
client = TestClient(app)
try:
response = client.get("/dashboard")
finally:
client.close()
finally:
app.dependency_overrides.pop(get_current_user, None)
self.assertEqual(response.status_code, 200)
self.assertIn("<title>Dashboard | MinerOps</title>", response.text)
self.assertIn('id="dashboardFleetTotal"', response.text)
self.assertEqual(response.text.count('id="dashboardFleetTotal"'), 1)
self.assertNotIn("/assets/minerops-repair-quick-nav.js", response.text)
self.assertNotIn(
"/assets/minerops-dashboard-repair-summary.js",
response.text,
)
self.assertNotIn("mineropsDashboardRepairSummary", response.text)
self.assertNotIn("Repair Summary", response.text)
def test_safe_next_path_accepts_only_protected_local_pages(self):
valid = (
"/dashboard",
"/assets/miners?query=S19%20Pro",
"/miners/00000000-0000-0000-0000-000000000123/detail",
)
for value in valid:
with self.subTest(valid=value):
self.assertEqual(safe_next_path(value), value)
invalid = (
None,
"",
"https://example.invalid/dashboard",
"//example.invalid/dashboard",
r"\\example.invalid\dashboard",
"/../login",
"/assets/%2f%2fexample.invalid",
"/dashboard#fragment",
"/login",
"/api/inventory/miners",
" /dashboard",
"/dashboard\n",
)
for value in invalid:
with self.subTest(invalid=value):
self.assertEqual(safe_next_path(value), "/dashboard")
def test_login_redirect_preserves_direct_link_query(self):
app = FastAPI()
app.include_router(identity_router)
app.include_router(router)
install_identity_middleware(app)
with TestClient(app) as client, patch(
"app.identity.request_user_or_none",
return_value=None,
):
response = client.get(
"/assets/miners?query=S19%20Pro",
follow_redirects=False,
)
self.assertEqual(response.status_code, 303)
location = urlsplit(response.headers["location"])
self.assertEqual(location.path, "/login")
self.assertEqual(
parse_qs(location.query)["next"],
["/assets/miners?query=S19%20Pro"],
)
def test_authenticated_login_redirect_rejects_unsafe_next(self):
app = FastAPI()
app.include_router(identity_router)
install_identity_middleware(app)
with TestClient(app) as client, patch(
"app.identity.request_user_or_none",
return_value={"id": "user"},
):
for value in (
"https://example.invalid",
"//example.invalid",
r"\\example.invalid\dashboard",
"/../login",
):
with self.subTest(value=value):
response = client.get(
"/login",
params={"next": value},
follow_redirects=False,
)
self.assertEqual(response.status_code, 303)
self.assertEqual(
response.headers["location"],
"/dashboard",
)
def test_login_template_escapes_safe_query_destination(self):
app = FastAPI()
app.include_router(identity_router)
hostile = '/assets/miners?query="><script>alert(1)</script>'
with TestClient(app) as client:
response = client.get("/login", params={"next": hostile})
self.assertEqual(response.status_code, 200)
self.assertNotIn(hostile, response.text)
self.assertIn("&quot;&gt;&lt;script&gt;", response.text)
self.assertNotIn("new URLSearchParams(location.search)", response.text)
def test_user_values_are_html_escaped(self):
hostile = '<img src=x onerror=alert(1)>'
self.app.dependency_overrides[get_current_user] = lambda: {
"id": "00000000-0000-0000-0000-000000000321",
"username": "operator",
"display_name": hostile,
"role": "Read Only",
"permissions": ["dashboard.view", "miners.view"],
}
response = self.client.get("/dashboard")
self.assertEqual(response.status_code, 200)
self.assertNotIn(hostile, response.text)
self.assertIn("&lt;img src=x onerror=alert(1)&gt;", response.text)
def test_navigation_and_search_are_permission_aware(self):
self.authenticate(permissions=("dashboard.view",))
response = self.client.get("/dashboard")
self.assertEqual(response.status_code, 200)
self.assertIn('href="/dashboard"', response.text)
for path in ROUTES[1:]:
self.assertNotIn(f'href="{path}"', response.text)
self.assertNotIn('id="globalSearchForm"', response.text)
self.assertIn('data-can-view-miners="false"', response.text)
def test_assets_disable_caching_and_missing_assets_are_404(self):
for path in (
"/assets/minerops-app.css",
"/assets/minerops-app.js",
"/assets/minerops-dashboard.js",
"/assets/minerops-mine-map.js",
"/assets/minerops-fleet.js",
):
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["cache-control"], "no-store")
self.assertEqual(
self.client.get("/assets/missing-minerops-file.js").status_code,
404,
)
def test_trailing_slash_redirects_are_consistent(self):
self.authenticate()
for path in ROUTES:
with self.subTest(path=path):
response = self.client.get(
path + "/",
follow_redirects=False,
)
self.assertEqual(response.status_code, 307)
self.assertEqual(
urlsplit(response.headers["location"]).path,
path,
)
def test_shell_markup_has_accessibility_landmarks(self):
self.authenticate()
for path in ROUTES:
with self.subTest(path=path):
text = self.client.get(path).text
self.assertEqual(text.count("<h1>"), 1)
self.assertIn('class="skip-link"', text)
self.assertIn('<main class="main-content"', text)
self.assertIn('aria-label="Primary navigation"', text)
self.assertIn('aria-controls="primaryNav"', text)
def test_javascript_uses_safe_dom_and_strict_failure_states(self):
javascript = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
repair_center = (STATIC_ROOT / "repair-center.js").read_text(
encoding="utf-8"
)
for source in (javascript, repair_center):
for unsafe in ("innerHTML", "insertAdjacentHTML", "document.write"):
self.assertNotIn(unsafe, source)
self.assertIn("window.location.replace", source)
self.assertNotIn("/api/repairs/queue", javascript)
self.assertIn("new AbortController()", repair_center)
self.assertIn("cell.textContent = text(content)", repair_center)
self.assertIn("encodeURIComponent(item.miner_id)", repair_center)
self.assertIn("invalid Repair Center response", repair_center)
def test_fleet_page_is_semantic_responsive_and_permission_aware(self):
self.authenticate()
response = self.client.get("/assets/miners")
self.assertEqual(response.status_code, 200)
for value in (
'id="fleetFilters"', 'id="fleetRows"', 'id="fleetResultSummary"',
'<table class="fleet-table">', '<th>Operational</th>',
'id="fleetPrevious"', 'id="fleetNext"',
'/assets/minerops-fleet.js', 'data-can-view-locations="true"',
'id="fleetSite"', 'id="fleetRoom"',
):
self.assertIn(value, response.text)
self.authenticate(permissions=("miners.view",))
restricted = self.client.get("/assets/miners").text
self.assertIn('data-can-view-locations="false"', restricted)
self.assertNotIn('id="fleetSite"', restricted)
self.assertNotIn('id="fleetRoom"', restricted)
def test_fleet_reset_action_has_a_visible_label(self):
self.authenticate()
response = self.client.get("/assets/miners")
self.assertIn(
'<button class="quiet-button" id="fleetReset" '
'type="button">Reset</button>',
response.text,
)
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
self.assertIn(
".fleet-filter-actions .quiet-button { color: var(--ink); }",
css,
)
def test_fleet_script_dom_contract_matches_authorized_page(self):
self.authenticate()
response = self.client.get("/assets/miners")
source = (STATIC_ROOT / "fleet.js").read_text(encoding="utf-8")
declared = re.search(r"const ids = \[(.*?)\];", source, re.DOTALL)
self.assertIsNotNone(declared)
required = set(re.findall(r'"([A-Za-z][A-Za-z0-9]+)"', declared.group(1)))
required.update(("fleetSite", "fleetRoom"))
rendered = set(re.findall(r'\bid="([^"]+)"', response.text))
self.assertEqual(required - rendered, set())
def test_fleet_script_is_bounded_read_only_and_xss_safe(self):
source = (STATIC_ROOT / "fleet.js").read_text(encoding="utf-8")
self.assertEqual(source.count('fetch("/api/inventory/miners?"'), 1)
self.assertNotIn("/api/location/map", source)
self.assertNotIn("setInterval", source)
for method in ('method: "POST"', 'method: "PATCH"', 'method: "PUT"', 'method: "DELETE"'):
self.assertNotIn(method, source)
for unsafe in ("innerHTML", "insertAdjacentHTML", "document.write"):
self.assertNotIn(unsafe, source)
self.assertIn("textContent", source)
self.assertIn('"/miners/" + encodeURIComponent(miner.id) + "/detail"', source)
self.assertIn('"/monitoring/map?" + params.toString()', source)
self.assertNotRegex(source.lower(), r"stale.{0,20}(threshold|minutes|hours)")
self.assertNotIn("lowHashrate", source)
def test_fleet_css_has_status_text_mobile_cards_and_visible_focus(self):
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
self.assertIn('.fleet-row[data-status="offline"]', css)
self.assertIn("content: attr(data-label)", css)
self.assertIn(".fleet-pagination", css)
self.assertIn(":focus-visible", css)
def test_styles_have_no_motion_to_reduce(self):
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
self.assertNotIn("animation:", css)
self.assertNotIn("transition:", css)
if __name__ == "__main__":
unittest.main()
+81
View File
@@ -45,6 +45,21 @@ def inventory_row(**overrides):
"created_at": now,
"updated_at": now,
"tags": [],
"site_name": None,
"room_name": None,
"location_position_id": None,
"rack_label": None,
"shelf_label": None,
"position_label": None,
"position_index": None,
"observed_at": None,
"hashrate_ths": None,
"power_w": None,
"chip_temp_max_c": None,
"hash_stable": None,
"active_repair_id": None,
"active_repair_status": None,
"active_repair_reason": None,
}
row.update(overrides)
return row
@@ -56,6 +71,7 @@ class FakeInventoryRepository:
self.created_values = None
self.asset_id_history = []
self.update_error = None
self.search_kwargs = None
@contextmanager
def transaction(self):
@@ -89,6 +105,7 @@ class FakeInventoryRepository:
return list(self.rows.values())[offset : offset + limit]
def search_miners(self, **kwargs):
self.search_kwargs = kwargs
rows = list(self.rows.values())
lifecycle = kwargs.get("lifecycle_status")
if lifecycle:
@@ -416,6 +433,54 @@ class InventoryServiceTests(unittest.TestCase):
)
self.assertEqual(self.repository.asset_id_history, [])
def test_fleet_projection_mapping_and_filter_forwarding(self):
miner_id = uuid4()
repair_id = uuid4()
site_id = uuid4()
row = inventory_row(
id=miner_id,
miner_sn="MINER-SN",
site_id=site_id,
site_name="North Site",
observed_at=datetime.now(timezone.utc),
hashrate_ths=0.0,
power_w=0.0,
chip_temp_max_c=0.0,
hash_stable=False,
active_repair_id=repair_id,
active_repair_status="IN_PROGRESS",
active_repair_reason="Hashboard diagnostics",
)
self.repository.rows[miner_id] = row
filters = InventorySearchFilters(
model=" M50 ", runtime_status="offline", repair="active",
operational_state="repair", problems=True, site_id=site_id,
limit=25, offset=0,
)
result = self.service.search_miners(
filters, include_location_details=True
)[0]
self.assertEqual(result.miner_sn, "MINER-SN")
self.assertEqual(result.hashrate_ths, 0.0)
self.assertEqual(result.power_w, 0.0)
self.assertEqual(result.chip_temp_max_c, 0.0)
self.assertFalse(result.hash_stable)
self.assertEqual(result.active_repair.id, repair_id)
self.assertEqual(self.repository.search_kwargs["model"], "M50")
self.assertEqual(self.repository.search_kwargs["site_id"], site_id)
self.assertTrue(self.repository.search_kwargs["include_location_details"])
def test_location_filter_without_projection_is_rejected(self):
with self.assertRaisesRegex(
InventoryValidationError, "Location filters require"
):
self.service.search_miners(
InventorySearchFilters(site_id=uuid4())
)
self.assertIsNone(self.repository.search_kwargs)
def test_search_validation_precedes_repository(self):
with self.assertRaises(InventoryValidationError):
self.service.search_miners(
@@ -485,6 +550,22 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase):
miner_id,
)
fleet = self.repository.search_miners(
model="M50",
runtime_status="unknown",
operational_state="unknown",
problems=True,
include_location_details=True,
)
self.assertEqual(fleet[0]["id"], miner_id)
self.assertEqual(fleet[0]["miner_sn"], "MINER-001")
self.assertIsNone(fleet[0]["observed_at"])
self.assertIsNone(fleet[0]["location_position_id"])
self.assertEqual(
self.repository.list_location_filter_options(),
{"sites": [], "rooms": []},
)
updated = self.repository.update_miner(
miner_id,
{"lifecycle_status": "spare", "notes": "Bench stock"},
+89 -4
View File
@@ -43,16 +43,24 @@ class StubInventoryService:
self.history_result: list[InventoryAssetIDHistoryEntry] = []
self.history_error: Exception | None = None
self.history_miner_id: UUID | None = None
self.include_location_details = False
self.location_options = {"sites": [], "rooms": []}
def search_miners(
self,
filters: InventorySearchFilters,
*,
include_location_details: bool = False,
) -> list[InventoryMinerSummary]:
self.search_filters = filters
self.include_location_details = include_location_details
if self.search_error is not None:
raise self.search_error
return self.search_result
def list_location_filter_options(self):
return self.location_options
def create_miner(
self,
request: InventoryCreateRequest,
@@ -110,10 +118,10 @@ def miner_summary(miner_id: UUID | None = None) -> InventoryMinerSummary:
def miner_detail(miner_id: UUID | None = None) -> InventoryMinerDetail:
summary = miner_summary(miner_id)
values = miner_summary(miner_id).model_dump()
values["miner_sn"] = "MINER-001"
return InventoryMinerDetail(
**summary.model_dump(),
miner_sn="MINER-001",
**values,
control_board_sn="CB-001",
psu_serial="PSU-001",
)
@@ -410,10 +418,77 @@ class InventoryRouterTests(unittest.TestCase):
self.assertEqual(body["data"][0]["asset_id"], "ASSET-001")
self.assertEqual(
body["meta"],
{"limit": 100, "offset": 0, "returned": 1},
{"limit": 100, "offset": 0, "returned": 1,
"location_options": {"sites": [], "rooms": []}},
)
self.assertIsNone(body["error"])
def test_list_projection_and_location_permission_are_explicit(self):
miner_id = uuid4()
site_id = uuid4()
room_id = uuid4()
position_id = uuid4()
self.service.search_result = [
InventoryMinerSummary(
id=miner_id,
asset_id="FLEET-001",
model="M50",
miner_sn="SN-001",
lifecycle_status="active",
runtime_status="online",
site_id=site_id,
site_name="North Site",
room_id=room_id,
room_name="Room A",
location_position_id=position_id,
rack_label="Rack 1",
shelf_label="Shelf 2",
position_index=3,
observed_at=datetime.now(timezone.utc),
hashrate_ths=120.0,
power_w=3400.0,
chip_temp_max_c=72.0,
hash_stable=True,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
]
self.service.location_options = {
"sites": [{"id": site_id, "name": "North Site"}],
"rooms": [{"id": room_id, "site_id": site_id, "name": "Room A"}],
}
self.authenticate(("miners.view", "locations.view"))
response = self.client.get("/api/inventory/miners")
data = response.json()["data"][0]
self.assertEqual(response.status_code, 200)
self.assertEqual(data["miner_sn"], "SN-001")
self.assertEqual(data["location_position_id"], str(position_id))
self.assertEqual(data["hashrate_ths"], 120.0)
self.assertTrue(self.service.include_location_details)
self.assertEqual(
response.json()["meta"]["location_options"]["sites"][0]["name"],
"North Site",
)
self.authenticate(("miners.view",))
self.service.search_result = [miner_summary(miner_id)]
response = self.client.get("/api/inventory/miners")
data = response.json()["data"][0]
self.assertIsNone(data["site_id"])
self.assertIsNone(data["room_id"])
self.assertIsNone(data["location_position_id"])
self.assertEqual(response.json()["meta"]["location_options"], {"sites": [], "rooms": []})
self.assertFalse(self.service.include_location_details)
def test_location_filters_require_locations_view(self):
self.authenticate(("miners.view",))
response = self.client.get(
"/api/inventory/miners", params={"site_id": str(uuid4())}
)
self.assertEqual(response.status_code, 403)
self.assertIsNone(self.service.search_filters)
def test_empty_list_returns_successful_empty_collection(self):
self.authenticate()
@@ -432,6 +507,11 @@ class InventoryRouterTests(unittest.TestCase):
("query", "M50"),
("asset_id", "ASSET-001"),
("lifecycle_status", "spare"),
("model", "M50"),
("runtime_status", "offline"),
("repair", "active"),
("operational_state", "repair"),
("problems", "true"),
("tags", "Priority"),
("tags", "Repair"),
("limit", "25"),
@@ -445,6 +525,11 @@ class InventoryRouterTests(unittest.TestCase):
self.assertEqual(filters.query, "M50")
self.assertEqual(filters.asset_id, "ASSET-001")
self.assertEqual(filters.lifecycle_status, "spare")
self.assertEqual(filters.model, "M50")
self.assertEqual(filters.runtime_status, "offline")
self.assertEqual(filters.repair, "active")
self.assertEqual(filters.operational_state, "repair")
self.assertTrue(filters.problems)
self.assertEqual(filters.tags, ["Priority", "Repair"])
self.assertEqual(filters.limit, 25)
self.assertEqual(filters.offset, 50)
+400
View File
@@ -0,0 +1,400 @@
import ast
import unittest
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import patch
from uuid import UUID, uuid4
from fastapi import FastAPI
import psycopg
from fastapi.testclient import TestClient
from app.config import settings
from app.identity import get_current_user
from app.locations.dependencies import get_location_map_service
from app.locations.repository import LocationMapRepository
from app.locations.router import router
from app.locations.service import LocationMapService
NOW = datetime(2026, 8, 10, 12, 0, tzinfo=timezone.utc)
STATIC_ROOT = Path(__file__).parents[1] / "app" / "frontend" / "static"
def map_rows():
site_a, site_b, room_a, room_b = uuid4(), uuid4(), uuid4(), uuid4()
miner_id, position_id, repair_id = uuid4(), uuid4(), uuid4()
return {
"sites": [{"id": site_a, "name": "North"}, {"id": site_b, "name": "South"}],
"rooms": [
{"id": room_a, "site_id": site_a, "site_name": "North", "name": "Room 1", "sort_order": 1},
{"id": room_b, "site_id": site_b, "site_name": "South", "name": "Room 1", "sort_order": 1},
],
"positions": [{
"location_position_id": position_id, "site_id": site_a,
"site_name": "North", "room_id": room_a, "room_name": "Room 1",
"rack_name": "A", "shelf_name": "Top", "shelf_index": 1,
"position_index": 1, "position_label": "A-1", "position_kind": "miner_slot",
"position_status": "occupied", "occupied": True,
"expected_mac": "de:ad:be:ef:00:01",
"equipment_label": None, "notes": None, "source_file": None,
"source_cell": None, "conflict_reason": None, "miner_id": miner_id,
"asset_id": '<img src=x onerror="alert(1)">', "current_mac": "aa:bb:cc:dd:ee:ff",
"current_ip": "192.0.2.10", "miner_status": "online", "hostname": "miner-a",
"miner_sn": "SERIAL-1", "power_sn": None, "model": "M50",
"miner_type": "WhatsMiner", "firmware_version": "1.0",
"lifecycle_status": "in_repair", "last_seen_at": NOW,
"asset_state": "shelved", "observed_at": NOW, "hashrate_ths": 120.5,
"power_w": 3320.0, "chip_temp_max_c": 71.0, "chip_temp_avg_c": 66.0,
"hash_stable": True, "pool_rejected_percent": 0.2,
"active_repair_id": repair_id, "active_repair_status": "OPEN",
"active_repair_reason": "CONTROL_BOARD",
}, {
"location_position_id": uuid4(), "site_id": site_a, "site_name": "North",
"room_id": room_a, "room_name": "Room 1", "rack_name": "A",
"shelf_name": "Top", "shelf_index": 1, "position_index": 2,
"position_label": "A-2", "position_kind": "miner_slot",
"position_status": "available", "occupied": False,
"active_repair_id": None, "active_repair_status": None,
"active_repair_reason": None,
}],
"virtual_miners": [],
}
class FakeLocationRepository:
def __init__(self):
self.rows = map_rows()
self.calls = []
def read_map(self, *, site_id=None, room_id=None):
self.calls.append((site_id, room_id))
return self.rows
class MineMapServiceTests(unittest.TestCase):
def test_service_builds_typed_bulk_map_with_repair_and_empty_position(self):
repository = FakeLocationRepository()
response = LocationMapService(repository).get_map(include_miner_details=True)
self.assertEqual(len(repository.calls), 1)
self.assertEqual(response.data.rooms[0].name, response.data.rooms[1].name)
self.assertNotEqual(response.data.rooms[0].site_id, response.data.rooms[1].site_id)
self.assertTrue(response.data.positions[0].occupied)
self.assertEqual(response.data.positions[0].active_repair.status, "OPEN")
self.assertFalse(response.data.positions[1].occupied)
self.assertEqual(response.data.positions[0].hashrate_ths, 120.5)
def test_location_only_user_receives_layout_without_miner_details(self):
response = LocationMapService(FakeLocationRepository()).get_map(
include_miner_details=False
)
position = response.data.positions[0]
self.assertTrue(position.occupied)
self.assertIsNone(position.miner_id)
self.assertIsNone(position.asset_id)
self.assertIsNone(position.expected_mac)
for field in ("current_mac", "current_ip", "miner_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"):
self.assertIsNone(getattr(position, field), field)
self.assertIsNone(position.active_repair)
self.assertEqual(response.data.virtual_miners, [])
self.assertFalse(response.meta.miner_details_included)
def test_service_preserves_legacy_position_without_assigning_durable_scope(self):
repository = FakeLocationRepository()
legacy = dict(repository.rows["positions"][1])
legacy.update({
"location_position_id": uuid4(), "site_id": None, "site_name": None,
"room_id": None, "room_name": "Imported Room", "rack_name": "A",
"shelf_name": "Top", "position_index": 3,
})
repository.rows["positions"].append(legacy)
response = LocationMapService(repository).get_map(include_miner_details=True)
position = response.data.positions[-1]
self.assertIsNone(position.site_id)
self.assertIsNone(position.room_id)
self.assertEqual(position.room_name, "Imported Room")
class StubMapService:
def __init__(self):
self.repository = FakeLocationRepository()
self.service = LocationMapService(self.repository)
self.include = None
def get_map(self, **kwargs):
self.include = kwargs["include_miner_details"]
return self.service.get_map(**kwargs)
class MineMapRouterTests(unittest.TestCase):
def setUp(self):
self.service = StubMapService()
self.app = FastAPI()
self.app.include_router(router)
self.app.dependency_overrides[get_location_map_service] = lambda: self.service
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "role": "Custom", "permissions": list(permissions)
}
def test_map_requires_authentication_and_locations_permission(self):
self.assertEqual(self.client.get("/api/location/map").status_code, 401)
self.authenticate(("miners.view",))
self.assertEqual(self.client.get("/api/location/map").status_code, 403)
def test_map_returns_typed_envelope_and_permission_aware_miner_shape(self):
self.authenticate(("locations.view", "miners.view"))
response = self.client.get("/api/location/map")
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["meta"]["miner_details_included"])
self.assertEqual(response.json()["data"]["positions"][0]["asset_id"],
'<img src=x onerror="alert(1)">')
self.assertIsNone(response.json()["error"])
self.authenticate(("locations.view",))
response = self.client.get("/api/location/map")
redacted = response.json()["data"]["positions"][0]
self.assertIsNone(redacted["miner_id"])
self.assertIsNone(redacted["expected_mac"])
self.assertIsNone(redacted["active_repair"])
self.assertEqual(response.json()["data"]["virtual_miners"], [])
self.assertFalse(response.json()["meta"]["miner_details_included"])
def test_uuid_filters_are_validated_and_forwarded(self):
self.authenticate(("locations.view", "miners.view"))
site_id, room_id = uuid4(), uuid4()
response = self.client.get(
"/api/location/map", params={"site_id": site_id, "room_id": room_id}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(self.service.repository.calls[-1], (site_id, room_id))
self.assertEqual(self.client.get("/api/location/map?site_id=bad").status_code, 422)
def test_router_has_no_sql_or_database_driver_imports(self):
path = Path(__file__).parents[1] / "app" / "locations" / "router.py"
source = path.read_text(encoding="utf-8")
modules = []
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Import): modules.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module: modules.append(node.module)
self.assertFalse(any(name.startswith(("psycopg", "sqlalchemy", "asyncpg")) for name in modules))
for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE "):
self.assertNotIn(keyword, source.upper())
class MineMapRepositoryIntegrationTests(unittest.TestCase):
def test_set_based_query_scopes_same_named_rooms_by_uuid_and_site(self):
connection = psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
)
try:
repository = LocationMapRepository(connection=connection)
site_a, site_b, room_a, room_b = uuid4(), uuid4(), uuid4(), uuid4()
miner_id = uuid4()
room_name = "Map Same Name " + uuid4().hex[:8]
with connection.cursor() as cur:
cur.execute(
"INSERT INTO sites (id, name) VALUES (%s, %s), (%s, %s)",
(site_a, "Map North " + uuid4().hex[:8],
site_b, "Map South " + uuid4().hex[:8]),
)
cur.execute(
"INSERT INTO rooms (id, site_id, name, sort_order) "
"VALUES (%s, %s, %s, 1), (%s, %s, %s, 1)",
(room_a, site_a, room_name, room_b, site_b, room_name),
)
cur.execute(
"INSERT INTO miners (id, site_id, room_id, asset_tag, status, "
"lifecycle_status) VALUES (%s, %s, %s, %s, 'online', 'active')",
(miner_id, site_a, room_a, "MAP-" + uuid4().hex[:8]),
)
cur.execute(
"INSERT INTO location_positions "
"(room_id, room_name, rack_name, shelf_name, shelf_index, "
"position_index, position_kind, position_status, miner_id) "
"VALUES (%s, %s, 'A', '1', 1, 1, 'miner_slot', 'occupied', %s), "
"(%s, %s, 'B', '1', 1, 1, 'miner_slot', 'available', NULL)",
(room_a, room_name, miner_id, room_b, room_name),
)
cur.execute(
"INSERT INTO miner_status_snapshots "
"(miner_id, mhs_av, power_w, chip_temp_max_c, hash_stable) "
"VALUES (%s, 120000000, 3300, 70, true)",
(miner_id,),
)
result = repository.read_map(site_id=site_a, room_id=room_a)
self.assertEqual(len(result["positions"]), 1)
row = result["positions"][0]
self.assertEqual(row["site_id"], site_a)
self.assertEqual(row["room_id"], room_a)
self.assertTrue(row["occupied"])
self.assertEqual(row["hashrate_ths"], 120.0)
matching_rooms = [room for room in result["rooms"] if room["name"] == room_name]
self.assertEqual({room["site_id"] for room in matching_rooms}, {site_a, site_b})
finally:
connection.rollback()
connection.close()
class DummyCursor:
def __enter__(self): return self
def __exit__(self, *args): return False
def execute(self, *args): return None
def fetchall(self): return []
class DummyConnection:
def cursor(self, **kwargs): return DummyCursor()
def close(self): return None
class StatusSnapshotPermissionTests(unittest.TestCase):
def setUp(self):
from app.main import list_miner_status_snapshots
self.app = FastAPI()
self.app.get("/api/miners/{miner_id}/status-snapshots")(
list_miner_status_snapshots
)
self.client = TestClient(self.app)
def tearDown(self):
self.app.dependency_overrides.pop(get_current_user, None)
self.client.close()
def test_status_snapshots_require_authentication_permission_and_allow_reader(self):
miner_id = uuid4()
self.assertEqual(self.client.get(f"/api/miners/{miner_id}/status-snapshots").status_code, 401)
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "role": "Custom", "permissions": ["locations.view"]
}
self.assertEqual(self.client.get(f"/api/miners/{miner_id}/status-snapshots").status_code, 403)
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "role": "Custom", "permissions": ["miners.view"]
}
with patch("app.main.get_db_connection", return_value=DummyConnection()):
response = self.client.get(f"/api/miners/{miner_id}/status-snapshots")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"], [])
def test_established_roles_and_custom_roles_follow_miners_view_permission(self):
miner_id = uuid4()
allowed = (("Admin", []), ("Manager", ["miners.view"]),
("Technician", ["miners.view"]), ("Read Only", ["miners.view"]),
("Custom", ["miners.view"]))
with patch("app.main.get_db_connection", return_value=DummyConnection()):
for role, permissions in allowed:
with self.subTest(role=role):
self.app.dependency_overrides[get_current_user] = lambda r=role, p=permissions: {
"id": str(uuid4()), "role": r, "permissions": p
}
response = self.client.get(f"/api/miners/{miner_id}/status-snapshots")
self.assertEqual(response.status_code, 200)
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "role": "Custom", "permissions": []
}
self.assertEqual(
self.client.get(f"/api/miners/{miner_id}/status-snapshots").status_code, 403
)
class MineMapFrontendAuditTests(unittest.TestCase):
def test_map_module_uses_one_bulk_endpoint_and_safe_dom(self):
source = (STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8")
self.assertEqual(source.count('fetch("/api/location/map"'), 1)
self.assertNotIn("/api/inventory/miners", source)
self.assertNotIn("status-snapshots", source)
for unsafe in ("innerHTML", "outerHTML", "insertAdjacentHTML",
"document.write", "eval(", "Function("):
self.assertNotIn(unsafe, source)
self.assertIn("textContent", source)
self.assertIn("encodeURIComponent(position.miner_id)", source)
self.assertIn('window.addEventListener("popstate"', source)
self.assertIn('el.mapWorkspace.addEventListener("click"', source)
self.assertIn('el.mapWorkspace.addEventListener("keydown"', source)
self.assertIn('tile.setAttribute("aria-pressed"', source)
self.assertIn("syncTileTabStops", source)
self.assertIn("renderLegacyPositions", source)
self.assertIn("minerDetailsIncluded", source)
self.assertIn("mapProblemsOnly", source)
def test_map_has_factual_status_rules_without_thresholds_or_mutation(self):
source = (STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8")
for invented in ("> 90", ">= 90", "staleMinutes", "degradedThreshold"):
self.assertNotIn(invented, source)
self.assertIn('runtime === "offline"', source)
self.assertIn("position.hash_stable === false", source)
self.assertNotIn('method: "POST"', source)
self.assertNotIn('method: "PATCH"', source)
self.assertNotIn('method: "DELETE"', source)
self.assertIn('if (!state.minerDetailsIncluded)', source)
self.assertIn('Number.isFinite(value)', source)
def test_problems_and_measurement_layers_use_factual_relative_values(self):
source = (STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8")
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
self.assertIn("const PROBLEM_KEYS = new Set", source)
for state in ("offline", "unknown", "unstable", "repair"):
self.assertIn(f'"{state}"', source)
self.assertIn("state.problemsOnly", source)
self.assertIn('"--relative-percent"', source)
self.assertIn("Relative range", source)
self.assertIn('data-problems-muted="true"', css)
self.assertIn(".relative-scale-track", css)
def test_map_repository_uses_constant_set_based_queries(self):
source = (Path(__file__).parents[1] / "app" / "locations" /
"repository.py").read_text(encoding="utf-8")
read_map_source = source.split(" def read_map(", 1)[1].split(
" def summarize_locations(", 1
)[0]
latest_telemetry_sql = source.split(
'LATEST_TELEMETRY_SQL = """', 1
)[1].split('"""', 1)[0]
active_repair_sql = source.split(
'ACTIVE_REPAIR_SQL = """', 1
)[1].split('"""', 1)[0]
self.assertEqual(read_map_source.count("cur.execute("), 4)
self.assertEqual(
read_map_source.count("{LATEST_TELEMETRY_SQL}"),
2,
)
self.assertEqual(
read_map_source.count("{ACTIVE_REPAIR_SQL}"),
2,
)
self.assertIn("LEFT JOIN LATERAL", latest_telemetry_sql)
self.assertIn("LEFT JOIN LATERAL", active_repair_sql)
self.assertIn(
"ORDER BY snapshots.observed_at DESC, snapshots.id DESC",
latest_telemetry_sql,
)
self.assertIn(
"ORDER BY repairs.opened_at, repairs.id",
active_repair_sql,
)
self.assertNotIn("for miner", read_map_source)
if __name__ == "__main__":
unittest.main()
+636
View File
@@ -0,0 +1,636 @@
import ast
import re
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
from uuid import UUID, uuid4
import psycopg
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.config import settings
from app.frontend.router import miner_detail_page, router as frontend_router
from app.identity import get_current_user
from app.inventory.exceptions import MinerNotFound
from app.inventory.models import InventoryMinerDetail
from app.inventory.repository import InventoryRepository
from app.inventory.service import InventoryService
from app.locations.models import MinerLocationDetail
from app.locations.repository import LocationMapRepository
from app.miner_detail.dependencies import get_miner_detail_service
from app.miner_detail.models import MinerDetailAccess
from app.miner_detail.router import router as detail_router
from app.miner_detail.service import MinerDetailService
from app.repairs.center_repository import RepairCenterRepository
from app.repairs.models import RepairCenterCapabilities, RepairCenterItem
NOW = datetime(2026, 8, 17, 12, 0, tzinfo=timezone.utc)
APP_ROOT = Path(__file__).parents[1] / "app"
STATIC_ROOT = APP_ROOT / "frontend" / "static"
def current_miner(**updates):
values = {
"id": uuid4(),
"asset_id": "MOP-001",
"current_mac": "aa:bb:cc:dd:ee:ff",
"current_ip": "192.0.2.10",
"model": "M50",
"miner_sn": "MINER-SN",
"lifecycle_status": "active",
"runtime_status": "online",
"site_id": uuid4(),
"site_name": "Restricted Site",
"room_id": uuid4(),
"room_name": "Restricted Room",
"location_position_id": uuid4(),
"rack_label": "Rack A",
"shelf_label": "Shelf 1",
"position_label": "A-01",
"position_index": 1,
"observed_at": NOW,
"hashrate_ths": 0.0,
"power_w": 0.0,
"chip_temp_max_c": 0.0,
"hash_stable": None,
"tags": [],
"created_at": NOW - timedelta(days=10),
"updated_at": NOW,
"last_seen_at": NOW,
"hostname": "miner-001",
"control_board_sn": "CB-SN",
"psu_serial": "PSU-SN",
"firmware_version": "1.2.3",
"notes": None,
"first_seen_at": NOW - timedelta(days=9),
}
values.update(updates)
return InventoryMinerDetail(**values)
def location():
return MinerLocationDetail(
site_id=uuid4(),
site_name="North Site",
room_id=uuid4(),
room_name="Room 1",
position_id=uuid4(),
rack_label="Rack A",
shelf_label="Shelf 1",
position_label="A-01",
position_index=1,
)
def repair_item(*, source="modular", active=True):
return RepairCenterItem(
repair_id=uuid4() if source == "modular" else None,
source=source,
active=active,
opened_at=NOW - timedelta(hours=2),
repair_status="OPEN" if active else "COMPLETED",
reason="CONTROL_BOARD" if source == "modular" else None,
latest_context="Board diagnostics",
latest_context_at=NOW,
miner_id=uuid4(),
lifecycle_status="in_repair" if active else "active",
parts_count=1 if source == "modular" else None,
parts_summary=["control board"] if source == "modular" else [],
capabilities=RepairCenterCapabilities(),
)
class FakeInventoryService:
def __init__(self, miner=None, *, missing=False):
self.miner = miner or current_miner()
self.missing = missing
self.calls = []
def get_current_miner(self, miner_id):
self.calls.append(miner_id)
if self.missing:
raise MinerNotFound(miner_id)
return self.miner
class FakeLocationService:
def __init__(self, value=None):
self.value = value
self.calls = []
def get_miner_location(self, miner_id):
self.calls.append(miner_id)
return self.value
class FakeRepairService:
def __init__(self, values=None):
self.values = list(values or [])
self.calls = []
def list_miner_repairs(self, miner_id, access):
self.calls.append((miner_id, access))
return self.values
class MinerDetailServiceTests(unittest.TestCase):
def build(self, *, miner=None, mapped=None, repairs=None, missing=False):
self.inventory = FakeInventoryService(miner, missing=missing)
self.locations = FakeLocationService(mapped)
self.repairs = FakeRepairService(repairs)
return MinerDetailService(self.inventory, self.locations, self.repairs)
def test_full_access_uses_three_domain_queries_and_preserves_zero(self):
miner_id = uuid4()
mapped = location()
active = repair_item()
service = self.build(mapped=mapped, repairs=[active])
response = service.get_overview(
miner_id,
MinerDetailAccess(
can_view_locations=True,
can_read_repairs=True,
can_view_repairs=True,
can_read_components=True,
),
)
self.assertEqual(self.inventory.calls, [miner_id])
self.assertEqual(self.locations.calls, [miner_id])
self.assertEqual(len(self.repairs.calls), 1)
self.assertEqual(response.meta.database_query_count, 3)
self.assertEqual(response.data.miner.hashrate_ths, 0)
self.assertEqual(response.data.miner.power_w, 0)
self.assertEqual(response.data.miner.chip_temp_max_c, 0)
self.assertIsNone(response.data.miner.hash_stable)
self.assertEqual(response.data.location.location, mapped)
self.assertEqual(response.data.repair.active_repair.repair_id, active.repair_id)
self.assertEqual(response.data.operational_state, "repair")
self.assertTrue(response.data.capabilities.can_view_map)
self.assertTrue(response.data.capabilities.can_open_repair_center)
def test_restricted_domains_are_not_queried_and_location_never_leaks(self):
service = self.build(mapped=location(), repairs=[repair_item()])
response = service.get_overview(uuid4(), MinerDetailAccess())
self.assertEqual(response.meta.database_query_count, 1)
self.assertEqual(self.locations.calls, [])
self.assertEqual(self.repairs.calls, [])
self.assertTrue(response.data.location.restricted)
self.assertIsNone(response.data.location.location)
self.assertTrue(response.data.repair.restricted)
self.assertEqual(response.data.repair.episodes, [])
miner = response.data.miner
for field in (
"site_id", "site_name", "room_id", "room_name",
"location_position_id", "rack_label", "shelf_label",
"position_label", "position_index",
):
self.assertIsNone(getattr(miner, field), field)
serialized = response.model_dump_json()
self.assertNotIn("Restricted Site", serialized)
self.assertNotIn("Restricted Room", serialized)
def test_no_repair_and_legacy_repair_are_truthfully_distinct(self):
no_repair = self.build().get_overview(
uuid4(), MinerDetailAccess(can_read_repairs=True)
)
self.assertTrue(no_repair.data.repair.available)
self.assertFalse(no_repair.data.repair.restricted)
self.assertIsNone(no_repair.data.repair.active_repair)
self.assertEqual(no_repair.data.repair.episodes, [])
legacy = repair_item(source="legacy")
legacy_response = self.build(repairs=[legacy]).get_overview(
uuid4(), MinerDetailAccess(can_read_repairs=True)
)
self.assertIsNone(legacy_response.data.repair.active_repair)
self.assertEqual(legacy_response.data.repair.episodes[0].source, "legacy")
self.assertIsNone(legacy_response.data.repair.episodes[0].repair_id)
def test_unscoped_legacy_position_is_truthful_but_not_map_linkable(self):
unscoped = location().model_copy(
update={"site_id": None, "site_name": None, "room_id": None}
)
response = self.build(mapped=unscoped).get_overview(
uuid4(), MinerDetailAccess(can_view_locations=True)
)
self.assertTrue(response.data.location.available)
self.assertEqual(response.data.location.location.position_label, "A-01")
self.assertFalse(response.data.capabilities.can_view_map)
def test_factual_state_uses_no_temperature_or_age_threshold(self):
cases = (
({"runtime_status": "offline", "chip_temp_max_c": 99.0}, "offline"),
({"runtime_status": "online", "hash_stable": False}, "unstable"),
({"runtime_status": "online", "observed_at": NOW - timedelta(days=90)}, "online"),
({"runtime_status": "unknown", "chip_temp_max_c": 1000.0}, "unknown"),
({"runtime_status": "online", "lifecycle_status": "spare"}, "spare"),
({"runtime_status": "online", "lifecycle_status": "retired"}, "retired"),
)
for updates, expected in cases:
with self.subTest(expected=expected):
response = self.build(miner=current_miner(**updates)).get_overview(
uuid4(), MinerDetailAccess()
)
self.assertEqual(response.data.operational_state, expected)
response = self.build(
miner=current_miner(hashrate_ths=float("nan"))
).get_overview(uuid4(), MinerDetailAccess())
self.assertIsNone(response.data.miner.hashrate_ths)
self.assertEqual(response.data.operational_state, "online")
def test_unknown_miner_propagates_domain_not_found_without_other_queries(self):
service = self.build(missing=True, mapped=location(), repairs=[repair_item()])
with self.assertRaises(MinerNotFound):
service.get_overview(
uuid4(),
MinerDetailAccess(can_view_locations=True, can_read_repairs=True),
)
self.assertEqual(self.locations.calls, [])
self.assertEqual(self.repairs.calls, [])
class StubMinerDetailService:
def __init__(self, *, missing=False):
self.missing = missing
self.calls = []
self.delegate = MinerDetailService(
FakeInventoryService(),
FakeLocationService(location()),
FakeRepairService([repair_item()]),
)
def get_overview(self, miner_id, access):
self.calls.append((miner_id, access))
if self.missing:
raise MinerNotFound(miner_id)
return self.delegate.get_overview(miner_id, access)
class MinerDetailRouterTests(unittest.TestCase):
def setUp(self):
self.service = StubMinerDetailService()
self.app = FastAPI()
self.app.include_router(detail_router)
self.app.dependency_overrides[get_miner_detail_service] = lambda: self.service
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()),
"role": "Custom",
"permissions": list(permissions),
}
def test_requires_authentication_and_miners_view(self):
path = f"/api/miners/{uuid4()}/overview"
self.assertEqual(self.client.get(path).status_code, 401)
self.authenticate(("locations.view", "repairs.read"))
self.assertEqual(self.client.get(path).status_code, 403)
def test_returns_typed_permission_aware_no_store_envelope(self):
miner_id = uuid4()
self.authenticate((
"miners.view", "locations.view", "repairs.read",
"repairs.view", "components.read",
))
response = self.client.get(f"/api/miners/{miner_id}/overview")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["cache-control"], "no-store")
payload = response.json()
self.assertEqual(payload["data"]["miner"]["id"], str(self.service.delegate.inventory_service.miner.id))
self.assertEqual(payload["meta"]["database_query_count"], 3)
self.assertIsNone(payload["error"])
_, access = self.service.calls[-1]
self.assertTrue(access.can_view_locations)
self.assertTrue(access.can_read_repairs)
self.assertTrue(access.can_view_repairs)
self.assertTrue(access.can_read_components)
def test_malformed_and_unknown_uuid_use_existing_validation_and_404(self):
self.authenticate(("miners.view",))
self.assertEqual(self.client.get("/api/miners/not-a-uuid/overview").status_code, 422)
self.service.missing = True
response = self.client.get(f"/api/miners/{uuid4()}/overview")
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["detail"], "Miner not found")
class MinerDetailRepositoryIntegrationTests(unittest.TestCase):
def setUp(self):
self.connection = psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
)
def tearDown(self):
self.connection.rollback()
self.connection.close()
def test_inventory_and_location_projections_use_latest_factual_rows(self):
miner_id, site_id, room_id = uuid4(), uuid4(), uuid4()
earlier = NOW - timedelta(hours=1)
with self.connection.cursor() as cur:
cur.execute("INSERT INTO sites (id, name) VALUES (%s, 'Detail Site')", (site_id,))
cur.execute(
"INSERT INTO rooms (id, site_id, name) VALUES (%s, %s, 'Detail Room')",
(room_id, site_id),
)
cur.execute(
"INSERT INTO miners (id, asset_tag, current_mac, current_ip, model, "
"status, lifecycle_status, first_seen_at, last_seen_at) "
"VALUES (%s, %s, 'aa:bb:cc:00:00:01', '192.0.2.22', 'M50', "
"'online', 'active', NULL, NULL)",
(miner_id, "DETAIL-" + uuid4().hex[:8]),
)
cur.execute(
"INSERT INTO location_positions (room_id, room_name, rack_name, "
"shelf_name, position_index, position_label, miner_id) "
"VALUES (%s, 'Detail Room', 'Rack A', 'Shelf 1', 2, 'A-02', %s)",
(room_id, miner_id),
)
cur.execute(
"INSERT INTO miner_status_snapshots "
"(id, miner_id, observed_at, mhs_av, power_w, chip_temp_max_c, hash_stable) "
"VALUES (%s, %s, %s, 120000000, 3300, 70, true), "
"(%s, %s, %s, 0, 0, 0, NULL)",
(uuid4(), miner_id, earlier, uuid4(), miner_id, NOW),
)
inventory = InventoryService(
InventoryRepository(connection=self.connection)
).get_current_miner(miner_id)
self.assertEqual(inventory.hashrate_ths, 0)
self.assertEqual(inventory.power_w, 0)
self.assertEqual(inventory.chip_temp_max_c, 0)
self.assertIsNone(inventory.hash_stable)
self.assertEqual(inventory.observed_at, NOW)
placement = LocationMapRepository(
connection=self.connection
).get_miner_location(miner_id)
self.assertEqual(placement["site_id"], site_id)
self.assertEqual(placement["room_id"], room_id)
self.assertEqual(placement["rack_label"], "Rack A")
self.assertIsInstance(placement["position_id"], UUID)
self.assertEqual(RepairCenterRepository().list_miner_repairs(miner_id), [])
class DummyCursor:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def execute(self, *args):
return None
def fetchall(self):
return []
class DummyConnection:
def cursor(self, **kwargs):
return DummyCursor()
def close(self):
return None
class NetworkIdentityPermissionTests(unittest.TestCase):
def setUp(self):
from app.main import list_miner_network_identities
self.app = FastAPI()
self.app.get("/api/miners/{miner_id}/network-identities")(
list_miner_network_identities
)
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def test_network_history_requires_miners_view(self):
path = f"/api/miners/{uuid4()}/network-identities"
self.assertEqual(self.client.get(path).status_code, 401)
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "role": "Custom", "permissions": ["locations.view"]
}
self.assertEqual(self.client.get(path).status_code, 403)
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "role": "Custom", "permissions": ["miners.view"]
}
with patch("app.main.get_db_connection", return_value=DummyConnection()):
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"], [])
class MinerDetailFrontendTests(unittest.TestCase):
def setUp(self):
self.app = FastAPI()
self.app.include_router(frontend_router)
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()),
"username": "reader",
"display_name": "Detail Reader",
"role": "Read Only",
"permissions": [
"miners.view", "locations.view", "repairs.read", "repairs.view"
],
}
self.client = TestClient(self.app)
self.miner_id = uuid4()
def tearDown(self):
self.client.close()
def test_page_requires_permission_and_renders_only_internal_alpha_shell(self):
path = f"/miners/{self.miner_id}/detail"
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
for expected in (
"<title>Miner Detail | MinerOps</title>",
'data-view="miner_detail"',
f'data-miner-id="{self.miner_id}"',
"/assets/minerops-miner-detail.js",
'id="minerIdentityDetails"',
'id="minerRepairEpisodes"',
'id="minerObservations"',
):
self.assertIn(expected, response.text)
for legacy in (
"minerops-miner-detail-extra", "minerops-miner-detail-clean-v3",
"minerops-location-management", "Manual Edit Missing Details",
"Reboot", "Performance Mode",
):
self.assertNotIn(legacy, response.text)
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "role": "Custom", "permissions": []
}
self.assertEqual(self.client.get(path).status_code, 403)
def test_return_to_accepts_protected_local_context_and_rejects_external(self):
path = f"/miners/{self.miner_id}/detail"
local = "/monitoring/map?site_id=" + str(uuid4()) + "&miner_id=" + str(self.miner_id)
response = self.client.get(path, params={"return_to": local})
self.assertIn("data-return-to=", response.text)
self.assertIn("/monitoring/map?site_id=", response.text)
response = self.client.get(
path, params={"return_to": "https://example.invalid/steal"}
)
self.assertNotIn("data-return-to=", response.text)
self.assertNotIn("example.invalid", response.text)
def test_refresh_action_has_visible_name_and_keeps_existing_behavior(self):
response = self.client.get(f"/miners/{self.miner_id}/detail")
self.assertRegex(
response.text,
r'<button class="quiet-button" id="minerDetailRefresh" '
r'type="button">Refresh</button>',
)
source = (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8")
self.assertIn(
'el.refresh.addEventListener("click", loadOverview);',
source,
)
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
self.assertIn(".miner-detail-actions .quiet-button { color: var(--ink); }", css)
def test_live_route_precedence_and_middleware_isolation(self):
from app.main import app
detail_handlers = [
route.endpoint for route in app.routes
if route.path == "/miners/{miner_id}/detail"
and "GET" in (route.methods or set())
]
self.assertGreaterEqual(len(detail_handlers), 2)
self.assertIs(detail_handlers[0], miner_detail_page)
current_user = {
"id": str(uuid4()), "username": "reader",
"display_name": "Detail Reader", "role": "Read Only",
"permissions": ["miners.view"],
}
app.dependency_overrides[get_current_user] = lambda: current_user
try:
with patch("app.identity.request_user_or_none", return_value=current_user):
with TestClient(app) as client:
response = client.get(f"/miners/{self.miner_id}/detail")
finally:
app.dependency_overrides.pop(get_current_user, None)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.text.count('data-view="miner_detail"'), 1)
self.assertIn("/assets/minerops-miner-detail.js", response.text)
for legacy in (
"/assets/minerops-miner-detail-extra.js",
"/assets/minerops-miner-detail-extra-v2.js",
"/assets/minerops-miner-detail-clean-v3.js",
"/assets/minerops-location-management.js",
):
self.assertNotIn(legacy, response.text)
paths = {route.path for route in app.routes}
for legacy_api in (
"/api/miners/{miner_id}",
"/api/miners/{miner_id}/detail-extra",
"/api/miners/{miner_id}/detail-clean-v3",
"/api/miners/{miner_id}/network-identities",
"/api/miners/{miner_id}/status-snapshots",
):
self.assertIn(legacy_api, paths)
def test_frontend_is_lazy_read_only_bounded_and_xss_safe(self):
source = (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8")
self.assertEqual(
source.count(
'api("/api/miners/" + encodeURIComponent(minerId) + "/overview")'
),
1,
)
self.assertEqual(source.count("Promise.all(["), 2)
self.assertIn("/asset-id-history", source)
self.assertIn("/network-identities", source)
self.assertIn("/status-snapshots", source)
self.assertIn("/events", source)
self.assertIn("/parts", source)
self.assertNotIn("setInterval", source)
for mutation in (
'method: "POST"', 'method: "PATCH"', 'method: "PUT"',
'method: "DELETE"', "reboot", "change-mac", "manual-details",
):
self.assertNotIn(mutation, source)
for unsafe in ("innerHTML", "outerHTML", "insertAdjacentHTML", "document.write"):
self.assertNotIn(unsafe, source)
self.assertIn("textContent", source)
self.assertIn("new URLSearchParams", source)
self.assertIn("encodeURIComponent", source)
self.assertNotRegex(
source.lower(),
r"(temperature|hashrate|stale).{0,40}(threshold|>=\s*90|>\s*90)",
)
main_source = (APP_ROOT / "main.py").read_text(encoding="utf-8")
snapshot = re.search(
r'def list_miner_status_snapshots\(.*?LIMIT 100;',
main_source,
re.DOTALL,
)
self.assertIsNotNone(snapshot)
def test_cross_navigation_preserves_context_with_durable_uuid(self):
fleet = (STATIC_ROOT / "fleet.js").read_text(encoding="utf-8")
repairs = (STATIC_ROOT / "repair-center.js").read_text(encoding="utf-8")
for source in (fleet, repairs):
self.assertIn('return_to', source)
self.assertIn("window.location.pathname + window.location.search", source)
self.assertIn("encodeURIComponent", source)
detail = (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8")
self.assertIn('new URLSearchParams({ miner_id: minerIdValue })', detail)
self.assertIn('params.set("site_id", location.site_id)', detail)
self.assertIn('params.set("room_id", location.room_id)', detail)
self.assertIn('new URLSearchParams({ scope: "all", q: data.miner.id })', detail)
class MinerDetailArchitectureTests(unittest.TestCase):
def test_router_and_service_preserve_dependency_boundaries(self):
for relative in ("miner_detail/router.py", "miner_detail/service.py"):
source = (APP_ROOT / relative).read_text(encoding="utf-8")
modules = []
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Import):
modules.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
modules.append(node.module)
self.assertFalse(
any(name.startswith(("psycopg", "sqlalchemy", "asyncpg"))
for name in modules)
)
for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE "):
self.assertNotIn(keyword, source.upper())
service_source = (APP_ROOT / "miner_detail" / "service.py").read_text(
encoding="utf-8"
)
self.assertNotIn("fastapi", service_source.lower())
def test_exactly_one_new_api_route_and_no_state_changing_page_get(self):
routes = [
(route.path, route.methods)
for route in detail_router.routes
]
self.assertEqual(routes, [("/api/miners/{miner_id}/overview", {"GET"})])
page = next(
route for route in frontend_router.routes
if route.path == "/miners/{miner_id}/detail"
)
self.assertEqual(page.methods, {"GET"})
if __name__ == "__main__":
unittest.main()
+448
View File
@@ -0,0 +1,448 @@
import ast
import time
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from uuid import uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
import psycopg
from app.config import settings
from app.identity import get_current_user, protected_page, required_page_permission
from app.monitoring.dependencies import get_monitoring_service
from app.monitoring.frontend import router as monitoring_frontend_router
from app.monitoring.models import MonitoringTrendQuery
from app.monitoring.repository import MonitoringRepository, TREND_SQL
from app.monitoring.router import router as monitoring_router
from app.monitoring.service import MonitoringService
NOW = datetime(2026, 8, 17, 12, 2, tzinfo=timezone.utc)
APP_ROOT = Path(__file__).parents[1] / "app"
STATIC_ROOT = APP_ROOT / "frontend" / "static"
def trend_rows(count=72, *, scope_exists=True, label="Fleet"):
start = datetime(2026, 8, 17, 6, 5, tzinfo=timezone.utc)
rows = []
for index in range(count):
bucket_start = start + timedelta(minutes=5 * index)
rows.append({
"scope_exists": scope_exists,
"scope_label": label,
"bucket_start": bucket_start,
"bucket_end": bucket_start + timedelta(minutes=5),
"hashrate_ths": 0 if index == 0 else None,
"power_w": 0 if index == 0 else None,
"observation_count": 1 if index < 2 else 0,
"hashrate_sample_count": 1 if index == 0 else 0,
"power_sample_count": 1 if index == 0 else 0,
"observed_miner_count": 1 if index < 2 else 0,
"hashrate_contributing_miner_count": 1 if index == 0 else 0,
"power_contributing_miner_count": 1 if index == 0 else 0,
"scoped_miner_count": 2,
"latest_observed_at": start + timedelta(minutes=1),
})
return rows
class FakeMonitoringRepository:
def __init__(self, rows=None):
self.rows = rows or trend_rows()
self.trend_calls = []
self.option_calls = 0
def read_trends(self, **kwargs):
self.trend_calls.append(kwargs)
return self.rows
def list_location_scope_options(self):
self.option_calls += 1
site_id, room_id = uuid4(), uuid4()
return [
{"option_type": "site", "id": site_id, "site_id": None,
"name": '<script>alert("site")</script>'},
{"option_type": "room", "id": room_id, "site_id": site_id,
"name": "Room 1"},
]
class MonitoringServiceTests(unittest.TestCase):
def test_fixed_ranges_emit_expected_bounded_points(self):
expected = {"6h": (72, 300), "24h": (96, 900), "7d": (168, 3600)}
for range_name, (count, seconds) in expected.items():
with self.subTest(range_name=range_name):
rows = trend_rows(count)
repository = FakeMonitoringRepository(rows)
response = MonitoringService(repository).get_trends(
MonitoringTrendQuery(scope="fleet", range=range_name),
include_location_scopes=False,
generated_at=NOW,
)
self.assertEqual(len(response.data.series), 2)
self.assertEqual(len(response.data.series[0].points), count)
self.assertLessEqual(count, response.meta.maximum_points)
self.assertEqual(response.meta.bucket_seconds, seconds)
self.assertEqual(response.meta.database_query_count, 1)
def test_zero_null_and_empty_bucket_semantics_remain_distinct(self):
response = MonitoringService(FakeMonitoringRepository()).get_trends(
MonitoringTrendQuery(scope="fleet", range="6h"),
include_location_scopes=False,
generated_at=NOW,
)
points = response.data.series[0].points
self.assertEqual(points[0].value, 0)
self.assertEqual(points[0].metric_sample_count, 1)
self.assertIsNone(points[1].value)
self.assertEqual(points[1].observation_count, 1)
self.assertEqual(points[1].metric_sample_count, 0)
self.assertIsNone(points[2].value)
self.assertEqual(points[2].observation_count, 0)
def test_location_options_and_attribution_are_permission_aware(self):
repository = FakeMonitoringRepository(trend_rows(label="North Site"))
response = MonitoringService(repository).get_trends(
MonitoringTrendQuery(scope="site", scope_id=uuid4(), range="6h"),
include_location_scopes=True,
generated_at=NOW,
)
self.assertEqual(response.meta.attribution, "current_placement_cohort")
self.assertEqual(response.meta.database_query_count, 2)
self.assertEqual(repository.option_calls, 1)
self.assertEqual(len(response.data.available_scopes.sites), 1)
repository = FakeMonitoringRepository()
response = MonitoringService(repository).get_trends(
MonitoringTrendQuery(scope="fleet", range="6h"),
include_location_scopes=False,
generated_at=NOW,
)
self.assertEqual(response.data.available_scopes.sites, [])
self.assertEqual(repository.option_calls, 0)
class MonitoringRouterTests(unittest.TestCase):
def setUp(self):
self.repository = FakeMonitoringRepository()
self.service = MonitoringService(self.repository)
self.app = FastAPI()
self.app.include_router(monitoring_router)
self.app.dependency_overrides[get_monitoring_service] = lambda: self.service
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "role": "Custom", "permissions": list(permissions)
}
def test_authentication_permission_and_location_scope_boundaries(self):
self.assertEqual(self.client.get("/api/monitoring/trends").status_code, 401)
self.authenticate(())
self.assertEqual(self.client.get("/api/monitoring/trends").status_code, 403)
self.authenticate(("miners.view",))
self.assertEqual(self.client.get("/api/monitoring/trends").status_code, 200)
response = self.client.get(
"/api/monitoring/trends",
params={"scope": "site", "scope_id": uuid4(), "range": "6h"},
)
self.assertEqual(response.status_code, 403)
self.assertEqual(self.repository.trend_calls[-1]["scope"], "fleet")
def test_typed_validation_rejects_unbounded_or_ambiguous_queries(self):
self.authenticate(("miners.view", "locations.view"))
cases = (
{"scope": "fleet", "scope_id": uuid4()},
{"scope": "miner"},
{"scope": "miner", "scope_id": "bad"},
{"scope": "fleet", "range": "30d"},
{"scope": "anything"},
{"scope": "fleet", "resolution": "1 second"},
)
for query in cases[:-1]:
with self.subTest(query=query):
self.assertEqual(
self.client.get("/api/monitoring/trends", params=query).status_code,
422,
)
response = self.client.get("/api/monitoring/trends", params=cases[-1])
self.assertEqual(response.status_code, 200)
self.assertNotIn("resolution", self.repository.trend_calls[-1])
def test_typed_envelope_returns_both_series_and_no_store(self):
self.authenticate(("miners.view",))
response = self.client.get("/api/monitoring/trends?scope=fleet&range=6h")
self.assertEqual(response.status_code, 200)
self.assertEqual(
self.repository.trend_calls[-1]["window_end"]
- self.repository.trend_calls[-1]["window_start"],
timedelta(hours=6),
)
self.assertEqual(response.headers["cache-control"], "no-store")
payload = response.json()
self.assertEqual([item["metric"] for item in payload["data"]["series"]],
["hashrate", "power"])
self.assertEqual(payload["meta"]["source"], "raw_snapshots")
self.assertEqual(payload["meta"]["completeness"], "not_guaranteed")
self.assertIsNone(payload["error"])
class MonitoringRepositoryIntegrationTests(unittest.TestCase):
def setUp(self):
self.connection = psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
)
self.repository = MonitoringRepository(connection=self.connection)
def tearDown(self):
self.connection.rollback()
self.connection.close()
def test_location_scope_options_execute_and_order_deterministically(self):
site_alpha, site_beta = uuid4(), uuid4()
room_alpha, room_beta = uuid4(), uuid4()
suffix = uuid4().hex[:10]
with self.connection.cursor() as cur:
cur.execute(
"INSERT INTO sites (id, name) VALUES (%s, %s), (%s, %s)",
(site_alpha, "Scope Alpha " + suffix,
site_beta, "scope Beta " + suffix),
)
cur.execute(
"INSERT INTO rooms (id, site_id, name) VALUES "
"(%s, %s, %s), (%s, %s, %s)",
(room_alpha, site_alpha, "Scope Alpha Room " + suffix,
room_beta, site_beta, "scope Beta Room " + suffix),
)
rows = self.repository.list_location_scope_options()
site_rows = [row for row in rows if row["option_type"] == "site"]
room_rows = [row for row in rows if row["option_type"] == "room"]
self.assertEqual(
[row["option_type"] for row in rows],
["site"] * len(site_rows) + ["room"] * len(room_rows),
)
ordering = lambda row: (row["name"].lower(), str(row["id"]))
self.assertEqual(site_rows, sorted(site_rows, key=ordering))
self.assertEqual(room_rows, sorted(room_rows, key=ordering))
by_id = {row["id"]: row for row in rows}
self.assertIsNone(by_id[site_alpha]["site_id"])
self.assertIsNone(by_id[site_beta]["site_id"])
self.assertEqual(by_id[room_alpha]["site_id"], site_alpha)
self.assertEqual(by_id[room_beta]["site_id"], site_beta)
def test_per_miner_first_math_counts_and_current_location_precedence(self):
site_a, site_b, room_a = uuid4(), uuid4(), uuid4()
miner_a, miner_b = uuid4(), uuid4()
suffix = uuid4().hex[:10]
with self.connection.cursor() as cur:
cur.execute(
"INSERT INTO sites (id, name) VALUES (%s, %s), (%s, %s)",
(site_a, "Trend A " + suffix, site_b, "Trend B " + suffix),
)
cur.execute(
"INSERT INTO rooms (id, site_id, name) VALUES (%s, %s, %s)",
(room_a, site_a, "Trend Room " + suffix),
)
cur.execute(
"INSERT INTO miners (id, site_id, room_id, asset_tag, lifecycle_status) "
"VALUES (%s, %s, NULL, %s, 'active'), "
"(%s, %s, %s, %s, 'active')",
(miner_a, site_b, "TREND-A-" + suffix,
miner_b, site_a, room_a, "TREND-B-" + suffix),
)
cur.execute(
"INSERT INTO location_positions "
"(room_id, room_name, rack_name, shelf_name, position_index, miner_id) "
"VALUES (%s, %s, %s, '1', 1, %s)",
(room_a, "Trend Room " + suffix, "Rack " + suffix, miner_a),
)
observed = datetime(2026, 8, 17, 11, 56, tzinfo=timezone.utc)
cur.execute(
"INSERT INTO miner_status_snapshots "
"(miner_id, observed_at, mhs_av, power_w) VALUES "
"(%s, %s, 100000000, 1000), (%s, %s, 200000000, NULL), "
"(%s, %s, 0, 0), (%s, %s, NULL, 3000)",
(miner_a, observed, miner_a, observed + timedelta(minutes=1),
miner_b, observed, miner_b, observed + timedelta(minutes=1)),
)
rows = self.repository.read_trends(
scope="site", scope_id=site_a,
window_start=datetime(2026, 8, 17, 6, 5, tzinfo=timezone.utc),
window_end=datetime(2026, 8, 17, 12, 5, tzinfo=timezone.utc),
bucket_width=timedelta(minutes=5),
)
point = next(row for row in rows if row["bucket_start"].hour == 11
and row["bucket_start"].minute == 55)
self.assertEqual(float(point["hashrate_ths"]), 150.0)
self.assertEqual(float(point["power_w"]), 2500.0)
self.assertEqual(point["observation_count"], 4)
self.assertEqual(point["hashrate_sample_count"], 3)
self.assertEqual(point["power_sample_count"], 3)
self.assertEqual(point["observed_miner_count"], 2)
self.assertEqual(point["hashrate_contributing_miner_count"], 2)
self.assertEqual(point["scoped_miner_count"], 2)
empty = next(row for row in rows if row["bucket_start"].hour == 11
and row["bucket_start"].minute == 50)
self.assertIsNone(empty["hashrate_ths"])
self.assertEqual(empty["observation_count"], 0)
self.assertEqual(len(rows), 72)
def test_utc_bucket_is_independent_of_session_timezone(self):
miner_id = uuid4()
with self.connection.cursor() as cur:
cur.execute("SET LOCAL TIME ZONE 'Pacific/Honolulu'")
cur.execute(
"INSERT INTO miners (id, asset_tag, lifecycle_status) "
"VALUES (%s, %s, 'active')",
(miner_id, "TZ-" + uuid4().hex[:10]),
)
cur.execute(
"INSERT INTO miner_status_snapshots "
"(miner_id, observed_at, mhs_av) VALUES (%s, %s, 1000000)",
(miner_id, datetime.fromisoformat("2026-08-17T07:01:00-05:00")),
)
rows = self.repository.read_trends(
scope="miner", scope_id=miner_id,
window_start=datetime(2026, 8, 17, 6, 5, tzinfo=timezone.utc),
window_end=datetime(2026, 8, 17, 12, 5, tzinfo=timezone.utc),
bucket_width=timedelta(minutes=5),
)
value = next(row for row in rows
if row["bucket_start"].astimezone(timezone.utc).hour == 12)
self.assertEqual(float(value["hashrate_ths"]), 1.0)
def test_representative_raw_query_is_bounded(self):
prefix = "BENCH-" + uuid4().hex[:8]
with self.connection.cursor() as cur:
cur.execute(
"INSERT INTO miners (asset_tag, lifecycle_status) "
"SELECT %s || '-' || value, 'active' "
"FROM generate_series(1, 500) AS value",
(prefix,),
)
cur.execute(
"INSERT INTO miner_status_snapshots "
"(miner_id, observed_at, mhs_av, power_w) "
"SELECT miners.id, %s::timestamptz - value * interval '1 hour', "
"100000000, 3200 FROM miners "
"CROSS JOIN generate_series(0, 167) AS value "
"WHERE miners.asset_tag LIKE %s",
(NOW, prefix + "%"),
)
started = time.monotonic()
rows = self.repository.read_trends(
scope="fleet", scope_id=None,
window_start=datetime(2026, 8, 10, 13, 0, tzinfo=timezone.utc),
window_end=datetime(2026, 8, 17, 13, 0, tzinfo=timezone.utc),
bucket_width=timedelta(hours=1),
)
elapsed = time.monotonic() - started
self.assertEqual(len(rows), 168)
self.assertLess(elapsed, 15.0)
print(f"Monitoring synthetic benchmark: 84,000 rows, 168 points, {elapsed:.3f}s")
class MonitoringFrontendTests(unittest.TestCase):
def setUp(self):
self.app = FastAPI()
self.app.include_router(monitoring_frontend_router)
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "username": "reader", "display_name": "Reader",
"role": "Custom", "permissions": list(permissions),
}
def test_page_requires_scope_aligned_permissions_and_renders_shell(self):
self.assertEqual(self.client.get("/monitoring/analytics").status_code, 401)
self.authenticate(("miners.view",))
response = self.client.get("/monitoring/analytics")
self.assertEqual(response.status_code, 200)
self.assertIn('data-view="analytics"', response.text)
self.assertIn("/assets/minerops-monitoring-trends.js", response.text)
self.assertNotIn('<option value="site">', response.text)
response = self.client.get(
"/monitoring/analytics",
params={"scope": "site", "scope_id": uuid4(), "range": "24h"},
)
self.assertEqual(response.status_code, 403)
self.authenticate(("miners.view", "locations.view"))
self.assertEqual(self.client.get(
"/monitoring/analytics",
params={"scope": "site", "scope_id": uuid4(), "range": "24h"},
).status_code, 200)
def test_script_is_one_request_read_only_safe_dom_and_accessible(self):
source = (STATIC_ROOT / "monitoring-trends.js").read_text(encoding="utf-8")
self.assertEqual(source.count('fetch(url,'), 1)
self.assertIn("return el.site.value || state.scopeId", source)
self.assertIn("return el.room.value || state.scopeId", source)
self.assertNotIn("setInterval", source)
for method in ('method: "POST"', 'method: "PATCH"', 'method: "PUT"',
'method: "DELETE"'):
self.assertNotIn(method, source)
for unsafe in ("innerHTML", "outerHTML", "insertAdjacentHTML",
"document.write", "eval(", "Function("):
self.assertNotIn(unsafe, source)
for expected in ("textContent", "tabindex", "aria-label", "View ",
"Gaps indicate", "prefers-reduced-motion"):
combined = source + (STATIC_ROOT / "monitoring-trends.css").read_text()
self.assertIn(expected, combined)
for forbidden in ("threshold", "healthy range", "kWh", "30d"):
self.assertNotIn(forbidden, source)
def test_navigation_identity_and_miner_detail_use_canonical_durable_link(self):
self.assertTrue(protected_page("/monitoring/analytics"))
self.assertEqual(required_page_permission("/monitoring/analytics"), "miners.view")
detail_html = (APP_ROOT / "frontend" / "miner_detail_view.py").read_text()
detail_js = (STATIC_ROOT / "miner-detail.js").read_text()
self.assertIn("View trends", detail_html)
self.assertIn('scope: "miner", scope_id: data.miner.id, range: "24h"', detail_js)
self.assertIn('"/monitoring/analytics?"', detail_js)
class MonitoringArchitectureTests(unittest.TestCase):
def test_layering_routes_and_sql_are_bounded(self):
for relative in ("monitoring/router.py", "monitoring/service.py",
"monitoring/frontend.py"):
source = (APP_ROOT / relative).read_text(encoding="utf-8")
modules = []
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Import):
modules.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
modules.append(node.module)
self.assertFalse(any(name.startswith(("psycopg", "asyncpg", "sqlalchemy"))
for name in modules))
for statement in ("SELECT ", "INSERT ", "UPDATE ", "DELETE "):
self.assertNotIn(statement, source.upper())
service = (APP_ROOT / "monitoring" / "service.py").read_text()
repository = (APP_ROOT / "monitoring" / "repository.py").read_text()
self.assertNotIn("fastapi", service.lower())
self.assertEqual(repository.count("cur.execute("), 2)
self.assertIn("miner_status_snapshots", TREND_SQL)
self.assertNotIn("miner_status_hourly_rollups", TREND_SQL)
self.assertNotIn("miner_status_daily_rollups", TREND_SQL)
self.assertIn("date_bin", TREND_SQL)
self.assertIn("%s", TREND_SQL)
self.assertEqual([(route.path, route.methods) for route in monitoring_router.routes],
[("/api/monitoring/trends", {"GET"})])
if __name__ == "__main__":
unittest.main()
+430
View File
@@ -0,0 +1,430 @@
import inspect
import unittest
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from app.frontend.router import router as frontend_router
from app.identity import get_current_user, required_api_permission
from app.repairs.center_repository import RepairCenterRepository
from app.repairs.center_service import RepairCenterService
from app.repairs.models import RepairCenterAccess, RepairCenterFilters
NOW = datetime(2026, 8, 11, 12, 0, tzinfo=timezone.utc)
STATIC_ROOT = Path(__file__).parents[1] / "app" / "frontend" / "static"
class FakeCenterRepository:
def __init__(self):
self.arguments = None
self.rows = []
def list_repair_center(self, filters, **permissions):
self.arguments = (filters, permissions)
return {
"items": self.rows,
"summary": {"total": len(self.rows), "active": len(self.rows),
"historical": 0, "status_counts": {"OPEN": len(self.rows)}},
"location_options": {"sites": [], "rooms": []},
}
def center_row(source="modular", active=True):
repair_id = uuid4() if source == "modular" else None
return {
"repair_id": repair_id,
"source": source,
"active": active,
"opened_at": NOW,
"closed_at": None,
"repair_status": "OPEN" if source == "modular" else "testing",
"reason": "HARDWARE_FAILURE" if source == "modular" else None,
"reported_problem": "Hashboard not detected",
"latest_context": "Testing replacement board",
"latest_context_at": NOW,
"opened_by_display_name": "Repair Tech" if source == "modular" else None,
"miner_id": uuid4(),
"asset_id": "MINER-100",
"model": "M60S",
"miner_sn": "SN-100",
"power_sn": "PSU-100",
"current_mac": "00:11:22:33:44:55",
"current_ip": "192.0.2.10",
"lifecycle_status": "in_repair",
"runtime_status": "offline",
"site_id": uuid4(),
"site_name": "Site A",
"room_id": uuid4(),
"room_name": "Room 1",
"rack_label": "12",
"shelf_label": "3",
"position_id": uuid4(),
"position_label": "P04",
"position_index": 4,
"parts_count": 1 if source == "modular" else None,
"parts_summary": ["Fan"] if source == "modular" else [],
"legacy_event_status": "testing" if source == "legacy" else None,
"legacy_asset_state": "repair" if source == "legacy" else None,
}
class RepairCenterServiceTests(unittest.TestCase):
def setUp(self):
self.repository = FakeCenterRepository()
self.service = RepairCenterService(self.repository)
def test_modular_capabilities_use_repair_uuid_and_permissions(self):
self.repository.rows = [center_row()]
result = self.service.list_repair_center(
RepairCenterFilters(),
RepairCenterAccess(can_read_repairs=True, can_view_miners=True,
can_view_locations=True, can_complete_repairs=True,
can_manage_parts=True, can_manage_legacy=True),
)
item = result.data.items[0]
self.assertTrue(item.capabilities.can_read_modular_timeline)
self.assertTrue(item.capabilities.can_manage_modular_parts)
self.assertTrue(item.capabilities.can_complete_modular_repair)
self.assertFalse(item.capabilities.can_manage_legacy)
self.assertTrue(item.capabilities.can_open_map)
def test_part_management_requires_modular_read_access(self):
self.repository.rows = [center_row()]
result = self.service.list_repair_center(
RepairCenterFilters(),
RepairCenterAccess(can_manage_parts=True),
)
self.assertFalse(
result.data.items[0].capabilities.can_manage_modular_parts
)
def test_legacy_capabilities_never_enable_modular_mutations(self):
self.repository.rows = [center_row("legacy")]
result = self.service.list_repair_center(
RepairCenterFilters(),
RepairCenterAccess(can_read_repairs=True, can_view_miners=True,
can_view_locations=True, can_complete_repairs=True,
can_manage_parts=True, can_manage_legacy=True),
)
item = result.data.items[0]
self.assertTrue(item.capabilities.can_manage_legacy)
self.assertTrue(item.capabilities.can_mark_legacy_repaired)
self.assertFalse(item.capabilities.can_complete_modular_repair)
self.assertFalse(item.capabilities.can_manage_modular_parts)
def test_repository_receives_explicit_redaction_flags(self):
self.service.list_repair_center(
RepairCenterFilters(query=" fan "), RepairCenterAccess()
)
filters, permissions = self.repository.arguments
self.assertEqual(filters["query"], "fan")
self.assertFalse(permissions["include_miner_details"])
self.assertFalse(permissions["include_location_details"])
def test_pagination_and_exact_summary_are_preserved(self):
self.repository.rows = [center_row()]
result = self.service.list_repair_center(
RepairCenterFilters(limit=25, offset=50), RepairCenterAccess()
)
self.assertEqual(result.meta.limit, 25)
self.assertEqual(result.meta.offset, 50)
self.assertEqual(result.meta.returned, 1)
self.assertEqual(result.meta.total, 1)
self.assertEqual(result.data.summary.status_counts, {"OPEN": 1})
class RepairCenterDatabaseTests(unittest.TestCase):
def test_empty_database_projection_executes_as_typed_set_query(self):
result = RepairCenterRepository().list_repair_center(
{"scope": "active", "limit": 25, "offset": 0},
include_miner_details=True,
include_location_details=True,
)
self.assertEqual(result["items"], [])
self.assertEqual(result["summary"]["total"], 0)
self.assertEqual(result["location_options"], {"sites": [], "rooms": []})
def test_projection_consolidates_sources_filters_and_pages_exactly(self):
repository = RepairCenterRepository()
actor_id = uuid4()
site_id = uuid4()
room_id = uuid4()
modular_miner_id = uuid4()
legacy_miner_id = uuid4()
completed_miner_id = uuid4()
connection = repository._connection_factory()
try:
with connection:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO users (
id, username, display_name, password_hash, role_id
)
SELECT %s, %s, 'Repair Center Actor', 'not-used', id
FROM roles WHERE name = 'Admin'
""",
(actor_id, f"repair-center-{actor_id}"),
)
cur.execute(
"INSERT INTO sites (id, name) VALUES (%s, %s)",
(site_id, f"Repair Center Site {site_id}"),
)
cur.execute(
"""
INSERT INTO rooms (id, site_id, name)
VALUES (%s, %s, 'Repair Room')
""",
(room_id, site_id),
)
for miner_id, asset_id, lifecycle in (
(modular_miner_id, "RC-MODULAR", "in_repair"),
(legacy_miner_id, "RC-LEGACY", "in_repair"),
(completed_miner_id, "RC-COMPLETED", "active"),
):
cur.execute(
"""
INSERT INTO miners (
id, asset_tag, model, status, lifecycle_status,
first_seen_at, last_seen_at
)
VALUES (%s, %s, 'M60S', 'offline', %s, NULL, NULL)
""",
(miner_id, asset_id, lifecycle),
)
for index, miner_id in enumerate(
(modular_miner_id, legacy_miner_id), start=1
):
cur.execute(
"""
INSERT INTO location_positions (
room_id, room_name, rack_name, shelf_name,
position_index, position_label, miner_id
)
VALUES (%s, 'Repair Room', 'Rack 1', 'Shelf A',
%s, %s, %s)
""",
(room_id, index, f"P{index:02d}", miner_id),
)
cur.execute(
"""
INSERT INTO miner_location_states (
miner_id, asset_state, state_notes,
updated_by_user_id
)
VALUES (%s, 'repair', 'Legacy repair context', %s)
""",
(miner_id, actor_id),
)
cur.execute(
"""
INSERT INTO miner_maintenance_events (
miner_id, event_type, event_status, event_title,
description, created_by_user_id
)
VALUES (%s, 'repair', 'waiting_parts',
'Legacy board issue', 'Waiting for board', %s)
""",
(miner_id, actor_id),
)
cur.execute(
"""
INSERT INTO repairs (miner_id, reason, opened_by)
VALUES (%s, 'CONTROL_BOARD', %s)
""",
(modular_miner_id, actor_id),
)
cur.execute(
"""
INSERT INTO repairs (
miner_id, status, reason, opened_by, completed_at,
completed_by, resulting_lifecycle
)
VALUES (%s, 'COMPLETED', 'INSPECTION', %s, now(),
%s, 'active')
""",
(completed_miner_id, actor_id, actor_id),
)
finally:
connection.close()
from app.main import minerops_repair_reject_modular_mutation
boundary_connection = repository._connection_factory()
try:
with boundary_connection.cursor() as cur:
with self.assertRaises(HTTPException) as raised:
minerops_repair_reject_modular_mutation(
cur, str(modular_miner_id)
)
self.assertEqual(raised.exception.status_code, 409)
minerops_repair_reject_modular_mutation(
cur, str(legacy_miner_id)
)
finally:
boundary_connection.close()
active = repository.list_repair_center(
{"scope": "active", "limit": 1, "offset": 0},
include_miner_details=True,
include_location_details=True,
)
self.assertEqual(active["summary"]["total"], 2)
self.assertEqual(len(active["items"]), 1)
self.assertEqual(active["summary"]["active"], 2)
self.assertEqual(active["location_options"]["sites"][0]["id"], str(site_id))
all_active = repository.list_repair_center(
{"scope": "active", "limit": 25, "offset": 0},
include_miner_details=True,
include_location_details=True,
)
by_miner = {row["miner_id"]: row for row in all_active["items"]}
self.assertEqual(set(by_miner), {modular_miner_id, legacy_miner_id})
self.assertEqual(by_miner[modular_miner_id]["source"], "modular")
self.assertEqual(by_miner[legacy_miner_id]["source"], "legacy")
self.assertEqual(by_miner[legacy_miner_id]["repair_event_count"], 1)
self.assertEqual(
by_miner[legacy_miner_id]["latest_event_title"],
"Legacy board issue",
)
self.assertEqual(all_active["summary"]["model_counts"], {"M60S": 2})
self.assertEqual(
all_active["summary"]["error_counts"],
{"CONTROL BOARD": 1, "Legacy board issue": 1},
)
filtered = repository.list_repair_center(
{"scope": "all", "query": "RC-COMPLETED", "model": "m60s",
"limit": 25, "offset": 0},
include_miner_details=True,
include_location_details=True,
)
self.assertEqual(filtered["summary"]["total"], 1)
self.assertFalse(filtered["items"][0]["active"])
redacted = repository.list_repair_center(
{"scope": "active", "limit": 25, "offset": 0},
include_miner_details=False,
include_location_details=False,
)
self.assertEqual(redacted["summary"]["total"], 2)
self.assertTrue(all(row["asset_id"] is None for row in redacted["items"]))
self.assertTrue(all(row["site_id"] is None for row in redacted["items"]))
self.assertEqual(redacted["location_options"], {"sites": [], "rooms": []})
class RepairCenterRepositoryAuditTests(unittest.TestCase):
def test_projection_prevents_duplicate_active_legacy_rows(self):
source = inspect.getsource(RepairCenterRepository._ctes)
self.assertIn("NOT EXISTS", source)
self.assertIn("active_repair.miner_id = miner.id", source)
self.assertIn("UNION ALL", source)
self.assertIn("part_rollup", source)
self.assertIn("placement", source)
def test_projection_uses_constant_set_based_queries(self):
source = inspect.getsource(RepairCenterRepository.list_repair_center)
self.assertEqual(source.count("cur.execute("), 3)
self.assertNotIn("for item in", source)
self.assertIn("count(*) FILTER", source)
self.assertIn("LIMIT %s OFFSET %s", source)
def test_filter_sql_is_parameterized_and_complete_result_scoped(self):
where, params = RepairCenterRepository._where({
"scope": "historical", "status": "COMPLETED", "query": "fan",
"site_id": uuid4(), "room_id": uuid4(),
})
self.assertIn("NOT candidate.active", where)
self.assertIn("candidate.repair_status = %s", where)
self.assertIn("candidate.site_id = %s", where)
self.assertTrue(params[-1].startswith("%fan"))
class RepairCenterFrontendTests(unittest.TestCase):
def setUp(self):
self.app = FastAPI()
self.app.include_router(frontend_router)
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "username": "tech", "display_name": "Repair Tech",
"role": "Technician", "permissions": ["repairs.view", "repairs.read",
"repairs.manage", "repairs.complete", "repairs.parts.manage",
"miners.view", "locations.view"],
}
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def test_page_has_semantic_queue_filters_paging_and_inspector(self):
response = self.client.get("/operations/repairs")
self.assertEqual(response.status_code, 200)
for value in ('id="repairFilters"', 'id="repairRows"',
'id="repairInspector"', 'id="repairPrevious"', 'id="repairNext"',
'<th>Reported problem</th>', '/assets/minerops-repair-center.js'):
self.assertIn(value, response.text)
def test_location_filters_are_not_rendered_without_permission(self):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "role": "Custom", "permissions": ["repairs.view"]
}
text = self.client.get("/operations/repairs").text
self.assertNotIn('id="repairSite"', text)
self.assertNotIn('id="repairModel"', text)
self.assertNotIn('id="repairRoom"', text)
def test_restricted_filter_parameters_are_rejected(self):
from app.main import minerops_repair_queue_api
arguments = {
"query": None, "scope": "active", "status_filter": None,
"model": None, "lifecycle_status": None, "site_id": None,
"room_id": None, "limit": 50, "offset": 0,
"current_user": {"permissions": ["repairs.view"]},
}
for field, value in (("model", "M60S"), ("site_id", uuid4())):
with self.subTest(field=field):
with self.assertRaises(HTTPException) as raised:
minerops_repair_queue_api(**{**arguments, field: value})
self.assertEqual(raised.exception.status_code, 403)
def test_frontend_is_one_initial_read_without_polling_or_unsafe_html(self):
source = (STATIC_ROOT / "repair-center.js").read_text(encoding="utf-8")
self.assertEqual(source.count('api("/api/repairs/queue?"'), 1)
self.assertNotIn("setInterval", source)
for unsafe in ("innerHTML", "insertAdjacentHTML", "document.write"):
self.assertNotIn(unsafe, source)
self.assertIn("Promise.all", source)
self.assertIn("encodeURIComponent", source)
self.assertIn("URLSearchParams", source)
def test_mutations_are_source_specific_and_gets_are_read_only(self):
source = (STATIC_ROOT / "repair-center.js").read_text(encoding="utf-8")
self.assertIn('/complete"', source)
self.assertIn('/parts"', source)
self.assertIn('"/note" : "/status"', source)
self.assertIn('/state"', source)
self.assertNotIn('method: "GET"', source)
self.assertIn("Type DECOMMISSION", source)
def test_miner_and_map_links_use_durable_safe_identifiers(self):
source = (STATIC_ROOT / "repair-center.js").read_text(encoding="utf-8")
self.assertIn('encodeURIComponent(item.miner_id) + "/detail"', source)
self.assertIn("site_id: item.site_id", source)
self.assertIn("room_id: item.room_id", source)
self.assertIn("miner_id: item.miner_id", source)
def test_history_get_requires_repairs_read(self):
self.assertEqual(required_api_permission(
"GET", f"/api/miners/{uuid4()}/maintenance-events"), "repairs.read")
self.assertEqual(required_api_permission(
"PATCH", f"/api/miners/{uuid4()}/maintenance-events/{uuid4()}"),
"repairs.manage")
if __name__ == "__main__":
unittest.main()
+455
View File
@@ -0,0 +1,455 @@
import ast
from contextlib import contextmanager
from copy import deepcopy
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
import unittest
from uuid import uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError
from app.components.dependencies import get_component_service
from app.components.exceptions import ComponentValidationError
from app.components.models import ComponentCreateRequest, ComponentDetail
from app.components.router import router as component_router
from app.components.service import ComponentService
from app.identity import get_current_user, required_api_permission
from app.repairs.dependencies import get_repair_service
from app.repairs.exceptions import RepairPartAlreadyVoided
from app.repairs.models import (
ConsumableInput,
RepairPartComponentSnapshot,
RepairPartConsumable,
RepairPartDetail,
RepairPartList,
RepairPartRecordRequest,
RepairPartVoidRequest,
)
from app.repairs.router import router as repair_router
from app.repairs.service import RepairService
NOW = datetime(2026, 8, 2, 12, 0, tzinfo=timezone.utc)
class FakeComponentRepository:
def __init__(self):
self.rows = {}
self.calls = []
@contextmanager
def transaction(self):
self.calls.append("transaction")
yield self
def create_component(self, **values):
component_id = uuid4()
row = {
"component_id": component_id,
"created_at": NOW,
**values,
}
self.rows[component_id] = row
return row
def get_component(self, component_id):
return self.rows.get(component_id)
class ComponentServiceTests(unittest.TestCase):
def test_create_normalizes_metadata_and_uses_authoritative_fields(self):
repository = FakeComponentRepository()
actor = uuid4()
component = ComponentService(repository).create_component(
ComponentCreateRequest(
component_type="hashboard",
asset_id=" HB-0042 ",
manufacturer=" MicroBT ",
),
actor_user_id=actor,
)
self.assertEqual(component.asset_id, "HB-0042")
self.assertEqual(component.manufacturer, "MicroBT")
self.assertEqual(component.created_by, actor)
self.assertIsNotNone(component.component_id)
self.assertEqual(repository.calls, ["transaction"])
def test_component_request_rejects_blank_unknown_and_server_fields(self):
for body, exception in (
({"component_type": "other"}, ValidationError),
({"component_type": "fan", "asset_id": " "}, ComponentValidationError),
({"component_type": "fan", "created_by": str(uuid4())}, ValidationError),
):
with self.subTest(body=body), self.assertRaises(exception):
request = ComponentCreateRequest(**body)
ComponentService(FakeComponentRepository()).create_component(
request, actor_user_id=uuid4()
)
class FakeRepairPartRepository:
def __init__(self):
self.repair_id = uuid4()
self.miner_id = uuid4()
self.actor_id = uuid4()
self.component_id = uuid4()
self.miner = {"id": self.miner_id, "lifecycle_status": "in_repair"}
self.repair = {
"id": self.repair_id,
"miner_id": self.miner_id,
"status": "OPEN",
}
self.component = {
"id": self.component_id,
"component_type": "control_board",
"asset_id": "CB-42",
"manufacturer": "MicroBT",
"model": None,
"manufacturer_serial": "SER-42",
}
self.events = [{"sequence": 1}]
self.parts = {}
self.calls = []
self.fail_at = None
@contextmanager
def transaction(self):
self.calls.append("transaction")
snapshot = deepcopy((self.events, self.parts))
try:
yield self
except Exception:
self.events, self.parts = snapshot
raise
def _call(self, name):
self.calls.append(name)
if self.fail_at == name:
raise RuntimeError(name)
def get_repair_identity(self, repair_id):
self._call("repair_identity")
return {"miner_id": self.miner_id} if repair_id == self.repair_id else None
def lock_miner_for_update(self, miner_id):
self._call("lock_miner")
return self.miner if miner_id == self.miner_id else None
def lock_repair_for_update(self, repair_id):
self._call("lock_repair")
return self.repair if repair_id == self.repair_id else None
def get_active_repair(self, miner_id):
self._call("active_repair")
return self.repair if miner_id == self.miner_id else None
def lock_component_for_update(self, component_id):
self._call("lock_component")
return self.component if component_id == self.component_id else None
def get_next_event_sequence(self, repair_id):
self._call("next_sequence")
return max(event["sequence"] for event in self.events) + 1
def create_event(self, repair_id, **values):
self._call("create_event")
row = {"id": uuid4(), "repair_id": repair_id, "created_at": NOW, **values}
self.events.append(row)
return row
def create_repair_part(self, **values):
self._call("create_part")
component = values["component"]
row = {
"id": values["repair_part_id"],
"repair_id": values["repair_id"],
"consumable_description": values["consumable_description"],
"quantity": values["quantity"],
"unit": values["unit"],
"component_id": component["id"] if component else None,
"component_type_snapshot": component["component_type"] if component else None,
"component_asset_id_snapshot": component["asset_id"] if component else None,
"component_manufacturer_snapshot": component["manufacturer"] if component else None,
"component_model_snapshot": component["model"] if component else None,
"component_manufacturer_serial_snapshot": component["manufacturer_serial"] if component else None,
"notes": values["notes"],
"recorded_by": values["recorded_event"]["created_by"],
"recorded_at": values["recorded_event"]["created_at"],
"recorded_event_id": values["recorded_event"]["id"],
"voided_by": None,
"voided_at": None,
"void_reason": None,
"void_event_id": None,
}
self.parts[row["id"]] = row
return row
def get_repair_part(self, repair_id, repair_part_id):
self._call("get_part")
row = self.parts.get(repair_part_id)
return row if row and row["repair_id"] == repair_id else None
def get_repair_parts(self, repair_id):
rows = [row for row in self.parts.values() if row["repair_id"] == repair_id]
return rows if repair_id == self.repair_id else None
def lock_repair_part_for_update(self, repair_id, repair_part_id):
self._call("lock_part")
return self.get_repair_part(repair_id, repair_part_id)
def void_repair_part(self, repair_id, repair_part_id, *, reason, void_event):
self._call("void_part")
row = self.get_repair_part(repair_id, repair_part_id)
if row is None or row["voided_at"] is not None:
return None
row.update(
voided_by=void_event["created_by"],
voided_at=void_event["created_at"],
void_reason=reason,
void_event_id=void_event["id"],
)
return row
class RepairPartServiceTests(unittest.TestCase):
def setUp(self):
self.repository = FakeRepairPartRepository()
self.service = RepairService(self.repository)
def test_request_xor_precision_units_and_trim(self):
valid = ConsumableInput(description=" Paste ", quantity="1.250", unit="gram")
self.assertEqual(valid.description, "Paste")
self.assertEqual(valid.quantity, Decimal("1.250"))
invalid = (
{"consumable": None, "tracked_component_uuid": None},
{"consumable": valid, "tracked_component_uuid": uuid4()},
{"consumable": {"description": "x", "quantity": "1.0001", "unit": "gram"}},
{"consumable": {"description": "x", "quantity": "1.5", "unit": "each"}},
{"consumable": {"description": "x", "quantity": "1", "unit": "kg"}},
)
for body in invalid:
with self.subTest(body=body), self.assertRaises(ValidationError):
RepairPartRecordRequest(**body)
def test_consumable_record_is_atomic_and_sequenced(self):
result = self.service.record_repair_part(
self.repository.repair_id,
RepairPartRecordRequest(
consumable={"description": " Paste ", "quantity": "2", "unit": "gram"},
notes=" Used on board ",
),
actor_user_id=self.repository.actor_id,
)
self.assertEqual(result.entry_kind, "consumable")
self.assertEqual(result.consumable.description, "Paste")
event = self.repository.events[-1]
self.assertEqual(event["event_type"], "REPAIR_PART_RECORDED")
self.assertEqual(event["sequence"], 2)
self.assertEqual(event["created_by"], self.repository.actor_id)
self.assertEqual(event["payload"]["quantity"], "2")
self.assertEqual(self.repository.calls[:5], [
"transaction", "repair_identity", "lock_miner", "lock_repair", "active_repair"
])
def test_tracked_component_uses_locked_snapshot_without_mutation(self):
before = deepcopy(self.repository.component)
result = self.service.record_repair_part(
self.repository.repair_id,
RepairPartRecordRequest(tracked_component_uuid=self.repository.component_id),
actor_user_id=self.repository.actor_id,
)
self.assertEqual(result.component_id, self.repository.component_id)
self.assertEqual(result.component_snapshot.asset_id, "CB-42")
self.assertEqual(self.repository.component, before)
self.assertLess(
self.repository.calls.index("lock_repair"),
self.repository.calls.index("lock_component"),
)
def test_void_is_one_way_and_rollbacks_leave_no_partial_event(self):
part = self.service.record_repair_part(
self.repository.repair_id,
RepairPartRecordRequest(
consumable={"description": "Fuse", "quantity": 1, "unit": "each"}
),
actor_user_id=self.repository.actor_id,
)
voided = self.service.void_repair_part(
self.repository.repair_id,
part.repair_part_id,
RepairPartVoidRequest(reason=" Wrong repair "),
actor_user_id=self.repository.actor_id,
)
self.assertEqual(voided.void_reason, "Wrong repair")
with self.assertRaises(RepairPartAlreadyVoided):
self.service.void_repair_part(
self.repository.repair_id,
part.repair_part_id,
RepairPartVoidRequest(reason="Again"),
actor_user_id=self.repository.actor_id,
)
self.assertEqual(
[event.get("event_type") for event in self.repository.events].count("REPAIR_PART_VOIDED"),
1,
)
def test_insert_failure_rolls_back_event_and_part(self):
self.repository.fail_at = "create_part"
with self.assertRaises(RuntimeError):
self.service.record_repair_part(
self.repository.repair_id,
RepairPartRecordRequest(
consumable={"description": "Fuse", "quantity": 1, "unit": "each"}
),
actor_user_id=self.repository.actor_id,
)
self.assertEqual(self.repository.events, [{"sequence": 1}])
self.assertEqual(self.repository.parts, {})
def part_detail(repair_id, actor_id):
return RepairPartDetail(
repair_part_id=uuid4(), repair_id=repair_id, entry_kind="consumable",
consumable=RepairPartConsumable(description="Fuse", quantity=1, unit="each"),
component_id=None, component_snapshot=None, notes=None,
recorded_by=actor_id, recorded_at=NOW, recorded_event_id=uuid4(),
)
class StubComponentService:
def __init__(self, actor):
self.actor = actor
def create_component(self, request, *, actor_user_id):
self.actor_seen = actor_user_id
return ComponentDetail(
component_id=uuid4(), component_type=request.component_type,
created_at=NOW, created_by=actor_user_id,
)
def get_component(self, component_id):
return ComponentDetail(
component_id=component_id, component_type="fan",
created_at=NOW, created_by=self.actor,
)
class StubRepairService:
def __init__(self, repair_id, actor):
self.repair_id = repair_id
self.actor = actor
self.detail = part_detail(repair_id, actor)
def record_repair_part(self, repair_id, request, *, actor_user_id):
self.actor_seen = actor_user_id
return self.detail
def list_repair_parts(self, repair_id):
return RepairPartList(repair_id=repair_id, entries=[self.detail])
def void_repair_part(self, repair_id, repair_part_id, request, *, actor_user_id):
self.actor_seen = actor_user_id
return self.detail.model_copy(update={
"voided_by": actor_user_id, "voided_at": NOW,
"void_reason": request.reason, "void_event_id": uuid4(),
})
class Task005RouterTests(unittest.TestCase):
def setUp(self):
self.actor = uuid4()
self.repair_id = uuid4()
self.component_service = StubComponentService(self.actor)
self.repair_service = StubRepairService(self.repair_id, self.actor)
self.app = FastAPI()
self.app.include_router(component_router)
self.app.include_router(repair_router)
self.app.dependency_overrides[get_component_service] = lambda: self.component_service
self.app.dependency_overrides[get_repair_service] = lambda: self.repair_service
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions, role="Technician"):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(self.actor), "role": role, "permissions": list(permissions)
}
def test_authentication_permissions_and_actor_authority(self):
path = f"/api/repairs/{self.repair_id}/parts"
body = {"consumable": {"description": "Fuse", "quantity": 1, "unit": "each"}}
self.assertEqual(self.client.post(path, json=body).status_code, 401)
self.authenticate(("repairs.read",), role="Read Only")
self.assertEqual(self.client.post(path, json=body).status_code, 403)
self.assertEqual(self.client.get(path).status_code, 200)
self.authenticate(("repairs.parts.manage",))
self.assertEqual(self.client.post(path, json=body).status_code, 201)
self.assertEqual(self.repair_service.actor_seen, self.actor)
def test_component_routes_permissions_and_envelopes(self):
self.authenticate(("components.read",), role="Read Only")
self.assertEqual(self.client.post("/api/components", json={"component_type": "fan"}).status_code, 403)
self.assertEqual(self.client.get(f"/api/components/{uuid4()}").status_code, 200)
self.authenticate(("components.create",))
response = self.client.post("/api/components", json={"component_type": "fan"})
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()["meta"], {})
self.assertIsNone(response.json()["error"])
def test_exact_routes_and_permission_mapping(self):
component_routes = {(route.path, tuple(sorted(route.methods))) for route in component_router.routes}
self.assertEqual(component_routes, {
("/api/components", ("POST",)),
("/api/components/{component_id}", ("GET",)),
})
part_routes = {
(route.path, tuple(sorted(route.methods)))
for route in repair_router.routes if "/parts" in route.path
}
self.assertEqual(part_routes, {
("/api/repairs/{repair_id}/parts", ("GET",)),
("/api/repairs/{repair_id}/parts", ("POST",)),
("/api/repairs/{repair_id}/parts/{repair_part_id}/void", ("POST",)),
})
repair_id = uuid4()
part_id = uuid4()
self.assertEqual(required_api_permission("GET", "/api/components"), "components.read")
self.assertEqual(required_api_permission("POST", "/api/components"), "components.create")
self.assertEqual(required_api_permission("GET", f"/api/repairs/{repair_id}/parts"), "repairs.read")
self.assertEqual(required_api_permission("POST", f"/api/repairs/{repair_id}/parts"), "repairs.parts.manage")
self.assertEqual(required_api_permission("POST", f"/api/repairs/{repair_id}/parts/{part_id}/void"), "repairs.parts.manage")
def test_architecture_boundaries(self):
root = Path(__file__).parents[1] / "app"
for relative in ("components/router.py", "repairs/router.py"):
source = (root / relative).read_text(encoding="utf-8")
tree = ast.parse(source)
imports = [
node.module for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom) and node.module
]
self.assertFalse(any(name.startswith(("psycopg", "sqlalchemy")) for name in imports))
self.assertNotIn("SELECT ", source.upper())
self.assertNotIn(".COMMIT(", source.upper())
for relative in (
"components/service.py", "components/repository.py",
"repairs/service.py", "repairs/repository.py",
):
tree = ast.parse((root / relative).read_text(encoding="utf-8"))
imports = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imports.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
imports.append(node.module)
self.assertFalse(
any(name == "fastapi" or name.startswith("fastapi.") for name in imports)
)
if __name__ == "__main__":
unittest.main()
+827
View File
@@ -0,0 +1,827 @@
import ast
import inspect
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from uuid import UUID, uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from psycopg.rows import dict_row
from starlette.routing import Match
from app.identity import get_current_user, required_api_permission
from app.repairs.dependencies import get_repair_service
from app.repairs.exceptions import RepairNotFound
from app.repairs.models import (
BeginRepairRequest,
BeginRepairResult,
RepairActor,
RepairDetail,
RepairTimeline,
RepairTimelineEvent,
)
from app.repairs.repository import RepairRepository
from app.repairs.router import router
from app.repairs.service import RepairService
OPENED_AT = datetime(2026, 7, 29, 12, 0, tzinfo=timezone.utc)
def detail_result(
repair_id: UUID | None = None,
miner_id: UUID | None = None,
) -> RepairDetail:
return RepairDetail(
repair_id=repair_id or uuid4(),
miner_id=miner_id or uuid4(),
miner_asset_id="ASSET-TIMELINE-001",
status="OPEN",
reason="INSPECTION",
opened_at=OPENED_AT,
opened_by=RepairActor(
user_id=uuid4(),
display_name="Timeline Technician",
),
closed_at=None,
closed_by=None,
miner_lifecycle="in_repair",
latest_event_sequence=2,
)
def timeline_result(repair_id: UUID | None = None) -> RepairTimeline:
return RepairTimeline(
repair_id=repair_id or uuid4(),
events=[
RepairTimelineEvent(
event_id=uuid4(),
sequence=1,
event_type="REPAIR_STARTED",
actor=RepairActor(
user_id=uuid4(),
display_name="Timeline Technician",
),
occurred_at=OPENED_AT,
payload={
"reason": "INSPECTION",
"from_lifecycle": "active",
"to_lifecycle": "in_repair",
},
),
RepairTimelineEvent(
event_id=uuid4(),
sequence=2,
event_type="INITIAL_NOTE",
actor=RepairActor(
user_id=uuid4(),
display_name="Timeline Manager",
),
occurred_at=OPENED_AT,
payload={"note": "Inspect control board."},
),
],
)
class FakeTimelineRepository:
def __init__(self):
self.detail = None
self.events = None
self.detail_repair_id = None
self.events_repair_id = None
def get_repair_detail(self, repair_id):
self.detail_repair_id = repair_id
return self.detail
def get_repair_events(self, repair_id):
self.events_repair_id = repair_id
return self.events
class RepairTimelineServiceTests(unittest.TestCase):
def setUp(self):
self.repository = FakeTimelineRepository()
self.service = RepairService(self.repository)
self.repair_id = uuid4()
self.miner_id = uuid4()
self.opened_by = uuid4()
self.closed_by = uuid4()
def detail_row(self):
return {
"repair_id": self.repair_id,
"miner_id": self.miner_id,
"miner_asset_id": "ASSET-SERVICE-001",
"status": "COMPLETED",
"reason": "PSU",
"opened_at": OPENED_AT,
"opened_by_user_id": self.opened_by,
"opened_by_display_name": "Opening Technician",
"closed_at": OPENED_AT + timedelta(hours=2),
"closed_by_user_id": self.closed_by,
"closed_by_display_name": "Closing Manager",
"miner_lifecycle": "active",
"latest_event_sequence": 3,
}
def test_detail_maps_public_fields_and_actors(self):
self.repository.detail = self.detail_row()
detail = self.service.get_repair(self.repair_id)
self.assertEqual(detail.repair_id, self.repair_id)
self.assertEqual(detail.miner_id, self.miner_id)
self.assertEqual(detail.miner_asset_id, "ASSET-SERVICE-001")
self.assertEqual(detail.status, "COMPLETED")
self.assertEqual(detail.reason, "PSU")
self.assertEqual(detail.opened_by.user_id, self.opened_by)
self.assertEqual(detail.closed_by.user_id, self.closed_by)
self.assertEqual(detail.closed_by.display_name, "Closing Manager")
self.assertEqual(detail.latest_event_sequence, 3)
self.assertEqual(self.repository.detail_repair_id, self.repair_id)
def test_detail_preserves_nullable_asset_and_empty_sequence(self):
row = self.detail_row()
row.update(
{
"miner_asset_id": None,
"status": "OPEN",
"closed_at": None,
"closed_by_user_id": None,
"closed_by_display_name": None,
"miner_lifecycle": "in_repair",
"latest_event_sequence": None,
}
)
self.repository.detail = row
detail = self.service.get_repair(self.repair_id)
self.assertIsNone(detail.miner_asset_id)
self.assertIsNone(detail.closed_at)
self.assertIsNone(detail.closed_by)
self.assertIsNone(detail.latest_event_sequence)
def test_missing_detail_raises_domain_not_found(self):
with self.assertRaises(RepairNotFound):
self.service.get_repair(self.repair_id)
def test_timeline_maps_structured_events_without_reordering(self):
actor_id = uuid4()
self.repository.events = [
{
"event_id": uuid4(),
"sequence": 1,
"event_type": "REPAIR_STARTED",
"actor_user_id": actor_id,
"actor_display_name": "Timeline Actor",
"occurred_at": OPENED_AT,
"payload": {
"reason": "OTHER",
"from_lifecycle": "spare",
"to_lifecycle": "in_repair",
},
}
]
timeline = self.service.get_repair_timeline(self.repair_id)
self.assertEqual(timeline.repair_id, self.repair_id)
self.assertEqual(len(timeline.events), 1)
self.assertEqual(timeline.events[0].sequence, 1)
self.assertEqual(timeline.events[0].actor.user_id, actor_id)
self.assertIsInstance(timeline.events[0].payload, dict)
self.assertEqual(
timeline.events[0].payload["from_lifecycle"],
"spare",
)
def test_empty_timeline_is_successful(self):
self.repository.events = []
timeline = self.service.get_repair_timeline(self.repair_id)
self.assertEqual(timeline.events, [])
def test_missing_timeline_raises_domain_not_found(self):
with self.assertRaises(RepairNotFound):
self.service.get_repair_timeline(self.repair_id)
class RepairTimelineRepositoryTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.repository = RepairRepository()
def setUp(self):
self.opened_actor = uuid4()
self.note_actor = uuid4()
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute("ALTER TABLE repair_events DISABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repair_events")
cur.execute("ALTER TABLE repair_events ENABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repairs")
cur.execute("DELETE FROM miner_tags")
cur.execute("DELETE FROM miner_asset_id_history")
cur.execute("DELETE FROM miners")
cur.execute(
"DELETE FROM users WHERE username LIKE "
"'repair-timeline-test-%'"
)
for actor_id, suffix, display_name in (
(
self.opened_actor,
"opened",
"Timeline Opening Technician",
),
(
self.note_actor,
"note",
"Timeline Note Manager",
),
):
cur.execute(
"""
INSERT INTO users (
id,
username,
display_name,
password_hash,
role_id
)
SELECT %s, %s, %s, 'not-used', id
FROM roles
WHERE name = 'Admin'
""",
(
actor_id,
f"repair-timeline-test-{suffix}",
display_name,
),
)
def create_miner(self, asset_id="ASSET-TIMELINE-DB"):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO miners (
asset_tag,
status,
lifecycle_status,
first_seen_at,
last_seen_at
)
VALUES (%s, 'unknown', 'in_repair', NULL, NULL)
RETURNING id
""",
(asset_id,),
)
return cur.fetchone()["id"]
def create_repair(self, miner_id):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO repairs (miner_id, reason, opened_by)
VALUES (%s, 'INSPECTION', %s)
RETURNING id
""",
(miner_id, self.opened_actor),
)
return cur.fetchone()["id"]
def insert_non_monotonic_events(self, repair_id):
later = OPENED_AT + timedelta(hours=1)
earlier = OPENED_AT
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO repair_events (
repair_id,
sequence,
event_type,
created_at,
created_by,
payload
)
VALUES
(
%s,
1,
'REPAIR_STARTED',
%s,
%s,
%s::jsonb
),
(
%s,
2,
'INITIAL_NOTE',
%s,
%s,
%s::jsonb
)
""",
(
repair_id,
later,
self.opened_actor,
'{"reason":"INSPECTION","from_lifecycle":'
'"active","to_lifecycle":"in_repair"}',
repair_id,
earlier,
self.note_actor,
'{"note":"Timestamp is earlier than sequence one."}',
),
)
def counts(self):
with self.repository._connection_scope() as connection:
with connection.cursor() as cur:
cur.execute(
"""
SELECT
(SELECT count(*) FROM repairs),
(SELECT count(*) FROM repair_events)
"""
)
return cur.fetchone()
def test_detail_joins_asset_lifecycle_actor_and_latest_sequence(self):
miner_id = self.create_miner()
repair_id = self.create_repair(miner_id)
self.insert_non_monotonic_events(repair_id)
detail = self.repository.get_repair_detail(repair_id)
self.assertEqual(detail["repair_id"], repair_id)
self.assertEqual(detail["miner_id"], miner_id)
self.assertEqual(detail["miner_asset_id"], "ASSET-TIMELINE-DB")
self.assertEqual(detail["miner_lifecycle"], "in_repair")
self.assertEqual(detail["opened_by_user_id"], self.opened_actor)
self.assertEqual(
detail["opened_by_display_name"],
"Timeline Opening Technician",
)
self.assertIsNone(detail["closed_at"])
self.assertIsNone(detail["closed_by_user_id"])
self.assertEqual(detail["latest_event_sequence"], 2)
def test_events_order_by_sequence_not_timestamp_and_join_actors(self):
miner_id = self.create_miner()
repair_id = self.create_repair(miner_id)
self.insert_non_monotonic_events(repair_id)
events = self.repository.get_repair_events(repair_id)
self.assertEqual([event["sequence"] for event in events], [1, 2])
self.assertGreater(
events[0]["occurred_at"],
events[1]["occurred_at"],
)
self.assertEqual(
[event["actor_display_name"] for event in events],
[
"Timeline Opening Technician",
"Timeline Note Manager",
],
)
self.assertIsInstance(events[0]["payload"], dict)
self.assertEqual(
events[1]["payload"],
{"note": "Timestamp is earlier than sequence one."},
)
def test_known_repair_with_no_events_returns_empty_timeline(self):
repair_id = self.create_repair(self.create_miner())
self.assertEqual(self.repository.get_repair_events(repair_id), [])
detail = self.repository.get_repair_detail(repair_id)
self.assertIsNone(detail["latest_event_sequence"])
def test_missing_repair_is_distinct_from_empty_timeline(self):
repair_id = uuid4()
self.assertIsNone(self.repository.get_repair_detail(repair_id))
self.assertIsNone(self.repository.get_repair_events(repair_id))
def test_reads_do_not_modify_repairs_or_events(self):
repair_id = self.create_repair(self.create_miner())
self.insert_non_monotonic_events(repair_id)
before = self.counts()
self.repository.get_repair_detail(repair_id)
self.repository.get_repair_events(repair_id)
self.assertEqual(self.counts(), before)
class StubTimelineService:
def __init__(self):
self.detail = None
self.timeline = None
self.error = None
self.detail_repair_id = None
self.timeline_repair_id = None
self.begin_called = False
def get_repair(self, repair_id):
self.detail_repair_id = repair_id
if self.error is not None:
raise self.error
return self.detail
def get_repair_timeline(self, repair_id):
self.timeline_repair_id = repair_id
if self.error is not None:
raise self.error
return self.timeline
def begin_repair(self, request: BeginRepairRequest, *, actor_user_id):
self.begin_called = True
return BeginRepairResult(
repair_id=uuid4(),
miner_id=request.miner_id,
status="OPEN",
reason=request.reason,
opened_at=OPENED_AT,
opened_by=actor_user_id,
)
class RepairTimelineRouterTests(unittest.TestCase):
def setUp(self):
self.service = StubTimelineService()
self.app = FastAPI()
self.app.include_router(router)
self.app.dependency_overrides[get_repair_service] = (
lambda: self.service
)
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(
self,
role="Read Only",
permissions=("repairs.read",),
):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()),
"username": role.lower().replace(" ", "-"),
"role": role,
"permissions": list(permissions),
}
def test_timeline_routes_require_authentication(self):
for path in (
f"/api/repairs/{uuid4()}",
f"/api/repairs/{uuid4()}/events",
):
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 401)
def test_timeline_routes_require_repairs_read(self):
self.authenticate(permissions=("repairs.view",))
for path in (
f"/api/repairs/{uuid4()}",
f"/api/repairs/{uuid4()}/events",
):
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 403)
def test_all_default_roles_can_read_detail_and_events(self):
repair_id = uuid4()
self.service.detail = detail_result(repair_id)
self.service.timeline = timeline_result(repair_id)
for role in ("Admin", "Manager", "Technician", "Read Only"):
with self.subTest(role=role):
self.authenticate(role=role)
self.assertEqual(
self.client.get(
f"/api/repairs/{repair_id}"
).status_code,
200,
)
self.assertEqual(
self.client.get(
f"/api/repairs/{repair_id}/events"
).status_code,
200,
)
def test_malformed_repair_uuid_returns_validation_error(self):
self.authenticate()
for path in (
"/api/repairs/not-a-uuid",
"/api/repairs/not-a-uuid/events",
):
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 422)
def test_missing_repair_returns_not_found(self):
self.authenticate()
repair_id = uuid4()
self.service.error = RepairNotFound(repair_id)
for path in (
f"/api/repairs/{repair_id}",
f"/api/repairs/{repair_id}/events",
):
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 404)
self.assertEqual(
response.json()["detail"],
"Repair not found",
)
def test_detail_returns_typed_public_envelope(self):
self.authenticate()
repair_id = uuid4()
expected = detail_result(repair_id)
self.service.detail = expected
response = self.client.get(f"/api/repairs/{repair_id}")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["data"]["repair_id"], str(repair_id))
self.assertEqual(body["data"]["miner_asset_id"], "ASSET-TIMELINE-001")
self.assertEqual(body["data"]["miner_lifecycle"], "in_repair")
self.assertEqual(body["data"]["latest_event_sequence"], 2)
self.assertEqual(
body["data"]["opened_by"]["display_name"],
"Timeline Technician",
)
self.assertIsNone(body["data"]["closed_at"])
self.assertIsNone(body["data"]["closed_by"])
self.assertEqual(body["meta"], {})
self.assertIsNone(body["error"])
def test_events_return_structured_payload_in_service_order(self):
self.authenticate()
repair_id = uuid4()
expected = timeline_result(repair_id)
self.service.timeline = expected
response = self.client.get(
f"/api/repairs/{repair_id}/events"
)
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["data"]["repair_id"], str(repair_id))
self.assertEqual(
[event["sequence"] for event in body["data"]["events"]],
[1, 2],
)
self.assertIsInstance(body["data"]["events"][0]["payload"], dict)
self.assertEqual(
body["data"]["events"][1]["actor"]["display_name"],
"Timeline Manager",
)
def test_known_empty_timeline_returns_success(self):
self.authenticate()
repair_id = uuid4()
self.service.timeline = RepairTimeline(
repair_id=repair_id,
events=[],
)
response = self.client.get(
f"/api/repairs/{repair_id}/events"
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["events"], [])
def test_read_only_cannot_begin_repair(self):
self.authenticate(role="Read Only", permissions=("repairs.read",))
response = self.client.post(
"/api/repairs",
json={"miner_id": str(uuid4()), "reason": "OTHER"},
)
self.assertEqual(response.status_code, 403)
self.assertFalse(self.service.begin_called)
def test_timeline_and_parts_get_routes_are_registered(self):
get_routes = {
route.path
for route in router.routes
if hasattr(route, "methods") and "GET" in route.methods
}
self.assertEqual(
get_routes,
{
"/api/repairs/{repair_id}",
"/api/repairs/{repair_id}/events",
"/api/repairs/{repair_id}/parts",
},
)
class RepairTimelineArchitectureTests(unittest.TestCase):
@staticmethod
def full_route_matches(app, method, path):
scope = {
"type": "http",
"method": method,
"path": path,
"root_path": "",
}
return [
route
for route in app.routes
if route.matches(scope)[0] is Match.FULL
]
def test_permission_mapper_scopes_new_and_legacy_routes(self):
repair_id = uuid4()
self.assertEqual(
required_api_permission(
"GET",
f"/api/repairs/{repair_id}",
),
"repairs.read",
)
self.assertEqual(
required_api_permission(
"GET",
f"/api/repairs/{repair_id}/events",
),
"repairs.read",
)
self.assertEqual(
required_api_permission("GET", "/api/repairs/queue"),
"repairs.view",
)
self.assertEqual(
required_api_permission(
"GET",
"/api/repairs/parts-summary",
),
"repairs.view",
)
self.assertEqual(
required_api_permission("POST", "/api/repairs"),
"repairs.create",
)
def test_repair_router_is_registered_after_legacy_static_routes(self):
main_path = Path(__file__).parents[1] / "app" / "main.py"
source = main_path.read_text(encoding="utf-8")
self.assertGreater(
source.rfind("app.include_router(repairs_router)"),
source.find('@app.get("/api/repairs/parts-summary")'),
)
def test_live_application_repair_route_inventory(self):
from app.main import app
routes = {
(method, route.path, route.endpoint.__name__)
for route in app.routes
if route.path.startswith("/api/repairs")
for method in route.methods
}
self.assertEqual(
routes,
{
("GET", "/api/repairs/queue", "minerops_repair_queue_api"),
(
"POST",
"/api/repairs/{miner_id}/note",
"minerops_repair_add_note_api",
),
(
"POST",
"/api/repairs/{miner_id}/status",
"minerops_repair_update_status_api",
),
(
"POST",
"/api/repairs/{miner_id}/state",
"minerops_repair_update_state_api",
),
(
"GET",
"/api/repairs/parts-summary",
"minerops_repair_parts_summary_api",
),
("POST", "/api/repairs", "begin_repair"),
(
"GET",
"/api/repairs/{repair_id}/parts",
"list_repair_parts",
),
(
"POST",
"/api/repairs/{repair_id}/parts",
"record_repair_part",
),
(
"POST",
"/api/repairs/{repair_id}/parts/{repair_part_id}/void",
"void_repair_part",
),
(
"POST",
"/api/repairs/{repair_id}/complete",
"complete_repair",
),
("GET", "/api/repairs/{repair_id}", "get_repair"),
(
"GET",
"/api/repairs/{repair_id}/events",
"get_repair_events",
),
},
)
def test_static_repair_routes_resolve_before_uuid_detail_route(self):
from app.main import app
for path, endpoint_name in (
("/api/repairs/queue", "minerops_repair_queue_api"),
(
"/api/repairs/parts-summary",
"minerops_repair_parts_summary_api",
),
):
with self.subTest(path=path):
matches = self.full_route_matches(app, "GET", path)
self.assertGreaterEqual(len(matches), 2)
self.assertEqual(matches[0].path, path)
self.assertEqual(matches[0].endpoint.__name__, endpoint_name)
self.assertEqual(matches[1].endpoint.__name__, "get_repair")
def test_uuid_repair_routes_resolve_to_timeline_handlers(self):
from app.main import app
repair_id = uuid4()
for path, endpoint_name in (
(f"/api/repairs/{repair_id}", "get_repair"),
(
f"/api/repairs/{repair_id}/events",
"get_repair_events",
),
):
with self.subTest(path=path):
matches = self.full_route_matches(app, "GET", path)
self.assertEqual(len(matches), 1)
self.assertEqual(matches[0].endpoint.__name__, endpoint_name)
def test_repository_event_read_is_one_joined_ordered_query(self):
source = inspect.getsource(RepairRepository.get_repair_events)
self.assertEqual(source.count("cur.execute("), 1)
self.assertIn("JOIN users AS actor", source)
self.assertIn("ORDER BY event.sequence ASC", source)
for keyword in ("INSERT ", "UPDATE ", "DELETE "):
self.assertNotIn(keyword, source.upper())
def test_service_and_repository_have_no_fastapi_dependency(self):
repairs_path = Path(__file__).parents[1] / "app" / "repairs"
for filename in ("service.py", "repository.py"):
tree = ast.parse(
(repairs_path / filename).read_text(encoding="utf-8")
)
modules = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
modules.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
modules.append(node.module)
self.assertFalse(
any(
module == "fastapi"
or module.startswith("fastapi.")
for module in modules
),
filename,
)
def test_timeline_has_no_mutation_route_or_repository_method(self):
methods = {
method
for route in router.routes
if hasattr(route, "methods")
for method in route.methods
}
self.assertNotIn("PATCH", methods)
self.assertNotIn("PUT", methods)
self.assertNotIn("DELETE", methods)
self.assertFalse(hasattr(RepairRepository, "update_repair_event"))
self.assertFalse(hasattr(RepairRepository, "delete_repair_event"))
if __name__ == "__main__":
unittest.main()
+739
View File
@@ -0,0 +1,739 @@
import ast
import unittest
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from threading import Barrier
from uuid import UUID, uuid4
from psycopg.errors import RaiseException
from psycopg.rows import dict_row
from app.repairs import (
ActiveRepairConflict,
BeginRepairRequest,
IncompatibleMinerLifecycle,
InconsistentRepairState,
RepairActorRequired,
RepairMinerNotFound,
RepairRepository,
RepairService,
)
def repair_row(miner_id, actor_user_id, reason="CONTROL_BOARD"):
return {
"id": uuid4(),
"miner_id": miner_id,
"status": "OPEN",
"reason": reason,
"opened_at": datetime.now(timezone.utc),
"opened_by": actor_user_id,
"completed_at": None,
"completed_by": None,
}
class FakeRepairRepository:
def __init__(self):
self.miners = {}
self.repairs = []
self.events = []
self.calls = []
self.create_error = None
self.event_error = None
self.lifecycle_error = None
@contextmanager
def transaction(self):
self.calls.append("transaction")
snapshot = deepcopy((self.miners, self.repairs, self.events))
try:
yield self
except Exception:
self.miners, self.repairs, self.events = snapshot
raise
def lock_miner_for_update(self, miner_id):
self.calls.append("lock_miner")
return self.miners.get(miner_id)
def get_active_repair(self, miner_id):
self.calls.append("get_active_repair")
return next(
(
repair
for repair in self.repairs
if repair["miner_id"] == miner_id
and repair["status"]
in {
"OPEN",
"IN_PROGRESS",
"WAITING_FOR_PARTS",
"READY_FOR_TEST",
}
),
None,
)
def create_repair(self, miner_id, reason, opened_by):
self.calls.append("create_repair")
if self.create_error is not None:
raise self.create_error
row = repair_row(miner_id, opened_by, reason)
self.repairs.append(row)
return row
def create_event(
self,
repair_id,
*,
sequence,
event_type,
created_by,
payload,
):
self.calls.append(f"create_event:{event_type}")
if self.event_error is not None:
raise self.event_error
event = {
"id": uuid4(),
"repair_id": repair_id,
"sequence": sequence,
"event_type": event_type,
"created_at": datetime.now(timezone.utc),
"created_by": created_by,
"payload": dict(payload),
}
self.events.append(event)
return event
def set_miner_lifecycle(self, miner_id, lifecycle_status):
self.calls.append("set_miner_lifecycle")
if self.lifecycle_error is not None:
raise self.lifecycle_error
self.miners[miner_id]["lifecycle_status"] = lifecycle_status
class RepairServiceTests(unittest.TestCase):
def setUp(self):
self.repository = FakeRepairRepository()
self.service = RepairService(self.repository)
self.actor_user_id = uuid4()
def add_miner(self, lifecycle_status="active"):
miner_id = uuid4()
self.repository.miners[miner_id] = {
"id": miner_id,
"lifecycle_status": lifecycle_status,
}
return miner_id
def test_begin_repair_creates_record_event_and_lifecycle_atomically(self):
miner_id = self.add_miner()
result = self.service.begin_repair(
BeginRepairRequest(
miner_id=miner_id,
reason="CONTROL_BOARD",
),
actor_user_id=self.actor_user_id,
)
self.assertEqual(result.miner_id, miner_id)
self.assertEqual(result.status, "OPEN")
self.assertEqual(result.reason, "CONTROL_BOARD")
self.assertEqual(result.opened_by, self.actor_user_id)
self.assertEqual(len(self.repository.repairs), 1)
self.assertEqual(len(self.repository.events), 1)
self.assertEqual(
self.repository.events[0]["event_type"],
"REPAIR_STARTED",
)
self.assertEqual(
self.repository.events[0]["payload"],
{
"reason": "CONTROL_BOARD",
"from_lifecycle": "active",
"to_lifecycle": "in_repair",
},
)
self.assertEqual(
self.repository.miners[miner_id]["lifecycle_status"],
"in_repair",
)
self.assertEqual(
self.repository.calls,
[
"transaction",
"lock_miner",
"get_active_repair",
"create_repair",
"create_event:REPAIR_STARTED",
"set_miner_lifecycle",
],
)
def test_begin_repair_normalizes_and_records_initial_note(self):
miner_id = self.add_miner("spare")
self.service.begin_repair(
BeginRepairRequest(
miner_id=miner_id,
reason="INSPECTION",
notes=" Check fan harness. ",
),
actor_user_id=self.actor_user_id,
)
self.assertEqual(
[event["event_type"] for event in self.repository.events],
["REPAIR_STARTED", "INITIAL_NOTE"],
)
self.assertEqual(
[event["sequence"] for event in self.repository.events],
[1, 2],
)
self.assertEqual(
self.repository.events[0]["payload"],
{
"reason": "INSPECTION",
"from_lifecycle": "spare",
"to_lifecycle": "in_repair",
},
)
self.assertEqual(
self.repository.events[1]["payload"],
{"note": "Check fan harness."},
)
def test_absent_and_blank_notes_create_no_note_event(self):
for note in (None, " \t\n "):
with self.subTest(note=note):
repository = FakeRepairRepository()
miner_id = uuid4()
repository.miners[miner_id] = {
"id": miner_id,
"lifecycle_status": "active",
}
RepairService(repository).begin_repair(
BeginRepairRequest(
miner_id=miner_id,
reason="OTHER",
notes=note,
),
actor_user_id=self.actor_user_id,
)
self.assertEqual(len(repository.events), 1)
def test_unknown_miner_raises_not_found_without_writes(self):
with self.assertRaises(RepairMinerNotFound):
self.service.begin_repair(
BeginRepairRequest(
miner_id=uuid4(),
reason="OTHER",
),
actor_user_id=self.actor_user_id,
)
self.assertEqual(self.repository.repairs, [])
self.assertEqual(self.repository.events, [])
def test_active_repair_conflict_prevents_partial_changes(self):
miner_id = self.add_miner()
self.repository.repairs.append(
repair_row(miner_id, self.actor_user_id)
)
snapshot = deepcopy(self.repository.repairs)
with self.assertRaises(ActiveRepairConflict):
self.service.begin_repair(
BeginRepairRequest(
miner_id=miner_id,
reason="PSU",
),
actor_user_id=self.actor_user_id,
)
self.assertEqual(self.repository.repairs, snapshot)
self.assertEqual(self.repository.events, [])
self.assertEqual(
self.repository.miners[miner_id]["lifecycle_status"],
"active",
)
def test_in_repair_without_active_record_is_inconsistent(self):
miner_id = self.add_miner("in_repair")
with self.assertRaises(InconsistentRepairState):
self.service.begin_repair(
BeginRepairRequest(
miner_id=miner_id,
reason="OTHER",
),
actor_user_id=self.actor_user_id,
)
def test_incompatible_lifecycle_is_rejected(self):
miner_id = self.add_miner("retired")
with self.assertRaises(IncompatibleMinerLifecycle):
self.service.begin_repair(
BeginRepairRequest(
miner_id=miner_id,
reason="OTHER",
),
actor_user_id=self.actor_user_id,
)
def test_actor_is_required_before_transaction_starts(self):
miner_id = self.add_miner()
with self.assertRaises(RepairActorRequired):
self.service.begin_repair(
BeginRepairRequest(
miner_id=miner_id,
reason="OTHER",
),
actor_user_id=None,
)
self.assertEqual(self.repository.calls, [])
def test_repository_domain_conflict_propagates(self):
miner_id = self.add_miner()
self.repository.create_error = ActiveRepairConflict(miner_id)
with self.assertRaises(ActiveRepairConflict):
self.service.begin_repair(
BeginRepairRequest(
miner_id=miner_id,
reason="OTHER",
),
actor_user_id=self.actor_user_id,
)
def test_event_failure_rolls_back_every_change(self):
miner_id = self.add_miner()
self.repository.event_error = RuntimeError("event failed")
with self.assertRaisesRegex(RuntimeError, "event failed"):
self.service.begin_repair(
BeginRepairRequest(
miner_id=miner_id,
reason="OTHER",
),
actor_user_id=self.actor_user_id,
)
self.assertEqual(self.repository.repairs, [])
self.assertEqual(self.repository.events, [])
self.assertEqual(
self.repository.miners[miner_id]["lifecycle_status"],
"active",
)
def test_lifecycle_failure_rolls_back_repair_and_events(self):
miner_id = self.add_miner()
self.repository.lifecycle_error = RuntimeError("lifecycle failed")
with self.assertRaisesRegex(RuntimeError, "lifecycle failed"):
self.service.begin_repair(
BeginRepairRequest(
miner_id=miner_id,
reason="OTHER",
notes="Initial note",
),
actor_user_id=self.actor_user_id,
)
self.assertEqual(self.repository.repairs, [])
self.assertEqual(self.repository.events, [])
self.assertEqual(
self.repository.miners[miner_id]["lifecycle_status"],
"active",
)
class RepairRepositoryIntegrationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.repository = RepairRepository()
cls.actor_user_id = UUID(
"00000000-0000-0000-0000-000000001101"
)
def setUp(self):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"DROP TRIGGER IF EXISTS test_repair_event_failure "
"ON repair_events"
)
cur.execute(
"DROP TRIGGER IF EXISTS test_repair_lifecycle_failure "
"ON miners"
)
cur.execute(
"DROP FUNCTION IF EXISTS test_fail_repair_event()"
)
cur.execute(
"DROP FUNCTION IF EXISTS test_fail_repair_lifecycle()"
)
cur.execute("ALTER TABLE repair_events DISABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repair_events")
cur.execute("ALTER TABLE repair_events ENABLE TRIGGER trg_protect_repair_event_history")
cur.execute("DELETE FROM repairs")
cur.execute("DELETE FROM miner_tags")
cur.execute("DELETE FROM miner_asset_id_history")
cur.execute("DELETE FROM miners")
cur.execute(
"DELETE FROM users "
"WHERE username = 'repair-domain-test'"
)
cur.execute(
"""
INSERT INTO users (
id, username, display_name, password_hash, role_id
)
SELECT %s, %s, %s, %s, id
FROM roles
WHERE name = 'Admin'
""",
(
self.actor_user_id,
"repair-domain-test",
"Repair Domain Test",
"not-used",
),
)
def create_miner(self, lifecycle_status="active"):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO miners (
asset_tag,
status,
lifecycle_status,
first_seen_at,
last_seen_at
)
VALUES (%s, 'unknown', %s, NULL, NULL)
RETURNING id
""",
(f"REPAIR-{uuid4()}", lifecycle_status),
)
return cur.fetchone()["id"]
def fetch_events(self, repair_id):
with self.repository._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT *
FROM repair_events
WHERE repair_id = %s
ORDER BY sequence
""",
(repair_id,),
)
return [dict(row) for row in cur.fetchall()]
def count_repairs(self, miner_id):
with self.repository._connection_scope() as connection:
with connection.cursor() as cur:
cur.execute(
"SELECT COUNT(*) FROM repairs WHERE miner_id = %s",
(miner_id,),
)
return cur.fetchone()[0]
def lifecycle(self, miner_id):
with self.repository._connection_scope() as connection:
with connection.cursor() as cur:
cur.execute(
"SELECT lifecycle_status FROM miners WHERE id = %s",
(miner_id,),
)
return cur.fetchone()[0]
def test_begin_repair_persists_actor_reason_timestamp_and_started_event(self):
miner_id = self.create_miner()
before = datetime.now(timezone.utc)
result = RepairService(self.repository).begin_repair(
BeginRepairRequest(
miner_id=miner_id,
reason="HASHBOARD",
),
actor_user_id=self.actor_user_id,
)
self.assertGreaterEqual(result.opened_at, before)
self.assertEqual(result.opened_by, self.actor_user_id)
self.assertEqual(result.reason, "HASHBOARD")
self.assertEqual(result.status, "OPEN")
self.assertEqual(self.lifecycle(miner_id), "in_repair")
active = self.repository.get_active_repair(miner_id)
self.assertEqual(active["id"], result.repair_id)
events = self.fetch_events(result.repair_id)
self.assertEqual(len(events), 1)
self.assertEqual(events[0]["event_type"], "REPAIR_STARTED")
self.assertEqual(events[0]["sequence"], 1)
self.assertEqual(events[0]["created_by"], self.actor_user_id)
self.assertIsNotNone(events[0]["created_at"])
self.assertEqual(
events[0]["payload"],
{
"reason": "HASHBOARD",
"from_lifecycle": "active",
"to_lifecycle": "in_repair",
},
)
def test_initial_note_is_normalized_and_ordered_after_started_event(self):
miner_id = self.create_miner("spare")
result = RepairService(self.repository).begin_repair(
BeginRepairRequest(
miner_id=miner_id,
reason="INSPECTION",
notes=" Inspect harness. ",
),
actor_user_id=self.actor_user_id,
)
events = self.fetch_events(result.repair_id)
self.assertEqual(
[(event["sequence"], event["event_type"]) for event in events],
[(1, "REPAIR_STARTED"), (2, "INITIAL_NOTE")],
)
self.assertEqual(
events[0]["payload"],
{
"reason": "INSPECTION",
"from_lifecycle": "spare",
"to_lifecycle": "in_repair",
},
)
self.assertEqual(events[1]["payload"], {"note": "Inspect harness."})
def test_database_rejects_duplicate_active_repair(self):
miner_id = self.create_miner()
service = RepairService(self.repository)
service.begin_repair(
BeginRepairRequest(miner_id=miner_id, reason="FAN"),
actor_user_id=self.actor_user_id,
)
with self.assertRaises(ActiveRepairConflict):
self.repository.create_repair(
miner_id,
"PSU",
self.actor_user_id,
)
self.assertEqual(self.count_repairs(miner_id), 1)
def test_repairs_are_isolated_by_miner(self):
first_miner = self.create_miner()
second_miner = self.create_miner()
service = RepairService(self.repository)
first = service.begin_repair(
BeginRepairRequest(
miner_id=first_miner,
reason="LOW_HASHRATE",
),
actor_user_id=self.actor_user_id,
)
second = service.begin_repair(
BeginRepairRequest(
miner_id=second_miner,
reason="NETWORKING",
),
actor_user_id=self.actor_user_id,
)
self.assertNotEqual(first.repair_id, second.repair_id)
self.assertEqual(
self.repository.get_active_repair(first_miner)["id"],
first.repair_id,
)
self.assertEqual(
self.repository.get_active_repair(second_miner)["id"],
second.repair_id,
)
def test_event_insertion_failure_rolls_back_repair_and_lifecycle(self):
miner_id = self.create_miner()
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
CREATE FUNCTION test_fail_repair_event()
RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'forced repair event failure';
END;
$$ LANGUAGE plpgsql
"""
)
cur.execute(
"""
CREATE TRIGGER test_repair_event_failure
BEFORE INSERT ON repair_events
FOR EACH ROW EXECUTE FUNCTION test_fail_repair_event()
"""
)
with self.assertRaises(RaiseException):
RepairService(self.repository).begin_repair(
BeginRepairRequest(
miner_id=miner_id,
reason="OTHER",
),
actor_user_id=self.actor_user_id,
)
self.assertEqual(self.count_repairs(miner_id), 0)
self.assertEqual(self.lifecycle(miner_id), "active")
def test_lifecycle_failure_rolls_back_repair_and_events(self):
miner_id = self.create_miner()
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
CREATE FUNCTION test_fail_repair_lifecycle()
RETURNS trigger AS $$
BEGIN
IF NEW.lifecycle_status = 'in_repair' THEN
RAISE EXCEPTION 'forced lifecycle failure';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql
"""
)
cur.execute(
"""
CREATE TRIGGER test_repair_lifecycle_failure
BEFORE UPDATE ON miners
FOR EACH ROW
EXECUTE FUNCTION test_fail_repair_lifecycle()
"""
)
with self.assertRaises(RaiseException):
RepairService(self.repository).begin_repair(
BeginRepairRequest(
miner_id=miner_id,
reason="OTHER",
notes="Must roll back.",
),
actor_user_id=self.actor_user_id,
)
self.assertEqual(self.count_repairs(miner_id), 0)
self.assertEqual(self.lifecycle(miner_id), "active")
def test_simultaneous_requests_create_only_one_active_repair(self):
miner_id = self.create_miner()
barrier = Barrier(2)
def begin(reason):
barrier.wait()
return RepairService(RepairRepository()).begin_repair(
BeginRepairRequest(miner_id=miner_id, reason=reason),
actor_user_id=self.actor_user_id,
)
outcomes = []
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(begin, "PSU"),
executor.submit(begin, "FAN"),
]
for future in futures:
try:
outcomes.append(future.result(timeout=10))
except ActiveRepairConflict as exc:
outcomes.append(exc)
self.assertEqual(
sum(not isinstance(item, Exception) for item in outcomes),
1,
)
self.assertEqual(
sum(isinstance(item, ActiveRepairConflict) for item in outcomes),
1,
)
self.assertEqual(self.count_repairs(miner_id), 1)
self.assertEqual(self.lifecycle(miner_id), "in_repair")
def test_repairs_create_permission_matrix_and_idempotent_seed(self):
with self.repository._connection_scope(write=True) as connection:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO permissions (key, description)
VALUES (
'repairs.create',
'Begin repairs for miner assets'
)
ON CONFLICT (key) DO UPDATE
SET description = EXCLUDED.description
"""
)
cur.execute(
"""
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
"""
)
cur.execute(
"""
SELECT roles.name
FROM role_permissions
JOIN roles ON roles.id = role_permissions.role_id
JOIN permissions
ON permissions.id = role_permissions.permission_id
WHERE permissions.key = 'repairs.create'
ORDER BY roles.name
"""
)
role_names = [row[0] for row in cur.fetchall()]
self.assertEqual(
role_names,
["Admin", "Manager", "Technician"],
)
class RepairArchitectureTests(unittest.TestCase):
def test_service_and_repository_have_no_fastapi_dependency(self):
repairs_path = Path(__file__).parents[1] / "app" / "repairs"
for filename in ("service.py", "repository.py"):
tree = ast.parse(
(repairs_path / filename).read_text(encoding="utf-8")
)
imported_modules = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imported_modules.extend(
alias.name for alias in node.names
)
elif isinstance(node, ast.ImportFrom) and node.module:
imported_modules.append(node.module)
self.assertFalse(
any(
module == "fastapi"
or module.startswith("fastapi.")
for module in imported_modules
),
filename,
)
if __name__ == "__main__":
unittest.main()
+279
View File
@@ -0,0 +1,279 @@
import ast
import unittest
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID, uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.identity import get_current_user
from app.repairs.dependencies import get_repair_service
from app.repairs.exceptions import (
ActiveRepairConflict,
IncompatibleMinerLifecycle,
InconsistentRepairState,
RepairMinerNotFound,
)
from app.repairs.models import (
BeginRepairRequest,
BeginRepairResult,
)
from app.repairs.router import router
class StubRepairService:
def __init__(self):
self.result = None
self.error = None
self.request = None
self.actor_user_id = None
def begin_repair(
self,
request: BeginRepairRequest,
*,
actor_user_id: UUID,
) -> BeginRepairResult:
self.request = request
self.actor_user_id = actor_user_id
if self.error is not None:
raise self.error
if self.result is None:
raise AssertionError("result was not configured")
return self.result
def repair_result(
miner_id=None,
actor_user_id=None,
) -> BeginRepairResult:
return BeginRepairResult(
repair_id=uuid4(),
miner_id=miner_id or uuid4(),
status="OPEN",
reason="CONTROL_BOARD",
opened_at=datetime(2026, 7, 29, 12, 0, tzinfo=timezone.utc),
opened_by=actor_user_id or uuid4(),
)
class RepairRouterTests(unittest.TestCase):
def setUp(self):
self.service = StubRepairService()
self.app = FastAPI()
self.app.include_router(router)
self.app.dependency_overrides[get_repair_service] = (
lambda: self.service
)
self.client = TestClient(self.app)
self.actor_user_id = uuid4()
def tearDown(self):
self.client.close()
def authenticate(self, permissions=("repairs.create",)):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(self.actor_user_id),
"username": "technician",
"role": "Technician",
"permissions": list(permissions),
}
def test_begin_repair_requires_authentication(self):
response = self.client.post(
"/api/repairs",
json={
"miner_id": str(uuid4()),
"reason": "CONTROL_BOARD",
},
)
self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Login required")
def test_begin_repair_requires_repairs_create(self):
self.authenticate(permissions=("repairs.view", "repairs.manage"))
response = self.client.post(
"/api/repairs",
json={
"miner_id": str(uuid4()),
"reason": "CONTROL_BOARD",
},
)
self.assertEqual(response.status_code, 403)
self.assertIsNone(self.service.request)
def test_malformed_miner_uuid_returns_validation_error(self):
self.authenticate()
response = self.client.post(
"/api/repairs",
json={
"miner_id": "not-a-uuid",
"reason": "CONTROL_BOARD",
},
)
self.assertEqual(response.status_code, 422)
self.assertIsNone(self.service.request)
def test_invalid_reason_returns_validation_error(self):
self.authenticate()
response = self.client.post(
"/api/repairs",
json={
"miner_id": str(uuid4()),
"reason": "FREE_FORM_REASON",
},
)
self.assertEqual(response.status_code, 422)
self.assertIsNone(self.service.request)
def test_unknown_miner_returns_not_found(self):
self.authenticate()
miner_id = uuid4()
self.service.error = RepairMinerNotFound(miner_id)
response = self.client.post(
"/api/repairs",
json={
"miner_id": str(miner_id),
"reason": "OTHER",
},
)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["detail"], "Miner not found")
def test_active_repair_returns_conflict(self):
self.authenticate()
miner_id = uuid4()
self.service.error = ActiveRepairConflict(miner_id)
response = self.client.post(
"/api/repairs",
json={
"miner_id": str(miner_id),
"reason": "PSU",
},
)
self.assertEqual(response.status_code, 409)
self.assertIn("active repair", response.json()["detail"])
def test_lifecycle_conflicts_return_conflict(self):
self.authenticate()
miner_id = uuid4()
for error in (
IncompatibleMinerLifecycle(miner_id, "retired"),
InconsistentRepairState(miner_id),
):
with self.subTest(error=error):
self.service.error = error
response = self.client.post(
"/api/repairs",
json={
"miner_id": str(miner_id),
"reason": "OTHER",
},
)
self.assertEqual(response.status_code, 409)
def test_success_returns_typed_envelope_and_authenticated_actor(self):
self.authenticate()
miner_id = uuid4()
expected = repair_result(miner_id, self.actor_user_id)
self.service.result = expected
response = self.client.post(
"/api/repairs",
json={
"miner_id": str(miner_id),
"reason": "CONTROL_BOARD",
"notes": "Miner will not boot.",
},
)
self.assertEqual(response.status_code, 201)
body = response.json()
self.assertEqual(body["data"]["repair_id"], str(expected.repair_id))
self.assertEqual(body["data"]["miner_id"], str(miner_id))
self.assertEqual(body["data"]["status"], "OPEN")
self.assertEqual(body["data"]["reason"], "CONTROL_BOARD")
self.assertEqual(body["data"]["opened_by"], str(self.actor_user_id))
self.assertEqual(
body["data"]["opened_at"],
"2026-07-29T12:00:00Z",
)
self.assertEqual(body["meta"], {})
self.assertIsNone(body["error"])
self.assertEqual(self.service.actor_user_id, self.actor_user_id)
self.assertEqual(
self.service.request.notes,
"Miner will not boot.",
)
def test_client_cannot_override_server_fields_or_actor(self):
self.authenticate()
base = {
"miner_id": str(uuid4()),
"reason": "OTHER",
}
forbidden = {
"repair_id": str(uuid4()),
"status": "COMPLETED",
"opened_at": "2026-01-01T00:00:00Z",
"opened_by": str(uuid4()),
"event_id": str(uuid4()),
"event_timestamp": "2026-01-01T00:00:00Z",
"event_actor": str(uuid4()),
"lifecycle_status": "retired",
}
for field, value in forbidden.items():
with self.subTest(field=field):
response = self.client.post(
"/api/repairs",
json={**base, field: value},
)
self.assertEqual(response.status_code, 422)
self.assertIsNone(self.service.request)
def test_router_registers_exact_begin_repair_route(self):
routes = [
(route.path, route.methods)
for route in router.routes
if hasattr(route, "methods")
and "POST" in route.methods
and route.path == "/api/repairs"
]
self.assertEqual(routes, [("/api/repairs", {"POST"})])
def test_router_has_no_sql_driver_or_transaction_management(self):
router_path = (
Path(__file__).parents[1] / "app" / "repairs" / "router.py"
)
source = router_path.read_text(encoding="utf-8")
tree = ast.parse(source)
imported_modules = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imported_modules.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
imported_modules.append(node.module)
database_modules = ("psycopg", "asyncpg", "sqlalchemy")
self.assertFalse(
any(
module == name or module.startswith(f"{name}.")
for module in imported_modules
for name in database_modules
)
)
upper_source = source.upper()
for keyword in (
"SELECT ",
"INSERT ",
"UPDATE ",
"DELETE ",
".COMMIT(",
".ROLLBACK(",
):
self.assertNotIn(keyword, upper_source)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large. Load diff
+142
View File
@@ -0,0 +1,142 @@
# MinerOps Coding Standards
## Purpose
This document defines the engineering conventions for MinerOps. Task specifications may add feature-specific requirements, but they should not weaken these standards without an explicit architectural decision.
## Core Principles
1. Prefer explicit domain operations over generic CRUD.
2. Preserve operational history.
3. Enforce critical invariants in PostgreSQL.
4. Keep operations atomic.
5. Separate network identity from durable asset identity.
6. Use server-controlled timestamps and authenticated actor identities.
7. Design for self-hosted deployment and safe recovery.
8. Prefer clear, maintainable code over clever abstractions.
9. Keep each implementation task focused and reviewable.
10. Treat safety as more important than convenience for actions affecting miners.
## Layered Architecture
```text
Router
-> Service
-> Repository
-> PostgreSQL
```
### Router
Routers may define HTTP routes, validate requests, enforce authentication and permissions, and serialize responses.
Routers must not contain SQL, manage transactions, implement domain decisions, import database drivers directly, or bypass the service layer.
### Service
Services implement domain workflows, coordinate repositories, enforce domain rules, and raise domain exceptions.
Services must not import FastAPI, raise HTTP-specific exceptions, contain raw SQL, or trust actor identity supplied in request bodies.
### Repository
Repositories own database queries, row locking, persistence details, and PostgreSQL-specific integrity behavior.
Repositories must not import FastAPI, make permission decisions, define HTTP behavior, or commit independently inside a larger service transaction.
## Domain Operations
Use explicit operations such as:
```http
POST /api/repairs
POST /api/repairs/{repair_id}/complete
POST /api/miners/{miner_id}/move
POST /api/miners/{miner_id}/replace-control-board
```
Only `POST /api/repairs` is implemented by the modular Repairs domain today.
The other routes are illustrative names for possible future explicit workflows,
not existing API contracts.
Avoid broad mutable CRUD endpoints when the request represents a meaningful operational event.
## Transactions and Concurrency
Any workflow that changes multiple authoritative records must execute atomically.
Use row locks and database constraints when concurrent requests could violate an invariant. Do not rely only on pre-insert application checks when PostgreSQL can enforce the rule.
## Database Integrity
Use constraints for uniqueness, valid state combinations, referential integrity, one-active-record rules, event-sequence integrity, and actor/timestamp requirements.
Operational history should generally use `RESTRICT` rather than disappearing through cascades.
Every migration must support upgrade, safe downgrade or explicit refusal, previous-head testing, re-upgrade, and empty-database-through-head validation. A downgrade must never invent historical values.
## Identity
A miner's durable identity must not depend on its current IP address, MAC address, control board, hostname, or location.
Network identifiers are observations or replaceable hardware attributes and must retain history.
Actor identity must come from the authenticated principal, never an authoritative request-body field.
## Time
Use timezone-aware timestamps. Authoritative operational timestamps are server- or database-controlled. Never use timestamp order as a substitute for an explicit event sequence.
## Immutable Operational History
Timeline and audit events are append-only. Once written, an event is never edited, deleted, reordered, or silently replaced. Corrections are new events.
Sequenced domain timeline events should include an event UUID, parent UUID,
sequence, event type, actor UUID, timestamp, and structured JSON payload.
## API Design
- Use UUID types for UUID fields.
- Use constrained domain values.
- Use 404 for missing resources, 409 for state conflicts, 422 for invalid input, and established project behavior for 401 and 403.
- Do not expose internal fields without a product requirement.
- Keep routes focused.
- Protect existing static routes when introducing parameter routes.
## Permissions
Permissions describe capabilities, not roles. Implemented examples include
`repairs.read` and `repairs.create`. Names such as `repairs.complete` and
`miners.move` illustrate possible future capabilities and are not currently
registered permissions.
Every protected route requires unauthenticated, unauthorized, and authorized-role tests. Read-only roles may view history but may not perform actions.
## Testing and Validation
Feature work should cover happy paths, validation, permissions, missing records, conflicts, rollback, concurrency where relevant, constraints, migrations, route compatibility, and architecture boundaries.
Unless a task says otherwise, run:
- Python compilation
- shell syntax validation
- focused tests
- relevant regression suites
- migration upgrade, downgrade, re-upgrade, and empty database
- disposable development baseline
- router SQL/driver/transaction audit
- service/repository FastAPI audit
- route-count audit
- `git diff --check`
## Security
Never commit credentials, tokens, environment files, dumps, private configuration, caches, or local database data. Do not log secrets. Apply least privilege and preserve audit history for sensitive actions.
## Documentation
Every implementation task should have a version-controlled specification under `docs/tasks/`. Update roadmaps only after validation passes. Add an ADR only for a genuinely new architectural principle.
## Review Checklist
Before approval, confirm scope, transaction boundaries, database invariants, actor attribution, timestamps, immutable history, permissions, migrations, regressions, route behavior, performance risks, and the reviewed file set.
+68
View File
@@ -0,0 +1,68 @@
# Contributing to MinerOps
## Workflow
```text
Product decision
-> Task specification
-> Implementation
-> Architecture review
-> Focused review and fixes
-> Commit
-> Push
```
Substantial work should begin from an approved specification in `docs/tasks/`.
## Before Starting
1. Confirm repository, branch, working tree, and expected latest commit.
2. Read the architecture, product, decisions, coding standards, task
specification, and relevant release planning document when one exists.
3. Confirm the current Alembic head.
4. Report material conflicts before deviating.
## Scope
Each task should implement one coherent feature. Adjacent ideas belong in later tasks rather than being bundled into the current change.
## Branches and Commits
Do not force-push, rebase shared branches, amend approved commits, or mix unrelated work without explicit approval.
Use concise conventional commit messages, for example:
```text
feat: add begin repair workflow
feat: add repair timeline read APIs
fix: restore lifecycle during repair downgrade
docs: add MinerOps coding standards
```
Commit only after implementation, validation, review, and file-set confirmation.
## Completion Report
Report files created and modified, migrations, routes, permissions, domain behavior, test counts, validation results, risks, and final `git status --short`.
## Architecture
Follow `docs/CODING_STANDARDS.md` and preserve:
```text
Router -> Service -> Repository -> PostgreSQL
```
Use explicit operational APIs and immutable history.
## Migrations
Validate previous head to new head, downgrade, re-upgrade, empty database through head, constraints, indexes, permission assignments, and transactional failure behavior.
## Review
Review for scope creep, hidden CRUD, incorrect transactions, application-only invariants, unsafe downgrades, route shadowing, permission regressions, loss of history, N+1 queries, concurrency gaps, and accidental sensitive files.
## AI-Assisted Work
AI tools must inspect the repository, follow the task, preserve conventions, report conflicts, run validation, avoid commits and pushes unless instructed, and provide a complete report. Human approval is still required.
+10 -1
View File
@@ -92,13 +92,20 @@ location changes.
### Phase 3: Operations
**Status: Planned direction.**
**Status: In progress.**
This phase develops day-to-day technician and operations workflows around the
durable asset record. Repairs, maintenance, work orders, parts usage, equipment
movement, testing, and return-to-service history become coherent operational
records rather than disconnected actions.
The Begin Repair, read-only Repair Timeline, Return to Service, and Control Board
Replacement workflows are complete. An authorized actor can open a repair
against a durable miner UUID, record sequenced events, move the miner into or out
of repair atomically, and replace a control board without changing the durable
asset identity. Authorized readers can inspect the repair and its ordered
immutable events. Later repair lifecycle workflows remain planned.
Technician experience is a priority. Responsive and QR-assisted workflows may
reduce lookup and data-entry effort where they reinforce the same asset record.
Existing repair and location behavior should move incrementally into clear
@@ -209,6 +216,8 @@ Major completed foundations include:
- Authentication, login gating, and role foundation
- Manual Inventory schema and backend domain foundation
- Focused Inventory and migration test foundations
- Begin Repair operational workflow
- Read-only Repair Timeline API
- MinerOps branding package and project README
- Architecture guide
- Product guide
+223
View File
@@ -0,0 +1,223 @@
# MinerOps
## Operational Intelligence for Mining
## Mission
MinerOps combines real-time operational awareness, durable asset identity,
repair intelligence, spatial visualization, historical analytics, and
actionable context in one self-hosted platform. It helps operators understand
what is happening, why it is happening, and what should happen next.
## Product Vision
MinerOps transforms operational data into useful decisions across the full
lifecycle of a mining operation. It connects live conditions to the durable
identity, location, repair history, component context, and prior performance of
the equipment involved. The goal is practical operational clarity: observations
should lead to explanations, priorities, and safe next actions.
The current product is an early-stage operational application with foundations
for authentication, inventory and identity, telemetry, locations, repair
workflows, control-board replacement, repair-part records, and a minimal tracked
component registry. These foundations do not yet constitute the complete vision.
Advanced spatial workflows, comparative time-series analytics, generalized
component movement and genealogy, donor workflows, and decision support remain
future direction unless an approved implementation task establishes otherwise.
## Product Pillars
### 1. Operations
Operations turns live fleet state into an understandable work queue. It covers
health, availability, alerts, safe controls, and prioritized next actions—for
example, identifying miners that are offline, overheating, unstable, or awaiting
intervention.
### 2. Assets
Assets gives every miner and meaningful tracked component a durable identity
independent of mutable network addresses, hardware assignments, and physical
location. Examples include Asset IDs, lifecycle state, MAC history, component
identity, and movement history.
### 3. Repairs
Repairs preserves the context and outcome of maintenance work. Examples include
repair episodes, ordered timelines, diagnostics, parts used, control-board
replacement, testing, and return-to-service decisions.
### 4. Spatial Intelligence
Spatial Intelligence explains operations through the physical structure of the
mine. It connects sites, rooms, racks, shelves, miners, work areas, and live
status so operators can see where problems and opportunities are concentrated.
### 5. Analytics
Analytics explains performance and reliability over time. It compares actual
behavior with expectations and relates changes to operational events—for
example, power and hashrate trends, thermal hotspots, recurring failures, and
repair outcomes.
## Core Principles
### Technician First
Design common workflows for the people doing the work, with fast lookup, clear
context, and minimal avoidable data entry at the point of action.
### Operational Awareness
Show current condition, scope, urgency, and impact so operators can distinguish
normal variation, intended downtime, and actionable problems.
### Durable Identity
Follow miners and tracked components through network changes, movement, repair,
transfer, and retirement without redefining them by mutable attributes.
### Immutable History
Preserve meaningful operational facts. Corrections append or void explicitly;
they do not silently rewrite or delete the past.
### Maps Explain Location
Use spatial views to reveal where assets, work, capacity, and operational risk
are concentrated.
### Graphs Explain Trends
Use synchronized time-series views to make change, correlation, and divergence
from expected performance visible.
### Timelines Explain History
Use ordered events to show what happened, when it happened, and who or what was
responsible.
### Tables Explain Detail
Use searchable, sortable tables when operators need precise values, comparisons,
and bulk review.
### Search Everything
Make durable identities, network observations, serials, locations, repairs, and
components discoverable from a consistent search experience.
### Live by Default
Present the freshest trustworthy operational state by default while making its
timestamp, source, and staleness clear.
### Explain, Do Not Merely Report
Connect symptoms to history, location, related events, and likely next checks
instead of displaying isolated measurements.
### Database-Backed Integrity
Enforce critical identity, history, ownership, and concurrency invariants in
PostgreSQL as well as in application workflows.
### Progressive Complexity
Keep basic inventory, monitoring, and repair work useful without requiring every
advanced model, while allowing richer structure as operational needs grow.
### Real Operational Problems Before Interesting Technology
Adopt technology only when it makes mining operations safer, clearer, faster,
or more reliable.
## Mine Map
The Mine Map is a primary operational interface, not an optional visualization.
It should connect physical layout to live condition, work state, and historical
context so an operator can move from a site-wide pattern to the relevant group
or miner.
The current application includes a location map and operational overlays. The
longer-term Mine Map should support production rooms, racks, shelves, miners,
repair benches, burn-in racks, quarantine, donor storage, component storage,
status overlays, temperature and efficiency hotspots, and drill-down into miners
and groups. Capabilities beyond the implemented location foundations are future
direction, not present functionality.
## Analytics
Time-series analytics is a core MinerOps capability. It should cover site and
miner power, hashrate, efficiency, temperature, availability, and expected
versus actual performance. Graphs should use synchronized event annotations so
repairs, moves, component changes, configuration changes, and operational events
can be evaluated against measured behavior.
Operators should be able to compare rooms, racks, models, firmware versions, or
selected miners over the same interval. Current telemetry collection and status
views are foundations for this direction; the complete comparative analytics
experience is not yet implemented.
## Component and Donor Workflows
MinerOps must support the real-world practice of combining working components
from multiple failed miners to return one miner to service. Meaningful physical
components need durable identity, transfer history, donor-miner context,
component genealogy, and explicit movement through repair benches and storage.
No workflow should silently recreate a component or lose its prior ownership,
installation, condition, or repair history.
The current minimal component registry, repair-part snapshots, and authoritative
control-board replacement history are foundations only. Generalized component
installation, transfer, location, donor, and genealogy workflows remain future
direction.
## Success Criteria
A technician should quickly answer:
- What is broken?
- Where is it?
- Why might it be failing?
- What repair work has already occurred?
- What parts or components were used?
- Did this component come from another miner?
- What should I do next?
A manager should quickly answer:
- Is the site healthy?
- Where are the largest operational risks?
- Where are power, hashrate, or temperature anomalies?
- Which repairs are delayed?
- Which rooms, racks, models, or components fail most often?
- Are reliability and efficiency improving?
## What MinerOps Is
MinerOps is being built as an operational intelligence platform that integrates
monitoring, repair workflows, durable asset history, component history, spatial
visualization, analytics, and operational context. It is the shared operational
record that connects a live observation to the physical asset, its location,
its history, and the decision it informs.
## What MinerOps Is Not
MinerOps is not intended to become a generic ERP, an accounting platform, a full
purchasing system, a general warehouse-management system, or a replacement for
operator judgment. Those systems may integrate with MinerOps later when doing so
improves operational context without changing MinerOps's core purpose.
## Long-Term Direction
Potential long-term directions include historical map playback, predictive
maintenance, failure correlation, component genealogy, fleet optimization,
AI-assisted investigation, and decision support. These are areas for evaluation,
not delivery commitments. AI should organize evidence, surface relationships,
and suggest investigations in support of operator judgment; it should not
replace that judgment.
## Final Guiding Principle
Every feature should solve a real operational problem experienced by technicians, operators, or managers. Operational clarity takes priority over technical novelty.
+156
View File
@@ -0,0 +1,156 @@
# MinerOps API Style Guide
## Purpose
Keep MinerOps APIs explicit, consistent, auditable, and aligned with the domain architecture. Implemented conventions and approved task specifications take precedence.
## Principles
1. Prefer domain commands over generic CRUD.
2. Use nouns for resources and explicit action routes for operational commands.
3. Keep writes focused and atomic.
4. Return truthful domain representations.
5. Separate authentication, permission, validation, domain logic, and persistence.
6. Preserve static-route compatibility.
7. Do not expose database structure as the public API.
## Current and Proposed Examples
Implemented modular repair routes:
```http
POST /api/repairs
GET /api/repairs/{repair_id}
GET /api/repairs/{repair_id}/events
POST /api/repairs/{repair_id}/complete
```
Relevant legacy compatibility routes include:
```http
GET /api/repairs/queue
GET /api/repairs/parts-summary
POST /api/miners/{miner_id}/change-mac
```
These integrated routes predate the modular domain conventions and are not
models for new code.
Proposed by Task 004, not implemented:
```http
POST /api/miners/{miner_id}/replace-control-board
```
Good:
```http
POST /api/repairs/{repair_id}/complete
```
Avoid:
```http
PATCH /api/repairs/{repair_id}
{"status":"completed"}
```
## Methods
- GET is read-only.
- POST creates a resource or invokes an explicit command.
- PATCH is limited to non-operational metadata when partial update semantics are
appropriate.
- Meaningful operational changes use explicit commands rather than generic PUT,
PATCH, or DELETE routes.
- Historical records are corrected with explicit append-only or void workflows,
not physical deletion.
## Status Codes
- 200: successful read or command with response
- 201: created resource
- 204: success with no useful response
- 401: unauthenticated
- 403: lacks permission
- 404: resource missing
- 409: valid request conflicts with domain state
- 422: request validation failure
## Identifiers
Use typed UUIDs for authoritative entity identifiers. Do not expose sequential database IDs as public identity.
## Time and Actors
Use timezone-aware ISO 8601 timestamps. Authoritative timestamps and actor IDs are server controlled. Never accept client-supplied event actor, event timestamp, completion timestamp, or sequence.
## Permissions
Implemented modular repair capabilities are:
- `repairs.read`
- `repairs.create`
- `repairs.complete`
Task 004 proposes `miners.replace_control_board`; it is not registered today.
Do not duplicate role logic in routers.
## Request Models
Use constrained types, normalize whitespace, reject unknown values, enforce length limits, avoid persistence-only fields, and never accept arbitrary client event payloads.
## Response Models
The modular Inventory and Repairs APIs use typed success envelopes containing
`data`, `meta`, and `error`. Expose useful domain state, not secrets or unrelated
nested data. Preserve null when historical truth is unknown.
## Errors
The application does not yet have one uniform typed error envelope. Modular
routes currently use FastAPI validation responses and `HTTPException` detail
responses while translating domain exceptions to 404, 409, or 422 as
appropriate. New routes must follow the established behavior of their domain
and must not leak SQL or driver errors. Repositories do not raise HTTP
exceptions. Services do not import FastAPI.
## Pagination
Potentially large collections require a bounded pagination design. The current
Inventory collection uses bounded `limit` and `offset`; do not claim cursor
pagination until a domain adopts it. The implemented repair timeline returns
all events for one repair by explicit Task 002 design. Any later change to that
contract requires compatibility review.
## Filtering
Use explicit allowlisted filters and sort fields. Never pass arbitrary column names into queries.
## Static Routes
Register static routes before overlapping UUID routes:
```text
/api/repairs/queue
/api/repairs/parts-summary
/api/repairs/{repair_id}
```
Add route-precedence tests.
## Idempotency and Concurrency
Use database invariants, row locks, affected-row checks, and narrow operation
identifiers where demonstrated need exists. Do not add a generalized
idempotency platform speculatively.
The implemented repair workflows prevent duplicate active repairs, duplicate
completion, duplicate event sequences, and partial state. Task 004 proposes the
additional invariant that concurrent requests cannot assign one normalized MAC
to two miners.
## Events
Clients request domain actions, not raw events. The service constructs immutable events.
## Versioning
MinerOps does not currently expose versioned API prefixes. Any incompatible API
versioning scheme requires a separate compatibility decision; this guide does
not reserve a future path.
## Route Specification Checklist
Every new route should define:
- method and path
- authentication and permission
- request and response
- status codes
- domain rules
- transaction boundary
- concurrency behavior
- events
- tests
- out-of-scope items
## Review Checklist
Confirm explicit action choice, status codes, permission, actor and timestamp authority, UUID typing, limits, transaction, concurrency, event construction, route precedence, architecture boundaries, and regressions.
+457
View File
@@ -0,0 +1,457 @@
# MinerOps Component Domain Model
## Status
Proposed design baseline for tracked physical components. The generalized
component model, tables, routes, permissions, lifecycles, and events described
here are not currently implemented. Task 004's implemented control-board
replacement structures and behavior remain authoritative.
## Purpose
MinerOps should represent important physical parts as durable entities with their own identity, installation history, condition, location, and repair relationships.
A miner remains the durable parent asset. Replacing a control board, hashboard, PSU, fan, or other component must not create a new miner or erase prior history.
## Design Principles
- Model meaningful physical objects when tracking them creates operational value.
- Give every individually tracked component an immutable UUID.
- Separate internal UUID, human Asset ID, and manufacturer identifiers.
- Preserve installation, movement, repair, and disposition history.
- Keep current-state projections fast without treating them as historical truth.
- Add complexity progressively rather than requiring full warehouse management.
## Core Model
### Component
The base durable identity for an individually tracked physical part.
Conceptual fields:
- immutable UUID
- optional human Asset ID
- component type
- manufacturer
- model
- manufacturer serial
- lifecycle
- condition
- notes
- creation and retirement metadata
A component type should not change after meaningful operational history exists.
### Specialized Components
Use type-specific tables only where structured attributes or rules justify them.
The following names are conceptual and do not reserve future table names:
```text
components
├── control_boards
├── hashboards
├── power_supplies
├── fans
└── cables
```
Examples:
- Control board: model, hardware revision, firmware, and relationships to the
implemented Task 004 installation and MAC history. A specialization must not
create competing MAC history.
- Hashboard: serial, revision, nominal hashrate, chip count.
- PSU: serial, model, rated power, voltage range.
- Fan: model, dimensions, connector, rated RPM.
Many simple parts may remain quantity-based rather than individually tracked.
## Identity
Every tracked component may have:
1. **Internal UUID**: immutable database and API identity.
2. **Human Asset ID**: operator-facing label such as `CB-00183`.
3. **Manufacturer identity**: serial, MAC, QR code, PCB identifier, or firmware-reported value.
Manufacturer values help identify a component but do not define its durable identity.
## Component Installation
A Component Installation records where a component was installed over time.
Conceptual fields:
- installation UUID
- component UUID
- miner UUID
- component slot
- installed timestamp and actor
- related repair
- removed timestamp and actor
- removal disposition
- notes
A current installation has no removal timestamp.
### Invariants
- A component has at most one current installation.
- It cannot be installed in two miners simultaneously.
- A miner cannot have conflicting components in an exclusive slot.
- Removal occurs after installation.
- Unknown legacy actor or timestamp values remain unknown.
- Historical installations are not physically deleted.
## Component Slots
Examples:
```text
control_board
hashboard_1
hashboard_2
hashboard_3
power_supply
front_fan_1
rear_fan_1
```
Slots may later be constrained by miner model or hardware profile.
## Lifecycle and Condition
Lifecycle and condition are different concepts.
Candidate lifecycle values:
- available
- installed
- under_repair
- quarantined
- sent_for_rma
- retired
- scrapped
- lost
- unknown
Candidate condition values:
- new
- working
- used
- repaired
- untested
- failed
- damaged
- unknown
Only introduce values when the corresponding workflows exist.
## Locations
A component may be:
- installed in a miner
- stored at a site, room, rack, shelf, or bin
- on a repair bench
- in transit
- with a vendor
- in RMA
Installed components normally inherit physical location from the miner. Uninstalled components require location history.
A component must not simultaneously have an incompatible current installation and current storage location.
## Events
Component history is append-only.
Candidate events:
- component created
- Asset ID changed
- installed
- removed
- moved
- condition changed
- repair started
- repair completed
- sent for RMA
- returned from RMA
- retired
- scrapped
The implemented `CONTROL_BOARD_REPLACED` event remains valid. It does not
currently contain a generalized component UUID. A later migration may add
component or installation references only when those identities exist
authoritatively; it must not reconstruct or invent them for older events.
Do not duplicate the same authoritative event across unrelated streams. One event may appear in several read models.
## Current Projections
Implemented Task 004 current-state projections are:
```text
miners.current_mac
miners.control_board_sn
```
`miners.firmware_version` is an existing miner field, but Task 004 records the
replacement firmware on the installation and does not update that miner field.
Possible generalized component projections include:
```text
components.lifecycle_status
components.current_location_id
```
Projection updates must occur in the same transaction as authoritative history. Constraints or deferred checks should prevent drift.
## Repairs and Components
Repairs and components are related but distinct.
- A repair belongs to one miner.
- A component may move through several miners.
- A component may appear in many repair histories.
- A repair event may reference a component or installation.
- The repair timeline explains what happened in that repair.
- The component history explains the component's lifetime.
## Parts Versus Components
Not every part needs an individual UUID.
### Individually tracked
Appropriate for:
- control boards
- hashboards
- PSUs
- expensive or serialized fans
- specialty harnesses
- tagged network hardware
### Quantity-based
Appropriate for:
- screws
- thermal pads
- connectors
- common cables
- cleaning supplies
- untracked fans
Task 005 remains a focused quantity-based repair record and does not require a
generalized component inventory. Whether it may optionally reference an
existing tracked component is an open decision for its implementation; it must
not create or infer component identity automatically.
## Donor Components
A component moved from Miner A to Miner B retains the same UUID.
```text
Installed in Miner A
Removed from Miner A
Stored or repaired
Installed in Miner B
```
Never clone the component record merely because ownership or location changes.
## Unknown Components
A component may be tracked without a known serial, Asset ID, exact installation date, or complete history. Unknown values remain null or explicitly unknown. Do not fabricate identifiers or dates.
## MAC Identity
A control board MAC is a mutable manufacturer/network identifier.
- It does not define the miner.
- It does not define the component UUID.
- Current and historical ownership remain searchable.
- Automatic transfer between durable miners is prohibited.
- Conflict resolution is explicit.
- Ownership, network association, and installation history must not drift.
Implemented Task 004 structures remain authoritative until an explicit,
history-preserving migration generalizes them:
- `miners.current_mac` and `miners.control_board_sn` are current projections.
- `miner_network_identities` stores current and historical network associations.
- `miner_mac_ownerships` prevents normalized MAC ownership from silently moving
between durable miners.
- `miner_control_board_installations` preserves installation and removal
history and enforces one current board per miner.
- `repair_events` stores the immutable, sequenced `CONTROL_BOARD_REPLACED` fact.
Task 004 requires the miner to be `in_repair`, requires one active repair, and
updates these records in one transaction. Deferred database checks keep the
current miner, installation, and network-identity projections aligned. Legacy
unknown actors and installation timestamps remain null.
## Permissions
Candidate capabilities:
```text
components.read
components.create
components.update_metadata
components.install
components.remove
components.move
components.change_condition
components.retire
components.scrap
components.rma.manage
```
`miners.replace_control_board` is implemented for Task 004 and is granted to
Admin, Manager, and Technician, but not Read Only. Task 005 proposes
`repairs.parts.manage`; it is not implemented. The candidate component
capabilities above remain future concepts.
## Transactions
Every future generalized component operation must own one transaction. Task 004
already performs its control-board replacement as one transaction.
### Install
- lock miner and component
- validate lifecycle and slot
- close prior installation only through an explicit replacement
- create installation
- update projections
- append event
### Remove
- lock component and current installation
- close installation
- update projections and location/lifecycle
- append event
### Move
- close current location
- create new location
- update projection
- append event
## Lock Ordering
Implemented repair and control-board workflows use:
```text
Miner -> Repair -> related component, network-identity, and ownership rows
```
Before generalized component workflows are implemented, define one global order across Miner, Component, Repair, Installation, and Location. No task should introduce a conflicting lock order without review.
## Database Invariants
Where practical, enforce:
- an explicitly decided Asset ID uniqueness scope when Asset IDs are introduced
- one current installation per component
- one component per exclusive miner slot
- installation chronology
- no incompatible installation and storage location
- constrained lifecycle and condition values
- immutable MAC ownership across durable miners
- actor and timestamp requirements for new rows
- history-preserving foreign keys
Use legacy-safe constraints where historical truth is incomplete.
## API Principles
Implemented Task 004 operation:
```http
POST /api/miners/{miner_id}/replace-control-board
```
The legacy `POST /api/miners/{miner_id}/change-mac` route is a compatibility
alias that uses the same permission and Task 004 service workflow.
Possible future explicit operations include:
```http
POST /api/miners/{miner_id}/components/{component_id}/install
POST /api/components/{component_id}/remove
POST /api/components/{component_id}/move
POST /api/components/{component_id}/retire
```
Avoid generic ownership or lifecycle patches for meaningful operations.
## Relationship to Task 004
Task 004 is the first implemented component-history workflow. Its route requires
`miners.replace_control_board`, an active repair, and miner lifecycle
`in_repair`.
Do not immediately rewrite it into a generic framework. Generalize only when Task 005 or later work proves a shared abstraction and a safe migration path.
## Relationship to Task 005
Task 005 should:
- record repair parts
- support quantity-based consumables
- remain usable without generalized component inventory
- reference an existing tracked component only if that optional relationship is
explicitly approved during Task 005 implementation
- preserve explicit void/correction history
- leave control-board identity changes under Task 004
- avoid warehouse, purchasing, vendor, and supplier scope
## Future Directions
This model can later support:
- spare-parts inventory
- vendors and purchasing
- warranty and RMA
- donor units
- test benches and results
- QR/barcode labels
- costs
- failure analytics
- predictive maintenance
These are future directions, not present commitments.
## Open Product Decisions
Before generalized component inventory is implemented, decide:
1. Which component types require individual tracking?
2. Are human Asset IDs required for each type?
3. Can technicians create components during repair work?
4. Can installation occur before a formal inventory record exists?
5. Which types require serial numbers?
6. How are donor miners represented?
7. What is the global lock order?
8. How does a removed component move to available, under repair, or scrapped?
9. Are component repairs separate records or work orders?
10. How will Task 004 history migrate into generalized components?
11. Do component Asset IDs share the miner Asset ID namespace or use a separate
uniqueness scope?
12. Should Task 005 accept an optional tracked-component UUID, or remain limited
to its approved quantity, type, and serial fields?
## Decision Test
A component feature should help identify, locate, repair, install, move, preserve history for, or prevent conflicting ownership of a physical part. Interesting data alone is not enough.
+752
View File
@@ -0,0 +1,752 @@
# MinerOps Mine Map v1
## Status
This document is the design baseline for the implemented Mine Map v1. The
current application exposes the read-only operational view at `/monitoring/map`
and retains the legacy Location Map at `/locations` for existing location
management workflows. Those mutation workflows are not redesigned by v1.
## Purpose
Mine Map v1 answers one primary question:
> What is happening physically in the mine right now, and where do I need to
> look?
It must let a technician or manager scan a physical room, identify explicit
runtime and work states, locate affected miners, understand empty capacity, and
inspect one miner without losing spatial context. It is an operational tool,
not a geographic GIS map and not a decorative dashboard.
## Current Data Model
### Physical Hierarchy
The implemented hierarchy is:
```text
Site (UUID)
-> Room (UUID, belongs to Site)
-> Rack (text label on a location position)
-> Shelf (text label and optional numeric index)
-> Position (UUID and numeric index)
-> Miner (UUID, optional occupant)
```
`sites` and `rooms` are normalized entities. A room belongs to one site.
`location_positions` stores `room_id`, denormalized `room_name`, `rack_name`,
`shelf_name`, optional `shelf_index`, `position_index`, and optional
`position_label`. Rack and shelf are not independent entities and do not have
durable UUIDs. Mine Map must therefore treat rack and shelf values as ordered
labels within a room, not as globally addressable assets.
A position may reference one miner. `miner_location_states` separately stores
the miner's current location state and optional position reference. The miner's
durable identity remains `miners.id`; neither its position, IP address, nor MAC
address is asset identity.
### Position Vocabulary
The current code uses these position kinds:
- `miner_slot`
- `switch`
- `blocked`
The current code uses these position statuses:
- `available`
- `occupied`
- `switch`
- `blocked`
- `conflict`
These values are used by the active implementation but are not constrained by
the database. V1 must render unknown values as **Unknown position state** and
must not silently treat them as empty or occupied. A later schema task should
constrain the vocabulary after legacy-data preflight.
The current miner location-state vocabulary is:
- `shelved`
- `waiting_placement`
- `storage`
- `repair`
- `decommissioned`
These values are also not database-constrained. They describe location or
placement state, not miner lifecycle and not authoritative repair status.
### Lifecycle And Repair State
Miner lifecycle is independently constrained to:
- `active`
- `spare`
- `retired`
- `in_repair`
The modular Repairs domain uses `repairs` and immutable `repair_events` as the
authoritative repair workflow. Active repair statuses are `OPEN`,
`IN_PROGRESS`, `WAITING_FOR_PARTS`, and `READY_FOR_TEST`. `COMPLETED` and
`CANCELLED` are not active repairs.
The legacy location API currently exposes `miner_location_states.asset_state`
but does not expose lifecycle or the authoritative active repair. Mine Map v1
must not equate legacy `asset_state = repair` with a modular repair record.
## User Outcomes
V1 must support these outcomes without requiring location mutation:
- Select a site and room and see its known physical layout.
- Scan racks, shelves, and positions in their stored order.
- Distinguish occupied, explicitly empty, blocked, switch, conflict, and
incomplete positions.
- Identify miners explicitly reported offline or hash-unstable.
- Identify miners in the authoritative repair lifecycle or an active repair.
- Distinguish active, spare, retired, and in-repair assets.
- See latest available hashrate, power, and temperature values with timestamps.
- Inspect a miner and open its existing detail page.
- Return to the same room, rack, layer, filters, selection, and scroll context.
## Page Composition
Mine Map uses the current authenticated application shell at
`/monitoring/map`. The page contains four functional regions.
### Context Bar
The context bar contains:
- site selector;
- room selector;
- layer selector, defaulting to **Operational Status**;
- search;
- filters;
- data freshness summary; and
- a link to the existing location-management page when the user has
`locations.manage`.
The selected site limits the room selector. The selected room is required for
the primary rack layout. An optional all-room summary may show room counts, but
v1 must not render every tile from every room by default at large sites.
### Spatial Layout
The main region renders the selected room as ordered rack groups. Each rack
contains shelf rows, and each row contains positions ordered by
`position_index`. The layout uses ordinary semantic DOM elements and CSS grid.
Canvas, WebGL, pan physics, and a geographic basemap are not required.
The visual order follows stored room `sort_order`, shelf `shelf_index` where
present, and position index. Rack ordering is label-based because no rack sort
field exists. The UI must not infer missing racks, shelves, or positions from
gaps in names or numbers.
### Legend And Summary
The legend is always visible for the active layer and pairs color with text and
an icon or pattern. A compact summary reports only counts derivable from the
loaded response, such as occupied positions, empty positions, conflicts,
explicit offline miners, in-repair miners, and unknown-status miners.
Counts must identify their scope and freshness. They must not claim site-wide
coverage when only one room is loaded or when data is partial.
### Miner Inspector
Selecting an occupied miner opens a side panel on desktop. On narrow screens it
opens a full-height sheet with an obvious close control. The map remains the
underlying context and selection is visible on the selected tile.
Closing the inspector restores focus to the tile that opened it. Opening the
full miner detail view uses the existing durable route:
```text
/miners/{miner_id}/detail
```
## Position And Miner Rendering
### Occupied Positions
An occupied miner tile shows, in priority order:
- position label or `Position {position_index}`;
- Asset ID when available, otherwise current MAC, otherwise **Unidentified
miner**;
- operational condition text and icon;
- the active layer's value; and
- observation age or **Never observed**.
The position remains the visible frame; filtering and status styling must not
make its physical address ambiguous.
### Empty And Non-Miner Positions
Empty positions are operationally meaningful and remain visible.
| Condition | V1 presentation |
| --- | --- |
| `miner_slot`, no miner, no expected MAC, `available` | **Empty position** |
| `miner_slot`, expected MAC, no miner | **Expected miner not resolved**; not empty |
| `blocked` kind or status | **Blocked position** with reason when available |
| `switch` kind or status | **Switch/equipment position** with equipment label |
| `conflict` or `conflict_reason` | **Location conflict**; never shown as empty |
| Unknown kind or status | **Unknown position state** |
| Filtered occupied miner | Tile remains in place but is visually deemphasized |
| Missing response data | **Unavailable**, distinct from an empty position |
An absent position record is not an empty position. V1 must not synthesize a
tile merely because a numeric position is missing from a shelf sequence.
### Unracked And Operationally Removed Miners
Miners returned in `virtual_miners` appear outside the rack canvas in an
**Unplaced and operational areas** section grouped by the stored location
state. V1 maps the stored values to plain labels without inventing workflow:
- `waiting_placement`: Waiting for placement
- `storage`: Storage
- `repair`: Legacy repair location state
- `decommissioned`: Decommissioned location state
Lifecycle is shown separately. A spare miner is not automatically in storage,
and a retired miner is not automatically decommissioned. Where lifecycle and
location state disagree, both are displayed and the discrepancy is not hidden.
### Repair Miners
A miner with `lifecycle_status = in_repair` or an authoritative active repair is
visually unmistakable through text, icon, and a repair treatment independent of
the selected data layer.
V1 distinguishes:
- in repair while still assigned to a physical position;
- in repair with no rack position;
- repair complete with lifecycle `active` or `spare`, awaiting placement; and
- legacy `asset_state = repair` without an authoritative active repair.
The last case is labelled **Legacy repair location state** rather than being
presented as a confirmed active repair.
## Operational Status Model
Operational Status is the default layer. It composes current facts with this
display precedence:
1. Location conflict, blocked position, switch, or empty position.
2. Active repair or lifecycle `in_repair`.
3. Lifecycle `retired` or decommissioned location state.
4. Lifecycle `spare`.
5. Runtime status explicitly equal to `offline`.
6. `hash_stable = false`, labelled **Hash unstable**.
7. Runtime status explicitly equal to `online`.
8. Unknown or unrecognized runtime status.
Lifecycle and repair treatments do not erase runtime facts. For example, an
in-repair miner may also show that its last runtime observation was online.
`miners.status` is free text and not database-constrained. V1 must not infer
that arbitrary values containing `error`, `warn`, or `fault` mean a particular
severity. Unknown values may be displayed as secondary raw context after safe
formatting.
### Freshness
`last_seen_at` and latest telemetry `observed_at` are available, but MinerOps has
no single authoritative staleness policy. Existing legacy screens use
inconsistent thresholds, including 10 and 30 minutes. Therefore:
- v1 always displays observation time or age;
- missing timestamps are **Never observed** or **Freshness unknown**;
- explicit runtime `offline` may be shown as Offline;
- v1 does not label a miner Stale based on age until a centralized policy is
approved and supplied by the backend; and
- late or failed refreshes keep the last successful values visible with a
prominent **Update failed** or **Data may be stale** state.
### Conditions Requiring Policy
The following labels require an approved backend policy and are not inferred by
v1:
- stale based on observation age;
- down based on missing hashrate or elapsed time;
- hot or overheating based on temperature;
- degraded based on hashrate, rejection rate, power, or temperature; and
- low hashrate relative to model expectation.
The legacy Location Map and dashboard endpoints contain hard-coded thresholds,
but they are not a consistent policy contract. V1 may display raw measurements
and the explicit `hash_stable` boolean without adopting those thresholds.
## Layer Model
The layer selector is extensible, but the first implementation is not required
to enable every layer.
| Layer | V1 status | Current data | Required behavior |
| --- | --- | --- | --- |
| Operational Status | Required, default | Position kind/status, runtime status, lifecycle after API addition, `hash_stable`, timestamps | Uses the factual precedence above; no invented thresholds |
| Repair State | Required presentation, optional standalone layer | Lifecycle and modular repair after API addition; legacy location state exists now | Repair treatment remains visible on every layer |
| Temperature | Optional v1 value layer | Latest chip maximum and average are already in the map response; environment temperature exists in snapshots but is omitted | Show numeric value and timestamp; no hot classification without policy |
| Hashrate | Optional v1 value layer | Latest average hashrate in TH/s is already in the map response | Show value and missing-data state; no low-hashrate threshold |
| Power | Optional v1 value layer | Latest power in watts is already in the map response | Show value and missing-data state; no density or efficiency inference |
| Thermal heat map | Future | Point temperatures exist for some miners | Requires approved thresholds, aggregation semantics, coverage disclosure, and legend |
The frontend implements layers through a shared tile contract rather than
separate maps. Adding a future layer changes tile content and legend, not the
physical hierarchy or selection model.
## Filtering And Search
The implemented filters are allowlisted and use returned miner facts: derived
operational status, lifecycle, model, and authoritative active-repair presence.
Search covers Asset ID, miner serial, current MAC, current IP, and model. Location
context remains visible while nonmatching occupied tiles are deemphasized. Users
without `miners.view` receive none of these miner fields; miner search, miner
filters, and non-status layers are disabled rather than operating on redacted
values.
Filtering preserves physical context. Positions remain in the layout;
nonmatching occupied tiles are deemphasized and marked as filtered. A separate
**Show only matches** density option may collapse nonmatching rack groups, but
it is off by default and clearly indicates that context is hidden. Empty,
blocked, switch, and conflict positions remain visible unless explicitly
filtered by position type.
No-results differs from load failure and from an empty room:
- **No positions configured**: the selected room has no position records.
- **No miners match these filters**: layout remains visible, with miners
deemphasized.
- **Map unavailable**: the request failed or returned an invalid response.
## Miner Inspector Fields
Where authorized and available, the inspector shows:
- Asset ID;
- model or miner type;
- miner serial number;
- current MAC and IP address;
- full site, room, rack, shelf, and position path;
- runtime status and telemetry observation age;
- lifecycle;
- active repair status and reason;
- latest hashrate in TH/s;
- latest power in watts;
- latest chip maximum temperature;
- telemetry observation time; and
- a link to full miner detail.
Null values render as **Unavailable**. Numeric zero is displayed as zero, not as
missing. The panel must not fetch one API resource per visible tile. A single
detail request for the selected miner is acceptable when it supplies fields not
needed by the map response.
## Context Preservation
Shareable state is URL-backed. The initial query contract is:
```text
/monitoring/map?site_id={uuid}&room_id={uuid}&layer=status&miner_id={uuid}
```
Optional encoded parameters hold allowlisted filters and search. Rack and shelf
do not have URL identifiers because they are labels rather than durable entities.
Unknown, unauthorized, or malformed parameters are discarded and the canonical
URL is replaced; they never broaden data access.
The URL preserves:
- selected site and room;
- layer;
- filters and search; and
- selected miner.
Selecting or closing a miner updates history so browser Back and Forward restore
predictable URL-backed selection. Refresh and bookmarked links reconstruct the
same valid shareable state. Scroll position is not persisted by v1.
When full miner detail opens in the same tab, the return URL contains the map
query state. A new tab remains available through normal browser behavior.
## Responsive Behavior
### Desktop
- Context controls remain visible above the map.
- The room layout occupies the main content width.
- The inspector uses a fixed-width side panel without resizing rack tiles.
- Horizontal scrolling is contained within an individual wide rack or map
viewport, not the whole page.
### Tablet And Phone
- Site, room, layer, and filter controls stack without hiding the active scope.
- One rack is shown at a time when the full room cannot remain legible.
- Shelf rows retain physical position order and allow contained horizontal
scrolling.
- The inspector becomes a full-screen sheet.
- Closing the sheet restores the selected tile and scroll location.
- Touch targets meet the shared UX guide sizing rules.
The mobile view does not turn positions into an unrelated flat miner list. Rack
and shelf context remains visible.
## Accessibility
Mine Map v1 must:
- communicate every state with text and an icon or pattern, not color alone;
- provide one page heading and semantic site, room, rack, shelf, and position
labels;
- expose each rack as a labelled region and each shelf as a labelled group;
- expose miner tiles as buttons when they open the inspector and noninteractive
elements when they do not;
- include an accessible name containing position, miner identity, condition,
and freshness;
- support logical keyboard movement through positions in stored spatial order;
- preserve visible focus and return focus after closing the inspector;
- provide keyboard-operable filters, layer selection, scrolling, and zoom or
density controls;
- avoid hover-only values;
- support browser zoom without clipping labels or controls; and
- avoid required motion and respect reduced-motion preferences if motion is
introduced later.
A screen-reader summary provides counts and a structured rack/shelf/position
list. It describes empty and blocked positions rather than omitting them.
## Loading, Failure, And Partial Data
The map has explicit states:
- **Loading**: layout skeletons preserve the eventual page structure.
- **Ready**: layout and response scope are known.
- **Empty room**: valid response with no configured positions.
- **No configured sites or rooms**: directs authorized users to existing
location management without implying a load failure.
- **Denied**: permission failure without leaking counts or miner previews.
- **Error**: request failed or response shape is invalid; remains visible until
retry or navigation.
- **Partial data**: physical layout is usable, but one or more miner fields or
telemetry observations are unavailable.
- **Refresh failed**: last successful map remains visible, clearly marked with
the failure time.
The frontend validates the response envelope and required position identifiers.
A malformed response is an error, not an empty room.
## Performance And Refresh
V1 is designed for hundreds or thousands of positions without prematurely
adopting canvas or WebGL.
- Fetch a map-shaped bulk response; never issue one request per position.
- Load one selected site and room where backend filtering is available.
- Render the visible room first and defer secondary summaries.
- Build rack and shelf fragments off-DOM, then attach them in batches.
- Update changed tile content without replacing the full page or shell.
- Keep layout refresh separate from telemetry refresh because positions change
less frequently than observations.
- Do not overlap refresh requests; cancel or ignore superseded responses.
- Pause automatic telemetry refresh while the page is hidden.
- Base any polling interval on collector cadence or backend guidance; this
document does not invent an interval.
- Preserve last successful values during transient failure and expose their
observation timestamps.
- Introduce list virtualization only after measurement shows the selected-room
DOM is too large; accessibility and keyboard order must be preserved.
The current bulk location query selects the latest snapshot per miner and is a
useful foundation. Before production use at large scale, its query plan and
indexes must be measured with representative site data.
## Current API Assessment
### `GET /api/location/map`
Permission: `locations.view`.
This is the primary existing map-shaped response and avoids N+1 tile requests.
It returns:
- `data.positions`: position UUID, room/rack/shelf labels, shelf and position
indexes, position label/kind/status, expected MAC, equipment metadata,
conflict information, miner UUID, current MAC/IP, runtime status, hostname,
serials, model/type, firmware, last seen, legacy location state, and latest
hashrate, power, chip temperatures, hash stability, rejection percentage, and
observation time;
- `data.virtual_miners`: miners in waiting-placement, storage, legacy repair,
or decommissioned location states; and
- counts in `meta`.
The implemented additive response also supplies site and room UUIDs, Asset ID,
lifecycle, authoritative active repair context, generated-at metadata, typed
contracts, and optional server-side site and room filters. Its read path follows
Router -> Service -> Repository -> PostgreSQL. Users with `locations.view` but
without `miners.view` receive physical layout and occupancy with miner identity,
telemetry, repair, and virtual-miner details removed.
Tags and environment temperature are not included because v1 does not require
them. Rack and shelf remain labels. The frontend renders legacy positions without
`room_id` in a separate **Unscoped legacy positions** section; it does not attach
their denormalized room labels to a selected site or room.
### `GET /api/sites`
Permission: `locations.view`.
Returns site UUID, name, description, and timestamps. It supplies the site
selector. It does not include room or map counts.
### `GET /api/rooms`
Permission: `locations.view`.
Returns room UUID, site UUID/name, room name/description, sort order, and
timestamps. It supplies site-scoped room selection.
### `GET /api/location/rooms-order`
Permission: `locations.view`.
Returns room UUID/name/order and position, miner-slot, switch, blocked, and
assigned-miner counts. Its location join is name-based and not site-scoped, so
it cannot be the sole source for a correct multi-site selector.
### `GET /api/inventory/miners`
Permission: `miners.view`.
Returns durable miner UUID, Asset ID, current MAC/IP, model, lifecycle, runtime
status, site/room UUIDs, tags, and timestamps. Search covers Asset ID, MAC, IP,
model, miner serial, control-board serial, PSU serial, and notes. The collection
is bounded to 500 records per request and has no physical position or telemetry.
It can validate the missing identity fields during development, but Mine Map
must not join paginated inventory calls in the browser for every room. The map
read model should include the small identity and lifecycle subset it needs.
### `GET /api/inventory/miners/{miner_id}`
Permission: `miners.view`.
Adds hostname, miner serial, control-board serial, PSU serial, firmware, notes,
and first-seen time. One call after selection is acceptable when the inspector
needs these fields, but it is not used to populate tiles.
### `GET /api/miners/{miner_id}/status-snapshots`
Authentication and `miners.view` are required. Admin is allowed by the existing
admin override; Manager, Technician, Read Only, and custom roles are authorized
according to their effective `miners.view` permission. Mine Map does not call
this endpoint per tile.
Returns up to 100 recent snapshots with hashrate, power, efficiency,
environment and chip temperatures, fans, uptime, stability, rejection rate,
performance mode, timestamps, and raw JSON. It is too heavy for every tile and
must not be called per visible miner. It may support a future selected-miner
trend, which is outside v1.
### Repair APIs
`GET /api/repairs/{repair_id}` and its event route expose authoritative modular
repair detail when a repair UUID is already known. There is no current modular
read endpoint that returns the active repair for each map miner.
`GET /api/repairs/queue` is a bulk legacy queue based on
`miner_location_states.asset_state = repair` and legacy maintenance events. It
does not represent the modular `repairs` table and must not be used as the
authoritative Mine Map repair source.
The additive map read model therefore needs a set-based join to at most one
active modular repair per miner. It must not perform per-miner repair lookups.
### Dashboard APIs
`GET /api/dashboard/miner-table` exposes latest telemetry in a fleet table.
The hot, unstable, and stale endpoints expose useful operational lists, but
they use endpoint-specific or caller-supplied thresholds. Mine Map does not
need these endpoints because the location map already includes the relevant raw
latest measurements. V1 must not combine their classifications into a new
status policy by accident.
## Implemented Map Read Model
The additive `GET /api/location/map` contract provides:
- site UUID and name;
- room UUID as well as room name;
- Asset ID;
- lifecycle status;
- a nullable active repair object containing repair UUID, status, and reason;
- response generation time;
- explicit scope metadata for selected site and room;
- per-miner latest telemetry observation time; and
- truthful nulls for unavailable fields.
The query is set-based, parameterized, permission-controlled, and uses four
fixed database executions independent of fleet size. Optional site and room UUID
filters are supported by the API; the current frontend performs one bulk request
and renders the selected room locally. The repository owns SQL, the service owns
response composition, and the router owns HTTP validation, authorization, and
the typed response envelope.
V1 does not require a write endpoint. Reading the map, changing layer, applying
filters, selecting a miner, or opening the inspector must never mutate location,
lifecycle, repair, or telemetry state.
## Permissions
- The page and location layout require `locations.view`.
- Supplemental Inventory detail requires `miners.view`.
- The existing miner detail link is shown only when the user can view miners.
- The location-management link is shown only with `locations.manage`.
- No placement or repair command is exposed by Mine Map v1.
- Frontend permission checks control presentation only; backend dependencies
remain authoritative.
All default roles currently receive both `locations.view` and `miners.view`,
but custom role combinations are possible. The backend implements the boundary:
`locations.view` alone returns layout and occupancy while redacting Asset ID,
miner UUID, expected/current MAC, IP, serials, model, runtime/lifecycle/location
state, telemetry, active repair, and virtual-miner data. This is authoritative
server-side redaction rather than frontend hiding.
## Architecture Boundary
Mine Map v1 follows:
```text
Frontend page
-> FastAPI router
-> Location map service/read model
-> Location repository
-> PostgreSQL
```
The frontend contains no SQL, status policy, repair reconstruction, or location
mutation. The read repository may join location, Inventory-owned projections,
latest telemetry, and active repair state for this explicit read model. It does
not write those domains. The service maps persistence results into the typed
map contract and preserves null and unknown values.
The existing integrated route may be migrated incrementally behind the same
path. The legacy `/locations` page remains available during rollout. Mine Map
v1 must not copy its inline HTML/JavaScript or hard-coded classification rules
into the new frontend shell.
## V1 Implementation Boundary
The smallest useful v1 includes:
- real site and room selection;
- real racks, shelves, positions, and miners;
- explicit empty, blocked, switch, conflict, and unknown positions;
- unplaced and operational-area miners;
- Operational Status as the default layer;
- repair and lifecycle treatments based on authoritative data;
- raw latest telemetry values and freshness;
- status legend and scoped counts;
- basic allowlisted filters and search;
- selected-miner inspector;
- existing miner-detail navigation;
- URL-backed context and predictable browser history;
- loading, empty, denied, error, and partial-data states;
- desktop, tablet, and phone behavior;
- keyboard, screen-reader, touch, zoom, and reduced-motion support;
- permission-aware presentation; and
- focused route, contract, rendering, permission, accessibility, and context
preservation tests.
Location creation, movement, import, rack/shelf editing, and position-type
changes remain in existing management workflows. V1 may link to those workflows
but does not reproduce them.
## Deferred Capabilities
The following are explicitly outside v1:
- advanced thermal heat maps;
- historical map playback;
- drag-and-drop or click-to-move relocation;
- map, rack, shelf, or position editing;
- predictive failure visualization;
- animated telemetry;
- power-density and electrical-capacity modelling;
- saved map views;
- multi-site geographic maps;
- multi-selection and bulk commands;
- repair, lifecycle, or component mutations;
- alerts and notifications;
- selected-miner telemetry charts; and
- generalized infrastructure modelling beyond current position records.
## Remaining Backend And Data Gaps
1. **Freshness policy:** No single approved stale/down threshold exists. V1
shows explicit offline state and observation age but does not classify stale.
2. **Health and thermal policy:** Hashrate, rejection, power, and temperature
measurements exist, but model-aware degraded and hot thresholds do not.
3. **Location vocabularies:** Position and location-state values are not
database-constrained. Unknown legacy values require truthful fallback.
4. **Rack and shelf identity/order:** Rack and shelf are labels, and racks lack
an explicit sort field. V1 cannot provide durable rack or shelf links or
operator-defined rack ordering.
## Acceptance Criteria
Mine Map v1 is accepted when all of the following are true:
- An authorized user can select a site and room and see every configured
position in stored spatial order.
- Occupied, empty, expected-but-unresolved, blocked, switch, conflict, unknown,
and unavailable states are visually and semantically distinct.
- Filtering does not silently erase physical context.
- Explicit offline, hash-unstable, spare, retired, and in-repair miners are
identifiable without inventing thresholds.
- Every telemetry value is tied to an observation time or marked unavailable.
- A user can identify whether apparent problems cluster by room, rack, or shelf
from factual tile states and measurements.
- A selected position identifies its miner or states why no miner is present.
- The inspector shows the key current identity, location, lifecycle, repair,
runtime, and telemetry details available to the user.
- The full miner detail page can be opened using the durable miner UUID.
- Closing the inspector or returning from detail restores the exact shareable
map context and practical scroll/focus context.
- Browser Back, Forward, refresh, bookmarks, and shared URLs behave
predictably.
- Permission failures expose no unauthorized counts, previews, search results,
or inspector fields.
- The page remains usable with keyboard, screen reader, browser zoom, touch,
narrow viewports, missing telemetry, partial records, and transient refresh
failures.
- The implementation performs no per-tile or per-miner request fan-out.
- The map is read-only; existing location management remains available.
Until the freshness and health-policy gaps are resolved, the map can answer
which miners are explicitly offline, hash-unstable, in repair, or associated
with location conflicts. It cannot truthfully claim that every down, stale,
hot, or degraded miner has been identified. The interface must state that
boundary rather than implying complete health classification.
## Unresolved Decisions
The following decisions remain for later map and data work:
1. Which backend-owned policy defines stale, down, hot, and degraded states,
including scope, thresholds, collector cadence, and configuration source.
2. Whether rack ordering may use a documented natural-label sort or requires an
additive persisted sort field.
3. Whether `location_positions.room_id` can be made required after legacy-data
preflight, eliminating name-only room association for production map data.
These decisions do not require changing the durable miner identity, existing
repair workflow, or v1 read-only boundary.
+407
View File
@@ -0,0 +1,407 @@
# MinerOps Navigation
## Status
This document is the authoritative baseline for the MinerOps application shell,
information architecture, navigation model, URL structure, and
context-preserving behavior. It follows the shared experience principles in
`docs/design/UX_UI_GUIDE.md`. Focused screen documents may refine individual
destinations without weakening these rules.
MinerOps uses the product identity:
**MinerOps**
**Operational Intelligence for Mining**
The current interface is rendered by the FastAPI backend and includes working
pages and legacy paths. MinerOps has not selected a final frontend framework.
Routes identified as proposed in this document are information-architecture
targets, not claims of implemented screens or commitments to a particular UI
technology.
## Navigation Goals
Navigation should help an operator move from fleet awareness to a specific
object or safe action without first knowing which backend domain owns the data.
It must:
- keep current scope and location obvious;
- expose Mine Map and Repair Center prominently;
- keep global search available on every authenticated screen;
- preserve operational context across summary, inspection, and detail views;
- adapt to permission, role, viewport, and input method;
- use durable identifiers in object routes; and
- remain shallow enough for frequent technician workflows.
Primary navigation should expose no more than two visible levels. A common
workflow should normally take no more than three meaningful interactions from
the user's current context. Permission checks, validation, and consequential
confirmations do not count as avoidable navigation depth.
## Information Architecture
The primary application areas are:
1. **Dashboard** - fleet summary, prioritized conditions, and next actions.
2. **Operations** - active operational workflows.
- Repair Center
- Scanner
3. **Mine Map** - a persistent direct entry into spatial operations. Mine Map is
conceptually part of Monitoring but must not be buried in its submenu.
4. **Monitoring** - time-based and comparative operational understanding.
- Mine Map
- Analytics
5. **Assets** - durable records and identity.
- Miners
- Components
6. **Reports** - historical and operational reporting.
7. **Administration** - users, roles, and system settings.
On desktop, the direct Mine Map entry should remain visible beside the other
primary destinations. Monitoring may also reference Mine Map to communicate its
domain relationship, but that secondary reference must not be the only route to
the map. Interfaces with limited space should prioritize Dashboard, Mine Map,
Repair Center, Miners, and Search.
## Application Shell
Every authenticated screen should use a consistent shell containing:
- persistent primary navigation appropriate to the viewport;
- one global search entry point;
- an alerts and notifications area;
- current user, role, and session controls;
- a page title and breadcrumbs when they clarify hierarchy;
- page-level actions related to the current scope;
- an optional contextual side panel; and
- a stable content region that communicates loading and failure state.
The shell should distinguish global navigation, page navigation, and object
actions. An action such as beginning a repair must not look like a destination,
and changing a filter must not look like an authoritative data mutation.
### Page Titles And Breadcrumbs
The page title names the current destination or durable object. A human-readable
Asset ID, serial, or location label may lead the title, while the durable UUID
remains available for disambiguation and links.
Breadcrumbs are useful on deep object and location pages, for example:
```text
Miners / MINER-042 / Repair 8c2... / Timeline
Mine Map / Site A / Room 3 / Rack 12
```
Breadcrumbs must not reproduce the entire primary navigation or imply a false
parent relationship. On mobile, they may collapse to a labeled back action and
current object.
### Page-Level Actions
Page-level actions belong near the title or current-object context. Show the
most likely authorized action directly and place less common actions in a
clearly labeled menu. Actions must follow the permission-aware and explicit
command rules in `docs/design/UX_UI_GUIDE.md`.
### Contextual Side Panel
A side panel supports lightweight inspection while preserving the underlying
map, table, graph, queue, filter, and selection. It may show current status,
identity, location, active repair, alert context, or a small set of safe actions.
It must not become the only way to perform deep work.
On narrow screens, a side panel becomes a full-screen sheet with an obvious
close or back control. Closing it restores the prior selection and viewport.
## Proposed Route Model
The table below defines proposed canonical UI routes. "Implemented path" refers
only to an existing server-rendered page in the current repository. An existing
API does not by itself make a proposed UI route implemented.
| Area | Proposed canonical route | Current implementation |
| --- | --- | --- |
| Dashboard | `/dashboard` | Implemented at `/dashboard` |
| Repair queue | `/operations/repairs` | Repair Center foundation implemented at `/repairs` |
| Repair detail | `/operations/repairs/{repair_id}` | Proposed; read APIs exist |
| Scanner | `/operations/scanner` | Proposed; scanner APIs and operational controls exist |
| Mine Map | `/monitoring/map` | Location Map foundation implemented at `/locations` |
| Analytics | `/monitoring/analytics` | Proposed |
| Miner list | `/assets/miners` | Proposed; inventory APIs exist |
| Miner detail | `/assets/miners/{miner_id}` | Detail foundation implemented at `/miners/{miner_id}/detail` |
| Component list | `/assets/components` | Proposed; no list UI route |
| Component detail | `/assets/components/{component_id}` | Proposed; read API exists |
| Reports | `/reports` | Proposed |
| Users | `/administration/users` | Account administration foundation implemented at `/accounts` |
| Roles | `/administration/roles` | Proposed; role APIs exist |
| Settings | `/administration/settings` | Proposed |
These canonical routes do not require immediate replacement of working paths.
When introduced, existing links may remain compatibility routes or redirect to
the canonical destination through a deliberate migration. Route aliases must
not produce two independent screen implementations.
Object routes use durable UUIDs for miners, repairs, components, locations, and
alerts. The UI displays recognizable Asset IDs, serials, names, and labels, but
must not use mutable human identifiers as authoritative route identity.
### Deep Links
Direct links should be available for:
- miner, repair, component, and location detail;
- an alert in its relevant object and time context;
- a selected site, room, rack, shelf, position, or miner on Mine Map; and
- a chart with its scope, comparison, and time range.
Stable route segments should hold object identity. Query parameters may hold
shareable view state such as filters, selected tabs, time range, comparison,
map layer, or map viewport. Sensitive or large transient state must not be
embedded in a URL.
Examples of future deep-link shapes are:
```text
/monitoring/map?room_id={room_id}&rack_id={rack_id}&miner_id={miner_id}
/monitoring/analytics?scope=room&scope_id={room_id}&range=24h
/assets/miners/{miner_id}?tab=history
/operations/repairs/{repair_id}?event_id={event_id}
```
These examples define intent, not a final query vocabulary. Each implemented
screen must document and test the parameters it accepts.
## Context-Preserving Navigation
Navigation from an operational view must preserve the user's place whenever it
is safe and practical. Returning from inspection or detail should restore:
- active filters, sorting, and saved column choices;
- selected objects and multi-selection state;
- map site, room, layers, position, pan, and zoom;
- table scroll or pagination position;
- graph scope, comparisons, and time range; and
- the originating queue or search query.
Use a side panel for quick inspection and a full detail page for deep work,
multi-step actions, extensive history, or a directly shareable destination.
Opening full detail from a map, alert, or queue should retain an explicit return
context. Browser back and forward controls must remain meaningful.
Where useful, filters and view state should be URL-backed so refresh, bookmarks,
and shared links restore the same view. Session state may supplement the URL but
must not make a copied deep link misleading.
Mine Map context deserves particular care. Selecting a miner, rack, or room,
opening operational context, inspecting an alert, or opening related analytics
should retain the current site, spatial selection, layers, pan, zoom, and time
context. A miner detail view should provide a practical return to that map state.
If navigation would discard unsaved form work, warn the user and name what will
be lost. A failed validation or permission response must preserve entered data
unless retaining it would expose sensitive information.
## Global Search Navigation
Global search is available from every authenticated screen and does not require
a category choice before entry. It searches only implemented, authorized
sources and groups results by type, such as miners, repairs, components,
locations, users, and relevant events.
Each result should support:
- keyboard focus, movement, selection, and dismissal;
- enough identity and context to distinguish similar records;
- a quick preview when lightweight inspection is useful; and
- an explicit path to full detail.
A quick preview should preserve the current page beneath a side panel or sheet.
Opening full detail should retain the search query as return context where
practical. Search cannot expose restricted objects through result text, counts,
group labels, previews, suggestions, or timing-dependent behavior.
Users do not need to know whether an IP address belongs to Inventory,
Monitoring, or Discovery before searching for it. No-results behavior must
distinguish a successful search with no authorized matches from a search
failure, without confirming that restricted records exist.
## Role-Aware Navigation
Navigation is permission-derived. Roles describe common experiences but are not
a substitute for checking current permissions.
### Administrator
Administrators receive the operational navigation plus Administration areas
permitted to them. Administrative destinations should not displace Dashboard,
Mine Map, Repair Center, or asset lookup.
### Manager
Managers typically need the broad operational, monitoring, asset, and reporting
structure with the actions their permissions allow. Administration appears only
for capabilities explicitly granted to the current user.
### Technician
Technician navigation prioritizes Mine Map, Repair Center, Miners, Scanner where
authorized, and direct lookup. It should minimize detours through reporting or
configuration areas during floor work.
### Read Only
Read Only navigation remains coherent rather than looking broken or empty. It
provides authorized Dashboard, Mine Map, repair, asset, history, and reporting
views while omitting or clearly disabling mutation commands according to
context.
The backend remains authoritative for authentication and permission
enforcement. Hiding a link or control is presentation, not security. Navigation
labels, badges, counts, previews, alerts, and search must not reveal data or
capabilities the current user cannot access.
When a permission changes during a session, refresh affected navigation and
screen capabilities. If the current destination is no longer authorized,
explain that access changed and return the user to the nearest coherent allowed
destination.
## Mobile And Tablet Navigation
On wide screens, MinerOps may use a persistent navigation rail or sidebar. On
tablets and phones, use a compact drawer, sheet, or similarly familiar pattern
that preserves content space and exposes a clear current location.
The compact shell must keep quick access to:
- global search;
- alerts and notifications;
- Mine Map;
- Repair Center;
- miner lookup; and
- current user and session controls.
Touch targets must be large enough for work around equipment. Navigation cannot
depend on hover, precision pointing, or desktop-only keyboard shortcuts. Side
panels become full-screen sheets where necessary, and closing a sheet restores
the prior context.
No critical workflow may exist only on desktop. Mobile layouts may reduce data
density or defer secondary columns, but must preserve identity, status,
freshness, consequences, validation, and required actions. Navigation and the
current screen should remain usable during delayed responses or intermittent
mine Wi-Fi without implying that an unconfirmed command succeeded.
## Operational Bookmarks And Followed Views
The following are future capabilities and are not Internal Alpha requirements:
- saved filters and table views;
- followed miners, racks, rooms, or queues;
- operational bookmarks; and
- personalized start views.
Before implementation, these capabilities need defined ownership, visibility,
permission, stale-reference, and deletion behavior. They must store durable
object identities and must not bypass current authorization when restored.
## Internal Alpha Navigation
The minimum Internal Alpha navigation target is:
- Dashboard;
- Mine Map;
- Repair Center;
- Miners;
- Global Search; and
- user and session controls.
This is a target subset, not a statement that every destination already has its
final screen or canonical route. Analytics may initially appear within
Dashboard, Mine Map, and Miner Detail before a complete standalone Analytics
area exists. Components and Administration may expose only capabilities that
are implemented and authorized; their placement must not imply generic
component management or unfinished administration features.
Scanner, Reports, standalone Analytics, broader Components navigation, and
additional Administration destinations can enter the shell as their coherent
screens become available. Adding a label before its destination works creates a
dead end and is not progressive disclosure.
## Loading And Failure Behavior
Navigation state must remain understandable while screens load or fail.
### Session Expiry
When a session expires, route the user to login rather than rendering an empty
application shell. Preserve a safe return destination when practical, but never
retain or expose sensitive form values across authentication boundaries.
### Missing Or Invalid Objects
If an object no longer exists, show a not-found state with its requested object
type and a route back to the originating collection or context. An invalid deep
link must not fall through to an unrelated screen or expose parsing details.
### Permission Changes
If access is denied or changes during a session, explain that the destination is
unavailable and provide the nearest authorized route. Do not disguise a
permission failure as an empty dataset when the distinction is safe to disclose.
### Backend Or Network Failure
If the backend is unavailable, keep the shell and last safe navigation context
where possible, mark displayed data as stale, and offer a bounded retry. Do not
show failed data as empty or current. During network interruption, avoid losing
the user's selection, filters, unsaved work, or return context.
### Partial Page Data
If only part of a page loads, keep successful sections usable and identify each
failed section without replacing the entire page with a generic error. Disable
actions that lack required authoritative context and explain why.
## Accessibility
The application shell must provide:
- keyboard-accessible primary, secondary, breadcrumb, search, panel, and user
navigation;
- visible focus in a logical order;
- semantic landmarks for navigation, search, main content, and complementary
context;
- a skip link to main content;
- screen-reader labels for icon-only controls, current location, expanded state,
alerts, and result counts;
- non-hover-only access to every destination and action; and
- responsive zoom without clipped navigation or lost controls.
Opening a drawer, search overlay, or side panel moves focus into it; closing it
returns focus to the control that opened it. Dynamic navigation updates should
be announced without repeatedly interrupting screen-reader users.
## Acceptance Criteria
A compliant MinerOps application shell:
1. keeps global search accessible from every authenticated screen;
2. makes the current destination, scope, and selected object obvious;
3. exposes Mine Map and Repair Center prominently;
4. keeps primary navigation to no more than two visible levels;
5. preserves filters, selection, spatial position, scroll, and time context when
moving between inspection and detail;
6. supports direct links based on durable object identity;
7. adapts destinations and actions to permission and screen size;
8. avoids unauthorized information leakage through navigation or search;
9. handles loading, partial failure, invalid links, permission changes, and
expired sessions explicitly;
10. provides a coherent Read Only experience;
11. supports keyboard, touch, assistive technology, and responsive zoom; and
12. does not discard unsaved work without warning.
## Final Principle
Navigation should preserve the operator's mental and operational context while
making the next authorized destination or action clear.
+174
View File
@@ -0,0 +1,174 @@
# MinerOps Repair Domain Model
## Purpose
This design guide describes repair-domain concepts, relationships, lifecycle, invariants, and operational boundaries. Implemented repository behavior and approved task specifications remain authoritative.
## Core Concepts
### Miner
A durable physical mining asset whose identity is independent of IP address, MAC address, control board, hostname, location, and repair state.
### Repair
A bounded operational episode for one miner. It has one start, zero or one completion, immutable timeline events, actors, and timestamps.
### Repair Event
An immutable fact ordered by explicit sequence. Implemented event types are
`REPAIR_STARTED`, `INITIAL_NOTE`, and `REPAIR_COMPLETED`.
### Network Identity History
`miner_network_identities` associates MAC, IP, and hostname observations with a
durable miner and identifies current versus historical rows. It is not a
separate asset identity.
### Proposed Component History
Task 004 proposes preserving control-board replacement details without
redefining the miner. No dedicated immutable component-history table currently
exists; the legacy generic maintenance-event table is not the modular repair
timeline.
### Proposed Repair Part Entry
Task 005 proposes a record of a part installed, removed, or consumed during a
repair. It is not a warehouse inventory transaction, and no `repair_parts`
table currently exists.
## Relationships
Current relationships:
```text
Miner -> many Repairs -> ordered Repair Events
Miner -> Network Identity History
```
Control-board replacement history and repair-part entries are proposed
extensions, not implemented relationships.
## Lifecycle
Current miner states relevant to repair:
- `active`
- `spare`
- `in_repair`
- `retired`
Eligible Begin Repair sources:
- active
- spare
Eligible Return to Service destinations:
- active
- spare
Terminal:
- retired
The repair schema defines these statuses:
- Active: `OPEN`, `IN_PROGRESS`, `WAITING_FOR_PARTS`, `READY_FOR_TEST`
- Terminal: `COMPLETED`, `CANCELLED`
The modular API currently creates repairs as `OPEN` and can complete any active
repair. It does not provide status-transition or cancellation routes. The
implemented modular workflow is therefore:
```text
OPEN -> COMPLETED
```
The other active values remain valid schema states for compatibility and later
explicit workflows. Cancellation or abandonment requires a separate task.
## Current Workflow
```text
Miner active/spare
-> Begin Repair
-> Miner in_repair
-> Timeline events
-> Return to Service
-> Miner active/spare
-> Repair completed
```
## Invariants
- replacing a board or MAC must not create a new miner
- asset-ID and network-identity history must be preserved
- no more than one active repair per miner is database-enforced
- modular repair actions require the miner and repair state to agree
- Return to Service prevents a completed repair from leaving the miner in `in_repair`
- multi-record transitions are atomic
- events are append-only and explicitly sequenced
- actor comes from authentication
- timestamps are server/database controlled
- new completions require completion time, actor, and eligible resulting lifecycle
- legacy unknown values remain unknown rather than fabricated
## Transaction Boundaries
### Begin Repair
Lock miner, create repair, transition miner to `in_repair`, append start event.
### Return to Service
Lock miner, lock repair, validate active state, complete repair, transition miner to active/spare, append completion event.
### Proposed Control Board Replacement
Task 004 proposes locking miner, active repair, and network-identity rows;
validating MAC ownership; updating current projections; and appending one event.
### Proposed Repair Part Recording
Task 005 proposes locking the affected repair or repair-part row, adding or
voiding the entry, and appending the corresponding event atomically.
## Lock Order
Use:
```text
Miner -> Repair -> related rows
```
This aligns workflows and reduces deadlock risk.
## Commands vs CRUD
Implemented explicit commands:
- Begin Repair
- Return to Service
Proposed explicit commands:
- Replace Control Board
- Record Repair Part
- Void Repair Part
Avoid generic repair patches for meaningful operations.
## Read Models
Implemented modular reads:
- repair detail
- ordered repair timeline
The integrated application also has legacy repair-queue, parts-summary, and
miner network-identity reads. Those routes are compatibility behavior, not the
proposed Task 004 or Task 005 read models.
Proposed focused reads include repair parts and control-board replacement
history. Read models do not weaken write invariants.
## Permissions
Implemented permissions:
- `repairs.read`
- `repairs.create`
- `repairs.complete`
Proposed permissions:
- `repairs.parts.manage`
- `miners.replace_control_board`
Task 005 reuses `repairs.read` for its proposed read route. Roles receive capabilities;
endpoints should not hard-code role names when permission checks exist.
## Legacy Data
Preserve truth, allow nullable historical fields where necessary, enforce stronger rules for new or updated rows, use PostgreSQL `NOT VALID` constraints where appropriate, and never backfill invented actors, states, or events.
## Future Extensions
Repair cancellation, diagnosis, work orders, technician assignment, burn-in testing, photos, warranty/RMA, component inventory, and donor-unit tracking should each arrive through explicit tasks.
+559
View File
@@ -0,0 +1,559 @@
# MinerOps UX/UI Guide
## Status
This document is the authoritative baseline for shared MinerOps user-experience
principles and application-wide interaction rules. Focused companion documents
will define detailed information architecture, screen behavior, and visual
system decisions.
MinerOps uses the product identity:
**MinerOps**
**Operational Intelligence for Mining**
The current interface is an early-stage, integrated application rendered by the
FastAPI backend. The repository does not contain a separate frontend project or
commit to a final frontend framework. Existing pages and controls are working
foundations, not evidence that every experience described here is implemented.
Future capabilities are identified as such throughout this guide.
## Product Experience Goal
MinerOps should help a technician or manager understand:
- what is happening;
- where it is happening;
- why it may be happening;
- what happened previously; and
- what action should happen next.
The interface should connect live observations to durable identity, physical
location, repair and component history, operational context, and safe actions.
It should never trade operational truth for visual simplicity.
## Primary Users
### Technician
Technicians need fast identification, floor-friendly navigation, repair context,
clear next actions, and minimal avoidable data entry. Their common workflows
must work on phones, tablets, and workstations.
### Manager / Site Operator
Managers and site operators need fleet state, site and room context, trends,
work queues, risk concentration, and permission-controlled operational actions.
They often move between summary and detail views.
### Read Only / Auditor
Read Only users and auditors need a coherent view of current state, immutable
history, actors, timestamps, and outcomes without controls that imply they can
change authoritative records.
### Administrator
Administrators need account, permission, configuration, deployment-health, and
audit context. Administrative capability must not obscure the operational views
they also use.
The same underlying data may be presented at different density or with different
actions according to role and permission. Presentation differences must not
change the meaning of the data.
## Core UX Principles
### Technician First
Optimize frequent floor workflows for fast recognition and safe completion.
Prefer direct lookup, durable identifiers, clear state, and focused commands over
multi-page data entry.
### Operational Awareness
Show scope, location, urgency, impact, freshness, and intentional operating state
together. Users must be able to distinguish a fault from planned curtailment,
repair work, stale data, or an unknown condition.
### Distinct Information Roles
Use each interface according to its strength:
- maps explain location and spatial concentration;
- graphs explain trends and comparisons;
- timelines explain history and causality; and
- tables explain detail and support precise review.
Do not use a visually impressive representation when another format answers the
operational question more clearly.
### Mine Map Is Primary
The Mine Map is a primary operational interface, not a decorative page. It
should connect physical layout, current state, work context, and drill-down
without forcing users to abandon spatial context.
### Search Everything
Durable identities, mutable network observations, serials, repairs, components,
locations, users, and relevant events should be discoverable through one
consistent search entry point, subject to authorization.
### Live By Default
Show the freshest trustworthy state by default. Always expose when it was
observed, its source when useful, and whether it is stale or incomplete.
### Explicit Actions
Use explicit commands for operational changes. Do not hide lifecycle, repair,
component, location, or fleet actions inside generic edits or implicit UI state.
### Immutable History Remains Visible
Historical facts, actors, timestamps, and corrections remain visible. A current
projection may summarize history but must not replace it.
### Surface Operational Context
Place relevant location, neighboring assets, current repair, component, alert,
and recent-history context near the object being inspected. Avoid making users
reconstruct one situation across unrelated pages.
### Progressive Disclosure
Lead with the current status and likely next decision. Reveal diagnostics,
history, raw values, and advanced controls as users ask for more detail.
Progressive disclosure must not conceal critical risk or uncertainty.
### Three-Click Goal
Frequent workflows should normally be reachable and actionable within three
meaningful interactions from the user's current operational context. This is a
design goal, not permission to skip confirmation or validation for consequential
actions.
### Keyboard And Touch Friendly
All core workflows must support keyboard navigation and appropriately sized
touch targets. Hover-only controls and precision pointer interactions cannot be
the sole path to an action.
### Accessible By Default
Accessibility is a design constraint from the first implementation, not a later
polish phase.
### Honest Uncertainty
Unknown, stale, partial, estimated, and unavailable data must look different
from known healthy or zero values. Do not invent certainty or interpolate across
unsupported gaps.
### No Silent Mutation
Show the action being requested, the affected object, and the resulting state.
Never alter authoritative records merely because a view opened, refreshed, or
changed selection.
### Preserve Operational History
Authoritative history is not physically deleted. Use explicit void, correction,
retire, or archive workflows. Confirmation language must name the operation and
its consequences rather than asking only “Are you sure?”
## Application Shell
The application shell should provide:
- persistent primary navigation appropriate to the viewport;
- one global search entry point;
- an alerts and notifications area;
- the current user, role, and session area;
- a clear page title and current site, location, asset, or workflow context;
- role-aware primary and secondary actions;
- an optional contextual side panel; and
- responsive behavior that preserves critical actions and state.
Desktop layouts may use a persistent navigation rail and side panel. Mobile
layouts may collapse navigation and use a full-height drawer or sheet, but the
current object, alerts, and primary action must remain easy to recover.
This guide does not choose final navigation labels or hierarchy. Those decisions
belong in the future `docs/design/NAVIGATION.md`.
## Global Search
Global search should use one box without requiring a category selection first.
Search targets include, where implemented and authorized:
- miners and Asset IDs;
- miner and component serial numbers;
- MAC and IP addresses;
- repairs;
- tracked components;
- sites, rooms, racks, shelves, and operational locations;
- technicians and users; and
- events when event search provides operational value.
Results should be grouped by domain, show enough context to disambiguate similar
identifiers, and support keyboard navigation and selection. A result preview
must not reveal restricted data.
No-results states should repeat the interpreted query, distinguish no match from
search failure, and suggest a narrower or alternate identifier without implying
the record does not exist outside the user's permissions.
Recent and saved searches are possible future capabilities. They require clear
retention, privacy, and permission behavior before implementation.
## Mine Map Principles
The Mine Map should show current spatial state and support drill-down through:
```text
Site -> Room -> Rack -> Shelf or Position -> Miner
```
It should represent both production and operational spaces, including:
- receiving;
- diagnostics;
- repair bench;
- burn-in;
- ready to install;
- donor storage;
- component storage;
- quarantine; and
- recycling or retired storage.
Map interactions should:
- expose status and future telemetry overlays;
- reveal hotspots and concentrations rather than merely place objects;
- support filters, single and multi-selection, and clear selection state;
- open details in a side panel where space permits;
- preserve pan, zoom, filters, and selection while inspecting details;
- support site, room, rack, shelf, and miner drill-down; and
- distinguish offline, in repair, intentionally curtailed, stale, degraded,
unknown, and healthy states.
Current location foundations do not imply that this full Mine Map experience is
complete. Historical map playback is future direction and is not required for
an initial Mine Map implementation.
## Graph And Analytics Principles
Graphs are first-class operational interfaces, not dashboard decoration. They
should support site, room, rack, selected group, and individual-miner scope as
data becomes available.
Relevant measures include:
- hashrate;
- power usage;
- efficiency;
- temperature;
- availability; and
- expected versus actual performance.
Graph interactions should provide:
- explicit time-range selection and timezone context;
- comparison of compatible scopes and units;
- synchronized operational-event annotations;
- consistent unit formatting and axis semantics;
- visible gaps, stale intervals, and missing sources;
- aggregation and downsampling disclosure; and
- detail values accessible without relying only on pointer hover.
Never draw misleading interpolation through unknown periods. Clearly label
estimated, aggregated, or incomplete values. Graphs explain trends, maps explain
location, timelines explain history, and tables explain detail.
Comparative fleet analytics and synchronized annotations are future capabilities
unless a focused implementation task establishes them.
## Operational Context
Context panels or inline context may surface:
- neighboring miners with related faults;
- shared PDU or network issues;
- nearby temperature anomalies;
- similar recent repairs;
- component genealogy;
- rack-level repair density; and
- recent correlated outages.
Correlations must identify their scope, time window, evidence, and freshness.
They may be rule-based and do not require AI. A correlation is supporting
context, not proof of cause; the interface must not present it as certainty.
Most correlation experiences remain future direction.
## Tables And Queues
Operational tables should be dense enough for repeated work while remaining
readable. They should provide:
- a deliberate column hierarchy with identity and status first;
- explicit sorting and allowlisted filtering;
- discoverable row actions;
- predictable single and multi-selection behavior;
- preserved filters, sort, selection, and scroll when users inspect and return;
- clear empty and no-results states; and
- pagination or virtual scrolling for large fleets.
Keep bulk actions limited to operations with clear scope, authorization,
validation, progress, and failure reporting. Selection must never imply that a
bulk action is safe.
Saved views are a future capability. Their later design must define ownership,
sharing, permission changes, and behavior when fields become unavailable.
## Forms And Explicit Commands
Forms and command dialogs should:
- use strong persistent labels rather than placeholder-only identification;
- group fields by the operator's decision, not database layout;
- validate structural input inline and preserve entered data after failure;
- explain domain conflicts without exposing database details;
- reveal every operational side effect before submission;
- derive authoritative actor and timestamps from the session and server;
- show only permission-appropriate actions;
- explain why a visible control is disabled;
- use confirmation only for consequential operations; and
- return focus and context predictably after completion.
Optimistic UI is appropriate only when rollback is safe, obvious, and truthful.
Do not optimistically show completion for miner control, repair transitions,
component changes, or other multi-record operational workflows before the server
confirms success.
## Timelines And History
Timelines show immutable chronological history. Each meaningful event should
make the following visible when known:
- actor;
- authoritative timestamp;
- action;
- affected object;
- result; and
- structured context needed to understand the change.
Use explicit database sequence when the domain defines one. Events may be grouped
for scanning, but grouping must not reorder, merge, or erase facts. Historical
events are not edited or deleted. Corrections appear as later correction or void
events linked to the original.
Link directly to related miners, components, repairs, and locations when those
records are authorized and available. Timeline filters may reduce the visible
set, but the interface must indicate that filtered history exists.
## Status And Severity Language
Use stable semantic meanings across screens:
| Meaning | Use |
| --- | --- |
| Healthy / active | Operating within expected bounds or available for normal work |
| Informational | Context that does not currently require intervention |
| Waiting | Work is intentionally blocked on a dependency or next step |
| Degraded | Operating outside expected bounds but not at immediate critical severity |
| Critical | Immediate attention is required to avoid or address significant impact |
| In repair | Removed from normal service under an active repair workflow |
| Intentionally offline / curtailed | Not producing by deliberate operational decision |
| Stale / unknown | Current truth cannot be established from sufficiently fresh data |
| Retired / archived | No longer part of normal active operations but retained historically |
Domain states and alert severity are separate concepts. For example, `in repair`
is not automatically critical, and stale data is not healthy merely because no
fault is observed.
Final colors, typography, icons, patterns, and tokens belong in the future
`docs/design/DESIGN_SYSTEM.md`. Every status must use text, icons, or patterns in
addition to color.
## Notifications And Feedback
Feedback should distinguish:
- success confirmation;
- validation error;
- domain or concurrency conflict;
- permission denial;
- stale-data warning;
- backend unavailable state;
- background refresh failure;
- long-running action progress; and
- retryable versus non-retryable failure.
Success feedback should name the completed action and affected object. Validation
feedback stays near the field and is summarized when necessary. Domain conflicts
should explain what changed and what the user can do next.
Critical errors must not disappear on a timer. Long-running operations need
progress, cancellation only when supported safely, and a durable final result.
Retries must not duplicate non-idempotent commands.
## Loading, Empty, Error, And Offline States
Every data surface must distinguish:
- first load;
- background refresh;
- partial data;
- stale cached data;
- no matching results;
- an empty new installation;
- a permission-restricted section;
- API failure;
- database unavailable; and
- network interruption.
First load should establish the shell and page context before progressively
loading independent data. Background refresh should preserve usable content and
show a restrained freshness indicator instead of replacing the page with a
spinner.
Partial or cached data must identify what is missing and when the visible data
was last known. “No data” must never be used for a failed request. Offline or
interrupted workflows should preserve unsent user input and avoid claiming that
an operational command succeeded.
## Responsive And Mobile UX
Technicians may use phones or tablets around the mine. Core workflows should
provide:
- large, stable touch targets;
- responsive drawers and side panels;
- stacked layouts that preserve identity and status;
- compact tables that prioritize essential columns and allow deliberate detail
expansion;
- mobile-first miner lookup and repair actions;
- no desktop-only critical workflow; and
- graceful recovery on unreliable Wi-Fi.
Do not shrink a desktop table until it becomes unreadable. Use prioritized
columns, row detail, or a task-specific list at narrow widths.
Barcode and QR scanning are future capabilities. They should accelerate durable
identity lookup without creating a competing identity system.
## Accessibility
All MinerOps interfaces must provide:
- complete keyboard navigation;
- visible focus indicators;
- semantic buttons, links, headings, dialogs, and form controls;
- accessible names and screen-reader labels;
- sufficient text and non-text contrast;
- text, icons, or patterns in addition to color;
- reduced-motion support;
- accessible table headers, captions, and sort state;
- chart summaries and keyboard-accessible data where charts carry meaning;
- programmatic association between errors and fields; and
- touch targets sized for reliable use in operational environments.
Focus must move intentionally when dialogs open, close, or report errors. Dynamic
updates should announce important changes without flooding assistive technology.
## Performance And Live Updates
The expected experience includes:
- a fast initial shell;
- progressive loading of independent data;
- near-real-time updates where operational value justifies them;
- no avoidable full-page refreshes;
- preserved selection, filters, scroll position, and map context;
- throttling or aggregation of high-frequency telemetry;
- visible last-updated and freshness indicators; and
- no layout or notification churn from insignificant changes.
Prioritize identity, status, freshness, and primary actions before secondary
analytics. Live updates must merge safely with user interaction: they cannot
clear input, move focus, close a dialog, reorder a selected row unexpectedly, or
hide an error.
Near-real-time delivery mechanisms are an implementation decision. This guide
does not require a particular client framework, polling strategy, WebSocket
architecture, or state library.
## Permission-Aware UX
The UI should hide actions that are irrelevant and unauthorized, or disable them
when seeing the action and its denial reason helps the user understand the
workflow. A disabled control must explain what permission or state is missing.
The backend remains the authority for authentication and authorization. UI
visibility is not a security boundary.
Read Only users must have a coherent inspection experience, including search,
current state, freshness, and authorized history. Restricted records must not
leak through search results, counts, previews, links, notifications, map
aggregates, or error wording.
## Destructive And Irreversible Actions
Do not physically delete authoritative operational history. Use explicit:
- retire;
- void;
- archive; or
- correction workflows.
Before an irreversible or high-impact action, show the affected objects, scope,
operational consequences, and whether recovery exists. Use typed confirmation
only for genuinely high-risk destructive actions where the additional friction
meaningfully prevents mistakes.
Confirmation labels must name the command, such as “Void repair-part entry” or
“Retire miner,” rather than “Confirm” or “Yes.” Never use an ambiguous dialog to
mask an action's side effects.
## Design Documentation Structure
This guide governs shared experience principles. Future focused documents may
define detailed behavior:
- `docs/design/DESIGN_SYSTEM.md`;
- `docs/design/NAVIGATION.md`;
- `docs/design/DASHBOARD.md`;
- `docs/design/MINE_MAP.md`;
- `docs/design/ANALYTICS.md`;
- `docs/design/REPAIR_CENTER.md`;
- `docs/design/MINER_DETAIL.md`;
- `docs/design/COMPONENT_DETAIL.md`;
- `docs/design/SEARCH.md`;
- `docs/design/USER_WORKFLOWS.md`; and
- `docs/design/OPERATIONAL_CONTEXT.md`.
These paths are planned documentation, not claims that the files or corresponding
screens exist. Focused documents may add screen-specific requirements but must
not weaken this baseline without a deliberate documented decision.
## Acceptance Criteria
A compliant MinerOps screen:
1. makes current status and scope clear;
2. exposes data freshness and uncertainty;
3. presents role- and permission-aware actions;
4. preserves context during navigation and inspection;
5. uses explicit commands for meaningful operational changes;
6. keeps authoritative history and later corrections visible;
7. handles loading, empty, partial, error, offline, and permission states;
8. is keyboard accessible and uses semantic controls;
9. remains usable at mobile widths and with touch input;
10. does not imply certainty when data is stale, missing, or incomplete; and
11. uses maps, graphs, timelines, and tables according to their proper roles.
## Final Principle
The interface should help users move from observation to understanding to action with the least possible friction, while preserving operational truth.
+47
View File
@@ -0,0 +1,47 @@
# MinerOps v0.2: Repair Workflow
## Status
Planning document. The version label and remaining scope are provisional and do
not override the current version summary in `README.md` or phase ordering in
`docs/ROADMAP.md`.
## Goal
Deliver a complete, auditable repair lifecycle based on durable miner identity
and immutable history.
## Completed
- [x] Begin Repair (`POST /api/repairs`)
- [x] Repair detail and timeline read APIs
- [x] Return to Service (`POST /api/repairs/{repair_id}/complete`)
- [x] Control Board Replacement
## Planned
- [ ] Repair Parts Tracking
These planned items refer to explicit workflows in the modular Repairs domain.
Existing legacy repair, maintenance, location, and parts-summary behavior does
not make these workflows complete.
## Definition of Done
- all planned tasks are implemented
- repair actions are atomic
- history is immutable
- actor and timestamps are authoritative
- critical invariants are enforced in PostgreSQL
- migration lifecycle tests pass
- repair and inventory regressions pass
- no generic repair CRUD is introduced
- operator behavior is documented
- the release candidate is built from a clean, reproducible repository state
## Remaining Task
1. `005-repair-parts-tracking.md`
This task is specified under `docs/tasks/`, but remains a planned workflow and
must not be treated as implemented.
+38
View File
@@ -0,0 +1,38 @@
# MinerOps v0.3: Operations
## Status
Planning document. The version label and candidate scope are provisional and do
not override the current version summary in `README.md` or phase ordering in
`docs/ROADMAP.md`.
## Goal
Add safe operational control for individual miners and groups while preserving
guardrails and auditability.
## Candidate Scope
- [ ] Miner move workflow
- [ ] Maintenance mode
- [ ] Bulk tagging and location updates
- [ ] Safe reboot and shutdown
- [ ] Scheduled actions
- [ ] Staggering and cooldown controls
- [ ] Action history
- [ ] Partial-failure reporting
- [ ] Role-based action permissions
This scope spans the roadmap's Operations and Fleet Control phases. Inventory,
identity, maintenance context, and monitoring dependencies remain governed by
`docs/ROADMAP.md`.
The repository already contains early-stage legacy movement, command,
scheduling, and action-status behavior. Unchecked items mean the modular,
permissioned, fully validated product workflow remains planned; they do not
claim that no related code exists.
## Definition of Done
Actions are explicit, permissioned, audited, safety-limited, recoverable, and
tested for authorization, concurrency, retries, and partial failure.
+38
View File
@@ -0,0 +1,38 @@
# MinerOps v0.4: Monitoring
## Status
Planning document. The version label and candidate scope are provisional and do
not override the current version summary in `README.md` or phase ordering in
`docs/ROADMAP.md`.
## Goal
Provide reliable current-state and historical visibility across the fleet.
## Candidate Scope
- [ ] Configurable discovery
- [ ] MAC-based asset reconciliation
- [ ] Miner status and telemetry
- [ ] Historical retention
- [ ] Fleet dashboard
- [ ] Room, rack, model, and electrical summaries
- [ ] Alert generation and lifecycle
- [ ] Notification integrations
Discovery and network reconciliation depend on the roadmap's Inventory &
Identity phase, while electrical summaries depend on Infrastructure. Their
presence here does not change those dependencies.
The repository already contains early-stage discovery, telemetry, retention,
dashboard, and infrastructure-summary foundations. Unchecked items describe
work needed for a coherent, supported monitoring product rather than an absence
of related implementation.
## Definition of Done
Observations remain distinct from authoritative inventory, stale data is
explicit, discovery does not blindly create assets, retention is documented,
alerts are auditable, and monitoring failures cannot corrupt inventory or
repair history.
+34
View File
@@ -0,0 +1,34 @@
# MinerOps v1.0
## Status
Planning document. The version label is provisional. This file describes release criteria, not current
capabilities or a delivery commitment. It does not override the current version
summary in `README.md` or phase ordering and completion status in
`docs/ROADMAP.md`.
## Goal
Deliver a production-ready, self-hosted operating platform for cryptocurrency
mining operations.
## Release Themes
- durable inventory and identity
- complete repair workflow
- safe operational controls
- monitoring and alerting
- work orders and maintenance
- spare parts and component history
- electrical mapping
- backup, restore, and migration
- security, roles, and auditability
- documented deployment and upgrades
## Production Readiness
v1.0 requires tested backup and restore, safe migrations, health checks,
reproducible deployment, encrypted credential handling, HTTPS guidance,
database-backed integrity, documented regression coverage for critical
workflows, performance testing at representative fleet size, release notes, and
documented limitations.
+544
View File
@@ -0,0 +1,544 @@
# Task 001: Begin Repair
## Status
Ready for implementation
## Phase
Phase 3: Operations
## Goal
Implement the first explicit operational workflow in MinerOps: beginning a repair for a miner.
This workflow creates permanent repair history. It is not generic CRUD and must not be implemented as a generic miner update.
## Endpoint
```http
POST /api/repairs
```
## Permission
Introduce and require:
```text
repairs.create
```
Do not reuse `miners.update`.
Register the permission using the repository's existing permission and role-seeding conventions.
At minimum, administrators must retain access. Do not grant this permission to viewer or auditor roles.
## Request
Use a typed request model equivalent to:
```json
{
"miner_id": "UUID",
"reason": "CONTROL_BOARD",
"notes": "Miner not booting after power outage."
}
```
### Fields
#### `miner_id`
- Required
- Durable internal miner UUID
- Must reference an existing miner
#### `reason`
- Required
- Must use the constrained repair-reason values below
#### `notes`
- Optional
- Trim surrounding whitespace using existing normalization conventions
- Treat whitespace-only notes as absent unless established project validation conventions require rejection
- Store the initial note as a repair timeline event, not as a mutable column on the repair record
Do not accept client-provided values for:
- repair ID
- repair status
- opened or started timestamp
- opened or started actor
- miner lifecycle
- event IDs
- event timestamps
- event actors
## Repair Reasons
Support exactly these initial values:
- `HARDWARE_FAILURE`
- `LOW_HASHRATE`
- `CONTROL_BOARD`
- `HASHBOARD`
- `PSU`
- `FAN`
- `OVERHEATING`
- `NETWORKING`
- `PREVENTIVE_MAINTENANCE`
- `INSPECTION`
- `OTHER`
Use the project's existing enum or check-constraint conventions. Do not substitute a free-form reason.
## Repair Statuses
The schema must anticipate:
- `OPEN`
- `IN_PROGRESS`
- `WAITING_FOR_PARTS`
- `READY_FOR_TEST`
- `COMPLETED`
- `CANCELLED`
This endpoint creates repairs only with status:
```text
OPEN
```
Active repair statuses are:
- `OPEN`
- `IN_PROGRESS`
- `WAITING_FOR_PARTS`
- `READY_FOR_TEST`
## Workflow
Beginning a repair must atomically:
1. Confirm the miner exists.
2. Lock the miner row.
3. Confirm the miner is eligible to enter repair.
4. Confirm the miner does not already have an active repair.
5. Create the repair record.
6. Create a `REPAIR_STARTED` timeline event.
7. Create an `INITIAL_NOTE` timeline event when a non-empty note was supplied.
8. Change the miner lifecycle to `IN_REPAIR`.
9. Commit all changes together.
If any step fails, none of the changes may persist.
## Business Rules
### One active repair
A miner may have no more than one active repair.
This rule must be protected by the database against concurrent requests. Do not rely only on a service-level pre-check.
A likely implementation is a PostgreSQL partial unique index on `miner_id` for active statuses, but inspect existing migration and database conventions before choosing the mechanism.
Duplicate active-repair attempts return:
```text
409 Conflict
```
### Miner lifecycle
Beginning a repair moves the miner lifecycle to:
```text
IN_REPAIR
```
Inspect the current lifecycle values and transition conventions before implementing.
Reject incompatible or terminal lifecycle states with a domain-specific conflict mapped to:
```text
409 Conflict
```
If a miner is already `IN_REPAIR` but has no active repair, treat that as inconsistent state. Reject it rather than silently creating ambiguous history.
Do not add another repair-status field to the miner.
### Actor attribution
The authenticated user's UUID is mandatory.
Pass the actor UUID explicitly from the router to the service.
Do not:
- derive the actor from request data
- permit actorless repair creation
- allow the client to override the actor
### Timestamps
All timestamps must be generated by the server or database.
Do not accept client timestamps.
Initial events must have deterministic ordering. If timestamps can match, use a stable secondary order consistent with repository conventions.
## Database
Create one Alembic migration after the current head.
Use Alembic only. Do not use runtime table creation or ORM auto-create behaviour.
### `repairs`
Required logical fields:
- `id`: UUID primary key
- `miner_id`: foreign key to miners
- `status`
- `reason`
- `opened_at` or `started_at`
- `opened_by` or `started_by`
- `completed_at`: nullable
- `completed_by`: nullable
Inspect existing naming conventions and choose either `opened_*` or `started_*`. Use the chosen terminology consistently across schema, models, service, API, and tests.
Add appropriate:
- foreign keys
- actor references
- indexes
- status constraints
- reason constraints
- server-generated timestamps
- database-backed active-repair uniqueness
### `repair_events`
Required logical fields:
- `id`: UUID primary key
- `repair_id`: foreign key to repairs
- `event_type`
- `created_at`
- `created_by`
- `payload`
Initial supported event types:
- `REPAIR_STARTED`
- `INITIAL_NOTE`
#### `REPAIR_STARTED` payload
Preserve relevant starting context, including at least:
- repair reason
- miner lifecycle transition where practical
#### `INITIAL_NOTE` payload
Preserve the normalized submitted note.
Use JSON only if it fits existing repository conventions or is clearly the best design. Do not store unvalidated, inconsistent payloads merely for convenience.
## Architecture
Preserve:
```text
Router
↓
RepairService
↓
RepairRepository
↓
PostgreSQL
```
The service may coordinate inventory persistence through an existing repository abstraction or a clean shared transaction boundary.
Do not bypass repository boundaries merely to simplify the transaction.
### Router responsibilities
- authentication
- authorization
- request validation
- HTTP status mapping
- response envelope
The router must contain:
- no SQL
- no database-driver imports
- no transaction management
- no business rules
### Service responsibilities
- workflow orchestration
- business rules
- transaction coordination
- domain exception propagation or translation
- no FastAPI imports
### Repository responsibilities
- persistence
- row locking
- parameterized SQL
- no FastAPI imports
Individual repository operations must not commit in a way that breaks atomic workflow orchestration.
## Response
Return:
```text
201 Created
```
Reuse the existing typed response envelope.
The response must contain at least:
- `repair_id`
- `miner_id`
- `status`
- `reason`
- `opened_at` or `started_at`
- `opened_by` or `started_by`
Use terminology consistent with the schema.
Do not include the full repair timeline in this response.
## Error Behaviour
Use existing response-envelope and error conventions.
### `401 Unauthorized`
- user is not authenticated
### `403 Forbidden`
- user lacks `repairs.create`
### `404 Not Found`
- miner does not exist
### `409 Conflict`
- miner already has an active repair
- miner lifecycle is incompatible with beginning repair
- miner is in an inconsistent repair state
### `422 Unprocessable Entity`
- malformed UUID
- invalid reason
- invalid request body
Do not expose raw database errors.
## Out of Scope
Do not implement:
- repair retrieval
- repair listing
- repair editing
- repair deletion
- repair cancellation
- repair completion
- return to service
- adding later notes
- diagnostics
- parts
- photos
- attachments
- uploads
- testing workflows
- control-board replacement
- notifications
- generic repair PATCH endpoints
- alert pausing unless an existing clean abstraction makes it unavoidable
Exactly one new API route is permitted in this task.
## Tests
Follow existing test conventions.
### Migration tests
Cover:
- upgrade from previous head
- downgrade
- re-upgrade
- migration at head
- empty-database migration
- table constraints
- foreign keys
- indexes
- active-repair uniqueness
- supported status values
- supported reason values
- supported event types
- nullable completion fields
- actor requirements
### Repository tests
Cover:
- repair creation
- `REPAIR_STARTED` event creation
- optional `INITIAL_NOTE` event creation
- no note event for absent or normalized-empty notes
- miner lifecycle transition
- actor persistence
- reason persistence
- timestamp persistence
- active-repair detection
- database uniqueness under duplicate active-repair attempts
- miner isolation
- rollback when event insertion fails
- rollback when lifecycle update fails
- incompatible lifecycle rejection
- concurrency or row-lock protection where practical
### Service tests
Cover:
- successful Begin Repair
- successful Begin Repair with an initial note
- unknown miner
- active-repair conflict
- incompatible lifecycle conflict
- actor required
- orchestration behaviour
- domain exception propagation
- no partial persistence when orchestration fails
### Router tests
Cover:
- authentication required
- `repairs.create` required
- malformed miner UUID
- invalid reason
- unknown miner
- active-repair conflict
- lifecycle conflict
- successful creation returns `201`
- response envelope
- typed response fields
- actor comes from authenticated principal
- client cannot override actor
- client cannot override status or timestamps
- router contains no SQL
- router contains no database-driver imports
### Permission tests
Cover:
- `repairs.create` registration
- administrator access
- unauthorized roles are not granted access accidentally
- idempotent permission seeding
## Documentation
After implementation and validation pass:
- Update `docs/ROADMAP.md` to mark Begin Repair complete.
- Update architecture or decision documentation only when the implementation introduces a genuinely new architectural decision.
If a new ADR is warranted, explain concisely:
- why repair is an operational entity
- why active-repair uniqueness is database-enforced
- why lifecycle transition and timeline creation are atomic
Avoid documentation churn for principles already documented.
## Validation
Run:
- Python compilation
- shell syntax validation
- migration upgrade from previous head
- migration downgrade
- migration re-upgrade
- migration head verification
- empty-database migration
- focused repair suite
- focused inventory regression suite
- permission and role tests
- duplicate active-repair or concurrency tests
- full disposable development baseline
- router SQL and database-driver audit
- service and repository FastAPI import audit
- route-count audit
- `git diff --check`
- `git status --short`
## Final Review Checklist
Confirm:
- exactly one endpoint was added
- no generic repair CRUD was added
- active-repair uniqueness is enforced by the database
- transaction boundaries prevent partial persistence
- authenticated actor UUID is explicit and mandatory
- timestamps are server-controlled
- initial notes are timeline events
- no unrelated changes are present
## Implementation Report
Report:
- implementation summary
- files created
- files modified
- migration revision and parent revision
- tables and constraints
- endpoint
- permission
- request model
- response model
- repair reasons
- repair statuses
- event types
- lifecycle behaviour
- actor attribution
- transaction design
- concurrency protection
- tests added
- validation results
- git diff summary
- `git status --short`
Do not commit.
Do not push.
+183
View File
@@ -0,0 +1,183 @@
# Task 002: Repair Timeline
## Objective
Add read-only APIs for viewing one repair and its immutable timeline.
## Scope
Add exactly these two endpoints:
```http
GET /api/repairs/{repair_id}
GET /api/repairs/{repair_id}/events
```
Do not add any other repair endpoints.
## Permissions
Add:
```text
repairs.read
```
Grant it to Admin, Manager, Technician, and Read Only. Existing `repairs.create` access remains unchanged.
## Architecture
Preserve:
```text
Router -> Service -> Repository -> PostgreSQL
```
Routers contain no SQL or transaction logic. Services contain no FastAPI dependencies. Repositories contain no FastAPI imports. Reads must not modify data.
## GET /api/repairs/{repair_id}
Return:
```json
{
"repair_id": "uuid",
"miner_id": "uuid",
"miner_asset_id": "string",
"status": "OPEN",
"reason": "INSPECTION",
"opened_at": "timestamp",
"opened_by": {
"user_id": "uuid",
"display_name": "string"
},
"closed_at": null,
"closed_by": null,
"miner_lifecycle": "in_repair",
"latest_event_sequence": 2
}
```
Requirements:
- Return 404 when the repair does not exist.
- Return the current miner lifecycle.
- Resolve actor identity using existing user conventions.
- Obtain `latest_event_sequence` from authoritative event data.
- Do not infer missing history or expose internal-only fields.
## GET /api/repairs/{repair_id}/events
Return:
```json
{
"repair_id": "uuid",
"events": [
{
"event_id": "uuid",
"sequence": 1,
"event_type": "REPAIR_STARTED",
"actor": {
"user_id": "uuid",
"display_name": "string"
},
"occurred_at": "timestamp",
"payload": {
"reason": "INSPECTION",
"from_lifecycle": "active",
"to_lifecycle": "in_repair"
}
}
]
}
```
Requirements:
- Return 404 when the repair does not exist.
- Return all events ordered strictly by database sequence ascending.
- Do not order only by timestamp.
- Preserve payload as structured JSON.
- Avoid N+1 actor lookups.
## Timeline Invariants
Events are immutable. This task must not add support for editing, deleting, replacing, or reordering timeline events. Corrections must be represented by new events.
## Models and Validation
- Use UUID types for identifiers.
- Use timezone-aware datetime types.
- Reuse existing constrained status, reason, lifecycle, and event types where practical.
- Represent payloads as structured JSON, not serialized strings.
- Avoid exposing unexpected internal fields.
## Repository Requirements
Add focused read methods for repair detail and ordered repair events. Queries should include miner asset ID, current lifecycle, actor display data, and latest event sequence. Do not add broad list or search methods.
## Service Requirements
The service must validate existence, coordinate repository reads, map missing repairs to the domain-standard not-found exception, and contain no SQL or FastAPI dependencies.
## Router Requirements
Expose exactly the two routes in this specification, require `repairs.read`, follow existing exception patterns, and contain no SQL, commits, or transaction management.
## Migration
Create the next Alembic revision only if required to add `repairs.read` and role assignments. Preserve existing permissions and provide a complete downgrade. Do not alter repair tables unless a material incompatibility is found; report that conflict before deviating.
## Tests
Cover at least:
- 401 unauthenticated
- 403 unauthorized
- Read Only can read detail and events
- Admin, Manager, and Technician can read
- `repairs.create` behavior remains unchanged
- existing repair detail
- missing repair 404
- asset ID, lifecycle, actor, nullable closed fields, and latest sequence
- sequence ordering even when timestamps are equal or non-monotonic
- structured JSON payload
- actor data
- explicit empty-timeline behavior
- Begin Repair regression
- Inventory regression
- migration upgrade, downgrade, re-upgrade, and empty database through head
- exactly two new routes
- architecture audits
## Validation
Run:
- Python compilation
- shell syntax validation
- focused timeline tests
- existing Begin Repair tests
- inventory regression tests
- migration validation
- disposable development baseline
- router SQL/driver/transaction audit
- service/repository FastAPI audit
- exact route-count audit
- `git diff --check`
## Documentation
Update `docs/ROADMAP.md` only after implementation and validation pass. Do not add an ADR unless a genuinely new architectural decision is required.
## Explicitly Out of Scope
Do not implement repair listing, search, pagination, adding or editing notes, deleting events, completion, return to service, cancellation, assignment, diagnostics, parts, photos, control-board replacement, generic CRUD, or UI changes.
## Completion Report
Report files created and modified, migration revision if any, routes, permission changes, tests and validation, and final `git status --short`.
Do not commit.
Do not push.
+814
View File
@@ -0,0 +1,814 @@
# Task 003: Return to Service
## Status
Ready for implementation.
## Objective
Complete the MinerOps repair lifecycle by adding an explicit **Return to Service** operation that closes an active repair, records an immutable completion event, and transitions the miner from `in_repair` to an eligible post-repair lifecycle state.
The operation must be atomic, permission-controlled, auditable, safe under concurrent requests, and compatible with the existing Begin Repair and Repair Timeline implementations.
## Product Intent
A repair should not remain open indefinitely after work is complete.
An authorized operator must be able to:
1. identify the active repair,
2. confirm the miner is ready to leave repair,
3. select or confirm the resulting lifecycle state,
4. optionally record a concise completion summary,
5. close the repair,
6. transition the miner out of `in_repair`,
7. preserve immutable operational history.
This is an operational command, not generic repair editing.
## Background
Task 001 introduced the Begin Repair workflow.
Task 002 introduced repair detail and immutable repair timeline read APIs.
The current repair model supports:
- creating an active repair,
- moving a miner into `in_repair`,
- preserving the miner's pre-repair lifecycle in the repair-start event payload,
- enforcing one active repair per miner,
- reading repair details,
- reading ordered repair events.
Task 003 completes that lifecycle.
The implementation must inspect the repository and reuse current names, models, exception conventions, migration patterns, event sequencing, and transaction infrastructure rather than inventing parallel abstractions.
## Scope
This task includes:
- a new `repairs.complete` permission,
- a new explicit Return to Service API,
- request and response models,
- service-layer completion workflow,
- focused repository methods,
- repair completion persistence,
- miner lifecycle transition,
- immutable completion event,
- concurrency protection,
- migration and permission assignment,
- full authorization, domain, migration, routing, and regression tests,
- release-document update after implementation passes.
## Explicitly Out of Scope
Do not implement:
- generic repair update or patch endpoints,
- reopening a completed repair,
- cancelling a repair,
- abandoning a repair,
- repair diagnosis fields,
- repair photos,
- repair parts consumption,
- technician assignment,
- work orders,
- control board replacement,
- MAC transfer,
- automatic monitoring confirmation,
- automated burn-in or test workflows,
- bulk Return to Service,
- notifications,
- UI changes unless the repository already requires generated API client updates,
- changes to unrelated legacy repair routes.
Those belong in later tasks.
## User and Operator Workflow
A typical flow is:
1. A technician opens the active repair.
2. The technician completes physical work and any required validation.
3. The technician invokes Return to Service.
4. The technician chooses the miner's destination lifecycle:
- the original pre-repair lifecycle, when eligible,
- or another explicitly allowed lifecycle.
5. The technician may enter a completion summary.
6. MinerOps atomically:
- verifies the repair is active,
- verifies the miner is still in repair,
- validates the destination lifecycle,
- closes the repair,
- transitions the miner,
- appends a completion event.
7. The repair becomes read-only historical data.
## API
Add one explicit action route.
```http
POST /api/repairs/{repair_id}/complete
```
Use `complete` unless existing route naming conventions make `return-to-service` materially more consistent. Do not add both aliases.
### Authentication
Required.
### Permission
```text
repairs.complete
```
### Path Parameter
- `repair_id`: UUID
### Request Body
The request should contain:
```json
{
"target_lifecycle": "active",
"completion_summary": "Replaced failed fan and completed load test."
}
```
Use repository-established lifecycle values and naming.
#### `target_lifecycle`
Required unless the service can unambiguously restore the original lifecycle under existing product rules.
Preferred design: require an explicit target lifecycle in the request so the operator confirms the resulting operational state.
Allowed target values must be restricted to valid non-repair lifecycle states.
At minimum:
- `active`
- `inactive`
Also allow other existing non-terminal lifecycle states only if they are already legitimate post-repair states in the current domain model.
Never allow:
- `in_repair`
- unknown values
- deprecated values
- terminal or destructive states that are not valid Return to Service destinations
Do not invent new lifecycle states.
#### `completion_summary`
Optional.
Requirements:
- trim leading and trailing whitespace,
- empty after trimming becomes `null`,
- enforce a sensible maximum length using current project conventions,
- preserve text exactly after normalization,
- do not interpret it as structured diagnosis or parts data.
### Successful Response
Return the completed repair using the existing repair-detail response model where practical.
The response must allow the client to determine:
- repair UUID,
- miner UUID,
- completed status,
- completion timestamp,
- completing actor where exposed by current conventions,
- resulting miner lifecycle,
- timeline or event count only if already part of the established response.
Do not expand the response with unrelated miner details.
### Status Codes
#### `200 OK`
Preferred when returning the completed repair representation.
Use `201` only if existing operational-command conventions clearly require it. Do not use `204` if the existing repair response can be returned cleanly.
#### `401 Unauthorized`
Unauthenticated request.
#### `403 Forbidden`
Authenticated actor lacks `repairs.complete`.
#### `404 Not Found`
The repair UUID does not exist.
Do not reveal authorization-sensitive information beyond existing project behavior.
#### `409 Conflict`
Use for valid requests that conflict with current domain state, including:
- repair is already completed,
- repair is otherwise not active,
- miner is no longer in `in_repair`,
- repair does not match the miner's active repair,
- target lifecycle is known but not eligible for Return to Service,
- a concurrent request completed the repair first.
#### `422 Unprocessable Entity`
Use for malformed UUIDs, invalid JSON, missing required fields, unknown lifecycle values, excessive summary length, or other request-validation errors.
## Permissions
Add:
```text
repairs.complete
```
### Intended Role Assignment
Follow current role conventions and migrations.
Expected behavior:
- Administrator: allowed
- Technician: allowed
- Read Only / Viewer: denied
- Auditor: denied unless current policy explicitly grants operational actions
- Unauthenticated: denied
Do not grant the permission to every role by default.
Do not overload `repairs.create`.
## Domain Rules
### Active Repair Requirement
Only an active repair can be completed.
A completed repair cannot be completed again.
### Miner Lifecycle Requirement
The associated miner must currently be in `in_repair`.
If it is not, return a domain conflict and do not modify anything.
### Destination Lifecycle
The destination must:
- exist in the current lifecycle domain,
- not equal `in_repair`,
- be explicitly allowed as a post-repair state,
- be applied only after all validation succeeds.
### Original Lifecycle
The Begin Repair workflow preserves the previous lifecycle in the `REPAIR_STARTED` event payload.
The service may use this value for:
- validation,
- response context,
- defaulting only if the API contract and existing model conventions support it safely.
The implementation must not assume every miner should return to `active`.
If restoration of the previous lifecycle is supported, preserve exact historical meaning. Do not infer or fabricate it when missing.
### Completion Is Final
Once completed:
- the repair is historical,
- the completion event is immutable,
- the active-repair constraint is released,
- a later repair must be a new repair record.
### No Silent Repair Mutation
Do not edit prior events.
Do not replace the start event.
Do not overwrite historical lifecycle values.
## Architecture
Preserve:
```text
Router -> Service -> Repository -> PostgreSQL
```
### Router Responsibilities
The router should:
- define the action route,
- validate the UUID and request model,
- enforce authentication and `repairs.complete`,
- call the service,
- map established domain exceptions to HTTP responses,
- return the established response model.
The router must not:
- contain SQL,
- manage commits or rollbacks,
- choose lifecycle transitions,
- construct event sequence values,
- read actor UUIDs from the request body.
### Service Responsibilities
The service should:
- load and lock the repair,
- verify it exists and is active,
- load and lock the associated miner when required,
- verify the miner is in repair,
- validate target lifecycle,
- normalize summary,
- close the repair,
- update miner lifecycle,
- append the completion event,
- return the completed domain result,
- ensure the operation is atomic.
### Repository Responsibilities
Repositories should provide focused methods for:
- retrieving an active repair for update,
- retrieving the associated miner for update,
- marking a repair completed,
- updating lifecycle,
- obtaining or allocating the next repair event sequence,
- appending a repair event,
- reading the completed repair representation efficiently.
Do not introduce a broad generic repair-update method.
## Database and Migration
A migration is required if the current schema does not already support all required completion data and permission assignment.
Inspect the existing repair table before adding columns.
### Preferred Repair Completion Fields
If not already present, add only the fields needed to represent completion authoritatively:
- `completed_at`: timezone-aware timestamp, nullable while active
- `completed_by_user_id`: UUID foreign key, nullable while active
- `completion_summary`: nullable text
- `resulting_lifecycle`: constrained lifecycle value, nullable while active
Use existing naming conventions if equivalent columns already exist.
### Status Representation
Do not add redundant mutable status data if active versus completed can be derived safely from established fields.
If the current repair schema already uses a status column, reuse and constrain it rather than introducing a second status mechanism.
### Constraints
Enforce critical invariants in PostgreSQL where practical.
Examples:
- active repair has no completion timestamp,
- completed repair has a completion timestamp,
- completed repair has a completing actor,
- resulting lifecycle is null while active and present when completed,
- resulting lifecycle cannot equal `in_repair`,
- completion summary length is constrained if the project uses database length checks,
- one active repair per miner remains enforced.
Adapt the exact checks to the existing schema.
### Foreign Keys
`completed_by_user_id` should reference the established user table.
Use the project's existing historical-record deletion policy. Operational history should not silently disappear.
### Indexes
Add only indexes justified by actual read patterns.
Likely useful:
- existing active-repair partial uniqueness,
- lookup by miner,
- lookup by completion timestamp only if current repair queue/history queries need it.
Avoid speculative indexes.
### Migration Data Handling
Existing active repairs must remain active.
Existing completed repairs, if legacy data exists, must be handled without inventing actor IDs, summaries, or lifecycle states.
If new non-null completion invariants cannot be truthfully applied to legacy completed rows:
- use nullable historical fields with conditional checks,
- preserve legacy data,
- document the limitation,
- do not fabricate values.
### Downgrade
The downgrade must restore the previous schema safely.
Before dropping completion columns or constraints, restore miner lifecycle only if the downgrade actually reverses newly created completion data and there is a truthful historical source.
Do not map every historical completed repair to `active`.
Do not invent original lifecycle values.
If a fully safe downgrade is impossible once real completion records exist, use the project's established explicit-refusal pattern rather than destructive guessing.
## Events and History
Append one immutable completion event.
Use the existing repair event naming conventions. Preferred semantic name:
```text
REPAIR_COMPLETED
```
Reuse an existing equivalent event type if already defined.
### Event Fields
The event must include:
- event UUID,
- repair UUID,
- next explicit sequence number,
- authenticated actor UUID,
- server/database timestamp,
- structured payload.
### Payload
Include only durable, meaningful values.
Recommended payload:
```json
{
"previous_miner_lifecycle": "in_repair",
"resulting_miner_lifecycle": "active",
"pre_repair_lifecycle": "inactive",
"completion_summary": "Replaced failed fan and completed load test."
}
```
`pre_repair_lifecycle` may be omitted if the existing event model or service cannot retrieve it reliably without ambiguity.
Do not duplicate arbitrary repair fields in the payload.
Do not store display names as authoritative actor identity.
### Event Ordering
Use the established explicit sequence mechanism.
Do not calculate order from timestamps alone.
Concurrent event insertion must not produce duplicate sequence values.
## Models and Validation
Create or extend focused models.
Suggested names, adapted to current conventions:
- `RepairCompleteRequest`
- `RepairDetailResponse` or existing equivalent
- internal completion result model if needed
### Validation Requirements
- UUID path validation,
- finite lifecycle validation,
- summary trimming,
- empty summary normalization,
- maximum summary length,
- no client-supplied actor,
- no client-supplied completion timestamp,
- no arbitrary event payload supplied by the client.
## Concurrency and Atomicity
The entire operation must use one transaction.
The following must succeed or fail together:
1. repair validation,
2. miner validation,
3. repair completion update,
4. miner lifecycle transition,
5. completion event insertion.
### Concurrent Completion
Two simultaneous completion requests must not both succeed.
Expected result:
- one request succeeds,
- the other receives `409 Conflict`,
- one completion event exists,
- one final lifecycle transition exists.
Use row locking, compare-and-update semantics, or another database-backed mechanism consistent with the repository.
### Concurrent Miner Changes
A simultaneous lifecycle-changing operation must not leave:
- a completed repair with miner still in `in_repair`,
- an active repair with miner already returned,
- conflicting lifecycle history.
Lock ordering must be consistent with Begin Repair and other miner workflows to reduce deadlock risk.
Document the chosen lock order in code comments only where it is not obvious.
## Error and Failure Behaviour
On any failure after the transaction starts:
- no partial completion fields persist,
- miner lifecycle remains unchanged,
- no completion event persists,
- repair remains active.
Unexpected database integrity failures should be translated using established service/repository exception conventions.
Do not catch broad exceptions merely to convert them into `409`.
## Tests
Add focused tests following current test organization.
### Authorization Tests
Verify:
- unauthenticated request is denied,
- user without `repairs.complete` is denied,
- Read Only / Viewer is denied,
- Technician is allowed,
- Administrator is allowed,
- existing `repairs.read` behavior is unchanged,
- existing `repairs.create` behavior is unchanged.
### Happy Path Tests
Verify:
- active repair completes successfully,
- miner transitions from `in_repair` to requested eligible lifecycle,
- repair is marked completed,
- completion timestamp is server-controlled,
- completing actor is authenticated user,
- optional summary is persisted,
- blank summary normalizes to null,
- completion event is appended,
- event sequence follows existing events,
- response represents the completed repair.
Test at least:
- return to `active`,
- return to `inactive`,
- restoration to a valid original lifecycle when relevant.
### Domain Conflict Tests
Verify `409` for:
- already completed repair,
- inactive/non-active repair,
- miner not in `in_repair`,
- mismatched active repair state,
- disallowed destination lifecycle,
- duplicate concurrent completion.
### Validation Tests
Verify `422` for:
- malformed repair UUID,
- missing required lifecycle,
- unknown lifecycle,
- `in_repair` as destination,
- summary over maximum length,
- invalid request shape.
If lifecycle defaulting is intentionally supported, replace the missing-field expectation with tests for documented default behavior.
### Missing Resource Test
Verify `404` for unknown repair UUID.
### Transaction Rollback Tests
Force failures at meaningful steps, such as:
- miner update failure,
- repair completion update failure,
- event insertion failure.
Verify no partial state persists.
### Event Tests
Verify:
- exactly one completion event,
- immutable event behavior remains,
- actor is correct,
- payload is structured,
- ordering is explicit,
- prior events remain unchanged.
### Database Constraint Tests
Verify applicable constraints directly, including:
- completed record requirements,
- active/completed consistency,
- resulting lifecycle restriction,
- one-active-repair rule remains valid.
### Migration Tests
Verify:
- previous head upgrades,
- new permission exists,
- intended roles receive the permission,
- unintended roles do not,
- schema constraints exist,
- downgrade works safely or refuses explicitly according to project convention,
- re-upgrade works,
- empty database upgrades through head.
### Routing Tests
Verify:
- static repair routes such as `/api/repairs/queue` and `/api/repairs/parts-summary` still resolve,
- `/api/repairs/{repair_id}/complete` resolves correctly,
- UUID detail and event routes remain unchanged,
- malformed static-looking paths are not accidentally consumed by UUID routes,
- expected route count increases only by the intended amount.
### Regression Tests
Run at minimum:
- Begin Repair tests,
- Repair Timeline tests,
- inventory lifecycle tests,
- permission tests,
- migration lifecycle suite,
- relevant legacy repair tests,
- complete project suite if practical.
## Documentation Updates
After implementation and validation succeed:
- mark Return to Service complete in `docs/releases/v0.2-repair-workflow.md`,
- update `docs/ROADMAP.md` only if the repository uses it as current implementation status,
- update API documentation if maintained separately.
Do not mark Control Board Replacement or Repair Parts Tracking complete.
Do not perform broad stale-document cleanup in this task unless a directly changed statement would become materially false.
## Acceptance Criteria
The task is complete when all of the following are true:
1. `POST /api/repairs/{repair_id}/complete` exists.
2. Authentication and `repairs.complete` are enforced.
3. Only an active repair can be completed.
4. The associated miner must be in `in_repair`.
5. The target lifecycle is explicit and validated.
6. Repair completion and miner transition are atomic.
7. Exactly one immutable completion event is appended.
8. Concurrent completion cannot succeed twice.
9. Existing repair static and UUID routes still resolve.
10. Existing Begin Repair and Repair Timeline behavior remains unchanged.
11. Migration lifecycle validation passes.
12. Permission assignments are correct.
13. Focused and regression tests pass.
14. `git diff --check` passes.
15. No unrelated files are changed.
16. No commit or push is performed.
## Validation Commands
Use repository-native commands where available.
At minimum run:
```bash
python -m compileall .
```
Run shell syntax checks for changed shell files, if any.
Run:
- focused Return to Service tests,
- Begin Repair tests,
- Repair Timeline tests,
- inventory lifecycle tests,
- permission tests,
- migration tests,
- route audits,
- architecture audits,
- full suite when practical.
Validate migrations through:
- previous head to new head,
- downgrade,
- re-upgrade,
- empty database to head,
- disposable development baseline.
Run:
```bash
git diff --check
git status --short
```
## Architecture Audit
Confirm:
- no SQL in routers,
- no database driver imports in routers,
- no transaction management in routers,
- no FastAPI imports in services or repositories,
- no generic repair update repository introduced,
- actor identity comes from authentication,
- timestamps are server-controlled,
- events are append-only,
- route ordering is safe.
## Completion Report
Return:
- branch and starting commit,
- files created,
- files modified,
- migration revision and parent,
- route added,
- permission added and role assignments,
- domain behavior,
- concurrency strategy,
- event type and payload,
- tests added,
- focused test results,
- regression results,
- migration validation results,
- architecture audit results,
- `git diff --check`,
- known limitations,
- final `git status --short`.
Do not commit.
Do not push.
+259
View File
@@ -0,0 +1,259 @@
# Task 004: Control Board Replacement
## Status
Design specification. Not yet implemented.
## Objective
Add an explicit, auditable workflow for replacing a miner control board while
preserving durable asset identity, network-identity history, repair context, and
immutable operational history.
## Product Intent
A miner is a durable physical asset. Its control board is replaceable. A
replacement may change the MAC address and control-board serial, but it must not
create a new miner or erase prior history.
**Current implementation context:** `miners.id` is the durable asset UUID.
`miners.current_mac` and `miners.control_board_sn` are current-state fields.
`miner_network_identities` already stores MAC, IP, hostname, source, seen
timestamps, and an `is_current` marker for a miner. The legacy
`miner_maintenance_events` table can contain MAC and component details, but
those records are editable, deletable, and not part of the immutable,
repair-scoped timeline. No dedicated control-board component-history table
exists.
The integrated application also exposes the legacy
`POST /api/miners/{miner_id}/change-mac` route, and Inventory, discovery, and
location-import code can write current network fields. These are existing
compatibility paths, not implementations of Task 004.
## Scope
- proposed route `POST /api/miners/{miner_id}/replace-control-board`
- proposed permission `miners.replace_control_board`
- require an active repair and miner lifecycle `in_repair`
- normalize and validate the new MAC address
- reuse and strengthen `miner_network_identities` for MAC history rather than
creating a competing MAC-history table
- update `miners.current_mac` and `miners.control_board_sn` as current-state
projections
- preserve the old and new control-board serials in immutable repair history
- append one immutable `CONTROL_BOARD_REPLACED` event
- execute atomically and safely under concurrency
- add migration, constraints, tests, and documentation
## Out of Scope
Automatic discovery reconciliation, generic component inventory, stock quantities, purchasing, warranties, firmware flashing, pool changes, remote commands, bulk replacement, automatic miner merge, UI redesign, and replacement of hashboards, fans, or PSUs.
## Permission
The proposed permission is:
```text
miners.replace_control_board
```
Proposed role assignment, following the implemented operational-action policy:
- Admin: allowed
- Manager: allowed
- Technician: allowed
- Read Only: denied
The permission and assignments do not exist yet. They must be introduced by the
Task 004 migration. The route must check the capability rather than hard-code
role names.
## Authentication and Response
Authentication is required. On success, return `200 OK` using a typed response
envelope. The response must identify the durable miner UUID, resolved active
repair UUID, current MAC, current control-board serial, and appended event ID and
sequence without exposing unrelated miner data.
## Request
Suggested shape:
```json
{
"new_mac_address": "AA:BB:CC:DD:EE:FF",
"new_control_board_serial": "CB-2026-001234",
"notes": "Replacement board installed and tested."
}
```
The new MAC is required. `new_control_board_serial` is a required, nullable
field so the operator explicitly records a serial or confirms that it is
unknown. A null or normalized blank value clears the old current-board serial;
it must never leave the removed board serial as current. Normalize nonblank
serials and limit them to the Inventory field maximum of 255 characters.
Notes are optional, trim like existing repair notes, treat whitespace-only as
absent, and use the established 4,000-character limit. The client must not
supply the old MAC, old serial, repair UUID, actor, timestamp, event sequence,
or event payload; the service derives those values from authoritative state.
## MAC Rules
Define and test one canonical display format, such as
`AA:BB:CC:DD:EE:FF`, while comparing ownership by a normalized 12-hex-digit
key. Existing data must be checked for normalized conflicts before a uniqueness
constraint is added.
Reject malformed, broadcast, multicast, all-zero, and currently duplicated MAC
addresses. A MAC historically associated with another asset must produce a
conflict requiring a later deliberate transfer or correction workflow rather
than automatic reassignment. Task 004 must not silently rewrite, merge, or
delete a conflicting association.
## Durable Identity Rules
- miner UUID and asset record remain unchanged
- current MAC changes
- prior MAC remains associated with the same miner in
`miner_network_identities`
- repair, location, ownership, and asset-ID history remain unchanged
- no second miner is auto-created
- no automatic asset merge occurs
## Preconditions
1. Miner exists.
2. Miner is `in_repair`.
3. Miner has exactly one active repair.
4. New MAC is valid and canonical.
5. New MAC is not current or historical on another miner.
6. When a current MAC exists, old and new MAC differ.
7. Actor and timestamp are authoritative.
## Data Model
Task 004 must build on the existing structures:
- `miners.current_mac` remains the current MAC projection.
- `miners.control_board_sn` remains the current control-board serial projection.
- `miner_network_identities` remains the MAC-history structure. The migration
may add only the constraints or indexes required to enforce one current
association and prevent cross-miner normalized-MAC ownership conflicts.
- `repair_events` remains the immutable repair timeline. Extend its constrained
event vocabulary and payload validation for `CONTROL_BOARD_REPLACED`.
Do not create a second MAC-history table. Do not treat legacy
`miner_maintenance_events` as authoritative component history for this
workflow. A separate component-installation table is outside this task unless
repository inspection during implementation identifies a concrete query or
integrity requirement that cannot be met by the current projections, network
identity history, and immutable repair event. Report that conflict before
adding such a table.
## Constraints
- no more than one current network-identity row per miner
- one miner owner per normalized MAC across current and historical identities
- valid normalized MAC for rows written by the new workflow
- current projections agree with the newly current identity after replacement
- foreign-key behavior preserves operational history
The migration must preflight existing normalized MAC duplicates and fail with
an actionable message rather than changing existing ownership silently.
## Legacy Data
Existing current MAC values may be reconciled with
`miner_network_identities` only when the association is unambiguous. Existing
identity rows and legacy maintenance events must be preserved. Unknown
installation actor, time, control-board serial, or source must remain unknown;
never fabricate a replacement event.
## Event
Proposed type: `CONTROL_BOARD_REPLACED`.
Suggested payload:
```json
{
"old_mac_address": "CC:0C:15:00:0A:49",
"new_mac_address": "CC:0C:15:00:9D:82",
"old_control_board_serial": null,
"new_control_board_serial": "CB-2026-001234",
"notes": "Replacement board installed and tested."
}
```
Include only authoritative values.
## Atomicity and Locking
Use one transaction and the established lock order:
```text
Miner -> Repair -> network-identity rows
```
The service resolves and locks the miner first, then its one active repair, then
the relevant network-identity rows. This matches Tasks 001 and 003.
The operation must atomically validate MAC ownership, mark prior network
identities non-current, create or activate the new association without
reassigning another miner's history, update the current miner fields, and append
exactly one event to the active repair.
Concurrent requests must not assign one MAC to two miners, create two current boards, duplicate event sequence values, or leave partial state.
## Errors
- `401`: unauthenticated
- `403`: authenticated user lacks `miners.replace_control_board`
- `404`: miner missing
- `409`: miner not in repair, no active repair, same current MAC, MAC conflict,
historical ownership conflict, or concurrent replacement
- `422`: malformed UUID, invalid MAC, missing required fields, excessive text,
unknown fields, or other invalid request data
## Architecture
Preserve `Router -> Service -> Repository -> PostgreSQL`.
No SQL or transaction management in routers. No FastAPI in services or repositories. Do not add generic component CRUD.
## Tests
Cover:
- role authorization
- valid replacement
- durable asset identity unchanged
- old MAC remains searchable
- malformed and prohibited MACs
- current and historical MAC conflicts
- duplicate existing normalized-MAC migration preflight
- missing active repair
- miner not in repair
- concurrent replacement
- rollback at each write step
- direct database constraints
- event payload and sequence
- Tasks 001-003 regressions
- migration upgrade, downgrade/refusal, re-upgrade, and empty database
## Decisions Required Before Implementation
Before Task 004 is marked ready for implementation, decide:
1. Whether the legacy `/api/miners/{miner_id}/change-mac` route will delegate to
the new service or be deprecated through an explicit compatibility plan. It
must not remain a competing writer that bypasses the active-repair workflow.
2. Whether an operator-recorded replacement MAC counts as a network observation
for the existing `first_seen_at` and `last_seen_at` semantics, or whether the
schema needs a distinct association timestamp. Do not label an unobserved MAC
as discovered accidentally.
3. How Inventory creation, discovery, and location-import writers will translate
normalized-MAC ownership conflicts without exposing raw database errors or
auto-merging assets.
These decisions do not permit automatic asset creation, merging, or MAC
transfer. A separate deliberate transfer workflow remains out of scope.
## Acceptance Criteria
1. One explicit route exists.
2. Durable miner identity remains unchanged.
3. Old and new MACs remain searchable.
4. Normalized MAC ownership and current-association invariants are
database-backed.
5. Active repair and `in_repair` are required.
6. Exactly one immutable event is written.
7. Operation is atomic and concurrency-safe.
8. Legacy data remains truthful.
9. Migration and regression suites pass.
10. No commit or push is performed.
## Completion Report
Report branch, starting commit, migration, files, route, permission, schema, constraints, normalization, history, transaction strategy, event, tests, validation, limitations, and final `git status --short`.
Do not commit.
Do not push.
+679
View File
@@ -0,0 +1,679 @@
# Task 005: Repair Parts Tracking
## Status
Approved implementation specification. Not yet implemented.
This task introduces a minimal durable component registry only to support
tracked-component references in repair-part history. It does not implement the
broader component inventory, lifecycle, installation, location, or warehouse
model described as future direction in
`docs/design/COMPONENT_DOMAIN_MODEL.md`.
## Objective
Allow authorized operators to record what was used during an active repair as
exactly one of:
1. a quantity-based consumable; or
2. one tracked component identified by a durable component UUID.
Repair-part history is immutable. Corrections use an explicit void action and,
when necessary, a new replacement entry. Original entries and events are never
edited or physically deleted.
## Product Intent
A technician should be able to determine which consumables and tracked
components were used during a repair. Consumable records preserve their
description, quantity, and unit. Tracked-component records preserve both the
live component UUID and an immutable display snapshot captured when the entry
is recorded.
All authoritative actors come from authentication. All authoritative
timestamps and repair-event sequences are server or database controlled.
## Authoritative Boundaries
Task 004 remains authoritative for:
- control-board replacement;
- MAC ownership;
- control-board installation and removal history;
- current control-board and MAC projections;
- `CONTROL_BOARD_REPLACED` events; and
- the `/api/miners/{miner_id}/change-mac` compatibility alias.
Task 005 records repair usage only. It must not install, remove, replace,
transfer, relocate, or change ownership of a tracked component. In particular,
recording a control-board component must not:
- replace a control board or transfer its MAC address;
- modify `miners.control_board_sn` or `miners.current_mac`;
- modify `miner_network_identities`, `miner_mac_ownerships`, or
`miner_control_board_installations`;
- append `CONTROL_BOARD_REPLACED`; or
- substitute for Task 004's explicit replacement workflow.
The existing `miner_control_board_installations.id` identifies an installation
history row, not a durable component. The existing `miner_hashboards.id`
identifies a miner-slot projection, not a component that retains identity
through movement. Task 005 must not repurpose either UUID as a component UUID or
infer component identity from a serial number, MAC address, miner, slot, or
installation.
## Scope
- a minimal durable component registry;
- narrow component creation and detail reads;
- focused repair-part list, record, and void routes;
- capability-based permissions;
- mutually exclusive consumable and tracked-component request shapes;
- fixed-precision quantities and controlled units;
- immutable tracked-component display snapshots;
- explicit void and replacement-entry corrections;
- immutable, explicitly sequenced repair events;
- atomic transactions and concurrency control;
- PostgreSQL constraints, foreign keys, and immutability protections; and
- focused, migration, regression, architecture, and route tests.
## Out Of Scope
- generalized component inventory or generic component CRUD;
- component update, delete, list, search, merge, or deduplication APIs;
- component locations, movements, installations, or removals;
- component lifecycle or condition workflows;
- component ownership transfer;
- warehouse inventory and transfers;
- stock levels, allocation, or automatic stock deduction;
- purchasing, suppliers, vendors, pricing, invoices, and cost accounting;
- warranties and RMA workflows;
- reorder automation;
- automatic creation of components while recording repair parts;
- automatic installation, removal, ownership transfer, or location tracking;
- specialized component tables or attributes;
- generalized bills of materials;
- bulk import;
- photos and work orders; and
- redesign or migration of Task 004 structures.
## Terminology
**Component:** A minimally registered physical part with an immutable UUID and
constrained type. Task 005 records identity and basic display metadata only.
**Repair-part entry:** An immutable record associating one consumable use or one
tracked component with a repair.
**Consumable:** A normalized description, positive quantity, controlled unit,
and optional notes. It has no component UUID.
**Tracked component:** One existing component referenced by UUID. Its repair-part
entry includes a display snapshot and has no consumable quantity or unit.
**Display snapshot:** Immutable component type, Asset ID, manufacturer, model,
and manufacturer serial values copied from the component when the repair-part
entry is recorded. Nullable source values remain null.
**Void:** A one-way audited correction that preserves the original entry. A
corrected value is represented by voiding the incorrect entry and recording a
new entry.
## Minimal Component Registry
### Component Types
The initial controlled component types are:
- `control_board`;
- `hashboard`;
- `power_supply`; and
- `fan`.
Do not accept `other` or arbitrary free-text types. Expanding the vocabulary
requires a reviewed migration and corresponding model and test updates.
### Component Schema
Create the focused `components` table with:
- `id` UUID primary key with a database default;
- `component_type` constrained text, required;
- `asset_id` text, optional;
- `manufacturer` text, optional;
- `model` text, optional;
- `manufacturer_serial` text, optional;
- `created_at` timezone-aware timestamp, required and server controlled; and
- `created_by` authenticated user UUID, required.
Rules:
- `id`, `component_type`, `created_at`, and `created_by` are immutable;
- optional text is trimmed, nonblank when present, and limited to 255
characters;
- Asset IDs are case-insensitively unique among components after trimming;
- component Asset IDs do not share a database uniqueness namespace with miner
Asset IDs in this task;
- manufacturer serials are not assumed globally unique;
- `created_by` references the existing user identity with `RESTRICT` deletion
behavior;
- no client may supply `id`, `created_at`, or `created_by`;
- no component is created implicitly from a repair-part request; and
- Task 005 provides no component metadata update or deletion workflow.
The UUID is the authoritative component identity. Asset ID and manufacturer
metadata do not replace it. A later explicit task may evolve mutable display
metadata while preserving the UUID and history.
## API
### Component Routes
Task 005 adds exactly these narrow component routes:
```http
POST /api/components
GET /api/components/{component_id}
```
`POST /api/components` creates one component and returns `201 Created`.
`GET /api/components/{component_id}` returns one component and `200 OK`.
Unknown component UUIDs return `404`. Malformed UUIDs use the existing `422`
validation response.
Do not add component list, search, update, patch, delete, install, remove, move,
or lifecycle routes.
### Repair-Part Routes
Task 005 adds exactly these repair-part routes:
```http
GET /api/repairs/{repair_id}/parts
POST /api/repairs/{repair_id}/parts
POST /api/repairs/{repair_id}/parts/{repair_part_id}/void
```
The list returns `200 OK`. Recording returns `201 Created`. A successful void
returns `200 OK` with the voided repair-part representation. No route physically
deletes or destructively edits a repair-part entry.
Register and test these routes without changing the precedence or behavior of
legacy static routes, including `/api/repairs/parts-summary`.
### Response Envelopes
Use the existing typed success envelope containing `data`, `meta`, and `error`.
Component responses expose only the approved component fields. Repair-part
responses expose the stored consumable fields or stored component snapshot,
plus component UUID, notes, recording metadata, and void metadata.
A tracked repair-part response must use its immutable snapshot for historical
display. It may expose `component_id` so a caller can separately request the
live component. It must not silently substitute current component metadata for
the snapshot.
### List Ordering
Return repair-part entries in ascending recorded repair-event sequence. The
explicit database sequence, not timestamps, determines order. Void state is
shown on the original entry; void events remain separately visible in the
existing repair timeline.
No pagination or filtering is added because the collection is bounded to one
repair. A later change requires compatibility review.
## Permissions
Introduce these proposed capabilities:
- `components.read` for component detail reads;
- `components.create` for component creation; and
- `repairs.parts.manage` for recording and voiding repair-part entries.
Reuse implemented `repairs.read` for the repair-part list.
Role grants:
| Capability | Admin | Manager | Technician | Read Only |
| --- | --- | --- | --- | --- |
| `components.read` | yes | yes | yes | yes |
| `components.create` | yes | yes | yes | no |
| `repairs.read` | yes | yes | yes | yes |
| `repairs.parts.manage` | yes | yes | yes | no |
Routers check capabilities, never role names. Every route requires
authentication and focused unauthenticated, unauthorized, and authorized-role
tests.
## Request Models
Reject unknown fields in every request. Actor UUIDs, timestamps, sequences,
event payloads, snapshots, and persistence identifiers are never accepted from
clients.
### Create Component
```json
{
"component_type": "hashboard",
"asset_id": "HB-0042",
"manufacturer": "MicroBT",
"model": "M50 Hashboard",
"manufacturer_serial": "HB-SN-12345"
}
```
Only `component_type` is required. Optional text values are normalized by
trimming surrounding whitespace and are rejected when blank after trimming.
Duplicate normalized Asset IDs return `409 Conflict` without leaking a database
exception.
### Record Consumable
```json
{
"consumable": {
"description": "Thermal paste",
"quantity": 1,
"unit": "gram"
},
"tracked_component_uuid": null,
"notes": "Applied during board service."
}
```
### Record Tracked Component
```json
{
"consumable": null,
"tracked_component_uuid": "c79f9f36-1e89-4dbc-8a84-9ff3167614be",
"notes": "Used during this repair."
}
```
Exactly one of `consumable` and `tracked_component_uuid` must be non-null. A
tracked-component request refers to one existing component and never creates,
installs, removes, transfers, or mutates it.
### Void Repair Part
```json
{
"reason": "Recorded against the wrong repair."
}
```
The reason is required, trimmed, nonblank, and limited to 1000 characters. The
actor and timestamp come from authentication and the server.
## Consumable Rules
A consumable requires:
- normalized, nonblank `description`, limited to 255 characters;
- `quantity` represented as `NUMERIC(12,3)` or the repository-equivalent fixed
precision;
- quantity greater than zero;
- one controlled `unit`; and
- optional shared notes.
The initial controlled units are:
- `each`;
- `gram`;
- `millilitre`;
- `metre`; and
- `centimetre`.
Do not accept aliases or arbitrary free-text units. The service and request
model reject more than three fractional digits or values outside the fixed
precision range.
When `unit` is `each`, quantity must be a whole number. Measurement units permit
fractional quantities to three decimal places. Quantity describes repair usage
only and never changes stock.
## Repair-Part Schema
Create the focused `repair_parts` table with:
- `id` UUID primary key with a database default;
- `repair_id` UUID, required;
- `consumable_description` text, nullable;
- `quantity` `NUMERIC(12,3)`, nullable;
- `unit` constrained text, nullable;
- `component_id` UUID, nullable;
- `component_type_snapshot` constrained text, nullable;
- `component_asset_id_snapshot` text, nullable;
- `component_manufacturer_snapshot` text, nullable;
- `component_model_snapshot` text, nullable;
- `component_manufacturer_serial_snapshot` text, nullable;
- `notes` text, nullable;
- `recorded_by` authenticated user UUID, required;
- `recorded_at` timezone-aware timestamp, required and server controlled;
- `recorded_event_id` repair-event UUID, required;
- `voided_by` authenticated user UUID, nullable;
- `voided_at` timezone-aware timestamp, nullable;
- `void_reason` text, nullable;
- `void_event_id` repair-event UUID, nullable.
Foreign keys use `RESTRICT` deletion behavior for repairs, users, components,
and events so authoritative history cannot disappear through cascades.
The repository captures tracked-component snapshots from the locked component
row in the same transaction as the repair-part insert and event. Client values
cannot populate snapshot columns. Nullable component metadata remains null in
the snapshot rather than being fabricated.
The original entry shape, consumable values, component UUID and snapshot,
notes, recording actor, timestamp, and recorded event data are immutable. A
void adds only the write-once void fields and immutable void event.
## PostgreSQL Invariants
PostgreSQL must enforce the complete XOR shape:
- consumable branch:
- `consumable_description`, `quantity`, and `unit` are all present;
- `component_id` and every component snapshot field are null; or
- tracked-component branch:
- `component_id` and `component_type_snapshot` are present;
- `consumable_description`, `quantity`, and `unit` are all null;
- optional snapshot fields match the nullable values captured at record time.
Additional constraints and protections:
- quantity is greater than zero when present;
- quantity uses at most three fractional digits;
- `each` requires `quantity = trunc(quantity)`;
- unit is one of `each`, `gram`, `millilitre`, `metre`, or `centimetre`;
- component and snapshot types use the approved component-type constraint;
- descriptions and snapshot text are trimmed and nonblank when present;
- notes are null or trimmed, nonblank, and limited to 4000 characters;
- recording actor, timestamp, and event UUID are required;
- component UUID references an existing component;
- recorded and void event UUIDs are unique and reference repair events;
- void actor, timestamp, reason, and event UUID are either all
null or all present;
- void reason is trimmed, nonblank, and limited to 1000 characters;
- void timestamp is not earlier than recorded timestamp;
- a repair part cannot be associated with another repair through its event
references;
- original entry fields cannot be updated;
- void fields transition only once from all-null to all-present; and
- repair-part and repair-event rows cannot be physically deleted.
Use PostgreSQL constraints, foreign keys, indexes, and narrowly scoped triggers
where a declarative constraint cannot enforce cross-row immutability or event
ownership. Service validation provides clear domain errors but does not replace
database integrity.
## Repair State Rules
- component creation and detail reads do not change repair state;
- record repair-part entries only while the repair is active;
- void repair-part entries only while the repair is active;
- list entries and void state after repair completion;
- completed and cancelled repairs are read-only for Task 005;
- no unrestricted editing is allowed in any repair state; and
- Task 005 never changes repair status or miner lifecycle.
Begin Repair, Repair Timeline, Return to Service, and Task 004 behavior must
remain unchanged.
## Events
Add these immutable repair event types:
- `REPAIR_PART_RECORDED`; and
- `REPAIR_PART_VOIDED`.
`REPAIR_PART_RECORDED` contains:
- repair-part UUID;
- entry kind: `consumable` or `tracked_component`;
- the stored description, quantity, and unit for a consumable; or
- the stored component UUID and complete component display snapshot for a
tracked component; and
- optional notes.
It must not claim that a tracked component was installed, removed, replaced,
transferred, relocated, or changed.
`REPAIR_PART_VOIDED` contains:
- repair-part UUID; and
- required correction reason.
The event actor, timestamp, and sequence use existing repair-event columns and
are server controlled. Clients request domain actions, not raw events. Events
are append-only and ordered only by the repair's explicit database sequence.
The `repair_parts` table is the queryable record and does not replace Task 004
history.
## Transactions And Concurrency
### Component Creation
Component creation is one transaction. The database generates its UUID and
timestamp, the authenticated actor is required, and the normalized Asset ID
constraint resolves concurrent duplicates. No repair or component event is
invented for this minimal identity registration.
### Record Repair Part
Resolve the repair's miner UUID, then lock in this order:
```text
Miner -> Repair -> Component (tracked entries only)
```
After locking, revalidate the miner and active repair state. For a tracked
entry, lock and load the component, capture its snapshot, insert the repair-part
row, allocate the next repair-event sequence, and append exactly one
`REPAIR_PART_RECORDED` event in one transaction.
For a consumable, perform the same workflow without a component lock. Any
validation, insert, snapshot, sequence, or event failure rolls back the complete
action.
### Void Repair Part
Resolve the repair's miner UUID, then lock in this order:
```text
Miner -> Repair -> Repair Part
```
Revalidate the active repair and route ownership after locking. Set the
write-once void fields and append exactly one `REPAIR_PART_VOIDED` event in one
transaction. Concurrent void requests produce one success and one `409`
conflict, with exactly one void event.
The Miner lock and Repair lock serialize record and void actions with Return to
Service and Task 004. If repair completion wins, the later Task 005 action sees
a terminal repair and fails with `409`. If Task 005 wins, its complete entry and
event commit before completion proceeds.
## Architecture
Preserve:
```text
Router
-> Service
-> Repository
-> PostgreSQL
```
Routers own authentication, permission checks, request validation, HTTP status
mapping, and response envelopes. Services own normalization, component and
repair-part rules, workflow coordination, and domain exceptions. Repositories
own parameterized SQL, locks, snapshots, persistence, and PostgreSQL integrity
translation.
Routers contain no SQL, database-driver imports, transaction handling, or
business rules. Services and repositories contain no FastAPI imports.
## Error Behavior
- unauthenticated: `401`;
- missing permission: `403`;
- unknown component, repair, or repair part: `404`;
- malformed UUID, unknown field, invalid type, invalid unit, invalid shape,
invalid precision, or invalid text: `422`;
- duplicate normalized component Asset ID: `409`;
- inactive repair, already-voided entry, or concurrent state conflict: `409`;
and
- persistence failures: translated without exposing SQL or driver details.
A repair-part UUID that does not belong to the repair in the route is treated as
not found and returns `404`.
## Migration
Create additive Alembic revision `0014_repair_parts_tracking` with parent
`0013_control_board_replacement`.
The migration must:
- create `components` with its type, text, actor, timestamp, Asset ID uniqueness,
immutability, and deletion protections;
- create `repair_parts` with XOR, quantity, unit, snapshot, actor, timestamp,
event, void, immutability, and deletion protections;
- add `components.read`, `components.create`, and `repairs.parts.manage` with the
approved role assignments;
- extend the repair-event type constraint with `REPAIR_PART_RECORDED` and
`REPAIR_PART_VOIDED`;
- add only indexes justified by component lookup, normalized Asset ID
uniqueness, repair-part listing, foreign keys, and event relationships;
- preserve all existing data without inventing components, repair parts,
actors, timestamps, snapshots, or events; and
- leave Task 004 tables, constraints, projections, routes, and history intact.
There is no legacy backfill. Generic maintenance events, telemetry, serial
numbers, miner hashboard projections, and Task 004 installation rows are not
sufficient evidence for durable component identity or repair-part usage.
Downgrade is permitted only when no component, repair-part, or Task 005 repair
event rows exist. Otherwise it must refuse with a clear actionable error rather
than delete authoritative identity or history. An empty safe downgrade removes
Task 005 constraints, permissions, tables, and event values in dependency order,
then supports re-upgrade.
Validate previous-head upgrade, preflight behavior, safe downgrade, downgrade
refusal, re-upgrade, and an empty database from base through head.
## Tests
### Component Registry
- create and detail-read authorization for every role;
- successful creation with required fields only and with all metadata;
- UUID, actor, and timestamp authority;
- all approved component types and rejection of unknown types;
- whitespace normalization, text limits, and blank optional values;
- case-insensitive normalized Asset ID uniqueness and concurrent duplicates;
- nullable manufacturer metadata and nonunique manufacturer serials;
- immutable identity, type, actor, and timestamp;
- no implicit component creation from a repair-part request;
- no list, search, update, delete, lifecycle, location, or installation routes;
and
- missing and malformed component UUID behavior.
### Repair-Part Requests And Reads
- consumable and tracked-component happy paths;
- controlled units and rejection of aliases or free text;
- `NUMERIC(12,3)` boundaries and rejection of excess precision;
- positive fractional measurement quantities;
- whole-number `each` quantities and rejection of fractional `each`;
- both entry branches supplied and neither branch supplied;
- incomplete consumable shapes;
- unknown, malformed, and client-created component UUIDs;
- optional notes and blank or overlength text;
- server-controlled actors, timestamps, sequences, events, and snapshots;
- stable list ordering by recorded event sequence;
- response envelopes and historical reads after completion;
- component metadata changed after recording without changing the stored
snapshot or historical display;
- no N+1 component or actor queries; and
- route ownership for nested repair-part UUIDs.
### Workflow And Integrity
- add and void authorization for every role;
- active repair enforcement;
- explicit void and void-plus-replacement correction;
- already-voided and cross-repair behavior;
- concurrent record versus Return to Service;
- concurrent void requests;
- rollback on component lookup, snapshot, part insert, sequence, or event
failure;
- direct XOR, type, quantity, unit, text, foreign-key, event, and void
constraints;
- original-field, event, and physical-deletion protections;
- confirmation that tracked-component recording changes no component, miner,
lifecycle, installation, ownership, MAC, location, or condition state;
- confirmation that a control-board reference cannot bypass or duplicate Task
004's replacement behavior; and
- lock-order and transaction-boundary tests.
### Regression And Validation
- Begin Repair tests;
- Repair Timeline tests;
- Return to Service tests;
- Task 004 control-board replacement and compatibility-alias tests;
- Inventory lifecycle and permission regressions;
- legacy static repair-route precedence tests;
- migration upgrade, downgrade, refusal, re-upgrade, previous-head, and empty
database tests;
- full backend suite;
- disposable development baseline;
- Python compilation and shell syntax checks;
- router SQL, driver, transaction, and business-rule audit;
- service and repository FastAPI audit;
- exact route-count audit; and
- `git diff --check`.
## Acceptance Criteria
1. Components receive immutable UUIDs and only the minimal approved identity and
display metadata.
2. Component types and consumable units use the approved controlled values.
3. Every repair-part entry represents exactly one complete consumable or one
existing tracked component, never both or neither.
4. Consumable quantities use fixed precision, remain positive, and enforce
whole numbers for `each`.
5. Tracked entries preserve a live component foreign key and immutable display
snapshot.
6. Historical display does not change when live component metadata later
changes.
7. Recording a tracked component does not install, remove, replace, transfer,
relocate, create, or merge a component or miner.
8. Original entries and events are immutable; corrections use an audited void
and optional replacement entry without physical deletion.
9. Actors, timestamps, snapshots, and event sequences are authoritative and
server controlled.
10. Record and void actions are atomic and concurrency safe.
11. Task 004 remains authoritative for control-board and MAC behavior.
12. No generalized inventory, warehouse, purchasing, supplier, stock, location,
lifecycle, warranty, RMA, or cost system is introduced.
13. Existing repair workflows and static route precedence remain unchanged.
14. Migration lifecycle, focused tests, regressions, architecture audits, and
the disposable baseline pass.
## Completion Report
Report files, migration revision and parent, routes and exact route count,
permissions and role grants, component registry schema, repair-part schema,
request and response models, constraints, snapshots, event payloads and
sequencing, transaction and lock strategy, downgrade behavior, tests and counts,
regression results, architecture and route audits, known limitations, and final
status.
Do not commit.
Do not push.
+88
View File
@@ -0,0 +1,88 @@
# Task NNN: Feature Name
## Objective
Describe the operational outcome.
## Background
Summarize relevant current behavior and dependencies.
## Scope
List exactly what is included.
## API
Define every route, request, response, permission, and status code.
## Permissions
Define capabilities and role assignments.
## Domain Rules
List invariants and lifecycle rules.
## Architecture
Preserve:
```text
Router -> Service -> Repository -> PostgreSQL
```
## Database and Migration
Define schema changes, constraints, indexes, migration order, and downgrade behavior.
## Events and History
Define immutable events, sequence, actor, timestamp, and structured payload.
## Models and Validation
Define UUID, time, enum, normalization, length, and JSON requirements.
## Repository Requirements
Define focused query and locking behavior. Avoid N+1 queries and generic CRUD.
## Service Requirements
Define validation, workflow, transaction boundaries, and domain exceptions.
## Router Requirements
Define dependencies, exception mapping, and route-count constraints.
## Error Behaviour
Define 401, 403, 404, 409, 422, and any additional statuses.
## Concurrency and Atomicity
Describe races and database enforcement.
## Tests
Cover authorization, domain behavior, rollback, constraints, migrations, regressions, routing, and architecture audits.
## Validation
Run compilation, shell validation, focused and regression tests, migration lifecycle tests, disposable baseline, architecture audits, route audit, and `git diff --check`.
## Documentation
List required roadmap, release, API, or operator documentation.
## Explicitly Out of Scope
List adjacent features that must not be added.
## Completion Report
Report files, migration, routes, permissions, tests, validation, limitations, and final `git status --short`.
Do not commit.
Do not push.
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more