34 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 d738b8e27e feat: add asset ID history API 2026-07-28 19:48:57 +00:00
lussierj a87ab2709f feat: add inventory metadata updates 2026-07-28 19:30:51 +00:00
lussierj 51aec1b404 feat: add read-only inventory API 2026-07-28 18:31:57 +00:00
lussierj 979885bbdd docs: add project roadmap 2026-07-28 17:57:43 +00:00
lussierj 93e034ea07 docs: add architecture decision records 2026-07-28 17:51:41 +00:00
lussierj 7280f1dfea docs: add product guide 2026-07-28 17:22:03 +00:00
lussierj fbf0f81df1 docs: add architecture guide 2026-07-28 17:09:09 +00:00
lussierj 1cae60ab46 Add MinerOps branding and project README 2026-07-28 16:49:48 +00:00
lussierj 577226cb4e Update README.md 2026-07-28 11:21:26 -05:00
136 changed files with 32080 additions and 510 deletions

No files matched your search

+283 -83
View File
@@ -1,122 +1,322 @@
# MinerOps
<p align="center">
<img src="branding/logo.png" alt="MinerOps Logo" width="180">
</p>
MinerOps is a self-hosted mining fleet operations console for tracking, repairing, locating, and controlling WhatsMiner ASICs.
<h1 align="center">MinerOps</h1>
## Current deployment
<p align="center">
<strong>The self-hosted operating system for cryptocurrency mining operations.</strong>
</p>
- Hub CT: 306
- Public URL: https://plexus.lussier.cc
- Repository: https://gitea.lussier.cc/lussierj/minerops
- Backend container: minerops-backend
- Database container: minerops-postgres
- Redis container: minerops-redis
<p align="center">
Know every miner • Track every repair • Control every rack
</p>
## Main pages
<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>
- /dashboard - fleet dashboard and repair summary
- /repairs - repair queue, notes, parts used, history, CSV export
- /locations - room/rack/shelf location map
- /system-health - app, database, table size, route, and retention health
---
## Repair workflow
## Overview
1. Open /repairs.
2. Find the miner by MAC, IP, model, serial, or problem.
3. Add repair notes.
4. Select parts used and quantity.
5. Update repair status.
6. Use History to edit or delete mistaken entries.
7. Mark Ready to Shelf or use Move to Shelf.
8. Return the miner to an open shelf position on /locations.
MinerOps is an open-source operations platform built for cryptocurrency mining facilities.
## Repair statuses
Rather than relying on spreadsheets, disconnected monitoring tools, and handwritten repair logs, MinerOps brings every operational workflow together into a single self-hosted application.
- Open
- Diagnosing
- Waiting Parts
- In Progress
MinerOps is designed to manage the complete lifecycle of a mining fleet, including inventory, physical locations, repairs, monitoring, maintenance, and secure remote management.
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.
---
# Vision
Running a mining operation requires far more than knowing whether a miner is online.
MinerOps is designed to become the operational hub for an entire mining facility by combining:
- Asset Inventory
- Physical Location Management
- Repair & Maintenance Tracking
- Live Fleet Monitoring
- Historical Reporting
- Secure Remote Management
- Operational Analytics
- Fleet Automation
into one cohesive platform.
The long-term goal is to replace multiple disconnected systems with a single source of truth for mining operations.
---
# Current Status
⚠️ **MinerOps is under active development.**
The current focus is building a scalable, maintainable architecture before expanding user-facing functionality.
## Completed
- Docker-based development environment
- PostgreSQL + Redis backend
- Alembic migration framework
- Authentication
- Role-based access control
- Manual inventory schema
- Inventory domain architecture
- Automated migration testing
- Disposable development environment
## Currently In Progress
- Inventory API
- Inventory user interface
- Domain-driven architecture migration
## Planned
- 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
---
# 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
- Ready to Shelf
- Needs Follow-up
- Completed
- Failed
- Cancelled
- Attachments
- Maintenance mode
## Retention
---
Retention is currently scheduled in dry-run mode.
## 📊 Fleet Monitoring
Current intended policy:
Monitor operational health across the fleet.
- Raw status snapshots: 30 days
- Hourly rollups: 1 year or longer
- Daily rollups: long term
- Repair, location, and MAC history: permanent
Planned monitoring includes:
Verify /system-health before switching retention to apply mode.
- Hashrate
- Temperature
- Fan status
- Hashboard health
- PSU status
- Pool connectivity
- Worker information
- Firmware
- Alerts
## Common commands
---
Health check:
## 🌐 Network Discovery
pct exec 306 -- bash -lc "curl -s http://127.0.0.1:8000/api/health"
Automatically discover miners while maintaining stable asset identities.
Rebuild backend:
Planned capabilities include:
pct exec 306 -- bash -lc "cd /opt/minerops && docker compose build backend || docker-compose build backend"
pct exec 306 -- bash -lc "cd /opt/minerops && docker compose up -d --force-recreate backend || docker-compose up -d --force-recreate backend"
- Configurable scan ranges
- Duplicate detection
- MAC history
- IP history
- Capability detection
- Discovery review workflow
Check logs:
---
pct exec 306 -- bash -lc "docker logs minerops-backend --since 30m"
## 🎛 Secure Remote Control
Git status:
Safely execute commands against supported miners.
pct exec 306 -- bash -lc "cd /opt/minerops && git status --short"
Examples include:
## Safe local development
- Reboot
- Shutdown
- Performance mode
- Pool configuration
- Firmware operations
Development uses a separate Compose project, database volume, Redis volume, and
localhost-only backend port. It does not reuse the production `.env` file.
Scheduled scanning and real miner power/reboot controls are disabled.
All commands will be permission-aware, audited, and designed for safe execution.
First-time setup:
---
```bash
./scripts/dev-env-init.sh
docker compose --env-file .env.dev -f compose.dev.yaml up -d --build
## 🛰 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
```
Verify the environment:
Business rules belong in Services.
```bash
docker compose --env-file .env.dev -f compose.dev.yaml ps
curl --fail --silent http://127.0.0.1:18000/api/health
```
Database access belongs in Repositories.
Run the disposable clean-database baseline:
Schema changes are managed exclusively through Alembic migrations.
```bash
./scripts/test-dev-baseline.sh
```
This architecture keeps MinerOps modular, testable, and easy to extend.
The baseline uses a separate Compose project and port, verifies migrations,
schema, pages, retention rollups, and safety interlocks, then deletes only its
own temporary volumes.
---
Stop development services without deleting their data:
# Technology Stack
```bash
docker compose --env-file .env.dev -f compose.dev.yaml down
```
Current
The generated `.env.dev` file is mode `0600` and ignored by Git. To use a
different local port, change `MINEROPS_DEV_PORT` in `.env.dev`.
- Python
- FastAPI
- PostgreSQL
- Redis
- Alembic
- Docker Compose
- Pydantic
## Safety notes
Planned
- Do not commit .env.
- Do not commit backups, logs, database dumps, imports, or runtime data.
- Do not enable scanning or real miner controls in the development environment.
- Check /system-health after major changes.
- Commit and push after each stable working change.
- React
- TypeScript
- Site Agents
- Protocol adapters
- Background task processing
---
# Development Philosophy
MinerOps is built around a few guiding principles.
- 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
---
# Roadmap
| 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 |
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,81 @@
"""add inventory Asset ID history
Revision ID: 0009_inventory_asset_id_history
Revises: 0008_manual_miner_inventory
Create Date: 2026-07-28
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0009_inventory_asset_id_history"
down_revision: Union[str, None] = "0008_manual_miner_inventory"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"miner_asset_id_history",
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("old_asset_id", sa.Text(), nullable=True),
sa.Column("new_asset_id", sa.Text(), nullable=False),
sa.Column(
"changed_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"changed_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="RESTRICT"),
nullable=False,
),
sa.CheckConstraint(
"old_asset_id IS NULL OR "
"(old_asset_id = trim(old_asset_id) AND old_asset_id <> '')",
name="ck_miner_asset_id_history_old_normalized",
),
sa.CheckConstraint(
"new_asset_id = trim(new_asset_id) AND new_asset_id <> ''",
name="ck_miner_asset_id_history_new_normalized",
),
)
op.create_index(
"idx_miner_asset_id_history_miner_changed_at",
"miner_asset_id_history",
["miner_id", "changed_at", "id"],
)
op.create_index(
"idx_miner_asset_id_history_changed_by",
"miner_asset_id_history",
["changed_by"],
)
def downgrade() -> None:
op.drop_index(
"idx_miner_asset_id_history_changed_by",
table_name="miner_asset_id_history",
)
op.drop_index(
"idx_miner_asset_id_history_miner_changed_at",
table_name="miner_asset_id_history",
)
op.drop_table("miner_asset_id_history")
@@ -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)
+2
View File
@@ -8,6 +8,7 @@ from app.inventory.exceptions import (
)
from app.inventory.models import (
InventoryCreateRequest,
InventoryMetadataUpdateRequest,
InventoryMinerDetail,
InventoryMinerSummary,
InventorySearchFilters,
@@ -21,6 +22,7 @@ __all__ = [
"DuplicateAssetID",
"InventoryCreateRequest",
"InventoryError",
"InventoryMetadataUpdateRequest",
"InventoryMinerDetail",
"InventoryMinerSummary",
"InventoryRepository",
+8
View File
@@ -0,0 +1,8 @@
"""Dependency construction for the Inventory domain."""
from app.inventory.repository import InventoryRepository
from app.inventory.service import InventoryService
def get_inventory_service() -> InventoryService:
return InventoryService(InventoryRepository())
+108 -3
View File
@@ -4,10 +4,10 @@ from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field, IPvAnyAddress
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
@@ -43,6 +62,14 @@ class InventoryMinerDetail(InventoryMinerSummary):
first_seen_at: datetime | None = None
class InventoryAssetIDHistoryEntry(BaseModel):
old_asset_id: str | None = None
new_asset_id: str
changed_at: datetime
changed_by: UUID
changed_by_display_name: str
class InventoryCreateRequest(BaseModel):
asset_id: str = Field(min_length=1, max_length=120)
current_mac: str | None = Field(default=None, max_length=64)
@@ -60,6 +87,16 @@ class InventoryCreateRequest(BaseModel):
tags: list[str] = Field(default_factory=list)
class InventoryMetadataUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
asset_id: str | None = Field(default=None, max_length=120)
miner_sn: str | None = Field(default=None, max_length=255)
psu_serial: str | None = Field(default=None, max_length=255)
model: str | None = Field(default=None, max_length=255)
notes: str | None = None
class InventoryUpdateRequest(BaseModel):
asset_id: str | None = Field(default=None, max_length=120)
current_mac: str | None = Field(default=None, max_length=64)
@@ -81,6 +118,74 @@ 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):
data: list[InventoryMinerSummary]
meta: InventoryListMeta
error: None = None
class InventoryMinerDetailResponse(BaseModel):
data: InventoryMinerDetail
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
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
+390 -13
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",
@@ -145,8 +300,18 @@ class InventoryRepository:
connection.close()
@staticmethod
def _fetch_miner(cur, where_sql: str, params: Sequence[Any]) -> dict | None:
cur.execute(f"{MINER_SELECT} WHERE {where_sql}", params)
def _fetch_miner(
cur,
where_sql: str,
params: Sequence[Any],
*,
for_update: bool = False,
) -> dict | None:
lock_sql = " FOR UPDATE OF miners" if for_update else ""
cur.execute(
f"{MINER_SELECT} WHERE {where_sql}{lock_sql}",
params,
)
row = cur.fetchone()
return dict(row) if row else None
@@ -155,6 +320,27 @@ 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:
return self._fetch_miner(
cur,
"miners.id = %s",
(miner_id,),
for_update=True,
)
def get_miner_by_asset_id(self, asset_id: str) -> dict | None:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
@@ -164,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,
@@ -173,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}%"
@@ -197,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"""
@@ -221,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])
@@ -228,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
@@ -291,6 +614,60 @@ class InventoryRepository:
return None
return self._fetch_miner(cur, "miners.id = %s", (miner_id,))
def add_asset_id_history(
self,
miner_id: UUID,
old_asset_id: str | None,
new_asset_id: str,
changed_by: UUID,
) -> dict:
with self._connection_scope(write=True) as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO miner_asset_id_history (
miner_id,
old_asset_id,
new_asset_id,
changed_by
)
VALUES (%s, %s, %s, %s)
RETURNING
id,
miner_id,
old_asset_id,
new_asset_id,
changed_at,
changed_by
""",
(miner_id, old_asset_id, new_asset_id, changed_by),
)
return dict(cur.fetchone())
def list_asset_id_history(self, miner_id: UUID) -> list[dict]:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT
history.id,
history.miner_id,
history.old_asset_id,
history.new_asset_id,
history.changed_at,
history.changed_by,
changed_by_user.display_name
AS changed_by_display_name
FROM miner_asset_id_history AS history
JOIN users AS changed_by_user
ON changed_by_user.id = history.changed_by
WHERE history.miner_id = %s
ORDER BY history.changed_at, history.id
""",
(miner_id,),
)
return [dict(row) for row in cur.fetchall()]
def list_tags(self) -> list[dict]:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
+175
View File
@@ -0,0 +1,175 @@
"""Read-only HTTP routes for the Inventory domain."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.identity import require_permission, user_has_permission
from app.inventory.dependencies import get_inventory_service
from app.inventory.exceptions import (
DuplicateAssetID,
InventoryValidationError,
MinerNotFound,
)
from app.inventory.models import (
InventoryAssetIDHistoryResponse,
InventoryCreateRequest,
InventoryListMeta,
InventoryLocationOptions,
InventoryMetadataUpdateRequest,
InventoryMinerDetailResponse,
InventoryMinerListResponse,
InventorySearchFilters,
)
from app.inventory.service import InventoryService
router = APIRouter(prefix="/api/inventory/miners", tags=["inventory"])
require_inventory_read = require_permission("miners.view")
require_inventory_write = require_permission("miners.update")
InventoryServiceDependency = Annotated[
InventoryService,
Depends(get_inventory_service),
]
InventoryReadUser = Annotated[dict, Depends(require_inventory_read)]
InventoryWriteUser = Annotated[dict, Depends(require_inventory_write)]
InventoryQuery = Annotated[InventorySearchFilters, Query()]
@router.post(
"",
response_model=InventoryMinerDetailResponse,
status_code=status.HTTP_201_CREATED,
)
def create_inventory_miner(
request: InventoryCreateRequest,
service: InventoryServiceDependency,
current_user: InventoryWriteUser,
) -> InventoryMinerDetailResponse:
del current_user
try:
miner = service.create_miner(request)
except DuplicateAssetID as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except InventoryValidationError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return InventoryMinerDetailResponse(data=miner)
@router.patch("/{miner_id}", response_model=InventoryMinerDetailResponse)
def update_inventory_miner_metadata(
miner_id: UUID,
request: InventoryMetadataUpdateRequest,
service: InventoryServiceDependency,
current_user: InventoryWriteUser,
) -> InventoryMinerDetailResponse:
try:
miner = service.update_metadata(
miner_id,
request,
actor_user_id=UUID(str(current_user["id"])),
)
except MinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Miner not found",
) from exc
except DuplicateAssetID as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except InventoryValidationError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
return InventoryMinerDetailResponse(data=miner)
@router.get("", response_model=InventoryMinerListResponse)
def list_inventory_miners(
filters: InventoryQuery,
service: InventoryServiceDependency,
current_user: InventoryReadUser,
) -> InventoryMinerListResponse:
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,
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,
),
)
@router.get(
"/{miner_id}/asset-id-history",
response_model=InventoryAssetIDHistoryResponse,
)
def get_inventory_miner_asset_id_history(
miner_id: UUID,
service: InventoryServiceDependency,
current_user: InventoryReadUser,
) -> InventoryAssetIDHistoryResponse:
del current_user
try:
history = service.get_asset_id_history(miner_id)
except MinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Miner not found",
) from exc
return InventoryAssetIDHistoryResponse(data=history)
@router.get("/{miner_id}", response_model=InventoryMinerDetailResponse)
def get_inventory_miner(
miner_id: UUID,
service: InventoryServiceDependency,
current_user: InventoryReadUser,
) -> InventoryMinerDetailResponse:
del current_user
try:
miner = service.get_miner(miner_id)
except MinerNotFound as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Miner not found",
) from exc
return InventoryMinerDetailResponse(data=miner)
+167 -6
View File
@@ -12,7 +12,11 @@ from app.inventory.exceptions import (
MinerNotFound,
)
from app.inventory.models import (
InventoryAssetIDHistoryEntry,
InventoryCreateRequest,
InventoryFleetAttentionSummary,
InventoryFleetSummary,
InventoryMetadataUpdateRequest,
InventoryMinerDetail,
InventoryMinerSummary,
InventorySearchFilters,
@@ -22,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:
@@ -75,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"],
@@ -97,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"),
@@ -111,6 +137,24 @@ 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,
) -> list[InventoryAssetIDHistoryEntry]:
if not self.repository.get_miner_by_id(miner_id):
raise MinerNotFound(miner_id)
return [
InventoryAssetIDHistoryEntry.model_validate(row)
for row in self.repository.list_asset_id_history(miner_id)
]
def list_miners(
self,
*,
@@ -122,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)
@@ -143,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,
@@ -197,6 +276,85 @@ class InventoryService:
return self._detail_model(row)
def update_metadata(
self,
miner_id: UUID,
request: InventoryMetadataUpdateRequest,
*,
actor_user_id: UUID,
) -> InventoryMinerDetail:
fields_set = request.model_fields_set
if not fields_set:
raise InventoryValidationError(
"At least one metadata field must be provided"
)
requested_asset_id = None
if "asset_id" in fields_set:
if request.asset_id is None:
raise InventoryValidationError("Asset ID cannot be cleared")
requested_asset_id = self.normalize_asset_id(request.asset_id)
requested_values: dict[str, Any] = {}
field_map = {
"miner_sn": "miner_sn",
"psu_serial": "power_sn",
"model": "model",
"notes": "notes",
}
for model_field, database_column in field_map.items():
if model_field in fields_set:
requested_values[database_column] = self._clean_optional_text(
getattr(request, model_field)
)
try:
with self.repository.transaction() as repository:
existing = repository.get_miner_by_id_for_update(miner_id)
if not existing:
raise MinerNotFound(miner_id)
previous_asset_id = (
str(existing.get("asset_tag") or "").strip() or None
)
asset_id_changed = (
requested_asset_id is not None
and requested_asset_id != previous_asset_id
)
values = {
key: value
for key, value in requested_values.items()
if existing.get(key) != value
}
if asset_id_changed:
duplicate = repository.get_miner_by_asset_id(
requested_asset_id
)
if duplicate and duplicate["id"] != miner_id:
raise DuplicateAssetID(requested_asset_id)
values["asset_tag"] = requested_asset_id
if not values:
return self._detail_model(existing)
self._before_update(existing, values)
row = repository.update_miner(miner_id, values)
if asset_id_changed:
repository.add_asset_id_history(
miner_id,
previous_asset_id,
requested_asset_id,
actor_user_id,
)
row = repository.get_miner_by_id(miner_id)
self._after_update(row)
except UniqueViolation as exc:
asset_id = requested_asset_id or "unknown"
raise DuplicateAssetID(asset_id) from exc
return self._detail_model(row)
def update_miner(
self,
miner_id: UUID,
@@ -213,10 +371,13 @@ class InventoryService:
if request.asset_id is None:
raise InventoryValidationError("Asset ID cannot be cleared")
asset_id = self.normalize_asset_id(request.asset_id)
duplicate = self.repository.get_miner_by_asset_id(asset_id)
if duplicate and duplicate["id"] != miner_id:
raise DuplicateAssetID(asset_id)
values["asset_tag"] = asset_id
existing_asset_id = (
str(existing.get("asset_tag") or "").strip() or None
)
if asset_id != existing_asset_id:
raise InventoryValidationError(
"Asset ID changes require actor-attributed metadata update"
)
else:
asset_id = existing["asset_tag"]
+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
)
)
+225 -377
View File
@@ -1,26 +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)
@@ -1775,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()
@@ -1849,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()
@@ -15427,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(
@@ -15461,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(
@@ -16652,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()
@@ -29240,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")):
@@ -29836,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")):
@@ -31074,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")):
@@ -31897,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,
@@ -32039,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,),
)
@@ -32049,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 (
@@ -32131,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,),
)
@@ -32141,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
@@ -32286,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,),
)
@@ -32296,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
@@ -33102,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.");
@@ -33628,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
@@ -33983,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":
@@ -35667,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
],
)
+1
View File
@@ -11,3 +11,4 @@ email-validator==2.3.0
python-multipart==0.0.20
asyncpg
pycryptodome==3.23.0
httpx==0.28.1
@@ -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()
+422 -9
View File
@@ -1,19 +1,25 @@
import ast
import unittest
from copy import deepcopy
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID, uuid4
from psycopg.errors import ForeignKeyViolation, UniqueViolation
from app.inventory import (
DuplicateAssetID,
InventoryCreateRequest,
InventoryMetadataUpdateRequest,
InventoryRepository,
InventorySearchFilters,
InventoryService,
InventoryUpdateRequest,
InventoryValidationError,
MinerNotFound,
)
from app.inventory.models import InventoryAssetIDHistoryEntry
def inventory_row(**overrides):
@@ -39,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
@@ -48,14 +69,27 @@ class FakeInventoryRepository:
def __init__(self):
self.rows = {}
self.created_values = None
self.asset_id_history = []
self.update_error = None
self.search_kwargs = None
@contextmanager
def transaction(self):
yield self
rows_snapshot = deepcopy(self.rows)
history_snapshot = deepcopy(self.asset_id_history)
try:
yield self
except Exception:
self.rows = rows_snapshot
self.asset_id_history = history_snapshot
raise
def get_miner_by_id(self, miner_id):
return self.rows.get(miner_id)
def get_miner_by_id_for_update(self, miner_id):
return self.get_miner_by_id(miner_id)
def get_miner_by_asset_id(self, asset_id):
key = asset_id.strip().casefold()
return next(
@@ -71,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:
@@ -86,6 +121,8 @@ class FakeInventoryRepository:
return row
def update_miner(self, miner_id, values):
if self.update_error is not None:
raise self.update_error
row = self.rows.get(miner_id)
if not row:
return None
@@ -93,6 +130,28 @@ class FakeInventoryRepository:
row["updated_at"] = datetime.now(timezone.utc)
return row
def add_asset_id_history(
self, miner_id, old_asset_id, new_asset_id, changed_by
):
history = {
"id": uuid4(),
"miner_id": miner_id,
"old_asset_id": old_asset_id,
"new_asset_id": new_asset_id,
"changed_at": datetime.now(timezone.utc),
"changed_by": changed_by,
"changed_by_display_name": "Test Actor",
}
self.asset_id_history.append(history)
return history
def list_asset_id_history(self, miner_id):
return [
row
for row in self.asset_id_history
if row["miner_id"] == miner_id
]
def add_tag(self, miner_id, tag_name):
now = datetime.now(timezone.utc)
tag = {
@@ -121,6 +180,7 @@ class InventoryServiceTests(unittest.TestCase):
def setUp(self):
self.repository = FakeInventoryRepository()
self.service = InventoryService(self.repository)
self.actor_user_id = uuid4()
def test_create_normalizes_asset_id_tags_and_never_seen(self):
created = self.service.create_miner(
@@ -167,6 +227,177 @@ class InventoryServiceTests(unittest.TestCase):
with self.assertRaises(InventoryValidationError):
self.service.normalize_tags(["valid", "\t\n"])
def test_asset_id_history_empty_for_known_miner(self):
row = inventory_row()
self.repository.rows[row["id"]] = row
self.assertEqual(
self.service.get_asset_id_history(row["id"]),
[],
)
def test_asset_id_history_maps_populated_repository_results(self):
row = inventory_row()
self.repository.rows[row["id"]] = row
first_changed_at = datetime(2026, 7, 27, tzinfo=timezone.utc)
second_changed_at = datetime(2026, 7, 28, tzinfo=timezone.utc)
self.repository.asset_id_history = [
{
"id": uuid4(),
"miner_id": row["id"],
"old_asset_id": "ASSET-FIRST",
"new_asset_id": "ASSET-SECOND",
"changed_at": first_changed_at,
"changed_by": self.actor_user_id,
"changed_by_display_name": "Test Actor",
},
{
"id": uuid4(),
"miner_id": row["id"],
"old_asset_id": "ASSET-SECOND",
"new_asset_id": "ASSET-THIRD",
"changed_at": second_changed_at,
"changed_by": self.actor_user_id,
"changed_by_display_name": "Test Actor",
},
]
history = self.service.get_asset_id_history(row["id"])
self.assertEqual(len(history), 2)
self.assertTrue(
all(
isinstance(entry, InventoryAssetIDHistoryEntry)
for entry in history
)
)
self.assertEqual(
[entry.new_asset_id for entry in history],
["ASSET-SECOND", "ASSET-THIRD"],
)
self.assertEqual(history[0].changed_by_display_name, "Test Actor")
def test_asset_id_history_unknown_miner_raises_not_found(self):
with self.assertRaises(MinerNotFound):
self.service.get_asset_id_history(uuid4())
def test_metadata_update_preserves_unspecified_fields(self):
row = inventory_row(
asset_tag="ASSET-001",
miner_sn="MINER-OLD",
power_sn="PSU-OLD",
model="M50",
notes="Old notes",
)
self.repository.rows[row["id"]] = row
updated = self.service.update_metadata(
row["id"],
InventoryMetadataUpdateRequest(
model="M60",
notes=None,
),
actor_user_id=self.actor_user_id,
)
self.assertEqual(updated.id, row["id"])
self.assertEqual(updated.asset_id, "ASSET-001")
self.assertEqual(updated.miner_sn, "MINER-OLD")
self.assertEqual(updated.psu_serial, "PSU-OLD")
self.assertEqual(updated.model, "M60")
self.assertIsNone(updated.notes)
self.assertEqual(self.repository.asset_id_history, [])
def test_asset_id_change_preserves_uuid_and_creates_history(self):
row = inventory_row(asset_tag="ASSET-OLD")
self.repository.rows[row["id"]] = row
updated = self.service.update_metadata(
row["id"],
InventoryMetadataUpdateRequest(asset_id=" ASSET-NEW "),
actor_user_id=self.actor_user_id,
)
self.assertEqual(updated.id, row["id"])
self.assertEqual(updated.asset_id, "ASSET-NEW")
self.assertEqual(len(self.repository.asset_id_history), 1)
history = self.repository.asset_id_history[0]
self.assertEqual(history["miner_id"], row["id"])
self.assertEqual(history["old_asset_id"], "ASSET-OLD")
self.assertEqual(history["new_asset_id"], "ASSET-NEW")
self.assertEqual(history["changed_by"], self.actor_user_id)
def test_unchanged_normalized_asset_id_creates_no_history(self):
row = inventory_row(asset_tag="ASSET-001")
original_updated_at = row["updated_at"]
self.repository.rows[row["id"]] = row
updated = self.service.update_metadata(
row["id"],
InventoryMetadataUpdateRequest(asset_id=" ASSET-001 "),
actor_user_id=self.actor_user_id,
)
self.assertEqual(updated.asset_id, "ASSET-001")
self.assertEqual(updated.updated_at, original_updated_at)
self.assertEqual(self.repository.asset_id_history, [])
def test_duplicate_asset_id_leaves_miner_and_history_unchanged(self):
row = inventory_row(asset_tag="ASSET-OLD")
duplicate = inventory_row(asset_tag="ASSET-TAKEN")
self.repository.rows[row["id"]] = row
self.repository.rows[duplicate["id"]] = duplicate
with self.assertRaises(DuplicateAssetID):
self.service.update_metadata(
row["id"],
InventoryMetadataUpdateRequest(asset_id=" asset-taken "),
actor_user_id=self.actor_user_id,
)
self.assertEqual(
self.repository.rows[row["id"]]["asset_tag"],
"ASSET-OLD",
)
self.assertEqual(self.repository.asset_id_history, [])
def test_empty_or_invalid_metadata_update_does_not_write(self):
row = inventory_row(asset_tag="ASSET-OLD", model="M50")
self.repository.rows[row["id"]] = row
for request in (
InventoryMetadataUpdateRequest(),
InventoryMetadataUpdateRequest(asset_id=" ", model="M60"),
):
with self.subTest(request=request):
with self.assertRaises(InventoryValidationError):
self.service.update_metadata(
row["id"],
request,
actor_user_id=self.actor_user_id,
)
self.assertEqual(self.repository.rows[row["id"]]["model"], "M50")
self.assertEqual(self.repository.asset_id_history, [])
def test_uniqueness_race_becomes_duplicate_asset_id(self):
row = inventory_row(asset_tag="ASSET-OLD")
self.repository.rows[row["id"]] = row
self.repository.update_error = UniqueViolation("duplicate Asset ID")
with self.assertRaises(DuplicateAssetID):
self.service.update_metadata(
row["id"],
InventoryMetadataUpdateRequest(asset_id="ASSET-NEW"),
actor_user_id=self.actor_user_id,
)
self.assertEqual(
self.repository.rows[row["id"]]["asset_tag"],
"ASSET-OLD",
)
self.assertEqual(self.repository.asset_id_history, [])
def test_update_maps_product_names_without_repository_rules(self):
row = inventory_row(asset_tag="ASSET-OLD", power_sn="PSU-OLD")
self.repository.rows[row["id"]] = row
@@ -174,16 +405,82 @@ class InventoryServiceTests(unittest.TestCase):
updated = self.service.update_miner(
row["id"],
InventoryUpdateRequest(
asset_id=" ASSET-NEW ",
psu_serial=" PSU-NEW ",
lifecycle_status="retired",
),
)
self.assertEqual(updated.asset_id, "ASSET-NEW")
self.assertEqual(updated.asset_id, "ASSET-OLD")
self.assertEqual(updated.psu_serial, "PSU-NEW")
self.assertEqual(updated.lifecycle_status, "retired")
def test_legacy_update_rejects_asset_id_change_without_actor(self):
row = inventory_row(asset_tag="ASSET-OLD")
self.repository.rows[row["id"]] = row
with self.assertRaisesRegex(
InventoryValidationError,
"actor-attributed metadata update",
):
self.service.update_miner(
row["id"],
InventoryUpdateRequest(asset_id="ASSET-NEW"),
)
self.assertEqual(
self.repository.rows[row["id"]]["asset_tag"],
"ASSET-OLD",
)
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(
@@ -197,11 +494,30 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase):
cls.repository = InventoryRepository()
def setUp(self):
self.actor_user_id = UUID("00000000-0000-0000-0000-000000000951")
with self.repository.transaction() as repository:
with repository._connection.cursor() as cur:
cur.execute("DELETE FROM miner_asset_id_history")
cur.execute("DELETE FROM miner_tags")
cur.execute("DELETE FROM tags")
cur.execute("DELETE FROM miners")
cur.execute(
"""
INSERT INTO users (
id, username, display_name, password_hash, role_id
)
SELECT %s, %s, %s, %s, id
FROM roles
WHERE name = 'Admin'
ON CONFLICT (id) DO NOTHING
""",
(
self.actor_user_id,
"inventory-history-test",
"Inventory History Test",
"not-used",
),
)
def test_repository_crud_search_and_tags(self):
created = self.repository.create_miner(
@@ -234,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"},
@@ -262,6 +594,83 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase):
[],
)
def test_repeated_asset_id_changes_preserve_chronological_history(self):
service = InventoryService(self.repository)
created = service.create_miner(
InventoryCreateRequest(asset_id="ASSET-FIRST")
)
second = service.update_metadata(
created.id,
InventoryMetadataUpdateRequest(asset_id="ASSET-SECOND"),
actor_user_id=self.actor_user_id,
)
third = service.update_metadata(
created.id,
InventoryMetadataUpdateRequest(asset_id="ASSET-THIRD"),
actor_user_id=self.actor_user_id,
)
other = service.create_miner(
InventoryCreateRequest(asset_id="OTHER-FIRST")
)
service.update_metadata(
other.id,
InventoryMetadataUpdateRequest(asset_id="OTHER-SECOND"),
actor_user_id=self.actor_user_id,
)
self.assertEqual(created.id, second.id)
self.assertEqual(second.id, third.id)
history = self.repository.list_asset_id_history(created.id)
self.assertEqual(len(history), 2)
self.assertEqual(
[
(row["old_asset_id"], row["new_asset_id"])
for row in history
],
[
("ASSET-FIRST", "ASSET-SECOND"),
("ASSET-SECOND", "ASSET-THIRD"),
],
)
self.assertLessEqual(
history[0]["changed_at"],
history[1]["changed_at"],
)
self.assertTrue(
all(
row["miner_id"] == created.id
and row["changed_by_display_name"]
== "Inventory History Test"
for row in history
)
)
other_history = self.repository.list_asset_id_history(other.id)
self.assertEqual(len(other_history), 1)
self.assertEqual(other_history[0]["new_asset_id"], "OTHER-SECOND")
def test_history_failure_rolls_back_asset_id_update(self):
service = InventoryService(self.repository)
created = service.create_miner(
InventoryCreateRequest(asset_id="ASSET-ROLLBACK-OLD")
)
with self.assertRaises(ForeignKeyViolation):
service.update_metadata(
created.id,
InventoryMetadataUpdateRequest(
asset_id="ASSET-ROLLBACK-NEW"
),
actor_user_id=uuid4(),
)
persisted = self.repository.get_miner_by_id(created.id)
self.assertEqual(persisted["asset_tag"], "ASSET-ROLLBACK-OLD")
self.assertEqual(
self.repository.list_asset_id_history(created.id),
[],
)
def test_transaction_rolls_back_repository_writes(self):
with self.assertRaises(RuntimeError):
with self.repository.transaction() as repository:
@@ -281,11 +690,17 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase):
class InventoryArchitectureTests(unittest.TestCase):
def test_inventory_package_has_no_http_layer_or_router(self):
import app.inventory as inventory
def test_inventory_domain_layers_have_no_http_dependency(self):
inventory_path = Path(__file__).parents[1] / "app" / "inventory"
for source_path in inventory_path.glob("*.py"):
domain_files = (
"dependencies.py",
"exceptions.py",
"models.py",
"repository.py",
"service.py",
)
for filename in domain_files:
source_path = inventory_path / filename
tree = ast.parse(source_path.read_text(encoding="utf-8"))
imported_modules = []
for node in ast.walk(tree):
@@ -298,8 +713,6 @@ class InventoryArchitectureTests(unittest.TestCase):
source_path,
)
self.assertFalse(hasattr(inventory, "router"))
if __name__ == "__main__":
unittest.main()
+689
View File
@@ -0,0 +1,689 @@
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.inventory.dependencies import get_inventory_service
from app.inventory.exceptions import (
DuplicateAssetID,
InventoryValidationError,
MinerNotFound,
)
from app.inventory.models import (
InventoryAssetIDHistoryEntry,
InventoryCreateRequest,
InventoryMetadataUpdateRequest,
InventoryMinerDetail,
InventoryMinerSummary,
InventorySearchFilters,
)
from app.inventory.router import router
class StubInventoryService:
def __init__(self):
self.search_result: list[InventoryMinerSummary] = []
self.detail_result: InventoryMinerDetail | None = None
self.search_filters: InventorySearchFilters | None = None
self.detail_miner_id: UUID | None = None
self.search_error: InventoryValidationError | None = None
self.create_result: InventoryMinerDetail | None = None
self.create_error: Exception | None = None
self.create_request: InventoryCreateRequest | None = None
self.update_result: InventoryMinerDetail | None = None
self.update_error: Exception | None = None
self.update_miner_id: UUID | None = None
self.update_request: InventoryMetadataUpdateRequest | None = None
self.update_actor_user_id: UUID | None = None
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,
) -> InventoryMinerDetail:
self.create_request = request
if self.create_error is not None:
raise self.create_error
if self.create_result is None:
raise AssertionError("create_result was not configured")
return self.create_result
def update_metadata(
self,
miner_id: UUID,
request: InventoryMetadataUpdateRequest,
*,
actor_user_id: UUID,
) -> InventoryMinerDetail:
self.update_miner_id = miner_id
self.update_request = request
self.update_actor_user_id = actor_user_id
if self.update_error is not None:
raise self.update_error
if self.update_result is None:
raise AssertionError("update_result was not configured")
return self.update_result
def get_miner(self, miner_id: UUID) -> InventoryMinerDetail:
self.detail_miner_id = miner_id
if self.detail_result is None or self.detail_result.id != miner_id:
raise MinerNotFound(miner_id)
return self.detail_result
def get_asset_id_history(
self,
miner_id: UUID,
) -> list[InventoryAssetIDHistoryEntry]:
self.history_miner_id = miner_id
if self.history_error is not None:
raise self.history_error
return self.history_result
def miner_summary(miner_id: UUID | None = None) -> InventoryMinerSummary:
now = datetime.now(timezone.utc)
return InventoryMinerSummary(
id=miner_id or uuid4(),
asset_id="ASSET-001",
model="M50",
lifecycle_status="active",
runtime_status="unknown",
created_at=now,
updated_at=now,
)
def miner_detail(miner_id: UUID | None = None) -> InventoryMinerDetail:
values = miner_summary(miner_id).model_dump()
values["miner_sn"] = "MINER-001"
return InventoryMinerDetail(
**values,
control_board_sn="CB-001",
psu_serial="PSU-001",
)
def history_entry(
*,
old_asset_id: str | None,
new_asset_id: str,
changed_at: datetime,
) -> InventoryAssetIDHistoryEntry:
return InventoryAssetIDHistoryEntry(
old_asset_id=old_asset_id,
new_asset_id=new_asset_id,
changed_at=changed_at,
changed_by=UUID("00000000-0000-0000-0000-000000000951"),
changed_by_display_name="Inventory Test User",
)
class InventoryRouterTests(unittest.TestCase):
def setUp(self):
self.service = StubInventoryService()
self.app = FastAPI()
self.app.include_router(router)
self.app.dependency_overrides[get_inventory_service] = (
lambda: self.service
)
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions=("miners.view",)):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()),
"username": "reader",
"role": "Read Only",
"permissions": list(permissions),
}
def test_create_requires_authentication(self):
response = self.client.post(
"/api/inventory/miners",
json={"asset_id": "ASSET-NEW"},
)
self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Login required")
def test_create_requires_miners_update_permission(self):
self.authenticate(permissions=("miners.view",))
response = self.client.post(
"/api/inventory/miners",
json={"asset_id": "ASSET-NEW"},
)
self.assertEqual(response.status_code, 403)
self.assertIsNone(self.service.create_request)
def test_create_returns_new_miner_in_typed_envelope(self):
self.authenticate(permissions=("miners.update",))
created = miner_detail()
self.service.create_result = created
response = self.client.post(
"/api/inventory/miners",
json={
"asset_id": " ASSET-NEW ",
"lifecycle_status": "spare",
"site_id": str(uuid4()),
"tags": ["Priority", "Repair"],
},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()["data"]["id"], str(created.id))
self.assertEqual(response.json()["meta"], {})
self.assertIsNone(response.json()["error"])
request = self.service.create_request
self.assertIsNotNone(request)
self.assertEqual(request.asset_id, " ASSET-NEW ")
self.assertEqual(request.lifecycle_status, "spare")
self.assertEqual(request.tags, ["Priority", "Repair"])
def test_duplicate_asset_id_returns_conflict(self):
self.authenticate(permissions=("miners.update",))
self.service.create_error = DuplicateAssetID("ASSET-001")
response = self.client.post(
"/api/inventory/miners",
json={"asset_id": "ASSET-001"},
)
self.assertEqual(response.status_code, 409)
self.assertEqual(
response.json()["detail"],
"Asset ID already exists: ASSET-001",
)
def test_invalid_create_body_returns_validation_error(self):
self.authenticate(permissions=("miners.update",))
for body in (
{},
{"asset_id": "A" * 121},
{"asset_id": "ASSET-001", "site_id": "not-a-uuid"},
):
with self.subTest(body=body):
response = self.client.post(
"/api/inventory/miners",
json=body,
)
self.assertEqual(response.status_code, 422)
self.assertIsNone(self.service.create_request)
def test_create_maps_domain_validation_error(self):
self.authenticate(permissions=("miners.update",))
self.service.create_error = InventoryValidationError(
"Tag names cannot be blank"
)
response = self.client.post(
"/api/inventory/miners",
json={"asset_id": "ASSET-001", "tags": [" "]},
)
self.assertEqual(response.status_code, 422)
self.assertEqual(
response.json()["detail"],
"Tag names cannot be blank",
)
def test_patch_requires_authentication(self):
response = self.client.patch(
f"/api/inventory/miners/{uuid4()}",
json={"model": "M60"},
)
self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Login required")
def test_patch_requires_miners_update_permission(self):
self.authenticate(permissions=("miners.view",))
response = self.client.patch(
f"/api/inventory/miners/{uuid4()}",
json={"model": "M60"},
)
self.assertEqual(response.status_code, 403)
self.assertIsNone(self.service.update_request)
def test_patch_unknown_miner_returns_not_found(self):
self.authenticate(permissions=("miners.update",))
miner_id = uuid4()
self.service.update_error = MinerNotFound(miner_id)
response = self.client.patch(
f"/api/inventory/miners/{miner_id}",
json={"model": "M60"},
)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["detail"], "Miner not found")
def test_patch_malformed_uuid_returns_validation_error(self):
self.authenticate(permissions=("miners.update",))
response = self.client.patch(
"/api/inventory/miners/not-a-uuid",
json={"model": "M60"},
)
self.assertEqual(response.status_code, 422)
self.assertIsNone(self.service.update_request)
def test_empty_patch_maps_domain_validation_error(self):
self.authenticate(permissions=("miners.update",))
self.service.update_error = InventoryValidationError(
"At least one metadata field must be provided"
)
response = self.client.patch(
f"/api/inventory/miners/{uuid4()}",
json={},
)
self.assertEqual(response.status_code, 422)
self.assertEqual(
response.json()["detail"],
"At least one metadata field must be provided",
)
def test_patch_rejects_unknown_and_operational_fields(self):
self.authenticate(permissions=("miners.update",))
for body in (
{"unknown_field": "value"},
{"lifecycle_status": "retired"},
{"current_ip": "192.0.2.10"},
{"current_mac": "AA:BB:CC:DD:EE:FF"},
{"control_board_sn": "CB-NEW"},
{"site_id": str(uuid4())},
{"room_id": str(uuid4())},
{"tags": ["Priority"]},
{"firmware_version": "1.2.3"},
{"hostname": "miner-host"},
):
with self.subTest(body=body):
response = self.client.patch(
f"/api/inventory/miners/{uuid4()}",
json=body,
)
self.assertEqual(response.status_code, 422)
self.assertIsNone(self.service.update_request)
def test_valid_metadata_patch_returns_typed_envelope(self):
self.authenticate(permissions=("miners.update",))
updated = miner_detail()
self.service.update_result = updated
response = self.client.patch(
f"/api/inventory/miners/{updated.id}",
json={
"asset_id": " ASSET-NEW ",
"miner_sn": "MINER-NEW",
"psu_serial": None,
"model": "M60",
"notes": "Bench verified",
},
)
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["data"]["id"], str(updated.id))
self.assertEqual(body["meta"], {})
self.assertIsNone(body["error"])
self.assertEqual(self.service.update_miner_id, updated.id)
self.assertIsInstance(self.service.update_actor_user_id, UUID)
request = self.service.update_request
self.assertIsNotNone(request)
self.assertEqual(
request.model_fields_set,
{"asset_id", "miner_sn", "psu_serial", "model", "notes"},
)
self.assertIsNone(request.psu_serial)
def test_patch_duplicate_asset_id_returns_conflict(self):
self.authenticate(permissions=("miners.update",))
self.service.update_error = DuplicateAssetID("ASSET-TAKEN")
response = self.client.patch(
f"/api/inventory/miners/{uuid4()}",
json={"asset_id": "ASSET-TAKEN"},
)
self.assertEqual(response.status_code, 409)
self.assertEqual(
response.json()["detail"],
"Asset ID already exists: ASSET-TAKEN",
)
def test_list_requires_authentication(self):
response = self.client.get("/api/inventory/miners")
self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Login required")
def test_detail_requires_authentication(self):
response = self.client.get(f"/api/inventory/miners/{uuid4()}")
self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Login required")
def test_asset_id_history_requires_authentication(self):
response = self.client.get(
f"/api/inventory/miners/{uuid4()}/asset-id-history"
)
self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["detail"], "Login required")
def test_list_requires_miners_view_permission(self):
self.authenticate(permissions=())
response = self.client.get("/api/inventory/miners")
self.assertEqual(response.status_code, 403)
def test_asset_id_history_requires_miners_view_permission(self):
self.authenticate(permissions=())
response = self.client.get(
f"/api/inventory/miners/{uuid4()}/asset-id-history"
)
self.assertEqual(response.status_code, 403)
self.assertIsNone(self.service.history_miner_id)
def test_authenticated_list_returns_typed_envelope(self):
self.authenticate()
self.service.search_result = [miner_summary()]
response = self.client.get("/api/inventory/miners")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(len(body["data"]), 1)
self.assertEqual(body["data"][0]["asset_id"], "ASSET-001")
self.assertEqual(
body["meta"],
{"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()
response = self.client.get("/api/inventory/miners")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"], [])
self.assertEqual(response.json()["meta"]["returned"], 0)
def test_search_filters_are_passed_to_service(self):
self.authenticate()
response = self.client.get(
"/api/inventory/miners",
params=[
("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"),
("offset", "50"),
],
)
self.assertEqual(response.status_code, 200)
filters = self.service.search_filters
self.assertIsNotNone(filters)
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)
def test_invalid_lifecycle_filter_returns_client_error(self):
self.authenticate()
self.service.search_error = InventoryValidationError(
"Lifecycle status must be active, spare, or retired"
)
response = self.client.get(
"/api/inventory/miners",
params={"lifecycle_status": "broken"},
)
self.assertEqual(response.status_code, 422)
self.assertIn("Lifecycle status", response.json()["detail"])
def test_pagination_is_validated_and_bounded(self):
self.authenticate()
for params in (
{"limit": 0},
{"limit": 501},
{"offset": -1},
):
with self.subTest(params=params):
response = self.client.get(
"/api/inventory/miners",
params=params,
)
self.assertEqual(response.status_code, 422)
def test_existing_uuid_returns_inventory_detail(self):
self.authenticate()
expected = miner_detail()
self.service.detail_result = expected
response = self.client.get(
f"/api/inventory/miners/{expected.id}"
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["id"], str(expected.id))
self.assertEqual(response.json()["data"]["psu_serial"], "PSU-001")
self.assertEqual(self.service.detail_miner_id, expected.id)
def test_unknown_uuid_returns_not_found(self):
self.authenticate()
response = self.client.get(f"/api/inventory/miners/{uuid4()}")
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["detail"], "Miner not found")
def test_malformed_uuid_returns_client_error(self):
self.authenticate()
response = self.client.get("/api/inventory/miners/not-a-uuid")
self.assertEqual(response.status_code, 422)
def test_asset_id_history_malformed_uuid_returns_validation_error(self):
self.authenticate()
response = self.client.get(
"/api/inventory/miners/not-a-uuid/asset-id-history"
)
self.assertEqual(response.status_code, 422)
self.assertIsNone(self.service.history_miner_id)
def test_asset_id_history_unknown_miner_returns_not_found(self):
self.authenticate()
miner_id = uuid4()
self.service.history_error = MinerNotFound(miner_id)
response = self.client.get(
f"/api/inventory/miners/{miner_id}/asset-id-history"
)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["detail"], "Miner not found")
def test_asset_id_history_empty_collection_uses_typed_envelope(self):
self.authenticate()
miner_id = uuid4()
response = self.client.get(
f"/api/inventory/miners/{miner_id}/asset-id-history"
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.json(),
{"data": [], "meta": {}, "error": None},
)
self.assertEqual(self.service.history_miner_id, miner_id)
def test_asset_id_history_preserves_chronological_service_order(self):
self.authenticate()
miner_id = uuid4()
first = datetime(2026, 7, 27, 12, 0, tzinfo=timezone.utc)
second = datetime(2026, 7, 28, 12, 0, tzinfo=timezone.utc)
self.service.history_result = [
history_entry(
old_asset_id="ASSET-FIRST",
new_asset_id="ASSET-SECOND",
changed_at=first,
),
history_entry(
old_asset_id="ASSET-SECOND",
new_asset_id="ASSET-FIRST",
changed_at=second,
),
]
response = self.client.get(
f"/api/inventory/miners/{miner_id}/asset-id-history"
)
self.assertEqual(response.status_code, 200)
data = response.json()["data"]
self.assertEqual(
[entry["new_asset_id"] for entry in data],
["ASSET-SECOND", "ASSET-FIRST"],
)
self.assertEqual(
data[0]["changed_by"],
"00000000-0000-0000-0000-000000000951",
)
self.assertEqual(
data[0]["changed_by_display_name"],
"Inventory Test User",
)
self.assertEqual(response.json()["meta"], {})
self.assertIsNone(response.json()["error"])
def test_router_has_no_sql_or_database_driver_imports(self):
router_path = (
Path(__file__).parents[1] / "app" / "inventory" / "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 "):
self.assertNotIn(keyword, upper_source)
if __name__ == "__main__":
unittest.main()
+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
+111
View File
@@ -0,0 +1,111 @@
# MinerOps Brand Guidelines
## Brand Position
MinerOps is practical infrastructure for operators who need a clear, reliable
view of mining assets, repairs, locations, and controls. The identity should
feel precise, capable, and built for daily engineering work.
Primary tagline:
> The self-hosted operating system for cryptocurrency mining operations.
Secondary tagline:
> Know every miner. Track every repair. Control every rack.
## Branding Philosophy
- **Operational clarity:** Prefer direct language, strong hierarchy, and
purposeful visual elements.
- **Engineering confidence:** Use measured geometry, consistent spacing, and
restrained color.
- **Self-hosted independence:** Present MinerOps as dependable infrastructure
owned and controlled by its operator.
- **Human utility:** Keep the identity approachable and readable without
becoming playful or ornamental.
## Logo System
The MinerOps mark combines a geometric hexagon with an angular letter M. The
hexagon suggests infrastructure, modular systems, and engineered hardware. The
M creates immediate product recognition.
Available placeholders:
- `logo.svg`: Primary mark for general use.
- `logo-light.svg`: Navy and orange mark for light backgrounds.
- `logo-dark.svg`: White and orange mark for dark backgrounds.
- `favicon.svg`: Compact filled mark optimized for small sizes.
- `wordmark.svg`: Symbol paired with the MinerOps name.
Use the supplied artwork without changing its proportions, stroke weights, or
relative colors. Do not rotate, skew, outline, add effects, or place the mark
inside another shape. Do not use gradients.
## Clear Space
Maintain clear space around the logo equal to at least one quarter of the
mark's width. For the wordmark, use the hexagon's internal margin as the
minimum clear space on every side.
Recommended minimum sizes:
- Standalone logo: 24 px digital, 8 mm print.
- Wordmark: 120 px digital, 32 mm print.
- Favicon: 16 px digital.
## Wordmark Usage
Use the wordmark when the audience needs explicit product identification, such
as documentation covers, sign-in screens, repository graphics, and event
materials. Use the standalone mark where MinerOps is already named nearby or
space is limited.
Keep `Miner` in Miner Navy or White and `Ops` in Ops Orange. Do not alter the
capitalization or insert a space between the words.
## Typography
Recommended interface and brand typefaces:
1. **Inter:** Primary choice for user interfaces, documentation, and marketing
copy.
2. **IBM Plex Sans:** Alternative for a more technical editorial voice.
3. **IBM Plex Mono:** Recommended for asset IDs, IP addresses, serial numbers,
logs, and other machine-oriented data.
Use system sans-serif fallbacks when these fonts are unavailable. Favor medium
or semibold weights for headings and regular weight for body copy. Avoid
condensed, decorative, or rounded display type.
## Spacing
Use a 4 px base spacing unit for digital products. Preferred increments are
4, 8, 12, 16, 24, 32, 48, and 64 px. Align logo placement, typography, and
supporting elements to a consistent grid.
Brand compositions should have enough open space to feel calm and technical.
Avoid crowding the mark with borders, controls, or dense copy.
## Icon Usage
Use simple outline icons with consistent stroke weight and square optical
bounds. Icons should describe actions or system states, not serve as
decoration. Use filled icons sparingly for selected or critical states.
Prefer Miner Navy or Steel Gray for neutral icons, Ops Orange for active
emphasis, and semantic colors defined by the product design system for status
and alerts.
Do not force unrelated interface icons into the hexagonal logo enclosure. The
hexagon-and-M combination is reserved for the MinerOps brand.
## Tagline Usage
Use the primary tagline for formal product positioning and first introductions.
Use the secondary tagline for compact campaign copy, presentation endings, or
operator-focused material.
Taglines should appear as supporting copy and should not be locked permanently
to the logo artwork. Preserve the exact wording and punctuation shown above.
+40
View File
@@ -0,0 +1,40 @@
# MinerOps Color Palette
## Core Colors
| Color | Hex | Intended usage |
| --- | --- | --- |
| Miner Navy | `#0F172A` | Primary brand color, headings, navigation, dark surfaces, and the main logo structure |
| Ops Orange | `#F97316` | Active emphasis, calls to action, selected states, and the M or Ops accent |
| Steel Gray | `#475569` | Secondary text, neutral icons, borders, metadata, and supporting UI elements |
| Light Gray | `#E2E8F0` | Dividers, subtle borders, disabled surfaces, and light neutral backgrounds |
| White | `#FFFFFF` | Content surfaces, reversed text, and logo details on dark backgrounds |
## Usage Principles
- Use Miner Navy as the primary visual anchor.
- Reserve Ops Orange for meaningful emphasis rather than large background
fields.
- Use Steel Gray for supporting information that should remain readable without
competing with primary content.
- Use Light Gray to organize interfaces with subtle separation.
- Use White to preserve clarity and contrast on dark or dense surfaces.
- Do not introduce gradients into core brand artwork.
- Check text and control combinations for accessible contrast before production
use.
## Common Combinations
| Context | Background | Foreground or accent |
| --- | --- | --- |
| Standard light surface | White | Miner Navy with Ops Orange emphasis |
| Subtle light surface | Light Gray | Miner Navy or Steel Gray |
| Primary dark surface | Miner Navy | White with Ops Orange emphasis |
| Neutral metadata | White or Light Gray | Steel Gray |
| Active control | Ops Orange | Miner Navy or White after contrast review |
## Color Proportions
For general brand compositions, use neutral surfaces and Miner Navy for most of
the visual area. Ops Orange should typically occupy the smallest share and
signal action, focus, or product identity.
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
<title id="title">MinerOps favicon</title>
<path fill="#0F172A" d="M32 2 59 17.5v29L32 62 5 46.5v-29L32 2Z"/>
<path fill="none" stroke="#F97316" stroke-linecap="square" stroke-linejoin="miter" stroke-width="7" d="M18 44V21l14 14 14-14v23"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
<title id="title">MinerOps logo for dark backgrounds</title>
<path fill="#FFFFFF" d="M32 3 57 17.5v29L32 61 7 46.5v-29L32 3Zm0 6.9L13 20.8v22.4l19 10.9 19-10.9V20.8L32 9.9Z"/>
<path fill="none" stroke="#F97316" stroke-linecap="square" stroke-linejoin="miter" stroke-width="6" d="M19 43V22l13 13 13-13v21"/>
</svg>

After

Width:  |  Height:  |  Size: 416 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
<title id="title">MinerOps logo for light backgrounds</title>
<path fill="none" stroke="#0F172A" stroke-linejoin="miter" stroke-width="6" d="M32 6.5 54 19v26L32 57.5 10 45V19L32 6.5Z"/>
<path fill="none" stroke="#F97316" stroke-linecap="square" stroke-linejoin="miter" stroke-width="6" d="M19 43V22l13 13 13-13v21"/>
</svg>

After

Width:  |  Height:  |  Size: 426 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
<title id="title">MinerOps logo</title>
<path fill="#0F172A" d="M32 3 57 17.5v29L32 61 7 46.5v-29L32 3Zm0 6.9L13 20.8v22.4l19 10.9 19-10.9V20.8L32 9.9Z"/>
<path fill="none" stroke="#F97316" stroke-linecap="square" stroke-linejoin="miter" stroke-width="6" d="M19 43V22l13 13 13-13v21"/>
</svg>

After

Width:  |  Height:  |  Size: 395 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 72" role="img" aria-labelledby="title">
<title id="title">MinerOps wordmark</title>
<g transform="translate(4 4) scale(.95)">
<path fill="#0F172A" d="M32 3 57 17.5v29L32 61 7 46.5v-29L32 3Zm0 6.9L13 20.8v22.4l19 10.9 19-10.9V20.8L32 9.9Z"/>
<path fill="none" stroke="#F97316" stroke-linecap="square" stroke-linejoin="miter" stroke-width="6" d="M19 43V22l13 13 13-13v21"/>
</g>
<text x="76" y="49" fill="#0F172A" font-family="Inter, IBM Plex Sans, Arial, sans-serif" font-size="42" font-weight="700">Miner</text>
<text x="188" y="49" fill="#F97316" font-family="Inter, IBM Plex Sans, Arial, sans-serif" font-size="42" font-weight="700">Ops</text>
</svg>

After

Width:  |  Height:  |  Size: 728 B

+457
View File
@@ -0,0 +1,457 @@
# MinerOps Architecture
## Purpose
This document is the primary engineering architecture guide for MinerOps. It
explains the codebase's current structure and the rules contributors must follow
when adding or changing features.
MinerOps is self-hosted software with operational data and workflows that must
remain understandable as the system grows. Maintainability, testability, and
clear boundaries are primary architectural goals. A feature is not complete
merely because it works; its responsibilities and dependencies must also be in
the correct layer.
**Current implementation:** The repository is transitional. Most existing HTTP
routes, persistence, and server-rendered assets are concentrated in
`backend/app/main.py`. Identity has been separated into a FastAPI router.
Inventory is the first domain with explicit models, exceptions, a service, a
repository, and focused tests.
**Engineering rule:** New domain code follows the layered architecture in this
document. Existing integrated code is compatibility context, not a pattern to
copy.
## Core Principles
### Separation of Concerns
Transport, business rules, persistence, and presentation are different
responsibilities. Keep them separate so each can change and be tested without
requiring unrelated changes.
### Single Responsibility
Each module, class, and function should have one clear reason to change. A
router handles HTTP. A service handles domain behavior. A repository handles
persistence. Combining these responsibilities makes ownership ambiguous and
tests expensive.
### Thin HTTP Layer
Routers accept and validate transport input, invoke services, and map results to
HTTP responses. They do not implement business workflows and do not access the
database directly.
### Business Logic Belongs in Services
Services own normalization, domain validation, decisions, and orchestration.
They must not depend on FastAPI request or response objects, route decorators,
or HTTP status codes.
### Persistence Belongs in Repositories
Repositories own queries, writes, database-facing filters, transaction
mechanics, and persistence mapping. They contain no HTTP behavior, permission
rules, or product decisions.
### Explicit Dependencies
Dependencies are visible at construction or call boundaries. The current
`InventoryService` receives its repository through its constructor, allowing a
substitute repository in service tests and a PostgreSQL repository in
integration tests.
### Incremental Evolution
MinerOps has working behavior that must remain usable while responsibilities
are separated. Improve boundaries in small, verified changes. Do not require a
large rewrite before a domain can become clearer.
### Testability
Architecture should make the correct test easy to write. Business rules should
be testable without FastAPI or PostgreSQL. Repository behavior should be
testable against a disposable PostgreSQL database. Cross-application behavior
should be testable through the disposable development stack.
## High-Level Architecture
The required dependency flow for domain-backed application features is:
```text
Browser
|
v
FastAPI Routers
|
v
Services
|
v
Repositories
|
v
PostgreSQL
```
- **Browser:** Presents the interface and sends authenticated HTTP requests. The
current interface is served by FastAPI using HTML, CSS, and JavaScript.
- **FastAPI routers:** Define the HTTP contract, enforce transport-level
validation and authorization dependencies, call services, and serialize
responses.
- **Services:** Implement use cases and domain rules without knowing how the
request arrived.
- **Repositories:** Perform database operations and expose persistence behavior
needed by services.
- **PostgreSQL:** Stores authoritative operational records and enforces durable
constraints.
Redis and Nginx are current infrastructure components, but they do not change
this domain dependency flow. Redis is configured as an application service and
included in health checks. Nginx is the gateway in the development Compose
stack.
## Repository Organization
The repository is organized by runtime responsibility and supporting material:
- **`backend/`:** Python application code, FastAPI composition, domain packages,
Alembic migrations, backend dependencies, tests, and the backend container
definition.
- **`frontend/` (planned):** A future standalone frontend may live here after a
frontend technology and migration plan are selected. No `frontend/` directory
or standalone frontend application exists today.
- **`docs/`:** Engineering and product documentation that applies across
domains.
- **`branding/`:** Product identity guidelines and source vector assets. It has
no runtime application responsibility.
- **`scripts/`:** Development setup, disposable test runners, schema checks,
retention helpers, and operational utilities.
Root Compose files define containerized services for the backend, PostgreSQL,
and Redis. The development configuration also defines an Nginx gateway and runs
Alembic before starting Uvicorn.
## Backend Layers
### Routers
Routers are the HTTP boundary. A domain router should be an `APIRouter` included
by the application entry point.
Router responsibilities:
- Declare paths, HTTP methods, parameters, and response status codes.
- Parse path, query, header, and body values.
- Apply authentication and permission dependencies.
- Construct domain input models from transport input.
- Call services.
- Translate domain exceptions into consistent HTTP errors.
- Serialize service results into documented response models.
Routers must never contain:
- SQL or database connection management.
- Business rules, normalization policy, or lifecycle decisions.
- Multi-step persistence workflows or transaction control.
- Device protocol implementations.
- Copies of logic already owned by a service or repository.
A router may reject structurally invalid HTTP input. It must delegate questions
such as whether an Asset ID is valid for a use case, whether a lifecycle change
is allowed, or whether a record conflicts with existing state to the service.
**Current implementation:** `backend/app/main.py` creates the `FastAPI`
application and includes the router from `backend/app/identity.py`. Identity is
the current example of router extraction. Most other routes remain registered
directly on `app` and often contain SQL or business logic. Those routes predate
the current layering rules and must not be used as templates for new domains.
The Inventory package currently has no router and registers no HTTP endpoints.
### Services
Services implement application use cases for a domain. A service coordinates
repositories and applies rules that must remain consistent regardless of the
transport calling it.
Service responsibilities:
- Normalize values according to domain semantics.
- Validate business rules and allowed state transitions.
- Detect conflicts meaningful to the domain.
- Coordinate multiple repository operations as one use case.
- Establish required transaction scope through repository abstractions.
- Return domain models or domain-specific results.
- Raise domain exceptions rather than HTTP exceptions.
The current `InventoryService` demonstrates these responsibilities. It:
- Normalizes Asset IDs and optional text.
- Normalizes and deduplicates tags.
- Validates `active`, `spare`, and `retired` lifecycle states.
- Detects duplicate Asset IDs.
- Defines never-seen timestamps for manually created miners.
- Coordinates miner creation or update with tag associations.
- Maps database-shaped records to Inventory summary and detail models.
The service imports no FastAPI components. Its extension hooks are domain-level
hooks, not route callbacks. A service must not inspect cookies, headers, request
objects, or response objects.
The current Inventory service catches a PostgreSQL uniqueness exception to
translate a race into `DuplicateAssetID`. This is a known persistence coupling.
New code should prevent database-specific exceptions from spreading further
through the service layer, preferably by translating them at an explicit
repository boundary.
### Repositories
Repositories are the persistence boundary for a domain. They expose operations
in terms useful to services while keeping SQL and database mechanics out of
higher layers.
Repository responsibilities:
- Execute parameterized reads and writes.
- Implement database-facing search, filtering, ordering, and pagination.
- Manage connections, commits, rollbacks, and transaction-bound instances.
- Maintain associations owned by the domain.
- Map database values into persistence records.
- Translate low-level persistence failures when an owned abstraction exists.
Repositories contain database access only. They must not:
- Import FastAPI or router modules.
- Choose HTTP responses or status codes.
- Check the current user's permissions.
- Normalize values according to product policy.
- Decide whether a lifecycle transition or operational action is allowed.
`InventoryRepository` is the current reference implementation. It uses
`psycopg`, accepts an optional connection factory, supports explicit transaction
scope, and owns miner and tag CRUD, search, and associations. Its methods return
database-shaped dictionaries; `InventoryService` maps those records to domain
models.
Dynamic SQL must be restricted to trusted identifiers or fixed fragments.
User-provided values always use database parameters. Repositories must not accept
raw SQL from routers or services.
### Database
PostgreSQL is the authoritative persistent store for MinerOps. Application rules
that must hold for every writer are also represented as database constraints
where practical, including uniqueness, allowed values, and referential
integrity.
Alembic is the authority for new persistent schema changes. Migrations must
state how existing data is handled and provide a downgrade when it can be done
without ambiguity or data loss.
**Current implementation:** Alembic migrations cover the foundational,
scanning, audit, identity, role, and Inventory schemas. Some legacy code in
`main.py` still creates or alters tables at runtime for compatibility. Do not add
new runtime DDL. Move persistent schema changes through reviewed Alembic
migrations.
The repository also contains a SQLAlchemy engine and session factory, while
most current operational persistence and the Inventory repository use direct
`psycopg`; some existing agent paths use `asyncpg`. A domain must choose and
encapsulate its persistence mechanism inside its repository rather than expose
driver details to routers.
## Domains
A domain package groups one operational capability behind an explicit public
interface. Inventory is the current reference domain:
```text
backend/app/inventory/
__init__.py
exceptions.py
models.py
repository.py
service.py
```
- `models.py` defines typed request, update, search, summary, detail, and tag
contracts.
- `exceptions.py` defines errors meaningful to Inventory callers.
- `service.py` defines Inventory rules and use cases.
- `repository.py` defines Inventory persistence.
- `__init__.py` exposes the package's intended public surface.
Inventory's internal asset identity is the miner UUID. Product-facing Asset IDs
are normalized and checked independently of mutable network fields. The domain
maps product terminology such as `asset_id` and `psu_serial` to existing database
columns at its service and persistence boundary.
Future domain packages should follow the same separation when it fits their use
cases. Identity, Repairs, Monitoring, Operations, Scheduling, and Locations are
examples of possible domain boundaries, not a fixed package list. A domain
boundary must be based on ownership of rules and data, not on menu pages or
route prefixes alone.
A domain may call another domain only through an explicit service contract. It
must not import another domain's repository to update tables it does not own.
Shared read requirements should use a clearly owned query service or read model
rather than duplicating cross-domain SQL.
## Dependency Rules
Dependencies flow downward:
```text
Routers
|
v
Services
|
v
Repositories
|
v
Database
```
Allowed dependencies:
- The application entry point may include routers and install application-wide
middleware.
- Routers may depend on domain models, services, and exceptions.
- Services may depend on their domain's models, exceptions, and explicit
repository contracts.
- Repositories may depend on configuration, database drivers, and
persistence-specific utilities.
- Tests may replace repositories or connection factories at defined dependency
boundaries.
Examples of allowed flow:
- An Inventory router validates a UUID, calls `InventoryService.get_miner`, and
translates `MinerNotFound` into an HTTP 404 response.
- `InventoryService.create_miner` normalizes an Asset ID and coordinates miner
and tag writes through an `InventoryRepository` transaction.
- `InventoryRepository.search_miners` builds parameterized filters and executes
them against PostgreSQL.
Prohibited dependencies:
- Repositories importing services, routers, or `app.main`.
- Services importing FastAPI request or response classes.
- Routers opening database connections or issuing SQL.
- Domain code importing globals from `app.main`.
- A lower layer choosing permissions or HTTP status codes.
- A domain bypassing another domain's service to write its tables.
- Circular imports between domains or layers.
Examples of prohibited flow:
- A route updating `miners` directly after checking request JSON.
- A service returning `JSONResponse` or raising `HTTPException`.
- A repository checking whether the current user is an administrator.
- A Repairs service importing `InventoryRepository` to mutate miner ownership
without an Inventory service contract.
Dependency direction is an architectural constraint, not a naming convention.
Putting SQL in a file named `service.py` does not make it service logic.
## Database Philosophy
- **Alembic-managed schema:** New persistent tables, columns, constraints, and
indexes are introduced through versioned migrations.
- **Explicit migrations:** Every migration explains existing-data behavior,
upgrade ordering, and downgrade behavior. Migrations do not silently merge,
rename, or discard operational records.
- **Stable identifiers:** Internal identity uses immutable identifiers rather
than mutable IP addresses, MAC addresses, names, or locations.
- **Repository ownership:** Each domain has one clear persistence boundary for
its owned records. Other layers do not duplicate its SQL.
- **Database enforcement:** PostgreSQL constraints protect invariants that must
hold independently of application code.
- **Transactional consistency:** Operations that must succeed or fail together
execute in one explicit transaction.
- **Data preservation:** Schema evolution preserves existing records unless a
separately reviewed product decision explicitly requires otherwise.
Migration tests should verify upgrade, downgrade, constraints, and existing-data
behavior. Runtime schema changes are legacy compatibility behavior and are not
permitted for new work.
## API Philosophy
MinerOps APIs use resource-oriented HTTP semantics. New or replaced domain APIs
follow these conventions:
- Use nouns for resources and standard HTTP methods for operations.
- Validate path, query, and body structure at the router boundary.
- Apply business validation in the service.
- Use Pydantic models for request and response contracts.
- Return predictable status codes and response shapes.
- Translate domain exceptions into consistent, non-leaking API errors.
- Bound collection endpoints with pagination.
- Make supported filtering, searching, and ordering explicit.
- Preserve stable identifiers in URLs and response contracts.
- Enforce authentication and permissions on the server.
- Avoid duplicate endpoints for the same use case.
**Current implementation:** Many routes return the common `data`, `meta`, and
`error` envelope defined in `main.py`. Pydantic validation, pagination, and
error shapes are not uniform across all existing routes. Identity supplies
current authentication and role-aware dependencies. New endpoints should follow
the conventions above while preserving compatibility for any route they
replace.
Pagination metadata should be sufficient for callers to understand the returned
window. Filters must be parameterized in repositories, not interpolated in
routers. Database exceptions and internal details must not be exposed to API
clients.
## Testing Philosophy
Tests follow the same boundaries as production code.
- **Unit tests:** Exercise service rules with substituted dependencies. They
should not require FastAPI or a database when the behavior is purely domain
logic.
- **Repository tests:** Exercise real queries, constraints, associations, and
transactions against a disposable PostgreSQL database.
- **Migration tests:** Verify schema upgrade, downgrade, constraint behavior,
and existing-data preservation.
- **Integration tests:** Exercise application startup, migrations,
authentication, authorization, safety behavior, and cross-layer workflows in
a disposable Compose stack.
- **Architecture tests:** Protect dependency rules that can be checked
mechanically, such as the Inventory package containing no FastAPI imports or
router registration.
Current Inventory tests use a fake repository for service rules and PostgreSQL
for repository behavior. The focused runner migrates a disposable database
through Alembic before executing the suite. The development baseline starts an
isolated Compose project and checks schema, login gating, roles, and safety
interlocks.
Before committing, run validation appropriate to the change. This commonly
includes Python compilation, shell syntax checks, focused unit or repository
tests, migration tests for schema work, the disposable development baseline for
cross-application changes, and `git diff --check`.
Keep commits small and reviewable. A commit should express one coherent change,
include focused tests when behavior changes, and avoid unrelated formatting or
refactoring. Broad integration coverage does not replace a focused test for a
new rule.
## Future Evolution
MinerOps will evolve incrementally as existing responsibilities are moved into
clear domain boundaries. New infrastructure or domain packages must preserve
the router-to-service-to-repository dependency direction, explicit ownership,
and testability rules in this document. Architectural changes should clarify
those rules rather than bypass them.
## When In Doubt
When a feature does not fit naturally into an existing module, refine the
architecture instead of weakening architectural boundaries. Clear boundaries
are easier to maintain than clever shortcuts.
+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.
+372
View File
@@ -0,0 +1,372 @@
# MinerOps Architecture & Product Decisions
This document records long-lived engineering and product decisions for
MinerOps. Each record explains the context, the chosen approach, and its
consequences so future work can preserve intent rather than infer it from code.
Decisions may evolve as operational evidence changes. Replacing or reversing a
decision should be deliberate and documented in a later record. Existing
records should remain available as history rather than being silently rewritten.
---
# ADR-0001
**Title:** MinerOps is self-hosted first.
## Context
Mining operations depend on local equipment, segmented networks, and
operational records that may need to remain under direct organizational control.
Requiring a hosted service would make availability, data access, and subscription
terms dependencies of core operations.
External services can still provide useful integrations. The question is
whether they are optional participants or mandatory owners of the operational
record.
## Decision
MinerOps is self-hosted first. Operators control the deployment and retain
ownership of their operational data. Core use must not require a mandatory SaaS
subscription or transfer the authoritative record to a hosted provider.
Remote access and integrations are allowed when the operator chooses them. They
must not change the self-hosted deployment into a secondary cache of a required
external service.
## Consequences
**Benefits:** Operators can place MinerOps near local mining networks, retain
control of data and availability, and use it in restricted environments.
**Tradeoffs:** Operators are responsible for deployment, updates, backups, and
recovery. Optional hosted integrations must be designed so loss of the
integration does not remove ownership of the core record.
---
# ADR-0002
**Title:** Assets use durable internal identity.
## Context
A miner's observable properties change during normal operations. IP addresses
change, network interfaces and control boards are replaced, equipment moves,
and operator-facing labels may be corrected. None of those changes should make
the asset appear to be a different historical object by default.
Operators also need a practical label that can be printed, scanned, and used in
conversation without exposing an internal database key.
## Decision
Every miner asset has an immutable internal UUID. That UUID is the durable
identity used for relationships and history.
The Asset ID is the operator-facing identifier. It is unique within a MinerOps
installation and can be managed independently of mutable network values. IP
addresses, MAC addresses, hardware serials, and location are attributes or
associations of the asset, not substitutes for its internal identity.
Hardware replacement does not automatically create a new asset identity. The
correct outcome depends on whether the operation represents repair of the same
asset or introduction of a genuinely different asset, and that distinction must
be explicit.
## Consequences
**Benefits:** Records can remain connected through network changes, movement,
and component replacement. Internal relationships do not depend on a mutable or
human-entered value.
**Tradeoffs:** Imports, discovery, and repair workflows must resolve observations
to an existing UUID or deliberately create a new asset. Duplicate resolution
cannot safely rely on IP or MAC alone.
---
# ADR-0003
**Title:** Network identity is not asset identity.
## Context
Discovery observes devices through IP addresses, MAC addresses, hostnames, and
protocol responses. These values are useful for finding and communicating with
a miner, but they can change because of DHCP, network reconfiguration, control
board replacement, or data correction.
Treating the latest network observation as the asset identity loses continuity
and can attach history to the wrong equipment.
## Decision
Network identities are associated with durable assets. Current IP and MAC values
represent the latest known network state, while meaningful prior associations
should remain available as history.
Discovery proposes or updates associations; it does not redefine the asset
solely because an address changed. Ambiguous matches require a deliberate
identity workflow rather than silent merging or replacement.
## Consequences
**Benefits:** Discovery can remain useful without erasing asset continuity.
Operators can understand address changes and component replacement in historical
context.
**Tradeoffs:** Matching requires more information and explicit conflict handling.
Some observations cannot be attached automatically, and network history adds
records that must be maintained and presented clearly.
---
# ADR-0004
**Title:** Business logic belongs in services.
## Context
When business rules are implemented in HTTP handlers, they become coupled to a
transport and are difficult to reuse or test. When they are placed in database
code, persistence concerns become mixed with product decisions.
MinerOps has existing integrated routes that predate the current domain
boundaries. Copying that structure would continue the coupling.
## Decision
Domain services own normalization, business validation, decisions, state-change
rules, and use-case orchestration. Routers handle HTTP concerns and call
services. Repositories persist the outcomes requested by services.
Services raise domain errors and return domain results. They do not depend on
FastAPI requests, responses, decorators, or status codes. The Inventory service
is the current reference for this separation.
## Consequences
**Benefits:** Business behavior can be tested without HTTP or a real database,
used by more than one adapter, and maintained in one explicit location.
**Tradeoffs:** Features require an additional boundary and clear input and output
contracts. Poorly chosen service responsibilities can become overly broad, so
service ownership must follow domain use cases rather than convenience.
---
# ADR-0005
**Title:** Persistence is isolated behind repositories.
## Context
SQL embedded in routes or services couples product behavior to schema details,
connection handling, and a specific database driver. Repeated queries also make
ownership unclear and allow filtering or transaction behavior to diverge.
Tests need a boundary where service behavior can substitute persistence and
where database behavior can be verified independently.
## Decision
Each domain isolates its database access behind repositories. Repositories own
SQL, database-facing filtering and ordering, connection and transaction
mechanics, and persistence mapping.
Routers and services do not issue SQL. A domain owns persistence for its records,
and another domain does not bypass that ownership by importing its repository
to perform unrelated writes.
## Consequences
**Benefits:** SQL has explicit ownership, service tests can use substitutes,
repository tests can target real database behavior, and persistence changes have
a defined boundary.
**Tradeoffs:** Repository interfaces must be maintained, and cross-domain read
requirements need deliberate query ownership. A repository is not an excuse to
hide business rules in database code.
---
# ADR-0006
**Title:** Database schema changes use Alembic.
## Context
Persistent schema must be reproducible across new installations, existing
deployments, tests, and recovery environments. Runtime table creation makes
schema state depend on which application paths have executed and is difficult
to review or reverse.
The existing application contains legacy runtime schema creation for
compatibility, but the repository now has a versioned Alembic migration chain
and focused migration tests.
## Decision
New persistent schema changes are made through explicit, versioned Alembic
migrations. A migration documents upgrade behavior, treatment of existing data,
constraints and indexes, and a downgrade path where it is safe and meaningful.
New code must not create or alter persistent tables at application runtime.
Legacy runtime schema behavior is an existing compatibility condition, not a
pattern for new work.
## Consequences
**Benefits:** Schema state is reviewable, reproducible, testable, and ordered.
Deployments and disposable tests can reach the same known schema from an empty
or existing database.
**Tradeoffs:** Schema work requires migration design and validation before the
application can use it. Some destructive downgrades cannot preserve all data and
must be documented explicitly.
---
# ADR-0007
**Title:** Operational history is preserved.
## Context
The current value of an asset does not explain how it arrived there. Repairs,
movement, lifecycle changes, network identity changes, component replacement,
and operator actions all affect operational understanding.
Overwriting important changes may simplify the current record but removes the
context technicians and managers need to diagnose problems and trust the
system.
## Decision
MinerOps preserves meaningful operational history. Current state remains easy
to identify, while important repairs, movement, lifecycle events, identity
associations, and audited actions remain traceable with time and context.
Corrections must not silently destroy history that remains operationally
relevant. Retention policy may summarize high-volume observations, but durable
asset, repair, movement, lifecycle, and audit meaning is preserved.
## Consequences
**Benefits:** Operators can understand an asset across its lifecycle, technicians
can review prior work, and changes can be audited and explained.
**Tradeoffs:** Historical data requires storage, retention policy, and interfaces
that distinguish current state from prior state. Corrections and privacy or
retention requirements need deliberate handling.
---
# ADR-0008
**Title:** Safety takes precedence over convenience.
## Context
MinerOps can coordinate actions that interrupt individual miners or affect a
large part of a fleet. A fast or convenient action can create substantial
operational impact when its target, scope, timing, or authorization is wrong.
Bulk operations and automation increase this risk because one request may affect
many devices without individual review.
## Decision
Potentially disruptive actions favor safety over minimum interaction. Product
workflows enforce permissions, communicate target and scope, require clear
confirmation where appropriate, and leave an audit trail.
Broad actions should support safeguards such as preview or dry-run behavior,
staggering, limits, expiry, and awareness of maintenance or intentionally
offline state when those safeguards apply. Development and new installations
use safe defaults.
## Consequences
**Benefits:** Operators receive clearer intent and accountability, accidental
fleet-wide disruption is less likely, and actions can be investigated after the
fact.
**Tradeoffs:** Some operations require more steps and may complete more slowly.
Safety behavior adds product and test complexity and must be maintained rather
than bypassed for convenience.
---
# ADR-0009
**Title:** Progressive complexity.
## Context
Mining operations vary from small fleets with simple inventory needs to
multi-site organizations with detailed infrastructure, automation, and role
requirements. Requiring every installation to configure the largest possible
model would make basic workflows difficult to adopt and maintain.
At the same time, limiting the product to the smallest deployment would prevent
larger operators from representing their real work.
## Decision
MinerOps exposes useful basic workflows without requiring every advanced
capability. Operators can begin with the records and structure they need, then
adopt additional location depth, monitoring, automation, integrations, and
organizational controls as their operation requires them.
Advanced features remain coherent extensions of the same operational record.
They do not create a separate product mode or make basic asset and repair work
dependent on unrelated configuration.
## Consequences
**Benefits:** Small deployments can gain value with less setup, while larger
deployments have room to model additional complexity without abandoning the
same system of record.
**Tradeoffs:** Defaults and optional states must be designed carefully. Features
must work when advanced context is absent, which increases the number of valid
configurations that testing must cover.
---
# ADR-0010
**Title:** Architecture evolves incrementally.
## Context
MinerOps contains working early-stage software alongside newer domain
boundaries. Replacing the application in one rewrite would put existing
workflows, data compatibility, and validation at unnecessary risk.
Leaving all existing responsibilities in integrated modules would also make new
features harder to test and maintain.
## Decision
Architecture improves incrementally. New domain code follows the established
router, service, and repository boundaries. Existing behavior moves behind
those boundaries in small, reviewable changes that preserve working software
and include compatibility validation.
A new boundary is introduced when it clarifies ownership or testability, not
merely to rename or relocate code. Existing integrated code is context to
preserve, not a template to duplicate.
## Consequences
**Benefits:** Architectural quality improves while the application remains
usable. Smaller changes are easier to review, test, diagnose, and reverse.
**Tradeoffs:** The repository will contain transitional patterns for a period of
time. Contributors must understand which code represents current rules and
which code remains for compatibility, and some temporary duplication may need
careful removal after migration.
+377
View File
@@ -0,0 +1,377 @@
# MinerOps Product Guide
## Purpose
MinerOps is intended to become the self-hosted operational system of record for
cryptocurrency mining operations.
Its primary product position is:
> The self-hosted operating system for cryptocurrency mining operations.
Its supporting statement is:
> Know every miner. Track every repair. Control every rack.
"Operating system" describes a shared operational foundation, not a replacement
for a computer operating system or miner firmware. MinerOps is intended to
connect the records and workflows that operators use to understand their fleet,
maintain equipment, and coordinate work.
"Know every miner" means that each asset should have a durable, understandable
record. "Track every repair" means that failures, work, parts, tests, and
outcomes should remain connected to that record. "Control every rack" means
that operational actions should be available with appropriate context,
permissions, safeguards, and visibility.
**Current implementation:** MinerOps is an early-stage application. The
repository contains authentication and role foundations, an Inventory schema
and backend domain foundation, and existing operational workflows for areas such
as discovery, repairs, locations, monitoring, and control. These areas are not
all complete or organized as finished product domains.
**Product direction:** MinerOps should bring these capabilities together around
one durable operational record while remaining practical for self-hosted use.
The directions in this guide are not claims that every capability is currently
implemented.
## Target Users
MinerOps is intended for people responsible for operating and maintaining
cryptocurrency mining equipment, including:
- Mining farm operators
- Technicians
- Operations managers
- Site administrators
- Owners managing small or medium fleets
- Organizations operating multiple rooms or sites
These users need different levels of visibility and control. A technician may
need fast access to asset history and repair context from the floor. A manager
may need fleet state, work status, and accountability across teams. A site
administrator may need configuration and access controls. An owner may need a
clear view across one facility or several sites.
MinerOps should support those different working contexts without forcing every
user to navigate the same amount of detail or receive the same operational
control. Detailed permissions belong to access-control design rather than this
product guide.
## Problems MinerOps Solves
Mining operations often build their working record from several disconnected
sources. Asset details may live in spreadsheets, current health in monitoring
tools, repair notes in informal documents, and physical location knowledge in a
technician's memory.
Common operational problems include:
- Asset information spread across spreadsheets and ad hoc records.
- IP addresses changing without a reliable link to the durable asset.
- Control board replacements changing MAC addresses and confusing identity.
- Repair and maintenance history being incomplete or difficult to find.
- Equipment being difficult to locate across rooms, racks, shelves, or storage.
- Important fleet knowledge existing only in individual technicians' memories.
- Monitoring and control tools being disconnected from inventory and repair
records.
- Limited audit history for changes and operational actions.
- Uncertainty about which miners are active, spare, under repair, missing, or
retired.
- Difficulty coordinating safe actions across many miners at once.
MinerOps is intended to reduce this fragmentation. It should help operators
answer practical questions about what an asset is, where it is, what condition
it is in, what has happened to it, and what action is appropriate next.
## Product Vision
The long-term product direction is one self-hosted platform connecting:
- Inventory
- Durable asset identity
- Network discovery
- Locations
- Repairs and work orders
- Spare parts
- Monitoring and telemetry
- Alerts
- Fleet control
- Scheduling and automation
- Electrical and infrastructure records
- Historical reporting
- Audit history
These capabilities should reinforce one another. A monitoring alert should
point to the correct asset. A repair should include the relevant history and
location. A component replacement should not erase the identity of the miner. A
fleet action should respect maintenance state and leave an audit record.
This list defines product direction. The repository contains foundations and
working early-stage workflows in several of these areas, but it does not yet
provide the complete product described here.
## Product Principles
### Single Source of Truth
Important operational records should have one authoritative home. Other tools
may contribute observations or consume data, but MinerOps should reduce the
need to reconcile competing spreadsheets, notes, and dashboards.
A source of truth must be understandable and correctable. It should make the
current record clear while retaining enough history to explain how that record
changed.
### Asset Identity Before Network Identity
A miner is not defined by its current IP address or MAC address. Network values
are mutable observations about an asset, not the asset's durable identity.
MinerOps should preserve a stable internal identity through address changes,
control board replacements, movement, repair, storage, and retirement. Network
identity remains important, but it must be associated with the asset rather
than substituted for it.
### Preserve History
Operationally important changes should remain traceable instead of silently
overwriting the past. Repairs, locations, network identities, components,
status changes, and operator actions may all affect how an asset's current state
is understood.
History should be useful rather than merely accumulated. Records need clear
timestamps, context, and attribution where appropriate.
### Self-Hosted First
Operators should retain control of their operational data and deployment.
MinerOps should work as software that an organization can run within its own
environment without requiring a mandatory hosted subscription.
Self-hosted first does not prohibit internet access, remote access, or external
integrations. Those capabilities should remain deliberate choices under the
operator's control.
### Safe by Default
MinerOps may coordinate actions that interrupt mining or affect many devices.
Potentially disruptive actions require deliberate authorization, clear scope,
validation, and auditability.
Defaults should favor observation and review over unexpected action. A new
installation or development environment should not begin issuing broad or
destructive commands without explicit configuration.
### Practical Operations Over Cryptocurrency Hype
MinerOps is an operations and infrastructure product. Its value comes from
making assets, repairs, locations, health, and controls easier to understand and
manage.
It is not a trading, investment, or promotional product. Product decisions
should be evaluated against operational usefulness rather than cryptocurrency
trends or speculative features.
### Technician-Friendly
Common workflows should be usable where the work happens. Interfaces should
support clear scanning, direct actions, and responsive layouts suitable for
phones and workstations.
Mobile and QR-assisted workflows are appropriate product directions when they
reduce lookup time or data-entry mistakes. They should complement durable asset
identity rather than introduce a separate record.
### Progressive Complexity
A small operator should be able to begin with useful inventory, location, and
repair workflows without configuring every advanced capability. Larger or more
complex installations should be able to add structure as their needs grow.
Advanced monitoring, automation, infrastructure mapping, and integrations
should not make the basic operational record harder to use.
### Explicit Human Control
Automation should assist operators without making important decisions
invisible. Users should understand what will happen, what is happening, and what
happened afterward.
Automated behavior should expose its scope, conditions, limits, and result. An
operator must be able to distinguish intentional automation from an unexpected
system action.
## Core Product Areas
### Inventory and Assets
Inventory provides durable records for miners and related hardware. It connects
operator-facing Asset IDs, hardware identifiers, lifecycle state, tags, notes,
and ownership of the asset record without relying on current network values.
### Identity and Discovery
Identity and discovery connect observations from the network to stable assets.
The product should distinguish current network information from history and
provide deliberate handling when components or identities change.
### Locations and Infrastructure
Locations and infrastructure describe where equipment is installed or stored
and how it relates to sites, rooms, racks, shelves, positions, and electrical
context. Movement should remain understandable over time.
### Repairs and Maintenance
Repairs and maintenance connect problems, diagnostics, work, parts, testing,
and return-to-service outcomes to the asset. Technicians should be able to see
prior work before beginning another repair.
### Monitoring and Alerts
Monitoring and alerts explain live and historical operational health. They
should add observations to the operational record without replacing durable
inventory, repair, or lifecycle information.
### Fleet Operations
Fleet operations provide permission-controlled actions for individual miners or
groups. Controls should include enough identity, location, health, and
maintenance context to reduce avoidable mistakes.
### Scheduling and Automation
Scheduling and automation support repeatable work while preserving safeguards,
expiry, rollback or recovery behavior where practical, and operator visibility.
Automation should make coordinated operations more predictable, not less
accountable.
### Reporting and Audit History
Reporting and audit history help operators understand what changed, who changed
it, why it changed, and what happened over time. Reports should draw from the
same operational records used by day-to-day workflows.
## Operational Source of Truth
MinerOps should connect information that is commonly separated:
- Asset identity
- Network identity
- Physical location
- Repair status
- Operational status
- Historical changes
These are related views of one operational asset, not independent records to be
manually reconciled. Current status should remain distinct from lifecycle,
repair, and location state so that one value does not erase important context.
A miner should remain understandable throughout its lifecycle: initial entry,
deployment, discovery, movement, component replacement, failure, repair,
testing, storage, redeployment, and retirement. Operators should be able to
follow that history even when the miner's address, control board, rack, or
condition changes.
The source-of-truth role also requires correction. Authorized users should be
able to fix inaccurate records without destroying meaningful history or hiding
who made the change.
## Self-Hosted Philosophy
Self-hosting matters because mining operations depend on local equipment,
segmented networks, and operational data that may need to remain under direct
organizational control.
A self-hosted MinerOps deployment should support:
- Operational control over configuration and availability.
- Ownership of asset, repair, telemetry, and audit data.
- Access to equipment on local networks where appropriate.
- Independence from mandatory hosted subscriptions.
- Operation in restricted or segmented environments.
- Backup, restore, and migration under the operator's control.
Self-hosted does not mean isolated. Optional remote access, manufacturer
interfaces, reporting systems, or other integrations may be valuable. They
should not make a hosted third party the mandatory owner of the operational
record.
## Safety and Control
MinerOps may eventually perform actions capable of disrupting individual miners
or a significant part of a fleet. Safety is therefore a product behavior, not
only an implementation concern.
Product expectations include:
- Role and permission enforcement.
- Clear confirmation for destructive or broad actions.
- Dry-run or preview behavior where practical.
- Staggering, rate limits, and scope limits for bulk actions.
- Commands that expire rather than remain indefinitely actionable.
- Audit history for requested actions and outcomes.
- Awareness of maintenance state and intentionally offline equipment.
- Safe defaults in development and new installations.
The interface should communicate the intended target, scope, and likely impact
before an action is accepted. Afterward, the operator should be able to see
whether the action ran, partially succeeded, failed, expired, or was cancelled.
Automation does not remove the need for authorization or visibility. The
broader the potential effect, the more explicit the safeguards should be.
## What MinerOps Is Not
MinerOps is not intended to be:
- A cryptocurrency exchange
- A wallet
- A trading platform
- An investment dashboard
- A hosted mining provider
- A replacement for manufacturer firmware
- A general-purpose data centre management suite
- A tool that hides unsafe automation from operators
- A promotional cryptocurrency project
MinerOps may integrate with manufacturer interfaces, accounting systems,
monitoring tools, or other external services when those integrations improve
mining operations. Integration does not change the product's core purpose: a
self-hosted operational system of record for mining assets and workflows.
## Measures of Success
MinerOps is succeeding when:
- Operators can identify and locate any recorded miner quickly.
- A miner's history remains understandable after IP, MAC, control board, or
location changes.
- Technicians can see prior failures and repairs before starting work.
- Managers can understand fleet state without reconciling several spreadsheets.
- Bulk actions are controlled, visible, and auditable.
- Operational data can be backed up, restored, and migrated.
- The system remains useful for both small and larger installations.
- New features strengthen a coherent operational record instead of creating
another disconnected tool.
These are practical outcomes rather than numerical targets. Measures should be
refined using real operator workflows and evidence as the product develops.
## Product Decision Test
A proposed feature should normally answer yes to one or more of these questions:
- Does it help identify a miner or related asset?
- Does it help locate equipment?
- Does it improve understanding of current or historical condition?
- Does it support repair, maintenance, or return-to-service work?
- Does it improve safe monitoring or control?
- Does it preserve meaningful operational history?
- Does it reduce fragmented or duplicated records?
- Does it remain consistent with self-hosting and operator control?
A technically interesting feature is not automatically a MinerOps feature. It
should improve cryptocurrency mining operations and fit the product's
source-of-truth role.
+238
View File
@@ -0,0 +1,238 @@
# MinerOps Roadmap
## Purpose
This roadmap communicates the intended direction of MinerOps from its current
early-stage foundation toward a production-ready, self-hosted mining operations
platform. It describes broad development phases, their priorities, and why work
is ordered that way.
The roadmap is not detailed project scheduling. It does not assign dates,
promise delivery order within a phase, or replace the work needed to define and
validate individual features. Priorities may change as operational experience
and operator feedback reveal better sequencing.
The architectural rules in `ARCHITECTURE.md`, product principles in
`PRODUCT.md`, and recorded decisions in `DECISIONS.md` take precedence over
roadmap speed. A milestone should move more slowly rather than weaken durable
identity, history, safety, testability, or domain ownership.
## Current Status
MinerOps currently has an early-stage backend and operational application,
containerized development services, PostgreSQL and Redis infrastructure, and a
versioned Alembic schema. Authentication, login gating, and role foundations are
implemented.
The Inventory domain has a database schema, Pydantic models, domain exceptions,
a service, a PostgreSQL repository, and focused automated tests. It is not yet
exposed through a new domain HTTP API. Existing application code also contains
working early-stage workflows for discovery, repairs, locations, telemetry, and
control, but those areas are not complete product domains and remain in the
integrated application module.
The repository now includes established branding and primary guides for product,
architecture, and long-lived decisions. The project remains in the Foundation
phase.
## Guiding Principles
Roadmap ordering follows these principles:
- **Stable foundations before features:** Identity, schema, access control, and
test boundaries must support later workflows rather than be repaired after
every expansion.
- **Durable operational records before automation:** MinerOps must know which
asset, location, condition, and history an action affects before automating
that action.
- **Safety before convenience:** Controls and broad actions require permissions,
clear scope, safeguards, and auditability.
- **Incremental delivery:** Working behavior is preserved while features move
behind explicit domain boundaries in reviewable steps.
- **Maintainability over rapid expansion:** New capability should strengthen the
shared operational record instead of creating another isolated workflow.
These principles describe how phases are evaluated. They do not require every
capability in one phase to be exhausted before useful work in another phase can
begin.
## Development Phases
### Phase 1: Foundation
**Status: Current phase.**
Foundation establishes the application, authentication and role model, database
migration authority, initial Inventory domain, repeatable development stack,
focused tests, branding, and primary project documentation.
The purpose of this phase is to create stable ownership and validation before
adding more product surface. Inventory is the first reference domain because
asset records are needed by identity, repair, monitoring, and control workflows.
Phase 1 is complete only when the foundations are usable as dependencies for
subsequent domain work, not merely present as isolated code.
### Phase 2: Inventory & Identity
**Status: Planned direction.**
This phase completes the core asset record and connects it to discovery and
network observations. It develops practical asset workflows around stable
internal identity, operator-facing Asset IDs, lifecycle state, tags, notes, and
location relationships.
Discovery should resolve observations to durable assets without defining an
asset by its current IP or MAC address. Network identity history and deliberate
handling of address or control board changes preserve continuity. Inventory
becomes the dependable source that later operational domains can reference.
This phase precedes deeper repair and control work because those workflows must
act on the correct asset and retain context through component, network, and
location changes.
### Phase 3: Operations
**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
domain ownership while preserving working use cases.
Operations follows Inventory and Identity so every repair, part, and movement
has stable ownership and useful history.
### Phase 4: Monitoring
**Status: Planned direction.**
This phase connects telemetry, alerts, fleet health, and historical trends to
the operational record. Monitoring should explain current and historical
condition without replacing lifecycle, repair, location, or maintenance state.
Alerts become more useful when they can account for asset identity, known
maintenance, intended downtime, location, and prior behavior. Historical trends
should help operators recognize persistent or recurring problems rather than
serve as an isolated stream of measurements.
Monitoring follows core operational records so observations carry enough
context to support decisions and avoid treating expected conditions as unknown
failures.
### Phase 5: Fleet Control
**Status: Planned direction.**
This phase develops controlled actions for individual miners and groups, then
extends them toward scheduling and automation. Permissions, confirmation,
scope, staggering, expiry, maintenance awareness, and audit history are product
requirements rather than optional polish.
Bulk operations should remain visible before, during, and after execution.
Automation must preserve explicit human control and distinguish planned actions
from unexpected device behavior.
Fleet control follows identity, operations, and monitoring because safe action
requires confidence in the target, awareness of maintenance state, and feedback
about the result.
### Phase 6: Infrastructure
**Status: Planned direction.**
This phase deepens the model of sites, rooms, racks, positions, electrical
relationships, power, and capacity. It connects miners and operational state to
the physical constraints under which a mining facility runs.
Earlier phases may use basic location relationships. Phase 6 develops the wider
infrastructure record needed for capacity planning, movement decisions, and
understanding how equipment relates to power and space.
Infrastructure depth should remain progressively adoptable. A small installation
should not need to model every electrical relationship before basic Inventory
or Operations workflows are useful.
### Phase 7: Production Readiness
**Status: Planned direction.**
This phase prepares MinerOps for sustained operation and supported upgrades. It
focuses on performance, broader automated testing, API consistency, complete
operator and deployment documentation, reliable upgrade paths, backup and
restore procedures, deployment hardening, observability, and long-term support
practices.
Production readiness applies across all product areas. It includes verifying
failure behavior, recovery, data migration, and operational maintenance rather
than only completing feature surfaces.
This phase is placed after the principal product domains so readiness work can
be measured against realistic workflows. Reliability, upgrade safety, and
observability remain ongoing concerns in every earlier phase rather than being
deferred entirely to the end.
## Future Opportunities
Possible areas for later exploration include additional external integrations,
manufacturer APIs, richer reporting, operational analytics, mobile workflow
improvements, and a plugin architecture if multiple real integrations justify a
stable extension boundary.
These are opportunities, not commitments. They should be evaluated against the
product decision test, self-hosted ownership, safety, maintenance cost, and the
need to preserve one coherent operational record.
## How Priorities Change
Priorities may change in response to operator feedback, experience from real
mining operations, reliability findings, safety concerns, maintenance cost, or
new operational requirements. A newly discovered data-integrity or safety need
may appropriately move ahead of a visible feature.
Changes to priority should be explained using the product principles and ADRs.
Reordering is appropriate when it improves the product without weakening stable
identity, history, operator control, or architectural boundaries. A roadmap
change does not silently reverse a long-lived decision; such a change requires a
new documented decision.
## Completed Milestones
Major completed foundations include:
- Repository and containerized development foundation
- PostgreSQL, Redis, and Alembic migration foundation
- 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
- Architecture and Product Decision Records
Completion here means the named foundation exists in the repository. It does not
mean every product workflow associated with that area is complete.
## Reading This Roadmap
The roadmap communicates intended direction rather than guaranteed delivery
order. Features may move between phases when doing so improves the overall
product without weakening the architectural, product, or safety principles
established elsewhere.
Use phases to understand dependency and priority, not as fixed release promises.
Evaluate actual work against current operational needs and the durable decisions
recorded in the project documentation.
+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.
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more