diff --git a/.env.dev.example b/.env.dev.example
new file mode 100644
index 0000000..940c122
--- /dev/null
+++ b/.env.dev.example
@@ -0,0 +1,20 @@
+# Copying this file is optional. scripts/dev-env-init.sh generates strong secrets.
+APP_ENV=development
+APP_TIMEZONE=America/Winnipeg
+POSTGRES_DB=minerops_dev
+POSTGRES_USER=minerops_dev
+POSTGRES_PASSWORD=replace-with-a-random-local-only-secret
+POSTGRES_HOST=postgres
+POSTGRES_PORT=5432
+DATABASE_URL=postgresql://minerops_dev:replace-with-a-random-local-only-secret@postgres:5432/minerops_dev
+REDIS_URL=redis://redis:6379/0
+JWT_SECRET_KEY=replace-with-a-random-local-only-secret
+MINEROPS_AGENT_SHARED_SECRET=replace-with-a-random-local-only-secret
+MINEROPS_DEV_PORT=18000
+
+# Safety interlocks: keep these values disabled for development.
+SCHEDULED_SCAN_ENABLED=false
+MINEROPS_HUB_MODE=true
+MINEROPS_DISABLE_LOCAL_SCANNER=true
+MINEROPS_ENABLE_REAL_REBOOT=false
+MINEROPS_ENABLE_REAL_POWER_CONTROL=false
diff --git a/.gitignore b/.gitignore
index cffc07c..478675f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,8 @@
.env
.env.*
*.env
+!.env.example
+!.env.dev.example
# Backups / dumps / generated data
backups/
@@ -13,6 +15,8 @@ imports/
*.dump
*.bak
*.csv
+!sql/
+!sql/*.sql
# Python
__pycache__/
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 0000000..dbaf6e3
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -0,0 +1,64 @@
+# MinerOps Target Architecture
+
+MinerOps will move from its current working checkpoint toward a modular
+operations platform without a big-bang rewrite. Every milestone must leave the
+production application deployable and preserve verified workflows.
+
+## Boundaries
+
+- The hub owns users, authorization, fleet inventory, repairs, locations,
+ reporting, command policy, audit history, and long-term telemetry.
+- Site agents own network-local discovery, polling, and miner command
+ execution.
+- The hub must not require a route to miner VLANs.
+- WhatsMiner protocols are adapters behind one capability-based interface.
+
+## Backend
+
+The target backend is organized into FastAPI routers, domain services, and
+database repositories for:
+
+- identity and access
+- fleet inventory
+- telemetry and retention
+- repairs and parts
+- locations
+- agents and command queues
+- WhatsMiner protocol capabilities
+- system health
+
+Alembic is the only authority for persistent schema changes. Runtime table
+creation remains temporarily for backward compatibility and will be removed
+after migrations and tests cover every current path.
+
+## Frontend
+
+The target frontend is a typed, responsive application with a shared design
+system. Authentication is established before protected pages render. Views
+adapt to desktop, tablet, and phone layouts and expose actions according to
+server-enforced permissions.
+
+## Identity
+
+MinerOps application accounts are distinct from WhatsMiner device accounts.
+The default application roles will be Admin, Manager, Technician, and Read
+Only. Authorization is permission-based and enforced by backend endpoints.
+
+## Safety
+
+- Scanning and real controls are disabled by default in development.
+- Site-agent commands are authenticated, durable, expiring, idempotent, and
+ audited.
+- Destructive operations support dry-run behavior and explicit authorization.
+- Protocol adapters are tested with sanitized fixtures before real controls are
+ enabled.
+
+## Migration Order
+
+1. Reproducible schema and development baseline.
+2. Accounts, roles, secure sessions, and login gate.
+3. Responsive application shell and shared UI components.
+4. Optimized dashboard loading.
+5. WhatsMiner 4028 and 4433 protocol adapters.
+6. Incremental extraction of repairs, locations, agents, commands, and health
+ from the legacy application module.
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..db484a1
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,6 @@
+__pycache__/
+*.py[cod]
+*.bak
+*.bak.*
+.pytest_cache/
+.mypy_cache/
diff --git a/backend/alembic/versions/0006_operational_schema_baseline.py b/backend/alembic/versions/0006_operational_schema_baseline.py
new file mode 100644
index 0000000..7e18404
--- /dev/null
+++ b/backend/alembic/versions/0006_operational_schema_baseline.py
@@ -0,0 +1,478 @@
+"""capture the operational schema baseline
+
+Revision ID: 0006_operational_schema_baseline
+Revises: 0005_miner_inventory
+Create Date: 2026-07-27
+
+This migration adopts tables that older MinerOps builds created at runtime.
+The IF NOT EXISTS clauses allow it to run against both an existing deployment
+and a new database.
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+
+
+revision: str = "0006_operational_schema_baseline"
+down_revision: Union[str, None] = "0005_miner_inventory"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.execute(
+ """
+ ALTER TABLE miners
+ ADD COLUMN IF NOT EXISTS manual_details JSONB NOT NULL DEFAULT '{}'::jsonb,
+ ADD COLUMN IF NOT EXISTS manual_details_updated_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS manual_details_updated_by TEXT,
+ ADD COLUMN IF NOT EXISTS psu_model TEXT,
+ ADD COLUMN IF NOT EXISTS customer_sn TEXT,
+ ADD COLUMN IF NOT EXISTS api3_supported BOOLEAN,
+ ADD COLUMN IF NOT EXISTS api3_api_version TEXT,
+ ADD COLUMN IF NOT EXISTS api3_api_switch TEXT,
+ ADD COLUMN IF NOT EXISTS api3_last_seen_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS api3_last_error TEXT,
+ ADD COLUMN IF NOT EXISTS configured_performance_mode TEXT,
+ ADD COLUMN IF NOT EXISTS configured_performance_mode_source TEXT,
+ ADD COLUMN IF NOT EXISTS configured_performance_mode_updated_at TIMESTAMPTZ;
+
+ ALTER TABLE miner_status_snapshots
+ ADD COLUMN IF NOT EXISTS ip_address INET,
+ ADD COLUMN IF NOT EXISTS mac_address TEXT,
+ ADD COLUMN IF NOT EXISTS fan_speed_in DOUBLE PRECISION,
+ ADD COLUMN IF NOT EXISTS fan_speed_out DOUBLE PRECISION,
+ ADD COLUMN IF NOT EXISTS elapsed_seconds DOUBLE PRECISION;
+ """
+ )
+
+ op.execute(
+ """
+ CREATE TABLE IF NOT EXISTS miner_maintenance_events (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ miner_id UUID NOT NULL REFERENCES miners(id) ON DELETE CASCADE,
+ event_type TEXT NOT NULL,
+ event_status TEXT NOT NULL DEFAULT 'completed',
+ event_title TEXT NOT NULL,
+ description TEXT,
+ event_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ old_mac TEXT,
+ new_mac TEXT,
+ old_ip INET,
+ new_ip INET,
+ component_type TEXT,
+ component_slot TEXT,
+ component_serial TEXT,
+ cost_amount NUMERIC(12, 2),
+ downtime_minutes INTEGER,
+ created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_miner_id
+ ON miner_maintenance_events(miner_id);
+ CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_event_at
+ ON miner_maintenance_events(event_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_event_type
+ ON miner_maintenance_events(event_type);
+
+ CREATE TABLE IF NOT EXISTS location_positions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ room_id UUID REFERENCES rooms(id) ON DELETE SET NULL,
+ room_name TEXT NOT NULL,
+ rack_name TEXT NOT NULL,
+ shelf_name TEXT NOT NULL,
+ shelf_index INTEGER,
+ position_index INTEGER NOT NULL,
+ position_label TEXT,
+ position_kind TEXT NOT NULL DEFAULT 'miner_slot',
+ position_status TEXT NOT NULL DEFAULT 'available',
+ expected_mac TEXT,
+ miner_id UUID REFERENCES miners(id) ON DELETE SET NULL,
+ equipment_label TEXT,
+ notes TEXT,
+ source_file TEXT,
+ source_sheet TEXT,
+ source_cell TEXT,
+ raw_value TEXT,
+ conflict_reason TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ );
+
+ DO $$
+ BEGIN
+ ALTER TABLE location_positions
+ ADD CONSTRAINT uq_location_positions_slot
+ UNIQUE (room_name, rack_name, shelf_name, position_index);
+ EXCEPTION
+ WHEN duplicate_object OR duplicate_table THEN NULL;
+ END $$;
+
+ CREATE INDEX IF NOT EXISTS idx_location_positions_room
+ ON location_positions(room_name);
+ CREATE INDEX IF NOT EXISTS idx_location_positions_rack
+ ON location_positions(room_name, rack_name);
+ CREATE INDEX IF NOT EXISTS idx_location_positions_expected_mac
+ ON location_positions(expected_mac);
+ CREATE INDEX IF NOT EXISTS idx_location_positions_miner_id
+ ON location_positions(miner_id);
+
+ CREATE TABLE IF NOT EXISTS miner_location_states (
+ miner_id UUID PRIMARY KEY REFERENCES miners(id) ON DELETE CASCADE,
+ asset_state TEXT NOT NULL DEFAULT 'waiting_placement',
+ location_position_id UUID REFERENCES location_positions(id) ON DELETE SET NULL,
+ state_notes TEXT,
+ updated_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_miner_location_states_asset_state
+ ON miner_location_states(asset_state);
+
+ CREATE TABLE IF NOT EXISTS location_import_issues (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ import_batch_id UUID NOT NULL,
+ issue_type TEXT NOT NULL,
+ severity TEXT NOT NULL DEFAULT 'warning',
+ message TEXT NOT NULL,
+ mac_address TEXT,
+ room_name TEXT,
+ rack_name TEXT,
+ shelf_name TEXT,
+ position_index INTEGER,
+ source_file TEXT,
+ source_cell TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ resolved_at TIMESTAMPTZ
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_location_import_issues_batch
+ ON location_import_issues(import_batch_id);
+ CREATE INDEX IF NOT EXISTS idx_location_import_issues_mac
+ ON location_import_issues(mac_address);
+ """
+ )
+
+ op.execute(
+ """
+ CREATE TABLE IF NOT EXISTS miner_command_events (
+ id TEXT PRIMARY KEY,
+ miner_id TEXT NOT NULL,
+ command_type TEXT NOT NULL,
+ command_status TEXT NOT NULL,
+ dry_run BOOLEAN NOT NULL DEFAULT true,
+ requested_by_user_id TEXT,
+ requested_by_username TEXT,
+ target_ip TEXT,
+ target_mac TEXT,
+ request_payload JSONB,
+ response_payload JSONB,
+ error_message TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ completed_at TIMESTAMPTZ
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_miner_command_events_miner_id_created
+ ON miner_command_events(miner_id, created_at DESC);
+
+ CREATE TABLE IF NOT EXISTS miner_api_credentials (
+ miner_id TEXT PRIMARY KEY,
+ api_account TEXT NOT NULL DEFAULT 'super',
+ api_password TEXT NOT NULL,
+ password_source TEXT NOT NULL DEFAULT 'minerops',
+ updated_by_username TEXT,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ );
+
+ CREATE TABLE IF NOT EXISTS minerops_site_agents (
+ agent_id TEXT PRIMARY KEY,
+ site_name TEXT NOT NULL,
+ hostname TEXT,
+ version TEXT,
+ ip_address TEXT,
+ last_checkin_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ last_payload JSONB NOT NULL DEFAULT '{}'::jsonb
+ );
+
+ CREATE TABLE IF NOT EXISTS minerops_agent_snapshots (
+ id BIGSERIAL PRIMARY KEY,
+ agent_id TEXT NOT NULL,
+ site_name TEXT NOT NULL,
+ snapshot_kind TEXT NOT NULL DEFAULT 'miner_poll',
+ received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ miner_count INTEGER NOT NULL DEFAULT 0,
+ payload JSONB NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_minerops_agent_snapshots_received
+ ON minerops_agent_snapshots(received_at DESC);
+
+ CREATE TABLE IF NOT EXISTS minerops_agent_command_results (
+ id BIGSERIAL PRIMARY KEY,
+ agent_id TEXT NOT NULL,
+ site_name TEXT NOT NULL,
+ command_id TEXT,
+ received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ status TEXT NOT NULL,
+ payload JSONB NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_results_received
+ ON minerops_agent_command_results(received_at DESC);
+
+ CREATE TABLE IF NOT EXISTS minerops_agent_command_queue (
+ id TEXT PRIMARY KEY,
+ agent_id TEXT NOT NULL,
+ command_type TEXT NOT NULL,
+ command_status TEXT NOT NULL DEFAULT 'pending',
+ requested_by_user_id TEXT,
+ requested_by_username TEXT,
+ request_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
+ result_payload JSONB,
+ error_message TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ claimed_at TIMESTAMPTZ,
+ completed_at TIMESTAMPTZ
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_queue_agent_status
+ ON minerops_agent_command_queue(agent_id, command_status, created_at);
+
+ CREATE TABLE IF NOT EXISTS minerops_agent_discoveries (
+ id TEXT PRIMARY KEY,
+ agent_id TEXT NOT NULL,
+ site_name TEXT NOT NULL,
+ room_name TEXT,
+ ip_address INET,
+ mac_address TEXT,
+ model TEXT,
+ hostname TEXT,
+ miner_sn TEXT,
+ raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
+ discovery_status TEXT NOT NULL DEFAULT 'new',
+ matched_miner_id UUID,
+ first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_minerops_agent_discoveries_mac
+ ON minerops_agent_discoveries(mac_address);
+ CREATE INDEX IF NOT EXISTS idx_minerops_agent_discoveries_ip
+ ON minerops_agent_discoveries(ip_address);
+ """
+ )
+
+ op.execute(
+ """
+ CREATE TABLE IF NOT EXISTS minerops_scheduled_bulk_actions (
+ id TEXT PRIMARY KEY,
+ name TEXT,
+ target_type TEXT NOT NULL,
+ target_value TEXT,
+ action TEXT NOT NULL DEFAULT 'performance_mode',
+ performance_mode TEXT,
+ repeat_rule TEXT NOT NULL DEFAULT 'once',
+ scheduled_for TIMESTAMPTZ NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ agent_id TEXT NOT NULL DEFAULT 'plexus-windows-agent',
+ requested_by_user_id TEXT,
+ requested_by_username TEXT,
+ queued_command_count INTEGER NOT NULL DEFAULT 0,
+ last_queued_command_count INTEGER NOT NULL DEFAULT 0,
+ run_count INTEGER NOT NULL DEFAULT 0,
+ last_error TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ processed_at TIMESTAMPTZ
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_minerops_scheduled_bulk_actions_due
+ ON minerops_scheduled_bulk_actions(status, scheduled_for);
+ CREATE INDEX IF NOT EXISTS idx_minerops_scheduled_bulk_actions_created
+ ON minerops_scheduled_bulk_actions(created_at DESC);
+
+ CREATE TABLE IF NOT EXISTS miner_pool_snapshots (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ miner_id UUID,
+ ip_address INET,
+ mac_address TEXT,
+ model TEXT,
+ pool_index INTEGER,
+ url TEXT,
+ worker_user TEXT,
+ pool_status TEXT,
+ stratum_active BOOLEAN,
+ accepted DOUBLE PRECISION,
+ rejected DOUBLE PRECISION,
+ stale DOUBLE PRECISION,
+ get_failures DOUBLE PRECISION,
+ remote_failures DOUBLE PRECISION,
+ pool_rejected_percent DOUBLE PRECISION,
+ pool_stale_percent DOUBLE PRECISION,
+ last_share_time_epoch DOUBLE PRECISION,
+ raw_pool_json JSONB NOT NULL DEFAULT '{}'::jsonb,
+ agent_id TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ );
+
+ ALTER TABLE miner_pool_snapshots
+ ADD COLUMN IF NOT EXISTS model TEXT,
+ ADD COLUMN IF NOT EXISTS pool_index INTEGER,
+ ADD COLUMN IF NOT EXISTS url TEXT,
+ ADD COLUMN IF NOT EXISTS worker_user TEXT,
+ ADD COLUMN IF NOT EXISTS pool_status TEXT,
+ ADD COLUMN IF NOT EXISTS stratum_active BOOLEAN,
+ ADD COLUMN IF NOT EXISTS accepted DOUBLE PRECISION,
+ ADD COLUMN IF NOT EXISTS rejected DOUBLE PRECISION,
+ ADD COLUMN IF NOT EXISTS stale DOUBLE PRECISION,
+ ADD COLUMN IF NOT EXISTS get_failures DOUBLE PRECISION,
+ ADD COLUMN IF NOT EXISTS remote_failures DOUBLE PRECISION,
+ ADD COLUMN IF NOT EXISTS pool_rejected_percent DOUBLE PRECISION,
+ ADD COLUMN IF NOT EXISTS pool_stale_percent DOUBLE PRECISION,
+ ADD COLUMN IF NOT EXISTS last_share_time_epoch DOUBLE PRECISION,
+ ADD COLUMN IF NOT EXISTS raw_pool_json JSONB NOT NULL DEFAULT '{}'::jsonb,
+ ADD COLUMN IF NOT EXISTS agent_id TEXT;
+
+ DO $$
+ DECLARE
+ id_type TEXT;
+ BEGIN
+ SELECT data_type INTO id_type
+ FROM information_schema.columns
+ WHERE table_schema = current_schema()
+ AND table_name = 'miner_pool_snapshots'
+ AND column_name = 'id';
+
+ IF id_type = 'text' THEN
+ ALTER TABLE miner_pool_snapshots
+ ALTER COLUMN id SET DEFAULT (gen_random_uuid())::text;
+ ELSIF id_type = 'uuid' THEN
+ ALTER TABLE miner_pool_snapshots
+ ALTER COLUMN id SET DEFAULT gen_random_uuid();
+ END IF;
+ END $$;
+
+ CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_miner_created
+ ON miner_pool_snapshots(miner_id, created_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_ip_created
+ ON miner_pool_snapshots(ip_address, created_at DESC);
+
+ CREATE TABLE IF NOT EXISTS miner_api3_enrichment_snapshots (
+ id BIGSERIAL PRIMARY KEY,
+ miner_id UUID,
+ agent_id TEXT,
+ site_name TEXT,
+ ip_address INET,
+ mac_address TEXT,
+ api_supported BOOLEAN,
+ api_error TEXT,
+ api_version TEXT,
+ api_switch TEXT,
+ model TEXT,
+ miner_sn TEXT,
+ power_sn TEXT,
+ psu_model TEXT,
+ device_info JSONB,
+ miner_setting JSONB,
+ miner_status JSONB,
+ fan_setting JSONB,
+ custom_data JSONB,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_miner_api3_enrichment_miner_created
+ ON miner_api3_enrichment_snapshots(miner_id, created_at DESC);
+
+ CREATE TABLE IF NOT EXISTS miner_api3_hashboard_snapshots (
+ id BIGSERIAL PRIMARY KEY,
+ miner_id UUID,
+ ip_address INET,
+ mac_address TEXT,
+ slot INTEGER,
+ pcb_sn TEXT,
+ hash_average_ths DOUBLE PRECISION,
+ factory_hash_ths DOUBLE PRECISION,
+ freq DOUBLE PRECISION,
+ effective_chips INTEGER,
+ chip_temp_min_c DOUBLE PRECISION,
+ chip_temp_avg_c DOUBLE PRECISION,
+ chip_temp_max_c DOUBLE PRECISION,
+ raw_hashboard_json JSONB,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_miner_api3_hashboards_miner_created
+ ON miner_api3_hashboard_snapshots(miner_id, created_at DESC);
+ """
+ )
+
+ op.execute(
+ """
+ CREATE TABLE IF NOT EXISTS miner_status_hourly_rollups (
+ miner_id UUID NOT NULL REFERENCES miners(id) ON DELETE CASCADE,
+ bucket_at TIMESTAMPTZ NOT NULL,
+ sample_count BIGINT NOT NULL,
+ avg_mhs_av DOUBLE PRECISION,
+ max_mhs_av DOUBLE PRECISION,
+ avg_power_w DOUBLE PRECISION,
+ max_chip_temp_c DOUBLE PRECISION,
+ avg_chip_temp_c DOUBLE PRECISION,
+ avg_pool_rejected_percent DOUBLE PRECISION,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (miner_id, bucket_at)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_miner_status_hourly_bucket
+ ON miner_status_hourly_rollups(bucket_at);
+
+ CREATE TABLE IF NOT EXISTS miner_status_daily_rollups (
+ miner_id UUID NOT NULL REFERENCES miners(id) ON DELETE CASCADE,
+ bucket_date DATE NOT NULL,
+ sample_count BIGINT NOT NULL,
+ avg_mhs_av DOUBLE PRECISION,
+ max_mhs_av DOUBLE PRECISION,
+ avg_power_w DOUBLE PRECISION,
+ max_chip_temp_c DOUBLE PRECISION,
+ avg_chip_temp_c DOUBLE PRECISION,
+ avg_pool_rejected_percent DOUBLE PRECISION,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (miner_id, bucket_date)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_miner_status_daily_bucket
+ ON miner_status_daily_rollups(bucket_date);
+
+ CREATE TABLE IF NOT EXISTS minerops_retention_runs (
+ id BIGSERIAL PRIMARY KEY,
+ mode TEXT NOT NULL,
+ raw_status_retention_days INTEGER NOT NULL,
+ hourly_rollup_rows_before BIGINT NOT NULL DEFAULT 0,
+ hourly_rollup_rows_after BIGINT NOT NULL DEFAULT 0,
+ daily_rollup_rows_before BIGINT NOT NULL DEFAULT 0,
+ daily_rollup_rows_after BIGINT NOT NULL DEFAULT 0,
+ raw_status_rows_before BIGINT NOT NULL DEFAULT 0,
+ raw_status_rows_after BIGINT NOT NULL DEFAULT 0,
+ raw_status_rows_eligible_delete BIGINT NOT NULL DEFAULT 0,
+ raw_status_rows_deleted BIGINT NOT NULL DEFAULT 0,
+ started_at TIMESTAMPTZ NOT NULL,
+ finished_at TIMESTAMPTZ NOT NULL,
+ notes TEXT
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_minerops_retention_runs_started
+ ON minerops_retention_runs(started_at DESC);
+ """
+ )
+
+
+def downgrade() -> None:
+ raise RuntimeError(
+ "0006 adopts pre-existing operational tables and is intentionally irreversible"
+ )
diff --git a/backend/alembic/versions/0007_identity_and_roles.py b/backend/alembic/versions/0007_identity_and_roles.py
new file mode 100644
index 0000000..83d6224
--- /dev/null
+++ b/backend/alembic/versions/0007_identity_and_roles.py
@@ -0,0 +1,161 @@
+"""add application roles and permission matrix
+
+Revision ID: 0007_identity_and_roles
+Revises: 0006_operational_schema_baseline
+Create Date: 2026-07-27
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+
+
+revision: str = "0007_identity_and_roles"
+down_revision: Union[str, None] = "0006_operational_schema_baseline"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+PERMISSIONS = (
+ ("dashboard.view", "View fleet dashboards"),
+ ("miners.view", "View miners and telemetry"),
+ ("miners.update", "Update miner inventory and details"),
+ ("miners.control", "Run approved miner control operations"),
+ ("repairs.view", "View repair queues and history"),
+ ("repairs.manage", "Manage repairs, notes, parts, and status"),
+ ("locations.view", "View rooms, racks, shelves, and placements"),
+ ("locations.manage", "Manage layouts and miner placements"),
+ ("agents.view", "View site agents and command status"),
+ ("agents.manage", "Queue and manage site-agent work"),
+ ("system_health.view", "View system health and retention status"),
+ ("users.manage", "Create and manage MinerOps accounts"),
+ ("credentials.manage", "Manage protected miner credentials"),
+ ("scan_ranges.manage", "Manage scan ranges"),
+ ("scans.run", "Run discovery and polling operations"),
+ ("exports.create", "Export operational data"),
+ ("audit.view", "View audit history"),
+)
+
+ROLE_PERMISSIONS = {
+ "Manager": (
+ "dashboard.view",
+ "miners.view",
+ "miners.update",
+ "miners.control",
+ "repairs.view",
+ "repairs.manage",
+ "locations.view",
+ "locations.manage",
+ "agents.view",
+ "agents.manage",
+ "system_health.view",
+ "scan_ranges.manage",
+ "scans.run",
+ "exports.create",
+ "audit.view",
+ ),
+ "Technician": (
+ "dashboard.view",
+ "miners.view",
+ "miners.update",
+ "miners.control",
+ "repairs.view",
+ "repairs.manage",
+ "locations.view",
+ "locations.manage",
+ "agents.view",
+ "scans.run",
+ "exports.create",
+ ),
+ "Read Only": (
+ "dashboard.view",
+ "miners.view",
+ "repairs.view",
+ "locations.view",
+ "agents.view",
+ "system_health.view",
+ ),
+}
+
+
+def upgrade() -> None:
+ connection = op.get_bind()
+
+ for key, description in PERMISSIONS:
+ connection.exec_driver_sql(
+ """
+ INSERT INTO permissions (key, description)
+ VALUES (%s, %s)
+ ON CONFLICT (key) DO UPDATE
+ SET description = EXCLUDED.description
+ """,
+ (key, description),
+ )
+
+ connection.exec_driver_sql(
+ """
+ INSERT INTO roles (name, description)
+ VALUES
+ ('Manager', 'Operational manager with fleet and workflow control'),
+ ('Read Only', 'Read-only access to operational views')
+ ON CONFLICT (name) DO UPDATE
+ SET description = EXCLUDED.description
+ """
+ )
+
+ connection.exec_driver_sql(
+ """
+ UPDATE roles
+ SET description = CASE name
+ WHEN 'Admin' THEN 'Full MinerOps administrator'
+ WHEN 'Technician' THEN 'Technician for repair and fleet operations'
+ ELSE description
+ END
+ WHERE name IN ('Admin', 'Technician')
+ """
+ )
+
+ connection.exec_driver_sql(
+ """
+ UPDATE users
+ SET role_id = (SELECT id FROM roles WHERE name = 'Read Only')
+ WHERE role_id IN (
+ SELECT id FROM roles WHERE name IN ('Viewer', 'Auditor')
+ )
+ """
+ )
+ connection.exec_driver_sql(
+ "DELETE FROM role_permissions WHERE role_id IN (SELECT id FROM roles WHERE name IN ('Viewer', 'Auditor'))"
+ )
+ connection.exec_driver_sql("DELETE FROM roles WHERE name IN ('Viewer', 'Auditor')")
+
+ connection.exec_driver_sql(
+ "DELETE FROM role_permissions WHERE role_id IN (SELECT id FROM roles WHERE name IN ('Admin', 'Manager', 'Technician', 'Read Only'))"
+ )
+ connection.exec_driver_sql(
+ """
+ INSERT INTO role_permissions (role_id, permission_id)
+ SELECT roles.id, permissions.id
+ FROM roles
+ CROSS JOIN permissions
+ WHERE roles.name = 'Admin'
+ ON CONFLICT DO NOTHING
+ """
+ )
+
+ for role_name, keys in ROLE_PERMISSIONS.items():
+ connection.exec_driver_sql(
+ """
+ INSERT INTO role_permissions (role_id, permission_id)
+ SELECT roles.id, permissions.id
+ FROM roles
+ JOIN permissions ON permissions.key = ANY(%s)
+ WHERE roles.name = %s
+ ON CONFLICT DO NOTHING
+ """,
+ (list(keys), role_name),
+ )
+
+
+def downgrade() -> None:
+ raise RuntimeError("0007 changes live role assignments and is intentionally irreversible")
diff --git a/backend/alembic/versions/0008_manual_miner_inventory.py b/backend/alembic/versions/0008_manual_miner_inventory.py
new file mode 100644
index 0000000..9f1cafc
--- /dev/null
+++ b/backend/alembic/versions/0008_manual_miner_inventory.py
@@ -0,0 +1,193 @@
+"""add manual miner inventory schema
+
+Revision ID: 0008_manual_miner_inventory
+Revises: 0007_identity_and_roles
+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 = "0008_manual_miner_inventory"
+down_revision: Union[str, None] = "0007_identity_and_roles"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+TAG_TRIMMED_NAME_SQL = (
+ "regexp_replace(name, '^[[:space:]]+|[[:space:]]+$', '', 'g')"
+)
+
+
+def upgrade() -> None:
+ op.add_column("miners", sa.Column("control_board_sn", sa.Text(), nullable=True))
+ op.add_column(
+ "miners",
+ sa.Column(
+ "lifecycle_status",
+ sa.Text(),
+ nullable=False,
+ server_default=sa.text("'active'"),
+ ),
+ )
+ op.create_check_constraint(
+ "ck_miners_lifecycle_status",
+ "miners",
+ "lifecycle_status IN ('active', 'spare', 'retired')",
+ )
+
+ op.execute(
+ """
+ DO $$
+ DECLARE
+ conflicting_asset_ids TEXT;
+ BEGIN
+ SELECT string_agg(
+ format('%L (%s records)', normalized_asset_id, record_count),
+ ', ' ORDER BY normalized_asset_id
+ )
+ INTO conflicting_asset_ids
+ FROM (
+ SELECT
+ lower(trim(asset_tag)) AS normalized_asset_id,
+ COUNT(*) AS record_count
+ FROM miners
+ WHERE asset_tag IS NOT NULL
+ AND trim(asset_tag) <> ''
+ GROUP BY lower(trim(asset_tag))
+ HAVING COUNT(*) > 1
+ ) conflicts;
+
+ IF conflicting_asset_ids IS NOT NULL THEN
+ RAISE EXCEPTION USING
+ MESSAGE = 'Cannot enforce globally unique Asset IDs. '
+ || 'Conflicting normalized values: '
+ || conflicting_asset_ids,
+ HINT = 'Correct the conflicting miners.asset_tag values '
+ || 'before upgrading, then run the migration again.';
+ END IF;
+ END
+ $$;
+ """
+ )
+ op.create_index(
+ "uq_miners_asset_tag_ci",
+ "miners",
+ [sa.text("lower(trim(asset_tag))")],
+ unique=True,
+ postgresql_where=sa.text("asset_tag IS NOT NULL AND trim(asset_tag) <> ''"),
+ )
+
+ op.create_table(
+ "tags",
+ sa.Column(
+ "id",
+ postgresql.UUID(as_uuid=True),
+ primary_key=True,
+ server_default=sa.text("gen_random_uuid()"),
+ ),
+ sa.Column("name", sa.Text(), nullable=False),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.CheckConstraint(
+ f"{TAG_TRIMMED_NAME_SQL} <> ''",
+ name="ck_tags_name_not_blank",
+ ),
+ )
+ op.create_index(
+ "uq_tags_name_ci",
+ "tags",
+ [sa.text(f"lower({TAG_TRIMMED_NAME_SQL})")],
+ unique=True,
+ )
+
+ op.create_table(
+ "miner_tags",
+ sa.Column(
+ "miner_id",
+ postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("miners.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column(
+ "tag_id",
+ postgresql.UUID(as_uuid=True),
+ sa.ForeignKey("tags.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.PrimaryKeyConstraint("miner_id", "tag_id", name="pk_miner_tags"),
+ )
+ op.create_index("idx_miner_tags_tag_id", "miner_tags", ["tag_id"])
+
+ # Discovery continues to receive now() defaults. Manual creation can
+ # explicitly store NULL to represent an asset that has never been observed.
+ op.alter_column(
+ "miners",
+ "first_seen_at",
+ existing_type=sa.DateTime(timezone=True),
+ existing_server_default=sa.text("now()"),
+ nullable=True,
+ )
+ op.alter_column(
+ "miners",
+ "last_seen_at",
+ existing_type=sa.DateTime(timezone=True),
+ existing_server_default=sa.text("now()"),
+ nullable=True,
+ )
+
+
+def downgrade() -> None:
+ # created_at is non-null in the pre-0008 schema, making this deterministic.
+ op.execute(
+ """
+ UPDATE miners
+ SET first_seen_at = COALESCE(first_seen_at, created_at),
+ last_seen_at = COALESCE(last_seen_at, created_at)
+ WHERE first_seen_at IS NULL
+ OR last_seen_at IS NULL;
+ """
+ )
+ op.alter_column(
+ "miners",
+ "last_seen_at",
+ existing_type=sa.DateTime(timezone=True),
+ existing_server_default=sa.text("now()"),
+ nullable=False,
+ )
+ op.alter_column(
+ "miners",
+ "first_seen_at",
+ existing_type=sa.DateTime(timezone=True),
+ existing_server_default=sa.text("now()"),
+ nullable=False,
+ )
+
+ op.drop_index("idx_miner_tags_tag_id", table_name="miner_tags")
+ op.drop_table("miner_tags")
+ op.drop_index("uq_tags_name_ci", table_name="tags")
+ op.drop_table("tags")
+
+ op.drop_index("uq_miners_asset_tag_ci", table_name="miners")
+ op.drop_constraint("ck_miners_lifecycle_status", "miners", type_="check")
+ op.drop_column("miners", "lifecycle_status")
+ op.drop_column("miners", "control_board_sn")
diff --git a/backend/alembic/versions/0009_inventory_asset_id_history.py b/backend/alembic/versions/0009_inventory_asset_id_history.py
new file mode 100644
index 0000000..df956c2
--- /dev/null
+++ b/backend/alembic/versions/0009_inventory_asset_id_history.py
@@ -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")
diff --git a/backend/alembic/versions/0010_begin_repair.py b/backend/alembic/versions/0010_begin_repair.py
new file mode 100644
index 0000000..df31b97
--- /dev/null
+++ b/backend/alembic/versions/0010_begin_repair.py
@@ -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')",
+ )
diff --git a/backend/alembic/versions/0011_repair_timeline.py b/backend/alembic/versions/0011_repair_timeline.py
new file mode 100644
index 0000000..2acacd1
--- /dev/null
+++ b/backend/alembic/versions/0011_repair_timeline.py
@@ -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'"
+ )
diff --git a/backend/alembic/versions/0012_return_to_service.py b/backend/alembic/versions/0012_return_to_service.py
new file mode 100644
index 0000000..46e1359
--- /dev/null
+++ b/backend/alembic/versions/0012_return_to_service.py
@@ -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'"
+ )
diff --git a/backend/alembic/versions/0013_control_board_replacement.py b/backend/alembic/versions/0013_control_board_replacement.py
new file mode 100644
index 0000000..fc5600f
--- /dev/null
+++ b/backend/alembic/versions/0013_control_board_replacement.py
@@ -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'")
diff --git a/backend/alembic/versions/0014_repair_parts_tracking.py b/backend/alembic/versions/0014_repair_parts_tracking.py
new file mode 100644
index 0000000..1ca5b6b
--- /dev/null
+++ b/backend/alembic/versions/0014_repair_parts_tracking.py
@@ -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');
+ """
+ )
diff --git a/backend/app/components/__init__.py b/backend/app/components/__init__.py
new file mode 100644
index 0000000..5618361
--- /dev/null
+++ b/backend/app/components/__init__.py
@@ -0,0 +1 @@
+"""Minimal durable component registry for repair-part references."""
diff --git a/backend/app/components/dependencies.py b/backend/app/components/dependencies.py
new file mode 100644
index 0000000..03a8dc4
--- /dev/null
+++ b/backend/app/components/dependencies.py
@@ -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())
diff --git a/backend/app/components/exceptions.py b/backend/app/components/exceptions.py
new file mode 100644
index 0000000..9f3c90a
--- /dev/null
+++ b/backend/app/components/exceptions.py
@@ -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")
diff --git a/backend/app/components/models.py b/backend/app/components/models.py
new file mode 100644
index 0000000..d6513c5
--- /dev/null
+++ b/backend/app/components/models.py
@@ -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
diff --git a/backend/app/components/repository.py b/backend/app/components/repository.py
new file mode 100644
index 0000000..7827848
--- /dev/null
+++ b/backend/app/components/repository.py
@@ -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
diff --git a/backend/app/components/router.py b/backend/app/components/router.py
new file mode 100644
index 0000000..4d9c90e
--- /dev/null
+++ b/backend/app/components/router.py
@@ -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)
diff --git a/backend/app/components/service.py b/backend/app/components/service.py
new file mode 100644
index 0000000..29d47aa
--- /dev/null
+++ b/backend/app/components/service.py
@@ -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)
diff --git a/backend/app/dashboard/__init__.py b/backend/app/dashboard/__init__.py
new file mode 100644
index 0000000..988f3f4
--- /dev/null
+++ b/backend/app/dashboard/__init__.py
@@ -0,0 +1 @@
+"""Permission-aware operational Dashboard read model."""
diff --git a/backend/app/dashboard/dependencies.py b/backend/app/dashboard/dependencies.py
new file mode 100644
index 0000000..9fa3da4
--- /dev/null
+++ b/backend/app/dashboard/dependencies.py
@@ -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(),
+ )
diff --git a/backend/app/dashboard/models.py b/backend/app/dashboard/models.py
new file mode 100644
index 0000000..6b85138
--- /dev/null
+++ b/backend/app/dashboard/models.py
@@ -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
diff --git a/backend/app/dashboard/router.py b/backend/app/dashboard/router.py
new file mode 100644
index 0000000..5d13892
--- /dev/null
+++ b/backend/app/dashboard/router.py
@@ -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",
+ ),
+ )
+ )
diff --git a/backend/app/dashboard/service.py b/backend/app/dashboard/service.py
new file mode 100644
index 0000000..c344623
--- /dev/null
+++ b/backend/app/dashboard/service.py
@@ -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),
+ )
diff --git a/backend/app/frontend/__init__.py b/backend/app/frontend/__init__.py
new file mode 100644
index 0000000..ca6f16c
--- /dev/null
+++ b/backend/app/frontend/__init__.py
@@ -0,0 +1 @@
+"""Server-rendered frontend foundation for MinerOps."""
diff --git a/backend/app/frontend/miner_detail_view.py b/backend/app/frontend/miner_detail_view.py
new file mode 100644
index 0000000..c06723d
--- /dev/null
+++ b/backend/app/frontend/miner_detail_view.py
@@ -0,0 +1,77 @@
+"""Semantic HTML for the Internal Alpha Miner Detail page."""
+
+
+def miner_detail_content() -> str:
+ return """
+
+ Loading miner
+ Requesting current identity and operational context.
+
+
+ Miner detail unavailable
+ The miner could not be loaded.
+ Retry
+
+
+
+
+
+
+ Latest facts
+
Current state
+
No observation
+
+
+
+
+ Physical context
+
Location
+
+
+
+ Work context
+
Repair context
+
+
+
+
+
+
+
+ Identity history
+ Open to load Asset ID and network identity history.
+
+
+ Recent observations
+ Open to load up to 100 recent factual observations.
+
+
+
+
+"""
diff --git a/backend/app/frontend/monitoring_trends_view.py b/backend/app/frontend/monitoring_trends_view.py
new file mode 100644
index 0000000..47b2f8a
--- /dev/null
+++ b/backend/app/frontend/monitoring_trends_view.py
@@ -0,0 +1,52 @@
+"""Semantic HTML for the Internal Alpha Monitoring Trends page."""
+
+
+def monitoring_trends_content(can_view_locations: bool) -> str:
+ location_options = (
+ 'Site Room '
+ if can_view_locations
+ else ""
+ )
+ return f"""
+
+ Historical scope
+
Trend controls
+
Not loaded
+
+ Fleet trends use current miner inventory.
+
+
+ Loading trends Aggregating bounded raw telemetry.
+
+
+ Trends unavailable The request failed.
+ Retry
+
+
+"""
diff --git a/backend/app/frontend/router.py b/backend/app/frontend/router.py
new file mode 100644
index 0000000..458be5b
--- /dev/null
+++ b/backend/app/frontend/router.py
@@ -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)
diff --git a/backend/app/frontend/static/app.css b/backend/app/frontend/static/app.css
new file mode 100644
index 0000000..01055f5
--- /dev/null
+++ b/backend/app/frontend/static/app.css
@@ -0,0 +1,1724 @@
+:root {
+ color-scheme: light;
+ font-family: Inter, "Segoe UI", Arial, sans-serif;
+ font-synthesis: none;
+ --ink: #17202a;
+ --muted: #5a6773;
+ --line: #ccd4dc;
+ --surface: #ffffff;
+ --canvas: #f3f5f7;
+ --nav: #0f172a;
+ --accent: #f97316;
+ --focus: #1677b8;
+ --good: #177245;
+ --warn: #9a5b00;
+ --danger: #b42318;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ min-width: 320px;
+ background: var(--canvas);
+}
+
+body {
+ margin: 0;
+ color: var(--ink);
+ background: var(--canvas);
+}
+
+button,
+input,
+select {
+ font: inherit;
+}
+
+a {
+ color: inherit;
+}
+
+.skip-link {
+ position: fixed;
+ left: 12px;
+ top: -60px;
+ z-index: 100;
+ padding: 10px 14px;
+ background: var(--surface);
+ border: 2px solid var(--focus);
+}
+
+.skip-link:focus {
+ top: 12px;
+}
+
+:focus-visible {
+ outline: 3px solid var(--focus);
+ outline-offset: 3px;
+}
+
+.app-shell {
+ min-height: 100vh;
+ display: grid;
+ grid-template-columns: 224px minmax(0, 1fr);
+ grid-template-rows: 68px minmax(0, 1fr);
+}
+
+.topbar {
+ grid-column: 1 / -1;
+ display: grid;
+ grid-template-columns: 224px minmax(240px, 620px) 1fr;
+ align-items: center;
+ gap: 20px;
+ min-height: 68px;
+ padding: 10px 18px;
+ color: #ffffff;
+ background: var(--nav);
+ border-bottom: 3px solid var(--accent);
+}
+
+.brand-area {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.brand {
+ display: block;
+ min-width: 0;
+ text-decoration: none;
+}
+
+.brand-name {
+ display: block;
+ font-size: 20px;
+ font-weight: 800;
+}
+
+.brand-tagline {
+ display: block;
+ margin-top: 2px;
+ color: #d3dae2;
+ font-size: 11px;
+}
+
+.global-search {
+ position: relative;
+}
+
+.global-search label {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+}
+
+.global-search input {
+ width: 100%;
+ min-height: 42px;
+ padding: 9px 14px;
+ border: 1px solid #647385;
+ border-radius: 5px;
+ color: var(--ink);
+ background: #ffffff;
+}
+
+.session-tools {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.session-copy {
+ min-width: 0;
+ text-align: right;
+}
+
+.session-user,
+.session-role {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.session-user {
+ font-size: 13px;
+ font-weight: 750;
+}
+
+.session-role {
+ color: #d3dae2;
+ font-size: 11px;
+}
+
+.quiet-button,
+.menu-button {
+ min-height: 40px;
+ border: 1px solid #8390a0;
+ border-radius: 5px;
+ padding: 8px 11px;
+ color: #ffffff;
+ background: transparent;
+ cursor: pointer;
+}
+
+.menu-button {
+ display: none;
+}
+
+.primary-nav {
+ grid-column: 1;
+ grid-row: 2;
+ padding: 20px 12px;
+ color: #eef2f6;
+ background: #17212d;
+}
+
+.primary-nav ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 5px;
+}
+
+.primary-nav a {
+ display: block;
+ min-height: 42px;
+ padding: 11px 12px;
+ border-left: 3px solid transparent;
+ text-decoration: none;
+ font-size: 14px;
+ font-weight: 650;
+}
+
+.primary-nav a:hover {
+ background: #263343;
+}
+
+.primary-nav a[aria-current="page"] {
+ border-color: var(--accent);
+ background: #2c3948;
+}
+
+.nav-note {
+ margin: 22px 12px 0;
+ color: #aeb9c5;
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.main-content {
+ grid-column: 2;
+ grid-row: 2;
+ min-width: 0;
+ padding: 26px clamp(16px, 3vw, 42px) 48px;
+}
+
+.page-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 24px;
+ margin-bottom: 22px;
+}
+
+.eyebrow {
+ margin: 0 0 5px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 750;
+ text-transform: uppercase;
+}
+
+h1 {
+ margin: 0;
+ font-size: 32px;
+ letter-spacing: 0;
+}
+
+.page-summary {
+ max-width: 760px;
+ margin: 8px 0 0;
+ color: var(--muted);
+ line-height: 1.5;
+}
+
+.freshness {
+ margin: 4px 0 0;
+ color: var(--muted);
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.metrics {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(130px, 1fr));
+ gap: 12px;
+ margin-bottom: 24px;
+}
+
+.metric,
+.panel,
+.map-inspector {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--surface);
+}
+
+.metric {
+ min-height: 122px;
+ padding: 15px;
+}
+
+.metric-label {
+ display: block;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.metric-value {
+ display: block;
+ margin: 14px 0 6px;
+ font-size: 25px;
+ font-weight: 800;
+}
+
+.metric-state {
+ display: block;
+ color: var(--warn);
+ font-size: 12px;
+}
+
+.content-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.7fr) minmax(260px, .7fr);
+ gap: 18px;
+}
+
+.panel {
+ padding: 18px;
+ min-width: 0;
+}
+
+.panel h2 {
+ margin: 0;
+ font-size: 18px;
+}
+
+.panel-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 15px;
+}
+
+.panel-copy {
+ margin: 7px 0 0;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+.state-message {
+ padding: 26px 16px;
+ border: 1px dashed #aeb8c2;
+ text-align: center;
+ color: var(--muted);
+}
+
+.state-message strong {
+ display: block;
+ margin-bottom: 6px;
+ color: var(--ink);
+}
+
+.state-message[data-kind="error"] {
+ border-color: #d4a09b;
+ color: var(--danger);
+}
+
+.state-message[hidden] {
+ display: none;
+}
+
+.table-wrap {
+ width: 100%;
+ overflow-x: auto;
+ border: 1px solid var(--line);
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ background: var(--surface);
+}
+
+th,
+td {
+ padding: 11px 12px;
+ border-bottom: 1px solid #e3e8ed;
+ text-align: left;
+ vertical-align: top;
+}
+
+th {
+ color: #40505f;
+ background: #f7f8fa;
+ font-size: 12px;
+}
+
+td {
+ font-size: 13px;
+}
+
+tbody tr:last-child td {
+ border-bottom: 0;
+}
+
+.object-link {
+ color: #075b91;
+ font-weight: 750;
+}
+
+.secondary {
+ display: block;
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.status-text {
+ font-weight: 750;
+}
+
+.status-text::before {
+ content: "";
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ margin-right: 7px;
+ border-radius: 50%;
+ background: #758493;
+}
+
+.status-text[data-status="active"]::before,
+.status-text[data-status="online"]::before {
+ background: var(--good);
+}
+
+.status-text[data-status="in_repair"]::before,
+.status-text[data-status="repair"]::before {
+ background: var(--warn);
+}
+
+.status-text[data-status="critical"]::before {
+ background: var(--danger);
+}
+
+.filter-bar {
+ display: flex;
+ align-items: end;
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.field {
+ display: grid;
+ gap: 6px;
+ min-width: min(100%, 320px);
+}
+
+.field label {
+ color: #40505f;
+ font-size: 12px;
+ font-weight: 750;
+}
+
+.field input,
+.field select {
+ min-height: 42px;
+ border: 1px solid #9da9b5;
+ border-radius: 5px;
+ padding: 8px 10px;
+ background: #ffffff;
+}
+
+.command-button {
+ min-height: 42px;
+ border: 1px solid #1b5f89;
+ border-radius: 5px;
+ padding: 8px 15px;
+ color: #ffffff;
+ background: #176b9a;
+ font-weight: 750;
+ cursor: pointer;
+}
+
+.command-button:disabled {
+ cursor: not-allowed;
+ opacity: .58;
+}
+
+.map-layout {
+ display: grid;
+ grid-template-columns: 210px minmax(340px, 1fr) 280px;
+ min-height: 520px;
+ border: 1px solid var(--line);
+ background: var(--surface);
+}
+
+.map-controls,
+.map-inspector {
+ padding: 16px;
+}
+
+.map-controls {
+ border-right: 1px solid var(--line);
+}
+
+.map-controls h2,
+.map-inspector h2 {
+ margin: 0 0 14px;
+ font-size: 16px;
+}
+
+.hierarchy {
+ margin: 0 0 20px;
+ padding-left: 18px;
+ color: #40505f;
+ line-height: 1.8;
+}
+
+.control-stack {
+ display: grid;
+ gap: 12px;
+}
+
+.map-viewport {
+ display: grid;
+ place-items: center;
+ min-height: 420px;
+ padding: 28px;
+ background: #e8edf1;
+ box-shadow: inset 0 0 0 10px #dde4ea;
+}
+
+.map-empty {
+ max-width: 460px;
+ padding: 22px;
+ border: 1px solid #9da9b5;
+ background: #ffffff;
+ text-align: center;
+}
+
+.map-empty strong {
+ display: block;
+ margin-bottom: 8px;
+}
+
+.map-inspector {
+ border: 0;
+ border-left: 1px solid var(--line);
+ border-radius: 0;
+}
+
+.definition-list {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 10px 14px;
+ margin: 0;
+ font-size: 13px;
+}
+
+.definition-list dt {
+ color: var(--muted);
+}
+
+.definition-list dd {
+ margin: 0;
+ font-weight: 650;
+}
+
+.notice {
+ padding: 12px 14px;
+ border-left: 4px solid var(--warn);
+ background: #fff8e8;
+ color: #694100;
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+@media (max-width: 1120px) {
+ .metrics {
+ grid-template-columns: repeat(3, minmax(140px, 1fr));
+ }
+
+ .map-layout {
+ grid-template-columns: 190px minmax(320px, 1fr);
+ }
+
+ .map-inspector {
+ grid-column: 1 / -1;
+ border-top: 1px solid var(--line);
+ border-left: 0;
+ }
+}
+
+@media (max-width: 820px) {
+ .app-shell {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ }
+
+ .topbar {
+ position: sticky;
+ top: 0;
+ z-index: 30;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: 10px;
+ }
+
+ .brand-tagline,
+ .session-copy {
+ display: none;
+ }
+
+ .menu-button {
+ display: inline-block;
+ }
+
+ .global-search {
+ grid-column: 1 / -1;
+ grid-row: 2;
+ }
+
+ .primary-nav {
+ display: none;
+ grid-column: 1;
+ grid-row: 2;
+ padding: 10px 12px;
+ }
+
+ .primary-nav[data-open="true"] {
+ display: block;
+ }
+
+ .primary-nav ul {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+
+ .primary-nav a {
+ min-height: 46px;
+ padding: 12px 8px;
+ text-align: center;
+ }
+
+ .nav-note {
+ display: none;
+ }
+
+ .main-content {
+ grid-column: 1;
+ grid-row: 3;
+ padding-top: 20px;
+ }
+
+ .metrics {
+ grid-template-columns: repeat(2, minmax(130px, 1fr));
+ }
+
+ .content-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .map-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .map-controls,
+ .map-inspector {
+ border: 0;
+ border-bottom: 1px solid var(--line);
+ }
+}
+
+@media (max-width: 560px) {
+ .topbar {
+ padding: 9px 10px;
+ }
+
+ .brand-name {
+ font-size: 18px;
+ }
+
+ .quiet-button {
+ padding: 7px 9px;
+ }
+
+ .main-content {
+ padding: 18px 12px 36px;
+ }
+
+ .page-head {
+ display: block;
+ }
+
+ .freshness {
+ margin-top: 10px;
+ white-space: normal;
+ }
+
+ .metrics {
+ grid-template-columns: 1fr;
+ }
+
+ .metric {
+ min-height: 100px;
+ }
+
+ .primary-nav ul {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .filter-bar {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .field {
+ min-width: 0;
+ width: 100%;
+ }
+
+ .command-button {
+ width: 100%;
+ }
+
+ .table-wrap {
+ border: 0;
+ overflow: visible;
+ }
+
+ table,
+ thead,
+ tbody,
+ tr,
+ th,
+ td {
+ display: block;
+ }
+
+ thead {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ }
+
+ tbody {
+ display: grid;
+ gap: 10px;
+ }
+
+ tr {
+ border: 1px solid var(--line);
+ background: var(--surface);
+ }
+
+ td {
+ display: grid;
+ grid-template-columns: minmax(90px, .45fr) 1fr;
+ gap: 10px;
+ border-bottom: 1px solid #e3e8ed;
+ }
+
+ td::before {
+ content: attr(data-label);
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 750;
+ }
+
+ .map-viewport {
+ min-height: 340px;
+ padding: 14px;
+ }
+}
+
+/* Mine Map v1 */
+.map-context {
+ padding: 10px 12px;
+ border: 1px solid var(--line);
+ background: var(--surface);
+}
+
+.map-context-head,
+.map-status-band,
+.inspector-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.map-context h2,
+.map-inspector-v1 h2,
+.unplaced-section h2 {
+ margin: 0;
+ font-size: 15px;
+}
+
+.map-control-grid {
+ display: grid;
+ grid-template-columns: minmax(130px, .8fr) minmax(130px, .8fr)
+ minmax(150px, .9fr) minmax(190px, 1.35fr) auto;
+ align-items: end;
+ gap: 7px;
+ margin-top: 7px;
+}
+
+.map-context .field {
+ gap: 3px;
+}
+
+.map-context .field label {
+ font-size: 11px;
+}
+
+.map-context select,
+.map-context input[type="search"] {
+ min-height: 36px;
+ padding-top: 6px;
+ padding-bottom: 6px;
+}
+
+.map-problems-control {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ min-height: 36px;
+ padding: 6px 9px;
+ border: 1px solid #9aa6b1;
+ border-radius: 4px;
+ background: #f8fafb;
+ color: #263442;
+ font-size: 12px;
+ font-weight: 750;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.map-problems-control input {
+ width: 16px;
+ height: 16px;
+ margin: 0;
+ accent-color: #176b9a;
+}
+
+.map-problems-control:has(input:checked) {
+ border-color: #176b9a;
+ background: #edf7fb;
+}
+
+.map-filters {
+ margin-top: 7px;
+ border-top: 1px solid #e3e8ed;
+ padding-top: 5px;
+}
+
+.map-filters summary {
+ min-height: 28px;
+ cursor: pointer;
+ color: #334252;
+ font-size: 13px;
+ font-weight: 750;
+}
+
+.map-filter-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(135px, 1fr)) auto;
+ align-items: end;
+ gap: 7px;
+}
+
+.map-filter-grid .quiet-button,
+.icon-button {
+ color: var(--ink);
+ border-color: #8e9ba8;
+ background: #fff;
+}
+
+.map-status-band {
+ align-items: flex-start;
+ margin: 7px 0;
+}
+
+.map-summary,
+.map-legend {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+
+.summary-fact {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 5px;
+ padding-right: 9px;
+ border-right: 1px solid var(--line);
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.summary-fact strong {
+ color: var(--ink);
+ font-size: 15px;
+}
+
+.mine-map-workspace {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 300px;
+ border: 1px solid var(--line);
+ background: var(--surface);
+}
+
+.mine-map-workspace[hidden],
+.map-inspector-v1[hidden],
+.unplaced-section[hidden],
+.map-notice[hidden] {
+ display: none;
+}
+
+.map-main-column {
+ min-width: 0;
+}
+
+.map-legend {
+ min-height: 38px;
+ padding: 6px 9px;
+ border-bottom: 1px solid var(--line);
+ background: #f7f8fa;
+ font-size: 12px;
+}
+
+.legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.legend-marker,
+.status-marker {
+ display: inline-grid;
+ place-items: center;
+ border: 1px solid #758493;
+ border-radius: 3px;
+ color: #263442;
+ background: #eef1f4;
+ font-weight: 850;
+}
+
+.legend-marker {
+ min-height: 21px;
+ padding: 2px 5px;
+ font-size: 9px;
+}
+
+.status-marker {
+ flex: 0 1 auto;
+ min-height: 17px;
+ max-width: 100%;
+ padding: 1px 4px;
+ font-size: 8px;
+ line-height: 1.2;
+ text-align: center;
+ overflow-wrap: anywhere;
+}
+
+.legend-marker[data-status="online"],
+.map-position[data-status="online"] .status-marker { border-color: #658675; color: #315c45; background: #f0f6f2; }
+.legend-marker[data-status="offline"],
+.map-position[data-status="offline"] .status-marker { border-color: #b42318; color: #8f1d14; background: #fff; }
+.legend-marker[data-status="unstable"],
+.map-position[data-status="unstable"] .status-marker { border-color: #9a5b00; color: #754600; background: #fff; }
+.legend-marker[data-status="repair"],
+.map-position[data-status="repair"] .status-marker { border-color: #6b4ca5; color: #523884; background: #fff; }
+.legend-marker[data-status="unknown"],
+.map-position[data-status="unknown"] .status-marker { border-color: #596675; color: #354351; background: #fff; }
+
+.relative-scale {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding-right: 8px;
+ border-right: 1px solid var(--line);
+ color: #40505f;
+}
+
+.relative-scale-label {
+ font-weight: 750;
+}
+
+.relative-scale-value {
+ font-variant-numeric: tabular-nums;
+}
+
+.relative-scale-track {
+ width: 64px;
+ height: 6px;
+ border: 1px solid #8e9ba8;
+ background: linear-gradient(90deg, #dfe9ee, #176b9a);
+}
+
+.map-notice {
+ margin: 14px;
+ padding: 13px;
+ border-left: 4px solid var(--warn);
+ background: #fff8e8;
+ color: #694100;
+ font-size: 13px;
+}
+
+.map-viewport-v1 {
+ display: grid;
+ gap: 8px;
+ min-height: 380px;
+ max-height: calc(100vh - 265px);
+ overflow: auto;
+ padding: 8px;
+ background: #e9edf0;
+}
+
+.map-rack {
+ min-width: 0;
+ border: 1px solid #aeb8c2;
+ background: #fff;
+}
+
+.rack-title {
+ margin: 0;
+ padding: 6px 9px;
+ color: #fff;
+ background: #263442;
+ font-size: 14px;
+}
+
+.map-shelf {
+ display: grid;
+ grid-template-columns: 78px minmax(0, 1fr);
+ border-top: 1px solid var(--line);
+}
+
+.map-shelf:first-of-type {
+ border-top: 0;
+}
+
+.shelf-title {
+ margin: 0;
+ padding: 7px;
+ color: #40505f;
+ background: #f3f5f7;
+ font-size: 12px;
+}
+
+.position-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(108px, 1fr));
+ gap: 4px;
+ padding: 5px;
+}
+
+.map-position {
+ position: relative;
+ display: grid;
+ grid-template-rows: auto auto auto auto;
+ gap: 2px;
+ min-width: 0;
+ min-height: 84px;
+ padding: 6px;
+ border: 1px solid #8e9ba8;
+ border-left: 3px solid #758493;
+ border-radius: 4px;
+ color: var(--ink);
+ background: #fff;
+ text-align: left;
+}
+
+button.map-position {
+ cursor: pointer;
+}
+
+button.map-position:hover {
+ border-color: #40505f;
+ background: #f8fafb;
+}
+
+.map-position[data-selected="true"] {
+ box-shadow: 0 0 0 3px var(--focus);
+}
+
+.map-position[data-filtered="true"] {
+ opacity: .28;
+ filter: grayscale(1);
+}
+
+.map-position[data-problems-muted="true"] {
+ opacity: .16;
+ filter: grayscale(.8);
+}
+
+.map-position[data-selected="true"] {
+ opacity: 1;
+ filter: none;
+}
+
+.map-position[data-problem="true"] {
+ border-left-width: 5px;
+ font-weight: 650;
+}
+
+.map-position[data-measurement="true"]::after {
+ position: absolute;
+ right: auto;
+ bottom: 0;
+ left: 0;
+ width: var(--relative-percent);
+ height: 4px;
+ background: #176b9a;
+ content: "";
+}
+
+.map-position[data-status="online"] { border-left-color: #658675; }
+.map-position[data-status="offline"] { border-left-color: #b42318; background: #fff5f4; }
+.map-position[data-status="unstable"] { border-left-color: #9a5b00; background: #fff9ed; }
+.map-position[data-status="repair"] { border-left-color: #6b4ca5; background: #f7f3fc; }
+.map-position[data-status="spare"] { border-left-color: #1677b8; background: #f1f8fc; }
+.map-position[data-status="retired"] { border-left-color: #4d5660; background: #eef0f2; }
+.map-position[data-status="empty"] { min-height: 56px; border-style: dashed; border-left-color: #aeb8c2; color: #687785; background: transparent; opacity: .62; }
+.map-position[data-status="expected"] { border-left-color: #996600; background: #fff9e8; }
+.map-position[data-status="conflict"] { border-left-color: #b42318; background: #fff0ee; }
+.map-position[data-status="blocked"] { border-left-color: #4d5660; background: #e5e8eb; }
+.map-position[data-status="switch"] { border-left-color: #28706d; background: #eff8f7; }
+.map-position[data-status="restricted"] { border-left-color: #596675; background: #f3f5f7; }
+
+.position-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 6px;
+}
+
+.position-label,
+.position-miner,
+.position-value,
+.position-freshness {
+ overflow-wrap: anywhere;
+}
+
+.position-label { flex: 1 1 auto; min-width: 0; font-size: 10px; line-height: 1.2; }
+.position-miner { font-size: 11px; font-weight: 750; line-height: 1.2; }
+.position-value { color: #334252; font-size: 10px; font-variant-numeric: tabular-nums; }
+.position-freshness { color: var(--muted); font-size: 9px; }
+
+.map-inspector-v1 {
+ position: sticky;
+ top: 12px;
+ align-self: start;
+ min-width: 0;
+ max-height: calc(100vh - 90px);
+ overflow-y: auto;
+ padding: 16px;
+ border-left: 1px solid var(--line);
+ background: #fff;
+}
+
+.icon-button {
+ width: 42px;
+ height: 42px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-weight: 800;
+}
+
+.status-callout {
+ padding: 10px;
+ border-left: 4px solid #758493;
+ background: #f3f5f7;
+ font-size: 13px;
+ font-weight: 750;
+}
+
+.status-callout[data-status="online"] { border-color: var(--good); }
+.status-callout[data-status="offline"] { border-color: var(--danger); }
+.status-callout[data-status="repair"] { border-color: #6b4ca5; }
+
+.inspector-details {
+ grid-template-columns: minmax(92px, auto) minmax(0, 1fr);
+ margin-bottom: 18px;
+}
+
+.inspector-details dd {
+ overflow-wrap: anywhere;
+}
+
+.command-link {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 42px;
+ padding: 8px 15px;
+ border: 1px solid #1b5f89;
+ border-radius: 5px;
+ color: #fff;
+ background: #176b9a;
+ text-decoration: none;
+ font-weight: 750;
+}
+
+.unplaced-section {
+ padding: 14px;
+ border-top: 1px solid var(--line);
+}
+
+#mapUnplaced,
+.legacy-position-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
+ gap: 8px;
+}
+
+.legacy-position-entry {
+ display: grid;
+ gap: 7px;
+ padding: 10px;
+ border: 1px solid var(--line);
+ background: #fff;
+}
+
+.legacy-position-path {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.unplaced-miner {
+ display: grid;
+ gap: 4px;
+ padding: 10px;
+ border-left: 4px solid #758493;
+ background: #f3f5f7;
+ font-size: 12px;
+}
+
+@media (max-width: 1180px) {
+ .map-control-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .map-filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .mine-map-workspace { grid-template-columns: 1fr; }
+ .map-inspector-v1 { position: static; max-height: none; border-top: 1px solid var(--line); border-left: 0; }
+}
+
+@media (max-width: 680px) {
+ .map-context-head,
+ .map-status-band { align-items: flex-start; flex-direction: column; }
+ .map-control-grid,
+ .map-filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .map-search { grid-column: 1 / -1; }
+ .map-problems-control { min-height: 44px; }
+ .map-viewport-v1 { max-height: none; padding: 9px; }
+ .map-shelf { grid-template-columns: 1fr; }
+ .shelf-title { position: sticky; left: 0; z-index: 1; }
+ .position-grid { grid-template-columns: repeat(2, minmax(108px, 1fr)); overflow-x: auto; }
+ .map-inspector-v1 { position: fixed; inset: 0; z-index: 80; max-height: none; overflow-y: auto; }
+}
+
+@media (max-width: 420px) {
+ .map-control-grid,
+ .map-filter-grid { grid-template-columns: 1fr; }
+ .map-search { grid-column: auto; }
+ .position-grid { grid-template-columns: repeat(2, minmax(104px, 1fr)); }
+}
+
+/* Fleet operations */
+.fleet-panel { padding: 14px; }
+.fleet-filters {
+ display: grid;
+ grid-template-columns: minmax(220px, 1.6fr) repeat(5, minmax(130px, 1fr));
+ align-items: end;
+ gap: 9px;
+ margin-bottom: 12px;
+}
+.fleet-filters .field { min-width: 0; }
+.fleet-filters .field input,
+.fleet-filters .field select { min-height: 38px; }
+.fleet-search { grid-column: span 2; }
+.check-control {
+ display: flex;
+ align-items: center;
+ min-height: 38px;
+ gap: 7px;
+ color: #40505f;
+ font-size: 12px;
+ font-weight: 750;
+}
+.check-control input { width: 17px; height: 17px; }
+.fleet-filter-actions { display: flex; gap: 7px; }
+.fleet-filter-actions .quiet-button { color: var(--ink); }
+.fleet-table { min-width: 1320px; }
+.fleet-table th,
+.fleet-table td { padding: 8px 9px; }
+.fleet-table th { white-space: nowrap; }
+.fleet-row { border-left: 4px solid transparent; }
+.fleet-row[data-status="offline"] { border-left-color: #b42318; background: #fff5f4; }
+.fleet-row[data-status="unstable"] { border-left-color: #9a5b00; background: #fff9ed; }
+.fleet-row[data-status="repair"] { border-left-color: #6b4ca5; background: #f7f3fc; }
+.fleet-row[data-status="unknown"] { border-left-color: #596675; background: #f7f8fa; }
+.fleet-row[data-status="online"],
+.fleet-row[data-status="spare"],
+.fleet-row[data-status="retired"] { border-left-color: #c6ced5; }
+.fleet-stack { display: grid; gap: 2px; }
+.fleet-primary { font-weight: 700; }
+.fleet-status { font-weight: 750; white-space: nowrap; }
+.fleet-status::before {
+ content: "";
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ margin-right: 7px;
+ border: 2px solid currentColor;
+ border-radius: 50%;
+}
+.fleet-status[data-status="online"] { color: #315c45; }
+.fleet-status[data-status="offline"] { color: #8f1d14; }
+.fleet-status[data-status="unstable"] { color: #754600; }
+.fleet-status[data-status="repair"] { color: #523884; }
+.fleet-status[data-status="unknown"] { color: #354351; }
+.fleet-status[data-status="spare"] { color: #075b91; }
+.fleet-status[data-status="retired"] { color: #4d5660; }
+.fleet-uuid { max-width: 150px; overflow: hidden; text-overflow: ellipsis; }
+.fleet-location { min-width: 180px; max-width: 260px; }
+.fleet-actions { display: grid; gap: 6px; white-space: nowrap; }
+.quiet-link { color: #40505f; font-weight: 700; }
+.fleet-result-summary { margin: 0 0 8px; color: var(--muted); font-size: 12px; }
+.fleet-pagination {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 9px;
+ margin-top: 12px;
+ color: #40505f;
+ font-size: 12px;
+ font-weight: 700;
+}
+.fleet-pagination select { min-height: 34px; }
+
+@media (max-width: 1180px) {
+ .fleet-filters { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ .fleet-search { grid-column: span 2; }
+}
+
+@media (max-width: 680px) {
+ .fleet-panel { padding: 12px; }
+ .fleet-panel .panel-head { align-items: flex-start; }
+ .fleet-filters { grid-template-columns: 1fr 1fr; }
+ .fleet-search { grid-column: 1 / -1; }
+ .fleet-filter-actions { grid-column: 1 / -1; }
+ .fleet-filter-actions > * { flex: 1; }
+ .fleet-table { min-width: 0; }
+ .fleet-table tbody { gap: 12px; }
+ .fleet-table tr { border-left-width: 4px; padding: 8px; }
+ .fleet-table td {
+ display: grid;
+ grid-template-columns: minmax(95px, .8fr) minmax(0, 1.6fr);
+ gap: 10px;
+ padding: 7px 5px;
+ }
+ .fleet-table td::before {
+ content: attr(data-label);
+ color: #40505f;
+ font-size: 11px;
+ font-weight: 750;
+ }
+ .fleet-location { min-width: 0; max-width: none; }
+ .fleet-actions { white-space: normal; }
+ .fleet-pagination { justify-content: space-between; flex-wrap: wrap; }
+}
+
+@media (max-width: 420px) {
+ .fleet-filters { grid-template-columns: 1fr; }
+ .fleet-search, .fleet-filter-actions { grid-column: auto; }
+ .fleet-table td { grid-template-columns: 88px minmax(0, 1fr); }
+}
+
+/* Repair Center */
+.repair-center { padding: 14px; }
+.repair-summary {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(100px, 160px));
+ gap: 8px;
+ margin: 10px 0 12px;
+}
+.repair-summary > div {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ border-left: 3px solid var(--accent);
+ padding: 7px 9px;
+ background: #f6f8fa;
+}
+.repair-summary span { color: var(--muted); font-size: 12px; }
+.repair-filters {
+ display: grid;
+ grid-template-columns: minmax(230px, 1.6fr) repeat(6, minmax(125px, 1fr));
+ align-items: end;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+.repair-filters .field { min-width: 0; }
+.repair-filters input,
+.repair-filters select { min-height: 38px; }
+.repair-search { grid-column: span 2; }
+.repair-filter-actions { display: flex; gap: 7px; }
+.repair-filter-actions .quiet-button,
+.repair-pagination .quiet-button,
+.repair-row-actions .quiet-button { color: var(--ink); background: #fff; }
+.repair-table { min-width: 1260px; }
+.repair-table th,
+.repair-table td { padding: 8px 9px; vertical-align: top; }
+.repair-row[data-source="legacy"] { border-left: 4px solid #8390a0; }
+.repair-row[data-source="modular"] { border-left: 4px solid #176b9a; }
+.repair-row[data-active="false"] { opacity: .78; }
+.repair-miner { display: grid; gap: 3px; min-width: 150px; }
+.source-label { color: var(--muted); font-size: 11px; font-weight: 700; }
+.repair-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: 800;
+}
+.repair-status::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #718096; }
+.repair-status[data-status="waiting_for_parts"]::before,
+.repair-status[data-status="waiting_parts"]::before,
+.repair-status[data-status="needs_followup"]::before { background: var(--warn); }
+.repair-status[data-status="completed"]::before { background: var(--good); }
+.repair-status[data-status="failed"]::before,
+.repair-status[data-status="cancelled"]::before { background: var(--danger); }
+.repair-row-actions { display: flex; flex-wrap: wrap; gap: 6px; }
+.quiet-link {
+ display: inline-flex;
+ align-items: center;
+ min-height: 40px;
+ border: 1px solid #8390a0;
+ border-radius: 5px;
+ padding: 8px 11px;
+ background: #fff;
+ text-decoration: none;
+}
+.repair-result-summary { color: var(--muted); font-size: 13px; }
+.repair-pagination { display: flex; justify-content: flex-end; align-items: center; gap: 9px; margin-top: 12px; }
+.repair-inspector {
+ position: fixed;
+ inset: 68px 0 0 auto;
+ z-index: 70;
+ width: min(520px, 100%);
+ overflow-y: auto;
+ border-left: 1px solid var(--line);
+ padding: 18px;
+ background: var(--surface);
+ box-shadow: -10px 0 26px rgb(15 23 42 / 18%);
+}
+.inspector-head { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; }
+.repair-detail-section { padding: 14px 0; border-top: 1px solid var(--line); }
+.repair-detail-section h3 { margin: 0 0 9px; font-size: 16px; }
+.repair-timeline,
+.repair-parts-list { display: grid; gap: 9px; margin: 0; padding-left: 22px; }
+.repair-timeline li,
+.repair-parts-list li { padding: 7px 0; }
+.repair-timeline li > span,
+.repair-parts-list li > span { display: block; }
+.voided-part { text-decoration: line-through; }
+.repair-action-form { display: grid; gap: 8px; margin: 10px 0; }
+.repair-action-form input,
+.repair-action-form select,
+.repair-action-form textarea { width: 100%; min-height: 40px; }
+.repair-action-form textarea { min-height: 78px; resize: vertical; }
+.danger-button {
+ min-height: 40px;
+ border: 1px solid var(--danger);
+ border-radius: 5px;
+ padding: 8px 11px;
+ color: var(--danger);
+ background: #fff;
+ font-weight: 750;
+ cursor: pointer;
+}
+.state-inline-error { color: var(--danger); font-weight: 700; }
+
+@media (max-width: 980px) {
+ .repair-filters { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ .repair-search { grid-column: span 2; }
+}
+
+@media (max-width: 680px) {
+ .repair-center { padding: 12px; }
+ .repair-summary { grid-template-columns: repeat(3, 1fr); }
+ .repair-summary > div { display: grid; gap: 2px; }
+ .repair-filters { grid-template-columns: 1fr 1fr; }
+ .repair-search, .repair-filter-actions { grid-column: 1 / -1; }
+ .repair-filter-actions > * { flex: 1; }
+ .repair-table { min-width: 0; }
+ .repair-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
+ .repair-table tbody { display: grid; gap: 12px; }
+ .repair-table tr { display: block; border: 1px solid var(--line); padding: 8px; }
+ .repair-table td { display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: 8px; border: 0; }
+ .repair-table td::before { content: attr(data-label); color: var(--muted); font-size: 11px; font-weight: 800; }
+ .repair-pagination { justify-content: space-between; flex-wrap: wrap; }
+ .repair-inspector { inset: 0; width: 100%; }
+}
+
+@media (max-width: 420px) {
+ .repair-filters { grid-template-columns: 1fr; }
+ .repair-search, .repair-filter-actions { grid-column: auto; }
+ .repair-table td { grid-template-columns: 90px minmax(0, 1fr); }
+}
+
+/* Dashboard */
+.dashboard-status-band {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 12px;
+}
+
+.dashboard-status-band p { margin: 0; }
+.dashboard-permission-note { color: var(--muted); font-size: 12px; }
+
+.dashboard-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.dashboard-section {
+ min-width: 0;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 16px;
+ background: var(--surface);
+}
+
+.dashboard-section-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 12px;
+}
+
+.dashboard-section-head .eyebrow { margin: 0 0 3px; }
+.dashboard-section h2 { margin: 0; font-size: 19px; }
+.dashboard-section h3 { margin: 0 0 8px; font-size: 14px; }
+.dashboard-section .object-link { white-space: nowrap; }
+
+.dashboard-total {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 16px;
+ border-left: 4px solid var(--accent);
+ padding: 9px 11px;
+ background: #f6f8fa;
+}
+
+.dashboard-total span { color: var(--muted); font-weight: 700; }
+.dashboard-total strong { font-size: 25px; }
+
+.dashboard-dimension-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px;
+ margin-top: 16px;
+}
+
+.dashboard-count-list,
+.dashboard-status-list {
+ display: grid;
+ gap: 0;
+ margin: 0;
+}
+
+.dashboard-count-list > div,
+.dashboard-status-list > div {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ min-height: 36px;
+ border-top: 1px solid #e3e7eb;
+}
+
+.dashboard-count-list dt,
+.dashboard-status-list dt { color: #40505f; }
+.dashboard-count-list dd,
+.dashboard-status-list dd { margin: 0; font-weight: 800; }
+.dashboard-status-list { margin-top: 10px; }
+.dashboard-status-list a { font-weight: 700; }
+.dashboard-boundary { margin: 13px 0 0; color: var(--muted); font-size: 12px; }
+
+.attention-list { display: grid; gap: 7px; }
+.attention-list a {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ min-height: 42px;
+ border: 1px solid var(--line);
+ border-left-width: 4px;
+ border-radius: 5px;
+ padding: 8px 11px;
+ background: #fff;
+ text-decoration: none;
+}
+
+.attention-list a[data-state="offline"] { border-left-color: var(--danger); }
+.attention-list a[data-state="unknown"] { border-left-color: #596675; }
+.attention-list a[data-state="unstable"] { border-left-color: var(--warn); }
+.attention-list a[data-state="repair"] { border-left-color: #6b4ca5; }
+.attention-list span { font-weight: 750; }
+.attention-list strong { font-size: 18px; }
+
+.dashboard-location-counts {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ column-gap: 18px;
+}
+
+@media (max-width: 900px) {
+ .dashboard-grid { grid-template-columns: 1fr; }
+}
+
+@media (max-width: 560px) {
+ .dashboard-status-band,
+ .dashboard-section-head { align-items: flex-start; flex-direction: column; }
+ .dashboard-dimension-grid { grid-template-columns: 1fr; }
+ .dashboard-location-counts { grid-template-columns: 1fr; }
+ .dashboard-section { padding: 13px; }
+ .dashboard-section .object-link { white-space: normal; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .dashboard-section *,
+ .attention-list * { scroll-behavior: auto; }
+}
+
+/* Internal Alpha Miner Detail */
+.miner-detail-hero {
+ display: grid;
+ grid-template-columns: minmax(0, 1.4fr) minmax(220px, .8fr);
+ gap: 18px;
+ align-items: start;
+ border: 1px solid var(--line);
+ border-left: 5px solid var(--accent);
+ background: #fff;
+ padding: 18px;
+ margin-bottom: 16px;
+}
+
+.miner-detail-hero h2 { margin: 2px 0 5px; font-size: 24px; }
+.miner-detail-uuid { color: var(--muted); font-family: ui-monospace, monospace; font-size: 12px; overflow-wrap: anywhere; }
+.miner-detail-state { display: grid; gap: 6px; justify-items: start; }
+.miner-detail-state strong {
+ border: 1px solid var(--line);
+ border-left: 4px solid #596675;
+ padding: 8px 10px;
+ background: #f7f8f9;
+}
+.miner-detail-state strong[data-status="online"] { border-left-color: var(--success); }
+.miner-detail-state strong[data-status="offline"] { border-left-color: var(--danger); }
+.miner-detail-state strong[data-status="unstable"] { border-left-color: var(--warn); }
+.miner-detail-state strong[data-status="repair"] { border-left-color: #6b4ca5; }
+.miner-detail-actions {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding-top: 12px;
+ border-top: 1px solid var(--line);
+}
+.miner-detail-actions .quiet-button { color: var(--ink); }
+
+.miner-detail-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+}
+
+.miner-detail-grid .panel { margin: 0 0 16px; }
+.metric-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ margin: 0;
+}
+.metric-grid > div {
+ border: 1px solid var(--line);
+ border-left: 3px solid #7b8792;
+ padding: 9px 10px;
+ background: #f8f9fa;
+}
+.metric-grid dt { color: var(--muted); font-size: 12px; font-weight: 700; }
+.metric-grid dd { margin: 4px 0 0; font-size: 16px; font-weight: 800; overflow-wrap: anywhere; }
+.tag-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
+.tag { border: 1px solid #aeb8c2; background: #f4f6f7; padding: 3px 7px; font-size: 12px; }
+
+.disclosure-list { display: grid; gap: 9px; margin-top: 12px; }
+.disclosure-list details {
+ border: 1px solid var(--line);
+ background: #fff;
+}
+.disclosure-list summary {
+ cursor: pointer;
+ min-height: 44px;
+ padding: 11px 13px;
+ font-weight: 750;
+}
+.disclosure-list summary:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
+.lazy-content,
+.disclosure-list details > .definition-list,
+.disclosure-list details > .permission-note { padding: 0 13px 13px; }
+.repair-episode-summary { display: flex; justify-content: space-between; gap: 12px; }
+.repair-episode-summary .secondary { font-weight: 500; }
+.repair-timeline-list { display: grid; gap: 10px; padding-left: 24px; }
+.repair-timeline-list li { padding-left: 4px; }
+.repair-timeline-list li > span { display: block; margin-top: 3px; }
+.event-payload { margin-top: 7px; }
+.compact-history-table { margin-top: 8px; max-height: 420px; overflow: auto; }
+.compact-history-table th { position: sticky; top: 0; background: #eef1f3; }
+.permission-note { color: #5b466f; border-left: 4px solid #8b6cad; padding: 9px 11px; background: #f7f3fa; }
+.empty-copy { color: var(--muted); }
+
+@media (max-width: 820px) {
+ .miner-detail-hero,
+ .miner-detail-grid { grid-template-columns: 1fr; }
+ .miner-detail-actions { grid-column: 1; }
+}
+
+@media (max-width: 560px) {
+ .miner-detail-hero { padding: 13px; }
+ .metric-grid { grid-template-columns: 1fr; }
+ .miner-detail-actions > * { width: 100%; text-align: center; }
+ .repair-episode-summary { align-items: flex-start; flex-direction: column; }
+ .compact-history-table table { min-width: 620px; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .miner-detail-hero *, .disclosure-list * { scroll-behavior: auto; }
+}
diff --git a/backend/app/frontend/static/app.js b/backend/app/frontend/static/app.js
new file mode 100644
index 0000000..e4acbfc
--- /dev/null
+++ b/backend/app/frontend/static/app.js
@@ -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();
+})();
diff --git a/backend/app/frontend/static/dashboard.js b/backend/app/frontend/static/dashboard.js
new file mode 100644
index 0000000..bb7e8db
--- /dev/null
+++ b/backend/app/frontend/static/dashboard.js
@@ -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();
+}());
diff --git a/backend/app/frontend/static/fleet.js b/backend/app/frontend/static/fleet.js
new file mode 100644
index 0000000..1f1728f
--- /dev/null
+++ b/backend/app/frontend/static/fleet.js
@@ -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();
+}());
diff --git a/backend/app/frontend/static/mine-map.js b/backend/app/frontend/static/mine-map.js
new file mode 100644
index 0000000..cea47a3
--- /dev/null
+++ b/backend/app/frontend/static/mine-map.js
@@ -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();
+}());
diff --git a/backend/app/frontend/static/miner-detail.js b/backend/app/frontend/static/miner-detail.js
new file mode 100644
index 0000000..f128d8f
--- /dev/null
+++ b/backend/app/frontend/static/miner-detail.js
@@ -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();
+})();
diff --git a/backend/app/frontend/static/monitoring-trends.css b/backend/app/frontend/static/monitoring-trends.css
new file mode 100644
index 0000000..0638d08
--- /dev/null
+++ b/backend/app/frontend/static/monitoring-trends.css
@@ -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; }
+}
diff --git a/backend/app/frontend/static/monitoring-trends.js b/backend/app/frontend/static/monitoring-trends.js
new file mode 100644
index 0000000..8bbd04a
--- /dev/null
+++ b/backend/app/frontend/static/monitoring-trends.js
@@ -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();
+})();
diff --git a/backend/app/frontend/static/repair-center.js b/backend/app/frontend/static/repair-center.js
new file mode 100644
index 0000000..4ba75c8
--- /dev/null
+++ b/backend/app/frontend/static/repair-center.js
@@ -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();
+})();
diff --git a/backend/app/frontend/views.py b/backend/app/frontend/views.py
new file mode 100644
index 0000000..2d6a081
--- /dev/null
+++ b/backend/app/frontend/views.py
@@ -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 = """
+
+
+ Total miners
+ -
+
+
Runtime
+
+
Online -
+
Offline -
+
Unknown -
+
+
Lifecycle
+
+
In repair -
+
Spare -
+
Retired -
+
+
+ Runtime and lifecycle are separate dimensions and may overlap.
+
+
+ """ if can_view_miners else ""
+ repairs = """
+
+
+ Active repair records
+ -
+
+
+ """ if can_view_repairs else ""
+ mapped_metrics = """
+
Mapped miners -
+
Unmapped miners -
+ """ if can_view_miners else ""
+ locations = f"""
+
+
+
+
Sites -
+
Rooms -
+
Positions -
+
Occupied -
+
Empty -
+ {mapped_metrics}
+
+
+ """ if can_view_locations else ""
+ available = sum((can_view_miners, can_view_repairs, can_view_locations))
+ partial = (
+ 'This overview includes only the '
+ 'operational areas available to your account.
'
+ if 0 < available < 3 else ""
+ )
+ return f"""
+
+ Loading operational overview
+ Requesting current fleet, repair, and location summaries.
+
+
+ Dashboard unavailable
+ The operational overview could not be loaded.
+ Retry
+
+
+ No operational sections available
+ Your account can open Dashboard but has no additional overview permissions.
+
+
+
+
Waiting for current summaries
+ {partial}
+
+
{fleet}{repairs}{locations}
+
+"""
+
+
+def _map_content(can_manage_locations: bool = False) -> str:
+ manage_link = (
+ 'Manage locations '
+ if can_manage_locations
+ else ""
+ )
+ return f"""
+
+
+
+
Map context
+
Select a site and room.
+
+ {manage_link}
+
+
+
Site
+ Loading sites...
+
Room
+ Loading rooms...
+
Layer
+
+ Operational Status
+ Temperature
+ Hashrate
+ Power
+ Repair State
+
+
Find miner
+
+
+
+ Problems only
+
+
+
+ Filters
+
+
Lifecycle
+ All lifecycle states
+
Model
+ All models
+
Repair
+ All repair states Active repair No active repair Legacy repair location
+
Operational status
+ All operational states
+
Clear filters
+
+
+
+
+
+ Waiting for map data
+
+
+ Map unavailable
+ The map could not be loaded.
+ Retry
+
+
+ Loading Mine Map
+ Requesting the current physical layout and operational observations.
+
+
+
+
+
+ Miner details require inventory access. Layout and occupancy remain visible.
+
+
+
+
+ Unscoped legacy positions
+
Stored positions without a durable room relationship. They are not assigned to the selected site or room.
+
+
+
+ Unplaced and operational areas
+
Miner records without a configured rack position.
+
+
+
+
+
+
Selected position
Miner details
+
X
+
+
+
+ Open Miner
+
+
+"""
+
+
+def _state_messages(noun: str) -> str:
+ return f"""
+
+ Loading {noun}
+ Requesting current information from MinerOps.
+
+
+ No {noun} found
+ There is no information to show in this view.
+
+
+ Access unavailable
+ Your account cannot view this information.
+
+
+ Could not load {noun}
+ Retry after checking the MinerOps connection.
+
+"""
+
+
+def _repairs_content(can_view_locations: bool, can_view_miners: bool) -> str:
+ location_filters = """
+ Site
+ All sites
+ Room
+ All rooms
+ """ 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 = """
+ Model
+
+ """ if can_view_miners else ""
+ return f"""
+
+
+
Repair Center
+
Active work and historical modular repairs, with legacy queue records clearly identified.
+
Loading repair work
+
+
+
Total results -
+
Active -
+
Historical -
+
+
+ {_state_messages("repair records")}
+
+
+
+
+ Miner Repair state Reported problem
+ Latest context Location Opened / age
+ Parts Actions
+
+
+
+
+
+
+
+"""
+
+
+def _miners_content(can_view_locations: bool) -> str:
+ location_filters = """
+ Site
+ All sites
+ Room
+ All rooms
+ """ if can_view_locations else ""
+ return f"""
+
+
+
Fleet
+
Current miner identity, operating state, telemetry, and repair context.
+
Loading current fleet
+
+
+ {_state_messages("miners")}
+
+
+
+
+ Miner Operational Model / Serial
+ Network Location Hashrate Power
+ Max chip temp Lifecycle / Repair Actions
+
+
+
+
+
+
+"""
+
+
+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'{escape(label)} '
+ if key == active_navigation
+ else f'{escape(label)} '
+ )
+ for key, label, href, permission in NAVIGATION
+ if has_permission(permission)
+ )
+ global_search = (
+ ''
+ if can_view_miners
+ else '
'
+ )
+ 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 = (
+ ''
+ if view == "miner_detail"
+ else ''
+ if view == "analytics"
+ else ''
+ if view == "map"
+ else ''
+ if view == "miners"
+ else ''
+ if view == "repairs"
+ else ''
+ if view == "dashboard"
+ else ""
+ )
+ page_style = (
+ ' '
+ 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"""
+
+
+
+
+ {escape(title)} | MinerOps
+
+ {page_style}
+
+
+ Skip to main content
+
+
+
+
+ Internal Alpha navigation. Additional
+ destinations appear only as working, authorized views.
+
+
+
+
+
{escape(context)}
+
{escape(title)}
+
{escape(summary)}
+
+ Current view: {escape(title)}
+
+ {content}
+
+
+
+ {page_script}
+
+"""
diff --git a/backend/app/identity.py b/backend/app/identity.py
new file mode 100644
index 0000000..15d8dae
--- /dev/null
+++ b/backend/app/identity.py
@@ -0,0 +1,1130 @@
+from datetime import datetime, timedelta, timezone
+from html import escape
+from typing import Any, Callable
+from urllib.parse import quote, urlsplit
+
+import jwt
+import psycopg
+from argon2 import PasswordHasher
+from argon2.exceptions import VerifyMismatchError
+from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
+from pydantic import BaseModel, EmailStr, Field
+
+from app.config import settings
+
+
+router = APIRouter()
+password_hasher = PasswordHasher()
+SESSION_COOKIE = "minerops_session"
+COOKIE_TOKEN_HINT = "cookie-session"
+DEFAULT_ROLES = ("Admin", "Manager", "Technician", "Read Only")
+
+
+class SetupAdminRequest(BaseModel):
+ username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")
+ display_name: str = Field(min_length=1, max_length=120)
+ email: EmailStr | None = None
+ password: str = Field(min_length=12, max_length=256)
+
+
+class LoginRequest(BaseModel):
+ username: str = Field(min_length=1, max_length=64)
+ password: str = Field(min_length=1, max_length=256)
+
+
+class CreateUserRequest(BaseModel):
+ username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")
+ display_name: str = Field(min_length=1, max_length=120)
+ email: EmailStr | None = None
+ role: str
+ password: str = Field(min_length=12, max_length=256)
+
+
+class UpdateUserRequest(BaseModel):
+ display_name: str | None = Field(default=None, min_length=1, max_length=120)
+ email: EmailStr | None = None
+ role: str | None = None
+ is_active: bool | None = None
+
+
+class ResetPasswordRequest(BaseModel):
+ password: str = Field(min_length=12, max_length=256)
+
+
+def api_response(data: Any = None, meta: dict | None = None, error: Any = None) -> dict:
+ return {"data": data, "meta": meta or {}, "error": error}
+
+
+def get_db_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,
+ )
+
+
+def create_access_token(user_id: str, username: str, role: str) -> str:
+ now = datetime.now(timezone.utc)
+ payload = {
+ "sub": user_id,
+ "username": username,
+ "role": role,
+ "iat": int(now.timestamp()),
+ "exp": int((now + timedelta(minutes=settings.jwt_access_token_minutes)).timestamp()),
+ "type": "access",
+ }
+ return jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256")
+
+
+def create_session_token(user_id: str, username: str, role: str) -> str:
+ now = datetime.now(timezone.utc)
+ payload = {
+ "sub": user_id,
+ "username": username,
+ "role": role,
+ "iat": int(now.timestamp()),
+ "exp": int((now + timedelta(days=settings.jwt_refresh_token_days)).timestamp()),
+ "type": "session",
+ }
+ return jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256")
+
+
+def decode_auth_token(token: str) -> dict:
+ try:
+ payload = jwt.decode(token, settings.jwt_secret_key, algorithms=["HS256"])
+ if payload.get("type") not in {"access", "session"}:
+ raise HTTPException(status_code=401, detail="Invalid token type")
+ return payload
+ except jwt.ExpiredSignatureError as exc:
+ raise HTTPException(status_code=401, detail="Session expired") from exc
+ except jwt.InvalidTokenError as exc:
+ raise HTTPException(status_code=401, detail="Invalid session") from exc
+
+
+def token_from_request(request: Request, authorization: str | None = None) -> str:
+ header = authorization or request.headers.get("authorization") or ""
+ if header.lower().startswith("bearer "):
+ token = header.split(" ", 1)[1].strip()
+ if token and token != COOKIE_TOKEN_HINT:
+ return token
+
+ cookie_token = request.cookies.get(SESSION_COOKIE, "").strip()
+ if cookie_token:
+ return cookie_token
+
+ raise HTTPException(status_code=401, detail="Login required")
+
+
+def get_auth_token(
+ request: Request,
+ authorization: str | None = Header(default=None),
+) -> str:
+ return token_from_request(request, authorization)
+
+
+def load_user_for_token(token: str) -> dict:
+ payload = decode_auth_token(token)
+ conn = None
+ try:
+ conn = get_db_connection()
+ with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
+ cur.execute(
+ """
+ SELECT
+ users.id::text AS id,
+ users.username,
+ users.display_name,
+ users.email,
+ users.is_active,
+ roles.name AS role,
+ COALESCE(
+ jsonb_agg(DISTINCT permissions.key)
+ FILTER (WHERE permissions.key IS NOT NULL),
+ '[]'::jsonb
+ ) AS permissions
+ FROM users
+ JOIN roles ON roles.id = users.role_id
+ LEFT JOIN role_permissions ON role_permissions.role_id = roles.id
+ LEFT JOIN permissions ON permissions.id = role_permissions.permission_id
+ WHERE users.id = %s
+ GROUP BY users.id, roles.name
+ """,
+ (payload["sub"],),
+ )
+ user = cur.fetchone()
+
+ if not user or not user["is_active"]:
+ raise HTTPException(status_code=401, detail="User not found or inactive")
+
+ result = dict(user)
+ result["permissions"] = list(result.get("permissions") or [])
+ return result
+ finally:
+ if conn is not None:
+ conn.close()
+
+
+def get_current_user(token: str = Depends(get_auth_token)) -> dict:
+ return load_user_for_token(token)
+
+
+def user_has_permission(user: dict, permission: str) -> bool:
+ return user.get("role") == "Admin" or permission in set(user.get("permissions") or [])
+
+
+def require_permission(permission: str) -> Callable:
+ def dependency(current_user: dict = Depends(get_current_user)) -> dict:
+ if not user_has_permission(current_user, permission):
+ raise HTTPException(status_code=403, detail="You do not have permission for this action")
+ return current_user
+
+ return dependency
+
+
+def request_uses_https(request: Request) -> bool:
+ forwarded = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip()
+ return forwarded == "https" or request.url.scheme == "https"
+
+
+def set_session_cookie(response: Response, request: Request, token: str) -> None:
+ response.set_cookie(
+ key=SESSION_COOKIE,
+ value=token,
+ max_age=settings.jwt_refresh_token_days * 24 * 60 * 60,
+ httponly=True,
+ secure=request_uses_https(request),
+ samesite="lax",
+ path="/",
+ )
+
+
+def clear_session_cookie(response: Response, request: Request) -> None:
+ response.delete_cookie(
+ SESSION_COOKIE,
+ path="/",
+ secure=request_uses_https(request),
+ httponly=True,
+ samesite="lax",
+ )
+
+
+def audit_event(
+ cur,
+ actor_user_id: str | None,
+ action: str,
+ entity_id: str | None,
+ details: dict,
+) -> None:
+ cur.execute(
+ """
+ INSERT INTO audit_logs (
+ actor_user_id, action, entity_type, entity_id, new_value_json
+ )
+ VALUES (%s, %s, 'user', %s, %s)
+ """,
+ (
+ actor_user_id,
+ action,
+ entity_id,
+ psycopg.types.json.Jsonb(details),
+ ),
+ )
+
+
+def serialize_user(row: dict) -> dict:
+ result = dict(row)
+ for key in ("created_at", "updated_at", "last_login_at"):
+ if result.get(key) is not None:
+ result[key] = result[key].isoformat()
+ return result
+
+
+def validate_role(role: str) -> str:
+ cleaned = str(role or "").strip()
+ if cleaned not in DEFAULT_ROLES:
+ raise HTTPException(status_code=400, detail="Invalid account role")
+ return cleaned
+
+
+@router.get("/api/auth/setup-status")
+def setup_status():
+ conn = None
+ try:
+ conn = get_db_connection()
+ with conn.cursor() as cur:
+ cur.execute("SELECT COUNT(*) FROM users")
+ user_count = cur.fetchone()[0]
+ return api_response(data={"setup_required": user_count == 0, "user_count": user_count})
+ finally:
+ if conn is not None:
+ conn.close()
+
+
+@router.post("/api/auth/setup-admin")
+def setup_admin(payload: SetupAdminRequest, request: Request, response: Response):
+ conn = None
+ try:
+ conn = get_db_connection()
+ with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
+ cur.execute("SELECT COUNT(*) AS user_count FROM users")
+ if cur.fetchone()["user_count"] > 0:
+ raise HTTPException(status_code=409, detail="Initial setup is already complete")
+
+ cur.execute("SELECT id FROM roles WHERE name = 'Admin'")
+ role = cur.fetchone()
+ if not role:
+ raise HTTPException(status_code=500, detail="Admin role is missing")
+
+ cur.execute(
+ """
+ INSERT INTO users (username, display_name, email, password_hash, role_id)
+ VALUES (%s, %s, %s, %s, %s)
+ RETURNING id::text, username, display_name, email
+ """,
+ (
+ payload.username.strip(),
+ payload.display_name.strip(),
+ str(payload.email) if payload.email else None,
+ password_hasher.hash(payload.password),
+ role["id"],
+ ),
+ )
+ user = dict(cur.fetchone())
+ audit_event(
+ cur,
+ user["id"],
+ "user.created",
+ user["id"],
+ {"username": user["username"], "role": "Admin", "source": "initial-setup"},
+ )
+ conn.commit()
+
+ session_token = create_session_token(user["id"], user["username"], "Admin")
+ set_session_cookie(response, request, session_token)
+ return api_response(data={"user": {**user, "role": "Admin"}})
+ except HTTPException:
+ if conn is not None:
+ conn.rollback()
+ raise
+ except psycopg.errors.UniqueViolation as exc:
+ if conn is not None:
+ conn.rollback()
+ raise HTTPException(status_code=409, detail="Username already exists") from exc
+ finally:
+ if conn is not None:
+ conn.close()
+
+
+@router.post("/api/auth/login")
+def login(payload: LoginRequest, request: Request, response: Response):
+ conn = None
+ try:
+ conn = get_db_connection()
+ with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
+ cur.execute(
+ """
+ SELECT users.id::text AS id, users.username, users.display_name,
+ users.email, users.password_hash, users.is_active,
+ roles.name AS role
+ FROM users
+ JOIN roles ON roles.id = users.role_id
+ WHERE lower(users.username) = lower(%s)
+ """,
+ (payload.username.strip(),),
+ )
+ user = cur.fetchone()
+ if not user or not user["is_active"]:
+ raise HTTPException(status_code=401, detail="Invalid username or password")
+
+ try:
+ password_hasher.verify(user["password_hash"], payload.password)
+ except VerifyMismatchError as exc:
+ raise HTTPException(status_code=401, detail="Invalid username or password") from exc
+
+ access_token = create_access_token(user["id"], user["username"], user["role"])
+ session_token = create_session_token(user["id"], user["username"], user["role"])
+ cur.execute(
+ "UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = %s",
+ (user["id"],),
+ )
+ audit_event(
+ cur,
+ user["id"],
+ "user.login",
+ user["id"],
+ {"username": user["username"]},
+ )
+ conn.commit()
+ set_session_cookie(response, request, session_token)
+ return api_response(
+ data={
+ "access_token": access_token,
+ "token_type": "bearer",
+ "expires_in": settings.jwt_access_token_minutes * 60,
+ "user": {
+ "id": user["id"],
+ "username": user["username"],
+ "display_name": user["display_name"],
+ "email": user["email"],
+ "role": user["role"],
+ },
+ }
+ )
+ except HTTPException:
+ if conn is not None:
+ conn.rollback()
+ raise
+ finally:
+ if conn is not None:
+ conn.close()
+
+
+@router.post("/api/auth/logout")
+def logout(request: Request, response: Response):
+ clear_session_cookie(response, request)
+ return api_response(data={"logged_out": True})
+
+
+@router.get("/api/auth/me")
+def me(current_user: dict = Depends(get_current_user)):
+ return api_response(data=current_user)
+
+
+@router.get("/api/admin/roles")
+def list_roles(current_user: dict = Depends(require_permission("users.manage"))):
+ del current_user
+ conn = None
+ try:
+ conn = get_db_connection()
+ with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
+ cur.execute(
+ """
+ SELECT roles.name, roles.description,
+ COALESCE(jsonb_agg(permissions.key ORDER BY permissions.key)
+ FILTER (WHERE permissions.key IS NOT NULL), '[]'::jsonb) AS permissions
+ FROM roles
+ LEFT JOIN role_permissions ON role_permissions.role_id = roles.id
+ LEFT JOIN permissions ON permissions.id = role_permissions.permission_id
+ WHERE roles.name = ANY(%s)
+ GROUP BY roles.id
+ ORDER BY array_position(%s::text[], roles.name)
+ """,
+ (list(DEFAULT_ROLES), list(DEFAULT_ROLES)),
+ )
+ rows = [dict(row) for row in cur.fetchall()]
+ return api_response(data=rows)
+ finally:
+ if conn is not None:
+ conn.close()
+
+
+@router.get("/api/admin/users")
+def list_users(current_user: dict = Depends(require_permission("users.manage"))):
+ del current_user
+ conn = None
+ try:
+ conn = get_db_connection()
+ with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
+ cur.execute(
+ """
+ SELECT users.id::text AS id, users.username, users.display_name,
+ users.email, users.is_active, users.last_login_at,
+ users.created_at, users.updated_at, roles.name AS role
+ FROM users
+ JOIN roles ON roles.id = users.role_id
+ ORDER BY users.is_active DESC, lower(users.display_name), lower(users.username)
+ """
+ )
+ rows = [serialize_user(dict(row)) for row in cur.fetchall()]
+ return api_response(data=rows)
+ finally:
+ if conn is not None:
+ conn.close()
+
+
+@router.post("/api/admin/users", status_code=201)
+def create_user(
+ payload: CreateUserRequest,
+ current_user: dict = Depends(require_permission("users.manage")),
+):
+ role_name = validate_role(payload.role)
+ conn = None
+ try:
+ conn = get_db_connection()
+ with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
+ cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
+ role = cur.fetchone()
+ if not role:
+ raise HTTPException(status_code=400, detail="Role is not available")
+
+ cur.execute(
+ "SELECT 1 FROM users WHERE lower(username) = lower(%s)",
+ (payload.username.strip(),),
+ )
+ if cur.fetchone():
+ raise HTTPException(status_code=409, detail="Username already exists")
+
+ cur.execute(
+ """
+ INSERT INTO users (username, display_name, email, password_hash, role_id)
+ VALUES (%s, %s, %s, %s, %s)
+ RETURNING id::text, username, display_name, email, is_active,
+ created_at, updated_at
+ """,
+ (
+ payload.username.strip(),
+ payload.display_name.strip(),
+ str(payload.email) if payload.email else None,
+ password_hasher.hash(payload.password),
+ role["id"],
+ ),
+ )
+ user = serialize_user(dict(cur.fetchone()))
+ user["role"] = role_name
+ audit_event(
+ cur,
+ current_user["id"],
+ "user.created",
+ user["id"],
+ {"username": user["username"], "role": role_name},
+ )
+ conn.commit()
+ return api_response(data=user)
+ except HTTPException:
+ if conn is not None:
+ conn.rollback()
+ raise
+ except psycopg.errors.UniqueViolation as exc:
+ if conn is not None:
+ conn.rollback()
+ raise HTTPException(status_code=409, detail="Username already exists") from exc
+ finally:
+ if conn is not None:
+ conn.close()
+
+
+def active_admin_count(cur) -> int:
+ cur.execute(
+ """
+ SELECT COUNT(*)
+ FROM users
+ JOIN roles ON roles.id = users.role_id
+ WHERE users.is_active = true AND roles.name = 'Admin'
+ """
+ )
+ return cur.fetchone()[0]
+
+
+@router.patch("/api/admin/users/{user_id}")
+def update_user(
+ user_id: str,
+ payload: UpdateUserRequest,
+ current_user: dict = Depends(require_permission("users.manage")),
+):
+ conn = None
+ try:
+ conn = get_db_connection()
+ with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
+ cur.execute(
+ """
+ SELECT users.id::text AS id, users.username, users.display_name,
+ users.email, users.is_active, roles.name AS role
+ FROM users JOIN roles ON roles.id = users.role_id
+ WHERE users.id = %s
+ """,
+ (user_id,),
+ )
+ existing = cur.fetchone()
+ if not existing:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ new_role = validate_role(payload.role) if payload.role is not None else existing["role"]
+ new_active = payload.is_active if payload.is_active is not None else existing["is_active"]
+ removing_admin = existing["role"] == "Admin" and (new_role != "Admin" or not new_active)
+ if removing_admin and active_admin_count(cur) <= 1:
+ raise HTTPException(status_code=409, detail="The last active administrator cannot be disabled or demoted")
+ if user_id == current_user["id"] and not new_active:
+ raise HTTPException(status_code=409, detail="You cannot disable your own account")
+
+ cur.execute("SELECT id FROM roles WHERE name = %s", (new_role,))
+ role = cur.fetchone()
+ cur.execute(
+ """
+ UPDATE users
+ SET display_name = %s, email = %s, role_id = %s,
+ is_active = %s, updated_at = now()
+ WHERE id = %s
+ RETURNING id::text, username, display_name, email, is_active,
+ last_login_at, created_at, updated_at
+ """,
+ (
+ payload.display_name.strip() if payload.display_name is not None else existing["display_name"],
+ str(payload.email) if payload.email else None,
+ role["id"],
+ new_active,
+ user_id,
+ ),
+ )
+ user = serialize_user(dict(cur.fetchone()))
+ user["role"] = new_role
+ audit_event(
+ cur,
+ current_user["id"],
+ "user.updated",
+ user_id,
+ {
+ "username": user["username"],
+ "role": new_role,
+ "is_active": new_active,
+ },
+ )
+ conn.commit()
+ return api_response(data=user)
+ except HTTPException:
+ if conn is not None:
+ conn.rollback()
+ raise
+ finally:
+ if conn is not None:
+ conn.close()
+
+
+@router.post("/api/admin/users/{user_id}/reset-password")
+def reset_password(
+ user_id: str,
+ payload: ResetPasswordRequest,
+ current_user: dict = Depends(require_permission("users.manage")),
+):
+ conn = None
+ try:
+ conn = get_db_connection()
+ with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
+ cur.execute(
+ """
+ UPDATE users SET password_hash = %s, updated_at = now()
+ WHERE id = %s
+ RETURNING id::text, username
+ """,
+ (password_hasher.hash(payload.password), user_id),
+ )
+ user = cur.fetchone()
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ audit_event(
+ cur,
+ current_user["id"],
+ "user.password_reset",
+ user_id,
+ {"username": user["username"]},
+ )
+ conn.commit()
+ return api_response(data={"user_id": user_id, "password_reset": True})
+ except HTTPException:
+ if conn is not None:
+ conn.rollback()
+ raise
+ finally:
+ if conn is not None:
+ conn.close()
+
+
+def protected_page(path: str) -> bool:
+ 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 in {"/repairs", "/operations/repairs"}:
+ return "repairs.view"
+ 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/"):
+ return "miners.view"
+ return "dashboard.view"
+
+
+def machine_agent_request(path: str) -> bool:
+ exact = {
+ "/api/agent/checkin",
+ "/api/agent/config",
+ "/api/agent/miner-snapshots",
+ "/api/agent/pool-snapshots",
+ "/api/agent/commands/pending",
+ "/api/agent/commands/result",
+ "/api/agent/discoveries",
+ "/api/agent/miner-enrichment",
+ "/api/agent/miner-enrichment-v2",
+ "/api/agent/miner-enrichment-v3",
+ "/api/agent/api3-enrichment",
+ }
+ 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"
+ if path.startswith("/api/repairs") or path.startswith("/api/maintenance"):
+ return "repairs.view"
+ if path.startswith("/api/location") or path in {"/api/rooms", "/api/sites"}:
+ return "locations.view"
+ return None
+
+ if path.startswith("/api/repairs") or "/maintenance-events" in path:
+ return "repairs.manage"
+ if path.startswith("/api/location") or path.startswith("/api/rooms"):
+ return "locations.manage"
+ if "/commands/" in path or path.endswith("/agent-control"):
+ return "miners.control"
+ if path.endswith("/api3/change-password"):
+ return "credentials.manage"
+ if path.startswith("/api/agent/") or path.startswith("/api/dashboard/scheduled-bulk-actions"):
+ return "agents.manage"
+ if path.startswith("/api/scan-ranges"):
+ return "scan_ranges.manage" if method in {"POST", "PATCH", "DELETE"} else "scans.run"
+ if path.startswith("/api/imports") or path.startswith("/api/tools"):
+ return "miners.update"
+ if path.startswith("/api/miners"):
+ return "miners.update"
+ return None
+
+
+def request_user_or_none(request: Request) -> dict | None:
+ try:
+ return load_user_for_token(token_from_request(request))
+ except HTTPException:
+ return None
+
+
+def install_identity_middleware(app) -> None:
+ @app.middleware("http")
+ async def identity_gate(request: Request, call_next):
+ path = request.url.path
+
+ if path == "/":
+ destination = "/dashboard" if request_user_or_none(request) else "/login"
+ return RedirectResponse(destination, status_code=303)
+
+ if path == "/login":
+ user = request_user_or_none(request)
+ if user:
+ 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:
+ 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
+ permission = required_page_permission(path)
+ if not user_has_permission(user, permission):
+ return HTMLResponse(
+ "Access denied Your account does not have permission to view this page.
Return to dashboard
",
+ status_code=403,
+ )
+ request.state.current_user = user
+
+ if path.startswith("/api/") and not path.startswith("/api/auth/") and not machine_agent_request(path):
+ permission = required_api_permission(request.method.upper(), path)
+ if permission:
+ user = request_user_or_none(request)
+ if not user:
+ return JSONResponse({"detail": "Login required"}, status_code=401)
+ if not user_has_permission(user, permission):
+ return JSONResponse(
+ {"detail": "You do not have permission for this action"},
+ status_code=403,
+ )
+ request.state.current_user = user
+
+ return await call_next(request)
+
+
+LOGIN_HTML = r"""
+
+
+
+
+Sign in | MinerOps
+
+
+
+
+
+
+Sign in Use your MinerOps account to continue.
+
+
+
+
+
+
+"""
+
+
+ACCOUNTS_HTML = r"""
+
+
+
+
+Accounts | MinerOps
+
+
+
+
+
+Accounts Manage access to MinerOps and assign operational roles.
Add account
+
+Admin Full system and account access
+Manager Fleet, workflow, and reporting control
+Technician Repair, location, and miner operations
+Read Only Operational visibility without changes
+
+
+Account Role Status Last login Actions Loading accounts...
+
+
Add account
+
Reset password
+
+
+"""
+
+
+@router.get("/login", response_class=HTMLResponse)
+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)
+def accounts_page():
+ return ACCOUNTS_HTML
diff --git a/backend/app/inventory/__init__.py b/backend/app/inventory/__init__.py
new file mode 100644
index 0000000..a22ddc7
--- /dev/null
+++ b/backend/app/inventory/__init__.py
@@ -0,0 +1,35 @@
+"""Inventory domain models, persistence, and business services."""
+
+from app.inventory.exceptions import (
+ DuplicateAssetID,
+ InventoryError,
+ InventoryValidationError,
+ MinerNotFound,
+)
+from app.inventory.models import (
+ InventoryCreateRequest,
+ InventoryMetadataUpdateRequest,
+ InventoryMinerDetail,
+ InventoryMinerSummary,
+ InventorySearchFilters,
+ InventoryTag,
+ InventoryUpdateRequest,
+)
+from app.inventory.repository import InventoryRepository
+from app.inventory.service import InventoryService
+
+__all__ = [
+ "DuplicateAssetID",
+ "InventoryCreateRequest",
+ "InventoryError",
+ "InventoryMetadataUpdateRequest",
+ "InventoryMinerDetail",
+ "InventoryMinerSummary",
+ "InventoryRepository",
+ "InventorySearchFilters",
+ "InventoryService",
+ "InventoryTag",
+ "InventoryUpdateRequest",
+ "InventoryValidationError",
+ "MinerNotFound",
+]
diff --git a/backend/app/inventory/dependencies.py b/backend/app/inventory/dependencies.py
new file mode 100644
index 0000000..7b2465b
--- /dev/null
+++ b/backend/app/inventory/dependencies.py
@@ -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())
diff --git a/backend/app/inventory/exceptions.py b/backend/app/inventory/exceptions.py
new file mode 100644
index 0000000..3bc9ebf
--- /dev/null
+++ b/backend/app/inventory/exceptions.py
@@ -0,0 +1,25 @@
+"""Inventory domain exceptions."""
+
+
+class InventoryError(Exception):
+ """Base exception for inventory operations."""
+
+
+class InventoryValidationError(InventoryError):
+ """Raised when an inventory business rule is violated."""
+
+
+class DuplicateAssetID(InventoryError):
+ """Raised when an Asset ID is already assigned to another miner."""
+
+ def __init__(self, asset_id: str):
+ self.asset_id = asset_id
+ super().__init__(f"Asset ID already exists: {asset_id}")
+
+
+class MinerNotFound(InventoryError):
+ """Raised when an inventory miner cannot be found."""
+
+ def __init__(self, miner_id: object):
+ self.miner_id = miner_id
+ super().__init__(f"Miner not found: {miner_id}")
diff --git a/backend/app/inventory/models.py b/backend/app/inventory/models.py
new file mode 100644
index 0000000..de278d0
--- /dev/null
+++ b/backend/app/inventory/models.py
@@ -0,0 +1,191 @@
+"""Pydantic contracts for the inventory domain."""
+
+from datetime import datetime
+from typing import Literal
+from uuid import UUID
+
+from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress
+
+
+LifecycleStatus = Literal["active", "spare", "retired", "in_repair"]
+
+
+class InventoryTag(BaseModel):
+ id: UUID
+ name: str
+ created_at: datetime
+ 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
+ last_seen_at: datetime | None = None
+
+
+class InventoryMinerDetail(InventoryMinerSummary):
+ hostname: str | None = None
+ control_board_sn: str | None = None
+ psu_serial: str | None = None
+ firmware_version: str | None = None
+ notes: str | None = None
+ 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)
+ current_ip: IPvAnyAddress | None = None
+ hostname: str | None = Field(default=None, max_length=255)
+ miner_sn: str | None = Field(default=None, max_length=255)
+ control_board_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)
+ firmware_version: str | None = Field(default=None, max_length=255)
+ lifecycle_status: str = "active"
+ site_id: UUID | None = None
+ room_id: UUID | None = None
+ notes: str | None = None
+ 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)
+ current_ip: IPvAnyAddress | None = None
+ hostname: str | None = Field(default=None, max_length=255)
+ miner_sn: str | None = Field(default=None, max_length=255)
+ control_board_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)
+ firmware_version: str | None = Field(default=None, max_length=255)
+ lifecycle_status: str | None = None
+ site_id: UUID | None = None
+ room_id: UUID | None = None
+ notes: str | None = None
+ tags: list[str] | None = None
+
+
+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
diff --git a/backend/app/inventory/repository.py b/backend/app/inventory/repository.py
new file mode 100644
index 0000000..1005cec
--- /dev/null
+++ b/backend/app/inventory/repository.py
@@ -0,0 +1,714 @@
+"""PostgreSQL persistence for the inventory domain."""
+
+from collections.abc import Callable, Iterator, Mapping, Sequence
+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]
+
+TAG_TRIMMED_NAME_SQL = (
+ "regexp_replace(name, '^[[:space:]]+|[[:space:]]+$', '', 'g')"
+)
+
+MINER_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
+ 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
+"""
+
+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",
+ "asset_tag",
+ "current_mac",
+ "current_ip",
+ "hostname",
+ "miner_sn",
+ "control_board_sn",
+ "power_sn",
+ "model",
+ "firmware_version",
+ "lifecycle_status",
+ "status",
+ "notes",
+ "first_seen_at",
+ "last_seen_at",
+}
+
+
+def default_connection_factory() -> psycopg.Connection:
+ return psycopg.connect(
+ host=settings.postgres_host,
+ port=settings.postgres_port,
+ dbname=settings.postgres_db,
+ user=settings.postgres_user,
+ password=settings.postgres_password,
+ connect_timeout=5,
+ )
+
+
+class InventoryRepository:
+ """Database-only inventory persistence with optional transaction binding."""
+
+ 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["InventoryRepository"]:
+ if self._connection is not None:
+ yield self
+ return
+
+ connection = self._connection_factory()
+ transaction_repository = InventoryRepository(
+ self._connection_factory,
+ connection=connection,
+ )
+ try:
+ yield transaction_repository
+ connection.commit()
+ 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()
+
+ @staticmethod
+ 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
+
+ def get_miner_by_id(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,))
+
+ 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:
+ return self._fetch_miner(
+ cur,
+ "lower(trim(miners.asset_tag)) = lower(trim(%s))",
+ (asset_id,),
+ )
+
+ 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,
+ *,
+ 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] = [list(ACTIVE_REPAIR_STATUSES)]
+
+ if query:
+ pattern = f"%{query}%"
+ conditions.append(
+ """
+ (
+ miners.asset_tag ILIKE %s
+ OR miners.current_mac ILIKE %s
+ OR host(miners.current_ip) ILIKE %s
+ OR miners.model ILIKE %s
+ OR miners.miner_sn ILIKE %s
+ OR miners.control_board_sn ILIKE %s
+ OR miners.power_sn ILIKE %s
+ OR miners.notes ILIKE %s
+ )
+ """
+ )
+ 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"""
+ EXISTS (
+ SELECT 1
+ FROM miner_tags search_miner_tags
+ JOIN tags search_tags ON search_tags.id = search_miner_tags.tag_id
+ WHERE search_miner_tags.miner_id = miners.id
+ AND lower({TAG_TRIMMED_NAME_SQL.replace("name", "search_tags.name")})
+ = %s
+ )
+ """
+ )
+ 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])
+
+ with self._connection_scope() as connection:
+ with connection.cursor(row_factory=dict_row) as cur:
+ cur.execute(
+ f"""
+ {select_sql}
+ WHERE {where_sql}
+ 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
+ }
+ columns = list(clean_values)
+ params = [clean_values[column] for column in columns]
+
+ with self._connection_scope(write=True) as connection:
+ with connection.cursor(row_factory=dict_row) as cur:
+ if columns:
+ placeholders = ", ".join(["%s"] * len(columns))
+ cur.execute(
+ f"""
+ INSERT INTO miners ({", ".join(columns)})
+ VALUES ({placeholders})
+ RETURNING id
+ """,
+ params,
+ )
+ else:
+ cur.execute("INSERT INTO miners DEFAULT VALUES RETURNING id")
+ miner_id = cur.fetchone()["id"]
+ return self._fetch_miner(cur, "miners.id = %s", (miner_id,))
+
+ def update_miner(self, miner_id: UUID, values: Mapping[str, Any]) -> dict | None:
+ clean_values = {
+ key: value for key, value in values.items() if key in MINER_WRITE_COLUMNS
+ }
+ if not clean_values:
+ return self.get_miner_by_id(miner_id)
+
+ assignments = [f"{column} = %s" for column in clean_values]
+ params = list(clean_values.values())
+ params.append(miner_id)
+
+ with self._connection_scope(write=True) as connection:
+ with connection.cursor(row_factory=dict_row) as cur:
+ cur.execute(
+ f"""
+ UPDATE miners
+ SET {", ".join(assignments)}, updated_at = now()
+ WHERE id = %s
+ RETURNING id
+ """,
+ params,
+ )
+ row = cur.fetchone()
+ if not row:
+ 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:
+ cur.execute(
+ f"""
+ SELECT id, name, created_at, updated_at
+ FROM tags
+ ORDER BY lower({TAG_TRIMMED_NAME_SQL}), id
+ """
+ )
+ return [dict(row) for row in cur.fetchall()]
+
+ def add_tag(self, miner_id: UUID, tag_name: str) -> dict:
+ with self._connection_scope(write=True) as connection:
+ with connection.cursor(row_factory=dict_row) as cur:
+ cur.execute(
+ f"""
+ INSERT INTO tags (name)
+ VALUES (%s)
+ ON CONFLICT (lower({TAG_TRIMMED_NAME_SQL}))
+ DO UPDATE SET updated_at = tags.updated_at
+ RETURNING id, name, created_at, updated_at
+ """,
+ (tag_name,),
+ )
+ tag = dict(cur.fetchone())
+ cur.execute(
+ """
+ INSERT INTO miner_tags (miner_id, tag_id)
+ VALUES (%s, %s)
+ ON CONFLICT (miner_id, tag_id) DO NOTHING
+ """,
+ (miner_id, tag["id"]),
+ )
+ return tag
+
+ def remove_tag(self, miner_id: UUID, tag_id: UUID) -> bool:
+ with self._connection_scope(write=True) as connection:
+ with connection.cursor() as cur:
+ cur.execute(
+ "DELETE FROM miner_tags WHERE miner_id = %s AND tag_id = %s",
+ (miner_id, tag_id),
+ )
+ return cur.rowcount > 0
diff --git a/backend/app/inventory/router.py b/backend/app/inventory/router.py
new file mode 100644
index 0000000..5c3fafe
--- /dev/null
+++ b/backend/app/inventory/router.py
@@ -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)
diff --git a/backend/app/inventory/service.py b/backend/app/inventory/service.py
new file mode 100644
index 0000000..db2d718
--- /dev/null
+++ b/backend/app/inventory/service.py
@@ -0,0 +1,451 @@
+"""Business rules and orchestration for manual miner inventory."""
+
+from collections.abc import Iterable
+from typing import Any
+from uuid import UUID
+
+from psycopg.errors import UniqueViolation
+
+from app.inventory.exceptions import (
+ DuplicateAssetID,
+ InventoryValidationError,
+ MinerNotFound,
+)
+from app.inventory.models import (
+ InventoryAssetIDHistoryEntry,
+ InventoryCreateRequest,
+ InventoryFleetAttentionSummary,
+ InventoryFleetSummary,
+ InventoryMetadataUpdateRequest,
+ InventoryMinerDetail,
+ InventoryMinerSummary,
+ InventorySearchFilters,
+ InventoryTag,
+ InventoryUpdateRequest,
+)
+from app.inventory.repository import InventoryRepository
+
+
+ALLOWED_LIFECYCLE_STATUSES = frozenset(
+ {"active", "spare", "retired", "in_repair"}
+)
+
+
+class InventoryService:
+ """Inventory business rules independent of FastAPI and other transports."""
+
+ def __init__(self, repository: InventoryRepository):
+ self.repository = repository
+
+ @staticmethod
+ def normalize_asset_id(asset_id: str) -> str:
+ normalized = str(asset_id or "").strip()
+ if not normalized:
+ raise InventoryValidationError("Asset ID cannot be blank")
+ return normalized
+
+ @staticmethod
+ def validate_lifecycle_status(lifecycle_status: str) -> str:
+ normalized = str(lifecycle_status or "").strip().lower()
+ if normalized not in ALLOWED_LIFECYCLE_STATUSES:
+ allowed = ", ".join(sorted(ALLOWED_LIFECYCLE_STATUSES))
+ raise InventoryValidationError(
+ f"Lifecycle status must be one of: {allowed}"
+ )
+ return normalized
+
+ @staticmethod
+ def normalize_tags(tags: Iterable[str]) -> list[str]:
+ normalized: list[str] = []
+ seen: set[str] = set()
+ for raw_tag in tags:
+ tag = str(raw_tag or "").strip()
+ if not tag:
+ raise InventoryValidationError("Tag names cannot be blank")
+ key = tag.casefold()
+ if key not in seen:
+ normalized.append(tag)
+ seen.add(key)
+ return normalized
+
+ @staticmethod
+ def _clean_optional_text(value: str | None) -> str | None:
+ if value is None:
+ return None
+ cleaned = str(value).strip()
+ return cleaned or None
+
+ @staticmethod
+ def _tag_model(row: dict) -> InventoryTag:
+ return InventoryTag.model_validate(row)
+
+ @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"],
+ last_seen_at=row.get("last_seen_at"),
+ )
+
+ @classmethod
+ def _detail_model(cls, row: dict) -> InventoryMinerDetail:
+ summary = cls._summary_model(row)
+ return InventoryMinerDetail(
+ **summary.model_dump(),
+ hostname=row.get("hostname"),
+ control_board_sn=row.get("control_board_sn"),
+ psu_serial=row.get("power_sn"),
+ firmware_version=row.get("firmware_version"),
+ notes=row.get("notes"),
+ first_seen_at=row.get("first_seen_at"),
+ )
+
+ def get_miner(self, miner_id: UUID) -> InventoryMinerDetail:
+ row = self.repository.get_miner_by_id(miner_id)
+ if not row:
+ 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,
+ *,
+ limit: int = 100,
+ offset: int = 0,
+ ) -> list[InventoryMinerSummary]:
+ return [
+ self._summary_model(row)
+ 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)
+ tag_names = [
+ tag.casefold() for tag in self.normalize_tags(filters.tags)
+ ]
+ return [
+ self._summary_model(row)
+ for row in self.repository.search_miners(
+ 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,
+ ) -> InventoryMinerDetail:
+ asset_id = self.normalize_asset_id(request.asset_id)
+ lifecycle_status = self.validate_lifecycle_status(
+ request.lifecycle_status
+ )
+ tags = self.normalize_tags(request.tags)
+
+ if self.repository.get_miner_by_asset_id(asset_id):
+ raise DuplicateAssetID(asset_id)
+
+ values: dict[str, Any] = {
+ "asset_tag": asset_id,
+ "current_mac": self._clean_optional_text(request.current_mac),
+ "current_ip": str(request.current_ip) if request.current_ip else None,
+ "hostname": self._clean_optional_text(request.hostname),
+ "miner_sn": self._clean_optional_text(request.miner_sn),
+ "control_board_sn": self._clean_optional_text(
+ request.control_board_sn
+ ),
+ "power_sn": self._clean_optional_text(request.psu_serial),
+ "model": self._clean_optional_text(request.model),
+ "firmware_version": self._clean_optional_text(
+ request.firmware_version
+ ),
+ "lifecycle_status": lifecycle_status,
+ "site_id": request.site_id,
+ "room_id": request.room_id,
+ "notes": self._clean_optional_text(request.notes),
+ "status": "unknown",
+ "first_seen_at": None,
+ "last_seen_at": None,
+ }
+
+ try:
+ with self.repository.transaction() as repository:
+ row = repository.create_miner(values)
+ for tag in tags:
+ repository.add_tag(row["id"], tag)
+ row = repository.get_miner_by_id(row["id"])
+ self._after_create(row)
+ except UniqueViolation as exc:
+ raise DuplicateAssetID(asset_id) from exc
+
+ 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,
+ request: InventoryUpdateRequest,
+ ) -> InventoryMinerDetail:
+ existing = self.repository.get_miner_by_id(miner_id)
+ if not existing:
+ raise MinerNotFound(miner_id)
+
+ values: dict[str, Any] = {}
+ fields_set = request.model_fields_set
+
+ if "asset_id" in fields_set:
+ if request.asset_id is None:
+ raise InventoryValidationError("Asset ID cannot be cleared")
+ asset_id = self.normalize_asset_id(request.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"]
+
+ if "lifecycle_status" in fields_set:
+ if request.lifecycle_status is None:
+ raise InventoryValidationError(
+ "Lifecycle status cannot be cleared"
+ )
+ values["lifecycle_status"] = self.validate_lifecycle_status(
+ request.lifecycle_status
+ )
+
+ field_map = {
+ "current_mac": "current_mac",
+ "current_ip": "current_ip",
+ "hostname": "hostname",
+ "miner_sn": "miner_sn",
+ "control_board_sn": "control_board_sn",
+ "psu_serial": "power_sn",
+ "model": "model",
+ "firmware_version": "firmware_version",
+ "site_id": "site_id",
+ "room_id": "room_id",
+ "notes": "notes",
+ }
+ for model_field, database_column in field_map.items():
+ if model_field not in fields_set:
+ continue
+ value = getattr(request, model_field)
+ if model_field == "current_ip":
+ values[database_column] = str(value) if value else None
+ elif isinstance(value, str) or value is None:
+ values[database_column] = self._clean_optional_text(value)
+ else:
+ values[database_column] = value
+
+ desired_tags = None
+ if "tags" in fields_set:
+ desired_tags = self.normalize_tags(request.tags or [])
+
+ try:
+ with self.repository.transaction() as repository:
+ self._before_update(existing, values)
+ row = repository.update_miner(miner_id, values)
+ if desired_tags is not None:
+ existing_tags = {
+ str(tag["name"]).strip().casefold(): tag
+ for tag in row.get("tags") or []
+ }
+ desired_keys = {tag.casefold() for tag in desired_tags}
+ for key, tag in existing_tags.items():
+ if key not in desired_keys:
+ repository.remove_tag(miner_id, tag["id"])
+ for tag in desired_tags:
+ if tag.casefold() not in existing_tags:
+ repository.add_tag(miner_id, tag)
+ row = repository.get_miner_by_id(miner_id)
+ self._after_update(row)
+ except UniqueViolation as exc:
+ raise DuplicateAssetID(asset_id) from exc
+
+ return self._detail_model(row)
+
+ def _after_create(self, miner: dict) -> None:
+ """Extension point for later inventory integrations."""
+
+ def _before_update(self, existing: dict, changes: dict[str, Any]) -> None:
+ """Extension point for later inventory integrations."""
+
+ def _after_update(self, miner: dict) -> None:
+ """Extension point for later inventory integrations."""
diff --git a/backend/app/locations/__init__.py b/backend/app/locations/__init__.py
new file mode 100644
index 0000000..4ebae0d
--- /dev/null
+++ b/backend/app/locations/__init__.py
@@ -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"]
diff --git a/backend/app/locations/dependencies.py b/backend/app/locations/dependencies.py
new file mode 100644
index 0000000..7d85b5a
--- /dev/null
+++ b/backend/app/locations/dependencies.py
@@ -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())
diff --git a/backend/app/locations/models.py b/backend/app/locations/models.py
new file mode 100644
index 0000000..5986509
--- /dev/null
+++ b/backend/app/locations/models.py
@@ -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
diff --git a/backend/app/locations/repository.py b/backend/app/locations/repository.py
new file mode 100644
index 0000000..10d0550
--- /dev/null
+++ b/backend/app/locations/repository.py
@@ -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
diff --git a/backend/app/locations/router.py b/backend/app/locations/router.py
new file mode 100644
index 0000000..625f89d
--- /dev/null
+++ b/backend/app/locations/router.py
@@ -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"),
+ )
diff --git a/backend/app/locations/service.py b/backend/app/locations/service.py
new file mode 100644
index 0000000..3e002d1
--- /dev/null
+++ b/backend/app/locations/service.py
@@ -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
+ )
+ )
diff --git a/backend/app/main.py b/backend/app/main.py
index 5af3f21..21aa961 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,19 +1,73 @@
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)
-password_hasher = PasswordHasher()
+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)
def api_response(data: Any = None, meta: dict | None = None, error: Any = None) -> dict:
@@ -74,83 +128,6 @@ def check_redis() -> str:
return f"error: {exc}"
-class SetupAdminRequest(BaseModel):
- username: str = Field(min_length=3, max_length=64)
- display_name: str = Field(min_length=1, max_length=120)
- email: EmailStr | None = None
- password: str = Field(min_length=10, max_length=256)
-
-
-class LoginRequest(BaseModel):
- username: str
- password: str
-
-
-def create_access_token(user_id: str, username: str, role: str) -> str:
- now = datetime.now(timezone.utc)
- payload = {
- "sub": user_id,
- "username": username,
- "role": role,
- "iat": int(now.timestamp()),
- "exp": int((now + timedelta(minutes=settings.jwt_access_token_minutes)).timestamp()),
- "type": "access",
- }
- return jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256")
-
-
-def decode_access_token(token: str) -> dict:
- try:
- payload = jwt.decode(token, settings.jwt_secret_key, algorithms=["HS256"])
- if payload.get("type") != "access":
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
- return payload
- except jwt.ExpiredSignatureError:
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
- except jwt.InvalidTokenError:
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
-
-
-def get_bearer_token(authorization: str | None = Header(default=None)) -> str:
- if not authorization or not authorization.lower().startswith("bearer "):
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
- return authorization.split(" ", 1)[1].strip()
-
-
-def get_current_user(token: str = Depends(get_bearer_token)) -> dict:
- payload = decode_access_token(token)
- user_id = payload["sub"]
-
- conn = None
- try:
- conn = get_db_connection()
- with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
- cur.execute(
- """
- SELECT
- users.id::text AS id,
- users.username,
- users.display_name,
- users.email,
- users.is_active,
- roles.name AS role
- FROM users
- JOIN roles ON roles.id = users.role_id
- WHERE users.id = %s;
- """,
- (user_id,),
- )
- user = cur.fetchone()
-
- if not user or not user["is_active"]:
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
-
- return dict(user)
- finally:
- if conn is not None:
- conn.close()
-
-
@app.get("/api/health")
def health_check():
database_status = check_database()
@@ -171,196 +148,6 @@ def health_check():
)
-@app.get("/api/auth/setup-status")
-def setup_status():
- conn = None
- try:
- conn = get_db_connection()
- with conn.cursor() as cur:
- cur.execute("SELECT COUNT(*) FROM users;")
- user_count = cur.fetchone()[0]
-
- return api_response(
- data={
- "setup_required": user_count == 0,
- "user_count": user_count,
- }
- )
- finally:
- if conn is not None:
- conn.close()
-
-
-@app.post("/api/auth/setup-admin")
-def setup_admin(request: SetupAdminRequest):
- conn = None
- try:
- conn = get_db_connection()
- conn.autocommit = False
-
- with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
- cur.execute("SELECT COUNT(*) AS user_count FROM users;")
- user_count = cur.fetchone()["user_count"]
-
- if user_count > 0:
- raise HTTPException(
- status_code=status.HTTP_409_CONFLICT,
- detail="Initial admin setup has already been completed",
- )
-
- cur.execute("SELECT id FROM roles WHERE name = 'Admin';")
- admin_role = cur.fetchone()
- if not admin_role:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Admin role is missing",
- )
-
- password_hash = password_hasher.hash(request.password)
-
- cur.execute(
- """
- INSERT INTO users (username, display_name, email, password_hash, role_id)
- VALUES (%s, %s, %s, %s, %s)
- RETURNING id::text, username, display_name, email;
- """,
- (
- request.username.strip(),
- request.display_name.strip(),
- request.email,
- password_hash,
- admin_role["id"],
- ),
- )
- user = cur.fetchone()
-
- cur.execute(
- """
- INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
- VALUES (%s, 'user.created', 'user', %s, %s);
- """,
- (
- user["id"],
- user["id"],
- psycopg.types.json.Jsonb(
- {
- "username": user["username"],
- "display_name": user["display_name"],
- "role": "Admin",
- "source": "setup-admin",
- }
- ),
- ),
- )
-
- conn.commit()
-
- return api_response(
- data={
- "user_id": user["id"],
- "username": user["username"],
- "display_name": user["display_name"],
- }
- )
- except HTTPException:
- if conn is not None:
- conn.rollback()
- raise
- except Exception as exc:
- if conn is not None:
- conn.rollback()
- raise HTTPException(status_code=500, detail=str(exc))
- finally:
- if conn is not None:
- conn.close()
-
-
-@app.post("/api/auth/login")
-def login(request: LoginRequest):
- conn = None
- try:
- conn = get_db_connection()
- conn.autocommit = False
-
- with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
- cur.execute(
- """
- SELECT
- users.id::text AS id,
- users.username,
- users.display_name,
- users.email,
- users.password_hash,
- users.is_active,
- roles.name AS role
- FROM users
- JOIN roles ON roles.id = users.role_id
- WHERE lower(users.username) = lower(%s);
- """,
- (request.username.strip(),),
- )
- user = cur.fetchone()
-
- if not user or not user["is_active"]:
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
-
- try:
- password_hasher.verify(user["password_hash"], request.password)
- except VerifyMismatchError:
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
-
- access_token = create_access_token(user["id"], user["username"], user["role"])
-
- cur.execute(
- "UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = %s;",
- (user["id"],),
- )
-
- cur.execute(
- """
- INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
- VALUES (%s, 'user.login', 'user', %s, %s);
- """,
- (
- user["id"],
- user["id"],
- psycopg.types.json.Jsonb({"username": user["username"]}),
- ),
- )
-
- conn.commit()
-
- return api_response(
- data={
- "access_token": access_token,
- "token_type": "bearer",
- "expires_in": settings.jwt_access_token_minutes * 60,
- "user": {
- "id": user["id"],
- "username": user["username"],
- "display_name": user["display_name"],
- "email": user["email"],
- "role": user["role"],
- },
- }
- )
- except HTTPException:
- if conn is not None:
- conn.rollback()
- raise
- except Exception as exc:
- if conn is not None:
- conn.rollback()
- raise HTTPException(status_code=500, detail=str(exc))
- finally:
- if conn is not None:
- conn.close()
-
-
-@app.get("/api/auth/me")
-def me(current_user: dict = Depends(get_current_user)):
- return api_response(data=current_user)
-
class CreateRoomRequest(BaseModel):
site_id: str | None = None
name: str = Field(min_length=1, max_length=120)
@@ -2034,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()
@@ -2108,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()
@@ -4303,7 +4093,7 @@ def minerops_ensure_pool_info_table():
cur.execute(
"""
CREATE TABLE IF NOT EXISTS miner_pool_snapshots (
- id TEXT PRIMARY KEY,
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
miner_id UUID,
ip_address INET,
mac_address TEXT,
@@ -4326,6 +4116,25 @@ def minerops_ensure_pool_info_table():
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+ DO $$
+ DECLARE
+ id_type TEXT;
+ BEGIN
+ SELECT data_type INTO id_type
+ FROM information_schema.columns
+ WHERE table_schema = current_schema()
+ AND table_name = 'miner_pool_snapshots'
+ AND column_name = 'id';
+
+ IF id_type = 'text' THEN
+ ALTER TABLE miner_pool_snapshots
+ ALTER COLUMN id SET DEFAULT (gen_random_uuid())::text;
+ ELSIF id_type = 'uuid' THEN
+ ALTER TABLE miner_pool_snapshots
+ ALTER COLUMN id SET DEFAULT gen_random_uuid();
+ END IF;
+ END $$;
+
CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_miner_created
ON miner_pool_snapshots (miner_id, created_at DESC);
@@ -4443,7 +4252,6 @@ async def agent_pool_snapshots(request: Request):
cur.execute(
"""
INSERT INTO miner_pool_snapshots (
- id,
miner_id,
ip_address,
mac_address,
@@ -4465,7 +4273,6 @@ async def agent_pool_snapshots(request: Request):
agent_id
)
VALUES (
- %s,
%s,
%s::inet,
%s,
@@ -4488,7 +4295,6 @@ async def agent_pool_snapshots(request: Request):
);
""",
(
- str(_uuid.uuid4()),
miner_id,
ip,
mac,
@@ -6241,22 +6047,25 @@ MINEROPS_SHELL_AUTH_JS = r"""
}
async function getCurrentUser() {
- const token = getToken();
-
- if (!token) {
- return null;
- }
-
try {
const response = await fetch("/api/auth/me", {
+ credentials: "same-origin",
headers: authHeaders()
});
if (!response.ok) {
+ window.location.replace("/login?next=" + encodeURIComponent(window.location.pathname));
return null;
}
- return await response.json();
+ const payload = await response.json();
+ const user = payload && payload.data ? payload.data : payload;
+
+ if (user && !getToken()) {
+ setToken("cookie-session");
+ }
+
+ return user;
} catch (error) {
return null;
}
@@ -6352,9 +6161,16 @@ MINEROPS_SHELL_AUTH_JS = r"""
}
}
- function logoutFromHeader() {
- clearToken();
- window.location.reload();
+ async function logoutFromHeader() {
+ try {
+ await fetch("/api/auth/logout", {
+ method: "POST",
+ credentials: "same-origin"
+ });
+ } finally {
+ clearToken();
+ window.location.replace("/login");
+ }
}
async function renderHeaderAuth() {
@@ -6388,6 +6204,13 @@ MINEROPS_SHELL_AUTH_JS = r"""
`;
+ if (user.role === "Admin") {
+ const accountsLink = document.createElement("a");
+ accountsLink.href = "/accounts";
+ accountsLink.textContent = "Accounts";
+ authArea.prepend(accountsLink);
+ }
+
document.getElementById("mineropsHeaderLogout").onclick = logoutFromHeader;
return;
}
@@ -15653,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(
@@ -15687,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(
@@ -16878,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()
@@ -23630,9 +23258,11 @@ async def insert_miner_command_event(
def minerops_command_require_bearer(request: Request):
auth_header = request.headers.get("authorization") or ""
+ cookie_token = request.cookies.get(SESSION_COOKIE) or ""
+ header_token = auth_header.split(" ", 1)[1].strip() if auth_header.lower().startswith("bearer ") else ""
- if not auth_header.lower().startswith("bearer "):
- raise HTTPException(status_code=401, detail="Login token required.")
+ if not cookie_token and (not header_token or header_token == COOKIE_TOKEN_HINT):
+ raise HTTPException(status_code=401, detail="Login required.")
async def minerops_command_user_from_request(request: Request):
@@ -29464,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")):
@@ -30060,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")):
@@ -31298,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")):
@@ -32121,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,
@@ -32263,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,),
)
@@ -32273,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 (
@@ -32355,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,),
)
@@ -32365,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
@@ -32510,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,),
)
@@ -32520,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
@@ -33326,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.");
@@ -33852,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
@@ -34207,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":
@@ -35891,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)
diff --git a/backend/app/miner_detail/__init__.py b/backend/app/miner_detail/__init__.py
new file mode 100644
index 0000000..42902e3
--- /dev/null
+++ b/backend/app/miner_detail/__init__.py
@@ -0,0 +1 @@
+"""Permission-aware Miner Detail read model."""
diff --git a/backend/app/miner_detail/dependencies.py b/backend/app/miner_detail/dependencies.py
new file mode 100644
index 0000000..bfcf3d3
--- /dev/null
+++ b/backend/app/miner_detail/dependencies.py
@@ -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(),
+ )
diff --git a/backend/app/miner_detail/models.py b/backend/app/miner_detail/models.py
new file mode 100644
index 0000000..6fd8662
--- /dev/null
+++ b/backend/app/miner_detail/models.py
@@ -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
+
diff --git a/backend/app/miner_detail/router.py b/backend/app/miner_detail/router.py
new file mode 100644
index 0000000..729028d
--- /dev/null
+++ b/backend/app/miner_detail/router.py
@@ -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
diff --git a/backend/app/miner_detail/service.py b/backend/app/miner_detail/service.py
new file mode 100644
index 0000000..f2e098a
--- /dev/null
+++ b/backend/app/miner_detail/service.py
@@ -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,
+ ),
+ )
diff --git a/backend/app/monitoring/__init__.py b/backend/app/monitoring/__init__.py
new file mode 100644
index 0000000..9e1a6ff
--- /dev/null
+++ b/backend/app/monitoring/__init__.py
@@ -0,0 +1 @@
+"""Historical monitoring read models."""
diff --git a/backend/app/monitoring/dependencies.py b/backend/app/monitoring/dependencies.py
new file mode 100644
index 0000000..124c812
--- /dev/null
+++ b/backend/app/monitoring/dependencies.py
@@ -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())
diff --git a/backend/app/monitoring/exceptions.py b/backend/app/monitoring/exceptions.py
new file mode 100644
index 0000000..81d3e32
--- /dev/null
+++ b/backend/app/monitoring/exceptions.py
@@ -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."""
diff --git a/backend/app/monitoring/frontend.py b/backend/app/monitoring/frontend.py
new file mode 100644
index 0000000..3aa55ef
--- /dev/null
+++ b/backend/app/monitoring/frontend.py
@@ -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),
+ )
diff --git a/backend/app/monitoring/models.py b/backend/app/monitoring/models.py
new file mode 100644
index 0000000..28179e4
--- /dev/null
+++ b/backend/app/monitoring/models.py
@@ -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
diff --git a/backend/app/monitoring/repository.py b/backend/app/monitoring/repository.py
new file mode 100644
index 0000000..999f534
--- /dev/null
+++ b/backend/app/monitoring/repository.py
@@ -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()]
diff --git a/backend/app/monitoring/router.py b/backend/app/monitoring/router.py
new file mode 100644
index 0000000..0414e6c
--- /dev/null
+++ b/backend/app/monitoring/router.py
@@ -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
diff --git a/backend/app/monitoring/service.py b/backend/app/monitoring/service.py
new file mode 100644
index 0000000..22e0612
--- /dev/null
+++ b/backend/app/monitoring/service.py
@@ -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,
+ ),
+ )
diff --git a/backend/app/repairs/__init__.py b/backend/app/repairs/__init__.py
new file mode 100644
index 0000000..40c5fd5
--- /dev/null
+++ b/backend/app/repairs/__init__.py
@@ -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",
+]
diff --git a/backend/app/repairs/center_repository.py b/backend/app/repairs/center_repository.py
new file mode 100644
index 0000000..3f287cd
--- /dev/null
+++ b/backend/app/repairs/center_repository.py
@@ -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()
diff --git a/backend/app/repairs/center_service.py b/backend/app/repairs/center_service.py
new file mode 100644
index 0000000..f8fd90c
--- /dev/null
+++ b/backend/app/repairs/center_service.py
@@ -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()
+ )
diff --git a/backend/app/repairs/dependencies.py b/backend/app/repairs/dependencies.py
new file mode 100644
index 0000000..586a989
--- /dev/null
+++ b/backend/app/repairs/dependencies.py
@@ -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())
diff --git a/backend/app/repairs/exceptions.py b/backend/app/repairs/exceptions.py
new file mode 100644
index 0000000..2e73dd9
--- /dev/null
+++ b/backend/app/repairs/exceptions.py
@@ -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}")
diff --git a/backend/app/repairs/models.py b/backend/app/repairs/models.py
new file mode 100644
index 0000000..7959cac
--- /dev/null
+++ b/backend/app/repairs/models.py
@@ -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
diff --git a/backend/app/repairs/repository.py b/backend/app/repairs/repository.py
new file mode 100644
index 0000000..5e695af
--- /dev/null
+++ b/backend/app/repairs/repository.py
@@ -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]
diff --git a/backend/app/repairs/router.py b/backend/app/repairs/router.py
new file mode 100644
index 0000000..6da37d1
--- /dev/null
+++ b/backend/app/repairs/router.py
@@ -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)
diff --git a/backend/app/repairs/service.py b/backend/app/repairs/service.py
new file mode 100644
index 0000000..f4eef43
--- /dev/null
+++ b/backend/app/repairs/service.py
@@ -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
+ ],
+ )
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 9e0cb02..b3d5926 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -11,3 +11,4 @@ email-validator==2.3.0
python-multipart==0.0.20
asyncpg
pycryptodome==3.23.0
+httpx==0.28.1
diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py
new file mode 100644
index 0000000..387c337
--- /dev/null
+++ b/backend/tests/__init__.py
@@ -0,0 +1 @@
+"""MinerOps backend tests."""
diff --git a/backend/tests/test_control_board_replacement.py b/backend/tests/test_control_board_replacement.py
new file mode 100644
index 0000000..c246f01
--- /dev/null
+++ b/backend/tests/test_control_board_replacement.py
@@ -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()
diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py
new file mode 100644
index 0000000..a4006d3
--- /dev/null
+++ b/backend/tests/test_dashboard.py
@@ -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()
diff --git a/backend/tests/test_frontend_shell.py b/backend/tests/test_frontend_shell.py
new file mode 100644
index 0000000..a18d1f7
--- /dev/null
+++ b/backend/tests/test_frontend_shell.py
@@ -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("Dashboard | MinerOps ", 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=">'
+ 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 = ' '
+ 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(""), 1)
+ self.assertIn('class="skip-link"', text)
+ self.assertIn('', 'Operational ',
+ '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(
+ 'Reset ',
+ 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()
diff --git a/backend/tests/test_inventory_domain.py b/backend/tests/test_inventory_domain.py
new file mode 100644
index 0000000..4df887a
--- /dev/null
+++ b/backend/tests/test_inventory_domain.py
@@ -0,0 +1,718 @@
+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):
+ now = datetime.now(timezone.utc)
+ row = {
+ "id": uuid4(),
+ "site_id": None,
+ "room_id": None,
+ "asset_tag": "ASSET-001",
+ "current_mac": None,
+ "current_ip": None,
+ "hostname": None,
+ "miner_sn": None,
+ "control_board_sn": None,
+ "power_sn": None,
+ "model": None,
+ "firmware_version": None,
+ "lifecycle_status": "active",
+ "status": "unknown",
+ "notes": None,
+ "first_seen_at": None,
+ "last_seen_at": None,
+ "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
+
+
+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):
+ 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(
+ (
+ row
+ for row in self.rows.values()
+ if str(row.get("asset_tag") or "").strip().casefold() == key
+ ),
+ None,
+ )
+
+ def list_miners(self, *, limit=100, offset=0):
+ 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:
+ rows = [
+ row for row in rows if row["lifecycle_status"] == lifecycle
+ ]
+ return rows[kwargs.get("offset", 0) : kwargs.get("limit", 100)]
+
+ def create_miner(self, values):
+ self.created_values = dict(values)
+ row = inventory_row(**values)
+ self.rows[row["id"]] = row
+ 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
+ row.update(values)
+ 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 = {
+ "id": uuid4(),
+ "name": tag_name,
+ "created_at": now,
+ "updated_at": now,
+ }
+ existing = {
+ item["name"].strip().casefold()
+ for item in self.rows[miner_id]["tags"]
+ }
+ if tag_name.casefold() not in existing:
+ self.rows[miner_id]["tags"].append(tag)
+ return tag
+
+ def remove_tag(self, miner_id, tag_id):
+ before = len(self.rows[miner_id]["tags"])
+ self.rows[miner_id]["tags"] = [
+ tag for tag in self.rows[miner_id]["tags"] if tag["id"] != tag_id
+ ]
+ return len(self.rows[miner_id]["tags"]) != before
+
+
+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(
+ InventoryCreateRequest(
+ asset_id=" Mine-001 ",
+ psu_serial=" PSU-9 ",
+ tags=[" Priority ", "priority", "\tRepair\n"],
+ )
+ )
+
+ self.assertEqual(created.asset_id, "Mine-001")
+ self.assertEqual(created.psu_serial, "PSU-9")
+ self.assertEqual([tag.name for tag in created.tags], ["Priority", "Repair"])
+ self.assertIsNone(created.first_seen_at)
+ self.assertIsNone(created.last_seen_at)
+ self.assertIsNone(self.repository.created_values["first_seen_at"])
+ self.assertIsNone(self.repository.created_values["last_seen_at"])
+
+ def test_duplicate_asset_id_is_case_and_whitespace_insensitive(self):
+ row = inventory_row(asset_tag="Mine-001")
+ self.repository.rows[row["id"]] = row
+
+ with self.assertRaises(DuplicateAssetID):
+ self.service.create_miner(
+ InventoryCreateRequest(asset_id=" mine-001 ")
+ )
+
+ def test_lifecycle_validation_is_owned_by_service(self):
+ self.assertEqual(
+ self.service.validate_lifecycle_status(" SPARE "),
+ "spare",
+ )
+ with self.assertRaises(InventoryValidationError):
+ self.service.create_miner(
+ InventoryCreateRequest(
+ asset_id="ASSET-BAD",
+ lifecycle_status="warehouse",
+ )
+ )
+
+ def test_blank_asset_and_tags_are_rejected(self):
+ with self.assertRaises(InventoryValidationError):
+ self.service.normalize_asset_id(" ")
+ 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
+
+ updated = self.service.update_miner(
+ row["id"],
+ InventoryUpdateRequest(
+ psu_serial=" PSU-NEW ",
+ lifecycle_status="retired",
+ ),
+ )
+
+ 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(
+ InventorySearchFilters(lifecycle_status="broken")
+ )
+
+
+class InventoryRepositoryIntegrationTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ 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(
+ {
+ "asset_tag": "REPO-001",
+ "current_ip": "192.0.2.10",
+ "model": "M50",
+ "miner_sn": "MINER-001",
+ "control_board_sn": "CB-001",
+ "power_sn": "PSU-001",
+ "lifecycle_status": "active",
+ "status": "unknown",
+ "first_seen_at": None,
+ "last_seen_at": None,
+ }
+ )
+ miner_id = created["id"]
+
+ self.assertIsInstance(miner_id, UUID)
+ self.assertIsNone(created["first_seen_at"])
+ self.assertIsNone(created["last_seen_at"])
+ self.assertEqual(
+ self.repository.get_miner_by_asset_id(" repo-001 ")["id"],
+ miner_id,
+ )
+ self.assertEqual(self.repository.get_miner_by_id(miner_id)["model"], "M50")
+ self.assertEqual(len(self.repository.list_miners()), 1)
+ self.assertEqual(
+ self.repository.search_miners(query="CB-001")[0]["id"],
+ 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"},
+ )
+ self.assertEqual(updated["lifecycle_status"], "spare")
+ self.assertEqual(
+ self.repository.search_miners(lifecycle_status="spare")[0]["id"],
+ miner_id,
+ )
+
+ tag = self.repository.add_tag(miner_id, "Priority")
+ repeated = self.repository.add_tag(miner_id, " priority ")
+ self.assertEqual(tag["id"], repeated["id"])
+ self.assertEqual(len(self.repository.list_tags()), 1)
+ self.assertEqual(
+ self.repository.search_miners(tag_names=["priority"])[0]["id"],
+ miner_id,
+ )
+ self.assertEqual(
+ len(self.repository.get_miner_by_id(miner_id)["tags"]),
+ 1,
+ )
+ self.assertTrue(self.repository.remove_tag(miner_id, tag["id"]))
+ self.assertEqual(
+ self.repository.get_miner_by_id(miner_id)["tags"],
+ [],
+ )
+
+ 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:
+ repository.create_miner(
+ {
+ "asset_tag": "ROLLBACK-001",
+ "status": "unknown",
+ "first_seen_at": None,
+ "last_seen_at": None,
+ }
+ )
+ raise RuntimeError("rollback")
+
+ self.assertIsNone(
+ self.repository.get_miner_by_asset_id("ROLLBACK-001")
+ )
+
+
+class InventoryArchitectureTests(unittest.TestCase):
+ def test_inventory_domain_layers_have_no_http_dependency(self):
+ inventory_path = Path(__file__).parents[1] / "app" / "inventory"
+ 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):
+ 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.startswith("fastapi") for module in imported_modules),
+ source_path,
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/tests/test_inventory_router.py b/backend/tests/test_inventory_router.py
new file mode 100644
index 0000000..c307036
--- /dev/null
+++ b/backend/tests/test_inventory_router.py
@@ -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()
diff --git a/backend/tests/test_mine_map.py b/backend/tests/test_mine_map.py
new file mode 100644
index 0000000..5a3fed0
--- /dev/null
+++ b/backend/tests/test_mine_map.py
@@ -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": ' ', "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"],
+ ' ')
+ 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()
diff --git a/backend/tests/test_miner_detail.py b/backend/tests/test_miner_detail.py
new file mode 100644
index 0000000..d6679d3
--- /dev/null
+++ b/backend/tests/test_miner_detail.py
@@ -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 (
+ "Miner Detail | MinerOps ",
+ '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'Refresh ',
+ )
+ 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()
diff --git a/backend/tests/test_monitoring_trends.py b/backend/tests/test_monitoring_trends.py
new file mode 100644
index 0000000..075cd06
--- /dev/null
+++ b/backend/tests/test_monitoring_trends.py
@@ -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": ''},
+ {"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('', 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()
diff --git a/backend/tests/test_repair_center.py b/backend/tests/test_repair_center.py
new file mode 100644
index 0000000..812f90a
--- /dev/null
+++ b/backend/tests/test_repair_center.py
@@ -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"',
+ 'Reported problem ', '/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()
diff --git a/backend/tests/test_repair_parts_tracking.py b/backend/tests/test_repair_parts_tracking.py
new file mode 100644
index 0000000..0fc4e4e
--- /dev/null
+++ b/backend/tests/test_repair_parts_tracking.py
@@ -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()
diff --git a/backend/tests/test_repair_timeline.py b/backend/tests/test_repair_timeline.py
new file mode 100644
index 0000000..ea23425
--- /dev/null
+++ b/backend/tests/test_repair_timeline.py
@@ -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()
diff --git a/backend/tests/test_repairs_domain.py b/backend/tests/test_repairs_domain.py
new file mode 100644
index 0000000..bd2457d
--- /dev/null
+++ b/backend/tests/test_repairs_domain.py
@@ -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()
diff --git a/backend/tests/test_repairs_router.py b/backend/tests/test_repairs_router.py
new file mode 100644
index 0000000..0ce9ef1
--- /dev/null
+++ b/backend/tests/test_repairs_router.py
@@ -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()
diff --git a/backend/tests/test_return_to_service.py b/backend/tests/test_return_to_service.py
new file mode 100644
index 0000000..251123c
--- /dev/null
+++ b/backend/tests/test_return_to_service.py
@@ -0,0 +1,1079 @@
+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 fastapi import FastAPI
+from fastapi.testclient import TestClient
+from psycopg.errors import CheckViolation, RaiseException
+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 (
+ RepairActiveMismatch,
+ RepairCompletionActorRequired,
+ RepairMinerNotInRepair,
+ RepairNotActive,
+ RepairNotFound,
+)
+from app.repairs.models import (
+ BeginRepairRequest,
+ RepairActor,
+ RepairCompleteRequest,
+ RepairDetail,
+)
+from app.repairs.repository import RepairRepository
+from app.repairs.router import router
+from app.repairs.service import RepairService
+
+
+NOW = datetime(2026, 7, 29, 16, 0, tzinfo=timezone.utc)
+ACTIVE_STATUSES = {
+ "OPEN",
+ "IN_PROGRESS",
+ "WAITING_FOR_PARTS",
+ "READY_FOR_TEST",
+}
+
+
+def completed_detail(repair_id=None, miner_id=None, actor_id=None):
+ return RepairDetail(
+ repair_id=repair_id or uuid4(),
+ miner_id=miner_id or uuid4(),
+ miner_asset_id="RETURN-TO-SERVICE-001",
+ status="COMPLETED",
+ reason="FAN",
+ opened_at=NOW,
+ opened_by=RepairActor(
+ user_id=uuid4(),
+ display_name="Opening Technician",
+ ),
+ closed_at=NOW,
+ closed_by=RepairActor(
+ user_id=actor_id or uuid4(),
+ display_name="Completing Technician",
+ ),
+ miner_lifecycle="active",
+ latest_event_sequence=3,
+ )
+
+
+class FakeCompletionRepository:
+ def __init__(self, *, status="OPEN", lifecycle="in_repair"):
+ self.repair_id = uuid4()
+ self.miner_id = uuid4()
+ self.opened_by = uuid4()
+ self.closed_by = uuid4()
+ self.miner = {
+ "id": self.miner_id,
+ "lifecycle_status": lifecycle,
+ }
+ self.repair = {
+ "id": self.repair_id,
+ "miner_id": self.miner_id,
+ "status": status,
+ "reason": "FAN",
+ "opened_at": NOW,
+ "opened_by": self.opened_by,
+ "completed_at": NOW if status == "COMPLETED" else None,
+ "completed_by": self.closed_by if status == "COMPLETED" else None,
+ "completion_summary": None,
+ "resulting_lifecycle": None,
+ }
+ self.events = [
+ {
+ "id": uuid4(),
+ "repair_id": self.repair_id,
+ "sequence": 1,
+ "event_type": "REPAIR_STARTED",
+ "created_at": NOW,
+ "created_by": self.opened_by,
+ "payload": {
+ "reason": "FAN",
+ "from_lifecycle": "spare",
+ "to_lifecycle": "in_repair",
+ },
+ },
+ {
+ "id": uuid4(),
+ "repair_id": self.repair_id,
+ "sequence": 2,
+ "event_type": "INITIAL_NOTE",
+ "created_at": NOW,
+ "created_by": self.opened_by,
+ "payload": {"note": "Replace fan."},
+ },
+ ]
+ self.calls = []
+ self.completion_error = None
+ self.lifecycle_error = None
+ self.event_error = None
+ self.identity_missing = False
+ self.active_mismatch = False
+
+ @contextmanager
+ def transaction(self):
+ self.calls.append("transaction")
+ snapshot = deepcopy((self.miner, self.repair, self.events))
+ try:
+ yield self
+ except Exception:
+ self.miner, self.repair, self.events = snapshot
+ raise
+
+ def get_repair_identity(self, repair_id):
+ self.calls.append("get_repair_identity")
+ if self.identity_missing or repair_id != self.repair_id:
+ return None
+ return {"id": self.repair_id, "miner_id": self.miner_id}
+
+ def lock_miner_for_update(self, miner_id):
+ self.calls.append("lock_miner")
+ return self.miner if miner_id == self.miner_id else None
+
+ def lock_repair_for_update(self, repair_id):
+ self.calls.append("lock_repair")
+ return self.repair if repair_id == self.repair_id else None
+
+ def get_active_repair(self, miner_id):
+ self.calls.append("get_active_repair")
+ if self.active_mismatch or self.repair["status"] not in ACTIVE_STATUSES:
+ return None
+ return self.repair
+
+ def get_next_event_sequence(self, repair_id):
+ self.calls.append("get_next_event_sequence")
+ return max(event["sequence"] for event in self.events) + 1
+
+ def get_pre_repair_lifecycle(self, repair_id):
+ self.calls.append("get_pre_repair_lifecycle")
+ return "spare"
+
+ def complete_repair(
+ self,
+ repair_id,
+ *,
+ completed_by,
+ completion_summary,
+ resulting_lifecycle,
+ ):
+ self.calls.append("complete_repair")
+ if self.completion_error is not None:
+ raise self.completion_error
+ self.repair.update(
+ {
+ "status": "COMPLETED",
+ "completed_at": NOW,
+ "completed_by": completed_by,
+ "completion_summary": completion_summary,
+ "resulting_lifecycle": resulting_lifecycle,
+ }
+ )
+ return self.repair
+
+ 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.miner["lifecycle_status"] = lifecycle_status
+
+ def create_event(
+ self,
+ repair_id,
+ *,
+ sequence,
+ event_type,
+ created_by,
+ payload,
+ ):
+ self.calls.append("create_event:REPAIR_COMPLETED")
+ 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": NOW,
+ "created_by": created_by,
+ "payload": dict(payload),
+ }
+ self.events.append(event)
+ return event
+
+ def get_repair_detail(self, repair_id):
+ self.calls.append("get_repair_detail")
+ if repair_id != self.repair_id:
+ return None
+ return {
+ "repair_id": self.repair_id,
+ "miner_id": self.miner_id,
+ "miner_asset_id": "RETURN-TO-SERVICE-001",
+ "status": self.repair["status"],
+ "reason": self.repair["reason"],
+ "opened_at": self.repair["opened_at"],
+ "opened_by_user_id": self.opened_by,
+ "opened_by_display_name": "Opening Technician",
+ "closed_at": self.repair["completed_at"],
+ "closed_by_user_id": self.repair["completed_by"],
+ "closed_by_display_name": "Completing Technician",
+ "miner_lifecycle": self.miner["lifecycle_status"],
+ "latest_event_sequence": max(
+ event["sequence"] for event in self.events
+ ),
+ }
+
+
+class ReturnToServiceServiceTests(unittest.TestCase):
+ def setUp(self):
+ self.repository = FakeCompletionRepository()
+ self.service = RepairService(self.repository)
+ self.actor_id = uuid4()
+
+ def complete(self, target="active", summary=" Load test passed. "):
+ return self.service.complete_repair(
+ self.repository.repair_id,
+ RepairCompleteRequest(
+ target_lifecycle=target,
+ completion_summary=summary,
+ ),
+ actor_user_id=self.actor_id,
+ )
+
+ def test_complete_repair_is_atomic_and_returns_detail(self):
+ result = self.complete()
+
+ self.assertEqual(result.status, "COMPLETED")
+ self.assertEqual(result.miner_lifecycle, "active")
+ self.assertEqual(result.closed_by.user_id, self.actor_id)
+ self.assertEqual(result.latest_event_sequence, 3)
+ self.assertEqual(self.repository.repair["completion_summary"], "Load test passed.")
+ self.assertEqual(self.repository.repair["resulting_lifecycle"], "active")
+ event = self.repository.events[-1]
+ self.assertEqual(event["event_type"], "REPAIR_COMPLETED")
+ self.assertEqual(event["sequence"], 3)
+ self.assertEqual(event["created_by"], self.actor_id)
+ self.assertEqual(
+ event["payload"],
+ {
+ "previous_miner_lifecycle": "in_repair",
+ "resulting_miner_lifecycle": "active",
+ "pre_repair_lifecycle": "spare",
+ "completion_summary": "Load test passed.",
+ },
+ )
+ self.assertEqual(
+ self.repository.calls,
+ [
+ "transaction",
+ "get_repair_identity",
+ "lock_miner",
+ "lock_repair",
+ "get_active_repair",
+ "get_next_event_sequence",
+ "get_pre_repair_lifecycle",
+ "complete_repair",
+ "set_miner_lifecycle",
+ "create_event:REPAIR_COMPLETED",
+ "get_repair_detail",
+ ],
+ )
+
+ def test_spare_target_and_blank_summary_are_preserved_truthfully(self):
+ result = self.complete(target="spare", summary=" \t ")
+
+ self.assertEqual(result.miner_lifecycle, "spare")
+ self.assertIsNone(self.repository.repair["completion_summary"])
+ self.assertEqual(
+ self.repository.events[-1]["payload"],
+ {
+ "previous_miner_lifecycle": "in_repair",
+ "resulting_miner_lifecycle": "spare",
+ "pre_repair_lifecycle": "spare",
+ },
+ )
+
+ def test_maximum_length_summary_is_preserved(self):
+ summary = "x" * 4000
+
+ self.complete(summary=summary)
+
+ self.assertEqual(
+ self.repository.repair["completion_summary"],
+ summary,
+ )
+ self.assertEqual(
+ self.repository.events[-1]["payload"]["completion_summary"],
+ summary,
+ )
+
+ def test_completion_actor_is_required_before_transaction(self):
+ with self.assertRaises(RepairCompletionActorRequired):
+ self.service.complete_repair(
+ self.repository.repair_id,
+ RepairCompleteRequest(target_lifecycle="active"),
+ actor_user_id=None,
+ )
+ self.assertEqual(self.repository.calls, [])
+
+ def test_unknown_repair_is_not_found_without_writes(self):
+ with self.assertRaises(RepairNotFound):
+ self.service.complete_repair(
+ uuid4(),
+ RepairCompleteRequest(target_lifecycle="active"),
+ actor_user_id=self.actor_id,
+ )
+ self.assertEqual(self.repository.repair["status"], "OPEN")
+
+ def test_completed_repair_is_conflict(self):
+ repository = FakeCompletionRepository(status="COMPLETED", lifecycle="active")
+ with self.assertRaises(RepairNotActive):
+ RepairService(repository).complete_repair(
+ repository.repair_id,
+ RepairCompleteRequest(target_lifecycle="active"),
+ actor_user_id=self.actor_id,
+ )
+
+ def test_every_non_active_repair_status_is_a_conflict(self):
+ for status in ("COMPLETED", "CANCELLED"):
+ with self.subTest(status=status):
+ repository = FakeCompletionRepository(
+ status=status,
+ lifecycle="in_repair",
+ )
+ with self.assertRaises(RepairNotActive):
+ RepairService(repository).complete_repair(
+ repository.repair_id,
+ RepairCompleteRequest(target_lifecycle="active"),
+ actor_user_id=self.actor_id,
+ )
+
+ def test_miner_outside_repair_is_conflict(self):
+ repository = FakeCompletionRepository(lifecycle="active")
+ with self.assertRaises(RepairMinerNotInRepair):
+ RepairService(repository).complete_repair(
+ repository.repair_id,
+ RepairCompleteRequest(target_lifecycle="spare"),
+ actor_user_id=self.actor_id,
+ )
+
+ def test_active_repair_mismatch_is_conflict(self):
+ self.repository.active_mismatch = True
+ with self.assertRaises(RepairActiveMismatch):
+ self.complete()
+
+ def test_completion_failure_rolls_back_every_change(self):
+ self.repository.completion_error = RuntimeError("completion failed")
+ with self.assertRaisesRegex(RuntimeError, "completion failed"):
+ self.complete()
+ self.assertEqual(self.repository.repair["status"], "OPEN")
+ self.assertEqual(self.repository.miner["lifecycle_status"], "in_repair")
+ self.assertEqual(len(self.repository.events), 2)
+
+ def test_lifecycle_failure_rolls_back_every_change(self):
+ self.repository.lifecycle_error = RuntimeError("lifecycle failed")
+ with self.assertRaisesRegex(RuntimeError, "lifecycle failed"):
+ self.complete()
+ self.assertEqual(self.repository.repair["status"], "OPEN")
+ self.assertEqual(self.repository.miner["lifecycle_status"], "in_repair")
+ self.assertEqual(len(self.repository.events), 2)
+
+ def test_event_failure_rolls_back_every_change(self):
+ self.repository.event_error = RuntimeError("event failed")
+ with self.assertRaisesRegex(RuntimeError, "event failed"):
+ self.complete()
+ self.assertEqual(self.repository.repair["status"], "OPEN")
+ self.assertEqual(self.repository.miner["lifecycle_status"], "in_repair")
+ self.assertEqual(len(self.repository.events), 2)
+
+
+class ReturnToServiceRepositoryTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.repository = RepairRepository()
+ cls.actor_id = UUID("00000000-0000-0000-0000-000000001203")
+
+ def setUp(self):
+ with self.repository._connection_scope(write=True) as connection:
+ with connection.cursor() as cur:
+ cur.execute(
+ "DROP TRIGGER IF EXISTS test_completion_event_failure "
+ "ON repair_events"
+ )
+ cur.execute(
+ "DROP TRIGGER IF EXISTS test_completion_lifecycle_failure "
+ "ON miners"
+ )
+ cur.execute(
+ "DROP FUNCTION IF EXISTS test_fail_completion_event()"
+ )
+ cur.execute(
+ "DROP FUNCTION IF EXISTS test_fail_completion_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 = 'return-service-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_id,
+ "return-service-test",
+ "Return Service Test",
+ "not-used",
+ ),
+ )
+
+ def create_miner(self, lifecycle="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"RETURN-{uuid4()}", lifecycle),
+ )
+ return cur.fetchone()["id"]
+
+ def begin(self, *, lifecycle="active", notes=None):
+ miner_id = self.create_miner(lifecycle)
+ result = RepairService(self.repository).begin_repair(
+ BeginRepairRequest(
+ miner_id=miner_id,
+ reason="FAN",
+ notes=notes,
+ ),
+ actor_user_id=self.actor_id,
+ )
+ return miner_id, result.repair_id
+
+ def fetch_repair(self, repair_id):
+ with self.repository._connection_scope() as connection:
+ with connection.cursor(row_factory=dict_row) as cur:
+ cur.execute("SELECT * FROM repairs WHERE id = %s", (repair_id,))
+ return dict(cur.fetchone())
+
+ 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 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_completion_persists_actor_time_summary_lifecycle_and_event(self):
+ miner_id, repair_id = self.begin(notes="Replace failed fan.")
+ before = datetime.now(timezone.utc)
+
+ result = RepairService(self.repository).complete_repair(
+ repair_id,
+ RepairCompleteRequest(
+ target_lifecycle="active",
+ completion_summary=" Load test passed. ",
+ ),
+ actor_user_id=self.actor_id,
+ )
+
+ repair = self.fetch_repair(repair_id)
+ events = self.fetch_events(repair_id)
+ self.assertEqual(repair["status"], "COMPLETED")
+ self.assertGreaterEqual(repair["completed_at"], before)
+ self.assertEqual(repair["completed_by"], self.actor_id)
+ self.assertEqual(repair["completion_summary"], "Load test passed.")
+ self.assertEqual(repair["resulting_lifecycle"], "active")
+ self.assertEqual(self.lifecycle(miner_id), "active")
+ self.assertEqual(result.status, "COMPLETED")
+ self.assertEqual(result.closed_by.user_id, self.actor_id)
+ self.assertEqual(result.latest_event_sequence, 3)
+ self.assertEqual(
+ [(event["sequence"], event["event_type"]) for event in events],
+ [
+ (1, "REPAIR_STARTED"),
+ (2, "INITIAL_NOTE"),
+ (3, "REPAIR_COMPLETED"),
+ ],
+ )
+ self.assertEqual(events[-1]["created_by"], self.actor_id)
+ self.assertEqual(
+ events[-1]["payload"],
+ {
+ "previous_miner_lifecycle": "in_repair",
+ "resulting_miner_lifecycle": "active",
+ "pre_repair_lifecycle": "active",
+ "completion_summary": "Load test passed.",
+ },
+ )
+
+ def test_spare_return_and_blank_summary(self):
+ miner_id, repair_id = self.begin(lifecycle="spare")
+ RepairService(self.repository).complete_repair(
+ repair_id,
+ RepairCompleteRequest(
+ target_lifecycle="spare",
+ completion_summary=" ",
+ ),
+ actor_user_id=self.actor_id,
+ )
+ repair = self.fetch_repair(repair_id)
+ self.assertIsNone(repair["completion_summary"])
+ self.assertEqual(repair["resulting_lifecycle"], "spare")
+ self.assertEqual(self.lifecycle(miner_id), "spare")
+ self.assertNotIn(
+ "completion_summary",
+ self.fetch_events(repair_id)[-1]["payload"],
+ )
+
+ def test_pre_repair_lifecycle_requires_exactly_one_start_event(self):
+ changes = (
+ ("delete", "DELETE FROM repair_events WHERE repair_id = %s"),
+ (
+ "duplicate",
+ """
+ INSERT INTO repair_events (
+ repair_id, sequence, event_type, created_by, payload
+ )
+ SELECT
+ repair_id,
+ 2,
+ event_type,
+ created_by,
+ payload
+ FROM repair_events
+ WHERE repair_id = %s
+ AND event_type = 'REPAIR_STARTED'
+ """,
+ ),
+ )
+ for name, change_sql in changes:
+ with self.subTest(change=name):
+ _, repair_id = self.begin()
+ with self.repository._connection_scope(write=True) as connection:
+ with connection.cursor() as cur:
+ if name == "delete":
+ cur.execute(
+ "ALTER TABLE repair_events DISABLE TRIGGER "
+ "trg_protect_repair_event_history"
+ )
+ cur.execute(change_sql, (repair_id,))
+ if name == "delete":
+ cur.execute("ALTER TABLE repair_events ENABLE TRIGGER trg_protect_repair_event_history")
+
+ RepairService(self.repository).complete_repair(
+ repair_id,
+ RepairCompleteRequest(target_lifecycle="active"),
+ actor_user_id=self.actor_id,
+ )
+
+ payload = self.fetch_events(repair_id)[-1]["payload"]
+ self.assertNotIn("pre_repair_lifecycle", payload)
+
+ def test_completion_releases_active_repair_constraint(self):
+ miner_id, repair_id = self.begin()
+ service = RepairService(self.repository)
+ service.complete_repair(
+ repair_id,
+ RepairCompleteRequest(target_lifecycle="active"),
+ actor_user_id=self.actor_id,
+ )
+ later = service.begin_repair(
+ BeginRepairRequest(miner_id=miner_id, reason="INSPECTION"),
+ actor_user_id=self.actor_id,
+ )
+ self.assertNotEqual(later.repair_id, repair_id)
+
+ def test_event_failure_rolls_back_completion_and_lifecycle(self):
+ miner_id, repair_id = self.begin()
+ with self.repository._connection_scope(write=True) as connection:
+ with connection.cursor() as cur:
+ cur.execute(
+ """
+ CREATE FUNCTION test_fail_completion_event()
+ RETURNS trigger AS $$
+ BEGIN
+ IF NEW.event_type = 'REPAIR_COMPLETED' THEN
+ RAISE EXCEPTION 'forced completion event failure';
+ END IF;
+ RETURN NEW;
+ END;
+ $$ LANGUAGE plpgsql
+ """
+ )
+ cur.execute(
+ """
+ CREATE TRIGGER test_completion_event_failure
+ BEFORE INSERT ON repair_events
+ FOR EACH ROW
+ EXECUTE FUNCTION test_fail_completion_event()
+ """
+ )
+
+ with self.assertRaises(RaiseException):
+ RepairService(self.repository).complete_repair(
+ repair_id,
+ RepairCompleteRequest(target_lifecycle="active"),
+ actor_user_id=self.actor_id,
+ )
+ self.assertEqual(self.fetch_repair(repair_id)["status"], "OPEN")
+ self.assertEqual(self.lifecycle(miner_id), "in_repair")
+ self.assertEqual(len(self.fetch_events(repair_id)), 1)
+
+ def test_lifecycle_failure_rolls_back_completion_and_event(self):
+ miner_id, repair_id = self.begin()
+ with self.repository._connection_scope(write=True) as connection:
+ with connection.cursor() as cur:
+ cur.execute(
+ """
+ CREATE FUNCTION test_fail_completion_lifecycle()
+ RETURNS trigger AS $$
+ BEGIN
+ IF OLD.lifecycle_status = 'in_repair'
+ AND NEW.lifecycle_status <> 'in_repair' THEN
+ RAISE EXCEPTION 'forced completion lifecycle failure';
+ END IF;
+ RETURN NEW;
+ END;
+ $$ LANGUAGE plpgsql
+ """
+ )
+ cur.execute(
+ """
+ CREATE TRIGGER test_completion_lifecycle_failure
+ BEFORE UPDATE ON miners
+ FOR EACH ROW
+ EXECUTE FUNCTION test_fail_completion_lifecycle()
+ """
+ )
+
+ with self.assertRaises(RaiseException):
+ RepairService(self.repository).complete_repair(
+ repair_id,
+ RepairCompleteRequest(target_lifecycle="active"),
+ actor_user_id=self.actor_id,
+ )
+ self.assertEqual(self.fetch_repair(repair_id)["status"], "OPEN")
+ self.assertEqual(self.lifecycle(miner_id), "in_repair")
+ self.assertEqual(len(self.fetch_events(repair_id)), 1)
+
+ def test_concurrent_completion_succeeds_once(self):
+ miner_id, repair_id = self.begin()
+ barrier = Barrier(2)
+
+ def complete():
+ barrier.wait()
+ return RepairService(RepairRepository()).complete_repair(
+ repair_id,
+ RepairCompleteRequest(target_lifecycle="active"),
+ actor_user_id=self.actor_id,
+ )
+
+ outcomes = []
+ with ThreadPoolExecutor(max_workers=2) as executor:
+ futures = [executor.submit(complete), executor.submit(complete)]
+ for future in futures:
+ try:
+ outcomes.append(future.result(timeout=10))
+ except RepairNotActive as exc:
+ outcomes.append(exc)
+
+ self.assertEqual(
+ sum(not isinstance(item, Exception) for item in outcomes),
+ 1,
+ )
+ self.assertEqual(
+ sum(isinstance(item, RepairNotActive) for item in outcomes),
+ 1,
+ )
+ completed_events = [
+ event
+ for event in self.fetch_events(repair_id)
+ if event["event_type"] == "REPAIR_COMPLETED"
+ ]
+ self.assertEqual(len(completed_events), 1)
+ self.assertEqual(self.lifecycle(miner_id), "active")
+
+ def test_database_constraints_reject_invalid_completion_metadata(self):
+ miner_id, repair_id = self.begin()
+ with self.assertRaises(CheckViolation):
+ with self.repository._connection_scope(write=True) as connection:
+ with connection.cursor() as cur:
+ cur.execute(
+ """
+ UPDATE repairs
+ SET resulting_lifecycle = 'active'
+ WHERE id = %s
+ """,
+ (repair_id,),
+ )
+ with self.assertRaises(CheckViolation):
+ with self.repository._connection_scope(write=True) as connection:
+ with connection.cursor() as cur:
+ cur.execute(
+ """
+ UPDATE repairs
+ SET status = 'COMPLETED',
+ completed_at = now(),
+ completed_by = %s,
+ resulting_lifecycle = 'retired'
+ WHERE id = %s
+ """,
+ (self.actor_id, repair_id),
+ )
+ with self.assertRaises(CheckViolation):
+ with self.repository._connection_scope(write=True) as connection:
+ with connection.cursor() as cur:
+ cur.execute(
+ """
+ UPDATE repairs
+ SET status = 'COMPLETED',
+ completed_at = now(),
+ completed_by = %s
+ WHERE id = %s
+ """,
+ (self.actor_id, repair_id),
+ )
+ self.assertEqual(self.fetch_repair(repair_id)["status"], "OPEN")
+ self.assertEqual(self.lifecycle(miner_id), "in_repair")
+
+ def test_database_rejects_invalid_completion_event_payload(self):
+ _, repair_id = self.begin()
+ with self.assertRaises(CheckViolation):
+ 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
+ ) VALUES (
+ %s, 2, 'REPAIR_COMPLETED', %s,
+ '{"resulting_miner_lifecycle":"active"}'::jsonb
+ )
+ """,
+ (repair_id, self.actor_id),
+ )
+
+
+class StubCompletionService:
+ def __init__(self):
+ self.result = None
+ self.error = None
+ self.repair_id = None
+ self.request = None
+ self.actor_id = None
+
+ def complete_repair(self, repair_id, request, *, actor_user_id):
+ self.repair_id = repair_id
+ self.request = request
+ self.actor_id = actor_user_id
+ if self.error is not None:
+ raise self.error
+ return self.result
+
+
+class ReturnToServiceRouterTests(unittest.TestCase):
+ def setUp(self):
+ self.service = StubCompletionService()
+ 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="Technician", permissions=("repairs.complete",)):
+ actor_id = uuid4()
+ self.app.dependency_overrides[get_current_user] = lambda: {
+ "id": str(actor_id),
+ "username": role.lower().replace(" ", "-"),
+ "role": role,
+ "permissions": list(permissions),
+ }
+ return actor_id
+
+ def test_authentication_and_permission_are_required(self):
+ repair_id = uuid4()
+ self.assertEqual(
+ self.client.post(
+ f"/api/repairs/{repair_id}/complete",
+ json={"target_lifecycle": "active"},
+ ).status_code,
+ 401,
+ )
+ self.authenticate(permissions=("repairs.read",))
+ self.assertEqual(
+ self.client.post(
+ f"/api/repairs/{repair_id}/complete",
+ json={"target_lifecycle": "active"},
+ ).status_code,
+ 403,
+ )
+
+ def test_admin_manager_and_technician_can_complete(self):
+ repair_id = uuid4()
+ for role in ("Admin", "Manager", "Technician"):
+ with self.subTest(role=role):
+ actor_id = self.authenticate(role=role)
+ self.service.result = completed_detail(
+ repair_id=repair_id,
+ actor_id=actor_id,
+ )
+ response = self.client.post(
+ f"/api/repairs/{repair_id}/complete",
+ json={"target_lifecycle": "active"},
+ )
+ self.assertEqual(response.status_code, 200)
+
+ def test_read_only_cannot_complete(self):
+ self.authenticate(role="Read Only", permissions=("repairs.read",))
+ response = self.client.post(
+ f"/api/repairs/{uuid4()}/complete",
+ json={"target_lifecycle": "active"},
+ )
+ self.assertEqual(response.status_code, 403)
+
+ def test_success_returns_detail_and_authenticated_actor(self):
+ repair_id = uuid4()
+ miner_id = uuid4()
+ actor_id = self.authenticate()
+ self.service.result = completed_detail(
+ repair_id=repair_id,
+ miner_id=miner_id,
+ actor_id=actor_id,
+ )
+
+ response = self.client.post(
+ f"/api/repairs/{repair_id}/complete",
+ json={
+ "target_lifecycle": "spare",
+ "completion_summary": "Ready for shelf.",
+ },
+ )
+
+ self.assertEqual(response.status_code, 200)
+ body = response.json()
+ self.assertEqual(body["data"]["repair_id"], str(repair_id))
+ self.assertEqual(body["data"]["status"], "COMPLETED")
+ self.assertEqual(body["data"]["latest_event_sequence"], 3)
+ self.assertEqual(self.service.actor_id, actor_id)
+ self.assertEqual(self.service.request.target_lifecycle, "spare")
+ self.assertEqual(body["meta"], {})
+ self.assertIsNone(body["error"])
+
+ def test_unknown_repair_and_conflicts_map_to_http(self):
+ actor_id = self.authenticate()
+ repair_id = uuid4()
+ self.service.error = RepairNotFound(repair_id)
+ self.assertEqual(
+ self.client.post(
+ f"/api/repairs/{repair_id}/complete",
+ json={"target_lifecycle": "active"},
+ ).status_code,
+ 404,
+ )
+ self.service.error = RepairNotActive(repair_id, "COMPLETED")
+ self.assertEqual(
+ self.client.post(
+ f"/api/repairs/{repair_id}/complete",
+ json={"target_lifecycle": "active"},
+ ).status_code,
+ 409,
+ )
+ self.assertIsNotNone(actor_id)
+
+ def test_request_validation(self):
+ self.authenticate()
+ repair_id = uuid4()
+ invalid_bodies = (
+ {},
+ {"target_lifecycle": "inactive"},
+ {"target_lifecycle": "in_repair"},
+ {"target_lifecycle": "retired"},
+ {"target_lifecycle": "unknown"},
+ {
+ "target_lifecycle": "active",
+ "completion_summary": "x" * 4001,
+ },
+ {"target_lifecycle": "active", "completed_by": str(uuid4())},
+ {"target_lifecycle": "active", "completed_at": NOW.isoformat()},
+ {"target_lifecycle": "active", "payload": {}},
+ )
+ for body in invalid_bodies:
+ with self.subTest(body=list(body)):
+ response = self.client.post(
+ f"/api/repairs/{repair_id}/complete",
+ json=body,
+ )
+ self.assertEqual(response.status_code, 422)
+
+ def test_maximum_length_summary_is_accepted(self):
+ repair_id = uuid4()
+ actor_id = self.authenticate()
+ self.service.result = completed_detail(
+ repair_id=repair_id,
+ actor_id=actor_id,
+ )
+
+ response = self.client.post(
+ f"/api/repairs/{repair_id}/complete",
+ json={
+ "target_lifecycle": "active",
+ "completion_summary": "x" * 4000,
+ },
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ self.service.request.completion_summary,
+ "x" * 4000,
+ )
+
+ def test_malformed_uuid_returns_validation_error(self):
+ self.authenticate()
+ response = self.client.post(
+ "/api/repairs/not-a-uuid/complete",
+ json={"target_lifecycle": "active"},
+ )
+ self.assertEqual(response.status_code, 422)
+
+
+class ReturnToServiceArchitectureTests(unittest.TestCase):
+ @staticmethod
+ def full_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_exactly_one_completion_route_and_permission_mapping(self):
+ complete_routes = [
+ route
+ for route in router.routes
+ if route.path == "/api/repairs/{repair_id}/complete"
+ and "POST" in route.methods
+ ]
+ self.assertEqual(len(complete_routes), 1)
+ repair_id = uuid4()
+ self.assertEqual(
+ required_api_permission(
+ "POST", f"/api/repairs/{repair_id}/complete"
+ ),
+ "repairs.complete",
+ )
+ self.assertEqual(
+ required_api_permission("POST", "/api/repairs"),
+ "repairs.create",
+ )
+ self.assertEqual(
+ required_api_permission(
+ "GET", f"/api/repairs/{repair_id}"
+ ),
+ "repairs.read",
+ )
+
+ def test_static_and_uuid_routes_keep_their_handlers(self):
+ from app.main import app
+
+ cases = (
+ ("GET", "/api/repairs/queue", "minerops_repair_queue_api"),
+ (
+ "GET",
+ "/api/repairs/parts-summary",
+ "minerops_repair_parts_summary_api",
+ ),
+ (
+ "POST",
+ f"/api/repairs/{uuid4()}/complete",
+ "complete_repair",
+ ),
+ )
+ for method, path, endpoint in cases:
+ with self.subTest(path=path):
+ matches = self.full_matches(app, method, path)
+ self.assertGreaterEqual(len(matches), 1)
+ self.assertEqual(matches[0].endpoint.__name__, endpoint)
+
+ def test_layering_and_immutable_event_surface(self):
+ repairs_path = Path(__file__).parents[1] / "app" / "repairs"
+ router_source = (repairs_path / "router.py").read_text(
+ encoding="utf-8"
+ )
+ for keyword in (
+ "SELECT ",
+ "INSERT ",
+ "UPDATE ",
+ "DELETE ",
+ ".commit(",
+ ".rollback(",
+ ".transaction(",
+ ):
+ self.assertNotIn(keyword.upper(), router_source.upper())
+ 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
+ )
+ )
+ self.assertFalse(hasattr(RepairRepository, "update_repair"))
+ self.assertFalse(hasattr(RepairRepository, "update_repair_event"))
+ self.assertFalse(hasattr(RepairRepository, "delete_repair_event"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/branding/BRANDING.md b/branding/BRANDING.md
new file mode 100644
index 0000000..1cbed0e
--- /dev/null
+++ b/branding/BRANDING.md
@@ -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.
diff --git a/branding/colors.md b/branding/colors.md
new file mode 100644
index 0000000..0483822
--- /dev/null
+++ b/branding/colors.md
@@ -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.
diff --git a/branding/favicon.svg b/branding/favicon.svg
new file mode 100644
index 0000000..f12dd5b
--- /dev/null
+++ b/branding/favicon.svg
@@ -0,0 +1,5 @@
+
+ MinerOps favicon
+
+
+
diff --git a/branding/logo-dark.svg b/branding/logo-dark.svg
new file mode 100644
index 0000000..6fe84d2
--- /dev/null
+++ b/branding/logo-dark.svg
@@ -0,0 +1,5 @@
+
+ MinerOps logo for dark backgrounds
+
+
+
diff --git a/branding/logo-light.svg b/branding/logo-light.svg
new file mode 100644
index 0000000..92184da
--- /dev/null
+++ b/branding/logo-light.svg
@@ -0,0 +1,5 @@
+
+ MinerOps logo for light backgrounds
+
+
+
diff --git a/branding/logo.svg b/branding/logo.svg
new file mode 100644
index 0000000..1dcb79d
--- /dev/null
+++ b/branding/logo.svg
@@ -0,0 +1,5 @@
+
+ MinerOps logo
+
+
+
diff --git a/branding/wordmark.svg b/branding/wordmark.svg
new file mode 100644
index 0000000..a7b3f4b
--- /dev/null
+++ b/branding/wordmark.svg
@@ -0,0 +1,9 @@
+
+ MinerOps wordmark
+
+
+
+
+ Miner
+ Ops
+
diff --git a/compose.dev.yaml b/compose.dev.yaml
new file mode 100644
index 0000000..0fb51e0
--- /dev/null
+++ b/compose.dev.yaml
@@ -0,0 +1,105 @@
+name: minerops-dev
+
+services:
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB}
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ volumes:
+ - postgres-dev:/var/lib/postgresql/data
+ networks:
+ - dev
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
+ interval: 5s
+ timeout: 5s
+ retries: 12
+
+ redis:
+ image: redis:7-alpine
+ command: ["redis-server", "--appendonly", "yes"]
+ volumes:
+ - redis-dev:/data
+ networks:
+ - dev
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 12
+
+ backend:
+ build:
+ context: ./backend
+ env_file:
+ - .env.dev
+ command:
+ - /bin/sh
+ - -ec
+ - alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ networks:
+ - dev
+ read_only: true
+ tmpfs:
+ - /tmp
+ cap_drop:
+ - ALL
+ security_opt:
+ - no-new-privileges:true
+ healthcheck:
+ test:
+ - CMD
+ - python
+ - -c
+ - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=3)"
+ interval: 10s
+ timeout: 5s
+ retries: 12
+ start_period: 20s
+
+ gateway:
+ image: nginx:1.29-alpine
+ depends_on:
+ backend:
+ condition: service_healthy
+ ports:
+ - "${MINEROPS_DEV_PORT:-18000}:8080"
+ networks:
+ - dev
+ - edge
+ volumes:
+ - ./dev/nginx.conf:/etc/nginx/nginx.conf:ro
+ read_only: true
+ tmpfs:
+ - /tmp
+ cap_drop:
+ - ALL
+ security_opt:
+ - no-new-privileges:true
+ user: "101:101"
+ healthcheck:
+ test:
+ - CMD
+ - wget
+ - --quiet
+ - --spider
+ - http://127.0.0.1:8080/api/health
+ interval: 10s
+ timeout: 5s
+ retries: 12
+
+networks:
+ dev:
+ internal: true
+ edge:
+
+volumes:
+ postgres-dev:
+ redis-dev:
diff --git a/dev/nginx.conf b/dev/nginx.conf
new file mode 100644
index 0000000..7ca033b
--- /dev/null
+++ b/dev/nginx.conf
@@ -0,0 +1,30 @@
+worker_processes 1;
+pid /tmp/nginx.pid;
+
+events {
+ worker_connections 128;
+}
+
+http {
+ access_log /dev/stdout;
+ error_log /dev/stderr notice;
+
+ client_body_temp_path /tmp/client_body;
+ proxy_temp_path /tmp/proxy;
+ fastcgi_temp_path /tmp/fastcgi;
+ uwsgi_temp_path /tmp/uwsgi;
+ scgi_temp_path /tmp/scgi;
+
+ server {
+ listen 8080;
+ server_name _;
+
+ location / {
+ proxy_pass http://backend:8000;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+ }
+}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..fed3a08
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -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.
diff --git a/docs/CODING_STANDARDS.md b/docs/CODING_STANDARDS.md
new file mode 100644
index 0000000..ec6a4c5
--- /dev/null
+++ b/docs/CODING_STANDARDS.md
@@ -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.
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
new file mode 100644
index 0000000..5142db1
--- /dev/null
+++ b/docs/CONTRIBUTING.md
@@ -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.
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
new file mode 100644
index 0000000..8c7ae00
--- /dev/null
+++ b/docs/DECISIONS.md
@@ -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.
diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md
new file mode 100644
index 0000000..a15c8c3
--- /dev/null
+++ b/docs/PRODUCT.md
@@ -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.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
new file mode 100644
index 0000000..0cf013c
--- /dev/null
+++ b/docs/ROADMAP.md
@@ -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.
diff --git a/docs/VISION.md b/docs/VISION.md
new file mode 100644
index 0000000..0ff6a08
--- /dev/null
+++ b/docs/VISION.md
@@ -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.
diff --git a/docs/design/API_STYLE_GUIDE.md b/docs/design/API_STYLE_GUIDE.md
new file mode 100644
index 0000000..9f55f37
--- /dev/null
+++ b/docs/design/API_STYLE_GUIDE.md
@@ -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.
diff --git a/docs/design/COMPONENT_DOMAIN_MODEL.md b/docs/design/COMPONENT_DOMAIN_MODEL.md
new file mode 100644
index 0000000..669827a
--- /dev/null
+++ b/docs/design/COMPONENT_DOMAIN_MODEL.md
@@ -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.
diff --git a/docs/design/MINE_MAP.md b/docs/design/MINE_MAP.md
new file mode 100644
index 0000000..da906b9
--- /dev/null
+++ b/docs/design/MINE_MAP.md
@@ -0,0 +1,752 @@
+# MinerOps Mine Map v1
+
+## Status
+
+This document is the design baseline for the implemented Mine Map v1. The
+current application exposes the read-only operational view at `/monitoring/map`
+and retains the legacy Location Map at `/locations` for existing location
+management workflows. Those mutation workflows are not redesigned by v1.
+
+## Purpose
+
+Mine Map v1 answers one primary question:
+
+> What is happening physically in the mine right now, and where do I need to
+> look?
+
+It must let a technician or manager scan a physical room, identify explicit
+runtime and work states, locate affected miners, understand empty capacity, and
+inspect one miner without losing spatial context. It is an operational tool,
+not a geographic GIS map and not a decorative dashboard.
+
+## Current Data Model
+
+### Physical Hierarchy
+
+The implemented hierarchy is:
+
+```text
+Site (UUID)
+ -> Room (UUID, belongs to Site)
+ -> Rack (text label on a location position)
+ -> Shelf (text label and optional numeric index)
+ -> Position (UUID and numeric index)
+ -> Miner (UUID, optional occupant)
+```
+
+`sites` and `rooms` are normalized entities. A room belongs to one site.
+`location_positions` stores `room_id`, denormalized `room_name`, `rack_name`,
+`shelf_name`, optional `shelf_index`, `position_index`, and optional
+`position_label`. Rack and shelf are not independent entities and do not have
+durable UUIDs. Mine Map must therefore treat rack and shelf values as ordered
+labels within a room, not as globally addressable assets.
+
+A position may reference one miner. `miner_location_states` separately stores
+the miner's current location state and optional position reference. The miner's
+durable identity remains `miners.id`; neither its position, IP address, nor MAC
+address is asset identity.
+
+### Position Vocabulary
+
+The current code uses these position kinds:
+
+- `miner_slot`
+- `switch`
+- `blocked`
+
+The current code uses these position statuses:
+
+- `available`
+- `occupied`
+- `switch`
+- `blocked`
+- `conflict`
+
+These values are used by the active implementation but are not constrained by
+the database. V1 must render unknown values as **Unknown position state** and
+must not silently treat them as empty or occupied. A later schema task should
+constrain the vocabulary after legacy-data preflight.
+
+The current miner location-state vocabulary is:
+
+- `shelved`
+- `waiting_placement`
+- `storage`
+- `repair`
+- `decommissioned`
+
+These values are also not database-constrained. They describe location or
+placement state, not miner lifecycle and not authoritative repair status.
+
+### Lifecycle And Repair State
+
+Miner lifecycle is independently constrained to:
+
+- `active`
+- `spare`
+- `retired`
+- `in_repair`
+
+The modular Repairs domain uses `repairs` and immutable `repair_events` as the
+authoritative repair workflow. Active repair statuses are `OPEN`,
+`IN_PROGRESS`, `WAITING_FOR_PARTS`, and `READY_FOR_TEST`. `COMPLETED` and
+`CANCELLED` are not active repairs.
+
+The legacy location API currently exposes `miner_location_states.asset_state`
+but does not expose lifecycle or the authoritative active repair. Mine Map v1
+must not equate legacy `asset_state = repair` with a modular repair record.
+
+## User Outcomes
+
+V1 must support these outcomes without requiring location mutation:
+
+- Select a site and room and see its known physical layout.
+- Scan racks, shelves, and positions in their stored order.
+- Distinguish occupied, explicitly empty, blocked, switch, conflict, and
+ incomplete positions.
+- Identify miners explicitly reported offline or hash-unstable.
+- Identify miners in the authoritative repair lifecycle or an active repair.
+- Distinguish active, spare, retired, and in-repair assets.
+- See latest available hashrate, power, and temperature values with timestamps.
+- Inspect a miner and open its existing detail page.
+- Return to the same room, rack, layer, filters, selection, and scroll context.
+
+## Page Composition
+
+Mine Map uses the current authenticated application shell at
+`/monitoring/map`. The page contains four functional regions.
+
+### Context Bar
+
+The context bar contains:
+
+- site selector;
+- room selector;
+- layer selector, defaulting to **Operational Status**;
+- search;
+- filters;
+- data freshness summary; and
+- a link to the existing location-management page when the user has
+ `locations.manage`.
+
+The selected site limits the room selector. The selected room is required for
+the primary rack layout. An optional all-room summary may show room counts, but
+v1 must not render every tile from every room by default at large sites.
+
+### Spatial Layout
+
+The main region renders the selected room as ordered rack groups. Each rack
+contains shelf rows, and each row contains positions ordered by
+`position_index`. The layout uses ordinary semantic DOM elements and CSS grid.
+Canvas, WebGL, pan physics, and a geographic basemap are not required.
+
+The visual order follows stored room `sort_order`, shelf `shelf_index` where
+present, and position index. Rack ordering is label-based because no rack sort
+field exists. The UI must not infer missing racks, shelves, or positions from
+gaps in names or numbers.
+
+### Legend And Summary
+
+The legend is always visible for the active layer and pairs color with text and
+an icon or pattern. A compact summary reports only counts derivable from the
+loaded response, such as occupied positions, empty positions, conflicts,
+explicit offline miners, in-repair miners, and unknown-status miners.
+
+Counts must identify their scope and freshness. They must not claim site-wide
+coverage when only one room is loaded or when data is partial.
+
+### Miner Inspector
+
+Selecting an occupied miner opens a side panel on desktop. On narrow screens it
+opens a full-height sheet with an obvious close control. The map remains the
+underlying context and selection is visible on the selected tile.
+
+Closing the inspector restores focus to the tile that opened it. Opening the
+full miner detail view uses the existing durable route:
+
+```text
+/miners/{miner_id}/detail
+```
+
+## Position And Miner Rendering
+
+### Occupied Positions
+
+An occupied miner tile shows, in priority order:
+
+- position label or `Position {position_index}`;
+- Asset ID when available, otherwise current MAC, otherwise **Unidentified
+ miner**;
+- operational condition text and icon;
+- the active layer's value; and
+- observation age or **Never observed**.
+
+The position remains the visible frame; filtering and status styling must not
+make its physical address ambiguous.
+
+### Empty And Non-Miner Positions
+
+Empty positions are operationally meaningful and remain visible.
+
+| Condition | V1 presentation |
+| --- | --- |
+| `miner_slot`, no miner, no expected MAC, `available` | **Empty position** |
+| `miner_slot`, expected MAC, no miner | **Expected miner not resolved**; not empty |
+| `blocked` kind or status | **Blocked position** with reason when available |
+| `switch` kind or status | **Switch/equipment position** with equipment label |
+| `conflict` or `conflict_reason` | **Location conflict**; never shown as empty |
+| Unknown kind or status | **Unknown position state** |
+| Filtered occupied miner | Tile remains in place but is visually deemphasized |
+| Missing response data | **Unavailable**, distinct from an empty position |
+
+An absent position record is not an empty position. V1 must not synthesize a
+tile merely because a numeric position is missing from a shelf sequence.
+
+### Unracked And Operationally Removed Miners
+
+Miners returned in `virtual_miners` appear outside the rack canvas in an
+**Unplaced and operational areas** section grouped by the stored location
+state. V1 maps the stored values to plain labels without inventing workflow:
+
+- `waiting_placement`: Waiting for placement
+- `storage`: Storage
+- `repair`: Legacy repair location state
+- `decommissioned`: Decommissioned location state
+
+Lifecycle is shown separately. A spare miner is not automatically in storage,
+and a retired miner is not automatically decommissioned. Where lifecycle and
+location state disagree, both are displayed and the discrepancy is not hidden.
+
+### Repair Miners
+
+A miner with `lifecycle_status = in_repair` or an authoritative active repair is
+visually unmistakable through text, icon, and a repair treatment independent of
+the selected data layer.
+
+V1 distinguishes:
+
+- in repair while still assigned to a physical position;
+- in repair with no rack position;
+- repair complete with lifecycle `active` or `spare`, awaiting placement; and
+- legacy `asset_state = repair` without an authoritative active repair.
+
+The last case is labelled **Legacy repair location state** rather than being
+presented as a confirmed active repair.
+
+## Operational Status Model
+
+Operational Status is the default layer. It composes current facts with this
+display precedence:
+
+1. Location conflict, blocked position, switch, or empty position.
+2. Active repair or lifecycle `in_repair`.
+3. Lifecycle `retired` or decommissioned location state.
+4. Lifecycle `spare`.
+5. Runtime status explicitly equal to `offline`.
+6. `hash_stable = false`, labelled **Hash unstable**.
+7. Runtime status explicitly equal to `online`.
+8. Unknown or unrecognized runtime status.
+
+Lifecycle and repair treatments do not erase runtime facts. For example, an
+in-repair miner may also show that its last runtime observation was online.
+
+`miners.status` is free text and not database-constrained. V1 must not infer
+that arbitrary values containing `error`, `warn`, or `fault` mean a particular
+severity. Unknown values may be displayed as secondary raw context after safe
+formatting.
+
+### Freshness
+
+`last_seen_at` and latest telemetry `observed_at` are available, but MinerOps has
+no single authoritative staleness policy. Existing legacy screens use
+inconsistent thresholds, including 10 and 30 minutes. Therefore:
+
+- v1 always displays observation time or age;
+- missing timestamps are **Never observed** or **Freshness unknown**;
+- explicit runtime `offline` may be shown as Offline;
+- v1 does not label a miner Stale based on age until a centralized policy is
+ approved and supplied by the backend; and
+- late or failed refreshes keep the last successful values visible with a
+ prominent **Update failed** or **Data may be stale** state.
+
+### Conditions Requiring Policy
+
+The following labels require an approved backend policy and are not inferred by
+v1:
+
+- stale based on observation age;
+- down based on missing hashrate or elapsed time;
+- hot or overheating based on temperature;
+- degraded based on hashrate, rejection rate, power, or temperature; and
+- low hashrate relative to model expectation.
+
+The legacy Location Map and dashboard endpoints contain hard-coded thresholds,
+but they are not a consistent policy contract. V1 may display raw measurements
+and the explicit `hash_stable` boolean without adopting those thresholds.
+
+## Layer Model
+
+The layer selector is extensible, but the first implementation is not required
+to enable every layer.
+
+| Layer | V1 status | Current data | Required behavior |
+| --- | --- | --- | --- |
+| Operational Status | Required, default | Position kind/status, runtime status, lifecycle after API addition, `hash_stable`, timestamps | Uses the factual precedence above; no invented thresholds |
+| Repair State | Required presentation, optional standalone layer | Lifecycle and modular repair after API addition; legacy location state exists now | Repair treatment remains visible on every layer |
+| Temperature | Optional v1 value layer | Latest chip maximum and average are already in the map response; environment temperature exists in snapshots but is omitted | Show numeric value and timestamp; no hot classification without policy |
+| Hashrate | Optional v1 value layer | Latest average hashrate in TH/s is already in the map response | Show value and missing-data state; no low-hashrate threshold |
+| Power | Optional v1 value layer | Latest power in watts is already in the map response | Show value and missing-data state; no density or efficiency inference |
+| Thermal heat map | Future | Point temperatures exist for some miners | Requires approved thresholds, aggregation semantics, coverage disclosure, and legend |
+
+The frontend implements layers through a shared tile contract rather than
+separate maps. Adding a future layer changes tile content and legend, not the
+physical hierarchy or selection model.
+
+## Filtering And Search
+
+The implemented filters are allowlisted and use returned miner facts: derived
+operational status, lifecycle, model, and authoritative active-repair presence.
+Search covers Asset ID, miner serial, current MAC, current IP, and model. Location
+context remains visible while nonmatching occupied tiles are deemphasized. Users
+without `miners.view` receive none of these miner fields; miner search, miner
+filters, and non-status layers are disabled rather than operating on redacted
+values.
+
+Filtering preserves physical context. Positions remain in the layout;
+nonmatching occupied tiles are deemphasized and marked as filtered. A separate
+**Show only matches** density option may collapse nonmatching rack groups, but
+it is off by default and clearly indicates that context is hidden. Empty,
+blocked, switch, and conflict positions remain visible unless explicitly
+filtered by position type.
+
+No-results differs from load failure and from an empty room:
+
+- **No positions configured**: the selected room has no position records.
+- **No miners match these filters**: layout remains visible, with miners
+ deemphasized.
+- **Map unavailable**: the request failed or returned an invalid response.
+
+## Miner Inspector Fields
+
+Where authorized and available, the inspector shows:
+
+- Asset ID;
+- model or miner type;
+- miner serial number;
+- current MAC and IP address;
+- full site, room, rack, shelf, and position path;
+- runtime status and telemetry observation age;
+- lifecycle;
+- active repair status and reason;
+- latest hashrate in TH/s;
+- latest power in watts;
+- latest chip maximum temperature;
+- telemetry observation time; and
+- a link to full miner detail.
+
+Null values render as **Unavailable**. Numeric zero is displayed as zero, not as
+missing. The panel must not fetch one API resource per visible tile. A single
+detail request for the selected miner is acceptable when it supplies fields not
+needed by the map response.
+
+## Context Preservation
+
+Shareable state is URL-backed. The initial query contract is:
+
+```text
+/monitoring/map?site_id={uuid}&room_id={uuid}&layer=status&miner_id={uuid}
+```
+
+Optional encoded parameters hold allowlisted filters and search. Rack and shelf
+do not have URL identifiers because they are labels rather than durable entities.
+Unknown, unauthorized, or malformed parameters are discarded and the canonical
+URL is replaced; they never broaden data access.
+
+The URL preserves:
+
+- selected site and room;
+- layer;
+- filters and search; and
+- selected miner.
+
+Selecting or closing a miner updates history so browser Back and Forward restore
+predictable URL-backed selection. Refresh and bookmarked links reconstruct the
+same valid shareable state. Scroll position is not persisted by v1.
+
+When full miner detail opens in the same tab, the return URL contains the map
+query state. A new tab remains available through normal browser behavior.
+
+## Responsive Behavior
+
+### Desktop
+
+- Context controls remain visible above the map.
+- The room layout occupies the main content width.
+- The inspector uses a fixed-width side panel without resizing rack tiles.
+- Horizontal scrolling is contained within an individual wide rack or map
+ viewport, not the whole page.
+
+### Tablet And Phone
+
+- Site, room, layer, and filter controls stack without hiding the active scope.
+- One rack is shown at a time when the full room cannot remain legible.
+- Shelf rows retain physical position order and allow contained horizontal
+ scrolling.
+- The inspector becomes a full-screen sheet.
+- Closing the sheet restores the selected tile and scroll location.
+- Touch targets meet the shared UX guide sizing rules.
+
+The mobile view does not turn positions into an unrelated flat miner list. Rack
+and shelf context remains visible.
+
+## Accessibility
+
+Mine Map v1 must:
+
+- communicate every state with text and an icon or pattern, not color alone;
+- provide one page heading and semantic site, room, rack, shelf, and position
+ labels;
+- expose each rack as a labelled region and each shelf as a labelled group;
+- expose miner tiles as buttons when they open the inspector and noninteractive
+ elements when they do not;
+- include an accessible name containing position, miner identity, condition,
+ and freshness;
+- support logical keyboard movement through positions in stored spatial order;
+- preserve visible focus and return focus after closing the inspector;
+- provide keyboard-operable filters, layer selection, scrolling, and zoom or
+ density controls;
+- avoid hover-only values;
+- support browser zoom without clipping labels or controls; and
+- avoid required motion and respect reduced-motion preferences if motion is
+ introduced later.
+
+A screen-reader summary provides counts and a structured rack/shelf/position
+list. It describes empty and blocked positions rather than omitting them.
+
+## Loading, Failure, And Partial Data
+
+The map has explicit states:
+
+- **Loading**: layout skeletons preserve the eventual page structure.
+- **Ready**: layout and response scope are known.
+- **Empty room**: valid response with no configured positions.
+- **No configured sites or rooms**: directs authorized users to existing
+ location management without implying a load failure.
+- **Denied**: permission failure without leaking counts or miner previews.
+- **Error**: request failed or response shape is invalid; remains visible until
+ retry or navigation.
+- **Partial data**: physical layout is usable, but one or more miner fields or
+ telemetry observations are unavailable.
+- **Refresh failed**: last successful map remains visible, clearly marked with
+ the failure time.
+
+The frontend validates the response envelope and required position identifiers.
+A malformed response is an error, not an empty room.
+
+## Performance And Refresh
+
+V1 is designed for hundreds or thousands of positions without prematurely
+adopting canvas or WebGL.
+
+- Fetch a map-shaped bulk response; never issue one request per position.
+- Load one selected site and room where backend filtering is available.
+- Render the visible room first and defer secondary summaries.
+- Build rack and shelf fragments off-DOM, then attach them in batches.
+- Update changed tile content without replacing the full page or shell.
+- Keep layout refresh separate from telemetry refresh because positions change
+ less frequently than observations.
+- Do not overlap refresh requests; cancel or ignore superseded responses.
+- Pause automatic telemetry refresh while the page is hidden.
+- Base any polling interval on collector cadence or backend guidance; this
+ document does not invent an interval.
+- Preserve last successful values during transient failure and expose their
+ observation timestamps.
+- Introduce list virtualization only after measurement shows the selected-room
+ DOM is too large; accessibility and keyboard order must be preserved.
+
+The current bulk location query selects the latest snapshot per miner and is a
+useful foundation. Before production use at large scale, its query plan and
+indexes must be measured with representative site data.
+
+## Current API Assessment
+
+### `GET /api/location/map`
+
+Permission: `locations.view`.
+
+This is the primary existing map-shaped response and avoids N+1 tile requests.
+It returns:
+
+- `data.positions`: position UUID, room/rack/shelf labels, shelf and position
+ indexes, position label/kind/status, expected MAC, equipment metadata,
+ conflict information, miner UUID, current MAC/IP, runtime status, hostname,
+ serials, model/type, firmware, last seen, legacy location state, and latest
+ hashrate, power, chip temperatures, hash stability, rejection percentage, and
+ observation time;
+- `data.virtual_miners`: miners in waiting-placement, storage, legacy repair,
+ or decommissioned location states; and
+- counts in `meta`.
+
+The implemented additive response also supplies site and room UUIDs, Asset ID,
+lifecycle, authoritative active repair context, generated-at metadata, typed
+contracts, and optional server-side site and room filters. Its read path follows
+Router -> Service -> Repository -> PostgreSQL. Users with `locations.view` but
+without `miners.view` receive physical layout and occupancy with miner identity,
+telemetry, repair, and virtual-miner details removed.
+
+Tags and environment temperature are not included because v1 does not require
+them. Rack and shelf remain labels. The frontend renders legacy positions without
+`room_id` in a separate **Unscoped legacy positions** section; it does not attach
+their denormalized room labels to a selected site or room.
+
+### `GET /api/sites`
+
+Permission: `locations.view`.
+
+Returns site UUID, name, description, and timestamps. It supplies the site
+selector. It does not include room or map counts.
+
+### `GET /api/rooms`
+
+Permission: `locations.view`.
+
+Returns room UUID, site UUID/name, room name/description, sort order, and
+timestamps. It supplies site-scoped room selection.
+
+### `GET /api/location/rooms-order`
+
+Permission: `locations.view`.
+
+Returns room UUID/name/order and position, miner-slot, switch, blocked, and
+assigned-miner counts. Its location join is name-based and not site-scoped, so
+it cannot be the sole source for a correct multi-site selector.
+
+### `GET /api/inventory/miners`
+
+Permission: `miners.view`.
+
+Returns durable miner UUID, Asset ID, current MAC/IP, model, lifecycle, runtime
+status, site/room UUIDs, tags, and timestamps. Search covers Asset ID, MAC, IP,
+model, miner serial, control-board serial, PSU serial, and notes. The collection
+is bounded to 500 records per request and has no physical position or telemetry.
+
+It can validate the missing identity fields during development, but Mine Map
+must not join paginated inventory calls in the browser for every room. The map
+read model should include the small identity and lifecycle subset it needs.
+
+### `GET /api/inventory/miners/{miner_id}`
+
+Permission: `miners.view`.
+
+Adds hostname, miner serial, control-board serial, PSU serial, firmware, notes,
+and first-seen time. One call after selection is acceptable when the inspector
+needs these fields, but it is not used to populate tiles.
+
+### `GET /api/miners/{miner_id}/status-snapshots`
+
+Authentication and `miners.view` are required. Admin is allowed by the existing
+admin override; Manager, Technician, Read Only, and custom roles are authorized
+according to their effective `miners.view` permission. Mine Map does not call
+this endpoint per tile.
+
+Returns up to 100 recent snapshots with hashrate, power, efficiency,
+environment and chip temperatures, fans, uptime, stability, rejection rate,
+performance mode, timestamps, and raw JSON. It is too heavy for every tile and
+must not be called per visible miner. It may support a future selected-miner
+trend, which is outside v1.
+
+### Repair APIs
+
+`GET /api/repairs/{repair_id}` and its event route expose authoritative modular
+repair detail when a repair UUID is already known. There is no current modular
+read endpoint that returns the active repair for each map miner.
+
+`GET /api/repairs/queue` is a bulk legacy queue based on
+`miner_location_states.asset_state = repair` and legacy maintenance events. It
+does not represent the modular `repairs` table and must not be used as the
+authoritative Mine Map repair source.
+
+The additive map read model therefore needs a set-based join to at most one
+active modular repair per miner. It must not perform per-miner repair lookups.
+
+### Dashboard APIs
+
+`GET /api/dashboard/miner-table` exposes latest telemetry in a fleet table.
+The hot, unstable, and stale endpoints expose useful operational lists, but
+they use endpoint-specific or caller-supplied thresholds. Mine Map does not
+need these endpoints because the location map already includes the relevant raw
+latest measurements. V1 must not combine their classifications into a new
+status policy by accident.
+
+## Implemented Map Read Model
+
+The additive `GET /api/location/map` contract provides:
+
+- site UUID and name;
+- room UUID as well as room name;
+- Asset ID;
+- lifecycle status;
+- a nullable active repair object containing repair UUID, status, and reason;
+- response generation time;
+- explicit scope metadata for selected site and room;
+- per-miner latest telemetry observation time; and
+- truthful nulls for unavailable fields.
+
+The query is set-based, parameterized, permission-controlled, and uses four
+fixed database executions independent of fleet size. Optional site and room UUID
+filters are supported by the API; the current frontend performs one bulk request
+and renders the selected room locally. The repository owns SQL, the service owns
+response composition, and the router owns HTTP validation, authorization, and
+the typed response envelope.
+
+V1 does not require a write endpoint. Reading the map, changing layer, applying
+filters, selecting a miner, or opening the inspector must never mutate location,
+lifecycle, repair, or telemetry state.
+
+## Permissions
+
+- The page and location layout require `locations.view`.
+- Supplemental Inventory detail requires `miners.view`.
+- The existing miner detail link is shown only when the user can view miners.
+- The location-management link is shown only with `locations.manage`.
+- No placement or repair command is exposed by Mine Map v1.
+- Frontend permission checks control presentation only; backend dependencies
+ remain authoritative.
+
+All default roles currently receive both `locations.view` and `miners.view`,
+but custom role combinations are possible. The backend implements the boundary:
+`locations.view` alone returns layout and occupancy while redacting Asset ID,
+miner UUID, expected/current MAC, IP, serials, model, runtime/lifecycle/location
+state, telemetry, active repair, and virtual-miner data. This is authoritative
+server-side redaction rather than frontend hiding.
+
+## Architecture Boundary
+
+Mine Map v1 follows:
+
+```text
+Frontend page
+ -> FastAPI router
+ -> Location map service/read model
+ -> Location repository
+ -> PostgreSQL
+```
+
+The frontend contains no SQL, status policy, repair reconstruction, or location
+mutation. The read repository may join location, Inventory-owned projections,
+latest telemetry, and active repair state for this explicit read model. It does
+not write those domains. The service maps persistence results into the typed
+map contract and preserves null and unknown values.
+
+The existing integrated route may be migrated incrementally behind the same
+path. The legacy `/locations` page remains available during rollout. Mine Map
+v1 must not copy its inline HTML/JavaScript or hard-coded classification rules
+into the new frontend shell.
+
+## V1 Implementation Boundary
+
+The smallest useful v1 includes:
+
+- real site and room selection;
+- real racks, shelves, positions, and miners;
+- explicit empty, blocked, switch, conflict, and unknown positions;
+- unplaced and operational-area miners;
+- Operational Status as the default layer;
+- repair and lifecycle treatments based on authoritative data;
+- raw latest telemetry values and freshness;
+- status legend and scoped counts;
+- basic allowlisted filters and search;
+- selected-miner inspector;
+- existing miner-detail navigation;
+- URL-backed context and predictable browser history;
+- loading, empty, denied, error, and partial-data states;
+- desktop, tablet, and phone behavior;
+- keyboard, screen-reader, touch, zoom, and reduced-motion support;
+- permission-aware presentation; and
+- focused route, contract, rendering, permission, accessibility, and context
+ preservation tests.
+
+Location creation, movement, import, rack/shelf editing, and position-type
+changes remain in existing management workflows. V1 may link to those workflows
+but does not reproduce them.
+
+## Deferred Capabilities
+
+The following are explicitly outside v1:
+
+- advanced thermal heat maps;
+- historical map playback;
+- drag-and-drop or click-to-move relocation;
+- map, rack, shelf, or position editing;
+- predictive failure visualization;
+- animated telemetry;
+- power-density and electrical-capacity modelling;
+- saved map views;
+- multi-site geographic maps;
+- multi-selection and bulk commands;
+- repair, lifecycle, or component mutations;
+- alerts and notifications;
+- selected-miner telemetry charts; and
+- generalized infrastructure modelling beyond current position records.
+
+## Remaining Backend And Data Gaps
+
+1. **Freshness policy:** No single approved stale/down threshold exists. V1
+ shows explicit offline state and observation age but does not classify stale.
+2. **Health and thermal policy:** Hashrate, rejection, power, and temperature
+ measurements exist, but model-aware degraded and hot thresholds do not.
+3. **Location vocabularies:** Position and location-state values are not
+ database-constrained. Unknown legacy values require truthful fallback.
+4. **Rack and shelf identity/order:** Rack and shelf are labels, and racks lack
+ an explicit sort field. V1 cannot provide durable rack or shelf links or
+ operator-defined rack ordering.
+
+## Acceptance Criteria
+
+Mine Map v1 is accepted when all of the following are true:
+
+- An authorized user can select a site and room and see every configured
+ position in stored spatial order.
+- Occupied, empty, expected-but-unresolved, blocked, switch, conflict, unknown,
+ and unavailable states are visually and semantically distinct.
+- Filtering does not silently erase physical context.
+- Explicit offline, hash-unstable, spare, retired, and in-repair miners are
+ identifiable without inventing thresholds.
+- Every telemetry value is tied to an observation time or marked unavailable.
+- A user can identify whether apparent problems cluster by room, rack, or shelf
+ from factual tile states and measurements.
+- A selected position identifies its miner or states why no miner is present.
+- The inspector shows the key current identity, location, lifecycle, repair,
+ runtime, and telemetry details available to the user.
+- The full miner detail page can be opened using the durable miner UUID.
+- Closing the inspector or returning from detail restores the exact shareable
+ map context and practical scroll/focus context.
+- Browser Back, Forward, refresh, bookmarks, and shared URLs behave
+ predictably.
+- Permission failures expose no unauthorized counts, previews, search results,
+ or inspector fields.
+- The page remains usable with keyboard, screen reader, browser zoom, touch,
+ narrow viewports, missing telemetry, partial records, and transient refresh
+ failures.
+- The implementation performs no per-tile or per-miner request fan-out.
+- The map is read-only; existing location management remains available.
+
+Until the freshness and health-policy gaps are resolved, the map can answer
+which miners are explicitly offline, hash-unstable, in repair, or associated
+with location conflicts. It cannot truthfully claim that every down, stale,
+hot, or degraded miner has been identified. The interface must state that
+boundary rather than implying complete health classification.
+
+## Unresolved Decisions
+
+The following decisions remain for later map and data work:
+
+1. Which backend-owned policy defines stale, down, hot, and degraded states,
+ including scope, thresholds, collector cadence, and configuration source.
+2. Whether rack ordering may use a documented natural-label sort or requires an
+ additive persisted sort field.
+3. Whether `location_positions.room_id` can be made required after legacy-data
+ preflight, eliminating name-only room association for production map data.
+
+These decisions do not require changing the durable miner identity, existing
+repair workflow, or v1 read-only boundary.
diff --git a/docs/design/NAVIGATION.md b/docs/design/NAVIGATION.md
new file mode 100644
index 0000000..ae5e765
--- /dev/null
+++ b/docs/design/NAVIGATION.md
@@ -0,0 +1,407 @@
+# MinerOps Navigation
+
+## Status
+
+This document is the authoritative baseline for the MinerOps application shell,
+information architecture, navigation model, URL structure, and
+context-preserving behavior. It follows the shared experience principles in
+`docs/design/UX_UI_GUIDE.md`. Focused screen documents may refine individual
+destinations without weakening these rules.
+
+MinerOps uses the product identity:
+
+**MinerOps**
+**Operational Intelligence for Mining**
+
+The current interface is rendered by the FastAPI backend and includes working
+pages and legacy paths. MinerOps has not selected a final frontend framework.
+Routes identified as proposed in this document are information-architecture
+targets, not claims of implemented screens or commitments to a particular UI
+technology.
+
+## Navigation Goals
+
+Navigation should help an operator move from fleet awareness to a specific
+object or safe action without first knowing which backend domain owns the data.
+It must:
+
+- keep current scope and location obvious;
+- expose Mine Map and Repair Center prominently;
+- keep global search available on every authenticated screen;
+- preserve operational context across summary, inspection, and detail views;
+- adapt to permission, role, viewport, and input method;
+- use durable identifiers in object routes; and
+- remain shallow enough for frequent technician workflows.
+
+Primary navigation should expose no more than two visible levels. A common
+workflow should normally take no more than three meaningful interactions from
+the user's current context. Permission checks, validation, and consequential
+confirmations do not count as avoidable navigation depth.
+
+## Information Architecture
+
+The primary application areas are:
+
+1. **Dashboard** - fleet summary, prioritized conditions, and next actions.
+2. **Operations** - active operational workflows.
+ - Repair Center
+ - Scanner
+3. **Mine Map** - a persistent direct entry into spatial operations. Mine Map is
+ conceptually part of Monitoring but must not be buried in its submenu.
+4. **Monitoring** - time-based and comparative operational understanding.
+ - Mine Map
+ - Analytics
+5. **Assets** - durable records and identity.
+ - Miners
+ - Components
+6. **Reports** - historical and operational reporting.
+7. **Administration** - users, roles, and system settings.
+
+On desktop, the direct Mine Map entry should remain visible beside the other
+primary destinations. Monitoring may also reference Mine Map to communicate its
+domain relationship, but that secondary reference must not be the only route to
+the map. Interfaces with limited space should prioritize Dashboard, Mine Map,
+Repair Center, Miners, and Search.
+
+## Application Shell
+
+Every authenticated screen should use a consistent shell containing:
+
+- persistent primary navigation appropriate to the viewport;
+- one global search entry point;
+- an alerts and notifications area;
+- current user, role, and session controls;
+- a page title and breadcrumbs when they clarify hierarchy;
+- page-level actions related to the current scope;
+- an optional contextual side panel; and
+- a stable content region that communicates loading and failure state.
+
+The shell should distinguish global navigation, page navigation, and object
+actions. An action such as beginning a repair must not look like a destination,
+and changing a filter must not look like an authoritative data mutation.
+
+### Page Titles And Breadcrumbs
+
+The page title names the current destination or durable object. A human-readable
+Asset ID, serial, or location label may lead the title, while the durable UUID
+remains available for disambiguation and links.
+
+Breadcrumbs are useful on deep object and location pages, for example:
+
+```text
+Miners / MINER-042 / Repair 8c2... / Timeline
+Mine Map / Site A / Room 3 / Rack 12
+```
+
+Breadcrumbs must not reproduce the entire primary navigation or imply a false
+parent relationship. On mobile, they may collapse to a labeled back action and
+current object.
+
+### Page-Level Actions
+
+Page-level actions belong near the title or current-object context. Show the
+most likely authorized action directly and place less common actions in a
+clearly labeled menu. Actions must follow the permission-aware and explicit
+command rules in `docs/design/UX_UI_GUIDE.md`.
+
+### Contextual Side Panel
+
+A side panel supports lightweight inspection while preserving the underlying
+map, table, graph, queue, filter, and selection. It may show current status,
+identity, location, active repair, alert context, or a small set of safe actions.
+It must not become the only way to perform deep work.
+
+On narrow screens, a side panel becomes a full-screen sheet with an obvious
+close or back control. Closing it restores the prior selection and viewport.
+
+## Proposed Route Model
+
+The table below defines proposed canonical UI routes. "Implemented path" refers
+only to an existing server-rendered page in the current repository. An existing
+API does not by itself make a proposed UI route implemented.
+
+| Area | Proposed canonical route | Current implementation |
+| --- | --- | --- |
+| Dashboard | `/dashboard` | Implemented at `/dashboard` |
+| Repair queue | `/operations/repairs` | Repair Center foundation implemented at `/repairs` |
+| Repair detail | `/operations/repairs/{repair_id}` | Proposed; read APIs exist |
+| Scanner | `/operations/scanner` | Proposed; scanner APIs and operational controls exist |
+| Mine Map | `/monitoring/map` | Location Map foundation implemented at `/locations` |
+| Analytics | `/monitoring/analytics` | Proposed |
+| Miner list | `/assets/miners` | Proposed; inventory APIs exist |
+| Miner detail | `/assets/miners/{miner_id}` | Detail foundation implemented at `/miners/{miner_id}/detail` |
+| Component list | `/assets/components` | Proposed; no list UI route |
+| Component detail | `/assets/components/{component_id}` | Proposed; read API exists |
+| Reports | `/reports` | Proposed |
+| Users | `/administration/users` | Account administration foundation implemented at `/accounts` |
+| Roles | `/administration/roles` | Proposed; role APIs exist |
+| Settings | `/administration/settings` | Proposed |
+
+These canonical routes do not require immediate replacement of working paths.
+When introduced, existing links may remain compatibility routes or redirect to
+the canonical destination through a deliberate migration. Route aliases must
+not produce two independent screen implementations.
+
+Object routes use durable UUIDs for miners, repairs, components, locations, and
+alerts. The UI displays recognizable Asset IDs, serials, names, and labels, but
+must not use mutable human identifiers as authoritative route identity.
+
+### Deep Links
+
+Direct links should be available for:
+
+- miner, repair, component, and location detail;
+- an alert in its relevant object and time context;
+- a selected site, room, rack, shelf, position, or miner on Mine Map; and
+- a chart with its scope, comparison, and time range.
+
+Stable route segments should hold object identity. Query parameters may hold
+shareable view state such as filters, selected tabs, time range, comparison,
+map layer, or map viewport. Sensitive or large transient state must not be
+embedded in a URL.
+
+Examples of future deep-link shapes are:
+
+```text
+/monitoring/map?room_id={room_id}&rack_id={rack_id}&miner_id={miner_id}
+/monitoring/analytics?scope=room&scope_id={room_id}&range=24h
+/assets/miners/{miner_id}?tab=history
+/operations/repairs/{repair_id}?event_id={event_id}
+```
+
+These examples define intent, not a final query vocabulary. Each implemented
+screen must document and test the parameters it accepts.
+
+## Context-Preserving Navigation
+
+Navigation from an operational view must preserve the user's place whenever it
+is safe and practical. Returning from inspection or detail should restore:
+
+- active filters, sorting, and saved column choices;
+- selected objects and multi-selection state;
+- map site, room, layers, position, pan, and zoom;
+- table scroll or pagination position;
+- graph scope, comparisons, and time range; and
+- the originating queue or search query.
+
+Use a side panel for quick inspection and a full detail page for deep work,
+multi-step actions, extensive history, or a directly shareable destination.
+Opening full detail from a map, alert, or queue should retain an explicit return
+context. Browser back and forward controls must remain meaningful.
+
+Where useful, filters and view state should be URL-backed so refresh, bookmarks,
+and shared links restore the same view. Session state may supplement the URL but
+must not make a copied deep link misleading.
+
+Mine Map context deserves particular care. Selecting a miner, rack, or room,
+opening operational context, inspecting an alert, or opening related analytics
+should retain the current site, spatial selection, layers, pan, zoom, and time
+context. A miner detail view should provide a practical return to that map state.
+
+If navigation would discard unsaved form work, warn the user and name what will
+be lost. A failed validation or permission response must preserve entered data
+unless retaining it would expose sensitive information.
+
+## Global Search Navigation
+
+Global search is available from every authenticated screen and does not require
+a category choice before entry. It searches only implemented, authorized
+sources and groups results by type, such as miners, repairs, components,
+locations, users, and relevant events.
+
+Each result should support:
+
+- keyboard focus, movement, selection, and dismissal;
+- enough identity and context to distinguish similar records;
+- a quick preview when lightweight inspection is useful; and
+- an explicit path to full detail.
+
+A quick preview should preserve the current page beneath a side panel or sheet.
+Opening full detail should retain the search query as return context where
+practical. Search cannot expose restricted objects through result text, counts,
+group labels, previews, suggestions, or timing-dependent behavior.
+
+Users do not need to know whether an IP address belongs to Inventory,
+Monitoring, or Discovery before searching for it. No-results behavior must
+distinguish a successful search with no authorized matches from a search
+failure, without confirming that restricted records exist.
+
+## Role-Aware Navigation
+
+Navigation is permission-derived. Roles describe common experiences but are not
+a substitute for checking current permissions.
+
+### Administrator
+
+Administrators receive the operational navigation plus Administration areas
+permitted to them. Administrative destinations should not displace Dashboard,
+Mine Map, Repair Center, or asset lookup.
+
+### Manager
+
+Managers typically need the broad operational, monitoring, asset, and reporting
+structure with the actions their permissions allow. Administration appears only
+for capabilities explicitly granted to the current user.
+
+### Technician
+
+Technician navigation prioritizes Mine Map, Repair Center, Miners, Scanner where
+authorized, and direct lookup. It should minimize detours through reporting or
+configuration areas during floor work.
+
+### Read Only
+
+Read Only navigation remains coherent rather than looking broken or empty. It
+provides authorized Dashboard, Mine Map, repair, asset, history, and reporting
+views while omitting or clearly disabling mutation commands according to
+context.
+
+The backend remains authoritative for authentication and permission
+enforcement. Hiding a link or control is presentation, not security. Navigation
+labels, badges, counts, previews, alerts, and search must not reveal data or
+capabilities the current user cannot access.
+
+When a permission changes during a session, refresh affected navigation and
+screen capabilities. If the current destination is no longer authorized,
+explain that access changed and return the user to the nearest coherent allowed
+destination.
+
+## Mobile And Tablet Navigation
+
+On wide screens, MinerOps may use a persistent navigation rail or sidebar. On
+tablets and phones, use a compact drawer, sheet, or similarly familiar pattern
+that preserves content space and exposes a clear current location.
+
+The compact shell must keep quick access to:
+
+- global search;
+- alerts and notifications;
+- Mine Map;
+- Repair Center;
+- miner lookup; and
+- current user and session controls.
+
+Touch targets must be large enough for work around equipment. Navigation cannot
+depend on hover, precision pointing, or desktop-only keyboard shortcuts. Side
+panels become full-screen sheets where necessary, and closing a sheet restores
+the prior context.
+
+No critical workflow may exist only on desktop. Mobile layouts may reduce data
+density or defer secondary columns, but must preserve identity, status,
+freshness, consequences, validation, and required actions. Navigation and the
+current screen should remain usable during delayed responses or intermittent
+mine Wi-Fi without implying that an unconfirmed command succeeded.
+
+## Operational Bookmarks And Followed Views
+
+The following are future capabilities and are not Internal Alpha requirements:
+
+- saved filters and table views;
+- followed miners, racks, rooms, or queues;
+- operational bookmarks; and
+- personalized start views.
+
+Before implementation, these capabilities need defined ownership, visibility,
+permission, stale-reference, and deletion behavior. They must store durable
+object identities and must not bypass current authorization when restored.
+
+## Internal Alpha Navigation
+
+The minimum Internal Alpha navigation target is:
+
+- Dashboard;
+- Mine Map;
+- Repair Center;
+- Miners;
+- Global Search; and
+- user and session controls.
+
+This is a target subset, not a statement that every destination already has its
+final screen or canonical route. Analytics may initially appear within
+Dashboard, Mine Map, and Miner Detail before a complete standalone Analytics
+area exists. Components and Administration may expose only capabilities that
+are implemented and authorized; their placement must not imply generic
+component management or unfinished administration features.
+
+Scanner, Reports, standalone Analytics, broader Components navigation, and
+additional Administration destinations can enter the shell as their coherent
+screens become available. Adding a label before its destination works creates a
+dead end and is not progressive disclosure.
+
+## Loading And Failure Behavior
+
+Navigation state must remain understandable while screens load or fail.
+
+### Session Expiry
+
+When a session expires, route the user to login rather than rendering an empty
+application shell. Preserve a safe return destination when practical, but never
+retain or expose sensitive form values across authentication boundaries.
+
+### Missing Or Invalid Objects
+
+If an object no longer exists, show a not-found state with its requested object
+type and a route back to the originating collection or context. An invalid deep
+link must not fall through to an unrelated screen or expose parsing details.
+
+### Permission Changes
+
+If access is denied or changes during a session, explain that the destination is
+unavailable and provide the nearest authorized route. Do not disguise a
+permission failure as an empty dataset when the distinction is safe to disclose.
+
+### Backend Or Network Failure
+
+If the backend is unavailable, keep the shell and last safe navigation context
+where possible, mark displayed data as stale, and offer a bounded retry. Do not
+show failed data as empty or current. During network interruption, avoid losing
+the user's selection, filters, unsaved work, or return context.
+
+### Partial Page Data
+
+If only part of a page loads, keep successful sections usable and identify each
+failed section without replacing the entire page with a generic error. Disable
+actions that lack required authoritative context and explain why.
+
+## Accessibility
+
+The application shell must provide:
+
+- keyboard-accessible primary, secondary, breadcrumb, search, panel, and user
+ navigation;
+- visible focus in a logical order;
+- semantic landmarks for navigation, search, main content, and complementary
+ context;
+- a skip link to main content;
+- screen-reader labels for icon-only controls, current location, expanded state,
+ alerts, and result counts;
+- non-hover-only access to every destination and action; and
+- responsive zoom without clipped navigation or lost controls.
+
+Opening a drawer, search overlay, or side panel moves focus into it; closing it
+returns focus to the control that opened it. Dynamic navigation updates should
+be announced without repeatedly interrupting screen-reader users.
+
+## Acceptance Criteria
+
+A compliant MinerOps application shell:
+
+1. keeps global search accessible from every authenticated screen;
+2. makes the current destination, scope, and selected object obvious;
+3. exposes Mine Map and Repair Center prominently;
+4. keeps primary navigation to no more than two visible levels;
+5. preserves filters, selection, spatial position, scroll, and time context when
+ moving between inspection and detail;
+6. supports direct links based on durable object identity;
+7. adapts destinations and actions to permission and screen size;
+8. avoids unauthorized information leakage through navigation or search;
+9. handles loading, partial failure, invalid links, permission changes, and
+ expired sessions explicitly;
+10. provides a coherent Read Only experience;
+11. supports keyboard, touch, assistive technology, and responsive zoom; and
+12. does not discard unsaved work without warning.
+
+## Final Principle
+
+Navigation should preserve the operator's mental and operational context while
+making the next authorized destination or action clear.
diff --git a/docs/design/REPAIR_DOMAIN_MODEL.md b/docs/design/REPAIR_DOMAIN_MODEL.md
new file mode 100644
index 0000000..7394224
--- /dev/null
+++ b/docs/design/REPAIR_DOMAIN_MODEL.md
@@ -0,0 +1,174 @@
+# MinerOps Repair Domain Model
+
+## Purpose
+This design guide describes repair-domain concepts, relationships, lifecycle, invariants, and operational boundaries. Implemented repository behavior and approved task specifications remain authoritative.
+
+## Core Concepts
+
+### Miner
+A durable physical mining asset whose identity is independent of IP address, MAC address, control board, hostname, location, and repair state.
+
+### Repair
+A bounded operational episode for one miner. It has one start, zero or one completion, immutable timeline events, actors, and timestamps.
+
+### Repair Event
+An immutable fact ordered by explicit sequence. Implemented event types are
+`REPAIR_STARTED`, `INITIAL_NOTE`, and `REPAIR_COMPLETED`.
+
+### Network Identity History
+`miner_network_identities` associates MAC, IP, and hostname observations with a
+durable miner and identifies current versus historical rows. It is not a
+separate asset identity.
+
+### Proposed Component History
+Task 004 proposes preserving control-board replacement details without
+redefining the miner. No dedicated immutable component-history table currently
+exists; the legacy generic maintenance-event table is not the modular repair
+timeline.
+
+### Proposed Repair Part Entry
+Task 005 proposes a record of a part installed, removed, or consumed during a
+repair. It is not a warehouse inventory transaction, and no `repair_parts`
+table currently exists.
+
+## Relationships
+Current relationships:
+
+```text
+Miner -> many Repairs -> ordered Repair Events
+Miner -> Network Identity History
+```
+
+Control-board replacement history and repair-part entries are proposed
+extensions, not implemented relationships.
+
+## Lifecycle
+
+Current miner states relevant to repair:
+- `active`
+- `spare`
+- `in_repair`
+- `retired`
+
+Eligible Begin Repair sources:
+- active
+- spare
+
+Eligible Return to Service destinations:
+- active
+- spare
+
+Terminal:
+- retired
+
+The repair schema defines these statuses:
+
+- Active: `OPEN`, `IN_PROGRESS`, `WAITING_FOR_PARTS`, `READY_FOR_TEST`
+- Terminal: `COMPLETED`, `CANCELLED`
+
+The modular API currently creates repairs as `OPEN` and can complete any active
+repair. It does not provide status-transition or cancellation routes. The
+implemented modular workflow is therefore:
+
+```text
+OPEN -> COMPLETED
+```
+
+The other active values remain valid schema states for compatibility and later
+explicit workflows. Cancellation or abandonment requires a separate task.
+
+## Current Workflow
+```text
+Miner active/spare
+ -> Begin Repair
+ -> Miner in_repair
+ -> Timeline events
+ -> Return to Service
+ -> Miner active/spare
+ -> Repair completed
+```
+
+## Invariants
+- replacing a board or MAC must not create a new miner
+- asset-ID and network-identity history must be preserved
+- no more than one active repair per miner is database-enforced
+- modular repair actions require the miner and repair state to agree
+- Return to Service prevents a completed repair from leaving the miner in `in_repair`
+- multi-record transitions are atomic
+- events are append-only and explicitly sequenced
+- actor comes from authentication
+- timestamps are server/database controlled
+- new completions require completion time, actor, and eligible resulting lifecycle
+- legacy unknown values remain unknown rather than fabricated
+
+## Transaction Boundaries
+
+### Begin Repair
+Lock miner, create repair, transition miner to `in_repair`, append start event.
+
+### Return to Service
+Lock miner, lock repair, validate active state, complete repair, transition miner to active/spare, append completion event.
+
+### Proposed Control Board Replacement
+Task 004 proposes locking miner, active repair, and network-identity rows;
+validating MAC ownership; updating current projections; and appending one event.
+
+### Proposed Repair Part Recording
+Task 005 proposes locking the affected repair or repair-part row, adding or
+voiding the entry, and appending the corresponding event atomically.
+
+## Lock Order
+Use:
+```text
+Miner -> Repair -> related rows
+```
+
+This aligns workflows and reduces deadlock risk.
+
+## Commands vs CRUD
+Implemented explicit commands:
+
+- Begin Repair
+- Return to Service
+
+Proposed explicit commands:
+
+- Replace Control Board
+- Record Repair Part
+- Void Repair Part
+
+Avoid generic repair patches for meaningful operations.
+
+## Read Models
+Implemented modular reads:
+
+- repair detail
+- ordered repair timeline
+
+The integrated application also has legacy repair-queue, parts-summary, and
+miner network-identity reads. Those routes are compatibility behavior, not the
+proposed Task 004 or Task 005 read models.
+
+Proposed focused reads include repair parts and control-board replacement
+history. Read models do not weaken write invariants.
+
+## Permissions
+Implemented permissions:
+
+- `repairs.read`
+- `repairs.create`
+- `repairs.complete`
+
+Proposed permissions:
+
+- `repairs.parts.manage`
+- `miners.replace_control_board`
+
+Task 005 reuses `repairs.read` for its proposed read route. Roles receive capabilities;
+endpoints should not hard-code role names when permission checks exist.
+
+## Legacy Data
+Preserve truth, allow nullable historical fields where necessary, enforce stronger rules for new or updated rows, use PostgreSQL `NOT VALID` constraints where appropriate, and never backfill invented actors, states, or events.
+
+## Future Extensions
+Repair cancellation, diagnosis, work orders, technician assignment, burn-in testing, photos, warranty/RMA, component inventory, and donor-unit tracking should each arrive through explicit tasks.
diff --git a/docs/design/UX_UI_GUIDE.md b/docs/design/UX_UI_GUIDE.md
new file mode 100644
index 0000000..29e73c9
--- /dev/null
+++ b/docs/design/UX_UI_GUIDE.md
@@ -0,0 +1,559 @@
+# MinerOps UX/UI Guide
+
+## Status
+
+This document is the authoritative baseline for shared MinerOps user-experience
+principles and application-wide interaction rules. Focused companion documents
+will define detailed information architecture, screen behavior, and visual
+system decisions.
+
+MinerOps uses the product identity:
+
+**MinerOps**
+**Operational Intelligence for Mining**
+
+The current interface is an early-stage, integrated application rendered by the
+FastAPI backend. The repository does not contain a separate frontend project or
+commit to a final frontend framework. Existing pages and controls are working
+foundations, not evidence that every experience described here is implemented.
+Future capabilities are identified as such throughout this guide.
+
+## Product Experience Goal
+
+MinerOps should help a technician or manager understand:
+
+- what is happening;
+- where it is happening;
+- why it may be happening;
+- what happened previously; and
+- what action should happen next.
+
+The interface should connect live observations to durable identity, physical
+location, repair and component history, operational context, and safe actions.
+It should never trade operational truth for visual simplicity.
+
+## Primary Users
+
+### Technician
+
+Technicians need fast identification, floor-friendly navigation, repair context,
+clear next actions, and minimal avoidable data entry. Their common workflows
+must work on phones, tablets, and workstations.
+
+### Manager / Site Operator
+
+Managers and site operators need fleet state, site and room context, trends,
+work queues, risk concentration, and permission-controlled operational actions.
+They often move between summary and detail views.
+
+### Read Only / Auditor
+
+Read Only users and auditors need a coherent view of current state, immutable
+history, actors, timestamps, and outcomes without controls that imply they can
+change authoritative records.
+
+### Administrator
+
+Administrators need account, permission, configuration, deployment-health, and
+audit context. Administrative capability must not obscure the operational views
+they also use.
+
+The same underlying data may be presented at different density or with different
+actions according to role and permission. Presentation differences must not
+change the meaning of the data.
+
+## Core UX Principles
+
+### Technician First
+
+Optimize frequent floor workflows for fast recognition and safe completion.
+Prefer direct lookup, durable identifiers, clear state, and focused commands over
+multi-page data entry.
+
+### Operational Awareness
+
+Show scope, location, urgency, impact, freshness, and intentional operating state
+together. Users must be able to distinguish a fault from planned curtailment,
+repair work, stale data, or an unknown condition.
+
+### Distinct Information Roles
+
+Use each interface according to its strength:
+
+- maps explain location and spatial concentration;
+- graphs explain trends and comparisons;
+- timelines explain history and causality; and
+- tables explain detail and support precise review.
+
+Do not use a visually impressive representation when another format answers the
+operational question more clearly.
+
+### Mine Map Is Primary
+
+The Mine Map is a primary operational interface, not a decorative page. It
+should connect physical layout, current state, work context, and drill-down
+without forcing users to abandon spatial context.
+
+### Search Everything
+
+Durable identities, mutable network observations, serials, repairs, components,
+locations, users, and relevant events should be discoverable through one
+consistent search entry point, subject to authorization.
+
+### Live By Default
+
+Show the freshest trustworthy state by default. Always expose when it was
+observed, its source when useful, and whether it is stale or incomplete.
+
+### Explicit Actions
+
+Use explicit commands for operational changes. Do not hide lifecycle, repair,
+component, location, or fleet actions inside generic edits or implicit UI state.
+
+### Immutable History Remains Visible
+
+Historical facts, actors, timestamps, and corrections remain visible. A current
+projection may summarize history but must not replace it.
+
+### Surface Operational Context
+
+Place relevant location, neighboring assets, current repair, component, alert,
+and recent-history context near the object being inspected. Avoid making users
+reconstruct one situation across unrelated pages.
+
+### Progressive Disclosure
+
+Lead with the current status and likely next decision. Reveal diagnostics,
+history, raw values, and advanced controls as users ask for more detail.
+Progressive disclosure must not conceal critical risk or uncertainty.
+
+### Three-Click Goal
+
+Frequent workflows should normally be reachable and actionable within three
+meaningful interactions from the user's current operational context. This is a
+design goal, not permission to skip confirmation or validation for consequential
+actions.
+
+### Keyboard And Touch Friendly
+
+All core workflows must support keyboard navigation and appropriately sized
+touch targets. Hover-only controls and precision pointer interactions cannot be
+the sole path to an action.
+
+### Accessible By Default
+
+Accessibility is a design constraint from the first implementation, not a later
+polish phase.
+
+### Honest Uncertainty
+
+Unknown, stale, partial, estimated, and unavailable data must look different
+from known healthy or zero values. Do not invent certainty or interpolate across
+unsupported gaps.
+
+### No Silent Mutation
+
+Show the action being requested, the affected object, and the resulting state.
+Never alter authoritative records merely because a view opened, refreshed, or
+changed selection.
+
+### Preserve Operational History
+
+Authoritative history is not physically deleted. Use explicit void, correction,
+retire, or archive workflows. Confirmation language must name the operation and
+its consequences rather than asking only “Are you sure?”
+
+## Application Shell
+
+The application shell should provide:
+
+- persistent primary navigation appropriate to the viewport;
+- one global search entry point;
+- an alerts and notifications area;
+- the current user, role, and session area;
+- a clear page title and current site, location, asset, or workflow context;
+- role-aware primary and secondary actions;
+- an optional contextual side panel; and
+- responsive behavior that preserves critical actions and state.
+
+Desktop layouts may use a persistent navigation rail and side panel. Mobile
+layouts may collapse navigation and use a full-height drawer or sheet, but the
+current object, alerts, and primary action must remain easy to recover.
+
+This guide does not choose final navigation labels or hierarchy. Those decisions
+belong in the future `docs/design/NAVIGATION.md`.
+
+## Global Search
+
+Global search should use one box without requiring a category selection first.
+Search targets include, where implemented and authorized:
+
+- miners and Asset IDs;
+- miner and component serial numbers;
+- MAC and IP addresses;
+- repairs;
+- tracked components;
+- sites, rooms, racks, shelves, and operational locations;
+- technicians and users; and
+- events when event search provides operational value.
+
+Results should be grouped by domain, show enough context to disambiguate similar
+identifiers, and support keyboard navigation and selection. A result preview
+must not reveal restricted data.
+
+No-results states should repeat the interpreted query, distinguish no match from
+search failure, and suggest a narrower or alternate identifier without implying
+the record does not exist outside the user's permissions.
+
+Recent and saved searches are possible future capabilities. They require clear
+retention, privacy, and permission behavior before implementation.
+
+## Mine Map Principles
+
+The Mine Map should show current spatial state and support drill-down through:
+
+```text
+Site -> Room -> Rack -> Shelf or Position -> Miner
+```
+
+It should represent both production and operational spaces, including:
+
+- receiving;
+- diagnostics;
+- repair bench;
+- burn-in;
+- ready to install;
+- donor storage;
+- component storage;
+- quarantine; and
+- recycling or retired storage.
+
+Map interactions should:
+
+- expose status and future telemetry overlays;
+- reveal hotspots and concentrations rather than merely place objects;
+- support filters, single and multi-selection, and clear selection state;
+- open details in a side panel where space permits;
+- preserve pan, zoom, filters, and selection while inspecting details;
+- support site, room, rack, shelf, and miner drill-down; and
+- distinguish offline, in repair, intentionally curtailed, stale, degraded,
+ unknown, and healthy states.
+
+Current location foundations do not imply that this full Mine Map experience is
+complete. Historical map playback is future direction and is not required for
+an initial Mine Map implementation.
+
+## Graph And Analytics Principles
+
+Graphs are first-class operational interfaces, not dashboard decoration. They
+should support site, room, rack, selected group, and individual-miner scope as
+data becomes available.
+
+Relevant measures include:
+
+- hashrate;
+- power usage;
+- efficiency;
+- temperature;
+- availability; and
+- expected versus actual performance.
+
+Graph interactions should provide:
+
+- explicit time-range selection and timezone context;
+- comparison of compatible scopes and units;
+- synchronized operational-event annotations;
+- consistent unit formatting and axis semantics;
+- visible gaps, stale intervals, and missing sources;
+- aggregation and downsampling disclosure; and
+- detail values accessible without relying only on pointer hover.
+
+Never draw misleading interpolation through unknown periods. Clearly label
+estimated, aggregated, or incomplete values. Graphs explain trends, maps explain
+location, timelines explain history, and tables explain detail.
+
+Comparative fleet analytics and synchronized annotations are future capabilities
+unless a focused implementation task establishes them.
+
+## Operational Context
+
+Context panels or inline context may surface:
+
+- neighboring miners with related faults;
+- shared PDU or network issues;
+- nearby temperature anomalies;
+- similar recent repairs;
+- component genealogy;
+- rack-level repair density; and
+- recent correlated outages.
+
+Correlations must identify their scope, time window, evidence, and freshness.
+They may be rule-based and do not require AI. A correlation is supporting
+context, not proof of cause; the interface must not present it as certainty.
+Most correlation experiences remain future direction.
+
+## Tables And Queues
+
+Operational tables should be dense enough for repeated work while remaining
+readable. They should provide:
+
+- a deliberate column hierarchy with identity and status first;
+- explicit sorting and allowlisted filtering;
+- discoverable row actions;
+- predictable single and multi-selection behavior;
+- preserved filters, sort, selection, and scroll when users inspect and return;
+- clear empty and no-results states; and
+- pagination or virtual scrolling for large fleets.
+
+Keep bulk actions limited to operations with clear scope, authorization,
+validation, progress, and failure reporting. Selection must never imply that a
+bulk action is safe.
+
+Saved views are a future capability. Their later design must define ownership,
+sharing, permission changes, and behavior when fields become unavailable.
+
+## Forms And Explicit Commands
+
+Forms and command dialogs should:
+
+- use strong persistent labels rather than placeholder-only identification;
+- group fields by the operator's decision, not database layout;
+- validate structural input inline and preserve entered data after failure;
+- explain domain conflicts without exposing database details;
+- reveal every operational side effect before submission;
+- derive authoritative actor and timestamps from the session and server;
+- show only permission-appropriate actions;
+- explain why a visible control is disabled;
+- use confirmation only for consequential operations; and
+- return focus and context predictably after completion.
+
+Optimistic UI is appropriate only when rollback is safe, obvious, and truthful.
+Do not optimistically show completion for miner control, repair transitions,
+component changes, or other multi-record operational workflows before the server
+confirms success.
+
+## Timelines And History
+
+Timelines show immutable chronological history. Each meaningful event should
+make the following visible when known:
+
+- actor;
+- authoritative timestamp;
+- action;
+- affected object;
+- result; and
+- structured context needed to understand the change.
+
+Use explicit database sequence when the domain defines one. Events may be grouped
+for scanning, but grouping must not reorder, merge, or erase facts. Historical
+events are not edited or deleted. Corrections appear as later correction or void
+events linked to the original.
+
+Link directly to related miners, components, repairs, and locations when those
+records are authorized and available. Timeline filters may reduce the visible
+set, but the interface must indicate that filtered history exists.
+
+## Status And Severity Language
+
+Use stable semantic meanings across screens:
+
+| Meaning | Use |
+| --- | --- |
+| Healthy / active | Operating within expected bounds or available for normal work |
+| Informational | Context that does not currently require intervention |
+| Waiting | Work is intentionally blocked on a dependency or next step |
+| Degraded | Operating outside expected bounds but not at immediate critical severity |
+| Critical | Immediate attention is required to avoid or address significant impact |
+| In repair | Removed from normal service under an active repair workflow |
+| Intentionally offline / curtailed | Not producing by deliberate operational decision |
+| Stale / unknown | Current truth cannot be established from sufficiently fresh data |
+| Retired / archived | No longer part of normal active operations but retained historically |
+
+Domain states and alert severity are separate concepts. For example, `in repair`
+is not automatically critical, and stale data is not healthy merely because no
+fault is observed.
+
+Final colors, typography, icons, patterns, and tokens belong in the future
+`docs/design/DESIGN_SYSTEM.md`. Every status must use text, icons, or patterns in
+addition to color.
+
+## Notifications And Feedback
+
+Feedback should distinguish:
+
+- success confirmation;
+- validation error;
+- domain or concurrency conflict;
+- permission denial;
+- stale-data warning;
+- backend unavailable state;
+- background refresh failure;
+- long-running action progress; and
+- retryable versus non-retryable failure.
+
+Success feedback should name the completed action and affected object. Validation
+feedback stays near the field and is summarized when necessary. Domain conflicts
+should explain what changed and what the user can do next.
+
+Critical errors must not disappear on a timer. Long-running operations need
+progress, cancellation only when supported safely, and a durable final result.
+Retries must not duplicate non-idempotent commands.
+
+## Loading, Empty, Error, And Offline States
+
+Every data surface must distinguish:
+
+- first load;
+- background refresh;
+- partial data;
+- stale cached data;
+- no matching results;
+- an empty new installation;
+- a permission-restricted section;
+- API failure;
+- database unavailable; and
+- network interruption.
+
+First load should establish the shell and page context before progressively
+loading independent data. Background refresh should preserve usable content and
+show a restrained freshness indicator instead of replacing the page with a
+spinner.
+
+Partial or cached data must identify what is missing and when the visible data
+was last known. “No data” must never be used for a failed request. Offline or
+interrupted workflows should preserve unsent user input and avoid claiming that
+an operational command succeeded.
+
+## Responsive And Mobile UX
+
+Technicians may use phones or tablets around the mine. Core workflows should
+provide:
+
+- large, stable touch targets;
+- responsive drawers and side panels;
+- stacked layouts that preserve identity and status;
+- compact tables that prioritize essential columns and allow deliberate detail
+ expansion;
+- mobile-first miner lookup and repair actions;
+- no desktop-only critical workflow; and
+- graceful recovery on unreliable Wi-Fi.
+
+Do not shrink a desktop table until it becomes unreadable. Use prioritized
+columns, row detail, or a task-specific list at narrow widths.
+
+Barcode and QR scanning are future capabilities. They should accelerate durable
+identity lookup without creating a competing identity system.
+
+## Accessibility
+
+All MinerOps interfaces must provide:
+
+- complete keyboard navigation;
+- visible focus indicators;
+- semantic buttons, links, headings, dialogs, and form controls;
+- accessible names and screen-reader labels;
+- sufficient text and non-text contrast;
+- text, icons, or patterns in addition to color;
+- reduced-motion support;
+- accessible table headers, captions, and sort state;
+- chart summaries and keyboard-accessible data where charts carry meaning;
+- programmatic association between errors and fields; and
+- touch targets sized for reliable use in operational environments.
+
+Focus must move intentionally when dialogs open, close, or report errors. Dynamic
+updates should announce important changes without flooding assistive technology.
+
+## Performance And Live Updates
+
+The expected experience includes:
+
+- a fast initial shell;
+- progressive loading of independent data;
+- near-real-time updates where operational value justifies them;
+- no avoidable full-page refreshes;
+- preserved selection, filters, scroll position, and map context;
+- throttling or aggregation of high-frequency telemetry;
+- visible last-updated and freshness indicators; and
+- no layout or notification churn from insignificant changes.
+
+Prioritize identity, status, freshness, and primary actions before secondary
+analytics. Live updates must merge safely with user interaction: they cannot
+clear input, move focus, close a dialog, reorder a selected row unexpectedly, or
+hide an error.
+
+Near-real-time delivery mechanisms are an implementation decision. This guide
+does not require a particular client framework, polling strategy, WebSocket
+architecture, or state library.
+
+## Permission-Aware UX
+
+The UI should hide actions that are irrelevant and unauthorized, or disable them
+when seeing the action and its denial reason helps the user understand the
+workflow. A disabled control must explain what permission or state is missing.
+
+The backend remains the authority for authentication and authorization. UI
+visibility is not a security boundary.
+
+Read Only users must have a coherent inspection experience, including search,
+current state, freshness, and authorized history. Restricted records must not
+leak through search results, counts, previews, links, notifications, map
+aggregates, or error wording.
+
+## Destructive And Irreversible Actions
+
+Do not physically delete authoritative operational history. Use explicit:
+
+- retire;
+- void;
+- archive; or
+- correction workflows.
+
+Before an irreversible or high-impact action, show the affected objects, scope,
+operational consequences, and whether recovery exists. Use typed confirmation
+only for genuinely high-risk destructive actions where the additional friction
+meaningfully prevents mistakes.
+
+Confirmation labels must name the command, such as “Void repair-part entry” or
+“Retire miner,” rather than “Confirm” or “Yes.” Never use an ambiguous dialog to
+mask an action's side effects.
+
+## Design Documentation Structure
+
+This guide governs shared experience principles. Future focused documents may
+define detailed behavior:
+
+- `docs/design/DESIGN_SYSTEM.md`;
+- `docs/design/NAVIGATION.md`;
+- `docs/design/DASHBOARD.md`;
+- `docs/design/MINE_MAP.md`;
+- `docs/design/ANALYTICS.md`;
+- `docs/design/REPAIR_CENTER.md`;
+- `docs/design/MINER_DETAIL.md`;
+- `docs/design/COMPONENT_DETAIL.md`;
+- `docs/design/SEARCH.md`;
+- `docs/design/USER_WORKFLOWS.md`; and
+- `docs/design/OPERATIONAL_CONTEXT.md`.
+
+These paths are planned documentation, not claims that the files or corresponding
+screens exist. Focused documents may add screen-specific requirements but must
+not weaken this baseline without a deliberate documented decision.
+
+## Acceptance Criteria
+
+A compliant MinerOps screen:
+
+1. makes current status and scope clear;
+2. exposes data freshness and uncertainty;
+3. presents role- and permission-aware actions;
+4. preserves context during navigation and inspection;
+5. uses explicit commands for meaningful operational changes;
+6. keeps authoritative history and later corrections visible;
+7. handles loading, empty, partial, error, offline, and permission states;
+8. is keyboard accessible and uses semantic controls;
+9. remains usable at mobile widths and with touch input;
+10. does not imply certainty when data is stale, missing, or incomplete; and
+11. uses maps, graphs, timelines, and tables according to their proper roles.
+
+## Final Principle
+
+The interface should help users move from observation to understanding to action with the least possible friction, while preserving operational truth.
diff --git a/docs/releases/v0.2-repair-workflow.md b/docs/releases/v0.2-repair-workflow.md
new file mode 100644
index 0000000..2f76d6d
--- /dev/null
+++ b/docs/releases/v0.2-repair-workflow.md
@@ -0,0 +1,47 @@
+# MinerOps v0.2: Repair Workflow
+
+## Status
+
+Planning document. The version label and remaining scope are provisional and do
+not override the current version summary in `README.md` or phase ordering in
+`docs/ROADMAP.md`.
+
+## Goal
+
+Deliver a complete, auditable repair lifecycle based on durable miner identity
+and immutable history.
+
+## Completed
+
+- [x] Begin Repair (`POST /api/repairs`)
+- [x] Repair detail and timeline read APIs
+- [x] Return to Service (`POST /api/repairs/{repair_id}/complete`)
+- [x] Control Board Replacement
+
+## Planned
+
+- [ ] Repair Parts Tracking
+
+These planned items refer to explicit workflows in the modular Repairs domain.
+Existing legacy repair, maintenance, location, and parts-summary behavior does
+not make these workflows complete.
+
+## Definition of Done
+
+- all planned tasks are implemented
+- repair actions are atomic
+- history is immutable
+- actor and timestamps are authoritative
+- critical invariants are enforced in PostgreSQL
+- migration lifecycle tests pass
+- repair and inventory regressions pass
+- no generic repair CRUD is introduced
+- operator behavior is documented
+- the release candidate is built from a clean, reproducible repository state
+
+## Remaining Task
+
+1. `005-repair-parts-tracking.md`
+
+This task is specified under `docs/tasks/`, but remains a planned workflow and
+must not be treated as implemented.
diff --git a/docs/releases/v0.3-operations.md b/docs/releases/v0.3-operations.md
new file mode 100644
index 0000000..107f67d
--- /dev/null
+++ b/docs/releases/v0.3-operations.md
@@ -0,0 +1,38 @@
+# MinerOps v0.3: Operations
+
+## Status
+
+Planning document. The version label and candidate scope are provisional and do
+not override the current version summary in `README.md` or phase ordering in
+`docs/ROADMAP.md`.
+
+## Goal
+
+Add safe operational control for individual miners and groups while preserving
+guardrails and auditability.
+
+## Candidate Scope
+
+- [ ] Miner move workflow
+- [ ] Maintenance mode
+- [ ] Bulk tagging and location updates
+- [ ] Safe reboot and shutdown
+- [ ] Scheduled actions
+- [ ] Staggering and cooldown controls
+- [ ] Action history
+- [ ] Partial-failure reporting
+- [ ] Role-based action permissions
+
+This scope spans the roadmap's Operations and Fleet Control phases. Inventory,
+identity, maintenance context, and monitoring dependencies remain governed by
+`docs/ROADMAP.md`.
+
+The repository already contains early-stage legacy movement, command,
+scheduling, and action-status behavior. Unchecked items mean the modular,
+permissioned, fully validated product workflow remains planned; they do not
+claim that no related code exists.
+
+## Definition of Done
+
+Actions are explicit, permissioned, audited, safety-limited, recoverable, and
+tested for authorization, concurrency, retries, and partial failure.
diff --git a/docs/releases/v0.4-monitoring.md b/docs/releases/v0.4-monitoring.md
new file mode 100644
index 0000000..4a07d73
--- /dev/null
+++ b/docs/releases/v0.4-monitoring.md
@@ -0,0 +1,38 @@
+# MinerOps v0.4: Monitoring
+
+## Status
+
+Planning document. The version label and candidate scope are provisional and do
+not override the current version summary in `README.md` or phase ordering in
+`docs/ROADMAP.md`.
+
+## Goal
+
+Provide reliable current-state and historical visibility across the fleet.
+
+## Candidate Scope
+
+- [ ] Configurable discovery
+- [ ] MAC-based asset reconciliation
+- [ ] Miner status and telemetry
+- [ ] Historical retention
+- [ ] Fleet dashboard
+- [ ] Room, rack, model, and electrical summaries
+- [ ] Alert generation and lifecycle
+- [ ] Notification integrations
+
+Discovery and network reconciliation depend on the roadmap's Inventory &
+Identity phase, while electrical summaries depend on Infrastructure. Their
+presence here does not change those dependencies.
+
+The repository already contains early-stage discovery, telemetry, retention,
+dashboard, and infrastructure-summary foundations. Unchecked items describe
+work needed for a coherent, supported monitoring product rather than an absence
+of related implementation.
+
+## Definition of Done
+
+Observations remain distinct from authoritative inventory, stale data is
+explicit, discovery does not blindly create assets, retention is documented,
+alerts are auditable, and monitoring failures cannot corrupt inventory or
+repair history.
diff --git a/docs/releases/v1.0.md b/docs/releases/v1.0.md
new file mode 100644
index 0000000..bd4909c
--- /dev/null
+++ b/docs/releases/v1.0.md
@@ -0,0 +1,34 @@
+# MinerOps v1.0
+
+## Status
+
+Planning document. The version label is provisional. This file describes release criteria, not current
+capabilities or a delivery commitment. It does not override the current version
+summary in `README.md` or phase ordering and completion status in
+`docs/ROADMAP.md`.
+
+## Goal
+
+Deliver a production-ready, self-hosted operating platform for cryptocurrency
+mining operations.
+
+## Release Themes
+
+- durable inventory and identity
+- complete repair workflow
+- safe operational controls
+- monitoring and alerting
+- work orders and maintenance
+- spare parts and component history
+- electrical mapping
+- backup, restore, and migration
+- security, roles, and auditability
+- documented deployment and upgrades
+
+## Production Readiness
+
+v1.0 requires tested backup and restore, safe migrations, health checks,
+reproducible deployment, encrypted credential handling, HTTPS guidance,
+database-backed integrity, documented regression coverage for critical
+workflows, performance testing at representative fleet size, release notes, and
+documented limitations.
diff --git a/docs/tasks/001-begin-repair.md b/docs/tasks/001-begin-repair.md
new file mode 100644
index 0000000..652afa5
--- /dev/null
+++ b/docs/tasks/001-begin-repair.md
@@ -0,0 +1,544 @@
+# Task 001: Begin Repair
+
+## Status
+
+Ready for implementation
+
+## Phase
+
+Phase 3: Operations
+
+## Goal
+
+Implement the first explicit operational workflow in MinerOps: beginning a repair for a miner.
+
+This workflow creates permanent repair history. It is not generic CRUD and must not be implemented as a generic miner update.
+
+## Endpoint
+
+```http
+POST /api/repairs
+```
+
+## Permission
+
+Introduce and require:
+
+```text
+repairs.create
+```
+
+Do not reuse `miners.update`.
+
+Register the permission using the repository's existing permission and role-seeding conventions.
+
+At minimum, administrators must retain access. Do not grant this permission to viewer or auditor roles.
+
+## Request
+
+Use a typed request model equivalent to:
+
+```json
+{
+ "miner_id": "UUID",
+ "reason": "CONTROL_BOARD",
+ "notes": "Miner not booting after power outage."
+}
+```
+
+### Fields
+
+#### `miner_id`
+
+- Required
+- Durable internal miner UUID
+- Must reference an existing miner
+
+#### `reason`
+
+- Required
+- Must use the constrained repair-reason values below
+
+#### `notes`
+
+- Optional
+- Trim surrounding whitespace using existing normalization conventions
+- Treat whitespace-only notes as absent unless established project validation conventions require rejection
+- Store the initial note as a repair timeline event, not as a mutable column on the repair record
+
+Do not accept client-provided values for:
+
+- repair ID
+- repair status
+- opened or started timestamp
+- opened or started actor
+- miner lifecycle
+- event IDs
+- event timestamps
+- event actors
+
+## Repair Reasons
+
+Support exactly these initial values:
+
+- `HARDWARE_FAILURE`
+- `LOW_HASHRATE`
+- `CONTROL_BOARD`
+- `HASHBOARD`
+- `PSU`
+- `FAN`
+- `OVERHEATING`
+- `NETWORKING`
+- `PREVENTIVE_MAINTENANCE`
+- `INSPECTION`
+- `OTHER`
+
+Use the project's existing enum or check-constraint conventions. Do not substitute a free-form reason.
+
+## Repair Statuses
+
+The schema must anticipate:
+
+- `OPEN`
+- `IN_PROGRESS`
+- `WAITING_FOR_PARTS`
+- `READY_FOR_TEST`
+- `COMPLETED`
+- `CANCELLED`
+
+This endpoint creates repairs only with status:
+
+```text
+OPEN
+```
+
+Active repair statuses are:
+
+- `OPEN`
+- `IN_PROGRESS`
+- `WAITING_FOR_PARTS`
+- `READY_FOR_TEST`
+
+## Workflow
+
+Beginning a repair must atomically:
+
+1. Confirm the miner exists.
+2. Lock the miner row.
+3. Confirm the miner is eligible to enter repair.
+4. Confirm the miner does not already have an active repair.
+5. Create the repair record.
+6. Create a `REPAIR_STARTED` timeline event.
+7. Create an `INITIAL_NOTE` timeline event when a non-empty note was supplied.
+8. Change the miner lifecycle to `IN_REPAIR`.
+9. Commit all changes together.
+
+If any step fails, none of the changes may persist.
+
+## Business Rules
+
+### One active repair
+
+A miner may have no more than one active repair.
+
+This rule must be protected by the database against concurrent requests. Do not rely only on a service-level pre-check.
+
+A likely implementation is a PostgreSQL partial unique index on `miner_id` for active statuses, but inspect existing migration and database conventions before choosing the mechanism.
+
+Duplicate active-repair attempts return:
+
+```text
+409 Conflict
+```
+
+### Miner lifecycle
+
+Beginning a repair moves the miner lifecycle to:
+
+```text
+IN_REPAIR
+```
+
+Inspect the current lifecycle values and transition conventions before implementing.
+
+Reject incompatible or terminal lifecycle states with a domain-specific conflict mapped to:
+
+```text
+409 Conflict
+```
+
+If a miner is already `IN_REPAIR` but has no active repair, treat that as inconsistent state. Reject it rather than silently creating ambiguous history.
+
+Do not add another repair-status field to the miner.
+
+### Actor attribution
+
+The authenticated user's UUID is mandatory.
+
+Pass the actor UUID explicitly from the router to the service.
+
+Do not:
+
+- derive the actor from request data
+- permit actorless repair creation
+- allow the client to override the actor
+
+### Timestamps
+
+All timestamps must be generated by the server or database.
+
+Do not accept client timestamps.
+
+Initial events must have deterministic ordering. If timestamps can match, use a stable secondary order consistent with repository conventions.
+
+## Database
+
+Create one Alembic migration after the current head.
+
+Use Alembic only. Do not use runtime table creation or ORM auto-create behaviour.
+
+### `repairs`
+
+Required logical fields:
+
+- `id`: UUID primary key
+- `miner_id`: foreign key to miners
+- `status`
+- `reason`
+- `opened_at` or `started_at`
+- `opened_by` or `started_by`
+- `completed_at`: nullable
+- `completed_by`: nullable
+
+Inspect existing naming conventions and choose either `opened_*` or `started_*`. Use the chosen terminology consistently across schema, models, service, API, and tests.
+
+Add appropriate:
+
+- foreign keys
+- actor references
+- indexes
+- status constraints
+- reason constraints
+- server-generated timestamps
+- database-backed active-repair uniqueness
+
+### `repair_events`
+
+Required logical fields:
+
+- `id`: UUID primary key
+- `repair_id`: foreign key to repairs
+- `event_type`
+- `created_at`
+- `created_by`
+- `payload`
+
+Initial supported event types:
+
+- `REPAIR_STARTED`
+- `INITIAL_NOTE`
+
+#### `REPAIR_STARTED` payload
+
+Preserve relevant starting context, including at least:
+
+- repair reason
+- miner lifecycle transition where practical
+
+#### `INITIAL_NOTE` payload
+
+Preserve the normalized submitted note.
+
+Use JSON only if it fits existing repository conventions or is clearly the best design. Do not store unvalidated, inconsistent payloads merely for convenience.
+
+## Architecture
+
+Preserve:
+
+```text
+Router
+ ↓
+RepairService
+ ↓
+RepairRepository
+ ↓
+PostgreSQL
+```
+
+The service may coordinate inventory persistence through an existing repository abstraction or a clean shared transaction boundary.
+
+Do not bypass repository boundaries merely to simplify the transaction.
+
+### Router responsibilities
+
+- authentication
+- authorization
+- request validation
+- HTTP status mapping
+- response envelope
+
+The router must contain:
+
+- no SQL
+- no database-driver imports
+- no transaction management
+- no business rules
+
+### Service responsibilities
+
+- workflow orchestration
+- business rules
+- transaction coordination
+- domain exception propagation or translation
+- no FastAPI imports
+
+### Repository responsibilities
+
+- persistence
+- row locking
+- parameterized SQL
+- no FastAPI imports
+
+Individual repository operations must not commit in a way that breaks atomic workflow orchestration.
+
+## Response
+
+Return:
+
+```text
+201 Created
+```
+
+Reuse the existing typed response envelope.
+
+The response must contain at least:
+
+- `repair_id`
+- `miner_id`
+- `status`
+- `reason`
+- `opened_at` or `started_at`
+- `opened_by` or `started_by`
+
+Use terminology consistent with the schema.
+
+Do not include the full repair timeline in this response.
+
+## Error Behaviour
+
+Use existing response-envelope and error conventions.
+
+### `401 Unauthorized`
+
+- user is not authenticated
+
+### `403 Forbidden`
+
+- user lacks `repairs.create`
+
+### `404 Not Found`
+
+- miner does not exist
+
+### `409 Conflict`
+
+- miner already has an active repair
+- miner lifecycle is incompatible with beginning repair
+- miner is in an inconsistent repair state
+
+### `422 Unprocessable Entity`
+
+- malformed UUID
+- invalid reason
+- invalid request body
+
+Do not expose raw database errors.
+
+## Out of Scope
+
+Do not implement:
+
+- repair retrieval
+- repair listing
+- repair editing
+- repair deletion
+- repair cancellation
+- repair completion
+- return to service
+- adding later notes
+- diagnostics
+- parts
+- photos
+- attachments
+- uploads
+- testing workflows
+- control-board replacement
+- notifications
+- generic repair PATCH endpoints
+- alert pausing unless an existing clean abstraction makes it unavoidable
+
+Exactly one new API route is permitted in this task.
+
+## Tests
+
+Follow existing test conventions.
+
+### Migration tests
+
+Cover:
+
+- upgrade from previous head
+- downgrade
+- re-upgrade
+- migration at head
+- empty-database migration
+- table constraints
+- foreign keys
+- indexes
+- active-repair uniqueness
+- supported status values
+- supported reason values
+- supported event types
+- nullable completion fields
+- actor requirements
+
+### Repository tests
+
+Cover:
+
+- repair creation
+- `REPAIR_STARTED` event creation
+- optional `INITIAL_NOTE` event creation
+- no note event for absent or normalized-empty notes
+- miner lifecycle transition
+- actor persistence
+- reason persistence
+- timestamp persistence
+- active-repair detection
+- database uniqueness under duplicate active-repair attempts
+- miner isolation
+- rollback when event insertion fails
+- rollback when lifecycle update fails
+- incompatible lifecycle rejection
+- concurrency or row-lock protection where practical
+
+### Service tests
+
+Cover:
+
+- successful Begin Repair
+- successful Begin Repair with an initial note
+- unknown miner
+- active-repair conflict
+- incompatible lifecycle conflict
+- actor required
+- orchestration behaviour
+- domain exception propagation
+- no partial persistence when orchestration fails
+
+### Router tests
+
+Cover:
+
+- authentication required
+- `repairs.create` required
+- malformed miner UUID
+- invalid reason
+- unknown miner
+- active-repair conflict
+- lifecycle conflict
+- successful creation returns `201`
+- response envelope
+- typed response fields
+- actor comes from authenticated principal
+- client cannot override actor
+- client cannot override status or timestamps
+- router contains no SQL
+- router contains no database-driver imports
+
+### Permission tests
+
+Cover:
+
+- `repairs.create` registration
+- administrator access
+- unauthorized roles are not granted access accidentally
+- idempotent permission seeding
+
+## Documentation
+
+After implementation and validation pass:
+
+- Update `docs/ROADMAP.md` to mark Begin Repair complete.
+- Update architecture or decision documentation only when the implementation introduces a genuinely new architectural decision.
+
+If a new ADR is warranted, explain concisely:
+
+- why repair is an operational entity
+- why active-repair uniqueness is database-enforced
+- why lifecycle transition and timeline creation are atomic
+
+Avoid documentation churn for principles already documented.
+
+## Validation
+
+Run:
+
+- Python compilation
+- shell syntax validation
+- migration upgrade from previous head
+- migration downgrade
+- migration re-upgrade
+- migration head verification
+- empty-database migration
+- focused repair suite
+- focused inventory regression suite
+- permission and role tests
+- duplicate active-repair or concurrency tests
+- full disposable development baseline
+- router SQL and database-driver audit
+- service and repository FastAPI import audit
+- route-count audit
+- `git diff --check`
+- `git status --short`
+
+## Final Review Checklist
+
+Confirm:
+
+- exactly one endpoint was added
+- no generic repair CRUD was added
+- active-repair uniqueness is enforced by the database
+- transaction boundaries prevent partial persistence
+- authenticated actor UUID is explicit and mandatory
+- timestamps are server-controlled
+- initial notes are timeline events
+- no unrelated changes are present
+
+## Implementation Report
+
+Report:
+
+- implementation summary
+- files created
+- files modified
+- migration revision and parent revision
+- tables and constraints
+- endpoint
+- permission
+- request model
+- response model
+- repair reasons
+- repair statuses
+- event types
+- lifecycle behaviour
+- actor attribution
+- transaction design
+- concurrency protection
+- tests added
+- validation results
+- git diff summary
+- `git status --short`
+
+Do not commit.
+Do not push.
diff --git a/docs/tasks/002-repair-timeline.md b/docs/tasks/002-repair-timeline.md
new file mode 100644
index 0000000..786596a
--- /dev/null
+++ b/docs/tasks/002-repair-timeline.md
@@ -0,0 +1,183 @@
+# Task 002: Repair Timeline
+
+## Objective
+
+Add read-only APIs for viewing one repair and its immutable timeline.
+
+## Scope
+
+Add exactly these two endpoints:
+
+```http
+GET /api/repairs/{repair_id}
+GET /api/repairs/{repair_id}/events
+```
+
+Do not add any other repair endpoints.
+
+## Permissions
+
+Add:
+
+```text
+repairs.read
+```
+
+Grant it to Admin, Manager, Technician, and Read Only. Existing `repairs.create` access remains unchanged.
+
+## Architecture
+
+Preserve:
+
+```text
+Router -> Service -> Repository -> PostgreSQL
+```
+
+Routers contain no SQL or transaction logic. Services contain no FastAPI dependencies. Repositories contain no FastAPI imports. Reads must not modify data.
+
+## GET /api/repairs/{repair_id}
+
+Return:
+
+```json
+{
+ "repair_id": "uuid",
+ "miner_id": "uuid",
+ "miner_asset_id": "string",
+ "status": "OPEN",
+ "reason": "INSPECTION",
+ "opened_at": "timestamp",
+ "opened_by": {
+ "user_id": "uuid",
+ "display_name": "string"
+ },
+ "closed_at": null,
+ "closed_by": null,
+ "miner_lifecycle": "in_repair",
+ "latest_event_sequence": 2
+}
+```
+
+Requirements:
+
+- Return 404 when the repair does not exist.
+- Return the current miner lifecycle.
+- Resolve actor identity using existing user conventions.
+- Obtain `latest_event_sequence` from authoritative event data.
+- Do not infer missing history or expose internal-only fields.
+
+## GET /api/repairs/{repair_id}/events
+
+Return:
+
+```json
+{
+ "repair_id": "uuid",
+ "events": [
+ {
+ "event_id": "uuid",
+ "sequence": 1,
+ "event_type": "REPAIR_STARTED",
+ "actor": {
+ "user_id": "uuid",
+ "display_name": "string"
+ },
+ "occurred_at": "timestamp",
+ "payload": {
+ "reason": "INSPECTION",
+ "from_lifecycle": "active",
+ "to_lifecycle": "in_repair"
+ }
+ }
+ ]
+}
+```
+
+Requirements:
+
+- Return 404 when the repair does not exist.
+- Return all events ordered strictly by database sequence ascending.
+- Do not order only by timestamp.
+- Preserve payload as structured JSON.
+- Avoid N+1 actor lookups.
+
+## Timeline Invariants
+
+Events are immutable. This task must not add support for editing, deleting, replacing, or reordering timeline events. Corrections must be represented by new events.
+
+## Models and Validation
+
+- Use UUID types for identifiers.
+- Use timezone-aware datetime types.
+- Reuse existing constrained status, reason, lifecycle, and event types where practical.
+- Represent payloads as structured JSON, not serialized strings.
+- Avoid exposing unexpected internal fields.
+
+## Repository Requirements
+
+Add focused read methods for repair detail and ordered repair events. Queries should include miner asset ID, current lifecycle, actor display data, and latest event sequence. Do not add broad list or search methods.
+
+## Service Requirements
+
+The service must validate existence, coordinate repository reads, map missing repairs to the domain-standard not-found exception, and contain no SQL or FastAPI dependencies.
+
+## Router Requirements
+
+Expose exactly the two routes in this specification, require `repairs.read`, follow existing exception patterns, and contain no SQL, commits, or transaction management.
+
+## Migration
+
+Create the next Alembic revision only if required to add `repairs.read` and role assignments. Preserve existing permissions and provide a complete downgrade. Do not alter repair tables unless a material incompatibility is found; report that conflict before deviating.
+
+## Tests
+
+Cover at least:
+
+- 401 unauthenticated
+- 403 unauthorized
+- Read Only can read detail and events
+- Admin, Manager, and Technician can read
+- `repairs.create` behavior remains unchanged
+- existing repair detail
+- missing repair 404
+- asset ID, lifecycle, actor, nullable closed fields, and latest sequence
+- sequence ordering even when timestamps are equal or non-monotonic
+- structured JSON payload
+- actor data
+- explicit empty-timeline behavior
+- Begin Repair regression
+- Inventory regression
+- migration upgrade, downgrade, re-upgrade, and empty database through head
+- exactly two new routes
+- architecture audits
+
+## Validation
+
+Run:
+
+- Python compilation
+- shell syntax validation
+- focused timeline tests
+- existing Begin Repair tests
+- inventory regression tests
+- migration validation
+- disposable development baseline
+- router SQL/driver/transaction audit
+- service/repository FastAPI audit
+- exact route-count audit
+- `git diff --check`
+
+## Documentation
+
+Update `docs/ROADMAP.md` only after implementation and validation pass. Do not add an ADR unless a genuinely new architectural decision is required.
+
+## Explicitly Out of Scope
+
+Do not implement repair listing, search, pagination, adding or editing notes, deleting events, completion, return to service, cancellation, assignment, diagnostics, parts, photos, control-board replacement, generic CRUD, or UI changes.
+
+## Completion Report
+
+Report files created and modified, migration revision if any, routes, permission changes, tests and validation, and final `git status --short`.
+
+Do not commit.
+Do not push.
diff --git a/docs/tasks/003-return-to-service.md b/docs/tasks/003-return-to-service.md
new file mode 100644
index 0000000..445cb3a
--- /dev/null
+++ b/docs/tasks/003-return-to-service.md
@@ -0,0 +1,814 @@
+# Task 003: Return to Service
+
+## Status
+
+Ready for implementation.
+
+## Objective
+
+Complete the MinerOps repair lifecycle by adding an explicit **Return to Service** operation that closes an active repair, records an immutable completion event, and transitions the miner from `in_repair` to an eligible post-repair lifecycle state.
+
+The operation must be atomic, permission-controlled, auditable, safe under concurrent requests, and compatible with the existing Begin Repair and Repair Timeline implementations.
+
+## Product Intent
+
+A repair should not remain open indefinitely after work is complete.
+
+An authorized operator must be able to:
+
+1. identify the active repair,
+2. confirm the miner is ready to leave repair,
+3. select or confirm the resulting lifecycle state,
+4. optionally record a concise completion summary,
+5. close the repair,
+6. transition the miner out of `in_repair`,
+7. preserve immutable operational history.
+
+This is an operational command, not generic repair editing.
+
+## Background
+
+Task 001 introduced the Begin Repair workflow.
+
+Task 002 introduced repair detail and immutable repair timeline read APIs.
+
+The current repair model supports:
+
+- creating an active repair,
+- moving a miner into `in_repair`,
+- preserving the miner's pre-repair lifecycle in the repair-start event payload,
+- enforcing one active repair per miner,
+- reading repair details,
+- reading ordered repair events.
+
+Task 003 completes that lifecycle.
+
+The implementation must inspect the repository and reuse current names, models, exception conventions, migration patterns, event sequencing, and transaction infrastructure rather than inventing parallel abstractions.
+
+## Scope
+
+This task includes:
+
+- a new `repairs.complete` permission,
+- a new explicit Return to Service API,
+- request and response models,
+- service-layer completion workflow,
+- focused repository methods,
+- repair completion persistence,
+- miner lifecycle transition,
+- immutable completion event,
+- concurrency protection,
+- migration and permission assignment,
+- full authorization, domain, migration, routing, and regression tests,
+- release-document update after implementation passes.
+
+## Explicitly Out of Scope
+
+Do not implement:
+
+- generic repair update or patch endpoints,
+- reopening a completed repair,
+- cancelling a repair,
+- abandoning a repair,
+- repair diagnosis fields,
+- repair photos,
+- repair parts consumption,
+- technician assignment,
+- work orders,
+- control board replacement,
+- MAC transfer,
+- automatic monitoring confirmation,
+- automated burn-in or test workflows,
+- bulk Return to Service,
+- notifications,
+- UI changes unless the repository already requires generated API client updates,
+- changes to unrelated legacy repair routes.
+
+Those belong in later tasks.
+
+## User and Operator Workflow
+
+A typical flow is:
+
+1. A technician opens the active repair.
+2. The technician completes physical work and any required validation.
+3. The technician invokes Return to Service.
+4. The technician chooses the miner's destination lifecycle:
+ - the original pre-repair lifecycle, when eligible,
+ - or another explicitly allowed lifecycle.
+5. The technician may enter a completion summary.
+6. MinerOps atomically:
+ - verifies the repair is active,
+ - verifies the miner is still in repair,
+ - validates the destination lifecycle,
+ - closes the repair,
+ - transitions the miner,
+ - appends a completion event.
+7. The repair becomes read-only historical data.
+
+## API
+
+Add one explicit action route.
+
+```http
+POST /api/repairs/{repair_id}/complete
+```
+
+Use `complete` unless existing route naming conventions make `return-to-service` materially more consistent. Do not add both aliases.
+
+### Authentication
+
+Required.
+
+### Permission
+
+```text
+repairs.complete
+```
+
+### Path Parameter
+
+- `repair_id`: UUID
+
+### Request Body
+
+The request should contain:
+
+```json
+{
+ "target_lifecycle": "active",
+ "completion_summary": "Replaced failed fan and completed load test."
+}
+```
+
+Use repository-established lifecycle values and naming.
+
+#### `target_lifecycle`
+
+Required unless the service can unambiguously restore the original lifecycle under existing product rules.
+
+Preferred design: require an explicit target lifecycle in the request so the operator confirms the resulting operational state.
+
+Allowed target values must be restricted to valid non-repair lifecycle states.
+
+At minimum:
+
+- `active`
+- `inactive`
+
+Also allow other existing non-terminal lifecycle states only if they are already legitimate post-repair states in the current domain model.
+
+Never allow:
+
+- `in_repair`
+- unknown values
+- deprecated values
+- terminal or destructive states that are not valid Return to Service destinations
+
+Do not invent new lifecycle states.
+
+#### `completion_summary`
+
+Optional.
+
+Requirements:
+
+- trim leading and trailing whitespace,
+- empty after trimming becomes `null`,
+- enforce a sensible maximum length using current project conventions,
+- preserve text exactly after normalization,
+- do not interpret it as structured diagnosis or parts data.
+
+### Successful Response
+
+Return the completed repair using the existing repair-detail response model where practical.
+
+The response must allow the client to determine:
+
+- repair UUID,
+- miner UUID,
+- completed status,
+- completion timestamp,
+- completing actor where exposed by current conventions,
+- resulting miner lifecycle,
+- timeline or event count only if already part of the established response.
+
+Do not expand the response with unrelated miner details.
+
+### Status Codes
+
+#### `200 OK`
+
+Preferred when returning the completed repair representation.
+
+Use `201` only if existing operational-command conventions clearly require it. Do not use `204` if the existing repair response can be returned cleanly.
+
+#### `401 Unauthorized`
+
+Unauthenticated request.
+
+#### `403 Forbidden`
+
+Authenticated actor lacks `repairs.complete`.
+
+#### `404 Not Found`
+
+The repair UUID does not exist.
+
+Do not reveal authorization-sensitive information beyond existing project behavior.
+
+#### `409 Conflict`
+
+Use for valid requests that conflict with current domain state, including:
+
+- repair is already completed,
+- repair is otherwise not active,
+- miner is no longer in `in_repair`,
+- repair does not match the miner's active repair,
+- target lifecycle is known but not eligible for Return to Service,
+- a concurrent request completed the repair first.
+
+#### `422 Unprocessable Entity`
+
+Use for malformed UUIDs, invalid JSON, missing required fields, unknown lifecycle values, excessive summary length, or other request-validation errors.
+
+## Permissions
+
+Add:
+
+```text
+repairs.complete
+```
+
+### Intended Role Assignment
+
+Follow current role conventions and migrations.
+
+Expected behavior:
+
+- Administrator: allowed
+- Technician: allowed
+- Read Only / Viewer: denied
+- Auditor: denied unless current policy explicitly grants operational actions
+- Unauthenticated: denied
+
+Do not grant the permission to every role by default.
+
+Do not overload `repairs.create`.
+
+## Domain Rules
+
+### Active Repair Requirement
+
+Only an active repair can be completed.
+
+A completed repair cannot be completed again.
+
+### Miner Lifecycle Requirement
+
+The associated miner must currently be in `in_repair`.
+
+If it is not, return a domain conflict and do not modify anything.
+
+### Destination Lifecycle
+
+The destination must:
+
+- exist in the current lifecycle domain,
+- not equal `in_repair`,
+- be explicitly allowed as a post-repair state,
+- be applied only after all validation succeeds.
+
+### Original Lifecycle
+
+The Begin Repair workflow preserves the previous lifecycle in the `REPAIR_STARTED` event payload.
+
+The service may use this value for:
+
+- validation,
+- response context,
+- defaulting only if the API contract and existing model conventions support it safely.
+
+The implementation must not assume every miner should return to `active`.
+
+If restoration of the previous lifecycle is supported, preserve exact historical meaning. Do not infer or fabricate it when missing.
+
+### Completion Is Final
+
+Once completed:
+
+- the repair is historical,
+- the completion event is immutable,
+- the active-repair constraint is released,
+- a later repair must be a new repair record.
+
+### No Silent Repair Mutation
+
+Do not edit prior events.
+
+Do not replace the start event.
+
+Do not overwrite historical lifecycle values.
+
+## Architecture
+
+Preserve:
+
+```text
+Router -> Service -> Repository -> PostgreSQL
+```
+
+### Router Responsibilities
+
+The router should:
+
+- define the action route,
+- validate the UUID and request model,
+- enforce authentication and `repairs.complete`,
+- call the service,
+- map established domain exceptions to HTTP responses,
+- return the established response model.
+
+The router must not:
+
+- contain SQL,
+- manage commits or rollbacks,
+- choose lifecycle transitions,
+- construct event sequence values,
+- read actor UUIDs from the request body.
+
+### Service Responsibilities
+
+The service should:
+
+- load and lock the repair,
+- verify it exists and is active,
+- load and lock the associated miner when required,
+- verify the miner is in repair,
+- validate target lifecycle,
+- normalize summary,
+- close the repair,
+- update miner lifecycle,
+- append the completion event,
+- return the completed domain result,
+- ensure the operation is atomic.
+
+### Repository Responsibilities
+
+Repositories should provide focused methods for:
+
+- retrieving an active repair for update,
+- retrieving the associated miner for update,
+- marking a repair completed,
+- updating lifecycle,
+- obtaining or allocating the next repair event sequence,
+- appending a repair event,
+- reading the completed repair representation efficiently.
+
+Do not introduce a broad generic repair-update method.
+
+## Database and Migration
+
+A migration is required if the current schema does not already support all required completion data and permission assignment.
+
+Inspect the existing repair table before adding columns.
+
+### Preferred Repair Completion Fields
+
+If not already present, add only the fields needed to represent completion authoritatively:
+
+- `completed_at`: timezone-aware timestamp, nullable while active
+- `completed_by_user_id`: UUID foreign key, nullable while active
+- `completion_summary`: nullable text
+- `resulting_lifecycle`: constrained lifecycle value, nullable while active
+
+Use existing naming conventions if equivalent columns already exist.
+
+### Status Representation
+
+Do not add redundant mutable status data if active versus completed can be derived safely from established fields.
+
+If the current repair schema already uses a status column, reuse and constrain it rather than introducing a second status mechanism.
+
+### Constraints
+
+Enforce critical invariants in PostgreSQL where practical.
+
+Examples:
+
+- active repair has no completion timestamp,
+- completed repair has a completion timestamp,
+- completed repair has a completing actor,
+- resulting lifecycle is null while active and present when completed,
+- resulting lifecycle cannot equal `in_repair`,
+- completion summary length is constrained if the project uses database length checks,
+- one active repair per miner remains enforced.
+
+Adapt the exact checks to the existing schema.
+
+### Foreign Keys
+
+`completed_by_user_id` should reference the established user table.
+
+Use the project's existing historical-record deletion policy. Operational history should not silently disappear.
+
+### Indexes
+
+Add only indexes justified by actual read patterns.
+
+Likely useful:
+
+- existing active-repair partial uniqueness,
+- lookup by miner,
+- lookup by completion timestamp only if current repair queue/history queries need it.
+
+Avoid speculative indexes.
+
+### Migration Data Handling
+
+Existing active repairs must remain active.
+
+Existing completed repairs, if legacy data exists, must be handled without inventing actor IDs, summaries, or lifecycle states.
+
+If new non-null completion invariants cannot be truthfully applied to legacy completed rows:
+
+- use nullable historical fields with conditional checks,
+- preserve legacy data,
+- document the limitation,
+- do not fabricate values.
+
+### Downgrade
+
+The downgrade must restore the previous schema safely.
+
+Before dropping completion columns or constraints, restore miner lifecycle only if the downgrade actually reverses newly created completion data and there is a truthful historical source.
+
+Do not map every historical completed repair to `active`.
+
+Do not invent original lifecycle values.
+
+If a fully safe downgrade is impossible once real completion records exist, use the project's established explicit-refusal pattern rather than destructive guessing.
+
+## Events and History
+
+Append one immutable completion event.
+
+Use the existing repair event naming conventions. Preferred semantic name:
+
+```text
+REPAIR_COMPLETED
+```
+
+Reuse an existing equivalent event type if already defined.
+
+### Event Fields
+
+The event must include:
+
+- event UUID,
+- repair UUID,
+- next explicit sequence number,
+- authenticated actor UUID,
+- server/database timestamp,
+- structured payload.
+
+### Payload
+
+Include only durable, meaningful values.
+
+Recommended payload:
+
+```json
+{
+ "previous_miner_lifecycle": "in_repair",
+ "resulting_miner_lifecycle": "active",
+ "pre_repair_lifecycle": "inactive",
+ "completion_summary": "Replaced failed fan and completed load test."
+}
+```
+
+`pre_repair_lifecycle` may be omitted if the existing event model or service cannot retrieve it reliably without ambiguity.
+
+Do not duplicate arbitrary repair fields in the payload.
+
+Do not store display names as authoritative actor identity.
+
+### Event Ordering
+
+Use the established explicit sequence mechanism.
+
+Do not calculate order from timestamps alone.
+
+Concurrent event insertion must not produce duplicate sequence values.
+
+## Models and Validation
+
+Create or extend focused models.
+
+Suggested names, adapted to current conventions:
+
+- `RepairCompleteRequest`
+- `RepairDetailResponse` or existing equivalent
+- internal completion result model if needed
+
+### Validation Requirements
+
+- UUID path validation,
+- finite lifecycle validation,
+- summary trimming,
+- empty summary normalization,
+- maximum summary length,
+- no client-supplied actor,
+- no client-supplied completion timestamp,
+- no arbitrary event payload supplied by the client.
+
+## Concurrency and Atomicity
+
+The entire operation must use one transaction.
+
+The following must succeed or fail together:
+
+1. repair validation,
+2. miner validation,
+3. repair completion update,
+4. miner lifecycle transition,
+5. completion event insertion.
+
+### Concurrent Completion
+
+Two simultaneous completion requests must not both succeed.
+
+Expected result:
+
+- one request succeeds,
+- the other receives `409 Conflict`,
+- one completion event exists,
+- one final lifecycle transition exists.
+
+Use row locking, compare-and-update semantics, or another database-backed mechanism consistent with the repository.
+
+### Concurrent Miner Changes
+
+A simultaneous lifecycle-changing operation must not leave:
+
+- a completed repair with miner still in `in_repair`,
+- an active repair with miner already returned,
+- conflicting lifecycle history.
+
+Lock ordering must be consistent with Begin Repair and other miner workflows to reduce deadlock risk.
+
+Document the chosen lock order in code comments only where it is not obvious.
+
+## Error and Failure Behaviour
+
+On any failure after the transaction starts:
+
+- no partial completion fields persist,
+- miner lifecycle remains unchanged,
+- no completion event persists,
+- repair remains active.
+
+Unexpected database integrity failures should be translated using established service/repository exception conventions.
+
+Do not catch broad exceptions merely to convert them into `409`.
+
+## Tests
+
+Add focused tests following current test organization.
+
+### Authorization Tests
+
+Verify:
+
+- unauthenticated request is denied,
+- user without `repairs.complete` is denied,
+- Read Only / Viewer is denied,
+- Technician is allowed,
+- Administrator is allowed,
+- existing `repairs.read` behavior is unchanged,
+- existing `repairs.create` behavior is unchanged.
+
+### Happy Path Tests
+
+Verify:
+
+- active repair completes successfully,
+- miner transitions from `in_repair` to requested eligible lifecycle,
+- repair is marked completed,
+- completion timestamp is server-controlled,
+- completing actor is authenticated user,
+- optional summary is persisted,
+- blank summary normalizes to null,
+- completion event is appended,
+- event sequence follows existing events,
+- response represents the completed repair.
+
+Test at least:
+
+- return to `active`,
+- return to `inactive`,
+- restoration to a valid original lifecycle when relevant.
+
+### Domain Conflict Tests
+
+Verify `409` for:
+
+- already completed repair,
+- inactive/non-active repair,
+- miner not in `in_repair`,
+- mismatched active repair state,
+- disallowed destination lifecycle,
+- duplicate concurrent completion.
+
+### Validation Tests
+
+Verify `422` for:
+
+- malformed repair UUID,
+- missing required lifecycle,
+- unknown lifecycle,
+- `in_repair` as destination,
+- summary over maximum length,
+- invalid request shape.
+
+If lifecycle defaulting is intentionally supported, replace the missing-field expectation with tests for documented default behavior.
+
+### Missing Resource Test
+
+Verify `404` for unknown repair UUID.
+
+### Transaction Rollback Tests
+
+Force failures at meaningful steps, such as:
+
+- miner update failure,
+- repair completion update failure,
+- event insertion failure.
+
+Verify no partial state persists.
+
+### Event Tests
+
+Verify:
+
+- exactly one completion event,
+- immutable event behavior remains,
+- actor is correct,
+- payload is structured,
+- ordering is explicit,
+- prior events remain unchanged.
+
+### Database Constraint Tests
+
+Verify applicable constraints directly, including:
+
+- completed record requirements,
+- active/completed consistency,
+- resulting lifecycle restriction,
+- one-active-repair rule remains valid.
+
+### Migration Tests
+
+Verify:
+
+- previous head upgrades,
+- new permission exists,
+- intended roles receive the permission,
+- unintended roles do not,
+- schema constraints exist,
+- downgrade works safely or refuses explicitly according to project convention,
+- re-upgrade works,
+- empty database upgrades through head.
+
+### Routing Tests
+
+Verify:
+
+- static repair routes such as `/api/repairs/queue` and `/api/repairs/parts-summary` still resolve,
+- `/api/repairs/{repair_id}/complete` resolves correctly,
+- UUID detail and event routes remain unchanged,
+- malformed static-looking paths are not accidentally consumed by UUID routes,
+- expected route count increases only by the intended amount.
+
+### Regression Tests
+
+Run at minimum:
+
+- Begin Repair tests,
+- Repair Timeline tests,
+- inventory lifecycle tests,
+- permission tests,
+- migration lifecycle suite,
+- relevant legacy repair tests,
+- complete project suite if practical.
+
+## Documentation Updates
+
+After implementation and validation succeed:
+
+- mark Return to Service complete in `docs/releases/v0.2-repair-workflow.md`,
+- update `docs/ROADMAP.md` only if the repository uses it as current implementation status,
+- update API documentation if maintained separately.
+
+Do not mark Control Board Replacement or Repair Parts Tracking complete.
+
+Do not perform broad stale-document cleanup in this task unless a directly changed statement would become materially false.
+
+## Acceptance Criteria
+
+The task is complete when all of the following are true:
+
+1. `POST /api/repairs/{repair_id}/complete` exists.
+2. Authentication and `repairs.complete` are enforced.
+3. Only an active repair can be completed.
+4. The associated miner must be in `in_repair`.
+5. The target lifecycle is explicit and validated.
+6. Repair completion and miner transition are atomic.
+7. Exactly one immutable completion event is appended.
+8. Concurrent completion cannot succeed twice.
+9. Existing repair static and UUID routes still resolve.
+10. Existing Begin Repair and Repair Timeline behavior remains unchanged.
+11. Migration lifecycle validation passes.
+12. Permission assignments are correct.
+13. Focused and regression tests pass.
+14. `git diff --check` passes.
+15. No unrelated files are changed.
+16. No commit or push is performed.
+
+## Validation Commands
+
+Use repository-native commands where available.
+
+At minimum run:
+
+```bash
+python -m compileall .
+```
+
+Run shell syntax checks for changed shell files, if any.
+
+Run:
+
+- focused Return to Service tests,
+- Begin Repair tests,
+- Repair Timeline tests,
+- inventory lifecycle tests,
+- permission tests,
+- migration tests,
+- route audits,
+- architecture audits,
+- full suite when practical.
+
+Validate migrations through:
+
+- previous head to new head,
+- downgrade,
+- re-upgrade,
+- empty database to head,
+- disposable development baseline.
+
+Run:
+
+```bash
+git diff --check
+git status --short
+```
+
+## Architecture Audit
+
+Confirm:
+
+- no SQL in routers,
+- no database driver imports in routers,
+- no transaction management in routers,
+- no FastAPI imports in services or repositories,
+- no generic repair update repository introduced,
+- actor identity comes from authentication,
+- timestamps are server-controlled,
+- events are append-only,
+- route ordering is safe.
+
+## Completion Report
+
+Return:
+
+- branch and starting commit,
+- files created,
+- files modified,
+- migration revision and parent,
+- route added,
+- permission added and role assignments,
+- domain behavior,
+- concurrency strategy,
+- event type and payload,
+- tests added,
+- focused test results,
+- regression results,
+- migration validation results,
+- architecture audit results,
+- `git diff --check`,
+- known limitations,
+- final `git status --short`.
+
+Do not commit.
+Do not push.
diff --git a/docs/tasks/004-control-board-replacement.md b/docs/tasks/004-control-board-replacement.md
new file mode 100644
index 0000000..01cf4e8
--- /dev/null
+++ b/docs/tasks/004-control-board-replacement.md
@@ -0,0 +1,259 @@
+# Task 004: Control Board Replacement
+
+## Status
+Design specification. Not yet implemented.
+
+## Objective
+Add an explicit, auditable workflow for replacing a miner control board while
+preserving durable asset identity, network-identity history, repair context, and
+immutable operational history.
+
+## Product Intent
+A miner is a durable physical asset. Its control board is replaceable. A
+replacement may change the MAC address and control-board serial, but it must not
+create a new miner or erase prior history.
+
+**Current implementation context:** `miners.id` is the durable asset UUID.
+`miners.current_mac` and `miners.control_board_sn` are current-state fields.
+`miner_network_identities` already stores MAC, IP, hostname, source, seen
+timestamps, and an `is_current` marker for a miner. The legacy
+`miner_maintenance_events` table can contain MAC and component details, but
+those records are editable, deletable, and not part of the immutable,
+repair-scoped timeline. No dedicated control-board component-history table
+exists.
+
+The integrated application also exposes the legacy
+`POST /api/miners/{miner_id}/change-mac` route, and Inventory, discovery, and
+location-import code can write current network fields. These are existing
+compatibility paths, not implementations of Task 004.
+
+## Scope
+- proposed route `POST /api/miners/{miner_id}/replace-control-board`
+- proposed permission `miners.replace_control_board`
+- require an active repair and miner lifecycle `in_repair`
+- normalize and validate the new MAC address
+- reuse and strengthen `miner_network_identities` for MAC history rather than
+ creating a competing MAC-history table
+- update `miners.current_mac` and `miners.control_board_sn` as current-state
+ projections
+- preserve the old and new control-board serials in immutable repair history
+- append one immutable `CONTROL_BOARD_REPLACED` event
+- execute atomically and safely under concurrency
+- add migration, constraints, tests, and documentation
+
+## Out of Scope
+Automatic discovery reconciliation, generic component inventory, stock quantities, purchasing, warranties, firmware flashing, pool changes, remote commands, bulk replacement, automatic miner merge, UI redesign, and replacement of hashboards, fans, or PSUs.
+
+## Permission
+The proposed permission is:
+
+```text
+miners.replace_control_board
+```
+
+Proposed role assignment, following the implemented operational-action policy:
+
+- Admin: allowed
+- Manager: allowed
+- Technician: allowed
+- Read Only: denied
+
+The permission and assignments do not exist yet. They must be introduced by the
+Task 004 migration. The route must check the capability rather than hard-code
+role names.
+
+## Authentication and Response
+Authentication is required. On success, return `200 OK` using a typed response
+envelope. The response must identify the durable miner UUID, resolved active
+repair UUID, current MAC, current control-board serial, and appended event ID and
+sequence without exposing unrelated miner data.
+
+## Request
+Suggested shape:
+
+```json
+{
+ "new_mac_address": "AA:BB:CC:DD:EE:FF",
+ "new_control_board_serial": "CB-2026-001234",
+ "notes": "Replacement board installed and tested."
+}
+```
+
+The new MAC is required. `new_control_board_serial` is a required, nullable
+field so the operator explicitly records a serial or confirms that it is
+unknown. A null or normalized blank value clears the old current-board serial;
+it must never leave the removed board serial as current. Normalize nonblank
+serials and limit them to the Inventory field maximum of 255 characters.
+
+Notes are optional, trim like existing repair notes, treat whitespace-only as
+absent, and use the established 4,000-character limit. The client must not
+supply the old MAC, old serial, repair UUID, actor, timestamp, event sequence,
+or event payload; the service derives those values from authoritative state.
+
+## MAC Rules
+Define and test one canonical display format, such as
+`AA:BB:CC:DD:EE:FF`, while comparing ownership by a normalized 12-hex-digit
+key. Existing data must be checked for normalized conflicts before a uniqueness
+constraint is added.
+
+Reject malformed, broadcast, multicast, all-zero, and currently duplicated MAC
+addresses. A MAC historically associated with another asset must produce a
+conflict requiring a later deliberate transfer or correction workflow rather
+than automatic reassignment. Task 004 must not silently rewrite, merge, or
+delete a conflicting association.
+
+## Durable Identity Rules
+- miner UUID and asset record remain unchanged
+- current MAC changes
+- prior MAC remains associated with the same miner in
+ `miner_network_identities`
+- repair, location, ownership, and asset-ID history remain unchanged
+- no second miner is auto-created
+- no automatic asset merge occurs
+
+## Preconditions
+1. Miner exists.
+2. Miner is `in_repair`.
+3. Miner has exactly one active repair.
+4. New MAC is valid and canonical.
+5. New MAC is not current or historical on another miner.
+6. When a current MAC exists, old and new MAC differ.
+7. Actor and timestamp are authoritative.
+
+## Data Model
+Task 004 must build on the existing structures:
+
+- `miners.current_mac` remains the current MAC projection.
+- `miners.control_board_sn` remains the current control-board serial projection.
+- `miner_network_identities` remains the MAC-history structure. The migration
+ may add only the constraints or indexes required to enforce one current
+ association and prevent cross-miner normalized-MAC ownership conflicts.
+- `repair_events` remains the immutable repair timeline. Extend its constrained
+ event vocabulary and payload validation for `CONTROL_BOARD_REPLACED`.
+
+Do not create a second MAC-history table. Do not treat legacy
+`miner_maintenance_events` as authoritative component history for this
+workflow. A separate component-installation table is outside this task unless
+repository inspection during implementation identifies a concrete query or
+integrity requirement that cannot be met by the current projections, network
+identity history, and immutable repair event. Report that conflict before
+adding such a table.
+
+## Constraints
+- no more than one current network-identity row per miner
+- one miner owner per normalized MAC across current and historical identities
+- valid normalized MAC for rows written by the new workflow
+- current projections agree with the newly current identity after replacement
+- foreign-key behavior preserves operational history
+
+The migration must preflight existing normalized MAC duplicates and fail with
+an actionable message rather than changing existing ownership silently.
+
+## Legacy Data
+Existing current MAC values may be reconciled with
+`miner_network_identities` only when the association is unambiguous. Existing
+identity rows and legacy maintenance events must be preserved. Unknown
+installation actor, time, control-board serial, or source must remain unknown;
+never fabricate a replacement event.
+
+## Event
+Proposed type: `CONTROL_BOARD_REPLACED`.
+
+Suggested payload:
+
+```json
+{
+ "old_mac_address": "CC:0C:15:00:0A:49",
+ "new_mac_address": "CC:0C:15:00:9D:82",
+ "old_control_board_serial": null,
+ "new_control_board_serial": "CB-2026-001234",
+ "notes": "Replacement board installed and tested."
+}
+```
+
+Include only authoritative values.
+
+## Atomicity and Locking
+Use one transaction and the established lock order:
+
+```text
+Miner -> Repair -> network-identity rows
+```
+
+The service resolves and locks the miner first, then its one active repair, then
+the relevant network-identity rows. This matches Tasks 001 and 003.
+
+The operation must atomically validate MAC ownership, mark prior network
+identities non-current, create or activate the new association without
+reassigning another miner's history, update the current miner fields, and append
+exactly one event to the active repair.
+
+Concurrent requests must not assign one MAC to two miners, create two current boards, duplicate event sequence values, or leave partial state.
+
+## Errors
+- `401`: unauthenticated
+- `403`: authenticated user lacks `miners.replace_control_board`
+- `404`: miner missing
+- `409`: miner not in repair, no active repair, same current MAC, MAC conflict,
+ historical ownership conflict, or concurrent replacement
+- `422`: malformed UUID, invalid MAC, missing required fields, excessive text,
+ unknown fields, or other invalid request data
+
+## Architecture
+Preserve `Router -> Service -> Repository -> PostgreSQL`.
+
+No SQL or transaction management in routers. No FastAPI in services or repositories. Do not add generic component CRUD.
+
+## Tests
+Cover:
+- role authorization
+- valid replacement
+- durable asset identity unchanged
+- old MAC remains searchable
+- malformed and prohibited MACs
+- current and historical MAC conflicts
+- duplicate existing normalized-MAC migration preflight
+- missing active repair
+- miner not in repair
+- concurrent replacement
+- rollback at each write step
+- direct database constraints
+- event payload and sequence
+- Tasks 001-003 regressions
+- migration upgrade, downgrade/refusal, re-upgrade, and empty database
+
+## Decisions Required Before Implementation
+Before Task 004 is marked ready for implementation, decide:
+
+1. Whether the legacy `/api/miners/{miner_id}/change-mac` route will delegate to
+ the new service or be deprecated through an explicit compatibility plan. It
+ must not remain a competing writer that bypasses the active-repair workflow.
+2. Whether an operator-recorded replacement MAC counts as a network observation
+ for the existing `first_seen_at` and `last_seen_at` semantics, or whether the
+ schema needs a distinct association timestamp. Do not label an unobserved MAC
+ as discovered accidentally.
+3. How Inventory creation, discovery, and location-import writers will translate
+ normalized-MAC ownership conflicts without exposing raw database errors or
+ auto-merging assets.
+
+These decisions do not permit automatic asset creation, merging, or MAC
+transfer. A separate deliberate transfer workflow remains out of scope.
+
+## Acceptance Criteria
+1. One explicit route exists.
+2. Durable miner identity remains unchanged.
+3. Old and new MACs remain searchable.
+4. Normalized MAC ownership and current-association invariants are
+ database-backed.
+5. Active repair and `in_repair` are required.
+6. Exactly one immutable event is written.
+7. Operation is atomic and concurrency-safe.
+8. Legacy data remains truthful.
+9. Migration and regression suites pass.
+10. No commit or push is performed.
+
+## Completion Report
+Report branch, starting commit, migration, files, route, permission, schema, constraints, normalization, history, transaction strategy, event, tests, validation, limitations, and final `git status --short`.
+
+Do not commit.
+Do not push.
diff --git a/docs/tasks/005-repair-parts-tracking.md b/docs/tasks/005-repair-parts-tracking.md
new file mode 100644
index 0000000..e3b7517
--- /dev/null
+++ b/docs/tasks/005-repair-parts-tracking.md
@@ -0,0 +1,679 @@
+# Task 005: Repair Parts Tracking
+
+## Status
+
+Approved implementation specification. Not yet implemented.
+
+This task introduces a minimal durable component registry only to support
+tracked-component references in repair-part history. It does not implement the
+broader component inventory, lifecycle, installation, location, or warehouse
+model described as future direction in
+`docs/design/COMPONENT_DOMAIN_MODEL.md`.
+
+## Objective
+
+Allow authorized operators to record what was used during an active repair as
+exactly one of:
+
+1. a quantity-based consumable; or
+2. one tracked component identified by a durable component UUID.
+
+Repair-part history is immutable. Corrections use an explicit void action and,
+when necessary, a new replacement entry. Original entries and events are never
+edited or physically deleted.
+
+## Product Intent
+
+A technician should be able to determine which consumables and tracked
+components were used during a repair. Consumable records preserve their
+description, quantity, and unit. Tracked-component records preserve both the
+live component UUID and an immutable display snapshot captured when the entry
+is recorded.
+
+All authoritative actors come from authentication. All authoritative
+timestamps and repair-event sequences are server or database controlled.
+
+## Authoritative Boundaries
+
+Task 004 remains authoritative for:
+
+- control-board replacement;
+- MAC ownership;
+- control-board installation and removal history;
+- current control-board and MAC projections;
+- `CONTROL_BOARD_REPLACED` events; and
+- the `/api/miners/{miner_id}/change-mac` compatibility alias.
+
+Task 005 records repair usage only. It must not install, remove, replace,
+transfer, relocate, or change ownership of a tracked component. In particular,
+recording a control-board component must not:
+
+- replace a control board or transfer its MAC address;
+- modify `miners.control_board_sn` or `miners.current_mac`;
+- modify `miner_network_identities`, `miner_mac_ownerships`, or
+ `miner_control_board_installations`;
+- append `CONTROL_BOARD_REPLACED`; or
+- substitute for Task 004's explicit replacement workflow.
+
+The existing `miner_control_board_installations.id` identifies an installation
+history row, not a durable component. The existing `miner_hashboards.id`
+identifies a miner-slot projection, not a component that retains identity
+through movement. Task 005 must not repurpose either UUID as a component UUID or
+infer component identity from a serial number, MAC address, miner, slot, or
+installation.
+
+## Scope
+
+- a minimal durable component registry;
+- narrow component creation and detail reads;
+- focused repair-part list, record, and void routes;
+- capability-based permissions;
+- mutually exclusive consumable and tracked-component request shapes;
+- fixed-precision quantities and controlled units;
+- immutable tracked-component display snapshots;
+- explicit void and replacement-entry corrections;
+- immutable, explicitly sequenced repair events;
+- atomic transactions and concurrency control;
+- PostgreSQL constraints, foreign keys, and immutability protections; and
+- focused, migration, regression, architecture, and route tests.
+
+## Out Of Scope
+
+- generalized component inventory or generic component CRUD;
+- component update, delete, list, search, merge, or deduplication APIs;
+- component locations, movements, installations, or removals;
+- component lifecycle or condition workflows;
+- component ownership transfer;
+- warehouse inventory and transfers;
+- stock levels, allocation, or automatic stock deduction;
+- purchasing, suppliers, vendors, pricing, invoices, and cost accounting;
+- warranties and RMA workflows;
+- reorder automation;
+- automatic creation of components while recording repair parts;
+- automatic installation, removal, ownership transfer, or location tracking;
+- specialized component tables or attributes;
+- generalized bills of materials;
+- bulk import;
+- photos and work orders; and
+- redesign or migration of Task 004 structures.
+
+## Terminology
+
+**Component:** A minimally registered physical part with an immutable UUID and
+constrained type. Task 005 records identity and basic display metadata only.
+
+**Repair-part entry:** An immutable record associating one consumable use or one
+tracked component with a repair.
+
+**Consumable:** A normalized description, positive quantity, controlled unit,
+and optional notes. It has no component UUID.
+
+**Tracked component:** One existing component referenced by UUID. Its repair-part
+entry includes a display snapshot and has no consumable quantity or unit.
+
+**Display snapshot:** Immutable component type, Asset ID, manufacturer, model,
+and manufacturer serial values copied from the component when the repair-part
+entry is recorded. Nullable source values remain null.
+
+**Void:** A one-way audited correction that preserves the original entry. A
+corrected value is represented by voiding the incorrect entry and recording a
+new entry.
+
+## Minimal Component Registry
+
+### Component Types
+
+The initial controlled component types are:
+
+- `control_board`;
+- `hashboard`;
+- `power_supply`; and
+- `fan`.
+
+Do not accept `other` or arbitrary free-text types. Expanding the vocabulary
+requires a reviewed migration and corresponding model and test updates.
+
+### Component Schema
+
+Create the focused `components` table with:
+
+- `id` UUID primary key with a database default;
+- `component_type` constrained text, required;
+- `asset_id` text, optional;
+- `manufacturer` text, optional;
+- `model` text, optional;
+- `manufacturer_serial` text, optional;
+- `created_at` timezone-aware timestamp, required and server controlled; and
+- `created_by` authenticated user UUID, required.
+
+Rules:
+
+- `id`, `component_type`, `created_at`, and `created_by` are immutable;
+- optional text is trimmed, nonblank when present, and limited to 255
+ characters;
+- Asset IDs are case-insensitively unique among components after trimming;
+- component Asset IDs do not share a database uniqueness namespace with miner
+ Asset IDs in this task;
+- manufacturer serials are not assumed globally unique;
+- `created_by` references the existing user identity with `RESTRICT` deletion
+ behavior;
+- no client may supply `id`, `created_at`, or `created_by`;
+- no component is created implicitly from a repair-part request; and
+- Task 005 provides no component metadata update or deletion workflow.
+
+The UUID is the authoritative component identity. Asset ID and manufacturer
+metadata do not replace it. A later explicit task may evolve mutable display
+metadata while preserving the UUID and history.
+
+## API
+
+### Component Routes
+
+Task 005 adds exactly these narrow component routes:
+
+```http
+POST /api/components
+GET /api/components/{component_id}
+```
+
+`POST /api/components` creates one component and returns `201 Created`.
+`GET /api/components/{component_id}` returns one component and `200 OK`.
+Unknown component UUIDs return `404`. Malformed UUIDs use the existing `422`
+validation response.
+
+Do not add component list, search, update, patch, delete, install, remove, move,
+or lifecycle routes.
+
+### Repair-Part Routes
+
+Task 005 adds exactly these repair-part routes:
+
+```http
+GET /api/repairs/{repair_id}/parts
+POST /api/repairs/{repair_id}/parts
+POST /api/repairs/{repair_id}/parts/{repair_part_id}/void
+```
+
+The list returns `200 OK`. Recording returns `201 Created`. A successful void
+returns `200 OK` with the voided repair-part representation. No route physically
+deletes or destructively edits a repair-part entry.
+
+Register and test these routes without changing the precedence or behavior of
+legacy static routes, including `/api/repairs/parts-summary`.
+
+### Response Envelopes
+
+Use the existing typed success envelope containing `data`, `meta`, and `error`.
+Component responses expose only the approved component fields. Repair-part
+responses expose the stored consumable fields or stored component snapshot,
+plus component UUID, notes, recording metadata, and void metadata.
+
+A tracked repair-part response must use its immutable snapshot for historical
+display. It may expose `component_id` so a caller can separately request the
+live component. It must not silently substitute current component metadata for
+the snapshot.
+
+### List Ordering
+
+Return repair-part entries in ascending recorded repair-event sequence. The
+explicit database sequence, not timestamps, determines order. Void state is
+shown on the original entry; void events remain separately visible in the
+existing repair timeline.
+
+No pagination or filtering is added because the collection is bounded to one
+repair. A later change requires compatibility review.
+
+## Permissions
+
+Introduce these proposed capabilities:
+
+- `components.read` for component detail reads;
+- `components.create` for component creation; and
+- `repairs.parts.manage` for recording and voiding repair-part entries.
+
+Reuse implemented `repairs.read` for the repair-part list.
+
+Role grants:
+
+| Capability | Admin | Manager | Technician | Read Only |
+| --- | --- | --- | --- | --- |
+| `components.read` | yes | yes | yes | yes |
+| `components.create` | yes | yes | yes | no |
+| `repairs.read` | yes | yes | yes | yes |
+| `repairs.parts.manage` | yes | yes | yes | no |
+
+Routers check capabilities, never role names. Every route requires
+authentication and focused unauthenticated, unauthorized, and authorized-role
+tests.
+
+## Request Models
+
+Reject unknown fields in every request. Actor UUIDs, timestamps, sequences,
+event payloads, snapshots, and persistence identifiers are never accepted from
+clients.
+
+### Create Component
+
+```json
+{
+ "component_type": "hashboard",
+ "asset_id": "HB-0042",
+ "manufacturer": "MicroBT",
+ "model": "M50 Hashboard",
+ "manufacturer_serial": "HB-SN-12345"
+}
+```
+
+Only `component_type` is required. Optional text values are normalized by
+trimming surrounding whitespace and are rejected when blank after trimming.
+Duplicate normalized Asset IDs return `409 Conflict` without leaking a database
+exception.
+
+### Record Consumable
+
+```json
+{
+ "consumable": {
+ "description": "Thermal paste",
+ "quantity": 1,
+ "unit": "gram"
+ },
+ "tracked_component_uuid": null,
+ "notes": "Applied during board service."
+}
+```
+
+### Record Tracked Component
+
+```json
+{
+ "consumable": null,
+ "tracked_component_uuid": "c79f9f36-1e89-4dbc-8a84-9ff3167614be",
+ "notes": "Used during this repair."
+}
+```
+
+Exactly one of `consumable` and `tracked_component_uuid` must be non-null. A
+tracked-component request refers to one existing component and never creates,
+installs, removes, transfers, or mutates it.
+
+### Void Repair Part
+
+```json
+{
+ "reason": "Recorded against the wrong repair."
+}
+```
+
+The reason is required, trimmed, nonblank, and limited to 1000 characters. The
+actor and timestamp come from authentication and the server.
+
+## Consumable Rules
+
+A consumable requires:
+
+- normalized, nonblank `description`, limited to 255 characters;
+- `quantity` represented as `NUMERIC(12,3)` or the repository-equivalent fixed
+ precision;
+- quantity greater than zero;
+- one controlled `unit`; and
+- optional shared notes.
+
+The initial controlled units are:
+
+- `each`;
+- `gram`;
+- `millilitre`;
+- `metre`; and
+- `centimetre`.
+
+Do not accept aliases or arbitrary free-text units. The service and request
+model reject more than three fractional digits or values outside the fixed
+precision range.
+
+When `unit` is `each`, quantity must be a whole number. Measurement units permit
+fractional quantities to three decimal places. Quantity describes repair usage
+only and never changes stock.
+
+## Repair-Part Schema
+
+Create the focused `repair_parts` table with:
+
+- `id` UUID primary key with a database default;
+- `repair_id` UUID, required;
+- `consumable_description` text, nullable;
+- `quantity` `NUMERIC(12,3)`, nullable;
+- `unit` constrained text, nullable;
+- `component_id` UUID, nullable;
+- `component_type_snapshot` constrained text, nullable;
+- `component_asset_id_snapshot` text, nullable;
+- `component_manufacturer_snapshot` text, nullable;
+- `component_model_snapshot` text, nullable;
+- `component_manufacturer_serial_snapshot` text, nullable;
+- `notes` text, nullable;
+- `recorded_by` authenticated user UUID, required;
+- `recorded_at` timezone-aware timestamp, required and server controlled;
+- `recorded_event_id` repair-event UUID, required;
+- `voided_by` authenticated user UUID, nullable;
+- `voided_at` timezone-aware timestamp, nullable;
+- `void_reason` text, nullable;
+- `void_event_id` repair-event UUID, nullable.
+
+Foreign keys use `RESTRICT` deletion behavior for repairs, users, components,
+and events so authoritative history cannot disappear through cascades.
+
+The repository captures tracked-component snapshots from the locked component
+row in the same transaction as the repair-part insert and event. Client values
+cannot populate snapshot columns. Nullable component metadata remains null in
+the snapshot rather than being fabricated.
+
+The original entry shape, consumable values, component UUID and snapshot,
+notes, recording actor, timestamp, and recorded event data are immutable. A
+void adds only the write-once void fields and immutable void event.
+
+## PostgreSQL Invariants
+
+PostgreSQL must enforce the complete XOR shape:
+
+- consumable branch:
+ - `consumable_description`, `quantity`, and `unit` are all present;
+ - `component_id` and every component snapshot field are null; or
+- tracked-component branch:
+ - `component_id` and `component_type_snapshot` are present;
+ - `consumable_description`, `quantity`, and `unit` are all null;
+ - optional snapshot fields match the nullable values captured at record time.
+
+Additional constraints and protections:
+
+- quantity is greater than zero when present;
+- quantity uses at most three fractional digits;
+- `each` requires `quantity = trunc(quantity)`;
+- unit is one of `each`, `gram`, `millilitre`, `metre`, or `centimetre`;
+- component and snapshot types use the approved component-type constraint;
+- descriptions and snapshot text are trimmed and nonblank when present;
+- notes are null or trimmed, nonblank, and limited to 4000 characters;
+- recording actor, timestamp, and event UUID are required;
+- component UUID references an existing component;
+- recorded and void event UUIDs are unique and reference repair events;
+- void actor, timestamp, reason, and event UUID are either all
+ null or all present;
+- void reason is trimmed, nonblank, and limited to 1000 characters;
+- void timestamp is not earlier than recorded timestamp;
+- a repair part cannot be associated with another repair through its event
+ references;
+- original entry fields cannot be updated;
+- void fields transition only once from all-null to all-present; and
+- repair-part and repair-event rows cannot be physically deleted.
+
+Use PostgreSQL constraints, foreign keys, indexes, and narrowly scoped triggers
+where a declarative constraint cannot enforce cross-row immutability or event
+ownership. Service validation provides clear domain errors but does not replace
+database integrity.
+
+## Repair State Rules
+
+- component creation and detail reads do not change repair state;
+- record repair-part entries only while the repair is active;
+- void repair-part entries only while the repair is active;
+- list entries and void state after repair completion;
+- completed and cancelled repairs are read-only for Task 005;
+- no unrestricted editing is allowed in any repair state; and
+- Task 005 never changes repair status or miner lifecycle.
+
+Begin Repair, Repair Timeline, Return to Service, and Task 004 behavior must
+remain unchanged.
+
+## Events
+
+Add these immutable repair event types:
+
+- `REPAIR_PART_RECORDED`; and
+- `REPAIR_PART_VOIDED`.
+
+`REPAIR_PART_RECORDED` contains:
+
+- repair-part UUID;
+- entry kind: `consumable` or `tracked_component`;
+- the stored description, quantity, and unit for a consumable; or
+- the stored component UUID and complete component display snapshot for a
+ tracked component; and
+- optional notes.
+
+It must not claim that a tracked component was installed, removed, replaced,
+transferred, relocated, or changed.
+
+`REPAIR_PART_VOIDED` contains:
+
+- repair-part UUID; and
+- required correction reason.
+
+The event actor, timestamp, and sequence use existing repair-event columns and
+are server controlled. Clients request domain actions, not raw events. Events
+are append-only and ordered only by the repair's explicit database sequence.
+The `repair_parts` table is the queryable record and does not replace Task 004
+history.
+
+## Transactions And Concurrency
+
+### Component Creation
+
+Component creation is one transaction. The database generates its UUID and
+timestamp, the authenticated actor is required, and the normalized Asset ID
+constraint resolves concurrent duplicates. No repair or component event is
+invented for this minimal identity registration.
+
+### Record Repair Part
+
+Resolve the repair's miner UUID, then lock in this order:
+
+```text
+Miner -> Repair -> Component (tracked entries only)
+```
+
+After locking, revalidate the miner and active repair state. For a tracked
+entry, lock and load the component, capture its snapshot, insert the repair-part
+row, allocate the next repair-event sequence, and append exactly one
+`REPAIR_PART_RECORDED` event in one transaction.
+
+For a consumable, perform the same workflow without a component lock. Any
+validation, insert, snapshot, sequence, or event failure rolls back the complete
+action.
+
+### Void Repair Part
+
+Resolve the repair's miner UUID, then lock in this order:
+
+```text
+Miner -> Repair -> Repair Part
+```
+
+Revalidate the active repair and route ownership after locking. Set the
+write-once void fields and append exactly one `REPAIR_PART_VOIDED` event in one
+transaction. Concurrent void requests produce one success and one `409`
+conflict, with exactly one void event.
+
+The Miner lock and Repair lock serialize record and void actions with Return to
+Service and Task 004. If repair completion wins, the later Task 005 action sees
+a terminal repair and fails with `409`. If Task 005 wins, its complete entry and
+event commit before completion proceeds.
+
+## Architecture
+
+Preserve:
+
+```text
+Router
+ -> Service
+ -> Repository
+ -> PostgreSQL
+```
+
+Routers own authentication, permission checks, request validation, HTTP status
+mapping, and response envelopes. Services own normalization, component and
+repair-part rules, workflow coordination, and domain exceptions. Repositories
+own parameterized SQL, locks, snapshots, persistence, and PostgreSQL integrity
+translation.
+
+Routers contain no SQL, database-driver imports, transaction handling, or
+business rules. Services and repositories contain no FastAPI imports.
+
+## Error Behavior
+
+- unauthenticated: `401`;
+- missing permission: `403`;
+- unknown component, repair, or repair part: `404`;
+- malformed UUID, unknown field, invalid type, invalid unit, invalid shape,
+ invalid precision, or invalid text: `422`;
+- duplicate normalized component Asset ID: `409`;
+- inactive repair, already-voided entry, or concurrent state conflict: `409`;
+ and
+- persistence failures: translated without exposing SQL or driver details.
+
+A repair-part UUID that does not belong to the repair in the route is treated as
+not found and returns `404`.
+
+## Migration
+
+Create additive Alembic revision `0014_repair_parts_tracking` with parent
+`0013_control_board_replacement`.
+
+The migration must:
+
+- create `components` with its type, text, actor, timestamp, Asset ID uniqueness,
+ immutability, and deletion protections;
+- create `repair_parts` with XOR, quantity, unit, snapshot, actor, timestamp,
+ event, void, immutability, and deletion protections;
+- add `components.read`, `components.create`, and `repairs.parts.manage` with the
+ approved role assignments;
+- extend the repair-event type constraint with `REPAIR_PART_RECORDED` and
+ `REPAIR_PART_VOIDED`;
+- add only indexes justified by component lookup, normalized Asset ID
+ uniqueness, repair-part listing, foreign keys, and event relationships;
+- preserve all existing data without inventing components, repair parts,
+ actors, timestamps, snapshots, or events; and
+- leave Task 004 tables, constraints, projections, routes, and history intact.
+
+There is no legacy backfill. Generic maintenance events, telemetry, serial
+numbers, miner hashboard projections, and Task 004 installation rows are not
+sufficient evidence for durable component identity or repair-part usage.
+
+Downgrade is permitted only when no component, repair-part, or Task 005 repair
+event rows exist. Otherwise it must refuse with a clear actionable error rather
+than delete authoritative identity or history. An empty safe downgrade removes
+Task 005 constraints, permissions, tables, and event values in dependency order,
+then supports re-upgrade.
+
+Validate previous-head upgrade, preflight behavior, safe downgrade, downgrade
+refusal, re-upgrade, and an empty database from base through head.
+
+## Tests
+
+### Component Registry
+
+- create and detail-read authorization for every role;
+- successful creation with required fields only and with all metadata;
+- UUID, actor, and timestamp authority;
+- all approved component types and rejection of unknown types;
+- whitespace normalization, text limits, and blank optional values;
+- case-insensitive normalized Asset ID uniqueness and concurrent duplicates;
+- nullable manufacturer metadata and nonunique manufacturer serials;
+- immutable identity, type, actor, and timestamp;
+- no implicit component creation from a repair-part request;
+- no list, search, update, delete, lifecycle, location, or installation routes;
+ and
+- missing and malformed component UUID behavior.
+
+### Repair-Part Requests And Reads
+
+- consumable and tracked-component happy paths;
+- controlled units and rejection of aliases or free text;
+- `NUMERIC(12,3)` boundaries and rejection of excess precision;
+- positive fractional measurement quantities;
+- whole-number `each` quantities and rejection of fractional `each`;
+- both entry branches supplied and neither branch supplied;
+- incomplete consumable shapes;
+- unknown, malformed, and client-created component UUIDs;
+- optional notes and blank or overlength text;
+- server-controlled actors, timestamps, sequences, events, and snapshots;
+- stable list ordering by recorded event sequence;
+- response envelopes and historical reads after completion;
+- component metadata changed after recording without changing the stored
+ snapshot or historical display;
+- no N+1 component or actor queries; and
+- route ownership for nested repair-part UUIDs.
+
+### Workflow And Integrity
+
+- add and void authorization for every role;
+- active repair enforcement;
+- explicit void and void-plus-replacement correction;
+- already-voided and cross-repair behavior;
+- concurrent record versus Return to Service;
+- concurrent void requests;
+- rollback on component lookup, snapshot, part insert, sequence, or event
+ failure;
+- direct XOR, type, quantity, unit, text, foreign-key, event, and void
+ constraints;
+- original-field, event, and physical-deletion protections;
+- confirmation that tracked-component recording changes no component, miner,
+ lifecycle, installation, ownership, MAC, location, or condition state;
+- confirmation that a control-board reference cannot bypass or duplicate Task
+ 004's replacement behavior; and
+- lock-order and transaction-boundary tests.
+
+### Regression And Validation
+
+- Begin Repair tests;
+- Repair Timeline tests;
+- Return to Service tests;
+- Task 004 control-board replacement and compatibility-alias tests;
+- Inventory lifecycle and permission regressions;
+- legacy static repair-route precedence tests;
+- migration upgrade, downgrade, refusal, re-upgrade, previous-head, and empty
+ database tests;
+- full backend suite;
+- disposable development baseline;
+- Python compilation and shell syntax checks;
+- router SQL, driver, transaction, and business-rule audit;
+- service and repository FastAPI audit;
+- exact route-count audit; and
+- `git diff --check`.
+
+## Acceptance Criteria
+
+1. Components receive immutable UUIDs and only the minimal approved identity and
+ display metadata.
+2. Component types and consumable units use the approved controlled values.
+3. Every repair-part entry represents exactly one complete consumable or one
+ existing tracked component, never both or neither.
+4. Consumable quantities use fixed precision, remain positive, and enforce
+ whole numbers for `each`.
+5. Tracked entries preserve a live component foreign key and immutable display
+ snapshot.
+6. Historical display does not change when live component metadata later
+ changes.
+7. Recording a tracked component does not install, remove, replace, transfer,
+ relocate, create, or merge a component or miner.
+8. Original entries and events are immutable; corrections use an audited void
+ and optional replacement entry without physical deletion.
+9. Actors, timestamps, snapshots, and event sequences are authoritative and
+ server controlled.
+10. Record and void actions are atomic and concurrency safe.
+11. Task 004 remains authoritative for control-board and MAC behavior.
+12. No generalized inventory, warehouse, purchasing, supplier, stock, location,
+ lifecycle, warranty, RMA, or cost system is introduced.
+13. Existing repair workflows and static route precedence remain unchanged.
+14. Migration lifecycle, focused tests, regressions, architecture audits, and
+ the disposable baseline pass.
+
+## Completion Report
+
+Report files, migration revision and parent, routes and exact route count,
+permissions and role grants, component registry schema, repair-part schema,
+request and response models, constraints, snapshots, event payloads and
+sequencing, transaction and lock strategy, downgrade behavior, tests and counts,
+regression results, architecture and route audits, known limitations, and final
+status.
+
+Do not commit.
+Do not push.
diff --git a/docs/tasks/TEMPLATE.md b/docs/tasks/TEMPLATE.md
new file mode 100644
index 0000000..fd12be9
--- /dev/null
+++ b/docs/tasks/TEMPLATE.md
@@ -0,0 +1,88 @@
+# Task NNN: Feature Name
+
+## Objective
+
+Describe the operational outcome.
+
+## Background
+
+Summarize relevant current behavior and dependencies.
+
+## Scope
+
+List exactly what is included.
+
+## API
+
+Define every route, request, response, permission, and status code.
+
+## Permissions
+
+Define capabilities and role assignments.
+
+## Domain Rules
+
+List invariants and lifecycle rules.
+
+## Architecture
+
+Preserve:
+
+```text
+Router -> Service -> Repository -> PostgreSQL
+```
+
+## Database and Migration
+
+Define schema changes, constraints, indexes, migration order, and downgrade behavior.
+
+## Events and History
+
+Define immutable events, sequence, actor, timestamp, and structured payload.
+
+## Models and Validation
+
+Define UUID, time, enum, normalization, length, and JSON requirements.
+
+## Repository Requirements
+
+Define focused query and locking behavior. Avoid N+1 queries and generic CRUD.
+
+## Service Requirements
+
+Define validation, workflow, transaction boundaries, and domain exceptions.
+
+## Router Requirements
+
+Define dependencies, exception mapping, and route-count constraints.
+
+## Error Behaviour
+
+Define 401, 403, 404, 409, 422, and any additional statuses.
+
+## Concurrency and Atomicity
+
+Describe races and database enforcement.
+
+## Tests
+
+Cover authorization, domain behavior, rollback, constraints, migrations, regressions, routing, and architecture audits.
+
+## Validation
+
+Run compilation, shell validation, focused and regression tests, migration lifecycle tests, disposable baseline, architecture audits, route audit, and `git diff --check`.
+
+## Documentation
+
+List required roadmap, release, API, or operator documentation.
+
+## Explicitly Out of Scope
+
+List adjacent features that must not be added.
+
+## Completion Report
+
+Report files, migration, routes, permissions, tests, validation, limitations, and final `git status --short`.
+
+Do not commit.
+Do not push.
diff --git a/scripts/dev-env-init.sh b/scripts/dev-env-init.sh
new file mode 100755
index 0000000..a1e2cfc
--- /dev/null
+++ b/scripts/dev-env-init.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+env_file="${project_dir}/.env.dev"
+
+if [[ -e "${env_file}" ]]; then
+ echo "Keeping existing ${env_file}"
+ exit 0
+fi
+
+umask 077
+
+generate_secret() {
+ if command -v openssl >/dev/null 2>&1; then
+ openssl rand -hex 32
+ else
+ python3 -c 'import secrets; print(secrets.token_hex(32))'
+ fi
+}
+
+postgres_password="$(generate_secret)"
+jwt_secret="$(generate_secret)"
+agent_secret="$(generate_secret)"
+
+{
+ echo "APP_ENV=development"
+ echo "APP_TIMEZONE=America/Winnipeg"
+ echo "POSTGRES_DB=minerops_dev"
+ echo "POSTGRES_USER=minerops_dev"
+ echo "POSTGRES_PASSWORD=${postgres_password}"
+ echo "POSTGRES_HOST=postgres"
+ echo "POSTGRES_PORT=5432"
+ echo "DATABASE_URL=postgresql://minerops_dev:${postgres_password}@postgres:5432/minerops_dev"
+ echo "REDIS_URL=redis://redis:6379/0"
+ echo "JWT_SECRET_KEY=${jwt_secret}"
+ echo "MINEROPS_AGENT_SHARED_SECRET=${agent_secret}"
+ echo "MINEROPS_DEV_PORT=18000"
+ echo "SCHEDULED_SCAN_ENABLED=false"
+ echo "MINEROPS_HUB_MODE=true"
+ echo "MINEROPS_DISABLE_LOCAL_SCANNER=true"
+ echo "MINEROPS_ENABLE_REAL_REBOOT=false"
+ echo "MINEROPS_ENABLE_REAL_POWER_CONTROL=false"
+} >"${env_file}"
+
+chmod 0600 "${env_file}"
+echo "Created ${env_file} with development-only secrets and safety interlocks."
diff --git a/scripts/seed-development-demo.sh b/scripts/seed-development-demo.sh
new file mode 100755
index 0000000..c0f1e1a
--- /dev/null
+++ b/scripts/seed-development-demo.sh
@@ -0,0 +1,107 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+
+env_file="${MINEROPS_DEMO_ENV_FILE:-.env.dev}"
+project_name="${MINEROPS_DEMO_PROJECT:-minerops-dev}"
+mode="${1:-seed}"
+
+usage() {
+ cat <<'EOF'
+Usage: scripts/seed-development-demo.sh [--reseed]
+
+Seeds the current Docker development database with deterministic synthetic data.
+--reseed reapplies the mutable demo projections without deleting immutable history.
+EOF
+}
+
+case "${mode}" in
+ seed) [[ $# -eq 0 ]] || { usage >&2; exit 2; } ;;
+ --reseed) [[ $# -eq 1 ]] || { usage >&2; exit 2; } ;;
+ -h|--help) usage; exit 0 ;;
+ *) usage >&2; exit 2 ;;
+esac
+
+[[ -f "${env_file}" ]] || { echo "Missing development environment file: ${env_file}" >&2; exit 1; }
+[[ -f compose.dev.yaml ]] || { echo "Missing compose.dev.yaml." >&2; exit 1; }
+[[ -f sql/development-demo.sql ]] || { echo "Missing sql/development-demo.sql." >&2; exit 1; }
+
+env_value() {
+ awk -F= -v wanted="$1" '
+ $1 == wanted {
+ sub(/^[^=]*=/, "")
+ gsub(/\r$/, "")
+ print
+ exit
+ }
+ ' "${env_file}"
+}
+
+app_env="$(env_value APP_ENV)"
+database_name="$(env_value POSTGRES_DB)"
+database_host="$(env_value POSTGRES_HOST)"
+
+if [[ "${app_env}" != "development" ]]; then
+ echo "Refusing demo seed: APP_ENV must be exactly development." >&2
+ exit 1
+fi
+if [[ ! "${database_name}" =~ (^|_)(dev|development|test)(_|$) ]]; then
+ echo "Refusing demo seed: POSTGRES_DB must be clearly named for development or testing." >&2
+ exit 1
+fi
+if [[ "${database_name,,}" =~ (prod|production|live) ]]; then
+ echo "Refusing demo seed: POSTGRES_DB resembles a production database." >&2
+ exit 1
+fi
+if [[ "${database_host}" != "postgres" ]]; then
+ echo "Refusing demo seed: POSTGRES_HOST must be the compose.dev.yaml postgres service." >&2
+ exit 1
+fi
+
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file "${env_file}"
+ -f compose.dev.yaml
+)
+
+"${compose[@]}" config --quiet
+if ! "${compose[@]}" ps --status running --services | grep -qx postgres; then
+ echo "Development PostgreSQL is not running for project ${project_name}." >&2
+ echo "Start it with: docker compose --project-name ${project_name} --env-file ${env_file} -f compose.dev.yaml up -d" >&2
+ exit 1
+fi
+
+migration_head="$(
+ "${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -At -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT version_num FROM alembic_version"'
+)"
+if [[ "${migration_head}" != "0014_repair_parts_tracking" ]]; then
+ echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0014_repair_parts_tracking." >&2
+ exit 1
+fi
+
+if [[ "${mode}" == "--reseed" ]]; then
+ echo "Reapplying deterministic demo projections; immutable repair and MAC history will not be rewritten."
+else
+ echo "Seeding deterministic MinerOps development demo data..."
+fi
+
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -v app_env=development -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
+ < sql/development-demo.sql
+
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -At -F " | " -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
+SELECT
+ 'demo summary',
+ count(*) FILTER (WHERE asset_tag LIKE 'DEMO-MIN-%') || ' miners',
+ (SELECT count(*) FROM location_positions WHERE source_file = 'MinerOps demo seed') || ' positions',
+ (SELECT count(*) FROM location_positions WHERE source_file = 'MinerOps demo seed' AND miner_id IS NULL) || ' empty',
+ (SELECT count(*) FROM repairs WHERE id::text LIKE 'd4000000-0000-4000-8000-%') || ' repairs'
+FROM miners;
+SQL
+
+echo "Demo data is ready at the configured development gateway."
diff --git a/scripts/test-asset-id-history-migration.sh b/scripts/test-asset-id-history-migration.sh
new file mode 100755
index 0000000..7c23e3a
--- /dev/null
+++ b/scripts/test-asset-id-history-migration.sh
@@ -0,0 +1,219 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+
+project_name="${MINEROPS_ASSET_HISTORY_TEST_PROJECT:-minerops-asset-history-$$}"
+empty_database="minerops_asset_history_empty"
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file .env.dev
+ -f compose.dev.yaml
+)
+
+cleanup() {
+ status=$?
+ if (( status != 0 )); then
+ echo
+ echo "Asset ID history migration test failed. Recent PostgreSQL logs:"
+ "${compose[@]}" logs --no-color --tail 120 postgres || true
+ fi
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+
+trap cleanup EXIT
+
+if [[ ! -f .env.dev ]]; then
+ echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
+ exit 1
+fi
+
+run_alembic() {
+ "${compose[@]}" run --rm --no-deps backend alembic "$@"
+}
+
+echo "Starting disposable PostgreSQL..."
+"${compose[@]}" up --detach postgres
+for attempt in $(seq 1 60); do
+ if "${compose[@]}" exec -T postgres sh -ec \
+ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
+ >/dev/null 2>&1; then
+ break
+ fi
+ if (( attempt == 60 )); then
+ echo "PostgreSQL did not become ready." >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+echo "Building migration image..."
+"${compose[@]}" build backend
+
+echo "Upgrading the current head to Asset ID history..."
+run_alembic upgrade 0008_manual_miner_inventory
+run_alembic upgrade head
+
+current_revision="$(run_alembic current 2>/dev/null)"
+[[ "${current_revision}" == *"0009_inventory_asset_id_history"* ]]
+
+echo "Checking history schema, constraints, foreign keys, and indexes..."
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
+ <<'SQL'
+DO $$
+DECLARE
+ v_role_id UUID;
+BEGIN
+ IF to_regclass('public.miner_asset_id_history') IS NULL THEN
+ RAISE EXCEPTION 'Asset ID history table was not created';
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'miner_asset_id_history'
+ AND column_name = 'old_asset_id'
+ AND is_nullable = 'YES'
+ ) OR EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'miner_asset_id_history'
+ AND column_name IN ('miner_id', 'new_asset_id', 'changed_at', 'changed_by')
+ AND is_nullable <> 'NO'
+ ) THEN
+ RAISE EXCEPTION 'Asset ID history nullability is incorrect';
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_indexes
+ WHERE schemaname = 'public'
+ AND tablename = 'miner_asset_id_history'
+ AND indexname = 'idx_miner_asset_id_history_miner_changed_at'
+ AND indexdef LIKE '%(miner_id, changed_at, id)%'
+ ) THEN
+ RAISE EXCEPTION 'Chronological miner history index is missing';
+ END IF;
+
+ SELECT id INTO v_role_id FROM roles WHERE name = 'Admin';
+ INSERT INTO users (
+ id, username, display_name, password_hash, role_id
+ ) VALUES (
+ '00000000-0000-0000-0000-000000000901',
+ 'asset-history-test',
+ 'Asset History Test',
+ 'not-used',
+ v_role_id
+ );
+ INSERT INTO miners (id, asset_tag, status)
+ VALUES (
+ '00000000-0000-0000-0000-000000000902',
+ 'ASSET-OLD',
+ 'manual'
+ );
+ INSERT INTO miner_asset_id_history (
+ miner_id, old_asset_id, new_asset_id, changed_at, changed_by
+ ) VALUES
+ (
+ '00000000-0000-0000-0000-000000000902',
+ 'ASSET-OLD',
+ 'ASSET-NEW',
+ '2026-01-01 00:00:00+00',
+ '00000000-0000-0000-0000-000000000901'
+ ),
+ (
+ '00000000-0000-0000-0000-000000000902',
+ 'ASSET-NEW',
+ 'ASSET-LATEST',
+ '2026-01-02 00:00:00+00',
+ '00000000-0000-0000-0000-000000000901'
+ );
+
+ BEGIN
+ INSERT INTO miner_asset_id_history (
+ miner_id, old_asset_id, new_asset_id, changed_by
+ ) VALUES (
+ '00000000-0000-0000-0000-000000000902',
+ ' ASSET-OLD ',
+ 'ASSET-BAD',
+ '00000000-0000-0000-0000-000000000901'
+ );
+ RAISE EXCEPTION 'Unnormalized old Asset ID was accepted';
+ EXCEPTION WHEN check_violation THEN
+ NULL;
+ END;
+
+ BEGIN
+ INSERT INTO miner_asset_id_history (
+ miner_id, old_asset_id, new_asset_id, changed_by
+ ) VALUES (
+ '00000000-0000-0000-0000-000000000999',
+ NULL,
+ 'ASSET-NEW',
+ '00000000-0000-0000-0000-000000000901'
+ );
+ RAISE EXCEPTION 'Unknown miner history was accepted';
+ EXCEPTION WHEN foreign_key_violation THEN
+ NULL;
+ END;
+
+ BEGIN
+ INSERT INTO miner_asset_id_history (
+ miner_id, old_asset_id, new_asset_id, changed_by
+ ) VALUES (
+ '00000000-0000-0000-0000-000000000902',
+ 'ASSET-OLD',
+ 'ASSET-NEW',
+ NULL
+ );
+ RAISE EXCEPTION 'History without an actor was accepted';
+ EXCEPTION WHEN not_null_violation THEN
+ NULL;
+ END;
+
+ BEGIN
+ DELETE FROM miners
+ WHERE id = '00000000-0000-0000-0000-000000000902';
+ RAISE EXCEPTION 'Miner with permanent Asset ID history was deleted';
+ EXCEPTION WHEN foreign_key_violation THEN
+ NULL;
+ END;
+END
+$$;
+SQL
+
+echo "Downgrading and re-upgrading Asset ID history..."
+run_alembic downgrade 0008_manual_miner_inventory
+table_after_downgrade="$(
+ "${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At' <<'SQL'
+SELECT to_regclass('public.miner_asset_id_history');
+SQL
+)"
+if [[ -n "${table_after_downgrade}" ]]; then
+ echo "Asset ID history table remained after downgrade." >&2
+ exit 1
+fi
+run_alembic upgrade head
+
+echo "Migrating an empty database from base through head..."
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres \
+ -c "CREATE DATABASE minerops_asset_history_empty"' >/dev/null
+"${compose[@]}" run --rm --no-deps \
+ -e "POSTGRES_DB=${empty_database}" \
+ backend alembic upgrade head
+
+empty_revision="$(
+ "${compose[@]}" run --rm --no-deps \
+ -e "POSTGRES_DB=${empty_database}" \
+ backend alembic current 2>/dev/null
+)"
+[[ "${empty_revision}" == *"0009_inventory_asset_id_history"* ]]
+
+echo "Asset ID history migration test passed."
diff --git a/scripts/test-begin-repair-migration.sh b/scripts/test-begin-repair-migration.sh
new file mode 100755
index 0000000..c6f4886
--- /dev/null
+++ b/scripts/test-begin-repair-migration.sh
@@ -0,0 +1,556 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+
+project_name="${MINEROPS_REPAIR_MIGRATION_PROJECT:-minerops-repair-migration-$$}"
+empty_database="minerops_repair_empty"
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file .env.dev
+ -f compose.dev.yaml
+)
+
+cleanup() {
+ status=$?
+ if (( status != 0 )); then
+ echo
+ echo "Begin Repair migration test failed. Recent PostgreSQL logs:"
+ "${compose[@]}" logs --no-color --tail 120 postgres || true
+ fi
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+
+trap cleanup EXIT
+
+if [[ ! -f .env.dev ]]; then
+ echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
+ exit 1
+fi
+
+run_alembic() {
+ "${compose[@]}" run --rm --no-deps backend alembic "$@"
+}
+
+echo "Starting disposable PostgreSQL..."
+"${compose[@]}" up --detach postgres
+for attempt in $(seq 1 60); do
+ if "${compose[@]}" exec -T postgres sh -ec \
+ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
+ >/dev/null 2>&1; then
+ break
+ fi
+ if (( attempt == 60 )); then
+ echo "PostgreSQL did not become ready." >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+echo "Building migration image..."
+"${compose[@]}" build backend
+
+echo "Upgrading the previous head to Begin Repair..."
+run_alembic upgrade 0009_inventory_asset_id_history
+previous_revision="$(run_alembic current 2>/dev/null)"
+[[ "${previous_revision}" == *"0009_inventory_asset_id_history"* ]]
+run_alembic upgrade 0010_begin_repair
+current_revision="$(run_alembic current 2>/dev/null)"
+[[ "${current_revision}" == *"0010_begin_repair"* ]]
+
+echo "Checking repair schema, constraints, permissions, and lifecycle..."
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
+ <<'SQL'
+DO $$
+DECLARE
+ v_actor UUID := '00000000-0000-0000-0000-000000001001';
+ v_miner UUID := '00000000-0000-0000-0000-000000001002';
+ v_other_miner UUID := '00000000-0000-0000-0000-000000001003';
+ v_invalid_miner UUID := '00000000-0000-0000-0000-000000001004';
+ v_repair UUID;
+ v_other_repair UUID;
+ v_role_id UUID;
+ v_count INTEGER;
+BEGIN
+ IF to_regclass('public.repairs') IS NULL
+ OR to_regclass('public.repair_events') IS NULL THEN
+ RAISE EXCEPTION 'Repair workflow tables were not created';
+ END IF;
+
+ IF EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'repairs'
+ AND column_name IN (
+ 'id', 'miner_id', 'status', 'reason', 'opened_at', 'opened_by'
+ )
+ AND is_nullable <> 'NO'
+ ) OR EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'repairs'
+ AND column_name IN ('completed_at', 'completed_by')
+ AND is_nullable <> 'YES'
+ ) THEN
+ RAISE EXCEPTION 'Repair column nullability is incorrect';
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_indexes
+ WHERE schemaname = 'public'
+ AND tablename = 'repairs'
+ AND indexname = 'uq_repairs_one_active_per_miner'
+ AND indexdef LIKE '%UNIQUE%'
+ AND indexdef LIKE '%WHERE (status = ANY%'
+ ) THEN
+ RAISE EXCEPTION 'Active repair partial unique index is missing';
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_indexes
+ WHERE schemaname = 'public'
+ AND tablename = 'repair_events'
+ AND indexname = 'idx_repair_events_repair_created'
+ AND indexdef LIKE '%repair_id, created_at, sequence, id%'
+ ) THEN
+ RAISE EXCEPTION 'Repair event chronological index is missing';
+ END IF;
+
+ IF (SELECT COUNT(*) FROM permissions WHERE key = 'repairs.create') <> 1 THEN
+ RAISE EXCEPTION 'repairs.create permission was not registered once';
+ END IF;
+
+ SELECT COUNT(*)
+ INTO v_count
+ 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'
+ AND roles.name IN ('Admin', 'Manager', 'Technician');
+ IF v_count <> 3 THEN
+ RAISE EXCEPTION 'Expected Admin, Manager, and Technician repair access';
+ END IF;
+
+ IF EXISTS (
+ SELECT 1
+ 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'
+ AND roles.name IN ('Read Only', 'Viewer', 'Auditor')
+ ) THEN
+ RAISE EXCEPTION 'A read-only role received repairs.create';
+ END IF;
+
+ INSERT INTO permissions (key, description)
+ VALUES ('repairs.create', 'Begin repairs for miner assets')
+ 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 = 'repairs.create'
+ WHERE roles.name IN ('Admin', 'Manager', 'Technician')
+ ON CONFLICT DO NOTHING;
+ IF (SELECT COUNT(*) FROM permissions WHERE key = 'repairs.create') <> 1 THEN
+ RAISE EXCEPTION 'Permission seeding is not idempotent';
+ END IF;
+
+ SELECT id INTO v_role_id FROM roles WHERE name = 'Admin';
+ INSERT INTO users (
+ id, username, display_name, password_hash, role_id
+ ) VALUES (
+ v_actor,
+ 'begin-repair-migration-test',
+ 'Begin Repair Migration Test',
+ 'not-used',
+ v_role_id
+ );
+ INSERT INTO miners (id, asset_tag, status, lifecycle_status)
+ VALUES
+ (v_miner, 'REPAIR-MIGRATION-001', 'unknown', 'active'),
+ (v_other_miner, 'REPAIR-MIGRATION-002', 'unknown', 'spare'),
+ (v_invalid_miner, 'REPAIR-MIGRATION-003', 'unknown', 'active');
+
+ INSERT INTO repairs (miner_id, reason, opened_by)
+ VALUES (v_miner, 'CONTROL_BOARD', v_actor)
+ RETURNING id INTO v_repair;
+ INSERT INTO repair_events (
+ repair_id, sequence, event_type, created_by, payload
+ ) VALUES
+ (
+ v_repair,
+ 1,
+ 'REPAIR_STARTED',
+ v_actor,
+ '{"reason":"CONTROL_BOARD","from_lifecycle":"active","to_lifecycle":"in_repair"}'
+ ),
+ (
+ v_repair,
+ 2,
+ 'INITIAL_NOTE',
+ v_actor,
+ '{"note":"Migration test note."}'
+ );
+ INSERT INTO repairs (miner_id, reason, opened_by)
+ VALUES (v_other_miner, 'FAN', v_actor)
+ RETURNING id INTO v_other_repair;
+ INSERT INTO repair_events (
+ repair_id, sequence, event_type, created_by, payload
+ ) VALUES (
+ v_other_repair,
+ 1,
+ 'REPAIR_STARTED',
+ v_actor,
+ '{"reason":"FAN","from_lifecycle":"spare","to_lifecycle":"in_repair"}'
+ );
+ UPDATE miners
+ SET lifecycle_status = 'in_repair'
+ WHERE id IN (v_miner, v_other_miner);
+
+ IF EXISTS (
+ SELECT 1
+ FROM repairs
+ WHERE id = v_repair
+ AND (
+ status <> 'OPEN'
+ OR completed_at IS NOT NULL
+ OR completed_by IS NOT NULL
+ OR opened_at IS NULL
+ )
+ ) THEN
+ RAISE EXCEPTION 'New repair defaults or completion fields are invalid';
+ END IF;
+ IF (
+ SELECT array_agg(event_type ORDER BY sequence)
+ FROM repair_events
+ WHERE repair_id = v_repair
+ ) <> ARRAY['REPAIR_STARTED', 'INITIAL_NOTE'] THEN
+ RAISE EXCEPTION 'Initial event ordering is incorrect';
+ END IF;
+ IF (
+ SELECT payload ->> 'from_lifecycle'
+ FROM repair_events
+ WHERE repair_id = v_other_repair
+ AND event_type = 'REPAIR_STARTED'
+ ) <> 'spare' THEN
+ RAISE EXCEPTION 'Spare lifecycle history was not preserved';
+ END IF;
+
+ BEGIN
+ INSERT INTO repairs (miner_id, reason, opened_by)
+ VALUES (v_miner, 'PSU', v_actor);
+ RAISE EXCEPTION 'A duplicate active repair was accepted';
+ EXCEPTION WHEN unique_violation THEN
+ NULL;
+ END;
+
+
+ BEGIN
+ INSERT INTO repairs (miner_id, status, reason, opened_by)
+ VALUES (v_invalid_miner, 'BROKEN', 'OTHER', v_actor);
+ RAISE EXCEPTION 'Unsupported repair status was accepted';
+ EXCEPTION WHEN check_violation THEN
+ NULL;
+ END;
+
+ BEGIN
+ INSERT INTO repairs (miner_id, reason, opened_by)
+ VALUES (v_invalid_miner, 'NOT_A_REASON', v_actor);
+ RAISE EXCEPTION 'Unsupported repair reason was accepted';
+ EXCEPTION WHEN check_violation THEN
+ NULL;
+ END;
+
+ BEGIN
+ INSERT INTO repair_events (
+ repair_id, sequence, event_type, created_by, payload
+ ) VALUES (
+ v_repair, 3, 'UNKNOWN_EVENT', v_actor, '{}'::jsonb
+ );
+ RAISE EXCEPTION 'Unsupported repair event type was accepted';
+ EXCEPTION WHEN check_violation THEN
+ NULL;
+ END;
+
+ BEGIN
+ INSERT INTO repair_events (
+ repair_id, sequence, event_type, created_by, payload
+ ) VALUES (
+ v_repair, 3, 'INITIAL_NOTE', v_actor, '{"note":" "}'::jsonb
+ );
+ RAISE EXCEPTION 'Blank initial note payload was accepted';
+ EXCEPTION WHEN check_violation THEN
+ NULL;
+ END;
+
+ BEGIN
+ INSERT INTO repair_events (
+ repair_id, sequence, event_type, created_by, payload
+ ) VALUES (
+ v_repair, 2, 'INITIAL_NOTE', v_actor, '{"note":"duplicate"}'::jsonb
+ );
+ RAISE EXCEPTION 'Duplicate event sequence was accepted';
+ EXCEPTION WHEN unique_violation THEN
+ NULL;
+ END;
+
+ BEGIN
+ INSERT INTO repairs (miner_id, reason, opened_by)
+ VALUES (gen_random_uuid(), 'OTHER', v_actor);
+ RAISE EXCEPTION 'Unknown miner repair was accepted';
+ EXCEPTION WHEN foreign_key_violation THEN
+ NULL;
+ END;
+
+ BEGIN
+ INSERT INTO repairs (miner_id, reason, opened_by)
+ VALUES (v_invalid_miner, 'OTHER', gen_random_uuid());
+ RAISE EXCEPTION 'Repair with an unknown actor was accepted';
+ EXCEPTION WHEN foreign_key_violation THEN
+ NULL;
+ END;
+
+ BEGIN
+ INSERT INTO repairs (miner_id, reason, opened_by)
+ VALUES (v_invalid_miner, 'OTHER', NULL);
+ RAISE EXCEPTION 'Repair without an actor was accepted';
+ EXCEPTION WHEN not_null_violation THEN
+ NULL;
+ END;
+
+ BEGIN
+ INSERT INTO repair_events (
+ repair_id, sequence, event_type, created_by, payload
+ ) VALUES (
+ v_repair,
+ 3,
+ 'INITIAL_NOTE',
+ NULL,
+ '{"note":"missing actor"}'::jsonb
+ );
+ RAISE EXCEPTION 'Repair event without an actor was accepted';
+ EXCEPTION WHEN not_null_violation THEN
+ NULL;
+ END;
+
+ BEGIN
+ INSERT INTO repair_events (
+ repair_id, sequence, event_type, created_by, payload
+ ) VALUES (
+ gen_random_uuid(),
+ 1,
+ 'INITIAL_NOTE',
+ v_actor,
+ '{"note":"unknown repair"}'::jsonb
+ );
+ RAISE EXCEPTION 'Event for an unknown repair was accepted';
+ EXCEPTION WHEN foreign_key_violation THEN
+ NULL;
+ END;
+
+ BEGIN
+ INSERT INTO repair_events (
+ repair_id, sequence, event_type, created_by, payload
+ ) VALUES (
+ v_repair,
+ 3,
+ 'INITIAL_NOTE',
+ gen_random_uuid(),
+ '{"note":"unknown actor"}'::jsonb
+ );
+ RAISE EXCEPTION 'Repair event with an unknown actor was accepted';
+ EXCEPTION WHEN foreign_key_violation THEN
+ NULL;
+ END;
+
+ BEGIN
+ UPDATE miners SET lifecycle_status = 'unknown' WHERE id = v_miner;
+ RAISE EXCEPTION 'Unsupported miner lifecycle was accepted';
+ EXCEPTION WHEN check_violation THEN
+ NULL;
+ END;
+END
+$$;
+
+INSERT INTO miners (id, asset_tag, status, lifecycle_status)
+SELECT
+ gen_random_uuid(),
+ 'REPAIR-STATUS-' || status,
+ 'unknown',
+ 'active'
+FROM (
+ VALUES
+ ('OPEN'),
+ ('IN_PROGRESS'),
+ ('WAITING_FOR_PARTS'),
+ ('READY_FOR_TEST'),
+ ('COMPLETED'),
+ ('CANCELLED')
+) AS supported(status);
+
+INSERT INTO repairs (
+ miner_id,
+ status,
+ reason,
+ opened_by,
+ completed_at,
+ completed_by
+)
+SELECT
+ miners.id,
+ supported.status,
+ 'OTHER',
+ actor.id,
+ CASE WHEN supported.status = 'COMPLETED' THEN now() END,
+ CASE WHEN supported.status = 'COMPLETED' THEN actor.id END
+FROM (
+ VALUES
+ ('OPEN'),
+ ('IN_PROGRESS'),
+ ('WAITING_FOR_PARTS'),
+ ('READY_FOR_TEST'),
+ ('COMPLETED'),
+ ('CANCELLED')
+) AS supported(status)
+JOIN miners ON miners.asset_tag = 'REPAIR-STATUS-' || supported.status
+CROSS JOIN (
+ SELECT id FROM users WHERE username = 'begin-repair-migration-test'
+) actor;
+
+INSERT INTO miners (id, asset_tag, status, lifecycle_status)
+SELECT
+ gen_random_uuid(),
+ 'REPAIR-REASON-' || reason,
+ 'unknown',
+ 'active'
+FROM (
+ VALUES
+ ('HARDWARE_FAILURE'),
+ ('LOW_HASHRATE'),
+ ('CONTROL_BOARD'),
+ ('HASHBOARD'),
+ ('PSU'),
+ ('FAN'),
+ ('OVERHEATING'),
+ ('NETWORKING'),
+ ('PREVENTIVE_MAINTENANCE'),
+ ('INSPECTION'),
+ ('OTHER')
+) AS supported(reason);
+
+INSERT INTO repairs (
+ miner_id,
+ status,
+ reason,
+ opened_by,
+ completed_at,
+ completed_by
+)
+SELECT
+ miners.id,
+ 'COMPLETED',
+ supported.reason,
+ actor.id,
+ now(),
+ actor.id
+FROM (
+ VALUES
+ ('HARDWARE_FAILURE'),
+ ('LOW_HASHRATE'),
+ ('CONTROL_BOARD'),
+ ('HASHBOARD'),
+ ('PSU'),
+ ('FAN'),
+ ('OVERHEATING'),
+ ('NETWORKING'),
+ ('PREVENTIVE_MAINTENANCE'),
+ ('INSPECTION'),
+ ('OTHER')
+) AS supported(reason)
+JOIN miners ON miners.asset_tag = 'REPAIR-REASON-' || supported.reason
+CROSS JOIN (
+ SELECT id FROM users WHERE username = 'begin-repair-migration-test'
+) actor;
+SQL
+
+
+echo "Checking downgrade refuses missing lifecycle history..."
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
+INSERT INTO miners (id, asset_tag, status, lifecycle_status)
+VALUES (
+ '00000000-0000-0000-0000-000000001999',
+ 'REPAIR-MIGRATION-INCONSISTENT',
+ 'unknown',
+ 'in_repair'
+);
+SQL
+if downgrade_error="$(run_alembic downgrade 0009_inventory_asset_id_history 2>&1)"; then
+ echo "Downgrade accepted missing lifecycle history." >&2
+ exit 1
+fi
+[[ "${downgrade_error}" == *"do not have exactly one active REPAIR_STARTED event"* ]]
+failed_revision="$(run_alembic current 2>/dev/null)"
+[[ "${failed_revision}" == *"0010_begin_repair"* ]]
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
+DELETE FROM miners
+WHERE id = '00000000-0000-0000-0000-000000001999';
+SQL
+echo "Downgrading and re-upgrading Begin Repair..."
+run_alembic downgrade 0009_inventory_asset_id_history
+downgrade_state="$(
+ "${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At' \
+ <<'SQL'
+SELECT
+ to_regclass('public.repairs') IS NULL
+ AND to_regclass('public.repair_events') IS NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM permissions WHERE key = 'repairs.create'
+ )
+ AND NOT EXISTS (
+ SELECT 1 FROM miners WHERE lifecycle_status = 'in_repair'
+ )
+ AND (
+ SELECT lifecycle_status
+ FROM miners
+ WHERE asset_tag = 'REPAIR-MIGRATION-001'
+ ) = 'active'
+ AND (
+ SELECT lifecycle_status
+ FROM miners
+ WHERE asset_tag = 'REPAIR-MIGRATION-002'
+ ) = 'spare';
+SQL
+)"
+[[ "${downgrade_state}" == "t" ]]
+run_alembic upgrade 0010_begin_repair
+reupgraded_revision="$(run_alembic current 2>/dev/null)"
+[[ "${reupgraded_revision}" == *"0010_begin_repair"* ]]
+
+echo "Migrating an empty database from base through Begin Repair..."
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres \
+ -c "CREATE DATABASE minerops_repair_empty"' >/dev/null
+"${compose[@]}" run --rm --no-deps \
+ -e "POSTGRES_DB=${empty_database}" \
+ backend alembic upgrade 0010_begin_repair
+
+empty_revision="$(
+ "${compose[@]}" run --rm --no-deps \
+ -e "POSTGRES_DB=${empty_database}" \
+ backend alembic current 2>/dev/null
+)"
+[[ "${empty_revision}" == *"0010_begin_repair"* ]]
+
+echo "Begin Repair migration test passed."
diff --git a/scripts/test-control-board-replacement-migration.sh b/scripts/test-control-board-replacement-migration.sh
new file mode 100755
index 0000000..1645a64
--- /dev/null
+++ b/scripts/test-control-board-replacement-migration.sh
@@ -0,0 +1,131 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+project_name="${MINEROPS_CONTROL_BOARD_MIGRATION_PROJECT:-minerops-control-board-migration-$$}"
+compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
+
+cleanup() {
+ status=$?
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+trap cleanup EXIT
+run_alembic() { "${compose[@]}" run --rm --no-deps backend alembic "$@"; }
+
+[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
+"${compose[@]}" up --detach postgres
+for attempt in $(seq 1 60); do
+ "${compose[@]}" exec -T postgres sh -ec \
+ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
+ (( attempt < 60 )) || exit 1
+ sleep 1
+done
+"${compose[@]}" build backend
+
+run_alembic upgrade 0012_return_to_service
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
+INSERT INTO users (id, username, display_name, password_hash, role_id)
+SELECT '00000000-0000-0000-0000-000000001401', 'board-migration',
+ 'Board Migration', 'unused', id FROM roles WHERE name = 'Admin';
+INSERT INTO miners (
+ id, asset_tag, current_mac, control_board_sn, lifecycle_status,
+ status, first_seen_at, last_seen_at
+) VALUES (
+ '00000000-0000-0000-0000-000000001402', 'BOARD-LEGACY',
+ '02-AA-BB-CC-DD-01', 'LEGACY-BOARD', 'active', 'unknown', NULL, NULL
+);
+SQL
+
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
+INSERT INTO miners (
+ id, asset_tag, current_mac, lifecycle_status, status,
+ first_seen_at, last_seen_at
+) VALUES (
+ '00000000-0000-0000-0000-000000001404', 'BOARD-CONFLICT',
+ '02:AA:BB:CC:DD:01', 'active', 'unknown', NULL, NULL
+);
+SQL
+if preflight_error="$(run_alembic upgrade head 2>&1)"; then
+ echo "Duplicate normalized MAC preflight did not stop the upgrade." >&2
+ exit 1
+fi
+[[ "${preflight_error}" == *"normalized MAC values have multiple miner owners"* ]]
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
+DELETE FROM miners WHERE id = '00000000-0000-0000-0000-000000001404';
+SQL
+
+run_alembic upgrade head
+[[ "$(run_alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]]
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
+DO $$
+DECLARE roles_for_permission text[];
+BEGIN
+ IF to_regclass('public.miner_control_board_installations') IS NULL
+ OR to_regclass('public.miner_mac_ownerships') IS NULL THEN
+ RAISE EXCEPTION 'Task 004 tables are missing';
+ END IF;
+ IF EXISTS (SELECT 1 FROM miner_control_board_installations) THEN
+ RAISE EXCEPTION 'Migration fabricated installation history';
+ END IF;
+ SELECT array_agg(role.name ORDER BY role.name) INTO roles_for_permission
+ FROM role_permissions rp JOIN roles role ON role.id = rp.role_id
+ JOIN permissions permission ON permission.id = rp.permission_id
+ WHERE permission.key = 'miners.replace_control_board';
+ IF roles_for_permission <> ARRAY['Admin', 'Manager', 'Technician'] THEN
+ RAISE EXCEPTION 'Unexpected permission roles: %', roles_for_permission;
+ END IF;
+ IF (SELECT miner_id FROM miner_mac_ownerships WHERE normalized_mac = '02aabbccdd01')
+ <> '00000000-0000-0000-0000-000000001402'::uuid THEN
+ RAISE EXCEPTION 'Legacy MAC ownership was not preserved';
+ END IF;
+END $$;
+SQL
+
+run_alembic downgrade 0012_return_to_service
+run_alembic upgrade head
+
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
+UPDATE miners
+SET lifecycle_status = 'in_repair', current_mac = '02:AA:BB:CC:DD:02',
+ control_board_sn = 'NEW-BOARD'
+WHERE id = '00000000-0000-0000-0000-000000001402';
+INSERT INTO miner_network_identities (
+ miner_id, mac_address, source, is_current, first_seen_at, last_seen_at, associated_at
+) VALUES (
+ '00000000-0000-0000-0000-000000001402', '02:AA:BB:CC:DD:02',
+ 'control_board_replacement', true, NULL, NULL, now()
+);
+INSERT INTO repairs (id, miner_id, reason, opened_by) VALUES (
+ '00000000-0000-0000-0000-000000001403',
+ '00000000-0000-0000-0000-000000001402', 'CONTROL_BOARD',
+ '00000000-0000-0000-0000-000000001401'
+);
+INSERT INTO miner_control_board_installations (
+ miner_id, record_origin, installed_repair_id, mac_address, normalized_mac, board_serial,
+ replacement_reason, installed_at, installed_by
+) VALUES (
+ '00000000-0000-0000-0000-000000001402', 'replacement',
+ '00000000-0000-0000-0000-000000001403', '02:AA:BB:CC:DD:02',
+ '02aabbccdd02', 'NEW-BOARD', 'failed', now(),
+ '00000000-0000-0000-0000-000000001401'
+);
+SQL
+if downgrade_error="$(run_alembic downgrade 0012_return_to_service 2>&1)"; then
+ echo "Downgrade discarded authoritative control-board history." >&2
+ exit 1
+fi
+[[ "${downgrade_error}" == *"authoritative installation records or events"* ]]
+[[ "$(run_alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]]
+
+database="minerops_control_board_empty"
+"${compose[@]}" exec -T postgres sh -ec \
+ 'createdb -U "$POSTGRES_USER" '"${database}"
+"${compose[@]}" run --rm --no-deps -e POSTGRES_DB="${database}" backend alembic upgrade head
+[[ "$("${compose[@]}" run --rm --no-deps -e POSTGRES_DB="${database}" backend alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]]
diff --git a/scripts/test-control-board-replacement.sh b/scripts/test-control-board-replacement.sh
new file mode 100755
index 0000000..fa3f769
--- /dev/null
+++ b/scripts/test-control-board-replacement.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+
+project_name="${MINEROPS_CONTROL_BOARD_TEST_PROJECT:-minerops-control-board-$$}"
+compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
+
+cleanup() {
+ status=$?
+ if (( status != 0 )); then
+ "${compose[@]}" logs --no-color --tail 120 postgres || true
+ fi
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+trap cleanup EXIT
+
+[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
+"${compose[@]}" up --detach postgres
+for attempt in $(seq 1 60); do
+ "${compose[@]}" exec -T postgres sh -ec \
+ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
+ (( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
+ sleep 1
+done
+"${compose[@]}" build backend
+"${compose[@]}" run --rm --no-deps backend alembic upgrade head
+"${compose[@]}" run --rm --no-deps \
+ -v "${repo_root}/backend/tests:/app/tests:ro" \
+ backend python -m unittest -v tests.test_control_board_replacement
+"${compose[@]}" run --rm --no-deps \
+ -v "${repo_root}/backend/tests:/app/tests:ro" \
+ backend python -m unittest discover -v -s tests
diff --git a/scripts/test-dashboard.sh b/scripts/test-dashboard.sh
new file mode 100755
index 0000000..5feca7e
--- /dev/null
+++ b/scripts/test-dashboard.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+
+project_name="${MINEROPS_DASHBOARD_TEST_PROJECT:-minerops-dashboard-$$}"
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file .env.dev
+ -f compose.dev.yaml
+)
+
+cleanup() {
+ status=$?
+ if (( status != 0 )); then
+ echo
+ echo "Dashboard tests failed. Recent PostgreSQL logs:"
+ "${compose[@]}" logs --no-color --tail 120 postgres || true
+ fi
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+
+trap cleanup EXIT
+
+if [[ ! -f .env.dev ]]; then
+ echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
+ exit 1
+fi
+
+echo "Starting disposable Dashboard database..."
+"${compose[@]}" up --detach postgres
+for attempt in $(seq 1 60); do
+ if "${compose[@]}" exec -T postgres sh -ec \
+ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; then
+ break
+ fi
+ if (( attempt == 60 )); then
+ echo "PostgreSQL did not become ready." >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+echo "Building Dashboard test image..."
+"${compose[@]}" build backend
+
+echo "Migrating disposable database..."
+"${compose[@]}" run --rm --no-deps backend alembic upgrade head
+
+echo "Running Dashboard and affected-domain focused tests..."
+"${compose[@]}" run --rm --no-deps \
+ -v "${repo_root}/backend/tests:/app/tests:ro" \
+ backend python -m unittest -v \
+ tests.test_dashboard \
+ tests.test_frontend_shell \
+ tests.test_inventory_domain \
+ tests.test_inventory_router \
+ tests.test_mine_map \
+ tests.test_repair_center
+
+echo "Dashboard focused tests passed."
diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh
new file mode 100755
index 0000000..531ec18
--- /dev/null
+++ b/scripts/test-dev-baseline.sh
@@ -0,0 +1,321 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+project_name="${MINEROPS_SMOKE_PROJECT:-minerops-smoke-$$}"
+dev_port="${MINEROPS_SMOKE_PORT:-18001}"
+keep_environment="${MINEROPS_SMOKE_KEEP:-false}"
+base_url="http://127.0.0.1:${dev_port}"
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file .env.dev
+ -f compose.dev.yaml
+)
+tmp_prefix="/tmp/${project_name}"
+admin_cookie="${tmp_prefix}-admin.cookies"
+manager_cookie="${tmp_prefix}-manager.cookies"
+technician_cookie="${tmp_prefix}-technician.cookies"
+readonly_cookie="${tmp_prefix}-readonly.cookies"
+
+cleanup() {
+ status=$?
+
+ if (( status != 0 )); then
+ echo
+ echo "Smoke test failed. Recent service logs:"
+ "${compose[@]}" logs --no-color --tail 160 || true
+ fi
+
+ rm -f "${tmp_prefix}"-*.json "${tmp_prefix}"-*.cookies "${tmp_prefix}"-*.headers
+ if [[ "${keep_environment}" != "true" ]]; then
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ else
+ echo "Keeping Compose project ${project_name} for inspection."
+ fi
+
+ exit "${status}"
+}
+
+trap cleanup EXIT
+
+status_for() {
+ local cookie_jar="$1"
+ local method="$2"
+ local path="$3"
+ local body="${4:-}"
+ local args=(--silent --output /dev/null --write-out '%{http_code}' -X "${method}")
+ if [[ -n "${cookie_jar}" ]]; then
+ # Read and update the jar so logout and refreshed cookies behave like a browser.
+ args+=(-b "${cookie_jar}" -c "${cookie_jar}")
+ fi
+ if [[ -n "${body}" ]]; then
+ args+=(-H 'Content-Type: application/json' --data "${body}")
+ fi
+ curl "${args[@]}" "${base_url}${path}"
+}
+
+if [[ ! -f .env.dev ]]; then
+ echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
+ exit 1
+fi
+
+echo "Validating Compose configuration..."
+MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" config --quiet
+
+echo "Starting disposable data services..."
+MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach postgres redis
+
+echo "Waiting for PostgreSQL initialization and Redis..."
+for attempt in $(seq 1 180); do
+ postgres_ready=false
+ redis_ready=false
+ if "${compose[@]}" exec -T postgres sh -ec \
+ 'test "$(cat /proc/1/comm)" = "postgres"; psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atqc "SELECT 1"' \
+ 2>/dev/null | grep -qx '1'; then
+ postgres_ready=true
+ fi
+ if "${compose[@]}" exec -T redis redis-cli ping 2>/dev/null | grep -qx 'PONG'; then
+ redis_ready=true
+ fi
+ if [[ "${postgres_ready}" == "true" && "${redis_ready}" == "true" ]]; then
+ break
+ fi
+ if (( attempt == 180 )); then
+ echo "Disposable data services did not become ready within 180 seconds." >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+echo "Building and starting the disposable backend..."
+MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --build --no-deps backend
+backend_container="$("${compose[@]}" ps -q backend)"
+for attempt in $(seq 1 180); do
+ backend_health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${backend_container}")"
+ if [[ "${backend_health}" == "healthy" ]]; then
+ break
+ fi
+ if [[ "${backend_health}" == "exited" || "${backend_health}" == "dead" ]]; then
+ echo "Disposable backend exited before becoming healthy." >&2
+ exit 1
+ fi
+ if (( attempt == 180 )); then
+ echo "Disposable backend did not become healthy within 180 seconds." >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+echo "Starting the disposable gateway..."
+MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --no-deps gateway
+
+echo "Waiting for the gateway health endpoint..."
+health_file="${tmp_prefix}-health.json"
+for attempt in $(seq 1 90); do
+ if curl --fail --silent "${base_url}/api/health" >"${health_file}"; then
+ break
+ fi
+
+ if (( attempt == 90 )); then
+ echo "Gateway did not become healthy within 90 seconds." >&2
+ exit 1
+ fi
+
+ sleep 1
+done
+
+health_json="$(cat "${health_file}")"
+[[ "${health_json}" == *'"status":"ok"'* ]]
+[[ "${health_json}" == *'"environment":"development"'* ]]
+[[ "${health_json}" == *'"database":"ok"'* ]]
+[[ "${health_json}" == *'"redis":"ok"'* ]]
+
+echo "Checking migrations and required schema..."
+current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
+[[ "${current_revision}" == *"0014_repair_parts_tracking"* ]]
+
+missing_tables="$(
+ "${compose[@]}" exec -T postgres sh -c \
+ 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At' \
+ < sql/verify-development-schema.sql
+)"
+if [[ -n "${missing_tables}" ]]; then
+ echo "Missing required tables:" >&2
+ echo "${missing_tables}" >&2
+ exit 1
+fi
+
+echo "Checking the unauthenticated login gate..."
+setup_status="$(curl --fail --silent "${base_url}/api/auth/setup-status")"
+[[ "${setup_status}" == *'"setup_required":true'* ]]
+[[ "$(status_for "" GET /login)" == "200" ]]
+for path in dashboard repairs locations system-health accounts monitoring/map operations/repairs assets/miners; do
+ [[ "$(status_for "" GET "/${path}")" == "303" ]]
+done
+[[ "$(status_for "" GET /api/scanner/status)" == "401" ]]
+
+echo "Creating the first administrator and role test accounts..."
+setup_response="$(curl --fail --silent -c "${admin_cookie}" \
+ -H 'Content-Type: application/json' \
+ --data '{"username":"admin","display_name":"Test Administrator","email":"admin@example.com","password":"MinerOpsTest!123"}' \
+ "${base_url}/api/auth/setup-admin")"
+[[ "${setup_response}" == *'"role":"Admin"'* ]]
+[[ "$(status_for "${admin_cookie}" GET /dashboard)" == "200" ]]
+[[ "$(status_for "${admin_cookie}" GET /accounts)" == "200" ]]
+for path in monitoring/map operations/repairs assets/miners; do
+ [[ "$(status_for "${admin_cookie}" GET "/${path}")" == "200" ]]
+done
+[[ "$(status_for "" GET /assets/minerops-app.css)" == "200" ]]
+[[ "$(status_for "" GET /assets/minerops-app.js)" == "200" ]]
+[[ "$(status_for "" GET /assets/minerops-mine-map.js)" == "200" ]]
+
+for account in \
+ 'manager|Operations Manager|Manager|manager@example.com' \
+ 'technician|Repair Technician|Technician|technician@example.com' \
+ 'readonly|Read Only User|Read Only|readonly@example.com'; do
+ IFS='|' read -r username display_name role email <<<"${account}"
+ response="$(curl --fail --silent -b "${admin_cookie}" \
+ -H 'Content-Type: application/json' \
+ --data "{\"username\":\"${username}\",\"display_name\":\"${display_name}\",\"email\":\"${email}\",\"role\":\"${role}\",\"password\":\"MinerOpsTest!123\"}" \
+ "${base_url}/api/admin/users")"
+ [[ "${response}" == *"\"role\":\"${role}\""* ]]
+done
+
+roles_response="$(curl --fail --silent -b "${admin_cookie}" "${base_url}/api/admin/roles")"
+for role in Admin Manager Technician 'Read Only'; do
+ [[ "${roles_response}" == *"\"name\":\"${role}\""* ]]
+done
+users_response="$(curl --fail --silent -b "${admin_cookie}" "${base_url}/api/admin/users")"
+for username in admin manager technician readonly; do
+ [[ "${users_response}" == *"\"username\":\"${username}\""* ]]
+done
+
+echo "Logging in each role and checking authorization boundaries..."
+for login in \
+ "manager|${manager_cookie}|Manager" \
+ "technician|${technician_cookie}|Technician" \
+ "readonly|${readonly_cookie}|Read Only"; do
+ IFS='|' read -r username cookie_jar role <<<"${login}"
+ login_response="$(curl --fail --silent -c "${cookie_jar}" \
+ -H 'Content-Type: application/json' \
+ --data "{\"username\":\"${username}\",\"password\":\"MinerOpsTest!123\"}" \
+ "${base_url}/api/auth/login")"
+ [[ "${login_response}" == *"\"role\":\"${role}\""* ]]
+ [[ "$(status_for "${cookie_jar}" GET /dashboard)" == "200" ]]
+ [[ "$(status_for "${cookie_jar}" GET /monitoring/map)" == "200" ]]
+ [[ "$(status_for "${cookie_jar}" GET /operations/repairs)" == "200" ]]
+ [[ "$(status_for "${cookie_jar}" GET /assets/miners)" == "200" ]]
+done
+
+[[ "$(status_for "${manager_cookie}" GET /system-health)" == "200" ]]
+[[ "$(status_for "${manager_cookie}" GET /accounts)" == "403" ]]
+[[ "$(status_for "${manager_cookie}" GET /api/admin/users)" == "403" ]]
+[[ "$(status_for "${technician_cookie}" GET /repairs)" == "200" ]]
+[[ "$(status_for "${technician_cookie}" GET /system-health)" == "403" ]]
+[[ "$(status_for "${readonly_cookie}" GET /locations)" == "200" ]]
+[[ "$(status_for "${readonly_cookie}" POST /api/rooms '{"name":"Must Not Be Created"}')" == "403" ]]
+[[ "$(status_for "" GET /api/location/map)" == "401" ]]
+[[ "$(status_for "${readonly_cookie}" GET /api/location/map)" == "200" ]]
+[[ "$(status_for "" GET /api/miners/00000000-0000-0000-0000-000000000999/status-snapshots)" == "401" ]]
+[[ "$(status_for "${readonly_cookie}" GET /api/miners/00000000-0000-0000-0000-000000000999/status-snapshots)" == "200" ]]
+
+echo "Checking Begin Repair permission and workflow..."
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
+INSERT INTO miners (
+ id,
+ asset_tag,
+ current_mac,
+ control_board_sn,
+ status,
+ lifecycle_status,
+ first_seen_at,
+ last_seen_at
+)
+VALUES (
+ '00000000-0000-0000-0000-000000000981',
+ 'SMOKE-REPAIR-001',
+ '02:AA:BB:CC:EE:01',
+ 'SMOKE-CB-OLD',
+ 'unknown',
+ 'active',
+ NULL,
+ NULL
+);
+SQL
+[[ "$(
+ status_for "${readonly_cookie}" POST /api/repairs \
+ '{"miner_id":"00000000-0000-0000-0000-000000000981","reason":"CONTROL_BOARD"}'
+)" == "403" ]]
+begin_repair_response="$(curl --fail --silent -b "${technician_cookie}" \
+ -H 'Content-Type: application/json' \
+ --data '{"miner_id":"00000000-0000-0000-0000-000000000981","reason":"CONTROL_BOARD","notes":"Smoke test repair."}' \
+ "${base_url}/api/repairs")"
+[[ "${begin_repair_response}" == *'"status":"OPEN"'* ]]
+[[ "${begin_repair_response}" == *'"reason":"CONTROL_BOARD"'* ]]
+[[ "$(
+ status_for "${technician_cookie}" POST /api/repairs \
+ '{"miner_id":"00000000-0000-0000-0000-000000000981","reason":"CONTROL_BOARD"}'
+)" == "409" ]]
+
+smoke_repair_id="$(
+ printf '%s' "${begin_repair_response}" \
+ | python3 -c 'import json, sys; print(json.load(sys.stdin)["data"]["repair_id"])'
+)"
+[[ -n "${smoke_repair_id}" ]]
+[[ "$(
+ status_for "${readonly_cookie}" POST /api/miners/00000000-0000-0000-0000-000000000981/replace-control-board \
+ '{"new_mac_address":"02:AA:BB:CC:EE:02","new_control_board_serial":"SMOKE-CB-NEW","removed_board_disposition":"sent_for_repair","replacement_reason":"failed"}'
+)" == "403" ]]
+board_response="$(curl --fail --silent -b "${technician_cookie}" \
+ -H 'Content-Type: application/json' \
+ --data '{"new_mac_address":"02:AA:BB:CC:EE:02","new_control_board_serial":"SMOKE-CB-NEW","removed_board_disposition":"sent_for_repair","replacement_reason":"failed","notes":"Smoke replacement."}' \
+ "${base_url}/api/miners/00000000-0000-0000-0000-000000000981/replace-control-board")"
+[[ "${board_response}" == *'"current_mac":"02:AA:BB:CC:EE:02"'* ]]
+[[ "${board_response}" == *'"event_sequence":3'* ]]
+readonly_repair_detail="$(
+ curl --fail --silent -b "${readonly_cookie}" \
+ "${base_url}/api/repairs/${smoke_repair_id}"
+)"
+[[ "${readonly_repair_detail}" == *'"miner_asset_id":"SMOKE-REPAIR-001"'* ]]
+[[ "${readonly_repair_detail}" == *'"miner_lifecycle":"in_repair"'* ]]
+[[ "${readonly_repair_detail}" == *'"latest_event_sequence":3'* ]]
+readonly_repair_events="$(
+ curl --fail --silent -b "${readonly_cookie}" \
+ "${base_url}/api/repairs/${smoke_repair_id}/events"
+)"
+[[ "${readonly_repair_events}" == *'"sequence":1'* ]]
+[[ "${readonly_repair_events}" == *'"sequence":2'* ]]
+[[ "${readonly_repair_events}" == *'"sequence":3'* ]]
+[[ "${readonly_repair_events}" == *'"event_type":"CONTROL_BOARD_REPLACED"'* ]]
+[[ "${readonly_repair_events}" == *'"event_type":"REPAIR_STARTED"'* ]]
+[[ "${readonly_repair_events}" == *'"event_type":"INITIAL_NOTE"'* ]]
+[[ "$(status_for "${technician_cookie}" GET /api/repairs/queue)" == "200" ]]
+[[ "$(status_for "${technician_cookie}" GET /api/repairs/parts-summary)" == "200" ]]
+
+room_status="$(status_for "${technician_cookie}" POST /api/rooms '{"name":"Role Matrix Test Room"}')"
+[[ "${room_status}" == "200" || "${room_status}" == "201" ]]
+
+me_response="$(curl --fail --silent -b "${readonly_cookie}" "${base_url}/api/auth/me")"
+[[ "${me_response}" == *'"role":"Read Only"'* ]]
+[[ "$(status_for "${readonly_cookie}" POST /api/auth/logout)" == "200" ]]
+[[ "$(status_for "${readonly_cookie}" GET /dashboard)" == "303" ]]
+
+echo "Checking development safety interlocks..."
+"${compose[@]}" exec -T backend sh -ec '
+ test "$SCHEDULED_SCAN_ENABLED" = "false"
+ test "$MINEROPS_HUB_MODE" = "true"
+ test "$MINEROPS_DISABLE_LOCAL_SCANNER" = "true"
+ test "$MINEROPS_ENABLE_REAL_REBOOT" = "false"
+ test "$MINEROPS_ENABLE_REAL_POWER_CONTROL" = "false"
+'
+
+echo "Running retention rollups against the disposable baseline..."
+"${compose[@]}" exec -T postgres sh -c \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
+ < sql/minerops-retention-hourly.sql >/dev/null
+"${compose[@]}" exec -T postgres sh -c \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
+ < sql/minerops-retention-daily.sql >/dev/null
+
+echo "Development identity baseline passed."
diff --git a/scripts/test-development-demo.sh b/scripts/test-development-demo.sh
new file mode 100755
index 0000000..19da9fb
--- /dev/null
+++ b/scripts/test-development-demo.sh
@@ -0,0 +1,173 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+
+project_name="${MINEROPS_DEMO_TEST_PROJECT:-minerops-demo-test-$$}"
+dev_port="${MINEROPS_DEMO_TEST_PORT:-$((21000 + ($$ % 10000)))}"
+base_url="http://127.0.0.1:${dev_port}"
+tmp_prefix="/tmp/${project_name}"
+admin_cookie="${tmp_prefix}-admin.cookies"
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file .env.dev
+ -f compose.dev.yaml
+)
+
+cleanup() {
+ status=$?
+ if (( status != 0 )); then
+ echo
+ echo "Development demo test failed. Recent service logs:"
+ "${compose[@]}" logs --no-color --tail 160 || true
+ fi
+ rm -f "${tmp_prefix}"-*
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+trap cleanup EXIT
+
+status_for() {
+ local path="$1"
+ curl --silent --output /dev/null --write-out '%{http_code}' \
+ -b "${admin_cookie}" -c "${admin_cookie}" "${base_url}${path}"
+}
+
+printf '%s\n' 'APP_ENV=production' > "${tmp_prefix}-production.env"
+if MINEROPS_DEMO_ENV_FILE="${tmp_prefix}-production.env" \
+ ./scripts/seed-development-demo.sh >"${tmp_prefix}-safety.out" 2>&1; then
+ echo "Demo seed accepted a production environment." >&2
+ exit 1
+fi
+grep -q 'APP_ENV must be exactly development' "${tmp_prefix}-safety.out"
+
+
+echo "Starting disposable MinerOps data services..."
+MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach postgres redis
+for attempt in $(seq 1 180); do
+ postgres_ready=false
+ redis_ready=false
+ if "${compose[@]}" exec -T postgres sh -ec \
+ 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atqc "SELECT 1"' \
+ 2>/dev/null | grep -qx 1; then
+ postgres_ready=true
+ fi
+ if "${compose[@]}" exec -T redis redis-cli ping 2>/dev/null | grep -qx PONG; then
+ redis_ready=true
+ fi
+ if [[ "${postgres_ready}" == "true" && "${redis_ready}" == "true" ]]; then
+ break
+ fi
+ (( attempt < 180 )) || { echo "Disposable data services did not become ready." >&2; exit 1; }
+ sleep 1
+done
+
+echo "Building and starting the disposable backend..."
+MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --build --no-deps backend
+backend_container="$("${compose[@]}" ps -q backend)"
+for attempt in $(seq 1 180); do
+ backend_health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${backend_container}")"
+ [[ "${backend_health}" == "healthy" ]] && break
+ [[ "${backend_health}" != "exited" && "${backend_health}" != "dead" ]] || { echo "Disposable backend exited." >&2; exit 1; }
+ (( attempt < 180 )) || { echo "Disposable backend did not become ready." >&2; exit 1; }
+ sleep 1
+done
+
+echo "Starting the disposable gateway..."
+MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --no-deps gateway
+for attempt in $(seq 1 90); do
+ if curl --fail --silent "${base_url}/api/health" >"${tmp_prefix}-health.json"; then
+ break
+ fi
+ (( attempt < 90 )) || { echo "Disposable gateway did not become ready." >&2; exit 1; }
+ sleep 1
+done
+echo "Creating disposable development administrator..."
+curl --fail --silent -c "${admin_cookie}" \
+ -H 'Content-Type: application/json' \
+ --data '{"username":"admin","display_name":"Test Administrator","email":"admin@example.com","password":"MinerOpsTest!123"}' \
+ "${base_url}/api/auth/setup-admin" >"${tmp_prefix}-setup.json"
+grep -q '"role":"Admin"' "${tmp_prefix}-setup.json"
+
+echo "Seeding and reapplying the deterministic fixture..."
+MINEROPS_DEMO_PROJECT="${project_name}" ./scripts/seed-development-demo.sh
+MINEROPS_DEMO_PROJECT="${project_name}" ./scripts/seed-development-demo.sh
+MINEROPS_DEMO_PROJECT="${project_name}" ./scripts/seed-development-demo.sh --reseed
+
+counts="$(
+ "${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -At -F "|" -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
+SELECT
+ count(*) FILTER (WHERE asset_tag LIKE 'DEMO-MIN-%'),
+ (SELECT count(*) FROM location_positions WHERE source_file = 'MinerOps demo seed'),
+ (SELECT count(*) FROM location_positions WHERE source_file = 'MinerOps demo seed' AND miner_id IS NULL),
+ (SELECT count(*) FROM repairs WHERE id::text LIKE 'd4000000-0000-4000-8000-%'),
+ (SELECT count(*) FROM repairs WHERE id::text LIKE 'd4000000-0000-4000-8000-%' AND status IN ('OPEN', 'IN_PROGRESS', 'WAITING_FOR_PARTS', 'READY_FOR_TEST')),
+ (SELECT count(*) FROM repair_events WHERE id::text LIKE 'd5000000-0000-4000-8000-%'),
+ (SELECT count(*) FROM miner_status_snapshots WHERE id::text LIKE 'd3000000-0000-4000-8000-%')
+FROM miners;
+SQL
+)"
+[[ "${counts}" == "30|50|23|4|3|8|23" ]]
+
+echo "Checking live authenticated pages and APIs..."
+for path in /dashboard /assets/miners /monitoring/map /operations/repairs; do
+ [[ "$(status_for "${path}")" == "200" ]]
+done
+curl --fail --silent -b "${admin_cookie}" "${base_url}/monitoring/map" >"${tmp_prefix}-map.html"
+curl --fail --silent -b "${admin_cookie}" "${base_url}/assets/minerops-mine-map.js" >"${tmp_prefix}-map.js"
+curl --fail --silent -b "${admin_cookie}" "${base_url}/api/location/map" >"${tmp_prefix}-map.json"
+curl --fail --silent -b "${admin_cookie}" "${base_url}/api/inventory/miners?limit=100" >"${tmp_prefix}-miners.json"
+curl --fail --silent -b "${admin_cookie}" "${base_url}/api/repairs/queue" >"${tmp_prefix}-repairs.json"
+
+for id in mapSite mapRoom mapLayer mapPermissionNote mapViewport mapInspector; do
+ grep -q "id=\"${id}\"" "${tmp_prefix}-map.html"
+done
+for layer in temperature hashrate power repair; do
+ grep -q "\"${layer}\"" "${tmp_prefix}-map.js"
+done
+! grep -q "Cannot set properties of null" "${tmp_prefix}-map.html"
+
+python3 - "${tmp_prefix}-map.json" "${tmp_prefix}-miners.json" "${tmp_prefix}-repairs.json" <<'PY'
+import json
+import sys
+
+with open(sys.argv[1], encoding="utf-8") as handle:
+ mine_map = json.load(handle)
+with open(sys.argv[2], encoding="utf-8") as handle:
+ miners = json.load(handle)
+with open(sys.argv[3], encoding="utf-8") as handle:
+ repairs = json.load(handle)
+
+assert mine_map["meta"]["miner_details_included"] is True
+assert mine_map["meta"]["position_count"] == 50
+assert len(mine_map["data"]["sites"]) >= 1
+assert {room["name"] for room in mine_map["data"]["rooms"]} >= {"Demo Unit A", "Demo Unit B"}
+positions = mine_map["data"]["positions"]
+assert {position["rack_name"] for position in positions} >= {"A1", "A2", "A3", "B1", "B2"}
+assert sum(position["occupied"] for position in positions) == 27
+assert sum(not position["occupied"] for position in positions) == 23
+assert any(position["active_repair"] for position in positions)
+assert any(position["occupied"] and position["observed_at"] is None for position in positions)
+assert any(position["hashrate_ths"] == 0 for position in positions)
+assert len({position["model"] for position in positions if position["model"]}) == 6
+assert len({position["chip_temp_max_c"] for position in positions if position["chip_temp_max_c"] is not None}) >= 5
+assert len(miners["data"]) == 30
+assert any(miner["asset_id"] == "DEMO-MIN-001" for miner in miners["data"])
+assert repairs["data"]["summary"]["total"] == 3
+assert {item["latest_event_status"] for item in repairs["data"]["items"]} == {"open", "waiting_parts", "testing"}
+PY
+
+for query in \
+ 'query=DEMO-MIN-001' \
+ 'query=DEMO-SERIAL-001' \
+ 'query=02%3A00%3A00%3A00%3A00%3A01' \
+ 'query=M60S'; do
+ response="$(curl --fail --silent -b "${admin_cookie}" "${base_url}/api/inventory/miners?${query}&limit=100")"
+ [[ "${response}" == *'"returned":'* ]]
+ [[ "${response}" != *'"returned":0'* ]]
+done
+
+echo "Development demo seed and live UI/API smoke checks passed."
diff --git a/scripts/test-frontend-shell.sh b/scripts/test-frontend-shell.sh
new file mode 100755
index 0000000..b29581d
--- /dev/null
+++ b/scripts/test-frontend-shell.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+
+project_name="${MINEROPS_FRONTEND_TEST_PROJECT:-minerops-frontend-shell-$$}"
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file .env.dev
+ -f compose.dev.yaml
+)
+
+cleanup() {
+ status=$?
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+
+trap cleanup EXIT
+
+if [[ ! -f .env.dev ]]; then
+ echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
+ exit 1
+fi
+
+echo "Building frontend test image..."
+"${compose[@]}" build backend
+
+echo "Running focused frontend shell tests..."
+"${compose[@]}" run --rm --no-deps -v "${repo_root}/backend/tests:/app/tests:ro" backend python -m unittest -v tests.test_frontend_shell
+
+echo "Frontend shell tests passed."
diff --git a/scripts/test-inventory-domain.sh b/scripts/test-inventory-domain.sh
new file mode 100755
index 0000000..b0ca167
--- /dev/null
+++ b/scripts/test-inventory-domain.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+
+project_name="${MINEROPS_INVENTORY_TEST_PROJECT:-minerops-inventory-domain-$$}"
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file .env.dev
+ -f compose.dev.yaml
+)
+
+cleanup() {
+ status=$?
+ if (( status != 0 )); then
+ echo
+ echo "Inventory domain tests failed. Recent PostgreSQL logs:"
+ "${compose[@]}" logs --no-color --tail 120 postgres || true
+ fi
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+
+trap cleanup EXIT
+
+if [[ ! -f .env.dev ]]; then
+ echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
+ exit 1
+fi
+
+echo "Starting disposable inventory database..."
+"${compose[@]}" up --detach postgres
+for attempt in $(seq 1 60); do
+ if "${compose[@]}" exec -T postgres sh -ec \
+ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; then
+ break
+ fi
+ if (( attempt == 60 )); then
+ echo "PostgreSQL did not become ready." >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+echo "Building inventory test image..."
+"${compose[@]}" build backend
+
+echo "Migrating disposable database..."
+"${compose[@]}" run --rm --no-deps backend alembic upgrade head
+
+echo "Running inventory repository and service tests..."
+"${compose[@]}" run --rm --no-deps \
+ -v "${repo_root}/backend/tests:/app/tests:ro" \
+ backend python -m unittest -v \
+ tests.test_inventory_domain \
+ tests.test_inventory_router
+
+echo "Inventory domain tests passed."
diff --git a/scripts/test-manual-inventory-migration.sh b/scripts/test-manual-inventory-migration.sh
new file mode 100755
index 0000000..34d63e8
--- /dev/null
+++ b/scripts/test-manual-inventory-migration.sh
@@ -0,0 +1,398 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+project_name="${MINEROPS_INVENTORY_MIGRATION_PROJECT:-minerops-inventory-migration-$$}"
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file .env.dev
+ -f compose.dev.yaml
+)
+empty_database="minerops_inventory_empty"
+
+cleanup() {
+ status=$?
+ if (( status != 0 )); then
+ echo
+ echo "Migration test failed. Recent PostgreSQL logs:"
+ "${compose[@]}" logs --no-color --tail 120 postgres || true
+ fi
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+
+trap cleanup EXIT
+
+if [[ ! -f .env.dev ]]; then
+ echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
+ exit 1
+fi
+
+run_alembic() {
+ "${compose[@]}" run --rm --no-deps backend alembic "$@"
+}
+
+run_empty_alembic() {
+ "${compose[@]}" run --rm --no-deps \
+ -e "POSTGRES_DB=${empty_database}" \
+ backend alembic "$@"
+}
+
+echo "Starting disposable PostgreSQL..."
+"${compose[@]}" up --detach postgres
+for attempt in $(seq 1 60); do
+ if "${compose[@]}" exec -T postgres sh -ec \
+ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; then
+ break
+ fi
+ if (( attempt == 60 )); then
+ echo "PostgreSQL did not become ready." >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+echo "Building migration image..."
+"${compose[@]}" build backend
+
+echo "Upgrading to the pre-inventory schema..."
+run_alembic upgrade 0007_identity_and_roles
+
+echo "Seeding existing miner data and conflicting Asset IDs..."
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
+INSERT INTO miners (
+ id,
+ asset_tag,
+ current_mac,
+ current_ip,
+ miner_sn,
+ power_sn,
+ status,
+ first_seen_at,
+ last_seen_at,
+ created_at,
+ updated_at
+)
+VALUES (
+ '00000000-0000-0000-0000-000000000801',
+ 'ASSET-EXISTING',
+ 'AA:BB:CC:DD:EE:01',
+ '192.0.2.81',
+ 'MINER-SN-EXISTING',
+ 'PSU-SN-EXISTING',
+ 'online',
+ '2026-01-01 00:00:00+00',
+ '2026-01-02 00:00:00+00',
+ '2025-12-31 00:00:00+00',
+ '2026-01-02 00:00:00+00'
+);
+
+INSERT INTO miners (id, asset_tag, status)
+VALUES
+ ('00000000-0000-0000-0000-000000000810', 'CaseAsset', 'discovered'),
+ ('00000000-0000-0000-0000-000000000811', 'caseasset', 'discovered'),
+ ('00000000-0000-0000-0000-000000000812', 'SpaceAsset', 'discovered'),
+ ('00000000-0000-0000-0000-000000000813', ' SpaceAsset ', 'discovered');
+SQL
+
+echo "Checking duplicate Asset ID preflight failure..."
+set +e
+migration_failure_output="$(run_alembic upgrade head 2>&1)"
+migration_failure_status=$?
+set -e
+if (( migration_failure_status == 0 )); then
+ echo "Migration unexpectedly accepted conflicting existing Asset IDs." >&2
+ exit 1
+fi
+for expected_text in \
+ "Cannot enforce globally unique Asset IDs" \
+ "caseasset" \
+ "spaceasset" \
+ "Correct the conflicting miners.asset_tag values before upgrading"; do
+ if [[ "${migration_failure_output}" != *"${expected_text}"* ]]; then
+ echo "Migration failure did not include expected text: ${expected_text}" >&2
+ echo "${migration_failure_output}" >&2
+ exit 1
+ fi
+done
+
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
+DO $$
+BEGIN
+ IF (SELECT version_num FROM alembic_version) <> '0007_identity_and_roles' THEN
+ RAISE EXCEPTION 'Failed migration changed the Alembic revision';
+ END IF;
+ IF EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'miners'
+ AND column_name IN ('control_board_sn', 'lifecycle_status')
+ ) OR to_regclass('public.tags') IS NOT NULL
+ OR to_regclass('public.miner_tags') IS NOT NULL THEN
+ RAISE EXCEPTION 'Failed migration left partial inventory schema behind';
+ END IF;
+END
+$$;
+
+DELETE FROM miners
+WHERE id IN (
+ '00000000-0000-0000-0000-000000000810',
+ '00000000-0000-0000-0000-000000000811',
+ '00000000-0000-0000-0000-000000000812',
+ '00000000-0000-0000-0000-000000000813'
+);
+SQL
+
+echo "Upgrading the corrected populated schema to head..."
+run_alembic upgrade head
+
+echo "Checking constraints, defaults, data preservation, and foreign keys..."
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
+DO $$
+DECLARE
+ v_tag_id UUID;
+ v_cascade_tag_id UUID;
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM miners
+ WHERE id = '00000000-0000-0000-0000-000000000801'
+ AND asset_tag = 'ASSET-EXISTING'
+ AND power_sn = 'PSU-SN-EXISTING'
+ AND status = 'online'
+ AND lifecycle_status = 'active'
+ AND first_seen_at = '2026-01-01 00:00:00+00'
+ AND last_seen_at = '2026-01-02 00:00:00+00'
+ ) THEN
+ RAISE EXCEPTION 'Existing miner data was not preserved';
+ END IF;
+
+ IF EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'miners'
+ AND column_name IN ('first_seen_at', 'last_seen_at')
+ AND (is_nullable <> 'YES' OR column_default IS NULL)
+ ) THEN
+ RAISE EXCEPTION 'Miner seen timestamps should be nullable and retain defaults';
+ END IF;
+
+ BEGIN
+ INSERT INTO miners (asset_tag, status, lifecycle_status)
+ VALUES (' asset-existing ', 'manual', 'active');
+ RAISE EXCEPTION 'Case-insensitive trimmed duplicate Asset ID was accepted';
+ EXCEPTION WHEN unique_violation THEN
+ NULL;
+ END;
+
+ INSERT INTO miners (
+ id, asset_tag, status, first_seen_at, last_seen_at, created_at
+ ) VALUES (
+ '00000000-0000-0000-0000-000000000803',
+ NULL,
+ 'manual',
+ NULL,
+ NULL,
+ '2026-02-03 04:05:06+00'
+ );
+ INSERT INTO miners (id, asset_tag, status)
+ VALUES (
+ '00000000-0000-0000-0000-000000000804',
+ 'ASSET-DEFAULT-SEEN',
+ 'manual'
+ );
+ INSERT INTO miners (id, asset_tag, status, lifecycle_status)
+ VALUES
+ ('00000000-0000-0000-0000-000000000805', 'ASSET-SPARE', 'manual', 'spare'),
+ ('00000000-0000-0000-0000-000000000806', 'ASSET-RETIRED', 'manual', 'retired');
+ INSERT INTO miners (asset_tag, status, first_seen_at, last_seen_at)
+ VALUES
+ (NULL, 'manual', NULL, NULL),
+ ('', 'manual', NULL, NULL),
+ (' ', 'manual', NULL, NULL);
+
+ IF NOT EXISTS (
+ SELECT 1 FROM miners
+ WHERE id = '00000000-0000-0000-0000-000000000803'
+ AND first_seen_at IS NULL
+ AND last_seen_at IS NULL
+ AND lifecycle_status = 'active'
+ ) THEN
+ RAISE EXCEPTION 'Explicit NULL seen timestamps or lifecycle default were not preserved';
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM miners
+ WHERE id = '00000000-0000-0000-0000-000000000804'
+ AND first_seen_at IS NOT NULL
+ AND last_seen_at IS NOT NULL
+ AND lifecycle_status = 'active'
+ ) THEN
+ RAISE EXCEPTION 'Omitted seen timestamps did not receive defaults';
+ END IF;
+
+ BEGIN
+ INSERT INTO miners (asset_tag, status, lifecycle_status)
+ VALUES ('ASSET-BAD-LIFECYCLE', 'manual', 'maintenance');
+ RAISE EXCEPTION 'Invalid lifecycle status was accepted';
+ EXCEPTION WHEN check_violation THEN
+ NULL;
+ END;
+
+ BEGIN
+ INSERT INTO tags (name) VALUES ('');
+ RAISE EXCEPTION 'Blank tag name was accepted';
+ EXCEPTION WHEN check_violation THEN
+ NULL;
+ END;
+ BEGIN
+ INSERT INTO tags (name) VALUES (' ');
+ RAISE EXCEPTION 'Whitespace-only tag name was accepted';
+ EXCEPTION WHEN check_violation THEN
+ NULL;
+ END;
+
+ INSERT INTO tags (name) VALUES ('Priority') RETURNING id INTO v_tag_id;
+ BEGIN
+ INSERT INTO tags (name) VALUES (' priority ');
+ RAISE EXCEPTION 'Case-insensitive trimmed duplicate tag was accepted';
+ EXCEPTION WHEN unique_violation THEN
+ NULL;
+ END;
+
+ INSERT INTO miner_tags (miner_id, tag_id)
+ VALUES ('00000000-0000-0000-0000-000000000801', v_tag_id);
+ BEGIN
+ INSERT INTO miner_tags (miner_id, tag_id)
+ VALUES ('00000000-0000-0000-0000-000000000801', v_tag_id);
+ RAISE EXCEPTION 'Duplicate miner-tag association was accepted';
+ EXCEPTION WHEN unique_violation THEN
+ NULL;
+ END;
+
+ BEGIN
+ INSERT INTO miner_tags (miner_id, tag_id)
+ VALUES ('00000000-0000-0000-0000-000000000899', v_tag_id);
+ RAISE EXCEPTION 'Invalid miner foreign key was accepted';
+ EXCEPTION WHEN foreign_key_violation THEN
+ NULL;
+ END;
+ BEGIN
+ INSERT INTO miner_tags (miner_id, tag_id)
+ VALUES (
+ '00000000-0000-0000-0000-000000000801',
+ '00000000-0000-0000-0000-000000000899'
+ );
+ RAISE EXCEPTION 'Invalid tag foreign key was accepted';
+ EXCEPTION WHEN foreign_key_violation THEN
+ NULL;
+ END;
+
+ DELETE FROM tags WHERE id = v_tag_id;
+ IF EXISTS (SELECT 1 FROM miner_tags WHERE tag_id = v_tag_id) THEN
+ RAISE EXCEPTION 'Deleting a tag did not cascade to miner_tags';
+ END IF;
+
+ INSERT INTO tags (name) VALUES ('Cascade') RETURNING id INTO v_cascade_tag_id;
+ INSERT INTO miners (id, asset_tag, status)
+ VALUES ('00000000-0000-0000-0000-000000000802', 'ASSET-CASCADE', 'manual');
+ INSERT INTO miner_tags (miner_id, tag_id)
+ VALUES ('00000000-0000-0000-0000-000000000802', v_cascade_tag_id);
+ DELETE FROM miners WHERE id = '00000000-0000-0000-0000-000000000802';
+ IF EXISTS (
+ SELECT 1 FROM miner_tags
+ WHERE miner_id = '00000000-0000-0000-0000-000000000802'
+ ) THEN
+ RAISE EXCEPTION 'Deleting a miner did not cascade to miner_tags';
+ END IF;
+END
+$$;
+SQL
+
+echo "Downgrading to the current schema..."
+run_alembic downgrade 0007_identity_and_roles
+
+echo "Checking downgrade and deterministic timestamp backfill..."
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
+DO $$
+BEGIN
+ IF to_regclass('public.tags') IS NOT NULL
+ OR to_regclass('public.miner_tags') IS NOT NULL THEN
+ RAISE EXCEPTION 'Inventory tag tables remain after downgrade';
+ END IF;
+
+ IF EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'miners'
+ AND column_name IN ('control_board_sn', 'lifecycle_status')
+ ) THEN
+ RAISE EXCEPTION 'Inventory miner columns remain after downgrade';
+ END IF;
+
+ IF EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'miners'
+ AND column_name IN ('first_seen_at', 'last_seen_at')
+ AND is_nullable <> 'NO'
+ ) THEN
+ RAISE EXCEPTION 'Miner seen timestamps were not restored to NOT NULL';
+ END IF;
+
+ IF EXISTS (
+ SELECT 1 FROM miners
+ WHERE first_seen_at IS NULL OR last_seen_at IS NULL
+ ) THEN
+ RAISE EXCEPTION 'Null seen timestamps were not backfilled';
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1
+ FROM miners
+ WHERE id = '00000000-0000-0000-0000-000000000803'
+ AND first_seen_at = '2026-02-03 04:05:06+00'
+ AND last_seen_at = '2026-02-03 04:05:06+00'
+ ) THEN
+ RAISE EXCEPTION 'Null seen timestamps were not deterministically backfilled from created_at';
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1
+ FROM miners
+ WHERE id = '00000000-0000-0000-0000-000000000801'
+ AND asset_tag = 'ASSET-EXISTING'
+ AND power_sn = 'PSU-SN-EXISTING'
+ AND status = 'online'
+ ) THEN
+ RAISE EXCEPTION 'Existing miner data was not preserved by downgrade';
+ END IF;
+END
+$$;
+SQL
+
+echo "Re-upgrading the populated schema..."
+run_alembic upgrade head
+populated_revision="$(
+ "${compose[@]}" exec -T postgres sh -ec \
+ 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT version_num FROM alembic_version"'
+)"
+[[ "${populated_revision}" == "0008_manual_miner_inventory" ]]
+
+echo "Testing an empty database migration from base through head..."
+"${compose[@]}" exec -T postgres sh -ec \
+ 'createdb -U "$POSTGRES_USER" minerops_inventory_empty'
+run_empty_alembic upgrade head
+empty_revision="$(
+ "${compose[@]}" exec -T postgres sh -ec \
+ 'psql -U "$POSTGRES_USER" -d minerops_inventory_empty -Atc "SELECT version_num FROM alembic_version"'
+)"
+[[ "${empty_revision}" == "0008_manual_miner_inventory" ]]
+
+echo "Manual inventory migration tests passed."
diff --git a/scripts/test-mine-map.sh b/scripts/test-mine-map.sh
new file mode 100755
index 0000000..f7f561f
--- /dev/null
+++ b/scripts/test-mine-map.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+project_name="${MINEROPS_MINE_MAP_TEST_PROJECT:-minerops-mine-map-$$}"
+compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
+cleanup() {
+ status=$?
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+trap cleanup EXIT
+[[ -f .env.dev ]] || { echo "Missing .env.dev." >&2; exit 1; }
+"${compose[@]}" up --detach postgres redis
+for attempt in $(seq 1 60); do
+ "${compose[@]}" exec -T postgres sh -ec 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
+ (( attempt < 60 )) || { echo "PostgreSQL did not become ready." >&2; exit 1; }
+ sleep 1
+done
+"${compose[@]}" build backend
+"${compose[@]}" run --rm --no-deps backend alembic upgrade head
+"${compose[@]}" run --rm --no-deps -v "${repo_root}/backend/tests:/app/tests:ro" \
+ backend python -m unittest -v tests.test_mine_map tests.test_frontend_shell
diff --git a/scripts/test-miner-detail.sh b/scripts/test-miner-detail.sh
new file mode 100755
index 0000000..7bebc4a
--- /dev/null
+++ b/scripts/test-miner-detail.sh
@@ -0,0 +1,102 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+
+readonly source_files=(
+ backend/app/frontend/miner_detail_view.py
+ backend/app/frontend/router.py
+ backend/app/frontend/static/app.css
+ backend/app/frontend/static/fleet.js
+ backend/app/frontend/static/miner-detail.js
+ backend/app/frontend/static/repair-center.js
+ backend/app/frontend/views.py
+ backend/app/inventory/repository.py
+ backend/app/inventory/service.py
+ backend/app/locations/models.py
+ backend/app/locations/repository.py
+ backend/app/locations/service.py
+ backend/app/main.py
+ backend/app/miner_detail/__init__.py
+ backend/app/miner_detail/dependencies.py
+ backend/app/miner_detail/models.py
+ backend/app/miner_detail/router.py
+ backend/app/miner_detail/service.py
+ backend/app/repairs/center_repository.py
+ backend/app/repairs/center_service.py
+ backend/tests/test_miner_detail.py
+)
+
+for file in "${source_files[@]}"; do
+ if [[ ! -r "${file}" ]]; then
+ echo "Miner Detail test input is not readable: ${file}" >&2
+ exit 1
+ fi
+ if [[ "$(stat -c '%a' "${file}")" != "644" ]]; then
+ echo "Miner Detail source/test mode must be 0644: ${file}" >&2
+ exit 1
+ fi
+done
+if [[ ! -x scripts/test-miner-detail.sh ]] \
+ || [[ "$(stat -c '%a' scripts/test-miner-detail.sh)" != "755" ]]; then
+ echo "scripts/test-miner-detail.sh must be executable with mode 0755." >&2
+ exit 1
+fi
+echo "Miner Detail file-mode preflight passed."
+
+project_name="${MINEROPS_MINER_DETAIL_TEST_PROJECT:-minerops-miner-detail-$$}"
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file .env.dev
+ -f compose.dev.yaml
+)
+
+cleanup() {
+ status=$?
+ if (( status != 0 )); then
+ echo
+ echo "Miner Detail tests failed. Recent PostgreSQL logs:"
+ "${compose[@]}" logs --no-color --tail 120 postgres || true
+ fi
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+trap cleanup EXIT
+
+if [[ ! -f .env.dev ]]; then
+ echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
+ exit 1
+fi
+
+echo "Starting disposable Miner Detail database..."
+"${compose[@]}" up --detach postgres
+for attempt in $(seq 1 60); do
+ if "${compose[@]}" exec -T postgres sh -ec \
+ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; then
+ break
+ fi
+ if (( attempt == 60 )); then
+ echo "PostgreSQL did not become ready." >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+echo "Building Miner Detail test image..."
+"${compose[@]}" build backend
+"${compose[@]}" run --rm --no-deps backend alembic upgrade head
+
+echo "Running Miner Detail and affected-domain focused tests..."
+"${compose[@]}" run --rm --no-deps \
+ -v "${repo_root}/backend/tests:/app/tests:ro" \
+ backend python -m unittest -v \
+ tests.test_miner_detail \
+ tests.test_frontend_shell \
+ tests.test_inventory_domain \
+ tests.test_inventory_router \
+ tests.test_mine_map \
+ tests.test_repair_center
+
+echo "Miner Detail focused tests passed."
diff --git a/scripts/test-monitoring-trends.sh b/scripts/test-monitoring-trends.sh
new file mode 100755
index 0000000..ea241f4
--- /dev/null
+++ b/scripts/test-monitoring-trends.sh
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+
+readonly source_files=(
+ backend/app/frontend/miner_detail_view.py
+ backend/app/frontend/monitoring_trends_view.py
+ backend/app/frontend/static/app.css
+ backend/app/frontend/static/miner-detail.js
+ backend/app/frontend/static/monitoring-trends.css
+ backend/app/frontend/static/monitoring-trends.js
+ backend/app/frontend/views.py
+ backend/app/identity.py
+ backend/app/main.py
+ backend/app/monitoring/__init__.py
+ backend/app/monitoring/dependencies.py
+ backend/app/monitoring/exceptions.py
+ backend/app/monitoring/frontend.py
+ backend/app/monitoring/models.py
+ backend/app/monitoring/repository.py
+ backend/app/monitoring/router.py
+ backend/app/monitoring/service.py
+ backend/tests/test_monitoring_trends.py
+)
+
+for file in "${source_files[@]}"; do
+ if [[ ! -r "${file}" ]]; then
+ echo "Monitoring Trends test input is not readable: ${file}" >&2
+ exit 1
+ fi
+ if [[ "$(stat -c '%a' "${file}")" != "644" ]]; then
+ echo "Monitoring Trends source/test mode must be 0644: ${file}" >&2
+ exit 1
+ fi
+done
+if [[ ! -x scripts/test-monitoring-trends.sh ]] \
+ || [[ "$(stat -c '%a' scripts/test-monitoring-trends.sh)" != "755" ]]; then
+ echo "scripts/test-monitoring-trends.sh must be executable with mode 0755." >&2
+ exit 1
+fi
+echo "Monitoring Trends file-mode preflight passed."
+
+if [[ ! -f .env.dev ]]; then
+ echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
+ exit 1
+fi
+
+project_name="${MINEROPS_MONITORING_TEST_PROJECT:-minerops-monitoring-$$}"
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file .env.dev
+ -f compose.dev.yaml
+)
+
+cleanup() {
+ status=$?
+ if (( status != 0 )); then
+ echo
+ echo "Monitoring Trends tests failed. Recent PostgreSQL logs:"
+ "${compose[@]}" logs --no-color --tail 120 postgres || true
+ fi
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+trap cleanup EXIT
+
+echo "Starting disposable Monitoring Trends database..."
+"${compose[@]}" up --detach postgres
+for attempt in $(seq 1 60); do
+ if "${compose[@]}" exec -T postgres sh -ec \
+ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; then
+ break
+ fi
+ if (( attempt == 60 )); then
+ echo "PostgreSQL did not become ready." >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+echo "Building Monitoring Trends test image..."
+"${compose[@]}" build backend
+"${compose[@]}" run --rm --no-deps backend alembic upgrade head
+
+echo "Running Monitoring Trends focused tests..."
+"${compose[@]}" run --rm --no-deps \
+ -v "${repo_root}/backend/tests:/app/tests:ro" \
+ backend python -m unittest -v \
+ tests.test_monitoring_trends \
+ tests.test_miner_detail \
+ tests.test_frontend_shell
+
+echo "Monitoring Trends focused tests passed."
diff --git a/scripts/test-repair-center.sh b/scripts/test-repair-center.sh
new file mode 100755
index 0000000..4fda8fa
--- /dev/null
+++ b/scripts/test-repair-center.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+project_name="${MINEROPS_REPAIR_CENTER_TEST_PROJECT:-minerops-repair-center-$$}"
+compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
+cleanup() {
+ status=$?
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+trap cleanup EXIT
+
+[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
+"${compose[@]}" up --detach postgres
+for attempt in $(seq 1 60); do
+ if "${compose[@]}" exec -T postgres sh -ec \
+ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1; then
+ break
+ fi
+ (( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
+ sleep 1
+done
+"${compose[@]}" build backend
+"${compose[@]}" run --rm --no-deps backend alembic upgrade head
+"${compose[@]}" run --rm --no-deps \
+ -v "${repo_root}/backend/tests:/app/tests:ro" \
+ backend python -m unittest -v \
+ tests.test_repair_center \
+ tests.test_repair_timeline \
+ tests.test_return_to_service \
+ tests.test_repair_parts_tracking
diff --git a/scripts/test-repair-parts-tracking-migration.sh b/scripts/test-repair-parts-tracking-migration.sh
new file mode 100755
index 0000000..58a5fc7
--- /dev/null
+++ b/scripts/test-repair-parts-tracking-migration.sh
@@ -0,0 +1,208 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+project_name="${MINEROPS_REPAIR_PARTS_MIGRATION_PROJECT:-minerops-repair-parts-migration-$$}"
+compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
+
+cleanup() {
+ status=$?
+ if (( status != 0 )); then
+ "${compose[@]}" logs --no-color --tail 120 postgres || true
+ fi
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+trap cleanup EXIT
+run_alembic() { "${compose[@]}" run --rm --no-deps backend alembic "$@"; }
+psql_db() {
+ "${compose[@]}" exec -T postgres sh -ec \
+ 'exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" "$@"' sh "$@"
+}
+
+[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
+"${compose[@]}" up --detach postgres
+for attempt in $(seq 1 60); do
+ "${compose[@]}" exec -T postgres sh -ec \
+ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
+ (( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
+ sleep 1
+done
+"${compose[@]}" build backend
+
+echo "Upgrading from Task 004 parent..."
+run_alembic upgrade 0013_control_board_replacement
+[[ "$(run_alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]]
+run_alembic upgrade 0014_repair_parts_tracking
+[[ "$(run_alembic current 2>/dev/null)" == *"0014_repair_parts_tracking"* ]]
+
+psql_db <<'SQL'
+DO $$
+DECLARE component_roles text[];
+DECLARE create_roles text[];
+DECLARE manage_roles text[];
+BEGIN
+ IF to_regclass('public.components') IS NULL OR to_regclass('public.repair_parts') IS NULL THEN
+ RAISE EXCEPTION 'Task 005 tables are missing';
+ END IF;
+ SELECT array_agg(r.name ORDER BY r.name) INTO component_roles
+ FROM role_permissions rp JOIN roles r ON r.id = rp.role_id
+ JOIN permissions p ON p.id = rp.permission_id WHERE p.key = 'components.read';
+ SELECT array_agg(r.name ORDER BY r.name) INTO create_roles
+ FROM role_permissions rp JOIN roles r ON r.id = rp.role_id
+ JOIN permissions p ON p.id = rp.permission_id WHERE p.key = 'components.create';
+ SELECT array_agg(r.name ORDER BY r.name) INTO manage_roles
+ FROM role_permissions rp JOIN roles r ON r.id = rp.role_id
+ JOIN permissions p ON p.id = rp.permission_id WHERE p.key = 'repairs.parts.manage';
+ IF component_roles <> ARRAY['Admin', 'Manager', 'Read Only', 'Technician'] THEN
+ RAISE EXCEPTION 'Unexpected components.read roles: %', component_roles;
+ END IF;
+ IF create_roles <> ARRAY['Admin', 'Manager', 'Technician']
+ OR manage_roles <> ARRAY['Admin', 'Manager', 'Technician'] THEN
+ RAISE EXCEPTION 'Unexpected Task 005 write roles: %, %', create_roles, manage_roles;
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint WHERE conname = 'ck_repair_parts_entry_xor'
+ ) OR NOT EXISTS (
+ SELECT 1 FROM pg_trigger WHERE tgname = 'trg_validate_repair_part_history'
+ ) THEN
+ RAISE EXCEPTION 'Task 005 constraints or triggers are missing';
+ END IF;
+END $$;
+SQL
+
+echo "Testing safe downgrade and re-upgrade..."
+run_alembic downgrade 0013_control_board_replacement
+[[ "$(run_alembic current 2>/dev/null)" == *"0013_control_board_replacement"* ]]
+absent="$(psql_db -Atc "SELECT to_regclass('public.components') IS NULL AND to_regclass('public.repair_parts') IS NULL")"
+[[ "${absent}" == "t" ]]
+run_alembic upgrade 0014_repair_parts_tracking
+
+echo "Testing registry, XOR, snapshots, voids, and immutability..."
+psql_db <<'SQL'
+INSERT INTO users (id, username, display_name, password_hash, role_id)
+SELECT '00000000-0000-0000-0000-000000001501', 'repair-parts-migration',
+ 'Repair Parts Migration', 'unused', id FROM roles WHERE name = 'Admin';
+INSERT INTO miners (
+ id, asset_tag, lifecycle_status, status, first_seen_at, last_seen_at
+) VALUES (
+ '00000000-0000-0000-0000-000000001502', 'PARTS-MINER',
+ 'in_repair', 'unknown', NULL, NULL
+);
+INSERT INTO repairs (id, miner_id, status, reason, opened_by)
+VALUES (
+ '00000000-0000-0000-0000-000000001503',
+ '00000000-0000-0000-0000-000000001502', 'OPEN', 'OTHER',
+ '00000000-0000-0000-0000-000000001501'
+);
+INSERT INTO repair_events (id, repair_id, sequence, event_type, created_by, payload)
+VALUES (
+ '00000000-0000-0000-0000-000000001504',
+ '00000000-0000-0000-0000-000000001503', 1, 'REPAIR_STARTED',
+ '00000000-0000-0000-0000-000000001501',
+ '{"reason":"OTHER","from_lifecycle":"active","to_lifecycle":"in_repair"}'
+);
+INSERT INTO components (
+ id, component_type, asset_id, manufacturer, manufacturer_serial, created_by
+) VALUES (
+ '00000000-0000-4000-8000-000000001505', 'hashboard', 'HB-005',
+ 'MicroBT', 'SER-005', '00000000-0000-0000-0000-000000001501'
+);
+INSERT INTO repair_events (id, repair_id, sequence, event_type, created_by, payload)
+VALUES (
+ '00000000-0000-0000-0000-000000001506',
+ '00000000-0000-0000-0000-000000001503', 2, 'REPAIR_PART_RECORDED',
+ '00000000-0000-0000-0000-000000001501',
+ '{"repair_part_id":"00000000-0000-4000-8000-000000001507","entry_kind":"tracked_component","component_id":"00000000-0000-4000-8000-000000001505","component_type":"hashboard","component_asset_id":"HB-005","component_manufacturer":"MicroBT","component_model":null,"component_manufacturer_serial":"SER-005","notes":null}'
+);
+INSERT INTO repair_parts (
+ id, repair_id, component_id, component_type_snapshot,
+ component_asset_id_snapshot, component_manufacturer_snapshot,
+ component_model_snapshot, component_manufacturer_serial_snapshot,
+ recorded_by, recorded_at, recorded_event_id
+) SELECT
+ '00000000-0000-4000-8000-000000001507',
+ '00000000-0000-0000-0000-000000001503',
+ '00000000-0000-4000-8000-000000001505', 'hashboard', 'HB-005',
+ 'MicroBT', NULL, 'SER-005', '00000000-0000-0000-0000-000000001501',
+ created_at, id FROM repair_events
+ WHERE id = '00000000-0000-0000-0000-000000001506';
+
+UPDATE components SET manufacturer = 'Updated Manufacturer'
+WHERE id = '00000000-0000-4000-8000-000000001505';
+DO $$
+BEGIN
+ IF (SELECT component_manufacturer_snapshot FROM repair_parts
+ WHERE id = '00000000-0000-4000-8000-000000001507') <> 'MicroBT' THEN
+ RAISE EXCEPTION 'Tracked snapshot changed with live metadata';
+ END IF;
+ BEGIN
+ INSERT INTO repair_parts (
+ repair_id, consumable_description, quantity, unit, component_id,
+ recorded_by, recorded_event_id
+ ) VALUES (
+ '00000000-0000-0000-0000-000000001503', 'invalid', 1, 'each',
+ '00000000-0000-4000-8000-000000001505',
+ '00000000-0000-0000-0000-000000001501',
+ '00000000-0000-0000-0000-000000001506'
+ );
+ RAISE EXCEPTION 'XOR violation was accepted';
+ EXCEPTION WHEN check_violation OR raise_exception THEN NULL;
+ END;
+END $$;
+
+INSERT INTO repair_events (id, repair_id, sequence, event_type, created_by, payload)
+VALUES (
+ '00000000-0000-0000-0000-000000001508',
+ '00000000-0000-0000-0000-000000001503', 3, 'REPAIR_PART_VOIDED',
+ '00000000-0000-0000-0000-000000001501',
+ '{"repair_part_id":"00000000-0000-4000-8000-000000001507","reason":"Wrong repair"}'
+);
+UPDATE repair_parts SET
+ voided_by = '00000000-0000-0000-0000-000000001501',
+ voided_at = (SELECT created_at FROM repair_events WHERE id = '00000000-0000-0000-0000-000000001508'),
+ void_reason = 'Wrong repair',
+ void_event_id = '00000000-0000-0000-0000-000000001508'
+WHERE id = '00000000-0000-4000-8000-000000001507';
+
+DO $$
+BEGIN
+ BEGIN
+ UPDATE repair_parts SET void_reason = 'Changed'
+ WHERE id = '00000000-0000-4000-8000-000000001507';
+ RAISE EXCEPTION 'Second void mutation was accepted';
+ EXCEPTION WHEN raise_exception THEN NULL;
+ END;
+ BEGIN
+ DELETE FROM repair_parts WHERE id = '00000000-0000-4000-8000-000000001507';
+ RAISE EXCEPTION 'Repair-part deletion was accepted';
+ EXCEPTION WHEN raise_exception THEN NULL;
+ END;
+ BEGIN
+ DELETE FROM repair_events WHERE id = '00000000-0000-0000-0000-000000001508';
+ RAISE EXCEPTION 'Repair-event deletion was accepted';
+ EXCEPTION WHEN raise_exception THEN NULL;
+ END;
+END $$;
+SQL
+
+if downgrade_output="$(run_alembic downgrade 0013_control_board_replacement 2>&1)"; then
+ echo "Task 005 downgrade unexpectedly destroyed history" >&2
+ exit 1
+fi
+[[ "${downgrade_output}" == *"Cannot downgrade Repair Parts Tracking"* ]]
+[[ "$(run_alembic current 2>/dev/null)" == *"0014_repair_parts_tracking"* ]]
+
+echo "Testing empty database through head..."
+"${compose[@]}" exec -T postgres sh -ec \
+ 'createdb -U "$POSTGRES_USER" minerops_task005_empty'
+"${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_task005_empty \
+ backend alembic upgrade head
+empty_revision="$(
+ "${compose[@]}" exec -T postgres sh -ec \
+ 'psql -U "$POSTGRES_USER" -d minerops_task005_empty -Atc "SELECT version_num FROM alembic_version"'
+)"
+[[ "${empty_revision}" == "0014_repair_parts_tracking" ]]
+
+echo "Repair Parts Tracking migration tests passed."
diff --git a/scripts/test-repair-timeline-migration.sh b/scripts/test-repair-timeline-migration.sh
new file mode 100755
index 0000000..78c47a9
--- /dev/null
+++ b/scripts/test-repair-timeline-migration.sh
@@ -0,0 +1,171 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+
+project_name="${MINEROPS_TIMELINE_MIGRATION_PROJECT:-minerops-timeline-migration-$$}"
+empty_database="minerops_timeline_empty"
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file .env.dev
+ -f compose.dev.yaml
+)
+
+cleanup() {
+ status=$?
+ if (( status != 0 )); then
+ echo
+ echo "Repair Timeline migration test failed. Recent PostgreSQL logs:"
+ "${compose[@]}" logs --no-color --tail 120 postgres || true
+ fi
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+
+trap cleanup EXIT
+
+if [[ ! -f .env.dev ]]; then
+ echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
+ exit 1
+fi
+
+run_alembic() {
+ "${compose[@]}" run --rm --no-deps backend alembic "$@"
+}
+
+echo "Starting disposable PostgreSQL..."
+"${compose[@]}" up --detach postgres
+for attempt in $(seq 1 60); do
+ if "${compose[@]}" exec -T postgres sh -ec \
+ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
+ >/dev/null 2>&1; then
+ break
+ fi
+ if (( attempt == 60 )); then
+ echo "PostgreSQL did not become ready." >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+echo "Building migration image..."
+"${compose[@]}" build backend
+
+echo "Upgrading the previous head to Repair Timeline..."
+run_alembic upgrade 0010_begin_repair
+previous_revision="$(run_alembic current 2>/dev/null)"
+[[ "${previous_revision}" == *"0010_begin_repair"* ]]
+run_alembic upgrade 0011_repair_timeline
+current_revision="$(run_alembic current 2>/dev/null)"
+[[ "${current_revision}" == *"0011_repair_timeline"* ]]
+
+echo "Checking repairs.read permission and role assignments..."
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
+ <<'SQL'
+DO $$
+DECLARE
+ v_read_roles TEXT[];
+ v_create_roles TEXT[];
+BEGIN
+ IF to_regclass('public.repairs') IS NULL
+ OR to_regclass('public.repair_events') IS NULL THEN
+ RAISE EXCEPTION 'Repair tables changed during timeline migration';
+ END IF;
+
+ IF (SELECT count(*) FROM permissions WHERE key = 'repairs.read') <> 1 THEN
+ RAISE EXCEPTION 'repairs.read permission was not registered once';
+ END IF;
+
+ SELECT array_agg(roles.name ORDER BY roles.name)
+ INTO v_read_roles
+ 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.read';
+ IF v_read_roles <> ARRAY[
+ 'Admin', 'Manager', 'Read Only', 'Technician'
+ ] THEN
+ RAISE EXCEPTION 'Unexpected repairs.read role matrix: %', v_read_roles;
+ END IF;
+
+ SELECT array_agg(roles.name ORDER BY roles.name)
+ INTO v_create_roles
+ 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';
+ IF v_create_roles <> ARRAY['Admin', 'Manager', 'Technician'] THEN
+ RAISE EXCEPTION
+ 'repairs.create role matrix changed: %',
+ v_create_roles;
+ END IF;
+
+ INSERT INTO permissions (key, description)
+ VALUES ('repairs.read', 'View repair details and timeline events')
+ 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 = 'repairs.read'
+ WHERE roles.name IN ('Admin', 'Manager', 'Technician', 'Read Only')
+ ON CONFLICT DO NOTHING;
+
+ IF (SELECT count(*) FROM permissions WHERE key = 'repairs.read') <> 1
+ OR (
+ SELECT count(*)
+ FROM role_permissions
+ JOIN permissions
+ ON permissions.id = role_permissions.permission_id
+ WHERE permissions.key = 'repairs.read'
+ ) <> 4 THEN
+ RAISE EXCEPTION 'repairs.read seeding is not idempotent';
+ END IF;
+END
+$$;
+SQL
+
+echo "Downgrading and re-upgrading Repair Timeline..."
+run_alembic downgrade 0010_begin_repair
+downgrade_state="$(
+ "${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At' \
+ <<'SQL'
+SELECT
+ NOT EXISTS (
+ SELECT 1 FROM permissions WHERE key = 'repairs.read'
+ )
+ AND EXISTS (
+ SELECT 1 FROM permissions WHERE key = 'repairs.create'
+ )
+ AND to_regclass('public.repairs') IS NOT NULL
+ AND to_regclass('public.repair_events') IS NOT NULL;
+SQL
+)"
+[[ "${downgrade_state}" == "t" ]]
+downgraded_revision="$(run_alembic current 2>/dev/null)"
+[[ "${downgraded_revision}" == *"0010_begin_repair"* ]]
+
+run_alembic upgrade 0011_repair_timeline
+reupgraded_revision="$(run_alembic current 2>/dev/null)"
+[[ "${reupgraded_revision}" == *"0011_repair_timeline"* ]]
+
+echo "Migrating an empty database from base through Repair Timeline..."
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres \
+ -c "CREATE DATABASE minerops_timeline_empty"' >/dev/null
+"${compose[@]}" run --rm --no-deps \
+ -e "POSTGRES_DB=${empty_database}" \
+ backend alembic upgrade 0011_repair_timeline
+
+empty_revision="$(
+ "${compose[@]}" run --rm --no-deps \
+ -e "POSTGRES_DB=${empty_database}" \
+ backend alembic current 2>/dev/null
+)"
+[[ "${empty_revision}" == *"0011_repair_timeline"* ]]
+
+echo "Repair Timeline migration test passed."
diff --git a/scripts/test-repair-timeline.sh b/scripts/test-repair-timeline.sh
new file mode 100755
index 0000000..402792c
--- /dev/null
+++ b/scripts/test-repair-timeline.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+
+project_name="${MINEROPS_TIMELINE_TEST_PROJECT:-minerops-repair-timeline-$$}"
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file .env.dev
+ -f compose.dev.yaml
+)
+
+cleanup() {
+ status=$?
+ if (( status != 0 )); then
+ echo
+ echo "Repair Timeline tests failed. Recent PostgreSQL logs:"
+ "${compose[@]}" logs --no-color --tail 120 postgres || true
+ fi
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+
+trap cleanup EXIT
+
+if [[ ! -f .env.dev ]]; then
+ echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
+ exit 1
+fi
+
+echo "Starting disposable Repair Timeline database..."
+"${compose[@]}" up --detach postgres
+for attempt in $(seq 1 60); do
+ if "${compose[@]}" exec -T postgres sh -ec \
+ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
+ >/dev/null 2>&1; then
+ break
+ fi
+ if (( attempt == 60 )); then
+ echo "PostgreSQL did not become ready." >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+echo "Building Repair Timeline test image..."
+"${compose[@]}" build backend
+
+echo "Migrating disposable database..."
+"${compose[@]}" run --rm --no-deps backend alembic upgrade head
+
+echo "Running Repair Timeline repository, service, and router tests..."
+"${compose[@]}" run --rm --no-deps \
+ -v "${repo_root}/backend/tests:/app/tests:ro" \
+ backend python -m unittest -v tests.test_repair_timeline
+
+echo "Repair Timeline tests passed."
diff --git a/scripts/test-repairs-domain.sh b/scripts/test-repairs-domain.sh
new file mode 100755
index 0000000..57ef8d9
--- /dev/null
+++ b/scripts/test-repairs-domain.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+
+project_name="${MINEROPS_REPAIR_TEST_PROJECT:-minerops-repair-domain-$$}"
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file .env.dev
+ -f compose.dev.yaml
+)
+
+cleanup() {
+ status=$?
+ if (( status != 0 )); then
+ echo
+ echo "Repairs domain tests failed. Recent PostgreSQL logs:"
+ "${compose[@]}" logs --no-color --tail 120 postgres || true
+ fi
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+
+trap cleanup EXIT
+
+if [[ ! -f .env.dev ]]; then
+ echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
+ exit 1
+fi
+
+echo "Starting disposable Repairs database..."
+"${compose[@]}" up --detach postgres
+for attempt in $(seq 1 60); do
+ if "${compose[@]}" exec -T postgres sh -ec \
+ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
+ >/dev/null 2>&1; then
+ break
+ fi
+ if (( attempt == 60 )); then
+ echo "PostgreSQL did not become ready." >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+echo "Building Repairs test image..."
+"${compose[@]}" build backend
+
+echo "Migrating disposable database..."
+"${compose[@]}" run --rm --no-deps backend alembic upgrade head
+
+echo "Running Repairs repository, service, and router tests..."
+"${compose[@]}" run --rm --no-deps \
+ -v "${repo_root}/backend/tests:/app/tests:ro" \
+ backend python -m unittest -v \
+ tests.test_repairs_domain \
+ tests.test_repairs_router
+
+echo "Repairs domain tests passed."
diff --git a/scripts/test-return-to-service-migration.sh b/scripts/test-return-to-service-migration.sh
new file mode 100755
index 0000000..ab4ea0d
--- /dev/null
+++ b/scripts/test-return-to-service-migration.sh
@@ -0,0 +1,295 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+
+project_name="${MINEROPS_RETURN_MIGRATION_PROJECT:-minerops-return-migration-$$}"
+empty_database="minerops_return_empty"
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file .env.dev
+ -f compose.dev.yaml
+)
+
+cleanup() {
+ status=$?
+ if (( status != 0 )); then
+ echo
+ echo "Return to Service migration test failed. Recent PostgreSQL logs:"
+ "${compose[@]}" logs --no-color --tail 160 postgres || true
+ fi
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+
+trap cleanup EXIT
+
+if [[ ! -f .env.dev ]]; then
+ echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
+ exit 1
+fi
+
+run_alembic() {
+ "${compose[@]}" run --rm --no-deps backend alembic "$@"
+}
+
+echo "Starting disposable PostgreSQL..."
+"${compose[@]}" up --detach postgres
+for attempt in $(seq 1 60); do
+ if "${compose[@]}" exec -T postgres sh -ec \
+ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
+ >/dev/null 2>&1; then
+ break
+ fi
+ if (( attempt == 60 )); then
+ echo "PostgreSQL did not become ready." >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+echo "Building migration image..."
+"${compose[@]}" build backend
+
+echo "Preparing previous head and truthful legacy completion data..."
+run_alembic upgrade 0011_repair_timeline
+previous_revision="$(run_alembic current 2>/dev/null)"
+[[ "${previous_revision}" == *"0011_repair_timeline"* ]]
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
+INSERT INTO users (
+ id, username, display_name, password_hash, role_id
+)
+SELECT
+ '00000000-0000-0000-0000-000000001301',
+ 'return-migration-test',
+ 'Return Migration Test',
+ 'not-used',
+ id
+FROM roles
+WHERE name = 'Admin';
+
+INSERT INTO miners (
+ id, asset_tag, status, lifecycle_status, first_seen_at, last_seen_at
+) VALUES (
+ '00000000-0000-0000-0000-000000001302',
+ 'RETURN-MIGRATION-LEGACY',
+ 'unknown',
+ 'active',
+ NULL,
+ NULL
+);
+
+INSERT INTO repairs (
+ id, miner_id, status, reason, opened_by, completed_at, completed_by
+) VALUES (
+ '00000000-0000-0000-0000-000000001303',
+ '00000000-0000-0000-0000-000000001302',
+ 'COMPLETED',
+ 'OTHER',
+ '00000000-0000-0000-0000-000000001301',
+ now(),
+ '00000000-0000-0000-0000-000000001301'
+);
+SQL
+
+echo "Upgrading previous head to Return to Service..."
+run_alembic upgrade 0012_return_to_service
+current_revision="$(run_alembic current 2>/dev/null)"
+[[ "${current_revision}" == *"0012_return_to_service"* ]]
+
+echo "Checking schema, permission matrix, constraints, and legacy preservation..."
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
+DO $$
+DECLARE
+ v_roles TEXT[];
+ v_constraint_count INTEGER;
+BEGIN
+ IF (
+ SELECT count(*)
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'repairs'
+ AND column_name IN ('completion_summary', 'resulting_lifecycle')
+ ) <> 2 THEN
+ RAISE EXCEPTION 'Return to Service columns are missing';
+ END IF;
+
+ IF EXISTS (
+ SELECT 1
+ FROM repairs
+ WHERE id = '00000000-0000-0000-0000-000000001303'
+ AND (
+ completion_summary IS NOT NULL
+ OR resulting_lifecycle IS NOT NULL
+ )
+ ) THEN
+ RAISE EXCEPTION 'Legacy completion metadata was fabricated';
+ END IF;
+
+ IF (SELECT count(*) FROM permissions WHERE key = 'repairs.complete') <> 1 THEN
+ RAISE EXCEPTION 'repairs.complete permission was not registered once';
+ END IF;
+
+ SELECT array_agg(roles.name ORDER BY roles.name)
+ INTO v_roles
+ 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.complete';
+ IF v_roles <> ARRAY['Admin', 'Manager', 'Technician'] THEN
+ RAISE EXCEPTION 'Unexpected repairs.complete role matrix: %', v_roles;
+ END IF;
+
+ SELECT count(*)
+ INTO v_constraint_count
+ FROM pg_constraint
+ WHERE conrelid IN ('repairs'::regclass, 'repair_events'::regclass)
+ AND conname IN (
+ 'ck_repairs_resulting_lifecycle',
+ 'ck_repairs_completion_summary',
+ 'ck_repairs_completion_metadata_state',
+ 'ck_repair_events_type',
+ 'ck_repair_events_payload_shape'
+ );
+ IF v_constraint_count <> 5 THEN
+ RAISE EXCEPTION 'Expected Return to Service constraints are missing';
+ END IF;
+
+ IF (
+ SELECT convalidated
+ FROM pg_constraint
+ WHERE conrelid = 'repairs'::regclass
+ AND conname = 'ck_repairs_completion_metadata_state'
+ ) THEN
+ RAISE EXCEPTION 'Legacy completion compatibility constraint was unexpectedly validated';
+ END IF;
+
+ BEGIN
+ INSERT INTO repairs (
+ miner_id, status, reason, opened_by, completed_at, completed_by
+ ) VALUES (
+ '00000000-0000-0000-0000-000000001302',
+ 'COMPLETED',
+ 'OTHER',
+ '00000000-0000-0000-0000-000000001301',
+ now(),
+ '00000000-0000-0000-0000-000000001301'
+ );
+ RAISE EXCEPTION 'New completed repair without resulting lifecycle was accepted';
+ EXCEPTION
+ WHEN check_violation THEN NULL;
+ END;
+END
+$$;
+
+INSERT INTO permissions (key, description)
+VALUES ('repairs.complete', 'Return repaired miners to service')
+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 = 'repairs.complete'
+WHERE roles.name IN ('Admin', 'Manager', 'Technician')
+ON CONFLICT DO NOTHING;
+
+DO $$
+BEGIN
+ IF (SELECT count(*) FROM permissions WHERE key = 'repairs.complete') <> 1
+ OR (
+ SELECT count(*)
+ FROM role_permissions
+ JOIN permissions ON permissions.id = role_permissions.permission_id
+ WHERE permissions.key = 'repairs.complete'
+ ) <> 3 THEN
+ RAISE EXCEPTION 'repairs.complete seeding is not idempotent';
+ END IF;
+END
+$$;
+SQL
+
+echo "Downgrading and re-upgrading without new completion history..."
+run_alembic downgrade 0011_repair_timeline
+downgrade_state="$(
+ "${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At' <<'SQL'
+SELECT
+ NOT EXISTS (
+ SELECT 1 FROM permissions WHERE key = 'repairs.complete'
+ )
+ AND EXISTS (
+ SELECT 1 FROM permissions WHERE key = 'repairs.read'
+ )
+ AND NOT EXISTS (
+ SELECT 1
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'repairs'
+ AND column_name IN ('completion_summary', 'resulting_lifecycle')
+ )
+ AND EXISTS (
+ SELECT 1
+ FROM repairs
+ WHERE id = '00000000-0000-0000-0000-000000001303'
+ AND status = 'COMPLETED'
+ AND completed_at IS NOT NULL
+ AND completed_by IS NOT NULL
+ );
+SQL
+)"
+[[ "${downgrade_state}" == "t" ]]
+downgraded_revision="$(run_alembic current 2>/dev/null)"
+[[ "${downgraded_revision}" == *"0011_repair_timeline"* ]]
+run_alembic upgrade 0012_return_to_service
+reupgraded_revision="$(run_alembic current 2>/dev/null)"
+[[ "${reupgraded_revision}" == *"0012_return_to_service"* ]]
+
+echo "Checking downgrade refusal after authoritative completion history..."
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
+UPDATE repairs
+SET completion_summary = 'Migration completion',
+ resulting_lifecycle = 'active'
+WHERE id = '00000000-0000-0000-0000-000000001303';
+
+INSERT INTO repair_events (
+ repair_id, sequence, event_type, created_by, payload
+) VALUES (
+ '00000000-0000-0000-0000-000000001303',
+ 1,
+ 'REPAIR_COMPLETED',
+ '00000000-0000-0000-0000-000000001301',
+ '{
+ "previous_miner_lifecycle":"in_repair",
+ "resulting_miner_lifecycle":"active",
+ "completion_summary":"Migration completion"
+ }'::jsonb
+);
+SQL
+if downgrade_error="$(run_alembic downgrade 0011_repair_timeline 2>&1)"; then
+ echo "Downgrade discarded Return to Service history." >&2
+ exit 1
+fi
+[[ "${downgrade_error}" == *"would lose authoritative history"* ]]
+failed_revision="$(run_alembic current 2>/dev/null)"
+[[ "${failed_revision}" == *"0012_return_to_service"* ]]
+
+echo "Migrating an empty database from base through head..."
+"${compose[@]}" exec -T postgres sh -ec \
+ 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres \
+ -c "CREATE DATABASE minerops_return_empty"' >/dev/null
+"${compose[@]}" run --rm --no-deps \
+ -e "POSTGRES_DB=${empty_database}" \
+ backend alembic upgrade 0012_return_to_service
+empty_revision="$(
+ "${compose[@]}" run --rm --no-deps \
+ -e "POSTGRES_DB=${empty_database}" \
+ backend alembic current 2>/dev/null
+)"
+[[ "${empty_revision}" == *"0012_return_to_service"* ]]
+
+echo "Return to Service migration test passed."
diff --git a/scripts/test-return-to-service.sh b/scripts/test-return-to-service.sh
new file mode 100755
index 0000000..9a8ce91
--- /dev/null
+++ b/scripts/test-return-to-service.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${repo_root}"
+
+project_name="${MINEROPS_RETURN_SERVICE_TEST_PROJECT:-minerops-return-service-$$}"
+compose=(
+ docker compose
+ --project-name "${project_name}"
+ --env-file .env.dev
+ -f compose.dev.yaml
+)
+
+cleanup() {
+ status=$?
+ if (( status != 0 )); then
+ echo
+ echo "Return to Service tests failed. Recent PostgreSQL logs:"
+ "${compose[@]}" logs --no-color --tail 120 postgres || true
+ fi
+ "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
+ exit "${status}"
+}
+
+trap cleanup EXIT
+
+if [[ ! -f .env.dev ]]; then
+ echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
+ exit 1
+fi
+
+echo "Starting disposable Return to Service database..."
+"${compose[@]}" up --detach postgres
+for attempt in $(seq 1 60); do
+ if "${compose[@]}" exec -T postgres sh -ec \
+ 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
+ >/dev/null 2>&1; then
+ break
+ fi
+ if (( attempt == 60 )); then
+ echo "PostgreSQL did not become ready." >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+echo "Building Return to Service test image..."
+"${compose[@]}" build backend
+
+echo "Migrating disposable database..."
+"${compose[@]}" run --rm --no-deps backend alembic upgrade head
+
+echo "Running Return to Service repository, service, and router tests..."
+"${compose[@]}" run --rm --no-deps \
+ -v "${repo_root}/backend/tests:/app/tests:ro" \
+ backend python -m unittest -v tests.test_return_to_service
+
+echo "Return to Service tests passed."
diff --git a/sql/development-demo.sql b/sql/development-demo.sql
new file mode 100644
index 0000000..274de11
--- /dev/null
+++ b/sql/development-demo.sql
@@ -0,0 +1,501 @@
+\set ON_ERROR_STOP on
+
+BEGIN;
+SET CONSTRAINTS ALL DEFERRED;
+SELECT set_config('minerops.seed_environment', :'app_env', true);
+
+DO $$
+BEGIN
+ IF current_setting('minerops.seed_environment', true) <> 'development' THEN
+ RAISE EXCEPTION 'MinerOps demo seed is restricted to APP_ENV=development';
+ END IF;
+ IF current_database() !~* '(^|_)(dev|development|test)(_|$)' THEN
+ RAISE EXCEPTION 'MinerOps demo seed requires a development or test database name; got %', current_database();
+ END IF;
+END $$;
+
+CREATE TEMP TABLE demo_seed_context ON COMMIT DROP AS
+SELECT users.id AS actor_id
+FROM users
+JOIN roles ON roles.id = users.role_id
+WHERE users.is_active AND roles.name = 'Admin'
+ORDER BY users.created_at, users.id
+LIMIT 1;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM demo_seed_context) THEN
+ RAISE EXCEPTION 'Create an active Admin account before seeding MinerOps demo data';
+ END IF;
+END $$;
+
+INSERT INTO sites (id, name, description)
+VALUES (
+ 'd0000000-0000-4000-8000-000000000001',
+ 'Demo Mining Site',
+ 'Synthetic development-only MinerOps fixture. Never use as production data.'
+)
+ON CONFLICT (id) DO UPDATE SET
+ name = EXCLUDED.name,
+ description = EXCLUDED.description,
+ updated_at = now();
+
+INSERT INTO rooms (id, site_id, name, description, sort_order)
+VALUES
+ (
+ 'd0000000-0000-4000-8000-000000000101',
+ 'd0000000-0000-4000-8000-000000000001',
+ 'Demo Unit A',
+ 'Synthetic room with racks A1 through A3.',
+ 10
+ ),
+ (
+ 'd0000000-0000-4000-8000-000000000102',
+ 'd0000000-0000-4000-8000-000000000001',
+ 'Demo Unit B',
+ 'Synthetic room with racks B1 and B2.',
+ 20
+ )
+ON CONFLICT (id) DO UPDATE SET
+ site_id = EXCLUDED.site_id,
+ name = EXCLUDED.name,
+ description = EXCLUDED.description,
+ sort_order = EXCLUDED.sort_order,
+ updated_at = now();
+
+WITH demo_miners AS (
+ SELECT
+ i,
+ ('d1000000-0000-4000-8000-' || lpad(i::text, 12, '0'))::uuid AS id,
+ CASE
+ WHEN i <= 18 THEN 'd0000000-0000-4000-8000-000000000101'::uuid
+ WHEN i <= 27 THEN 'd0000000-0000-4000-8000-000000000102'::uuid
+ ELSE NULL
+ END AS room_id,
+ (ARRAY['M30S', 'M30S+', 'M50', 'M50S', 'M60', 'M60S'])[((i - 1) % 6) + 1] AS model,
+ format('02:00:00:00:00:%s', upper(lpad(to_hex(i), 2, '0'))) AS mac,
+ CASE
+ WHEN i IN (21, 22, 23) THEN 'in_repair'
+ WHEN i IN (25, 26, 29) THEN 'spare'
+ WHEN i = 28 THEN 'retired'
+ ELSE 'active'
+ END AS lifecycle_status,
+ CASE
+ WHEN i IN (6, 12, 21, 22, 23, 24) THEN 'offline'
+ WHEN i IN (5, 10, 20, 25, 28, 29, 30) THEN 'unknown'
+ ELSE 'online'
+ END AS runtime_status,
+ CASE
+ WHEN i IN (5, 10, 20, 25, 28, 29, 30) THEN NULL
+ WHEN i = 19 THEN now() - interval '3 days'
+ WHEN i IN (21, 22, 23) THEN now() - make_interval(hours => i - 17)
+ ELSE now() - make_interval(mins => i * 3)
+ END AS observed_at
+ FROM generate_series(1, 30) AS series(i)
+)
+INSERT INTO miners (
+ id, site_id, room_id, asset_tag, current_mac, current_ip, hostname,
+ miner_sn, power_sn, control_board_sn, model, miner_type, api_version,
+ firmware_version, platform, chip, status, lifecycle_status, notes,
+ first_seen_at, last_seen_at, created_at, updated_at
+)
+SELECT
+ id,
+ 'd0000000-0000-4000-8000-000000000001',
+ room_id,
+ format('DEMO-MIN-%s', lpad(i::text, 3, '0')),
+ mac,
+ ('192.0.2.' || i)::inet,
+ format('demo-miner-%s', lpad(i::text, 3, '0')),
+ format('DEMO-SERIAL-%s', lpad(i::text, 3, '0')),
+ format('DEMO-PSU-%s', lpad(i::text, 3, '0')),
+ format('DEMO-CB-%s', lpad(i::text, 3, '0')),
+ model,
+ 'WhatsMiner',
+ '3.2-demo',
+ format('DEMO-FW-2026.%s', ((i - 1) % 4) + 1),
+ 'Demo Platform',
+ 'Synthetic ASIC',
+ runtime_status,
+ lifecycle_status,
+ 'Synthetic development-only miner. No real fleet identity or measurements.',
+ now() - make_interval(days => 120 - i),
+ observed_at,
+ now() - make_interval(days => 120 - i),
+ now()
+FROM demo_miners
+ON CONFLICT (id) DO UPDATE SET
+ site_id = EXCLUDED.site_id,
+ room_id = EXCLUDED.room_id,
+ asset_tag = EXCLUDED.asset_tag,
+ current_mac = EXCLUDED.current_mac,
+ current_ip = EXCLUDED.current_ip,
+ hostname = EXCLUDED.hostname,
+ miner_sn = EXCLUDED.miner_sn,
+ power_sn = EXCLUDED.power_sn,
+ control_board_sn = EXCLUDED.control_board_sn,
+ model = EXCLUDED.model,
+ miner_type = EXCLUDED.miner_type,
+ api_version = EXCLUDED.api_version,
+ firmware_version = EXCLUDED.firmware_version,
+ platform = EXCLUDED.platform,
+ chip = EXCLUDED.chip,
+ status = EXCLUDED.status,
+ lifecycle_status = EXCLUDED.lifecycle_status,
+ notes = EXCLUDED.notes,
+ first_seen_at = EXCLUDED.first_seen_at,
+ last_seen_at = EXCLUDED.last_seen_at,
+ updated_at = now();
+
+WITH room_a AS (
+ SELECT
+ n AS ordinal,
+ 'd0000000-0000-4000-8000-000000000101'::uuid AS room_id,
+ 'Demo Unit A'::text AS room_name,
+ format('A%s', ((n - 1) / 10) + 1) AS rack_name,
+ ((n - 1) % 10) / 5 + 1 AS shelf_index,
+ ((n - 1) % 5) + 1 AS position_index,
+ CASE WHEN n <= 18 THEN n ELSE NULL END AS miner_number
+ FROM generate_series(1, 30) AS positions(n)
+),
+room_b AS (
+ SELECT
+ n + 30 AS ordinal,
+ 'd0000000-0000-4000-8000-000000000102'::uuid AS room_id,
+ 'Demo Unit B'::text AS room_name,
+ format('B%s', ((n - 1) / 10) + 1) AS rack_name,
+ ((n - 1) % 10) / 5 + 1 AS shelf_index,
+ ((n - 1) % 5) + 1 AS position_index,
+ CASE WHEN n <= 9 THEN n + 18 ELSE NULL END AS miner_number
+ FROM generate_series(1, 20) AS positions(n)
+),
+positions AS (
+ SELECT * FROM room_a
+ UNION ALL
+ SELECT * FROM room_b
+)
+INSERT INTO location_positions (
+ id, room_id, room_name, rack_name, shelf_name, shelf_index,
+ position_index, position_label, position_kind, position_status,
+ expected_mac, miner_id, notes, source_file, source_sheet, created_at, updated_at
+)
+SELECT
+ ('d2000000-0000-4000-8000-' || lpad(ordinal::text, 12, '0'))::uuid,
+ room_id,
+ room_name,
+ rack_name,
+ format('Shelf %s', shelf_index),
+ shelf_index,
+ position_index,
+ format('%s-S%s-P%s', rack_name, shelf_index, position_index),
+ 'miner_slot',
+ CASE WHEN miner_number IS NULL THEN 'available' ELSE 'occupied' END,
+ CASE WHEN ordinal = 50 THEN '02:00:00:00:00:FE' ELSE NULL END,
+ CASE WHEN miner_number IS NULL THEN NULL ELSE
+ ('d1000000-0000-4000-8000-' || lpad(miner_number::text, 12, '0'))::uuid
+ END,
+ 'Synthetic development-only position.',
+ 'MinerOps demo seed',
+ 'Synthetic layout',
+ now(),
+ now()
+FROM positions
+ON CONFLICT (id) DO UPDATE SET
+ room_id = EXCLUDED.room_id,
+ room_name = EXCLUDED.room_name,
+ rack_name = EXCLUDED.rack_name,
+ shelf_name = EXCLUDED.shelf_name,
+ shelf_index = EXCLUDED.shelf_index,
+ position_index = EXCLUDED.position_index,
+ position_label = EXCLUDED.position_label,
+ position_kind = EXCLUDED.position_kind,
+ position_status = EXCLUDED.position_status,
+ expected_mac = EXCLUDED.expected_mac,
+ miner_id = EXCLUDED.miner_id,
+ notes = EXCLUDED.notes,
+ source_file = EXCLUDED.source_file,
+ source_sheet = EXCLUDED.source_sheet,
+ updated_at = now();
+
+WITH demo_miners AS (
+ SELECT
+ i,
+ ('d1000000-0000-4000-8000-' || lpad(i::text, 12, '0'))::uuid AS miner_id,
+ CASE
+ WHEN i <= 18 THEN i
+ WHEN i <= 27 THEN i + 12
+ ELSE NULL
+ END AS position_ordinal
+ FROM generate_series(1, 30) AS series(i)
+)
+INSERT INTO miner_location_states (
+ miner_id, asset_state, location_position_id, state_notes,
+ updated_by_user_id, updated_at
+)
+SELECT
+ miner_id,
+ CASE
+ WHEN i IN (21, 22, 23) THEN 'repair'
+ WHEN i IN (25, 26, 29) THEN 'storage'
+ WHEN i = 28 THEN 'decommissioned'
+ WHEN position_ordinal IS NULL THEN 'waiting_placement'
+ ELSE 'shelved'
+ END,
+ CASE WHEN position_ordinal IS NULL THEN NULL ELSE
+ ('d2000000-0000-4000-8000-' || lpad(position_ordinal::text, 12, '0'))::uuid
+ END,
+ 'Synthetic development-only location state.',
+ (SELECT actor_id FROM demo_seed_context),
+ now()
+FROM demo_miners
+ON CONFLICT (miner_id) DO UPDATE SET
+ asset_state = EXCLUDED.asset_state,
+ location_position_id = EXCLUDED.location_position_id,
+ state_notes = EXCLUDED.state_notes,
+ updated_by_user_id = EXCLUDED.updated_by_user_id,
+ updated_at = now();
+
+WITH telemetry AS (
+ SELECT
+ i,
+ ('d1000000-0000-4000-8000-' || lpad(i::text, 12, '0'))::uuid AS miner_id,
+ CASE
+ WHEN i = 19 THEN now() - interval '3 days'
+ WHEN i IN (21, 22, 23) THEN now() - make_interval(hours => i - 17)
+ ELSE now() - make_interval(mins => i * 3)
+ END AS observed_at,
+ CASE ((i - 1) % 6)
+ WHEN 0 THEN 90
+ WHEN 1 THEN 100
+ WHEN 2 THEN 112
+ WHEN 3 THEN 126
+ WHEN 4 THEN 170
+ ELSE 186
+ END::numeric AS nominal_ths,
+ i IN (6, 12, 21, 22, 23, 24) AS stopped
+ FROM generate_series(1, 30) AS series(i)
+ WHERE i NOT IN (5, 10, 20, 25, 28, 29, 30)
+)
+INSERT INTO miner_status_snapshots (
+ id, miner_id, observed_ip, ip_address, mac_address, observed_at,
+ mhs_av, mhs_1m, mhs_15m, hs_rt, power_w, power_rate_w_per_th,
+ env_temp_c, chip_temp_min_c, chip_temp_max_c, chip_temp_avg_c,
+ fan_speed_in_rpm, fan_speed_out_rpm, uptime_seconds, elapsed_seconds,
+ hash_stable, pool_rejected_percent, raw_summary_json, created_at
+)
+SELECT
+ ('d3000000-0000-4000-8000-' || lpad(i::text, 12, '0'))::uuid,
+ miner_id,
+ ('192.0.2.' || i)::inet,
+ ('192.0.2.' || i)::inet,
+ format('02:00:00:00:00:%s', upper(lpad(to_hex(i), 2, '0'))),
+ observed_at,
+ CASE WHEN stopped THEN 0 ELSE (nominal_ths + ((i % 5) - 2) * 0.7) * 1000000 END,
+ CASE WHEN stopped THEN 0 ELSE (nominal_ths + ((i % 4) - 1) * 0.6) * 1000000 END,
+ CASE WHEN stopped THEN 0 ELSE (nominal_ths + ((i % 3) - 1) * 0.4) * 1000000 END,
+ CASE WHEN stopped THEN 0 ELSE (nominal_ths + ((i % 5) - 2) * 0.7) * 1000000 END,
+ CASE WHEN stopped THEN 0 ELSE 2920 + nominal_ths * 4.8 + (i % 7) * 23 END,
+ CASE WHEN stopped THEN NULL ELSE round((28 + (i % 5) * 0.6)::numeric, 2) END,
+ 24 + (i % 7),
+ 48 + (i % 6) * 2,
+ 58 + (i % 9) * 3,
+ 54 + (i % 8) * 2,
+ CASE WHEN stopped THEN 0 ELSE 4700 + (i % 6) * 120 END,
+ CASE WHEN stopped THEN 0 ELSE 4950 + (i % 5) * 135 END,
+ CASE WHEN stopped THEN 0 ELSE 86400 + i * 731 END,
+ CASE WHEN stopped THEN 0 ELSE 86400 + i * 731 END,
+ CASE WHEN stopped THEN false WHEN i IN (9, 17) THEN false ELSE true END,
+ CASE WHEN stopped THEN 0 ELSE round((0.15 + (i % 6) * 0.08)::numeric, 2) END,
+ jsonb_build_object(
+ 'fixture', 'MinerOps development demo',
+ 'synthetic', true,
+ 'model_index', i
+ ),
+ observed_at
+FROM telemetry
+ON CONFLICT (id) DO UPDATE SET
+ observed_ip = EXCLUDED.observed_ip,
+ ip_address = EXCLUDED.ip_address,
+ mac_address = EXCLUDED.mac_address,
+ observed_at = EXCLUDED.observed_at,
+ mhs_av = EXCLUDED.mhs_av,
+ mhs_1m = EXCLUDED.mhs_1m,
+ mhs_15m = EXCLUDED.mhs_15m,
+ hs_rt = EXCLUDED.hs_rt,
+ power_w = EXCLUDED.power_w,
+ power_rate_w_per_th = EXCLUDED.power_rate_w_per_th,
+ env_temp_c = EXCLUDED.env_temp_c,
+ chip_temp_min_c = EXCLUDED.chip_temp_min_c,
+ chip_temp_max_c = EXCLUDED.chip_temp_max_c,
+ chip_temp_avg_c = EXCLUDED.chip_temp_avg_c,
+ fan_speed_in_rpm = EXCLUDED.fan_speed_in_rpm,
+ fan_speed_out_rpm = EXCLUDED.fan_speed_out_rpm,
+ uptime_seconds = EXCLUDED.uptime_seconds,
+ elapsed_seconds = EXCLUDED.elapsed_seconds,
+ hash_stable = EXCLUDED.hash_stable,
+ pool_rejected_percent = EXCLUDED.pool_rejected_percent,
+ raw_summary_json = EXCLUDED.raw_summary_json,
+ created_at = EXCLUDED.created_at;
+
+INSERT INTO repairs (
+ id, miner_id, status, reason, opened_at, opened_by,
+ completed_at, completed_by, completion_summary, resulting_lifecycle
+)
+VALUES
+ (
+ 'd4000000-0000-4000-8000-000000000001',
+ 'd1000000-0000-4000-8000-000000000021',
+ 'OPEN', 'CONTROL_BOARD', now() - interval '2 days',
+ (SELECT actor_id FROM demo_seed_context),
+ NULL, NULL, NULL, NULL
+ ),
+ (
+ 'd4000000-0000-4000-8000-000000000002',
+ 'd1000000-0000-4000-8000-000000000022',
+ 'WAITING_FOR_PARTS', 'PSU', now() - interval '5 days',
+ (SELECT actor_id FROM demo_seed_context),
+ NULL, NULL, NULL, NULL
+ ),
+ (
+ 'd4000000-0000-4000-8000-000000000003',
+ 'd1000000-0000-4000-8000-000000000023',
+ 'READY_FOR_TEST', 'HASHBOARD', now() - interval '1 day',
+ (SELECT actor_id FROM demo_seed_context),
+ NULL, NULL, NULL, NULL
+ ),
+ (
+ 'd4000000-0000-4000-8000-000000000004',
+ 'd1000000-0000-4000-8000-000000000004',
+ 'COMPLETED', 'PREVENTIVE_MAINTENANCE', now() - interval '21 days',
+ (SELECT actor_id FROM demo_seed_context),
+ now() - interval '20 days',
+ (SELECT actor_id FROM demo_seed_context),
+ 'Synthetic scheduled inspection completed successfully.',
+ 'active'
+ )
+ON CONFLICT (id) DO NOTHING;
+
+INSERT INTO repair_events (
+ id, repair_id, sequence, event_type, created_at, created_by, payload
+)
+VALUES
+ (
+ 'd5000000-0000-4000-8000-000000000001',
+ 'd4000000-0000-4000-8000-000000000001',
+ 1, 'REPAIR_STARTED', now() - interval '2 days',
+ (SELECT actor_id FROM demo_seed_context),
+ jsonb_build_object('reason', 'CONTROL_BOARD', 'from_lifecycle', 'active', 'to_lifecycle', 'in_repair')
+ ),
+ (
+ 'd5000000-0000-4000-8000-000000000002',
+ 'd4000000-0000-4000-8000-000000000001',
+ 2, 'INITIAL_NOTE', now() - interval '2 days' + interval '10 minutes',
+ (SELECT actor_id FROM demo_seed_context),
+ jsonb_build_object('note', 'Synthetic intermittent control-board communication fault.')
+ ),
+ (
+ 'd5000000-0000-4000-8000-000000000003',
+ 'd4000000-0000-4000-8000-000000000002',
+ 1, 'REPAIR_STARTED', now() - interval '5 days',
+ (SELECT actor_id FROM demo_seed_context),
+ jsonb_build_object('reason', 'PSU', 'from_lifecycle', 'spare', 'to_lifecycle', 'in_repair')
+ ),
+ (
+ 'd5000000-0000-4000-8000-000000000004',
+ 'd4000000-0000-4000-8000-000000000002',
+ 2, 'INITIAL_NOTE', now() - interval '5 days' + interval '15 minutes',
+ (SELECT actor_id FROM demo_seed_context),
+ jsonb_build_object('note', 'Synthetic replacement PSU is awaiting delivery.')
+ ),
+ (
+ 'd5000000-0000-4000-8000-000000000005',
+ 'd4000000-0000-4000-8000-000000000003',
+ 1, 'REPAIR_STARTED', now() - interval '1 day',
+ (SELECT actor_id FROM demo_seed_context),
+ jsonb_build_object('reason', 'HASHBOARD', 'from_lifecycle', 'active', 'to_lifecycle', 'in_repair')
+ ),
+ (
+ 'd5000000-0000-4000-8000-000000000006',
+ 'd4000000-0000-4000-8000-000000000003',
+ 2, 'INITIAL_NOTE', now() - interval '1 day' + interval '20 minutes',
+ (SELECT actor_id FROM demo_seed_context),
+ jsonb_build_object('note', 'Synthetic hashboard repair passed bench checks and is ready for test.')
+ ),
+ (
+ 'd5000000-0000-4000-8000-000000000007',
+ 'd4000000-0000-4000-8000-000000000004',
+ 1, 'REPAIR_STARTED', now() - interval '21 days',
+ (SELECT actor_id FROM demo_seed_context),
+ jsonb_build_object('reason', 'PREVENTIVE_MAINTENANCE', 'from_lifecycle', 'active', 'to_lifecycle', 'in_repair')
+ ),
+ (
+ 'd5000000-0000-4000-8000-000000000008',
+ 'd4000000-0000-4000-8000-000000000004',
+ 2, 'REPAIR_COMPLETED', now() - interval '20 days',
+ (SELECT actor_id FROM demo_seed_context),
+ jsonb_build_object(
+ 'previous_miner_lifecycle', 'in_repair',
+ 'resulting_miner_lifecycle', 'active',
+ 'pre_repair_lifecycle', 'active',
+ 'completion_summary', 'Synthetic scheduled inspection completed successfully.'
+ )
+ )
+ON CONFLICT (id) DO NOTHING;
+
+INSERT INTO miner_maintenance_events (
+ id, miner_id, event_type, event_status, event_title, description,
+ event_at, created_by_user_id, created_at, updated_at
+)
+VALUES
+ (
+ 'd6000000-0000-4000-8000-000000000001',
+ 'd1000000-0000-4000-8000-000000000021',
+ 'repair', 'open', 'Control board diagnostics',
+ 'Synthetic repair projection. Intermittent control-board communication fault.',
+ now() - interval '2 days', (SELECT actor_id FROM demo_seed_context), now(), now()
+ ),
+ (
+ 'd6000000-0000-4000-8000-000000000002',
+ 'd1000000-0000-4000-8000-000000000022',
+ 'repair', 'waiting_parts', 'Awaiting replacement PSU',
+ 'Synthetic repair projection. Replacement PSU is awaiting delivery.',
+ now() - interval '5 days', (SELECT actor_id FROM demo_seed_context), now(), now()
+ ),
+ (
+ 'd6000000-0000-4000-8000-000000000003',
+ 'd1000000-0000-4000-8000-000000000023',
+ 'repair', 'testing', 'Hashboard validation',
+ 'Synthetic repair projection. Repaired hashboard is ready for a controlled test.',
+ now() - interval '1 day', (SELECT actor_id FROM demo_seed_context), now(), now()
+ ),
+ (
+ 'd6000000-0000-4000-8000-000000000004',
+ 'd1000000-0000-4000-8000-000000000004',
+ 'repair', 'completed', 'Scheduled inspection complete',
+ 'Synthetic historical repair projection.',
+ now() - interval '20 days', (SELECT actor_id FROM demo_seed_context), now(), now()
+ )
+ON CONFLICT (id) DO UPDATE SET
+ event_status = EXCLUDED.event_status,
+ event_title = EXCLUDED.event_title,
+ description = EXCLUDED.description,
+ event_at = EXCLUDED.event_at,
+ created_by_user_id = EXCLUDED.created_by_user_id,
+ updated_at = now();
+
+DO $$
+DECLARE
+ miner_count integer;
+ position_count integer;
+ repair_count integer;
+BEGIN
+ SELECT count(*) INTO miner_count FROM miners WHERE asset_tag LIKE 'DEMO-MIN-%';
+ SELECT count(*) INTO position_count FROM location_positions WHERE source_file = 'MinerOps demo seed';
+ SELECT count(*) INTO repair_count FROM repairs WHERE id::text LIKE 'd4000000-0000-4000-8000-%';
+ IF miner_count <> 30 OR position_count <> 50 OR repair_count <> 4 THEN
+ RAISE EXCEPTION 'Demo seed verification failed: miners %, positions %, repairs %',
+ miner_count, position_count, repair_count;
+ END IF;
+END $$;
+
+COMMIT;
diff --git a/sql/minerops-retention-daily.sql b/sql/minerops-retention-daily.sql
new file mode 100644
index 0000000..2a6f3b1
--- /dev/null
+++ b/sql/minerops-retention-daily.sql
@@ -0,0 +1,34 @@
+INSERT INTO miner_status_daily_rollups (
+ miner_id,
+ bucket_date,
+ sample_count,
+ avg_mhs_av,
+ max_mhs_av,
+ avg_power_w,
+ max_chip_temp_c,
+ avg_chip_temp_c,
+ avg_pool_rejected_percent,
+ updated_at
+)
+SELECT
+ miner_id,
+ observed_at::date AS bucket_date,
+ COUNT(*) AS sample_count,
+ AVG(mhs_av)::double precision,
+ MAX(mhs_av)::double precision,
+ AVG(power_w)::double precision,
+ MAX(chip_temp_max_c)::double precision,
+ AVG(chip_temp_avg_c)::double precision,
+ AVG(pool_rejected_percent)::double precision,
+ now()
+FROM miner_status_snapshots
+GROUP BY miner_id, observed_at::date
+ON CONFLICT (miner_id, bucket_date) DO UPDATE
+SET sample_count = EXCLUDED.sample_count,
+ avg_mhs_av = EXCLUDED.avg_mhs_av,
+ max_mhs_av = EXCLUDED.max_mhs_av,
+ avg_power_w = EXCLUDED.avg_power_w,
+ max_chip_temp_c = EXCLUDED.max_chip_temp_c,
+ avg_chip_temp_c = EXCLUDED.avg_chip_temp_c,
+ avg_pool_rejected_percent = EXCLUDED.avg_pool_rejected_percent,
+ updated_at = now();
diff --git a/sql/minerops-retention-hourly.sql b/sql/minerops-retention-hourly.sql
new file mode 100644
index 0000000..6c4c3b2
--- /dev/null
+++ b/sql/minerops-retention-hourly.sql
@@ -0,0 +1,34 @@
+INSERT INTO miner_status_hourly_rollups (
+ miner_id,
+ bucket_at,
+ sample_count,
+ avg_mhs_av,
+ max_mhs_av,
+ avg_power_w,
+ max_chip_temp_c,
+ avg_chip_temp_c,
+ avg_pool_rejected_percent,
+ updated_at
+)
+SELECT
+ miner_id,
+ date_trunc('hour', observed_at) AS bucket_at,
+ COUNT(*) AS sample_count,
+ AVG(mhs_av)::double precision,
+ MAX(mhs_av)::double precision,
+ AVG(power_w)::double precision,
+ MAX(chip_temp_max_c)::double precision,
+ AVG(chip_temp_avg_c)::double precision,
+ AVG(pool_rejected_percent)::double precision,
+ now()
+FROM miner_status_snapshots
+GROUP BY miner_id, date_trunc('hour', observed_at)
+ON CONFLICT (miner_id, bucket_at) DO UPDATE
+SET sample_count = EXCLUDED.sample_count,
+ avg_mhs_av = EXCLUDED.avg_mhs_av,
+ max_mhs_av = EXCLUDED.max_mhs_av,
+ avg_power_w = EXCLUDED.avg_power_w,
+ max_chip_temp_c = EXCLUDED.max_chip_temp_c,
+ avg_chip_temp_c = EXCLUDED.avg_chip_temp_c,
+ avg_pool_rejected_percent = EXCLUDED.avg_pool_rejected_percent,
+ updated_at = now();
diff --git a/sql/verify-development-schema.sql b/sql/verify-development-schema.sql
new file mode 100644
index 0000000..f9a9546
--- /dev/null
+++ b/sql/verify-development-schema.sql
@@ -0,0 +1,39 @@
+WITH expected(table_name) AS (
+ VALUES
+ ('roles'),
+ ('permissions'),
+ ('users'),
+ ('miners'),
+ ('tags'),
+ ('miner_tags'),
+ ('miner_asset_id_history'),
+ ('repairs'),
+ ('repair_events'),
+ ('components'),
+ ('repair_parts'),
+ ('miner_control_board_installations'),
+ ('miner_mac_ownerships'),
+ ('miner_status_snapshots'),
+ ('miner_status_hourly_rollups'),
+ ('miner_status_daily_rollups'),
+ ('minerops_retention_runs'),
+ ('miner_maintenance_events'),
+ ('location_positions'),
+ ('miner_location_states'),
+ ('location_import_issues'),
+ ('miner_command_events'),
+ ('miner_api_credentials'),
+ ('miner_pool_snapshots'),
+ ('miner_api3_enrichment_snapshots'),
+ ('miner_api3_hashboard_snapshots'),
+ ('minerops_site_agents'),
+ ('minerops_agent_snapshots'),
+ ('minerops_agent_command_results'),
+ ('minerops_agent_command_queue'),
+ ('minerops_agent_discoveries'),
+ ('minerops_scheduled_bulk_actions')
+)
+SELECT table_name
+FROM expected
+WHERE to_regclass('public.' || table_name) IS NULL
+ORDER BY table_name;