Compare commits
34
Commits
d1d02962da
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
390e73d950 | ||
|
|
0dd26a3f24 | ||
|
|
4d360fac42 | ||
|
|
19fc29c72c | ||
|
|
b8b741ccee | ||
|
|
4a1959ecd6 | ||
|
|
786a1ab910 | ||
|
|
c6d01df31c | ||
|
|
06ed8248f5 | ||
|
|
d06ee7bcc6 | ||
|
|
60a831950c | ||
|
|
548051899e | ||
|
|
b741fd799b | ||
|
|
eeb3c7634e | ||
|
|
4656714837 | ||
|
|
39832fcf80 | ||
|
|
eab9646960 | ||
|
|
6e550c1c13 | ||
|
|
73f98685ce | ||
|
|
bcf1b4e3cf | ||
|
|
62c91a2c3e | ||
|
|
f2b736b08e | ||
|
|
72ad19a071 | ||
|
|
36a0caecd8 | ||
|
|
f6a2512e23 | ||
|
|
d738b8e27e | ||
|
|
a87ab2709f | ||
|
|
51aec1b404 | ||
|
|
979885bbdd | ||
|
|
93e034ea07 | ||
|
|
7280f1dfea | ||
|
|
fbf0f81df1 | ||
|
|
1cae60ab46 | ||
|
|
577226cb4e |
No files matched your search
@@ -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');
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Minimal durable component registry for repair-part references."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Dependency construction for the Components domain."""
|
||||
|
||||
from app.components.repository import ComponentRepository
|
||||
from app.components.service import ComponentService
|
||||
|
||||
|
||||
def get_component_service() -> ComponentService:
|
||||
return ComponentService(ComponentRepository())
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Domain exceptions for the minimal component registry."""
|
||||
|
||||
|
||||
class ComponentError(Exception):
|
||||
"""Base exception for component operations."""
|
||||
|
||||
|
||||
class ComponentNotFound(ComponentError):
|
||||
"""Raised when a component UUID does not exist."""
|
||||
|
||||
def __init__(self, component_id: object):
|
||||
self.component_id = component_id
|
||||
super().__init__(f"Component not found: {component_id}")
|
||||
|
||||
|
||||
class DuplicateComponentAssetID(ComponentError):
|
||||
"""Raised when a normalized component Asset ID already exists."""
|
||||
|
||||
def __init__(self, asset_id: str):
|
||||
self.asset_id = asset_id
|
||||
super().__init__(f"Component Asset ID already exists: {asset_id}")
|
||||
|
||||
|
||||
class ComponentValidationError(ComponentError):
|
||||
"""Raised when component metadata violates a domain rule."""
|
||||
|
||||
|
||||
class ComponentActorRequired(ComponentError):
|
||||
"""Raised when component creation lacks an authenticated actor."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("An authenticated actor is required to create a component")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Typed contracts for the minimal component registry."""
|
||||
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
ComponentType = Literal["control_board", "hashboard", "power_supply", "fan"]
|
||||
|
||||
|
||||
class ComponentCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
|
||||
component_type: ComponentType
|
||||
asset_id: str | None = Field(default=None, max_length=255)
|
||||
manufacturer: str | None = Field(default=None, max_length=255)
|
||||
model: str | None = Field(default=None, max_length=255)
|
||||
manufacturer_serial: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class ComponentDetail(BaseModel):
|
||||
component_id: UUID
|
||||
component_type: ComponentType
|
||||
asset_id: str | None = None
|
||||
manufacturer: str | None = None
|
||||
model: str | None = None
|
||||
manufacturer_serial: str | None = None
|
||||
created_at: AwareDatetime
|
||||
created_by: UUID
|
||||
|
||||
|
||||
class ComponentResponse(BaseModel):
|
||||
data: ComponentDetail
|
||||
meta: dict[str, object] = Field(default_factory=dict)
|
||||
error: None = None
|
||||
@@ -0,0 +1,143 @@
|
||||
"""PostgreSQL persistence for the minimal component registry."""
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from psycopg.errors import UniqueViolation
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.components.exceptions import DuplicateComponentAssetID
|
||||
from app.config import settings
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
COMPONENT_ASSET_ID_CONSTRAINT = "uq_components_normalized_asset_id"
|
||||
|
||||
|
||||
def default_connection_factory() -> psycopg.Connection:
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
class ComponentRepository:
|
||||
"""Persistence boundary for minimal component identity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connection_factory: ConnectionFactory | None = None,
|
||||
*,
|
||||
connection: psycopg.Connection | None = None,
|
||||
):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
self._connection = connection
|
||||
|
||||
@contextmanager
|
||||
def transaction(self) -> Iterator["ComponentRepository"]:
|
||||
if self._connection is not None:
|
||||
yield self
|
||||
return
|
||||
|
||||
connection = self._connection_factory()
|
||||
repository = ComponentRepository(
|
||||
self._connection_factory,
|
||||
connection=connection,
|
||||
)
|
||||
try:
|
||||
yield repository
|
||||
connection.commit()
|
||||
except UniqueViolation as exc:
|
||||
connection.rollback()
|
||||
if exc.diag.constraint_name == COMPONENT_ASSET_ID_CONSTRAINT:
|
||||
raise DuplicateComponentAssetID("normalized Asset ID") from exc
|
||||
raise
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@contextmanager
|
||||
def _connection_scope(self) -> Iterator[psycopg.Connection]:
|
||||
if self._connection is not None:
|
||||
yield self._connection
|
||||
return
|
||||
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def create_component(
|
||||
self,
|
||||
*,
|
||||
component_type: str,
|
||||
asset_id: str | None,
|
||||
manufacturer: str | None,
|
||||
model: str | None,
|
||||
manufacturer_serial: str | None,
|
||||
created_by: UUID,
|
||||
) -> dict:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO components (
|
||||
component_type,
|
||||
asset_id,
|
||||
manufacturer,
|
||||
model,
|
||||
manufacturer_serial,
|
||||
created_by
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING
|
||||
id AS component_id,
|
||||
component_type,
|
||||
asset_id,
|
||||
manufacturer,
|
||||
model,
|
||||
manufacturer_serial,
|
||||
created_at,
|
||||
created_by
|
||||
""",
|
||||
(
|
||||
component_type,
|
||||
asset_id,
|
||||
manufacturer,
|
||||
model,
|
||||
manufacturer_serial,
|
||||
created_by,
|
||||
),
|
||||
)
|
||||
return dict(cur.fetchone())
|
||||
|
||||
def get_component(self, component_id: UUID) -> dict | None:
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
id AS component_id,
|
||||
component_type,
|
||||
asset_id,
|
||||
manufacturer,
|
||||
model,
|
||||
manufacturer_serial,
|
||||
created_at,
|
||||
created_by
|
||||
FROM components
|
||||
WHERE id = %s
|
||||
""",
|
||||
(component_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
@@ -0,0 +1,80 @@
|
||||
"""HTTP mapping for the minimal component registry."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.components.dependencies import get_component_service
|
||||
from app.components.exceptions import (
|
||||
ComponentActorRequired,
|
||||
ComponentNotFound,
|
||||
ComponentValidationError,
|
||||
DuplicateComponentAssetID,
|
||||
)
|
||||
from app.components.models import (
|
||||
ComponentCreateRequest,
|
||||
ComponentResponse,
|
||||
)
|
||||
from app.components.service import ComponentService
|
||||
from app.identity import require_permission
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/components", tags=["components"])
|
||||
require_component_read = require_permission("components.read")
|
||||
require_component_create = require_permission("components.create")
|
||||
|
||||
ComponentServiceDependency = Annotated[
|
||||
ComponentService,
|
||||
Depends(get_component_service),
|
||||
]
|
||||
ComponentReadUser = Annotated[dict, Depends(require_component_read)]
|
||||
ComponentCreateUser = Annotated[dict, Depends(require_component_create)]
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ComponentResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_component(
|
||||
request: ComponentCreateRequest,
|
||||
service: ComponentServiceDependency,
|
||||
current_user: ComponentCreateUser,
|
||||
) -> ComponentResponse:
|
||||
try:
|
||||
component = service.create_component(
|
||||
request,
|
||||
actor_user_id=UUID(str(current_user["id"])),
|
||||
)
|
||||
except DuplicateComponentAssetID as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except (ComponentActorRequired, ComponentValidationError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
return ComponentResponse(data=component)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{component_id}",
|
||||
response_model=ComponentResponse,
|
||||
)
|
||||
def get_component(
|
||||
component_id: UUID,
|
||||
service: ComponentServiceDependency,
|
||||
current_user: ComponentReadUser,
|
||||
) -> ComponentResponse:
|
||||
del current_user
|
||||
try:
|
||||
component = service.get_component(component_id)
|
||||
except ComponentNotFound as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Component not found",
|
||||
) from exc
|
||||
return ComponentResponse(data=component)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Business rules for the minimal component registry."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.components.exceptions import (
|
||||
ComponentActorRequired,
|
||||
ComponentNotFound,
|
||||
ComponentValidationError,
|
||||
DuplicateComponentAssetID,
|
||||
)
|
||||
from app.components.models import ComponentCreateRequest, ComponentDetail
|
||||
from app.components.repository import ComponentRepository
|
||||
|
||||
|
||||
class ComponentService:
|
||||
"""Minimal component workflows independent of FastAPI."""
|
||||
|
||||
def __init__(self, repository: ComponentRepository):
|
||||
self.repository = repository
|
||||
|
||||
@staticmethod
|
||||
def _normalize_optional_text(value: str | None, field_name: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = str(value).strip()
|
||||
if not normalized:
|
||||
raise ComponentValidationError(
|
||||
f"{field_name} must not be blank when supplied"
|
||||
)
|
||||
if len(normalized) > 255:
|
||||
raise ComponentValidationError(
|
||||
f"{field_name} must not exceed 255 characters"
|
||||
)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _map_component(component: dict) -> ComponentDetail:
|
||||
return ComponentDetail(**component)
|
||||
|
||||
def create_component(
|
||||
self,
|
||||
request: ComponentCreateRequest,
|
||||
*,
|
||||
actor_user_id: UUID | None,
|
||||
) -> ComponentDetail:
|
||||
if actor_user_id is None:
|
||||
raise ComponentActorRequired()
|
||||
|
||||
asset_id = self._normalize_optional_text(request.asset_id, "asset_id")
|
||||
values = {
|
||||
"manufacturer": self._normalize_optional_text(
|
||||
request.manufacturer, "manufacturer"
|
||||
),
|
||||
"model": self._normalize_optional_text(request.model, "model"),
|
||||
"manufacturer_serial": self._normalize_optional_text(
|
||||
request.manufacturer_serial, "manufacturer_serial"
|
||||
),
|
||||
}
|
||||
try:
|
||||
with self.repository.transaction() as repository:
|
||||
component = repository.create_component(
|
||||
component_type=request.component_type,
|
||||
asset_id=asset_id,
|
||||
created_by=actor_user_id,
|
||||
**values,
|
||||
)
|
||||
except DuplicateComponentAssetID as exc:
|
||||
raise DuplicateComponentAssetID(asset_id or "") from exc
|
||||
return self._map_component(component)
|
||||
|
||||
def get_component(self, component_id: UUID) -> ComponentDetail:
|
||||
component = self.repository.get_component(component_id)
|
||||
if component is None:
|
||||
raise ComponentNotFound(component_id)
|
||||
return self._map_component(component)
|
||||
@@ -0,0 +1 @@
|
||||
"""Permission-aware operational Dashboard read model."""
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Dependency construction for the Dashboard composition service."""
|
||||
|
||||
from app.dashboard.service import DashboardService
|
||||
from app.inventory.dependencies import get_inventory_service
|
||||
from app.locations.dependencies import get_location_map_service
|
||||
from app.repairs.dependencies import get_repair_center_service
|
||||
|
||||
|
||||
def get_dashboard_service() -> DashboardService:
|
||||
return DashboardService(
|
||||
inventory_service=get_inventory_service(),
|
||||
repair_center_service=get_repair_center_service(),
|
||||
location_service=get_location_map_service(),
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Typed contracts for the operational Dashboard overview."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.inventory.models import InventoryFleetSummary
|
||||
from app.locations.models import LocationSummary
|
||||
from app.repairs.models import ActiveRepairSummary
|
||||
|
||||
|
||||
class DashboardAccess(BaseModel):
|
||||
can_view_miners: bool = False
|
||||
can_view_repairs: bool = False
|
||||
can_view_locations: bool = False
|
||||
|
||||
|
||||
class DashboardOverviewData(BaseModel):
|
||||
fleet: InventoryFleetSummary | None = None
|
||||
repairs: ActiveRepairSummary | None = None
|
||||
locations: LocationSummary | None = None
|
||||
generated_at: datetime
|
||||
|
||||
|
||||
class DashboardOverviewMeta(BaseModel):
|
||||
available_sections: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DashboardOverviewResponse(BaseModel):
|
||||
data: DashboardOverviewData
|
||||
meta: DashboardOverviewMeta = Field(default_factory=DashboardOverviewMeta)
|
||||
error: None = None
|
||||
@@ -0,0 +1,40 @@
|
||||
"""HTTP boundary for the operational Dashboard overview."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
|
||||
from app.dashboard.dependencies import get_dashboard_service
|
||||
from app.dashboard.models import (
|
||||
DashboardAccess,
|
||||
DashboardOverviewResponse,
|
||||
)
|
||||
from app.dashboard.service import DashboardService
|
||||
from app.identity import require_permission, user_has_permission
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))]
|
||||
DashboardServiceDependency = Annotated[
|
||||
DashboardService,
|
||||
Depends(get_dashboard_service),
|
||||
]
|
||||
|
||||
|
||||
@router.get("/overview", response_model=DashboardOverviewResponse)
|
||||
def get_dashboard_overview(
|
||||
current_user: DashboardUser,
|
||||
service: DashboardServiceDependency,
|
||||
response: Response,
|
||||
) -> DashboardOverviewResponse:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return service.get_overview(
|
||||
DashboardAccess(
|
||||
can_view_miners=user_has_permission(current_user, "miners.view"),
|
||||
can_view_repairs=user_has_permission(current_user, "repairs.view"),
|
||||
can_view_locations=user_has_permission(
|
||||
current_user,
|
||||
"locations.view",
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Composition service for permission-aware Dashboard summaries."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.dashboard.models import (
|
||||
DashboardAccess,
|
||||
DashboardOverviewData,
|
||||
DashboardOverviewMeta,
|
||||
DashboardOverviewResponse,
|
||||
)
|
||||
from app.inventory.service import InventoryService
|
||||
from app.locations.service import LocationMapService
|
||||
from app.repairs.center_service import RepairCenterService
|
||||
|
||||
|
||||
class DashboardService:
|
||||
"""Coordinate domain-owned summaries without introducing new rules."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inventory_service: InventoryService,
|
||||
repair_center_service: RepairCenterService,
|
||||
location_service: LocationMapService,
|
||||
):
|
||||
self.inventory_service = inventory_service
|
||||
self.repair_center_service = repair_center_service
|
||||
self.location_service = location_service
|
||||
|
||||
def get_overview(self, access: DashboardAccess) -> DashboardOverviewResponse:
|
||||
fleet = (
|
||||
self.inventory_service.summarize_fleet()
|
||||
if access.can_view_miners
|
||||
else None
|
||||
)
|
||||
repairs = (
|
||||
self.repair_center_service.summarize_active_repairs()
|
||||
if access.can_view_repairs
|
||||
else None
|
||||
)
|
||||
locations = (
|
||||
self.location_service.summarize_locations(
|
||||
include_miner_counts=access.can_view_miners
|
||||
)
|
||||
if access.can_view_locations
|
||||
else None
|
||||
)
|
||||
available_sections = [
|
||||
name
|
||||
for name, value in (
|
||||
("fleet", fleet),
|
||||
("repairs", repairs),
|
||||
("locations", locations),
|
||||
)
|
||||
if value is not None
|
||||
]
|
||||
return DashboardOverviewResponse(
|
||||
data=DashboardOverviewData(
|
||||
fleet=fleet,
|
||||
repairs=repairs,
|
||||
locations=locations,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
meta=DashboardOverviewMeta(available_sections=available_sections),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Server-rendered frontend foundation for MinerOps."""
|
||||
@@ -0,0 +1,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>
|
||||
"""
|
||||
@@ -0,0 +1,141 @@
|
||||
"""HTTP routes for the MinerOps Internal Alpha application shell."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
|
||||
from app.frontend.views import render_app_page
|
||||
from app.identity import require_permission, safe_next_path, user_has_permission
|
||||
|
||||
|
||||
router = APIRouter(tags=["frontend"])
|
||||
STATIC_ROOT = Path(__file__).with_name("static")
|
||||
|
||||
DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))]
|
||||
MapUser = Annotated[dict, Depends(require_permission("locations.view"))]
|
||||
RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))]
|
||||
MinerUser = Annotated[dict, Depends(require_permission("miners.view"))]
|
||||
|
||||
|
||||
def render_page(view: str, current_user: dict, **context) -> str:
|
||||
return render_app_page(
|
||||
view,
|
||||
current_user,
|
||||
lambda permission: user_has_permission(current_user, permission),
|
||||
**context,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assets/minerops-app.css", include_in_schema=False)
|
||||
def frontend_styles() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "app.css").read_text(encoding="utf-8"),
|
||||
media_type="text/css",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assets/minerops-app.js", include_in_schema=False)
|
||||
def frontend_script() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "app.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assets/minerops-dashboard.js", include_in_schema=False)
|
||||
def dashboard_script() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "dashboard.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assets/minerops-mine-map.js", include_in_schema=False)
|
||||
def mine_map_script() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assets/minerops-fleet.js", include_in_schema=False)
|
||||
def fleet_script() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "fleet.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.get("/assets/minerops-repair-center.js", include_in_schema=False)
|
||||
def repair_center_script() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "repair-center.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assets/minerops-miner-detail.js", include_in_schema=False)
|
||||
def miner_detail_script() -> Response:
|
||||
return Response(
|
||||
(STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8"),
|
||||
media_type="text/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
|
||||
def dashboard_page(current_user: DashboardUser) -> str:
|
||||
return render_page("dashboard", current_user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/monitoring/map",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
def mine_map_page(current_user: MapUser) -> str:
|
||||
return render_page("map", current_user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/operations/repairs",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
def repair_center_page(current_user: RepairUser) -> str:
|
||||
return render_page("repairs", current_user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/assets/miners",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
def miners_page(current_user: MinerUser) -> str:
|
||||
return render_page("miners", current_user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/miners/{miner_id}/detail",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
def miner_detail_page(
|
||||
miner_id: UUID,
|
||||
current_user: MinerUser,
|
||||
return_to: Annotated[str | None, Query(max_length=2048)] = None,
|
||||
) -> str:
|
||||
safe_return = safe_next_path(return_to) if return_to else None
|
||||
if safe_return != return_to:
|
||||
safe_return = None
|
||||
return render_page("miner_detail", current_user, miner_id=str(miner_id),
|
||||
return_to=safe_return)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,47 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function setupShell() {
|
||||
const menu = document.getElementById("menuButton");
|
||||
const nav = document.getElementById("primaryNav");
|
||||
if (menu && nav) {
|
||||
menu.addEventListener("click", function () {
|
||||
const open = nav.dataset.open !== "true";
|
||||
nav.dataset.open = String(open);
|
||||
menu.setAttribute("aria-expanded", String(open));
|
||||
});
|
||||
}
|
||||
|
||||
const search = document.getElementById("globalSearchForm");
|
||||
const searchInput = document.getElementById("globalSearchInput");
|
||||
if (search && searchInput) {
|
||||
search.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
const query = searchInput.value.trim();
|
||||
const target = query
|
||||
? "/assets/miners?query=" + encodeURIComponent(query)
|
||||
: "/assets/miners";
|
||||
window.location.assign(target);
|
||||
});
|
||||
}
|
||||
|
||||
const logout = document.getElementById("logoutButton");
|
||||
if (logout) {
|
||||
logout.addEventListener("click", async function () {
|
||||
logout.disabled = true;
|
||||
try {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "same-origin"
|
||||
});
|
||||
} finally {
|
||||
window.location.replace("/login");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
setupShell();
|
||||
})();
|
||||
@@ -0,0 +1,155 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
if (document.body.dataset.view !== "dashboard") return;
|
||||
|
||||
const ids = [
|
||||
"dashboardLoading", "dashboardError", "dashboardErrorDetail",
|
||||
"dashboardRetry", "dashboardEmpty", "dashboardContent",
|
||||
"dashboardLoadStatus", "dashboardFleetTotal", "dashboardRuntimeOnline",
|
||||
"dashboardRuntimeOffline", "dashboardRuntimeUnknown",
|
||||
"dashboardLifecycleRepair", "dashboardLifecycleSpare",
|
||||
"dashboardLifecycleRetired", "dashboardAttentionOffline",
|
||||
"dashboardAttentionUnknown", "dashboardAttentionUnstable",
|
||||
"dashboardAttentionRepair", "dashboardActiveRepairs",
|
||||
"dashboardRepairStatuses", "dashboardSites", "dashboardRooms",
|
||||
"dashboardPositions", "dashboardOccupied", "dashboardEmptyPositions",
|
||||
"dashboardMappedMiners", "dashboardUnmappedMiners"
|
||||
];
|
||||
const el = {};
|
||||
for (const id of ids) el[id] = document.getElementById(id);
|
||||
let controller = null;
|
||||
|
||||
const STATUS_LABELS = {
|
||||
OPEN: "Open",
|
||||
IN_PROGRESS: "In progress",
|
||||
WAITING_FOR_PARTS: "Waiting for parts",
|
||||
READY_FOR_TEST: "Ready for test",
|
||||
open: "Open",
|
||||
diagnosing: "Diagnosing",
|
||||
waiting_parts: "Waiting parts",
|
||||
in_progress: "In progress",
|
||||
testing: "Testing",
|
||||
ready_to_shelf: "Ready to shelf",
|
||||
needs_followup: "Needs follow-up",
|
||||
repair: "In repair",
|
||||
unknown: "Unknown"
|
||||
};
|
||||
|
||||
function setCount(node, value) {
|
||||
if (node) node.textContent = Number.isInteger(value) ? String(value) : "Unavailable";
|
||||
}
|
||||
|
||||
function show(name) {
|
||||
el.dashboardLoading.hidden = name !== "loading";
|
||||
el.dashboardError.hidden = name !== "error";
|
||||
el.dashboardEmpty.hidden = name !== "empty";
|
||||
el.dashboardContent.hidden = name !== "ready";
|
||||
}
|
||||
|
||||
function statusLabel(value) {
|
||||
if (Object.prototype.hasOwnProperty.call(STATUS_LABELS, value)) {
|
||||
return STATUS_LABELS[value];
|
||||
}
|
||||
return String(value || "Unknown").replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function renderFleet(fleet) {
|
||||
if (!fleet || !el.dashboardFleetTotal) return;
|
||||
setCount(el.dashboardFleetTotal, fleet.total);
|
||||
setCount(el.dashboardRuntimeOnline, fleet.runtime_online);
|
||||
setCount(el.dashboardRuntimeOffline, fleet.runtime_offline);
|
||||
setCount(el.dashboardRuntimeUnknown, fleet.runtime_unknown);
|
||||
setCount(el.dashboardLifecycleRepair, fleet.lifecycle_in_repair);
|
||||
setCount(el.dashboardLifecycleSpare, fleet.lifecycle_spare);
|
||||
setCount(el.dashboardLifecycleRetired, fleet.lifecycle_retired);
|
||||
const attention = fleet.attention || {};
|
||||
setCount(el.dashboardAttentionOffline, attention.offline);
|
||||
setCount(el.dashboardAttentionUnknown, attention.unknown);
|
||||
setCount(el.dashboardAttentionUnstable, attention.unstable);
|
||||
setCount(el.dashboardAttentionRepair, attention.repair);
|
||||
}
|
||||
|
||||
function renderRepairs(repairs) {
|
||||
if (!repairs || !el.dashboardActiveRepairs) return;
|
||||
setCount(el.dashboardActiveRepairs, repairs.active);
|
||||
const entries = Object.entries(repairs.status_counts || {}).sort(function (left, right) {
|
||||
return statusLabel(left[0]).localeCompare(statusLabel(right[0]));
|
||||
});
|
||||
el.dashboardRepairStatuses.replaceChildren();
|
||||
if (!entries.length) {
|
||||
const row = document.createElement("div");
|
||||
const term = document.createElement("dt");
|
||||
const value = document.createElement("dd");
|
||||
term.textContent = "No active repair statuses";
|
||||
value.textContent = "0";
|
||||
row.append(term, value);
|
||||
el.dashboardRepairStatuses.appendChild(row);
|
||||
return;
|
||||
}
|
||||
for (const [status, count] of entries) {
|
||||
const row = document.createElement("div");
|
||||
const term = document.createElement("dt");
|
||||
const link = document.createElement("a");
|
||||
const value = document.createElement("dd");
|
||||
const params = new URLSearchParams({ scope: "active", status: status });
|
||||
link.href = "/operations/repairs?" + params.toString();
|
||||
link.textContent = statusLabel(status);
|
||||
term.appendChild(link);
|
||||
value.textContent = Number.isInteger(count) ? String(count) : "Unavailable";
|
||||
row.append(term, value);
|
||||
el.dashboardRepairStatuses.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLocations(locations) {
|
||||
if (!locations || !el.dashboardSites) return;
|
||||
setCount(el.dashboardSites, locations.sites);
|
||||
setCount(el.dashboardRooms, locations.rooms);
|
||||
setCount(el.dashboardPositions, locations.positions);
|
||||
setCount(el.dashboardOccupied, locations.occupied_positions);
|
||||
setCount(el.dashboardEmptyPositions, locations.empty_positions);
|
||||
setCount(el.dashboardMappedMiners, locations.mapped_miners);
|
||||
setCount(el.dashboardUnmappedMiners, locations.unmapped_miners);
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
if (controller) controller.abort();
|
||||
controller = new AbortController();
|
||||
show("loading");
|
||||
try {
|
||||
const response = await fetch("/api/dashboard/overview", {
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal
|
||||
});
|
||||
if (response.status === 401) {
|
||||
window.location.replace("/login?next=" + encodeURIComponent("/dashboard"));
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error("The overview service returned " + response.status + ".");
|
||||
const payload = await response.json();
|
||||
const data = payload && payload.data ? payload.data : {};
|
||||
const available = payload && payload.meta ? payload.meta.available_sections || [] : [];
|
||||
if (!available.length) {
|
||||
show("empty");
|
||||
return;
|
||||
}
|
||||
renderFleet(data.fleet);
|
||||
renderRepairs(data.repairs);
|
||||
renderLocations(data.locations);
|
||||
const generated = new Date(data.generated_at);
|
||||
el.dashboardLoadStatus.textContent = Number.isNaN(generated.valueOf())
|
||||
? "Operational summary loaded"
|
||||
: "Operational summary generated " + generated.toLocaleString();
|
||||
show("ready");
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") return;
|
||||
el.dashboardErrorDetail.textContent = error.message || "The operational overview could not be loaded.";
|
||||
show("error");
|
||||
}
|
||||
}
|
||||
|
||||
el.dashboardRetry.addEventListener("click", loadOverview);
|
||||
loadOverview();
|
||||
}());
|
||||
@@ -0,0 +1,359 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
if (document.body.dataset.view !== "miners") return;
|
||||
|
||||
const canViewLocations = document.body.dataset.canViewLocations === "true";
|
||||
const ids = [
|
||||
"fleetFilters", "fleetQuery", "fleetLifecycle", "fleetModel",
|
||||
"fleetRuntime", "fleetRepair", "fleetOperational", "fleetTags",
|
||||
"fleetProblems", "fleetReset", "fleetRows", "fleetResultSummary",
|
||||
"fleetFreshness", "fleetPrevious", "fleetNext", "fleetLimit"
|
||||
];
|
||||
const el = {};
|
||||
for (const id of ids) el[id] = document.getElementById(id);
|
||||
if (canViewLocations) {
|
||||
el.fleetSite = document.getElementById("fleetSite");
|
||||
el.fleetRoom = document.getElementById("fleetRoom");
|
||||
}
|
||||
|
||||
const limits = new Set([25, 50, 100, 250]);
|
||||
const state = { offset: 0, limit: 100, returned: 0, locationOptions: null,
|
||||
siteId: "", roomId: "" };
|
||||
|
||||
function display(value, fallback) {
|
||||
if (value === null || value === undefined || String(value).trim() === "") {
|
||||
return fallback || "Unavailable";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function words(value) {
|
||||
return display(value, "unknown").replaceAll("_", " ").toLowerCase();
|
||||
}
|
||||
|
||||
function create(tag, className, value) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (value !== undefined) node.textContent = value;
|
||||
return node;
|
||||
}
|
||||
|
||||
function showState(name, message) {
|
||||
for (const node of document.querySelectorAll("[data-view-state]")) {
|
||||
node.hidden = node.dataset.viewState !== name;
|
||||
}
|
||||
const active = document.querySelector('[data-view-state="' + name + '"]');
|
||||
const detail = active && active.querySelector("[data-state-detail]");
|
||||
if (detail && message) detail.textContent = message;
|
||||
}
|
||||
|
||||
function finite(value) {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function metric(value, suffix) {
|
||||
return finite(value) ? String(value) + suffix : "Unavailable";
|
||||
}
|
||||
|
||||
function age(value) {
|
||||
if (!value) return "Never observed";
|
||||
const time = new Date(value).valueOf();
|
||||
if (Number.isNaN(time)) return "Observation time unavailable";
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000));
|
||||
if (seconds < 60) return seconds + "s ago";
|
||||
if (seconds < 3600) return Math.floor(seconds / 60) + "m ago";
|
||||
if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago";
|
||||
return Math.floor(seconds / 86400) + "d ago";
|
||||
}
|
||||
|
||||
function operational(miner) {
|
||||
if (miner.active_repair || miner.lifecycle_status === "in_repair") {
|
||||
return { key: "repair", label: "In repair", problem: true };
|
||||
}
|
||||
if (miner.lifecycle_status === "retired") {
|
||||
return { key: "retired", label: "Retired", problem: false };
|
||||
}
|
||||
if (miner.lifecycle_status === "spare") {
|
||||
return { key: "spare", label: "Spare", problem: false };
|
||||
}
|
||||
const runtime = words(miner.runtime_status);
|
||||
if (runtime === "offline") {
|
||||
return { key: "offline", label: "Offline", problem: true };
|
||||
}
|
||||
if (miner.hash_stable === false) {
|
||||
return { key: "unstable", label: "Hash unstable", problem: true };
|
||||
}
|
||||
if (runtime === "online") {
|
||||
return { key: "online", label: "Online", problem: false };
|
||||
}
|
||||
return { key: "unknown", label: "Operational state unknown", problem: true };
|
||||
}
|
||||
|
||||
function prefixedLabel(value, prefix) {
|
||||
const label = display(value, "");
|
||||
if (!label) return "";
|
||||
return new RegExp("^" + prefix + "\\b", "i").test(label)
|
||||
? label : prefix + " " + label;
|
||||
}
|
||||
|
||||
function locationText(miner) {
|
||||
if (!canViewLocations || !miner.location_position_id) return "Not mapped";
|
||||
const parts = [];
|
||||
if (miner.site_name) parts.push(miner.site_name);
|
||||
if (miner.room_name) parts.push(miner.room_name);
|
||||
if (miner.rack_label) parts.push(prefixedLabel(miner.rack_label, "Rack"));
|
||||
if (miner.shelf_label) parts.push(prefixedLabel(miner.shelf_label, "Shelf"));
|
||||
parts.push(display(
|
||||
miner.position_label,
|
||||
miner.position_index === null ? "Position unavailable" : "Position " + miner.position_index
|
||||
));
|
||||
return parts.join(" / ");
|
||||
}
|
||||
|
||||
function detailLink(miner) {
|
||||
const returnTo = window.location.pathname + window.location.search;
|
||||
const path = "/miners/" + encodeURIComponent(miner.id) + "/detail";
|
||||
return path + "?return_to=" + encodeURIComponent(returnTo);
|
||||
}
|
||||
|
||||
function mapLink(miner) {
|
||||
if (!canViewLocations || !miner.location_position_id) return null;
|
||||
const params = new URLSearchParams({ miner_id: miner.id });
|
||||
if (miner.site_id && miner.room_id) {
|
||||
params.set("site_id", miner.site_id);
|
||||
params.set("room_id", miner.room_id);
|
||||
}
|
||||
return "/monitoring/map?" + params.toString();
|
||||
}
|
||||
|
||||
function addCell(row, label, content, className) {
|
||||
const cell = create("td", className || "");
|
||||
cell.dataset.label = label;
|
||||
if (content instanceof Node) cell.appendChild(content);
|
||||
else cell.textContent = display(content);
|
||||
row.appendChild(cell);
|
||||
}
|
||||
|
||||
function stack(primary, secondary) {
|
||||
const box = create("div", "fleet-stack");
|
||||
box.append(create("span", "fleet-primary", display(primary)));
|
||||
if (secondary) box.append(create("span", "secondary", secondary));
|
||||
return box;
|
||||
}
|
||||
|
||||
function renderRow(miner) {
|
||||
const condition = operational(miner);
|
||||
const row = create("tr", "fleet-row");
|
||||
row.dataset.status = condition.key;
|
||||
|
||||
const identity = create("div", "fleet-stack");
|
||||
const link = create("a", "object-link", display(miner.asset_id, "No Asset ID"));
|
||||
link.href = detailLink(miner);
|
||||
identity.append(link, create("span", "secondary fleet-uuid", miner.id));
|
||||
addCell(row, "Miner", identity, "fleet-identity");
|
||||
|
||||
const status = create("div", "fleet-stack");
|
||||
const marker = create("span", "fleet-status", condition.label);
|
||||
marker.dataset.status = condition.key;
|
||||
status.append(marker, create("span", "secondary", age(miner.observed_at)));
|
||||
addCell(row, "Operational", status);
|
||||
addCell(row, "Model / Serial", stack(miner.model, display(miner.miner_sn)));
|
||||
addCell(row, "Network", stack(miner.current_mac, display(miner.current_ip)));
|
||||
addCell(row, "Location", locationText(miner), "fleet-location");
|
||||
addCell(row, "Hashrate", metric(miner.hashrate_ths, " TH/s"));
|
||||
addCell(row, "Power", metric(miner.power_w, " W"));
|
||||
addCell(row, "Max chip temp", metric(miner.chip_temp_max_c, " C"));
|
||||
const repair = miner.active_repair
|
||||
? words(miner.active_repair.status) + " / " + display(miner.active_repair.reason)
|
||||
: "No active repair";
|
||||
addCell(row, "Lifecycle / Repair", stack(words(miner.lifecycle_status), repair));
|
||||
|
||||
const actions = create("div", "fleet-actions");
|
||||
const open = create("a", "command-link", "Open miner");
|
||||
open.href = detailLink(miner);
|
||||
actions.appendChild(open);
|
||||
const mapHref = mapLink(miner);
|
||||
if (mapHref) {
|
||||
const map = create("a", "quiet-link", "View on Mine Map");
|
||||
map.href = mapHref;
|
||||
actions.appendChild(map);
|
||||
}
|
||||
addCell(row, "Actions", actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
function option(value, label) {
|
||||
const node = document.createElement("option");
|
||||
node.value = value;
|
||||
node.textContent = label;
|
||||
return node;
|
||||
}
|
||||
|
||||
function syncLocationOptions(selectedSite, selectedRoom) {
|
||||
if (!canViewLocations || !state.locationOptions) return;
|
||||
el.fleetSite.replaceChildren(option("", "All sites"));
|
||||
for (const site of state.locationOptions.sites || []) {
|
||||
el.fleetSite.appendChild(option(String(site.id), site.name));
|
||||
}
|
||||
el.fleetSite.value = Array.from(el.fleetSite.options).some(function (item) {
|
||||
return item.value === selectedSite;
|
||||
}) ? selectedSite : "";
|
||||
el.fleetRoom.replaceChildren(option("", "All rooms"));
|
||||
for (const room of state.locationOptions.rooms || []) {
|
||||
if (!el.fleetSite.value || String(room.site_id) === el.fleetSite.value) {
|
||||
el.fleetRoom.appendChild(option(String(room.id), room.name));
|
||||
}
|
||||
}
|
||||
el.fleetRoom.value = Array.from(el.fleetRoom.options).some(function (item) {
|
||||
return item.value === selectedRoom;
|
||||
}) ? selectedRoom : "";
|
||||
state.siteId = el.fleetSite.value;
|
||||
state.roomId = el.fleetRoom.value;
|
||||
}
|
||||
|
||||
function readUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
el.fleetQuery.value = (params.get("query") || "").slice(0, 255);
|
||||
el.fleetLifecycle.value = params.get("lifecycle_status") || "";
|
||||
el.fleetModel.value = (params.get("model") || "").slice(0, 255);
|
||||
el.fleetRuntime.value = params.get("runtime_status") || "";
|
||||
el.fleetRepair.value = params.get("repair") || "";
|
||||
el.fleetOperational.value = params.get("operational_state") || "";
|
||||
el.fleetTags.value = params.getAll("tags").join(", ");
|
||||
el.fleetProblems.checked = params.get("problems") === "1";
|
||||
const requestedLimit = Number(params.get("limit"));
|
||||
state.limit = limits.has(requestedLimit) ? requestedLimit : 100;
|
||||
state.offset = Math.max(0, Number.parseInt(params.get("offset") || "0", 10) || 0);
|
||||
el.fleetLimit.value = String(state.limit);
|
||||
if (canViewLocations) {
|
||||
state.siteId = params.get("site_id") || "";
|
||||
state.roomId = params.get("room_id") || "";
|
||||
syncLocationOptions(state.siteId, state.roomId);
|
||||
}
|
||||
}
|
||||
|
||||
function requestParams() {
|
||||
const params = new URLSearchParams();
|
||||
const values = {
|
||||
query: el.fleetQuery.value.trim(),
|
||||
lifecycle_status: el.fleetLifecycle.value,
|
||||
model: el.fleetModel.value.trim(),
|
||||
runtime_status: el.fleetRuntime.value,
|
||||
repair: el.fleetRepair.value,
|
||||
operational_state: el.fleetOperational.value,
|
||||
problems: el.fleetProblems.checked ? "1" : "",
|
||||
site_id: canViewLocations ? state.siteId : "",
|
||||
room_id: canViewLocations ? state.roomId : "",
|
||||
limit: String(state.limit), offset: String(state.offset)
|
||||
};
|
||||
for (const pair of Object.entries(values)) {
|
||||
if (pair[1]) params.set(pair[0], pair[1]);
|
||||
}
|
||||
for (const tag of el.fleetTags.value.split(",")) {
|
||||
const clean = tag.trim();
|
||||
if (clean) params.append("tags", clean);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function setUrl(mode) {
|
||||
const params = requestParams();
|
||||
window.history[mode === "push" ? "pushState" : "replaceState"](
|
||||
{}, "", "/assets/miners?" + params.toString()
|
||||
);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
showState("loading");
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(function () { controller.abort(); }, 15000);
|
||||
try {
|
||||
const response = await fetch("/api/inventory/miners?" + requestParams().toString(), {
|
||||
credentials: "same-origin", headers: { Accept: "application/json" },
|
||||
signal: controller.signal
|
||||
});
|
||||
if (response.status === 401) {
|
||||
window.location.replace("/login?next=" + encodeURIComponent(
|
||||
window.location.pathname + window.location.search
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (response.status === 403) {
|
||||
const denied = new Error("Your account cannot view this fleet information.");
|
||||
denied.permissionDenied = true;
|
||||
throw denied;
|
||||
}
|
||||
if (!response.ok) throw new Error("The fleet service returned " + response.status + ".");
|
||||
const envelope = await response.json();
|
||||
if (!envelope || !Array.isArray(envelope.data) || !envelope.meta) {
|
||||
throw new Error("MinerOps returned an incomplete fleet response.");
|
||||
}
|
||||
state.returned = envelope.data.length;
|
||||
state.locationOptions = envelope.meta.location_options || null;
|
||||
const selectedSite = canViewLocations ? state.siteId : "";
|
||||
const selectedRoom = canViewLocations ? state.roomId : "";
|
||||
syncLocationOptions(selectedSite, selectedRoom);
|
||||
el.fleetRows.replaceChildren();
|
||||
for (const miner of envelope.data) el.fleetRows.appendChild(renderRow(miner));
|
||||
if (!envelope.data.length) {
|
||||
el.fleetPrevious.disabled = state.offset === 0;
|
||||
el.fleetNext.disabled = true;
|
||||
showState("empty", state.offset
|
||||
? "No miners are present on this page. Return to the previous page."
|
||||
: "No authorized miners match the current filters.");
|
||||
return;
|
||||
}
|
||||
el.fleetPrevious.disabled = state.offset === 0;
|
||||
el.fleetNext.disabled = state.returned < state.limit;
|
||||
const first = state.offset + 1;
|
||||
const last = state.offset + state.returned;
|
||||
el.fleetResultSummary.textContent = "Showing miners " + first + "-" + last +
|
||||
". Total fleet size is not calculated.";
|
||||
el.fleetFreshness.textContent = state.returned + " miners on this page";
|
||||
showState("ready");
|
||||
} catch (error) {
|
||||
showState(error.permissionDenied ? "denied" : "error",
|
||||
error && error.name === "AbortError"
|
||||
? "MinerOps took too long to respond. Retry in a moment."
|
||||
: display(error && error.message, "The fleet could not be loaded."));
|
||||
} finally {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
el.fleetFilters.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
if (canViewLocations) {
|
||||
state.siteId = el.fleetSite.value; state.roomId = el.fleetRoom.value;
|
||||
}
|
||||
state.offset = 0; setUrl("push"); load();
|
||||
});
|
||||
el.fleetReset.addEventListener("click", function () {
|
||||
el.fleetFilters.reset(); state.offset = 0; state.limit = 100;
|
||||
state.siteId = ""; state.roomId = "";
|
||||
syncLocationOptions("", ""); setUrl("push"); load();
|
||||
});
|
||||
el.fleetPrevious.addEventListener("click", function () {
|
||||
state.offset = Math.max(0, state.offset - state.limit); setUrl("push"); load();
|
||||
});
|
||||
el.fleetNext.addEventListener("click", function () {
|
||||
state.offset += state.limit; setUrl("push"); load();
|
||||
});
|
||||
el.fleetLimit.addEventListener("change", function () {
|
||||
state.limit = Number(el.fleetLimit.value); state.offset = 0; setUrl("push"); load();
|
||||
});
|
||||
if (canViewLocations) {
|
||||
el.fleetSite.addEventListener("change", function () {
|
||||
state.siteId = el.fleetSite.value; state.roomId = "";
|
||||
syncLocationOptions(state.siteId, "");
|
||||
});
|
||||
el.fleetRoom.addEventListener("change", function () {
|
||||
state.roomId = el.fleetRoom.value;
|
||||
});
|
||||
}
|
||||
window.addEventListener("popstate", function () { readUrl(); load(); });
|
||||
|
||||
readUrl();
|
||||
load();
|
||||
}());
|
||||
@@ -0,0 +1,784 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
if (document.body.dataset.view !== "map") return;
|
||||
|
||||
const ids = [
|
||||
"mapSite", "mapRoom", "mapLayer", "mapSearch", "mapLifecycle",
|
||||
"mapModel", "mapRepair", "mapStatus", "mapClearFilters",
|
||||
"mapProblemsOnly",
|
||||
"mapFilterCount", "mapScope", "mapSummary", "mapFreshness",
|
||||
"mapCriticalState", "mapCriticalTitle", "mapCriticalDetail",
|
||||
"mapRetry", "mapLoading", "mapWorkspace", "mapLegend", "mapNotice",
|
||||
"mapPermissionNote", "mapViewport", "mapLegacySection",
|
||||
"mapLegacyPositions", "mapUnplacedSection", "mapUnplaced",
|
||||
"mapInspector", "mapInspectorTitle", "mapInspectorStatus",
|
||||
"mapInspectorDetails", "mapInspectorClose", "mapOpenMiner"
|
||||
];
|
||||
const el = {};
|
||||
for (const id of ids) el[id] = document.getElementById(id);
|
||||
|
||||
const state = {
|
||||
data: null,
|
||||
siteId: "",
|
||||
roomId: "",
|
||||
layer: "status",
|
||||
query: "",
|
||||
lifecycle: "",
|
||||
model: "",
|
||||
repair: "",
|
||||
status: "",
|
||||
problemsOnly: false,
|
||||
minerId: "",
|
||||
minerDetailsIncluded: false
|
||||
};
|
||||
|
||||
function create(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function display(value, fallback) {
|
||||
if (value === null || value === undefined || String(value).trim() === "") {
|
||||
return fallback || "Unavailable";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function shelfLabel(value) {
|
||||
const label = display(value, "Unlabelled shelf");
|
||||
return /^shelf\b/i.test(label) ? label : "Shelf " + label;
|
||||
}
|
||||
|
||||
function words(value) {
|
||||
return display(value, "Unknown").replaceAll("_", " ").toLowerCase();
|
||||
}
|
||||
|
||||
function dateTime(value) {
|
||||
if (!value) return "Unavailable";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.valueOf()) ? "Unavailable" : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function metric(value, suffix) {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value + suffix : "Unavailable";
|
||||
}
|
||||
|
||||
function age(value) {
|
||||
if (!value) return "Never observed";
|
||||
const time = new Date(value).valueOf();
|
||||
if (Number.isNaN(time)) return "Observation time unavailable";
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000));
|
||||
if (seconds < 60) return seconds + "s ago";
|
||||
if (seconds < 3600) return Math.floor(seconds / 60) + "m ago";
|
||||
if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago";
|
||||
return Math.floor(seconds / 86400) + "d ago";
|
||||
}
|
||||
|
||||
const PROBLEM_KEYS = new Set([
|
||||
"offline", "unknown", "unstable", "repair", "conflict", "blocked",
|
||||
"expected"
|
||||
]);
|
||||
|
||||
const MEASUREMENT_LAYERS = {
|
||||
temperature: { field: "chip_temp_max_c", suffix: " C max" },
|
||||
hashrate: { field: "hashrate_ths", suffix: " TH/s" },
|
||||
power: { field: "power_w", suffix: " W" }
|
||||
};
|
||||
|
||||
function operational(position) {
|
||||
const kind = words(position.position_kind);
|
||||
const status = words(position.position_status);
|
||||
if (position.conflict_reason || status === "conflict") {
|
||||
return { key: "conflict", label: "Location conflict", mark: "Conflict" };
|
||||
}
|
||||
if (kind === "blocked" || status === "blocked") {
|
||||
return { key: "blocked", label: "Blocked position", mark: "Blocked" };
|
||||
}
|
||||
if (kind === "switch" || status === "switch") {
|
||||
return { key: "switch", label: "Switch / equipment", mark: "Equipment" };
|
||||
}
|
||||
if (!position.occupied) {
|
||||
if (position.expected_mac) {
|
||||
return { key: "expected", label: "Expected miner not resolved", mark: "Expected" };
|
||||
}
|
||||
if (kind === "miner slot" && status === "available") {
|
||||
return { key: "empty", label: "Empty position", mark: "Empty" };
|
||||
}
|
||||
return { key: "unknown", label: "Unknown position state", mark: "Unknown" };
|
||||
}
|
||||
if (!state.minerDetailsIncluded) {
|
||||
return { key: "restricted", label: "Occupied; miner details restricted", mark: "Restricted" };
|
||||
}
|
||||
if (position.active_repair || position.lifecycle_status === "in_repair") {
|
||||
return { key: "repair", label: "In repair", mark: "Repair" };
|
||||
}
|
||||
if (position.lifecycle_status === "retired" || position.asset_state === "decommissioned") {
|
||||
return { key: "retired", label: "Retired / decommissioned", mark: "Retired" };
|
||||
}
|
||||
if (position.lifecycle_status === "spare") {
|
||||
return { key: "spare", label: "Spare inventory", mark: "Spare" };
|
||||
}
|
||||
const runtime = words(position.miner_status);
|
||||
if (runtime === "offline") {
|
||||
return { key: "offline", label: "Offline", mark: "Offline" };
|
||||
}
|
||||
if (position.hash_stable === false) {
|
||||
return { key: "unstable", label: "Hash unstable", mark: "Unstable" };
|
||||
}
|
||||
if (runtime === "online") {
|
||||
return { key: "online", label: "Online", mark: "Online" };
|
||||
}
|
||||
return { key: "unknown", label: "Operational state unknown", mark: "Unknown" };
|
||||
}
|
||||
|
||||
function isProblem(position) {
|
||||
return PROBLEM_KEYS.has(operational(position).key);
|
||||
}
|
||||
|
||||
function measurementScale(positions) {
|
||||
const config = MEASUREMENT_LAYERS[state.layer];
|
||||
if (!config) return null;
|
||||
const values = positions.filter(function (position) {
|
||||
return position.occupied && matches(position) &&
|
||||
(!state.problemsOnly || isProblem(position));
|
||||
}).map(function (position) {
|
||||
return position[config.field];
|
||||
}).filter(function (value) {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
});
|
||||
if (!values.length) return { config: config, min: null, max: null };
|
||||
let min = values[0];
|
||||
let max = values[0];
|
||||
for (const value of values.slice(1)) {
|
||||
min = Math.min(min, value);
|
||||
max = Math.max(max, value);
|
||||
}
|
||||
return {
|
||||
config: config,
|
||||
min: min,
|
||||
max: max
|
||||
};
|
||||
}
|
||||
|
||||
function relativePercent(value, scale) {
|
||||
if (!scale || scale.min === null || scale.max === null ||
|
||||
typeof value !== "number" || !Number.isFinite(value)) return null;
|
||||
if (scale.min === scale.max) return 50;
|
||||
return Math.max(0, Math.min(
|
||||
100,
|
||||
((value - scale.min) / (scale.max - scale.min)) * 100
|
||||
));
|
||||
}
|
||||
|
||||
function layerValue(position, condition) {
|
||||
if (!position.occupied) return condition.label;
|
||||
if (!state.minerDetailsIncluded) return condition.label;
|
||||
if (state.layer === "temperature") {
|
||||
return metric(position.chip_temp_max_c, " C max");
|
||||
}
|
||||
if (state.layer === "hashrate") {
|
||||
return metric(position.hashrate_ths, " TH/s");
|
||||
}
|
||||
if (state.layer === "power") {
|
||||
return metric(position.power_w, " W");
|
||||
}
|
||||
if (state.layer === "repair") {
|
||||
if (position.active_repair) return words(position.active_repair.status);
|
||||
if (position.lifecycle_status === "in_repair") return "In-repair lifecycle";
|
||||
if (position.asset_state === "repair") return "Legacy repair location";
|
||||
return "No active repair";
|
||||
}
|
||||
return condition.label;
|
||||
}
|
||||
|
||||
function identity(position) {
|
||||
if (!state.minerDetailsIncluded) return "Miner details restricted";
|
||||
return display(position.asset_id, display(position.current_mac, "Unidentified miner"));
|
||||
}
|
||||
|
||||
function matches(position) {
|
||||
if (!position.occupied) return true;
|
||||
if (!state.minerDetailsIncluded) return true;
|
||||
const condition = operational(position);
|
||||
const haystack = [position.asset_id, position.miner_sn, position.current_mac,
|
||||
position.current_ip, position.model].map(function (value) {
|
||||
return display(value, "").toLowerCase();
|
||||
}).join(" ");
|
||||
return (!state.query || haystack.includes(state.query.toLowerCase())) &&
|
||||
(!state.lifecycle || position.lifecycle_status === state.lifecycle) &&
|
||||
(!state.model || position.model === state.model) &&
|
||||
(!state.status || condition.key === state.status) &&
|
||||
(!state.repair ||
|
||||
(state.repair === "active" && Boolean(position.active_repair)) ||
|
||||
(state.repair === "none" && !position.active_repair) ||
|
||||
(state.repair === "legacy" && position.asset_state === "repair" && !position.active_repair));
|
||||
}
|
||||
|
||||
function option(value, label) {
|
||||
const node = create("option", "", label);
|
||||
node.value = value;
|
||||
return node;
|
||||
}
|
||||
|
||||
function unique(values) {
|
||||
return Array.from(new Set(values.filter(Boolean))).sort(function (a, b) {
|
||||
return String(a).localeCompare(String(b), undefined, { numeric: true });
|
||||
});
|
||||
}
|
||||
|
||||
function syncSelectors() {
|
||||
const sites = state.data.sites;
|
||||
if (!sites.some(function (site) { return String(site.id) === state.siteId; })) {
|
||||
state.siteId = sites.length ? String(sites[0].id) : "";
|
||||
}
|
||||
el.mapSite.replaceChildren();
|
||||
for (const site of sites) el.mapSite.appendChild(option(String(site.id), site.name));
|
||||
el.mapSite.value = state.siteId;
|
||||
|
||||
const rooms = state.data.rooms.filter(function (room) {
|
||||
return String(room.site_id) === state.siteId;
|
||||
});
|
||||
if (!rooms.some(function (room) { return String(room.id) === state.roomId; })) {
|
||||
state.roomId = rooms.length ? String(rooms[0].id) : "";
|
||||
}
|
||||
el.mapRoom.replaceChildren();
|
||||
if (!rooms.length) el.mapRoom.appendChild(option("", "No rooms at this site"));
|
||||
for (const room of rooms) {
|
||||
el.mapRoom.appendChild(option(String(room.id), room.name));
|
||||
}
|
||||
el.mapRoom.value = state.roomId;
|
||||
el.mapRoom.disabled = !rooms.length;
|
||||
|
||||
const site = sites.find(function (item) { return String(item.id) === state.siteId; });
|
||||
const room = rooms.find(function (item) { return String(item.id) === state.roomId; });
|
||||
el.mapScope.textContent = site && room
|
||||
? site.name + " / " + room.name
|
||||
: site ? site.name + " / No configured rooms" : "No configured sites";
|
||||
}
|
||||
|
||||
function syncPermissionControls() {
|
||||
const allowed = state.minerDetailsIncluded;
|
||||
if (!allowed) {
|
||||
state.layer = "status";
|
||||
state.query = state.lifecycle = state.model = state.repair = state.status = "";
|
||||
state.problemsOnly = false;
|
||||
el.mapSearch.value = "";
|
||||
el.mapRepair.value = "";
|
||||
el.mapProblemsOnly.checked = false;
|
||||
}
|
||||
for (const item of el.mapLayer.options) {
|
||||
item.disabled = !allowed && item.value !== "status";
|
||||
}
|
||||
el.mapLayer.value = state.layer;
|
||||
el.mapSearch.disabled = !allowed;
|
||||
el.mapLifecycle.disabled = !allowed;
|
||||
el.mapModel.disabled = !allowed;
|
||||
el.mapRepair.disabled = !allowed;
|
||||
el.mapStatus.disabled = !allowed;
|
||||
el.mapClearFilters.disabled = !allowed;
|
||||
el.mapProblemsOnly.disabled = !allowed;
|
||||
el.mapSearch.closest(".field").hidden = !allowed;
|
||||
document.getElementById("mapFilters").hidden = !allowed;
|
||||
el.mapPermissionNote.hidden = allowed;
|
||||
}
|
||||
|
||||
function syncFilterOptions() {
|
||||
const positions = state.data.positions.filter(function (position) {
|
||||
return String(position.room_id || "") === state.roomId && position.occupied;
|
||||
});
|
||||
const lifecycle = unique(positions.map(function (item) { return item.lifecycle_status; }));
|
||||
const models = unique(positions.map(function (item) { return item.model; }));
|
||||
const statuses = unique(positions.map(function (item) { return operational(item).key; }));
|
||||
const selectedLifecycle = state.lifecycle;
|
||||
const selectedModel = state.model;
|
||||
const selectedStatus = state.status;
|
||||
el.mapLifecycle.replaceChildren(option("", "All lifecycle states"));
|
||||
lifecycle.forEach(function (value) { el.mapLifecycle.appendChild(option(value, words(value))); });
|
||||
el.mapModel.replaceChildren(option("", "All models"));
|
||||
models.forEach(function (value) { el.mapModel.appendChild(option(value, value)); });
|
||||
el.mapStatus.replaceChildren(option("", "All operational states"));
|
||||
statuses.forEach(function (value) { el.mapStatus.appendChild(option(value, words(value))); });
|
||||
if (lifecycle.includes(selectedLifecycle)) el.mapLifecycle.value = selectedLifecycle;
|
||||
else state.lifecycle = "";
|
||||
if (models.includes(selectedModel)) el.mapModel.value = selectedModel;
|
||||
else state.model = "";
|
||||
if (statuses.includes(selectedStatus)) el.mapStatus.value = selectedStatus;
|
||||
else state.status = "";
|
||||
}
|
||||
|
||||
function stateUrl() {
|
||||
const params = new URLSearchParams();
|
||||
const values = {
|
||||
site_id: state.siteId, room_id: state.roomId,
|
||||
layer: state.layer === "status" ? "" : state.layer,
|
||||
q: state.query, lifecycle: state.lifecycle, model: state.model,
|
||||
repair: state.repair, status: state.status,
|
||||
problems: state.problemsOnly ? "1" : "", miner_id: state.minerId
|
||||
};
|
||||
for (const pair of Object.entries(values)) {
|
||||
if (pair[1]) params.set(pair[0], pair[1]);
|
||||
}
|
||||
return window.location.pathname +
|
||||
(params.size ? "?" + params.toString() : "");
|
||||
}
|
||||
|
||||
function setUrl(mode) {
|
||||
window.history[mode === "push" ? "pushState" : "replaceState"](
|
||||
{}, "", stateUrl()
|
||||
);
|
||||
}
|
||||
|
||||
function readUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
state.siteId = params.get("site_id") || "";
|
||||
state.roomId = params.get("room_id") || "";
|
||||
state.layer = ["status", "temperature", "hashrate", "power", "repair"]
|
||||
.includes(params.get("layer")) ? params.get("layer") : "status";
|
||||
state.query = (params.get("q") || "").slice(0, 160);
|
||||
state.lifecycle = params.get("lifecycle") || "";
|
||||
state.model = params.get("model") || "";
|
||||
state.repair = ["active", "none", "legacy"].includes(params.get("repair"))
|
||||
? params.get("repair") : "";
|
||||
state.status = params.get("status") || "";
|
||||
state.problemsOnly = params.get("problems") === "1";
|
||||
state.minerId = params.get("miner_id") || "";
|
||||
syncPermissionControls();
|
||||
el.mapLayer.value = state.layer;
|
||||
el.mapSearch.value = state.query;
|
||||
el.mapRepair.value = state.repair;
|
||||
el.mapProblemsOnly.checked = state.problemsOnly;
|
||||
}
|
||||
|
||||
function appendLegendItem(key, label, mark) {
|
||||
const item = create("span", "legend-item");
|
||||
const marker = create("span", "legend-marker", mark);
|
||||
marker.dataset.status = key;
|
||||
marker.setAttribute("aria-hidden", "true");
|
||||
item.append(marker, document.createTextNode(label));
|
||||
el.mapLegend.appendChild(item);
|
||||
}
|
||||
|
||||
function legend(scale) {
|
||||
const entries = !state.minerDetailsIncluded
|
||||
? [["restricted", "Miner details restricted", "Restricted"],
|
||||
["empty", "Empty position", "Empty"],
|
||||
["conflict", "Location conflict", "Conflict"],
|
||||
["blocked", "Blocked position", "Blocked"],
|
||||
["switch", "Switch or equipment", "Equipment"]]
|
||||
: state.layer === "status"
|
||||
? [["online", "Online", "Online"], ["offline", "Offline", "Offline"],
|
||||
["unstable", "Hash unstable", "Unstable"],
|
||||
["repair", "In repair", "Repair"], ["spare", "Spare", "Spare"],
|
||||
["retired", "Retired", "Retired"],
|
||||
["unknown", "Operational state unknown", "Unknown"],
|
||||
["empty", "Empty position", "Empty"],
|
||||
["conflict", "Location conflict", "Conflict"]]
|
||||
: [["online", "Online marker", "Online"],
|
||||
["offline", "Offline marker", "Offline"],
|
||||
["unstable", "Hash unstable marker", "Unstable"],
|
||||
["repair", "In-repair marker", "Repair"],
|
||||
["unknown", "Unknown-state marker", "Unknown"],
|
||||
["empty", "Empty position", "Empty"]];
|
||||
el.mapLegend.replaceChildren();
|
||||
el.mapLegend.appendChild(create(
|
||||
"strong", "legend-title", el.mapLayer.options[el.mapLayer.selectedIndex].text
|
||||
));
|
||||
if (scale) {
|
||||
const range = create("span", "relative-scale");
|
||||
if (scale.min === null || scale.max === null) {
|
||||
range.textContent = "Relative range unavailable: no valid displayed readings";
|
||||
} else {
|
||||
const low = metric(scale.min, scale.config.suffix);
|
||||
const high = metric(scale.max, scale.config.suffix);
|
||||
range.setAttribute(
|
||||
"aria-label",
|
||||
"Relative range across emphasized miners from " + low + " to " + high
|
||||
);
|
||||
range.append(
|
||||
create("span", "relative-scale-label", "Relative range (emphasized)"),
|
||||
create("span", "relative-scale-value", low),
|
||||
create("span", "relative-scale-track"),
|
||||
create("span", "relative-scale-value", high)
|
||||
);
|
||||
}
|
||||
el.mapLegend.appendChild(range);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
appendLegendItem(entry[0], entry[1], entry[2]);
|
||||
}
|
||||
}
|
||||
|
||||
function positionTile(position, scale) {
|
||||
const condition = operational(position);
|
||||
const tile = create(position.occupied && position.miner_id ? "button" : "div", "map-position");
|
||||
tile.dataset.status = condition.key;
|
||||
tile.dataset.positionId = position.location_position_id;
|
||||
tile.dataset.occupied = String(position.occupied);
|
||||
tile.dataset.problem = String(isProblem(position));
|
||||
if (position.miner_id) tile.dataset.minerId = position.miner_id;
|
||||
if (position.occupied && !matches(position)) tile.dataset.filtered = "true";
|
||||
if (state.problemsOnly && position.occupied && !isProblem(position)) {
|
||||
tile.dataset.problemsMuted = "true";
|
||||
}
|
||||
if (String(position.miner_id) === state.minerId) tile.dataset.selected = "true";
|
||||
const scaleEligible = position.occupied && matches(position) &&
|
||||
(!state.problemsOnly || isProblem(position));
|
||||
const relative = scale && scaleEligible
|
||||
? relativePercent(position[scale.config.field], scale) : null;
|
||||
if (relative !== null) {
|
||||
tile.dataset.measurement = "true";
|
||||
tile.style.setProperty("--relative-percent", relative.toFixed(2) + "%");
|
||||
}
|
||||
if (tile.tagName === "BUTTON") {
|
||||
tile.type = "button";
|
||||
tile.tabIndex = -1;
|
||||
tile.setAttribute("aria-pressed", String(String(position.miner_id) === state.minerId));
|
||||
tile.setAttribute("aria-label", display(position.position_label, "Position " + position.position_index) +
|
||||
", " + identity(position) + ", " + condition.label + ", " + age(position.observed_at));
|
||||
} else {
|
||||
tile.setAttribute("role", "group");
|
||||
tile.setAttribute("aria-label", display(position.position_label, "Position " + position.position_index) +
|
||||
", " + condition.label);
|
||||
}
|
||||
const head = create("span", "position-head");
|
||||
const marker = create("span", "status-marker", condition.mark);
|
||||
marker.setAttribute("aria-hidden", "true");
|
||||
head.append(create("strong", "position-label", display(position.position_label, "Position " + position.position_index)),
|
||||
marker);
|
||||
const name = create("span", "position-miner", position.occupied ? identity(position) : condition.label);
|
||||
tile.append(head, name);
|
||||
if (position.occupied) {
|
||||
tile.append(
|
||||
create("span", "position-value", layerValue(position, condition)),
|
||||
create("span", "position-freshness", state.minerDetailsIncluded
|
||||
? age(position.observed_at) : "Miner details restricted")
|
||||
);
|
||||
}
|
||||
return tile;
|
||||
}
|
||||
|
||||
function renderMap() {
|
||||
syncSelectors();
|
||||
const positions = state.data.positions.filter(function (position) {
|
||||
return String(position.room_id || "") === state.roomId;
|
||||
});
|
||||
if (state.minerId && !state.data.positions.some(function (position) {
|
||||
const visible = String(position.room_id || "") === state.roomId || !position.room_id;
|
||||
return visible && position.miner_id && String(position.miner_id) === state.minerId;
|
||||
})) state.minerId = "";
|
||||
syncPermissionControls();
|
||||
syncFilterOptions();
|
||||
const scale = measurementScale(positions);
|
||||
legend(scale);
|
||||
el.mapViewport.replaceChildren();
|
||||
if (!state.data.sites.length) {
|
||||
el.mapNotice.hidden = false;
|
||||
el.mapNotice.textContent = "No sites are configured. Use location management to create the physical hierarchy.";
|
||||
} else if (!state.roomId) {
|
||||
el.mapNotice.hidden = false;
|
||||
el.mapNotice.textContent = "This site has no configured rooms.";
|
||||
} else if (!positions.length) {
|
||||
el.mapNotice.hidden = false;
|
||||
el.mapNotice.textContent = "No positions are configured for this room.";
|
||||
} else {
|
||||
el.mapNotice.hidden = true;
|
||||
}
|
||||
const racks = new Map();
|
||||
for (const position of positions) {
|
||||
const rack = display(position.rack_name, "Unlabelled rack");
|
||||
const shelf = display(position.shelf_name, "Unlabelled shelf");
|
||||
if (!racks.has(rack)) racks.set(rack, new Map());
|
||||
if (!racks.get(rack).has(shelf)) racks.get(rack).set(shelf, []);
|
||||
racks.get(rack).get(shelf).push(position);
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const rackEntry of racks) {
|
||||
const rack = create("section", "map-rack");
|
||||
rack.setAttribute("aria-label", "Rack " + rackEntry[0]);
|
||||
rack.appendChild(create("h2", "rack-title", "Rack " + rackEntry[0]));
|
||||
for (const shelfEntry of rackEntry[1]) {
|
||||
const shelf = create("section", "map-shelf");
|
||||
shelf.setAttribute("aria-label", shelfLabel(shelfEntry[0]));
|
||||
shelf.appendChild(create("h3", "shelf-title", shelfLabel(shelfEntry[0])));
|
||||
const grid = create("div", "position-grid");
|
||||
for (const position of shelfEntry[1]) {
|
||||
grid.appendChild(positionTile(position, scale));
|
||||
}
|
||||
shelf.appendChild(grid);
|
||||
rack.appendChild(shelf);
|
||||
}
|
||||
fragment.appendChild(rack);
|
||||
}
|
||||
el.mapViewport.appendChild(fragment);
|
||||
renderSummary(positions);
|
||||
renderLegacyPositions();
|
||||
renderUnplaced();
|
||||
renderInspector();
|
||||
syncTileTabStops();
|
||||
const count = [state.query, state.lifecycle, state.model, state.repair,
|
||||
state.status, state.problemsOnly].filter(Boolean).length;
|
||||
el.mapFilterCount.textContent = count ? "(" + count + " active)" : "";
|
||||
el.mapFreshness.textContent = "Map generated " + dateTime(state.meta.generated_at) +
|
||||
(state.minerDetailsIncluded ? ". Telemetry age is shown per miner."
|
||||
: ". Miner details are restricted for this account.");
|
||||
}
|
||||
|
||||
function renderSummary(positions) {
|
||||
const facts = { occupied: 0, empty: 0, conflict: 0, offline: 0,
|
||||
repair: 0, unknown: 0, restricted: 0, problems: 0 };
|
||||
for (const position of positions) {
|
||||
const condition = operational(position);
|
||||
if (position.occupied) facts.occupied += 1;
|
||||
if (condition.key in facts) facts[condition.key] += 1;
|
||||
if (isProblem(position)) facts.problems += 1;
|
||||
}
|
||||
el.mapSummary.replaceChildren();
|
||||
const pairs = [["Positions", positions.length], ["Occupied", facts.occupied],
|
||||
["Empty", facts.empty], ["Conflicts", facts.conflict]];
|
||||
if (state.minerDetailsIncluded) pairs.splice(3, 0,
|
||||
["Problems", facts.problems], ["Offline", facts.offline],
|
||||
["In repair", facts.repair], ["Unknown", facts.unknown]);
|
||||
else pairs.splice(3, 0, ["Restricted", facts.restricted]);
|
||||
for (const pair of pairs) {
|
||||
const item = create("span", "summary-fact");
|
||||
item.append(create("strong", "", String(pair[1])), document.createTextNode(pair[0]));
|
||||
el.mapSummary.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLegacyPositions() {
|
||||
const positions = state.data.positions.filter(function (position) {
|
||||
return !position.room_id;
|
||||
});
|
||||
el.mapLegacySection.hidden = !positions.length;
|
||||
el.mapLegacyPositions.replaceChildren();
|
||||
for (const position of positions) {
|
||||
const item = create("article", "legacy-position-entry");
|
||||
item.append(create("strong", "", display(position.position_label,
|
||||
"Position " + position.position_index)),
|
||||
create("span", "legacy-position-path", "Unscoped / Room label " +
|
||||
display(position.room_name, "Unavailable") + " / Rack " +
|
||||
display(position.rack_name, "Unavailable") + " / Shelf " +
|
||||
display(position.shelf_name, "Unavailable")),
|
||||
positionTile(position));
|
||||
el.mapLegacyPositions.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function renderUnplaced() {
|
||||
const miners = state.data.virtual_miners.filter(function (miner) {
|
||||
return (!state.siteId || String(miner.site_id || "") === state.siteId) &&
|
||||
(!state.roomId || !miner.room_id || String(miner.room_id) === state.roomId);
|
||||
});
|
||||
el.mapUnplacedSection.hidden = !miners.length;
|
||||
el.mapUnplaced.replaceChildren();
|
||||
for (const miner of miners) {
|
||||
const item = create("div", "unplaced-miner");
|
||||
item.append(create("strong", "", display(miner.asset_id, display(miner.current_mac, "Unidentified miner"))),
|
||||
create("span", "", words(miner.asset_state) + " / " + words(miner.lifecycle_status)),
|
||||
create("span", "", miner.active_repair
|
||||
? "Active repair: " + words(miner.active_repair.status)
|
||||
: "No active repair record"));
|
||||
el.mapUnplaced.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function detailRow(term, value) {
|
||||
el.mapInspectorDetails.append(create("dt", "", term), create("dd", "", display(value)));
|
||||
}
|
||||
|
||||
function renderInspector() {
|
||||
const position = state.data.positions.find(function (item) {
|
||||
return item.miner_id && String(item.miner_id) === state.minerId;
|
||||
});
|
||||
if (!position) {
|
||||
el.mapInspector.hidden = true;
|
||||
return;
|
||||
}
|
||||
const condition = operational(position);
|
||||
el.mapInspector.hidden = false;
|
||||
el.mapInspectorTitle.textContent = identity(position);
|
||||
el.mapInspectorStatus.textContent = condition.label + " / " + age(position.observed_at);
|
||||
el.mapInspectorStatus.dataset.status = condition.key;
|
||||
el.mapInspectorDetails.replaceChildren();
|
||||
detailRow("Asset ID", position.asset_id);
|
||||
detailRow("Model", position.model || position.miner_type);
|
||||
detailRow("Serial", position.miner_sn);
|
||||
detailRow("Current MAC", position.current_mac);
|
||||
detailRow("Current IP", position.current_ip);
|
||||
const location = position.room_id
|
||||
? [position.site_name, position.room_name, "Rack " + position.rack_name,
|
||||
shelfLabel(position.shelf_name), display(position.position_label,
|
||||
"Position " + position.position_index)].join(" / ")
|
||||
: ["Unscoped", "Room label " + display(position.room_name),
|
||||
"Rack " + display(position.rack_name), shelfLabel(position.shelf_name),
|
||||
display(position.position_label, "Position " + position.position_index)].join(" / ");
|
||||
detailRow("Location", location);
|
||||
detailRow("Lifecycle", words(position.lifecycle_status));
|
||||
detailRow("Runtime", words(position.miner_status));
|
||||
detailRow("Repair", position.active_repair
|
||||
? words(position.active_repair.status) + " / " + words(position.active_repair.reason)
|
||||
: position.asset_state === "repair" ? "Legacy repair location state" : "No active repair");
|
||||
detailRow("Hashrate", metric(position.hashrate_ths, " TH/s"));
|
||||
detailRow("Power", metric(position.power_w, " W"));
|
||||
detailRow("Chip temperature", metric(position.chip_temp_max_c, " C max"));
|
||||
detailRow("Observed", dateTime(position.observed_at));
|
||||
const returnTo = stateUrl();
|
||||
el.mapOpenMiner.href = "/miners/" + encodeURIComponent(position.miner_id) +
|
||||
"/detail?return_to=" + encodeURIComponent(returnTo);
|
||||
}
|
||||
|
||||
function mapTiles() {
|
||||
return Array.from(el.mapWorkspace.querySelectorAll("button.map-position[data-miner-id]"));
|
||||
}
|
||||
|
||||
function syncTileTabStops() {
|
||||
const tiles = mapTiles();
|
||||
const current = tiles.find(function (tile) {
|
||||
return tile.dataset.minerId === state.minerId;
|
||||
});
|
||||
for (const tile of tiles) tile.tabIndex = tile === current ? 0 : -1;
|
||||
if (!current && tiles.length) tiles[0].tabIndex = 0;
|
||||
}
|
||||
|
||||
function syncSelection() {
|
||||
for (const tile of mapTiles()) {
|
||||
const selected = tile.dataset.minerId === state.minerId;
|
||||
tile.dataset.selected = String(selected);
|
||||
tile.setAttribute("aria-pressed", String(selected));
|
||||
}
|
||||
renderInspector();
|
||||
syncTileTabStops();
|
||||
}
|
||||
|
||||
function bind() {
|
||||
const controls = [el.mapLayer, el.mapSearch, el.mapLifecycle, el.mapModel, el.mapRepair, el.mapStatus];
|
||||
let renderFrame = null;
|
||||
function scheduleRender() {
|
||||
if (renderFrame !== null) window.cancelAnimationFrame(renderFrame);
|
||||
renderFrame = window.requestAnimationFrame(function () {
|
||||
renderFrame = null;
|
||||
renderMap();
|
||||
setUrl("replace");
|
||||
});
|
||||
}
|
||||
el.mapSite.addEventListener("change", function () {
|
||||
state.siteId = el.mapSite.value; state.roomId = ""; state.minerId = "";
|
||||
renderMap(); setUrl("push");
|
||||
});
|
||||
el.mapRoom.addEventListener("change", function () {
|
||||
state.roomId = el.mapRoom.value; state.minerId = ""; renderMap(); setUrl("push");
|
||||
});
|
||||
for (const control of controls) {
|
||||
control.addEventListener(control === el.mapSearch ? "input" : "change", function () {
|
||||
state.layer = el.mapLayer.value; state.query = el.mapSearch.value.trim();
|
||||
state.lifecycle = el.mapLifecycle.value; state.model = el.mapModel.value;
|
||||
state.repair = el.mapRepair.value; state.status = el.mapStatus.value;
|
||||
scheduleRender();
|
||||
});
|
||||
}
|
||||
el.mapProblemsOnly.addEventListener("change", function () {
|
||||
state.problemsOnly = el.mapProblemsOnly.checked;
|
||||
scheduleRender();
|
||||
});
|
||||
el.mapClearFilters.addEventListener("click", function () {
|
||||
state.query = state.lifecycle = state.model = state.repair = state.status = "";
|
||||
state.problemsOnly = false;
|
||||
el.mapSearch.value = ""; el.mapRepair.value = "";
|
||||
el.mapProblemsOnly.checked = false;
|
||||
renderMap(); setUrl("replace");
|
||||
});
|
||||
el.mapWorkspace.addEventListener("click", function (event) {
|
||||
const tile = event.target.closest("button[data-miner-id]");
|
||||
if (!tile || !el.mapWorkspace.contains(tile)) return;
|
||||
state.minerId = tile.dataset.minerId;
|
||||
syncSelection(); setUrl("push");
|
||||
el.mapInspectorClose.focus();
|
||||
});
|
||||
el.mapWorkspace.addEventListener("keydown", function (event) {
|
||||
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||
const tile = event.target.closest("button.map-position[data-miner-id]");
|
||||
if (!tile) return;
|
||||
const tiles = mapTiles();
|
||||
const current = tiles.indexOf(tile);
|
||||
const delta = ["ArrowLeft", "ArrowUp"].includes(event.key) ? -1 : 1;
|
||||
const next = tiles[(current + delta + tiles.length) % tiles.length];
|
||||
event.preventDefault();
|
||||
for (const item of tiles) item.tabIndex = item === next ? 0 : -1;
|
||||
next.focus();
|
||||
});
|
||||
el.mapInspectorClose.addEventListener("click", function () {
|
||||
const restoreId = state.minerId; state.minerId = "";
|
||||
syncSelection(); setUrl("push");
|
||||
const restore = mapTiles().find(function (tile) {
|
||||
return tile.dataset.minerId === restoreId;
|
||||
});
|
||||
if (restore) {
|
||||
for (const tile of mapTiles()) tile.tabIndex = -1;
|
||||
restore.tabIndex = 0;
|
||||
restore.focus();
|
||||
}
|
||||
});
|
||||
el.mapRetry.addEventListener("click", load);
|
||||
window.addEventListener("popstate", function () {
|
||||
readUrl(); renderMap(); setUrl("replace");
|
||||
});
|
||||
}
|
||||
|
||||
function validate(envelope) {
|
||||
return envelope && envelope.data && envelope.meta &&
|
||||
Array.isArray(envelope.data.sites) && Array.isArray(envelope.data.rooms) &&
|
||||
Array.isArray(envelope.data.positions) && Array.isArray(envelope.data.virtual_miners) &&
|
||||
envelope.data.positions.every(function (item) {
|
||||
return item && typeof item.location_position_id === "string" &&
|
||||
typeof item.position_index === "number";
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
el.mapLoading.hidden = false;
|
||||
el.mapCriticalState.hidden = true;
|
||||
el.mapWorkspace.hidden = true;
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(function () { controller.abort(); }, 15000);
|
||||
try {
|
||||
const response = await fetch("/api/location/map", {
|
||||
credentials: "same-origin", headers: { Accept: "application/json" },
|
||||
signal: controller.signal
|
||||
});
|
||||
if (response.status === 401) {
|
||||
window.location.replace("/login?next=" + encodeURIComponent(window.location.pathname + window.location.search));
|
||||
return;
|
||||
}
|
||||
if (response.status === 403) {
|
||||
el.mapCriticalTitle.textContent = "Map access unavailable";
|
||||
el.mapCriticalDetail.textContent = "Your account cannot view location data.";
|
||||
el.mapCriticalState.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error("The map service returned " + response.status + ".");
|
||||
const envelope = await response.json();
|
||||
if (!validate(envelope)) throw new Error("The map response was incomplete.");
|
||||
state.data = envelope.data; state.meta = envelope.meta;
|
||||
state.minerDetailsIncluded = envelope.meta.miner_details_included === true;
|
||||
readUrl(); syncSelectors();
|
||||
el.mapWorkspace.hidden = false;
|
||||
renderMap(); setUrl("replace");
|
||||
} catch (error) {
|
||||
el.mapCriticalTitle.textContent = "Map unavailable";
|
||||
el.mapCriticalDetail.textContent = error && error.name === "AbortError"
|
||||
? "MinerOps took too long to respond. Retry in a moment."
|
||||
: display(error && error.message, "The map could not be loaded.");
|
||||
el.mapCriticalState.hidden = false;
|
||||
} finally {
|
||||
window.clearTimeout(timer); el.mapLoading.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
bind();
|
||||
load();
|
||||
}());
|
||||
@@ -0,0 +1,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();
|
||||
})();
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Mine Map location read domain."""
|
||||
|
||||
from app.locations.models import MineMapResponse
|
||||
from app.locations.repository import LocationMapRepository
|
||||
from app.locations.service import LocationMapService
|
||||
|
||||
__all__ = ["LocationMapRepository", "LocationMapService", "MineMapResponse"]
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Dependency construction for the location read domain."""
|
||||
|
||||
from app.locations.repository import LocationMapRepository
|
||||
from app.locations.service import LocationMapService
|
||||
|
||||
|
||||
def get_location_map_service() -> LocationMapService:
|
||||
return LocationMapService(LocationMapRepository())
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Typed contracts for the read-only Mine Map."""
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MapSite(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
|
||||
|
||||
class MapRoom(BaseModel):
|
||||
id: UUID
|
||||
site_id: UUID
|
||||
site_name: str
|
||||
name: str
|
||||
sort_order: int
|
||||
|
||||
|
||||
class MapActiveRepair(BaseModel):
|
||||
id: UUID
|
||||
status: str
|
||||
reason: str
|
||||
|
||||
|
||||
class MapPosition(BaseModel):
|
||||
location_position_id: UUID
|
||||
site_id: UUID | None = None
|
||||
site_name: str | None = None
|
||||
room_id: UUID | None = None
|
||||
room_name: str
|
||||
rack_name: str
|
||||
shelf_name: str
|
||||
shelf_index: int | None = None
|
||||
position_index: int
|
||||
position_label: str | None = None
|
||||
position_kind: str
|
||||
position_status: str
|
||||
occupied: bool
|
||||
expected_mac: str | None = None
|
||||
equipment_label: str | None = None
|
||||
notes: str | None = None
|
||||
source_file: str | None = None
|
||||
source_cell: str | None = None
|
||||
conflict_reason: str | None = None
|
||||
miner_id: UUID | None = None
|
||||
asset_id: str | None = None
|
||||
current_mac: str | None = None
|
||||
current_ip: str | None = None
|
||||
miner_status: str | None = None
|
||||
hostname: str | None = None
|
||||
miner_sn: str | None = None
|
||||
power_sn: str | None = None
|
||||
model: str | None = None
|
||||
miner_type: str | None = None
|
||||
firmware_version: str | None = None
|
||||
lifecycle_status: str | None = None
|
||||
last_seen_at: datetime | None = None
|
||||
asset_state: str | None = None
|
||||
observed_at: datetime | None = None
|
||||
hashrate_ths: float | None = None
|
||||
power_w: float | None = None
|
||||
chip_temp_max_c: float | None = None
|
||||
chip_temp_avg_c: float | None = None
|
||||
hash_stable: bool | None = None
|
||||
pool_rejected_percent: float | None = None
|
||||
active_repair: MapActiveRepair | None = None
|
||||
|
||||
|
||||
class MapVirtualMiner(BaseModel):
|
||||
miner_id: UUID | None = None
|
||||
site_id: UUID | None = None
|
||||
room_id: UUID | None = None
|
||||
asset_id: str | None = None
|
||||
asset_state: str
|
||||
current_mac: str | None = None
|
||||
current_ip: str | None = None
|
||||
miner_status: str | None = None
|
||||
status: str | None = None
|
||||
hostname: str | None = None
|
||||
miner_sn: str | None = None
|
||||
power_sn: str | None = None
|
||||
model: str | None = None
|
||||
miner_type: str | None = None
|
||||
firmware_version: str | None = None
|
||||
lifecycle_status: str | None = None
|
||||
last_seen_at: datetime | None = None
|
||||
observed_at: datetime | None = None
|
||||
hashrate_ths: float | None = None
|
||||
power_w: float | None = None
|
||||
chip_temp_max_c: float | None = None
|
||||
chip_temp_avg_c: float | None = None
|
||||
hash_stable: bool | None = None
|
||||
active_repair: MapActiveRepair | None = None
|
||||
|
||||
|
||||
class MineMapData(BaseModel):
|
||||
sites: list[MapSite]
|
||||
rooms: list[MapRoom]
|
||||
positions: list[MapPosition]
|
||||
virtual_miners: list[MapVirtualMiner]
|
||||
|
||||
|
||||
class MineMapMeta(BaseModel):
|
||||
generated_at: datetime
|
||||
site_id: UUID | None = None
|
||||
room_id: UUID | None = None
|
||||
position_count: int
|
||||
virtual_miner_count: int
|
||||
miner_details_included: bool
|
||||
|
||||
|
||||
class MineMapResponse(BaseModel):
|
||||
data: MineMapData
|
||||
meta: MineMapMeta
|
||||
error: None = None
|
||||
|
||||
|
||||
class MinerLocationDetail(BaseModel):
|
||||
site_id: UUID | None = None
|
||||
site_name: str | None = None
|
||||
room_id: UUID | None = None
|
||||
room_name: str | None = None
|
||||
position_id: UUID
|
||||
rack_label: str | None = None
|
||||
shelf_label: str | None = None
|
||||
position_label: str | None = None
|
||||
position_index: int | None = None
|
||||
|
||||
|
||||
class LocationSummary(BaseModel):
|
||||
sites: int
|
||||
rooms: int
|
||||
positions: int
|
||||
occupied_positions: int
|
||||
empty_positions: int
|
||||
mapped_miners: int | None = None
|
||||
unmapped_miners: int | None = None
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Set-based PostgreSQL reads for Mine Map v1."""
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
ACTIVE_REPAIR_STATUSES = (
|
||||
"OPEN",
|
||||
"IN_PROGRESS",
|
||||
"WAITING_FOR_PARTS",
|
||||
"READY_FOR_TEST",
|
||||
)
|
||||
|
||||
|
||||
def default_connection_factory() -> psycopg.Connection:
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
LATEST_TELEMETRY_SQL = """
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT snapshots.observed_at,
|
||||
ROUND((snapshots.mhs_av / 1000000.0)::numeric, 2)::float
|
||||
AS hashrate_ths,
|
||||
ROUND(snapshots.power_w::numeric, 2)::float AS power_w,
|
||||
ROUND(snapshots.chip_temp_max_c::numeric, 2)::float
|
||||
AS chip_temp_max_c,
|
||||
ROUND(snapshots.chip_temp_avg_c::numeric, 2)::float
|
||||
AS chip_temp_avg_c,
|
||||
snapshots.hash_stable, snapshots.pool_rejected_percent
|
||||
FROM miner_status_snapshots snapshots
|
||||
WHERE snapshots.miner_id = miners.id
|
||||
ORDER BY snapshots.observed_at DESC, snapshots.id DESC
|
||||
LIMIT 1
|
||||
) latest ON true
|
||||
"""
|
||||
|
||||
ACTIVE_REPAIR_SQL = """
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT repairs.id, repairs.status, repairs.reason
|
||||
FROM repairs
|
||||
WHERE repairs.miner_id = miners.id AND repairs.status = ANY(%s)
|
||||
ORDER BY repairs.opened_at, repairs.id
|
||||
LIMIT 1
|
||||
) active_repair ON true
|
||||
"""
|
||||
|
||||
|
||||
class LocationMapRepository:
|
||||
"""Own all persistence used by the Mine Map read model."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connection_factory: ConnectionFactory | None = None,
|
||||
*,
|
||||
connection: psycopg.Connection | None = None,
|
||||
):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
self._connection = connection
|
||||
|
||||
@contextmanager
|
||||
def _connection_scope(self) -> Iterator[psycopg.Connection]:
|
||||
if self._connection is not None:
|
||||
yield self._connection
|
||||
return
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def read_map(
|
||||
self,
|
||||
*,
|
||||
site_id: UUID | None = None,
|
||||
room_id: UUID | None = None,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
position_where = []
|
||||
position_params: list[Any] = [list(ACTIVE_REPAIR_STATUSES)]
|
||||
virtual_where = [
|
||||
"COALESCE(mls.asset_state, 'waiting_placement') = ANY(%s)",
|
||||
"NOT EXISTS (SELECT 1 FROM location_positions assigned "
|
||||
"WHERE assigned.miner_id = miners.id)",
|
||||
]
|
||||
virtual_params: list[Any] = [
|
||||
list(ACTIVE_REPAIR_STATUSES),
|
||||
["waiting_placement", "storage", "repair", "decommissioned"],
|
||||
]
|
||||
if site_id is not None:
|
||||
position_where.append("rooms.site_id = %s")
|
||||
position_params.append(site_id)
|
||||
virtual_where.append("miners.site_id = %s")
|
||||
virtual_params.append(site_id)
|
||||
if room_id is not None:
|
||||
position_where.append("lp.room_id = %s")
|
||||
position_params.append(room_id)
|
||||
virtual_where.append("miners.room_id = %s")
|
||||
virtual_params.append(room_id)
|
||||
position_filter = (
|
||||
"WHERE " + " AND ".join(position_where) if position_where else ""
|
||||
)
|
||||
virtual_filter = "WHERE " + " AND ".join(virtual_where)
|
||||
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute("SELECT id, name FROM sites ORDER BY lower(name), id")
|
||||
sites = [dict(row) for row in cur.fetchall()]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT rooms.id, rooms.site_id, sites.name AS site_name,
|
||||
rooms.name, rooms.sort_order
|
||||
FROM rooms JOIN sites ON sites.id = rooms.site_id
|
||||
ORDER BY lower(sites.name), rooms.sort_order,
|
||||
lower(rooms.name), rooms.id
|
||||
"""
|
||||
)
|
||||
rooms = [dict(row) for row in cur.fetchall()]
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT lp.id AS location_position_id, sites.id AS site_id,
|
||||
sites.name AS site_name, rooms.id AS room_id,
|
||||
COALESCE(rooms.name, lp.room_name) AS room_name,
|
||||
lp.rack_name, lp.shelf_name, lp.shelf_index,
|
||||
lp.position_index, lp.position_label, lp.position_kind,
|
||||
lp.position_status, (miners.id IS NOT NULL) AS occupied,
|
||||
lp.expected_mac, lp.equipment_label, lp.notes,
|
||||
lp.source_file, lp.source_cell, lp.conflict_reason,
|
||||
miners.id AS miner_id, miners.asset_tag AS asset_id,
|
||||
miners.current_mac, host(miners.current_ip) AS current_ip,
|
||||
miners.status AS miner_status, miners.hostname,
|
||||
miners.miner_sn, miners.power_sn, miners.model,
|
||||
miners.miner_type, miners.firmware_version,
|
||||
miners.lifecycle_status, miners.last_seen_at,
|
||||
COALESCE(mls.asset_state, 'unassigned') AS asset_state,
|
||||
latest.observed_at, latest.hashrate_ths, latest.power_w,
|
||||
latest.chip_temp_max_c, latest.chip_temp_avg_c,
|
||||
latest.hash_stable, latest.pool_rejected_percent,
|
||||
active_repair.id AS active_repair_id,
|
||||
active_repair.status AS active_repair_status,
|
||||
active_repair.reason AS active_repair_reason
|
||||
FROM location_positions lp
|
||||
LEFT JOIN rooms ON rooms.id = lp.room_id
|
||||
LEFT JOIN sites ON sites.id = rooms.site_id
|
||||
LEFT JOIN miners ON miners.id = lp.miner_id
|
||||
LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id
|
||||
{LATEST_TELEMETRY_SQL}
|
||||
{ACTIVE_REPAIR_SQL}
|
||||
{position_filter}
|
||||
ORDER BY lower(sites.name) NULLS LAST,
|
||||
rooms.sort_order NULLS LAST,
|
||||
lower(COALESCE(rooms.name, lp.room_name)),
|
||||
lower(lp.rack_name), lp.shelf_index NULLS LAST,
|
||||
lower(lp.shelf_name), lp.position_index, lp.id
|
||||
""",
|
||||
position_params,
|
||||
)
|
||||
positions = [dict(row) for row in cur.fetchall()]
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT miners.id AS miner_id, miners.site_id, miners.room_id,
|
||||
miners.asset_tag AS asset_id,
|
||||
COALESCE(mls.asset_state, 'waiting_placement') AS asset_state,
|
||||
miners.current_mac, host(miners.current_ip) AS current_ip,
|
||||
miners.status AS miner_status, miners.status, miners.hostname,
|
||||
miners.miner_sn, miners.power_sn, miners.model,
|
||||
miners.miner_type, miners.firmware_version,
|
||||
miners.lifecycle_status, miners.last_seen_at,
|
||||
latest.observed_at, latest.hashrate_ths, latest.power_w,
|
||||
latest.chip_temp_max_c, latest.chip_temp_avg_c,
|
||||
latest.hash_stable,
|
||||
active_repair.id AS active_repair_id,
|
||||
active_repair.status AS active_repair_status,
|
||||
active_repair.reason AS active_repair_reason
|
||||
FROM miners
|
||||
LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id
|
||||
{LATEST_TELEMETRY_SQL}
|
||||
{ACTIVE_REPAIR_SQL}
|
||||
{virtual_filter}
|
||||
ORDER BY asset_state, lower(miners.asset_tag), miners.id
|
||||
""",
|
||||
virtual_params,
|
||||
)
|
||||
virtual_miners = [dict(row) for row in cur.fetchall()]
|
||||
return {
|
||||
"sites": sites,
|
||||
"rooms": rooms,
|
||||
"positions": positions,
|
||||
"virtual_miners": virtual_miners,
|
||||
}
|
||||
|
||||
def summarize_locations(self, *, include_miner_counts: bool) -> dict[str, Any]:
|
||||
miner_counts = (
|
||||
"count(DISTINCT lp.miner_id)::integer AS mapped_miners, "
|
||||
"(SELECT count(*)::integer FROM miners WHERE NOT EXISTS ("
|
||||
"SELECT 1 FROM location_positions assigned "
|
||||
"WHERE assigned.miner_id = miners.id)) AS unmapped_miners"
|
||||
if include_miner_counts
|
||||
else "NULL::integer AS mapped_miners, NULL::integer AS unmapped_miners"
|
||||
)
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT (SELECT count(*)::integer FROM sites) AS sites,
|
||||
(SELECT count(*)::integer FROM rooms) AS rooms,
|
||||
count(lp.id)::integer AS positions,
|
||||
count(lp.id) FILTER (
|
||||
WHERE lp.miner_id IS NOT NULL
|
||||
)::integer AS occupied_positions,
|
||||
count(lp.id) FILTER (
|
||||
WHERE lp.miner_id IS NULL
|
||||
)::integer AS empty_positions,
|
||||
{miner_counts}
|
||||
FROM location_positions AS lp
|
||||
"""
|
||||
)
|
||||
return dict(cur.fetchone())
|
||||
|
||||
def get_miner_location(self, miner_id: UUID) -> dict[str, Any] | None:
|
||||
"""Return the one current physical position assigned to a miner."""
|
||||
with self._connection_scope() as connection:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT sites.id AS site_id, sites.name AS site_name,
|
||||
rooms.id AS room_id,
|
||||
COALESCE(rooms.name, position.room_name) AS room_name,
|
||||
position.id AS position_id,
|
||||
position.rack_name AS rack_label,
|
||||
position.shelf_name AS shelf_label,
|
||||
position.position_label,
|
||||
position.position_index
|
||||
FROM location_positions AS position
|
||||
LEFT JOIN rooms ON rooms.id = position.room_id
|
||||
LEFT JOIN sites ON sites.id = rooms.site_id
|
||||
WHERE position.miner_id = %s
|
||||
ORDER BY position.id
|
||||
LIMIT 1
|
||||
""",
|
||||
(miner_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
@@ -0,0 +1,30 @@
|
||||
"""HTTP boundary for the read-only Mine Map API."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.identity import require_permission, user_has_permission
|
||||
from app.locations.dependencies import get_location_map_service
|
||||
from app.locations.models import MineMapResponse
|
||||
from app.locations.service import LocationMapService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/location", tags=["locations"])
|
||||
MapUser = Annotated[dict, Depends(require_permission("locations.view"))]
|
||||
MapService = Annotated[LocationMapService, Depends(get_location_map_service)]
|
||||
|
||||
|
||||
@router.get("/map", response_model=MineMapResponse)
|
||||
def get_mine_map(
|
||||
current_user: MapUser,
|
||||
service: MapService,
|
||||
site_id: Annotated[UUID | None, Query()] = None,
|
||||
room_id: Annotated[UUID | None, Query()] = None,
|
||||
) -> MineMapResponse:
|
||||
return service.get_map(
|
||||
site_id=site_id,
|
||||
room_id=room_id,
|
||||
include_miner_details=user_has_permission(current_user, "miners.view"),
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Mine Map read-model composition independent of HTTP."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.locations.models import (
|
||||
LocationSummary,
|
||||
MapActiveRepair,
|
||||
MapPosition,
|
||||
MapRoom,
|
||||
MapSite,
|
||||
MapVirtualMiner,
|
||||
MineMapData,
|
||||
MineMapMeta,
|
||||
MineMapResponse,
|
||||
MinerLocationDetail,
|
||||
)
|
||||
from app.locations.repository import LocationMapRepository
|
||||
|
||||
|
||||
MINER_FIELDS = {
|
||||
"miner_id", "asset_id", "current_mac", "expected_mac", "current_ip", "miner_status",
|
||||
"status",
|
||||
"hostname", "miner_sn", "power_sn", "model", "miner_type",
|
||||
"firmware_version", "lifecycle_status", "last_seen_at", "asset_state",
|
||||
"observed_at", "hashrate_ths", "power_w", "chip_temp_max_c",
|
||||
"chip_temp_avg_c", "hash_stable", "pool_rejected_percent",
|
||||
}
|
||||
|
||||
|
||||
class LocationMapService:
|
||||
def __init__(self, repository: LocationMapRepository):
|
||||
self.repository = repository
|
||||
|
||||
@staticmethod
|
||||
def _repair(row: dict[str, Any]) -> MapActiveRepair | None:
|
||||
repair_id = row.pop("active_repair_id", None)
|
||||
status = row.pop("active_repair_status", None)
|
||||
reason = row.pop("active_repair_reason", None)
|
||||
if repair_id is None:
|
||||
return None
|
||||
return MapActiveRepair(id=repair_id, status=status, reason=reason)
|
||||
|
||||
@classmethod
|
||||
def _redact(cls, row: dict[str, Any]) -> dict[str, Any]:
|
||||
redacted = dict(row)
|
||||
for field in MINER_FIELDS:
|
||||
redacted[field] = None
|
||||
redacted["active_repair_id"] = None
|
||||
redacted["active_repair_status"] = None
|
||||
redacted["active_repair_reason"] = None
|
||||
return redacted
|
||||
|
||||
def get_map(
|
||||
self,
|
||||
*,
|
||||
site_id: UUID | None = None,
|
||||
room_id: UUID | None = None,
|
||||
include_miner_details: bool,
|
||||
) -> MineMapResponse:
|
||||
rows = self.repository.read_map(site_id=site_id, room_id=room_id)
|
||||
positions = []
|
||||
for source in rows["positions"]:
|
||||
row = dict(source) if include_miner_details else self._redact(source)
|
||||
row["active_repair"] = self._repair(row)
|
||||
positions.append(MapPosition.model_validate(row))
|
||||
virtual_miners = []
|
||||
if include_miner_details:
|
||||
for source in rows["virtual_miners"]:
|
||||
row = dict(source)
|
||||
row["active_repair"] = self._repair(row)
|
||||
virtual_miners.append(MapVirtualMiner.model_validate(row))
|
||||
return MineMapResponse(
|
||||
data=MineMapData(
|
||||
sites=[MapSite.model_validate(row) for row in rows["sites"]],
|
||||
rooms=[MapRoom.model_validate(row) for row in rows["rooms"]],
|
||||
positions=positions,
|
||||
virtual_miners=virtual_miners,
|
||||
),
|
||||
meta=MineMapMeta(
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
site_id=site_id,
|
||||
room_id=room_id,
|
||||
position_count=len(positions),
|
||||
virtual_miner_count=len(virtual_miners),
|
||||
miner_details_included=include_miner_details,
|
||||
),
|
||||
)
|
||||
|
||||
def get_miner_location(self, miner_id: UUID) -> MinerLocationDetail | None:
|
||||
row = self.repository.get_miner_location(miner_id)
|
||||
if row is None:
|
||||
return None
|
||||
return MinerLocationDetail.model_validate(row)
|
||||
|
||||
def summarize_locations(self, *, include_miner_counts: bool) -> LocationSummary:
|
||||
return LocationSummary.model_validate(
|
||||
self.repository.summarize_locations(
|
||||
include_miner_counts=include_miner_counts
|
||||
)
|
||||
)
|
||||
+225
-377
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Permission-aware Miner Detail read model."""
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Historical monitoring read models."""
|
||||
@@ -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())
|
||||
@@ -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."""
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
],
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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(""><script>", 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("<img src=x onerror=alert(1)>", 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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
Reference in new issue
Block a user