From f5af75f3058036fdca65d8e70d1bb87d176395a3 Mon Sep 17 00:00:00 2001 From: JC Date: Mon, 27 Jul 2026 19:06:01 +0000 Subject: [PATCH 01/37] Add isolated development environment --- .env.dev.example | 20 ++++++++ .gitignore | 2 + README.md | 30 ++++++++++++ backend/.dockerignore | 6 +++ compose.dev.yaml | 105 ++++++++++++++++++++++++++++++++++++++++ dev/nginx.conf | 30 ++++++++++++ scripts/dev-env-init.sh | 47 ++++++++++++++++++ 7 files changed, 240 insertions(+) create mode 100644 .env.dev.example create mode 100644 backend/.dockerignore create mode 100644 compose.dev.yaml create mode 100644 dev/nginx.conf create mode 100755 scripts/dev-env-init.sh 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..686bbf4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ .env .env.* *.env +!.env.example +!.env.dev.example # Backups / dumps / generated data backups/ diff --git a/README.md b/README.md index 60a50d6..35240a8 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,39 @@ Git status: pct exec 306 -- bash -lc "cd /opt/minerops && git status --short" +## Safe local development + +Development uses a separate Compose project, database volume, Redis volume, and +localhost-only backend port. It does not reuse the production `.env` file. +Scheduled scanning and real miner power/reboot controls are disabled. + +First-time setup: + +```bash +./scripts/dev-env-init.sh +docker compose --env-file .env.dev -f compose.dev.yaml up -d --build +``` + +Verify the environment: + +```bash +docker compose --env-file .env.dev -f compose.dev.yaml ps +curl --fail --silent http://127.0.0.1:18000/api/health +``` + +Stop development services without deleting their data: + +```bash +docker compose --env-file .env.dev -f compose.dev.yaml down +``` + +The generated `.env.dev` file is mode `0600` and ignored by Git. To use a +different local port, change `MINEROPS_DEV_PORT` in `.env.dev`. + ## Safety notes - Do not commit .env. - Do not commit backups, logs, database dumps, imports, or runtime data. +- Do not enable scanning or real miner controls in the development environment. - Check /system-health after major changes. - Commit and push after each stable working change. 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/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..091762c --- /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: + - "127.0.0.1:${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/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." From f181482463f1349ae60ff14716a77e8a1647fe99 Mon Sep 17 00:00:00 2001 From: JC Date: Tue, 28 Jul 2026 13:46:10 +0000 Subject: [PATCH 02/37] Add operational schema and identity roles --- .gitignore | 2 + ARCHITECTURE.md | 64 ++ README.md | 10 + .../0006_operational_schema_baseline.py | 478 ++++++++ .../versions/0007_identity_and_roles.py | 161 +++ backend/app/identity.py | 1006 +++++++++++++++++ backend/app/main.py | 344 +----- scripts/test-dev-baseline.sh | 189 ++++ sql/minerops-retention-daily.sql | 34 + sql/minerops-retention-hourly.sql | 34 + sql/verify-development-schema.sql | 30 + 11 files changed, 2068 insertions(+), 284 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 backend/alembic/versions/0006_operational_schema_baseline.py create mode 100644 backend/alembic/versions/0007_identity_and_roles.py create mode 100644 backend/app/identity.py create mode 100755 scripts/test-dev-baseline.sh create mode 100644 sql/minerops-retention-daily.sql create mode 100644 sql/minerops-retention-hourly.sql create mode 100644 sql/verify-development-schema.sql diff --git a/.gitignore b/.gitignore index 686bbf4..478675f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,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/README.md b/README.md index 35240a8..6027fb1 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,16 @@ docker compose --env-file .env.dev -f compose.dev.yaml ps curl --fail --silent http://127.0.0.1:18000/api/health ``` +Run the disposable clean-database baseline: + +```bash +./scripts/test-dev-baseline.sh +``` + +The baseline uses a separate Compose project and port, verifies migrations, +schema, pages, retention rollups, and safety interlocks, then deletes only its +own temporary volumes. + Stop development services without deleting their data: ```bash 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/app/identity.py b/backend/app/identity.py new file mode 100644 index 0000000..9d7ef3c --- /dev/null +++ b/backend/app/identity.py @@ -0,0 +1,1006 @@ +from datetime import datetime, timedelta, timezone +from html import escape +from typing import Any, Callable +from urllib.parse import quote + +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) -> None: + response.delete_cookie(SESSION_COOKIE, path="/", 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(response: Response): + clear_session_cookie(response) + 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"} + prefixes = ("/miners/", "/agent/", "/tools/") + return path in exact or path.startswith(prefixes) + + +def required_page_permission(path: str) -> str: + if path == "/accounts": + return "users.manage" + if path == "/system-health": + return "system_health.view" + if path == "/repairs": + return "repairs.view" + if path == "/locations": + return "locations.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 required_api_permission(method: str, path: str) -> str | None: + if path.startswith("/api/admin/"): + return "users.manage" + 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: + next_path = request.query_params.get("next") or "/dashboard" + if not next_path.startswith("/") or next_path.startswith("//"): + next_path = "/dashboard" + return RedirectResponse(next_path, status_code=303) + return await call_next(request) + + if protected_page(path): + user = request_user_or_none(request) + if not user: + destination = "/login?next=" + quote(path, safe="/") + response = RedirectResponse(destination, status_code=303) + clear_session_cookie(response) + 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 + + + +
MinerOps
+
+ +
+ + +""" + + +ACCOUNTS_HTML = r""" + + + + +Accounts | MinerOps + + + +
MinerOps
+
+

Accounts

Manage access to MinerOps and assign operational roles.

+
+
AdminFull system and account access
+
ManagerFleet, workflow, and reporting control
+
TechnicianRepair, location, and miner operations
+
Read OnlyOperational visibility without changes
+
+
+
AccountRoleStatusLast loginActions
Loading accounts...
+
+

Add account

+ +
+
+
+
+
+ +
+
+

Reset password

+ + +""" + + +@router.get("/login", response_class=HTMLResponse) +def login_page(): + return LOGIN_HTML + + +@router.get("/accounts", response_class=HTMLResponse) +def accounts_page(): + return ACCOUNTS_HTML diff --git a/backend/app/main.py b/backend/app/main.py index 5af3f21..8d2276e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,10 +10,18 @@ from fastapi import Request, Depends, FastAPI, Header, HTTPException, status from pydantic import BaseModel, EmailStr, Field from app.config import settings +from app.identity import ( + COOKIE_TOKEN_HINT, + SESSION_COOKIE, + get_current_user, + install_identity_middleware, + router as identity_router, +) app = FastAPI(title=settings.app_name) -password_hasher = PasswordHasher() +app.include_router(identity_router) +install_identity_middleware(app) def api_response(data: Any = None, meta: dict | None = None, error: Any = None) -> dict: @@ -74,83 +82,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 +102,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) @@ -4303,7 +4044,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 +4067,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 +4203,6 @@ async def agent_pool_snapshots(request: Request): cur.execute( """ INSERT INTO miner_pool_snapshots ( - id, miner_id, ip_address, mac_address, @@ -4465,7 +4224,6 @@ async def agent_pool_snapshots(request: Request): agent_id ) VALUES ( - %s, %s, %s::inet, %s, @@ -4488,7 +4246,6 @@ async def agent_pool_snapshots(request: Request): ); """, ( - str(_uuid.uuid4()), miner_id, ip, mac, @@ -6241,22 +5998,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 +6112,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 +6155,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; } @@ -23630,9 +23404,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): diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh new file mode 100755 index 0000000..40436e2 --- /dev/null +++ b/scripts/test-dev-baseline.sh @@ -0,0 +1,189 @@ +#!/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 + args+=(-b "${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 "Building and starting disposable development stack..." +MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --build + +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}" == *"0007_identity_and_roles"* ]] + +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; 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 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" ]] +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" ]] + +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/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..19c95af --- /dev/null +++ b/sql/verify-development-schema.sql @@ -0,0 +1,30 @@ +WITH expected(table_name) AS ( + VALUES + ('roles'), + ('permissions'), + ('users'), + ('miners'), + ('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; From 816da519dd517e968aa116703958241e4c4e3402 Mon Sep 17 00:00:00 2001 From: JC Date: Tue, 28 Jul 2026 13:52:02 +0000 Subject: [PATCH 03/37] Fix logout session teardown --- backend/app/identity.py | 16 +++++++++++----- scripts/test-dev-baseline.sh | 3 ++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/backend/app/identity.py b/backend/app/identity.py index 9d7ef3c..6a0f94c 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -202,8 +202,14 @@ def set_session_cookie(response: Response, request: Request, token: str) -> None ) -def clear_session_cookie(response: Response) -> None: - response.delete_cookie(SESSION_COOKIE, path="/", httponly=True, samesite="lax") +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( @@ -378,8 +384,8 @@ def login(payload: LoginRequest, request: Request, response: Response): @router.post("/api/auth/logout") -def logout(response: Response): - clear_session_cookie(response) +def logout(request: Request, response: Response): + clear_session_cookie(response, request) return api_response(data={"logged_out": True}) @@ -726,7 +732,7 @@ def install_identity_middleware(app) -> None: if not user: destination = "/login?next=" + quote(path, safe="/") response = RedirectResponse(destination, status_code=303) - clear_session_cookie(response) + clear_session_cookie(response, request) return response permission = required_page_permission(path) if not user_has_permission(user, permission): diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh index 40436e2..3e35396 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -45,7 +45,8 @@ status_for() { local body="${4:-}" local args=(--silent --output /dev/null --write-out '%{http_code}' -X "${method}") if [[ -n "${cookie_jar}" ]]; then - args+=(-b "${cookie_jar}") + # 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}") From 6c19f6a9a165ea89c1bc2e69eb195b46f313cef2 Mon Sep 17 00:00:00 2001 From: JC Date: Tue, 28 Jul 2026 15:09:16 +0000 Subject: [PATCH 04/37] Allow LAN access to development gateway --- compose.dev.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose.dev.yaml b/compose.dev.yaml index 091762c..0fb51e0 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -70,7 +70,7 @@ services: backend: condition: service_healthy ports: - - "127.0.0.1:${MINEROPS_DEV_PORT:-18000}:8080" + - "${MINEROPS_DEV_PORT:-18000}:8080" networks: - dev - edge From d4df20b7402a9dc32cf065a35f610b3c1d3979b1 Mon Sep 17 00:00:00 2001 From: JC Date: Tue, 28 Jul 2026 15:38:52 +0000 Subject: [PATCH 05/37] Add manual miner inventory schema --- .../versions/0008_manual_miner_inventory.py | 193 +++++++++ scripts/test-dev-baseline.sh | 2 +- scripts/test-manual-inventory-migration.sh | 398 ++++++++++++++++++ sql/verify-development-schema.sql | 2 + 4 files changed, 594 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/0008_manual_miner_inventory.py create mode 100755 scripts/test-manual-inventory-migration.sh 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/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh index 3e35396..afaee47 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -88,7 +88,7 @@ health_json="$(cat "${health_file}")" echo "Checking migrations and required schema..." current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" -[[ "${current_revision}" == *"0007_identity_and_roles"* ]] +[[ "${current_revision}" == *"0008_manual_miner_inventory"* ]] missing_tables="$( "${compose[@]}" exec -T postgres sh -c \ 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/sql/verify-development-schema.sql b/sql/verify-development-schema.sql index 19c95af..79f5010 100644 --- a/sql/verify-development-schema.sql +++ b/sql/verify-development-schema.sql @@ -4,6 +4,8 @@ WITH expected(table_name) AS ( ('permissions'), ('users'), ('miners'), + ('tags'), + ('miner_tags'), ('miner_status_snapshots'), ('miner_status_hourly_rollups'), ('miner_status_daily_rollups'), From d1d02962da20f4dc5ff9861f181c0e97362e6537 Mon Sep 17 00:00:00 2001 From: JC Date: Tue, 28 Jul 2026 16:18:11 +0000 Subject: [PATCH 06/37] Add inventory domain foundation --- backend/app/inventory/__init__.py | 33 +++ backend/app/inventory/exceptions.py | 25 ++ backend/app/inventory/models.py | 86 +++++++ backend/app/inventory/repository.py | 337 +++++++++++++++++++++++++ backend/app/inventory/service.py | 290 +++++++++++++++++++++ backend/tests/__init__.py | 1 + backend/tests/test_inventory_domain.py | 305 ++++++++++++++++++++++ scripts/test-inventory-domain.sh | 58 +++++ 8 files changed, 1135 insertions(+) create mode 100644 backend/app/inventory/__init__.py create mode 100644 backend/app/inventory/exceptions.py create mode 100644 backend/app/inventory/models.py create mode 100644 backend/app/inventory/repository.py create mode 100644 backend/app/inventory/service.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_inventory_domain.py create mode 100755 scripts/test-inventory-domain.sh diff --git a/backend/app/inventory/__init__.py b/backend/app/inventory/__init__.py new file mode 100644 index 0000000..95a5c01 --- /dev/null +++ b/backend/app/inventory/__init__.py @@ -0,0 +1,33 @@ +"""Inventory domain models, persistence, and business services.""" + +from app.inventory.exceptions import ( + DuplicateAssetID, + InventoryError, + InventoryValidationError, + MinerNotFound, +) +from app.inventory.models import ( + InventoryCreateRequest, + InventoryMinerDetail, + InventoryMinerSummary, + InventorySearchFilters, + InventoryTag, + InventoryUpdateRequest, +) +from app.inventory.repository import InventoryRepository +from app.inventory.service import InventoryService + +__all__ = [ + "DuplicateAssetID", + "InventoryCreateRequest", + "InventoryError", + "InventoryMinerDetail", + "InventoryMinerSummary", + "InventoryRepository", + "InventorySearchFilters", + "InventoryService", + "InventoryTag", + "InventoryUpdateRequest", + "InventoryValidationError", + "MinerNotFound", +] 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..c534dfa --- /dev/null +++ b/backend/app/inventory/models.py @@ -0,0 +1,86 @@ +"""Pydantic contracts for the inventory domain.""" + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, Field, IPvAnyAddress + + +LifecycleStatus = Literal["active", "spare", "retired"] + + +class InventoryTag(BaseModel): + id: UUID + name: str + created_at: datetime + updated_at: datetime + + +class InventoryMinerSummary(BaseModel): + id: UUID + asset_id: str | None = None + current_mac: str | None = None + current_ip: str | None = None + model: str | None = None + lifecycle_status: LifecycleStatus + runtime_status: str + site_id: UUID | None = None + room_id: UUID | 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 + miner_sn: 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 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 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 + tags: list[str] = Field(default_factory=list) + limit: int = Field(default=100, ge=1, le=500) + offset: int = Field(default=0, ge=0) diff --git a/backend/app/inventory/repository.py b/backend/app/inventory/repository.py new file mode 100644 index 0000000..f20b256 --- /dev/null +++ b/backend/app/inventory/repository.py @@ -0,0 +1,337 @@ +"""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_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]) -> dict | None: + cur.execute(f"{MINER_SELECT} WHERE {where_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_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) -> list[dict]: + return self.search_miners(limit=limit, offset=offset) + + def search_miners( + self, + *, + query: str | None = None, + asset_id: str | None = None, + lifecycle_status: str | None = None, + tag_names: Sequence[str] = (), + limit: int = 100, + offset: int = 0, + ) -> list[dict]: + conditions: list[str] = [] + params: list[Any] = [] + + 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) + + 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) + + 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""" + {MINER_SELECT} + 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 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 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/service.py b/backend/app/inventory/service.py new file mode 100644 index 0000000..13fa37f --- /dev/null +++ b/backend/app/inventory/service.py @@ -0,0 +1,290 @@ +"""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 ( + InventoryCreateRequest, + InventoryMinerDetail, + InventoryMinerSummary, + InventorySearchFilters, + InventoryTag, + InventoryUpdateRequest, +) +from app.inventory.repository import InventoryRepository + + +ALLOWED_LIFECYCLE_STATUSES = frozenset({"active", "spare", "retired"}) + + +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: + 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"), + lifecycle_status=row["lifecycle_status"], + runtime_status=row["status"], + site_id=row.get("site_id"), + room_id=row.get("room_id"), + 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"), + miner_sn=row.get("miner_sn"), + control_board_sn=row.get("control_board_sn"), + psu_serial=row.get("power_sn"), + firmware_version=row.get("firmware_version"), + 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 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 search_miners( + self, + filters: InventorySearchFilters, + ) -> list[InventoryMinerSummary]: + lifecycle_status = None + if filters.lifecycle_status: + lifecycle_status = self.validate_lifecycle_status( + filters.lifecycle_status + ) + 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, + tag_names=tag_names, + limit=filters.limit, + offset=filters.offset, + ) + ] + + 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_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) + duplicate = self.repository.get_miner_by_asset_id(asset_id) + if duplicate and duplicate["id"] != miner_id: + raise DuplicateAssetID(asset_id) + values["asset_tag"] = asset_id + 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/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_inventory_domain.py b/backend/tests/test_inventory_domain.py new file mode 100644 index 0000000..cac02a8 --- /dev/null +++ b/backend/tests/test_inventory_domain.py @@ -0,0 +1,305 @@ +import ast +import unittest +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from uuid import UUID, uuid4 + +from app.inventory import ( + DuplicateAssetID, + InventoryCreateRequest, + InventoryRepository, + InventorySearchFilters, + InventoryService, + InventoryUpdateRequest, + InventoryValidationError, +) + + +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": [], + } + row.update(overrides) + return row + + +class FakeInventoryRepository: + def __init__(self): + self.rows = {} + self.created_values = None + + @contextmanager + def transaction(self): + yield self + + def get_miner_by_id(self, miner_id): + return self.rows.get(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): + 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): + 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_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) + + 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_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( + asset_id=" ASSET-NEW ", + psu_serial=" PSU-NEW ", + lifecycle_status="retired", + ), + ) + + self.assertEqual(updated.asset_id, "ASSET-NEW") + self.assertEqual(updated.psu_serial, "PSU-NEW") + self.assertEqual(updated.lifecycle_status, "retired") + + 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): + with self.repository.transaction() as repository: + with repository._connection.cursor() as cur: + cur.execute("DELETE FROM miner_tags") + cur.execute("DELETE FROM tags") + cur.execute("DELETE FROM miners") + + 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, + ) + + 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_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_package_has_no_http_layer_or_router(self): + import app.inventory as inventory + + inventory_path = Path(__file__).parents[1] / "app" / "inventory" + for source_path in inventory_path.glob("*.py"): + 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, + ) + + self.assertFalse(hasattr(inventory, "router")) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-inventory-domain.sh b/scripts/test-inventory-domain.sh new file mode 100755 index 0000000..526432b --- /dev/null +++ b/scripts/test-inventory-domain.sh @@ -0,0 +1,58 @@ +#!/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 discover -s /app/tests -v + +echo "Inventory domain tests passed." From 1cae60ab46ab4aa95fef6d028c7cdb6f826e5105 Mon Sep 17 00:00:00 2001 From: JC Date: Tue, 28 Jul 2026 16:49:48 +0000 Subject: [PATCH 07/37] Add MinerOps branding and project README --- README.md | 244 ++++++++++++++++++++++++++-------------- branding/BRANDING.md | 111 ++++++++++++++++++ branding/colors.md | 40 +++++++ branding/favicon.svg | 5 + branding/logo-dark.svg | 5 + branding/logo-light.svg | 5 + branding/logo.svg | 5 + branding/wordmark.svg | 9 ++ 8 files changed, 340 insertions(+), 84 deletions(-) create mode 100644 branding/BRANDING.md create mode 100644 branding/colors.md create mode 100644 branding/favicon.svg create mode 100644 branding/logo-dark.svg create mode 100644 branding/logo-light.svg create mode 100644 branding/logo.svg create mode 100644 branding/wordmark.svg diff --git a/README.md b/README.md index 6027fb1..1aa12c5 100644 --- a/README.md +++ b/README.md @@ -1,122 +1,198 @@ -# MinerOps +

+ MinerOps +

-MinerOps is a self-hosted mining fleet operations console for tracking, repairing, locating, and controlling WhatsMiner ASICs. +

MinerOps

-## Current deployment +

+ The self-hosted operating system for cryptocurrency mining operations. +
+ Know every miner. Track every repair. Control every rack. +

-- Hub CT: 306 -- Public URL: https://plexus.lussier.cc -- Repository: https://gitea.lussier.cc/lussierj/minerops -- Backend container: minerops-backend -- Database container: minerops-postgres -- Redis container: minerops-redis +## Overview -## Main pages +MinerOps is a self-hosted platform intended to provide one authoritative source +of truth for cryptocurrency mining operations. It is being built to connect +asset records, network identity, repair history, physical location, telemetry, +and operational controls without depending on a hosted management service. -- /dashboard - fleet dashboard and repair summary -- /repairs - repair queue, notes, parts used, history, CSV export -- /locations - room/rack/shelf location map -- /system-health - app, database, table size, route, and retention health +The repository contains a working early-stage application and the first +foundations of a more modular architecture. Current development is focused on +turning those foundations into well-defined product domains with tested service +and persistence boundaries. -## Repair workflow +The longer-term product direction combines: -1. Open /repairs. -2. Find the miner by MAC, IP, model, serial, or problem. -3. Add repair notes. -4. Select parts used and quantity. -5. Update repair status. -6. Use History to edit or delete mistaken entries. -7. Mark Ready to Shelf or use Move to Shelf. -8. Return the miner to an open shelf position on /locations. +- Miner inventory +- Network discovery and identity tracking +- Repairs and maintenance +- Locations and electrical infrastructure +- Monitoring and alerts +- Fleet control +- Scheduling and automation +- Historical records and reporting -## Repair statuses +These areas describe the product direction, not a claim that every workflow is +complete or ready for production use. -- Open -- Diagnosing -- Waiting Parts -- In Progress -- Testing -- Ready to Shelf -- Needs Follow-up -- Completed -- Failed -- Cancelled +## Why MinerOps? -## Retention +Mining operations often accumulate several partial versions of the truth: +spreadsheets for assets, separate monitoring tools for performance, informal +repair notes, and location knowledge held by individual technicians. -Retention is currently scheduled in dry-run mode. +That fragmentation becomes harder to manage when IP addresses change, control +boards or network interfaces are replaced, equipment moves between racks, and +repair history is incomplete. MinerOps is intended to give operators one +self-hosted operational record that follows each miner through its full +lifecycle. -Current intended policy: +## Core Capabilities -- Raw status snapshots: 30 days -- Hourly rollups: 1 year or longer -- Daily rollups: long term -- Repair, location, and MAC history: permanent +### Asset and Inventory Management -Verify /system-health before switching retention to apply mode. +Maintain stable asset records independently of a miner's current IP address. +The inventory schema and backend domain foundation currently support Asset IDs, +hardware identifiers, lifecycle state, tags, notes, location references, and +seen timestamps. Inventory HTTP APIs and product workflows are the next +application milestone. -## Common commands +### Miner Identity and Discovery -Health check: +Associate changing network information with durable miner records. Existing +code includes discovery and network identity foundations; formal identity +history and transfer workflows remain planned work. -pct exec 306 -- bash -lc "curl -s http://127.0.0.1:8000/api/health" +### Repairs and Maintenance -Rebuild backend: +Build a complete service history around each miner, including repair status, +notes, parts, and return-to-service workflows. Operational repair functionality +exists today, while extraction into a dedicated domain architecture is still +planned. -pct exec 306 -- bash -lc "cd /opt/minerops && docker compose build backend || docker-compose build backend" -pct exec 306 -- bash -lc "cd /opt/minerops && docker compose up -d --force-recreate backend || docker-compose up -d --force-recreate backend" +### Locations and Infrastructure -Check logs: +Track where equipment is installed and relate miners to sites, rooms, racks, +shelves, and operational infrastructure. Existing location workflows will be +refined as the modular architecture develops. -pct exec 306 -- bash -lc "docker logs minerops-backend --since 30m" +### Monitoring and Health -Git status: +Collect miner status and telemetry, identify stale or unhealthy equipment, and +support operational health views. The repository contains current telemetry and +retention foundations; broader alerting remains planned. -pct exec 306 -- bash -lc "cd /opt/minerops && git status --short" +### Fleet Operations -## Safe local development +Provide controlled fleet actions while keeping command policy, authorization, +and site-local execution explicit. Existing command workflows are under active +development and should not be treated as a finished control platform. -Development uses a separate Compose project, database volume, Redis volume, and -localhost-only backend port. It does not reuse the production `.env` file. -Scheduled scanning and real miner power/reboot controls are disabled. +### Automation and Scheduling -First-time setup: +Coordinate recurring scans, scheduled actions, retention, and other repeatable +operations. Scheduling and automation will expand after the core inventory and +identity domains are established. -```bash -./scripts/dev-env-init.sh -docker compose --env-file .env.dev -f compose.dev.yaml up -d --build +### Audit History and Reporting + +Preserve important operational changes and support practical reporting across +assets, repairs, locations, and fleet activity. Audit-log and reporting +foundations exist; the complete reporting model remains planned. + +## Current Status + +MinerOps is under active development. + +**Current development stage:** `v0.2 - Foundation` + +Verified foundations in the repository include: + +- Docker-based development environment +- PostgreSQL data store +- Redis service +- Alembic schema migrations +- Session-based authentication and login gating +- Admin, Manager, Technician, and Read Only role foundations +- Manual miner inventory database schema +- Inventory Pydantic models +- Inventory repository and service domain foundation +- Automated repository and service tests for the Inventory domain +- Disposable development baseline tests + +**Next application milestone:** `v0.3 - Inventory` + +**Next technical feature:** Inventory API + +## Architecture + +MinerOps is moving toward a modular backend with deliberate separation between +transport, business rules, and persistence: + +```text +Frontend + | + v +FastAPI routers + | + v +Domain services + | + v +Repositories + | + v +PostgreSQL ``` -Verify the environment: +FastAPI routers will own HTTP request and response concerns. Domain services +will own operational rules and orchestration. Repositories will own database +access. The Inventory domain is the first backend area established with these +boundaries; other working application areas are still being migrated +incrementally. -```bash -docker compose --env-file .env.dev -f compose.dev.yaml ps -curl --fail --silent http://127.0.0.1:18000/api/health -``` +## Technology Stack -Run the disposable clean-database baseline: +Current technologies: -```bash -./scripts/test-dev-baseline.sh -``` +- Python +- FastAPI +- Pydantic +- PostgreSQL +- Alembic +- Redis +- Docker and Docker Compose +- Nginx development gateway -The baseline uses a separate Compose project and port, verifies migrations, -schema, pages, retention rollups, and safety interlocks, then deletes only its -own temporary volumes. +A typed, responsive frontend with a shared design system is planned. Its +technology selection has not yet been finalized in this repository. -Stop development services without deleting their data: +## Roadmap -```bash -docker compose --env-file .env.dev -f compose.dev.yaml down -``` +- `v0.2 - Foundation` +- `v0.3 - Inventory` +- `v0.4 - Identity` +- `v0.5 - Operations` +- `v0.6 - Monitoring` +- `v0.7 - Automation` +- `v1.0 - Production Ready` -The generated `.env.dev` file is mode `0600` and ignored by Git. To use a -different local port, change `MINEROPS_DEV_PORT` in `.env.dev`. +Roadmap scope and release boundaries may evolve as development continues and +operational requirements become clearer. -## Safety notes +## Documentation -- Do not commit .env. -- Do not commit backups, logs, database dumps, imports, or runtime data. -- Do not enable scanning or real miner controls in the development environment. -- Check /system-health after major changes. -- Commit and push after each stable working change. +Detailed product, architecture, development, database, testing, and roadmap +documentation will live under `docs/`. That documentation set is still being +developed, so this README does not link to files that do not yet exist. + +## Contributing + +MinerOps is in early development. Contribution guidelines will be added as the +architecture, development workflow, and review expectations stabilize. + +## License + +The MinerOps license has not yet been finalized. No license file is currently +included in this repository. 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 + From fbf0f81df1a07f6c2309652f64db5c592cecb0b2 Mon Sep 17 00:00:00 2001 From: JC Date: Tue, 28 Jul 2026 17:09:09 +0000 Subject: [PATCH 08/37] docs: add architecture guide --- docs/ARCHITECTURE.md | 457 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 docs/ARCHITECTURE.md 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. From 7280f1dfeaabfbe5d363dd352bbfff49ab54bb28 Mon Sep 17 00:00:00 2001 From: JC Date: Tue, 28 Jul 2026 17:22:03 +0000 Subject: [PATCH 09/37] docs: add product guide --- docs/PRODUCT.md | 377 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 docs/PRODUCT.md 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. From 93e034ea07beb7e93a475ebf1222e8edddce3b00 Mon Sep 17 00:00:00 2001 From: JC Date: Tue, 28 Jul 2026 17:51:41 +0000 Subject: [PATCH 10/37] docs: add architecture decision records --- docs/DECISIONS.md | 372 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 docs/DECISIONS.md 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. From 979885bbddcd914e2d8d6bd3e4abcedfb59e2135 Mon Sep 17 00:00:00 2001 From: JC Date: Tue, 28 Jul 2026 17:57:43 +0000 Subject: [PATCH 11/37] docs: add project roadmap --- docs/ROADMAP.md | 229 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 docs/ROADMAP.md diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..70d5eb6 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,229 @@ +# 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: Planned direction.** + +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. + +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 +- 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. From 51aec1b4044838a7859848c7319439dae267caae Mon Sep 17 00:00:00 2001 From: JC Date: Tue, 28 Jul 2026 18:31:57 +0000 Subject: [PATCH 12/37] feat: add read-only inventory API --- backend/app/inventory/dependencies.py | 8 + backend/app/inventory/models.py | 18 ++ backend/app/inventory/router.py | 71 ++++++++ backend/app/main.py | 2 + backend/requirements.txt | 1 + backend/tests/test_inventory_domain.py | 16 +- backend/tests/test_inventory_router.py | 236 +++++++++++++++++++++++++ 7 files changed, 346 insertions(+), 6 deletions(-) create mode 100644 backend/app/inventory/dependencies.py create mode 100644 backend/app/inventory/router.py create mode 100644 backend/tests/test_inventory_router.py 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/models.py b/backend/app/inventory/models.py index c534dfa..af49176 100644 --- a/backend/app/inventory/models.py +++ b/backend/app/inventory/models.py @@ -84,3 +84,21 @@ class InventorySearchFilters(BaseModel): tags: list[str] = Field(default_factory=list) limit: int = Field(default=100, ge=1, le=500) offset: int = Field(default=0, ge=0) + + +class InventoryListMeta(BaseModel): + limit: int + offset: int + returned: int + + +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 diff --git a/backend/app/inventory/router.py b/backend/app/inventory/router.py new file mode 100644 index 0000000..7f51810 --- /dev/null +++ b/backend/app/inventory/router.py @@ -0,0 +1,71 @@ +"""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 +from app.inventory.dependencies import get_inventory_service +from app.inventory.exceptions import InventoryValidationError, MinerNotFound +from app.inventory.models import ( + InventoryListMeta, + InventoryMinerDetailResponse, + InventoryMinerListResponse, + InventorySearchFilters, +) +from app.inventory.service import InventoryService + + +router = APIRouter(prefix="/api/inventory/miners", tags=["inventory"]) +require_inventory_read = require_permission("miners.view") + +InventoryServiceDependency = Annotated[ + InventoryService, + Depends(get_inventory_service), +] +InventoryReadUser = Annotated[dict, Depends(require_inventory_read)] +InventoryQuery = Annotated[InventorySearchFilters, Query()] + + +@router.get("", response_model=InventoryMinerListResponse) +def list_inventory_miners( + filters: InventoryQuery, + service: InventoryServiceDependency, + current_user: InventoryReadUser, +) -> InventoryMinerListResponse: + del current_user + try: + miners = service.search_miners(filters) + except InventoryValidationError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + return InventoryMinerListResponse( + data=miners, + meta=InventoryListMeta( + limit=filters.limit, + offset=filters.offset, + returned=len(miners), + ), + ) + + +@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/main.py b/backend/app/main.py index 8d2276e..2d8dc8c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,10 +17,12 @@ from app.identity import ( install_identity_middleware, router as identity_router, ) +from app.inventory.router import router as inventory_router app = FastAPI(title=settings.app_name) app.include_router(identity_router) +app.include_router(inventory_router) install_identity_middleware(app) 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/test_inventory_domain.py b/backend/tests/test_inventory_domain.py index cac02a8..eb8db17 100644 --- a/backend/tests/test_inventory_domain.py +++ b/backend/tests/test_inventory_domain.py @@ -281,11 +281,17 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase): class InventoryArchitectureTests(unittest.TestCase): - def test_inventory_package_has_no_http_layer_or_router(self): - import app.inventory as inventory - + def test_inventory_domain_layers_have_no_http_dependency(self): inventory_path = Path(__file__).parents[1] / "app" / "inventory" - for source_path in inventory_path.glob("*.py"): + domain_files = ( + "dependencies.py", + "exceptions.py", + "models.py", + "repository.py", + "service.py", + ) + for filename in domain_files: + source_path = inventory_path / filename tree = ast.parse(source_path.read_text(encoding="utf-8")) imported_modules = [] for node in ast.walk(tree): @@ -298,8 +304,6 @@ class InventoryArchitectureTests(unittest.TestCase): source_path, ) - self.assertFalse(hasattr(inventory, "router")) - 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..ede1fc9 --- /dev/null +++ b/backend/tests/test_inventory_router.py @@ -0,0 +1,236 @@ +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 ( + InventoryValidationError, + MinerNotFound, +) +from app.inventory.models import ( + 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 + + def search_miners( + self, + filters: InventorySearchFilters, + ) -> list[InventoryMinerSummary]: + self.search_filters = filters + if self.search_error is not None: + raise self.search_error + return self.search_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 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: + summary = miner_summary(miner_id) + return InventoryMinerDetail( + **summary.model_dump(), + miner_sn="MINER-001", + control_board_sn="CB-001", + psu_serial="PSU-001", + ) + + +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_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_list_requires_miners_view_permission(self): + self.authenticate(permissions=()) + response = self.client.get("/api/inventory/miners") + self.assertEqual(response.status_code, 403) + + 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}, + ) + self.assertIsNone(body["error"]) + + 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"), + ("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.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_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() From a87ab2709fbf311a83250f8f396720684bb8df82 Mon Sep 17 00:00:00 2001 From: JC Date: Tue, 28 Jul 2026 19:30:51 +0000 Subject: [PATCH 13/37] feat: add inventory metadata updates --- .../0009_inventory_asset_id_history.py | 81 ++++++ backend/app/inventory/__init__.py | 2 + backend/app/inventory/models.py | 12 +- backend/app/inventory/repository.py | 74 ++++- backend/app/inventory/router.py | 69 ++++- backend/app/inventory/service.py | 91 +++++- backend/tests/test_inventory_domain.py | 258 +++++++++++++++++- backend/tests/test_inventory_router.py | 251 +++++++++++++++++ scripts/test-asset-id-history-migration.sh | 219 +++++++++++++++ scripts/test-dev-baseline.sh | 2 +- sql/verify-development-schema.sql | 1 + 11 files changed, 1048 insertions(+), 12 deletions(-) create mode 100644 backend/alembic/versions/0009_inventory_asset_id_history.py create mode 100755 scripts/test-asset-id-history-migration.sh 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/app/inventory/__init__.py b/backend/app/inventory/__init__.py index 95a5c01..a22ddc7 100644 --- a/backend/app/inventory/__init__.py +++ b/backend/app/inventory/__init__.py @@ -8,6 +8,7 @@ from app.inventory.exceptions import ( ) from app.inventory.models import ( InventoryCreateRequest, + InventoryMetadataUpdateRequest, InventoryMinerDetail, InventoryMinerSummary, InventorySearchFilters, @@ -21,6 +22,7 @@ __all__ = [ "DuplicateAssetID", "InventoryCreateRequest", "InventoryError", + "InventoryMetadataUpdateRequest", "InventoryMinerDetail", "InventoryMinerSummary", "InventoryRepository", diff --git a/backend/app/inventory/models.py b/backend/app/inventory/models.py index af49176..3a15b4c 100644 --- a/backend/app/inventory/models.py +++ b/backend/app/inventory/models.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import Literal from uuid import UUID -from pydantic import BaseModel, Field, IPvAnyAddress +from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress LifecycleStatus = Literal["active", "spare", "retired"] @@ -60,6 +60,16 @@ class InventoryCreateRequest(BaseModel): tags: list[str] = Field(default_factory=list) +class InventoryMetadataUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + asset_id: str | None = Field(default=None, max_length=120) + miner_sn: str | None = Field(default=None, max_length=255) + psu_serial: str | None = Field(default=None, max_length=255) + model: str | None = Field(default=None, max_length=255) + notes: str | None = None + + class InventoryUpdateRequest(BaseModel): asset_id: str | None = Field(default=None, max_length=120) current_mac: str | None = Field(default=None, max_length=64) diff --git a/backend/app/inventory/repository.py b/backend/app/inventory/repository.py index f20b256..7b7035e 100644 --- a/backend/app/inventory/repository.py +++ b/backend/app/inventory/repository.py @@ -145,8 +145,18 @@ class InventoryRepository: connection.close() @staticmethod - def _fetch_miner(cur, where_sql: str, params: Sequence[Any]) -> dict | None: - cur.execute(f"{MINER_SELECT} WHERE {where_sql}", params) + def _fetch_miner( + cur, + where_sql: str, + params: Sequence[Any], + *, + for_update: bool = False, + ) -> dict | None: + lock_sql = " FOR UPDATE OF miners" if for_update else "" + cur.execute( + f"{MINER_SELECT} WHERE {where_sql}{lock_sql}", + params, + ) row = cur.fetchone() return dict(row) if row else None @@ -155,6 +165,16 @@ class InventoryRepository: with connection.cursor(row_factory=dict_row) as cur: return self._fetch_miner(cur, "miners.id = %s", (miner_id,)) + 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: @@ -291,6 +311,56 @@ class InventoryRepository: return None return self._fetch_miner(cur, "miners.id = %s", (miner_id,)) + def add_asset_id_history( + self, + miner_id: UUID, + old_asset_id: str | None, + new_asset_id: str, + changed_by: UUID, + ) -> dict: + with self._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + INSERT INTO miner_asset_id_history ( + miner_id, + old_asset_id, + new_asset_id, + changed_by + ) + VALUES (%s, %s, %s, %s) + RETURNING + id, + miner_id, + old_asset_id, + new_asset_id, + changed_at, + changed_by + """, + (miner_id, old_asset_id, new_asset_id, changed_by), + ) + return dict(cur.fetchone()) + + def list_asset_id_history(self, miner_id: UUID) -> list[dict]: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT + id, + miner_id, + old_asset_id, + new_asset_id, + changed_at, + changed_by + FROM miner_asset_id_history + WHERE miner_id = %s + ORDER BY changed_at, 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: diff --git a/backend/app/inventory/router.py b/backend/app/inventory/router.py index 7f51810..262b933 100644 --- a/backend/app/inventory/router.py +++ b/backend/app/inventory/router.py @@ -7,9 +7,15 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from app.identity import require_permission from app.inventory.dependencies import get_inventory_service -from app.inventory.exceptions import InventoryValidationError, MinerNotFound +from app.inventory.exceptions import ( + DuplicateAssetID, + InventoryValidationError, + MinerNotFound, +) from app.inventory.models import ( + InventoryCreateRequest, InventoryListMeta, + InventoryMetadataUpdateRequest, InventoryMinerDetailResponse, InventoryMinerListResponse, InventorySearchFilters, @@ -19,15 +25,76 @@ 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, diff --git a/backend/app/inventory/service.py b/backend/app/inventory/service.py index 13fa37f..18bd20a 100644 --- a/backend/app/inventory/service.py +++ b/backend/app/inventory/service.py @@ -13,6 +13,7 @@ from app.inventory.exceptions import ( ) from app.inventory.models import ( InventoryCreateRequest, + InventoryMetadataUpdateRequest, InventoryMinerDetail, InventoryMinerSummary, InventorySearchFilters, @@ -197,6 +198,85 @@ class InventoryService: return self._detail_model(row) + def update_metadata( + self, + miner_id: UUID, + request: InventoryMetadataUpdateRequest, + *, + actor_user_id: UUID, + ) -> InventoryMinerDetail: + fields_set = request.model_fields_set + if not fields_set: + raise InventoryValidationError( + "At least one metadata field must be provided" + ) + + requested_asset_id = None + if "asset_id" in fields_set: + if request.asset_id is None: + raise InventoryValidationError("Asset ID cannot be cleared") + requested_asset_id = self.normalize_asset_id(request.asset_id) + + requested_values: dict[str, Any] = {} + field_map = { + "miner_sn": "miner_sn", + "psu_serial": "power_sn", + "model": "model", + "notes": "notes", + } + for model_field, database_column in field_map.items(): + if model_field in fields_set: + requested_values[database_column] = self._clean_optional_text( + getattr(request, model_field) + ) + + try: + with self.repository.transaction() as repository: + existing = repository.get_miner_by_id_for_update(miner_id) + if not existing: + raise MinerNotFound(miner_id) + + previous_asset_id = ( + str(existing.get("asset_tag") or "").strip() or None + ) + asset_id_changed = ( + requested_asset_id is not None + and requested_asset_id != previous_asset_id + ) + + values = { + key: value + for key, value in requested_values.items() + if existing.get(key) != value + } + if asset_id_changed: + duplicate = repository.get_miner_by_asset_id( + requested_asset_id + ) + if duplicate and duplicate["id"] != miner_id: + raise DuplicateAssetID(requested_asset_id) + values["asset_tag"] = requested_asset_id + + if not values: + return self._detail_model(existing) + + self._before_update(existing, values) + row = repository.update_miner(miner_id, values) + if asset_id_changed: + repository.add_asset_id_history( + miner_id, + previous_asset_id, + requested_asset_id, + actor_user_id, + ) + row = repository.get_miner_by_id(miner_id) + self._after_update(row) + except UniqueViolation as exc: + asset_id = requested_asset_id or "unknown" + raise DuplicateAssetID(asset_id) from exc + + return self._detail_model(row) + def update_miner( self, miner_id: UUID, @@ -213,10 +293,13 @@ class InventoryService: if request.asset_id is None: raise InventoryValidationError("Asset ID cannot be cleared") asset_id = self.normalize_asset_id(request.asset_id) - duplicate = self.repository.get_miner_by_asset_id(asset_id) - if duplicate and duplicate["id"] != miner_id: - raise DuplicateAssetID(asset_id) - values["asset_tag"] = asset_id + existing_asset_id = ( + str(existing.get("asset_tag") or "").strip() or None + ) + if asset_id != existing_asset_id: + raise InventoryValidationError( + "Asset ID changes require actor-attributed metadata update" + ) else: asset_id = existing["asset_tag"] diff --git a/backend/tests/test_inventory_domain.py b/backend/tests/test_inventory_domain.py index eb8db17..6840ac4 100644 --- a/backend/tests/test_inventory_domain.py +++ b/backend/tests/test_inventory_domain.py @@ -1,13 +1,17 @@ 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, @@ -48,14 +52,26 @@ class FakeInventoryRepository: def __init__(self): self.rows = {} self.created_values = None + self.asset_id_history = [] + self.update_error = None @contextmanager def transaction(self): - yield self + rows_snapshot = deepcopy(self.rows) + history_snapshot = deepcopy(self.asset_id_history) + try: + yield self + except Exception: + self.rows = rows_snapshot + self.asset_id_history = history_snapshot + raise def get_miner_by_id(self, miner_id): return self.rows.get(miner_id) + def get_miner_by_id_for_update(self, miner_id): + return self.get_miner_by_id(miner_id) + def get_miner_by_asset_id(self, asset_id): key = asset_id.strip().casefold() return next( @@ -86,6 +102,8 @@ class FakeInventoryRepository: return row def update_miner(self, miner_id, values): + if self.update_error is not None: + raise self.update_error row = self.rows.get(miner_id) if not row: return None @@ -93,6 +111,27 @@ class FakeInventoryRepository: row["updated_at"] = datetime.now(timezone.utc) return row + def add_asset_id_history( + self, miner_id, old_asset_id, new_asset_id, changed_by + ): + history = { + "id": uuid4(), + "miner_id": miner_id, + "old_asset_id": old_asset_id, + "new_asset_id": new_asset_id, + "changed_at": datetime.now(timezone.utc), + "changed_by": changed_by, + } + self.asset_id_history.append(history) + return history + + def list_asset_id_history(self, miner_id): + return [ + row + for row in self.asset_id_history + if row["miner_id"] == miner_id + ] + def add_tag(self, miner_id, tag_name): now = datetime.now(timezone.utc) tag = { @@ -121,6 +160,7 @@ class InventoryServiceTests(unittest.TestCase): def setUp(self): self.repository = FakeInventoryRepository() self.service = InventoryService(self.repository) + self.actor_user_id = uuid4() def test_create_normalizes_asset_id_tags_and_never_seen(self): created = self.service.create_miner( @@ -167,6 +207,123 @@ class InventoryServiceTests(unittest.TestCase): with self.assertRaises(InventoryValidationError): self.service.normalize_tags(["valid", "\t\n"]) + def test_metadata_update_preserves_unspecified_fields(self): + row = inventory_row( + asset_tag="ASSET-001", + miner_sn="MINER-OLD", + power_sn="PSU-OLD", + model="M50", + notes="Old notes", + ) + self.repository.rows[row["id"]] = row + + updated = self.service.update_metadata( + row["id"], + InventoryMetadataUpdateRequest( + model="M60", + notes=None, + ), + actor_user_id=self.actor_user_id, + ) + + self.assertEqual(updated.id, row["id"]) + self.assertEqual(updated.asset_id, "ASSET-001") + self.assertEqual(updated.miner_sn, "MINER-OLD") + self.assertEqual(updated.psu_serial, "PSU-OLD") + self.assertEqual(updated.model, "M60") + self.assertIsNone(updated.notes) + self.assertEqual(self.repository.asset_id_history, []) + + def test_asset_id_change_preserves_uuid_and_creates_history(self): + row = inventory_row(asset_tag="ASSET-OLD") + self.repository.rows[row["id"]] = row + + updated = self.service.update_metadata( + row["id"], + InventoryMetadataUpdateRequest(asset_id=" ASSET-NEW "), + actor_user_id=self.actor_user_id, + ) + + self.assertEqual(updated.id, row["id"]) + self.assertEqual(updated.asset_id, "ASSET-NEW") + self.assertEqual(len(self.repository.asset_id_history), 1) + history = self.repository.asset_id_history[0] + self.assertEqual(history["miner_id"], row["id"]) + self.assertEqual(history["old_asset_id"], "ASSET-OLD") + self.assertEqual(history["new_asset_id"], "ASSET-NEW") + self.assertEqual(history["changed_by"], self.actor_user_id) + + def test_unchanged_normalized_asset_id_creates_no_history(self): + row = inventory_row(asset_tag="ASSET-001") + original_updated_at = row["updated_at"] + self.repository.rows[row["id"]] = row + + updated = self.service.update_metadata( + row["id"], + InventoryMetadataUpdateRequest(asset_id=" ASSET-001 "), + actor_user_id=self.actor_user_id, + ) + + self.assertEqual(updated.asset_id, "ASSET-001") + self.assertEqual(updated.updated_at, original_updated_at) + self.assertEqual(self.repository.asset_id_history, []) + + def test_duplicate_asset_id_leaves_miner_and_history_unchanged(self): + row = inventory_row(asset_tag="ASSET-OLD") + duplicate = inventory_row(asset_tag="ASSET-TAKEN") + self.repository.rows[row["id"]] = row + self.repository.rows[duplicate["id"]] = duplicate + + with self.assertRaises(DuplicateAssetID): + self.service.update_metadata( + row["id"], + InventoryMetadataUpdateRequest(asset_id=" asset-taken "), + actor_user_id=self.actor_user_id, + ) + + self.assertEqual( + self.repository.rows[row["id"]]["asset_tag"], + "ASSET-OLD", + ) + self.assertEqual(self.repository.asset_id_history, []) + + def test_empty_or_invalid_metadata_update_does_not_write(self): + row = inventory_row(asset_tag="ASSET-OLD", model="M50") + self.repository.rows[row["id"]] = row + + for request in ( + InventoryMetadataUpdateRequest(), + InventoryMetadataUpdateRequest(asset_id=" ", model="M60"), + ): + with self.subTest(request=request): + with self.assertRaises(InventoryValidationError): + self.service.update_metadata( + row["id"], + request, + actor_user_id=self.actor_user_id, + ) + + self.assertEqual(self.repository.rows[row["id"]]["model"], "M50") + self.assertEqual(self.repository.asset_id_history, []) + + def test_uniqueness_race_becomes_duplicate_asset_id(self): + row = inventory_row(asset_tag="ASSET-OLD") + self.repository.rows[row["id"]] = row + self.repository.update_error = UniqueViolation("duplicate Asset ID") + + with self.assertRaises(DuplicateAssetID): + self.service.update_metadata( + row["id"], + InventoryMetadataUpdateRequest(asset_id="ASSET-NEW"), + actor_user_id=self.actor_user_id, + ) + + self.assertEqual( + self.repository.rows[row["id"]]["asset_tag"], + "ASSET-OLD", + ) + self.assertEqual(self.repository.asset_id_history, []) + def test_update_maps_product_names_without_repository_rules(self): row = inventory_row(asset_tag="ASSET-OLD", power_sn="PSU-OLD") self.repository.rows[row["id"]] = row @@ -174,16 +331,34 @@ class InventoryServiceTests(unittest.TestCase): updated = self.service.update_miner( row["id"], InventoryUpdateRequest( - asset_id=" ASSET-NEW ", psu_serial=" PSU-NEW ", lifecycle_status="retired", ), ) - self.assertEqual(updated.asset_id, "ASSET-NEW") + self.assertEqual(updated.asset_id, "ASSET-OLD") self.assertEqual(updated.psu_serial, "PSU-NEW") self.assertEqual(updated.lifecycle_status, "retired") + def test_legacy_update_rejects_asset_id_change_without_actor(self): + row = inventory_row(asset_tag="ASSET-OLD") + self.repository.rows[row["id"]] = row + + with self.assertRaisesRegex( + InventoryValidationError, + "actor-attributed metadata update", + ): + self.service.update_miner( + row["id"], + InventoryUpdateRequest(asset_id="ASSET-NEW"), + ) + + self.assertEqual( + self.repository.rows[row["id"]]["asset_tag"], + "ASSET-OLD", + ) + self.assertEqual(self.repository.asset_id_history, []) + def test_search_validation_precedes_repository(self): with self.assertRaises(InventoryValidationError): self.service.search_miners( @@ -197,11 +372,30 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase): cls.repository = InventoryRepository() def setUp(self): + self.actor_user_id = UUID("00000000-0000-0000-0000-000000000951") with self.repository.transaction() as repository: with repository._connection.cursor() as cur: + cur.execute("DELETE FROM miner_asset_id_history") cur.execute("DELETE FROM miner_tags") cur.execute("DELETE FROM tags") cur.execute("DELETE FROM miners") + cur.execute( + """ + INSERT INTO users ( + id, username, display_name, password_hash, role_id + ) + SELECT %s, %s, %s, %s, id + FROM roles + WHERE name = 'Admin' + ON CONFLICT (id) DO NOTHING + """, + ( + self.actor_user_id, + "inventory-history-test", + "Inventory History Test", + "not-used", + ), + ) def test_repository_crud_search_and_tags(self): created = self.repository.create_miner( @@ -262,6 +456,64 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase): [], ) + def test_repeated_asset_id_changes_preserve_chronological_history(self): + service = InventoryService(self.repository) + created = service.create_miner( + InventoryCreateRequest(asset_id="ASSET-FIRST") + ) + + second = service.update_metadata( + created.id, + InventoryMetadataUpdateRequest(asset_id="ASSET-SECOND"), + actor_user_id=self.actor_user_id, + ) + third = service.update_metadata( + created.id, + InventoryMetadataUpdateRequest(asset_id="ASSET-THIRD"), + actor_user_id=self.actor_user_id, + ) + + 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"], + ) + + 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: diff --git a/backend/tests/test_inventory_router.py b/backend/tests/test_inventory_router.py index ede1fc9..d6a9bf6 100644 --- a/backend/tests/test_inventory_router.py +++ b/backend/tests/test_inventory_router.py @@ -10,10 +10,13 @@ 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 ( + InventoryCreateRequest, + InventoryMetadataUpdateRequest, InventoryMinerDetail, InventoryMinerSummary, InventorySearchFilters, @@ -28,6 +31,14 @@ class StubInventoryService: 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 def search_miners( self, @@ -38,6 +49,33 @@ class StubInventoryService: raise self.search_error return self.search_result + 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: @@ -89,6 +127,219 @@ class InventoryRouterTests(unittest.TestCase): "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) 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-dev-baseline.sh b/scripts/test-dev-baseline.sh index afaee47..383f1cb 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -88,7 +88,7 @@ health_json="$(cat "${health_file}")" echo "Checking migrations and required schema..." current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" -[[ "${current_revision}" == *"0008_manual_miner_inventory"* ]] +[[ "${current_revision}" == *"0009_inventory_asset_id_history"* ]] missing_tables="$( "${compose[@]}" exec -T postgres sh -c \ diff --git a/sql/verify-development-schema.sql b/sql/verify-development-schema.sql index 79f5010..9ee8e2c 100644 --- a/sql/verify-development-schema.sql +++ b/sql/verify-development-schema.sql @@ -6,6 +6,7 @@ WITH expected(table_name) AS ( ('miners'), ('tags'), ('miner_tags'), + ('miner_asset_id_history'), ('miner_status_snapshots'), ('miner_status_hourly_rollups'), ('miner_status_daily_rollups'), From d738b8e27e02f9554d27425a19f59e131283529e Mon Sep 17 00:00:00 2001 From: JC Date: Tue, 28 Jul 2026 19:48:57 +0000 Subject: [PATCH 14/37] feat: add asset ID history API --- backend/app/inventory/models.py | 14 +++ backend/app/inventory/repository.py | 22 +++-- backend/app/inventory/router.py | 22 +++++ backend/app/inventory/service.py | 12 +++ backend/tests/test_inventory_domain.py | 76 ++++++++++++++++ backend/tests/test_inventory_router.py | 117 +++++++++++++++++++++++++ 6 files changed, 254 insertions(+), 9 deletions(-) diff --git a/backend/app/inventory/models.py b/backend/app/inventory/models.py index 3a15b4c..b193911 100644 --- a/backend/app/inventory/models.py +++ b/backend/app/inventory/models.py @@ -43,6 +43,14 @@ class InventoryMinerDetail(InventoryMinerSummary): first_seen_at: datetime | None = None +class InventoryAssetIDHistoryEntry(BaseModel): + old_asset_id: str | None = None + new_asset_id: str + changed_at: datetime + changed_by: UUID + changed_by_display_name: str + + class InventoryCreateRequest(BaseModel): asset_id: str = Field(min_length=1, max_length=120) current_mac: str | None = Field(default=None, max_length=64) @@ -112,3 +120,9 @@ 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 diff --git a/backend/app/inventory/repository.py b/backend/app/inventory/repository.py index 7b7035e..c1ec443 100644 --- a/backend/app/inventory/repository.py +++ b/backend/app/inventory/repository.py @@ -347,15 +347,19 @@ class InventoryRepository: cur.execute( """ SELECT - id, - miner_id, - old_asset_id, - new_asset_id, - changed_at, - changed_by - FROM miner_asset_id_history - WHERE miner_id = %s - ORDER BY changed_at, id + 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,), ) diff --git a/backend/app/inventory/router.py b/backend/app/inventory/router.py index 262b933..980cf22 100644 --- a/backend/app/inventory/router.py +++ b/backend/app/inventory/router.py @@ -13,6 +13,7 @@ from app.inventory.exceptions import ( MinerNotFound, ) from app.inventory.models import ( + InventoryAssetIDHistoryResponse, InventoryCreateRequest, InventoryListMeta, InventoryMetadataUpdateRequest, @@ -120,6 +121,27 @@ def list_inventory_miners( ) +@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, diff --git a/backend/app/inventory/service.py b/backend/app/inventory/service.py index 18bd20a..f0706b8 100644 --- a/backend/app/inventory/service.py +++ b/backend/app/inventory/service.py @@ -12,6 +12,7 @@ from app.inventory.exceptions import ( MinerNotFound, ) from app.inventory.models import ( + InventoryAssetIDHistoryEntry, InventoryCreateRequest, InventoryMetadataUpdateRequest, InventoryMinerDetail, @@ -112,6 +113,17 @@ class InventoryService: 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, *, diff --git a/backend/tests/test_inventory_domain.py b/backend/tests/test_inventory_domain.py index 6840ac4..091c091 100644 --- a/backend/tests/test_inventory_domain.py +++ b/backend/tests/test_inventory_domain.py @@ -17,7 +17,9 @@ from app.inventory import ( InventoryService, InventoryUpdateRequest, InventoryValidationError, + MinerNotFound, ) +from app.inventory.models import InventoryAssetIDHistoryEntry def inventory_row(**overrides): @@ -121,6 +123,7 @@ class FakeInventoryRepository: "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 @@ -207,6 +210,60 @@ class InventoryServiceTests(unittest.TestCase): with self.assertRaises(InventoryValidationError): self.service.normalize_tags(["valid", "\t\n"]) + def test_asset_id_history_empty_for_known_miner(self): + row = inventory_row() + self.repository.rows[row["id"]] = row + + self.assertEqual( + self.service.get_asset_id_history(row["id"]), + [], + ) + + def test_asset_id_history_maps_populated_repository_results(self): + row = inventory_row() + self.repository.rows[row["id"]] = row + first_changed_at = datetime(2026, 7, 27, tzinfo=timezone.utc) + second_changed_at = datetime(2026, 7, 28, tzinfo=timezone.utc) + self.repository.asset_id_history = [ + { + "id": uuid4(), + "miner_id": row["id"], + "old_asset_id": "ASSET-FIRST", + "new_asset_id": "ASSET-SECOND", + "changed_at": first_changed_at, + "changed_by": self.actor_user_id, + "changed_by_display_name": "Test Actor", + }, + { + "id": uuid4(), + "miner_id": row["id"], + "old_asset_id": "ASSET-SECOND", + "new_asset_id": "ASSET-THIRD", + "changed_at": second_changed_at, + "changed_by": self.actor_user_id, + "changed_by_display_name": "Test Actor", + }, + ] + + history = self.service.get_asset_id_history(row["id"]) + + self.assertEqual(len(history), 2) + self.assertTrue( + all( + isinstance(entry, InventoryAssetIDHistoryEntry) + for entry in history + ) + ) + self.assertEqual( + [entry.new_asset_id for entry in history], + ["ASSET-SECOND", "ASSET-THIRD"], + ) + self.assertEqual(history[0].changed_by_display_name, "Test Actor") + + def test_asset_id_history_unknown_miner_raises_not_found(self): + with self.assertRaises(MinerNotFound): + self.service.get_asset_id_history(uuid4()) + def test_metadata_update_preserves_unspecified_fields(self): row = inventory_row( asset_tag="ASSET-001", @@ -472,6 +529,14 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase): 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) @@ -491,6 +556,17 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase): 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) diff --git a/backend/tests/test_inventory_router.py b/backend/tests/test_inventory_router.py index d6a9bf6..d4e2228 100644 --- a/backend/tests/test_inventory_router.py +++ b/backend/tests/test_inventory_router.py @@ -15,6 +15,7 @@ from app.inventory.exceptions import ( MinerNotFound, ) from app.inventory.models import ( + InventoryAssetIDHistoryEntry, InventoryCreateRequest, InventoryMetadataUpdateRequest, InventoryMinerDetail, @@ -39,6 +40,9 @@ class StubInventoryService: 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 def search_miners( self, @@ -82,6 +86,15 @@ class StubInventoryService: 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) @@ -106,6 +119,21 @@ def miner_detail(miner_id: UUID | None = None) -> InventoryMinerDetail: ) +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() @@ -350,11 +378,26 @@ class InventoryRouterTests(unittest.TestCase): 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()] @@ -457,6 +500,80 @@ class InventoryRouterTests(unittest.TestCase): 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" From f6a2512e2301ba844edd2499f7aabbf792b4ca4d Mon Sep 17 00:00:00 2001 From: JC Date: Wed, 29 Jul 2026 14:50:46 +0000 Subject: [PATCH 15/37] feat: add begin repair workflow --- backend/alembic/versions/0010_begin_repair.py | 322 ++++++++ backend/app/identity.py | 2 + backend/app/inventory/models.py | 2 +- backend/app/inventory/service.py | 4 +- backend/app/main.py | 2 + backend/app/repairs/__init__.py | 37 + backend/app/repairs/dependencies.py | 8 + backend/app/repairs/exceptions.py | 58 ++ backend/app/repairs/models.py | 65 ++ backend/app/repairs/repository.py | 225 ++++++ backend/app/repairs/router.py | 60 ++ backend/app/repairs/service.py | 103 +++ backend/tests/test_repairs_domain.py | 737 ++++++++++++++++++ backend/tests/test_repairs_router.py | 277 +++++++ docs/ROADMAP.md | 7 +- docs/tasks/001-begin-repair.md | 544 +++++++++++++ scripts/test-begin-repair-migration.sh | 556 +++++++++++++ scripts/test-dev-baseline.sh | 37 +- scripts/test-inventory-domain.sh | 4 +- scripts/test-repairs-domain.sh | 61 ++ sql/verify-development-schema.sql | 2 + 21 files changed, 3108 insertions(+), 5 deletions(-) create mode 100644 backend/alembic/versions/0010_begin_repair.py create mode 100644 backend/app/repairs/__init__.py create mode 100644 backend/app/repairs/dependencies.py create mode 100644 backend/app/repairs/exceptions.py create mode 100644 backend/app/repairs/models.py create mode 100644 backend/app/repairs/repository.py create mode 100644 backend/app/repairs/router.py create mode 100644 backend/app/repairs/service.py create mode 100644 backend/tests/test_repairs_domain.py create mode 100644 backend/tests/test_repairs_router.py create mode 100644 docs/tasks/001-begin-repair.md create mode 100755 scripts/test-begin-repair-migration.sh create mode 100755 scripts/test-repairs-domain.sh 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/app/identity.py b/backend/app/identity.py index 6a0f94c..333672c 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -674,6 +674,8 @@ def machine_agent_request(path: str) -> bool: def required_api_permission(method: str, path: str) -> str | None: if path.startswith("/api/admin/"): return "users.manage" + if method == "POST" and path == "/api/repairs": + return "repairs.create" if method == "GET": if path.startswith("/api/system/"): return "system_health.view" diff --git a/backend/app/inventory/models.py b/backend/app/inventory/models.py index b193911..2c5204e 100644 --- a/backend/app/inventory/models.py +++ b/backend/app/inventory/models.py @@ -7,7 +7,7 @@ from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress -LifecycleStatus = Literal["active", "spare", "retired"] +LifecycleStatus = Literal["active", "spare", "retired", "in_repair"] class InventoryTag(BaseModel): diff --git a/backend/app/inventory/service.py b/backend/app/inventory/service.py index f0706b8..2237811 100644 --- a/backend/app/inventory/service.py +++ b/backend/app/inventory/service.py @@ -24,7 +24,9 @@ from app.inventory.models import ( from app.inventory.repository import InventoryRepository -ALLOWED_LIFECYCLE_STATUSES = frozenset({"active", "spare", "retired"}) +ALLOWED_LIFECYCLE_STATUSES = frozenset( + {"active", "spare", "retired", "in_repair"} +) class InventoryService: diff --git a/backend/app/main.py b/backend/app/main.py index 2d8dc8c..037b697 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,11 +18,13 @@ from app.identity import ( router as identity_router, ) from app.inventory.router import router as inventory_router +from app.repairs.router import router as repairs_router app = FastAPI(title=settings.app_name) app.include_router(identity_router) app.include_router(inventory_router) +app.include_router(repairs_router) install_identity_middleware(app) diff --git a/backend/app/repairs/__init__.py b/backend/app/repairs/__init__.py new file mode 100644 index 0000000..7f2d3a7 --- /dev/null +++ b/backend/app/repairs/__init__.py @@ -0,0 +1,37 @@ +"""Repair workflow models, persistence, and business services.""" + +from app.repairs.exceptions import ( + ActiveRepairConflict, + IncompatibleMinerLifecycle, + InconsistentRepairState, + RepairActorRequired, + RepairConflict, + RepairError, + RepairMinerNotFound, + RepairPersistenceError, +) +from app.repairs.models import ( + BeginRepairRequest, + BeginRepairResult, + RepairReason, + RepairStatus, +) +from app.repairs.repository import RepairRepository +from app.repairs.service import RepairService + +__all__ = [ + "ActiveRepairConflict", + "BeginRepairRequest", + "BeginRepairResult", + "IncompatibleMinerLifecycle", + "InconsistentRepairState", + "RepairActorRequired", + "RepairConflict", + "RepairError", + "RepairMinerNotFound", + "RepairPersistenceError", + "RepairReason", + "RepairRepository", + "RepairService", + "RepairStatus", +] diff --git a/backend/app/repairs/dependencies.py b/backend/app/repairs/dependencies.py new file mode 100644 index 0000000..8fafe37 --- /dev/null +++ b/backend/app/repairs/dependencies.py @@ -0,0 +1,8 @@ +"""Dependency construction for the Repairs domain.""" + +from app.repairs.repository import RepairRepository +from app.repairs.service import RepairService + + +def get_repair_service() -> RepairService: + return RepairService(RepairRepository()) diff --git a/backend/app/repairs/exceptions.py b/backend/app/repairs/exceptions.py new file mode 100644 index 0000000..b570d65 --- /dev/null +++ b/backend/app/repairs/exceptions.py @@ -0,0 +1,58 @@ +"""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 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 RepairPersistenceError(RepairError): + """Raised when a required repair persistence operation fails.""" diff --git a/backend/app/repairs/models.py b/backend/app/repairs/models.py new file mode 100644 index 0000000..bc96be4 --- /dev/null +++ b/backend/app/repairs/models.py @@ -0,0 +1,65 @@ +"""Typed contracts for the Repairs domain.""" + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +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"] +RepairEligibleLifecycle = Literal["active", "spare"] + + +class BeginRepairRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + miner_id: UUID + reason: RepairReason + 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 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 diff --git a/backend/app/repairs/repository.py b/backend/app/repairs/repository.py new file mode 100644 index 0000000..215d51f --- /dev/null +++ b/backend/app/repairs/repository.py @@ -0,0 +1,225 @@ +"""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 UniqueViolation +from psycopg.rows import dict_row +from psycopg.types.json import Jsonb + +from app.config import settings +from app.repairs.exceptions import ( + ActiveRepairConflict, + 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 the atomic Begin Repair workflow.""" + + 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 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, lifecycle_status + FROM miners + WHERE id = %s + FOR UPDATE + """, + (miner_id,), + ) + 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" + ) diff --git a/backend/app/repairs/router.py b/backend/app/repairs/router.py new file mode 100644 index 0000000..a369bbf --- /dev/null +++ b/backend/app/repairs/router.py @@ -0,0 +1,60 @@ +"""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 ( + RepairActorRequired, + RepairConflict, + RepairMinerNotFound, +) +from app.repairs.models import BeginRepairRequest, BeginRepairResponse +from app.repairs.service import RepairService + + +router = APIRouter(prefix="/api/repairs", tags=["repairs"]) +require_repair_create = require_permission("repairs.create") + +RepairServiceDependency = Annotated[ + RepairService, + Depends(get_repair_service), +] +RepairCreateUser = Annotated[dict, Depends(require_repair_create)] + + +@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) diff --git a/backend/app/repairs/service.py b/backend/app/repairs/service.py new file mode 100644 index 0000000..af63ffa --- /dev/null +++ b/backend/app/repairs/service.py @@ -0,0 +1,103 @@ +"""Business rules and orchestration for repair workflows.""" + +from uuid import UUID + +from app.repairs.exceptions import ( + ActiveRepairConflict, + IncompatibleMinerLifecycle, + InconsistentRepairState, + RepairActorRequired, + RepairMinerNotFound, +) +from app.repairs.models import ( + BeginRepairRequest, + BeginRepairResult, + InitialRepairNotePayload, + RepairStartedPayload, +) +from app.repairs.repository import RepairRepository + + +ELIGIBLE_LIFECYCLE_STATUSES = frozenset({"active", "spare"}) +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 + + 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"], + ) diff --git a/backend/tests/test_repairs_domain.py b/backend/tests/test_repairs_domain.py new file mode 100644 index 0000000..cb472a5 --- /dev/null +++ b/backend/tests/test_repairs_domain.py @@ -0,0 +1,737 @@ +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("DELETE FROM repair_events") + 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..bf4d121 --- /dev/null +++ b/backend/tests/test_repairs_router.py @@ -0,0 +1,277 @@ +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_exactly_one_post_route(self): + routes = [ + (route.path, route.methods) + for route in router.routes + if hasattr(route, "methods") + ] + 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/docs/ROADMAP.md b/docs/ROADMAP.md index 70d5eb6..783db9e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -92,13 +92,17 @@ location changes. ### Phase 3: Operations -**Status: Planned direction.** +**Status: In progress.** This phase develops day-to-day technician and operations workflows around the durable asset record. Repairs, maintenance, work orders, parts usage, equipment movement, testing, and return-to-service history become coherent operational records rather than disconnected actions. +The Begin Repair workflow is complete. An authorized actor can open a repair +against a durable miner UUID, record sequenced starting events, and move the +miner into repair atomically. Later repair lifecycle workflows remain planned. + Technician experience is a priority. Responsive and QR-assisted workflows may reduce lookup and data-entry effort where they reinforce the same asset record. Existing repair and location behavior should move incrementally into clear @@ -209,6 +213,7 @@ Major completed foundations include: - Authentication, login gating, and role foundation - Manual Inventory schema and backend domain foundation - Focused Inventory and migration test foundations +- Begin Repair operational workflow - MinerOps branding package and project README - Architecture guide - Product guide 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/scripts/test-begin-repair-migration.sh b/scripts/test-begin-repair-migration.sh new file mode 100755 index 0000000..a43dc3e --- /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 head +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 head +reupgraded_revision="$(run_alembic current 2>/dev/null)" +[[ "${reupgraded_revision}" == *"0010_begin_repair"* ]] + +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_repair_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}" == *"0010_begin_repair"* ]] + +echo "Begin Repair migration test passed." diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh index 383f1cb..1382623 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -88,7 +88,7 @@ health_json="$(cat "${health_file}")" echo "Checking migrations and required schema..." current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" -[[ "${current_revision}" == *"0009_inventory_asset_id_history"* ]] +[[ "${current_revision}" == *"0010_begin_repair"* ]] missing_tables="$( "${compose[@]}" exec -T postgres sh -c \ @@ -162,6 +162,41 @@ done [[ "$(status_for "${readonly_cookie}" GET /locations)" == "200" ]] [[ "$(status_for "${readonly_cookie}" POST /api/rooms '{"name":"Must Not Be Created"}')" == "403" ]] +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, + status, + lifecycle_status, + first_seen_at, + last_seen_at +) +VALUES ( + '00000000-0000-0000-0000-000000000981', + 'SMOKE-REPAIR-001', + '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" ]] + room_status="$(status_for "${technician_cookie}" POST /api/rooms '{"name":"Role Matrix Test Room"}')" [[ "${room_status}" == "200" || "${room_status}" == "201" ]] diff --git a/scripts/test-inventory-domain.sh b/scripts/test-inventory-domain.sh index 526432b..b0ca167 100755 --- a/scripts/test-inventory-domain.sh +++ b/scripts/test-inventory-domain.sh @@ -53,6 +53,8 @@ echo "Migrating disposable database..." echo "Running inventory repository and service tests..." "${compose[@]}" run --rm --no-deps \ -v "${repo_root}/backend/tests:/app/tests:ro" \ - backend python -m unittest discover -s /app/tests -v + backend python -m unittest -v \ + tests.test_inventory_domain \ + tests.test_inventory_router echo "Inventory domain 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/sql/verify-development-schema.sql b/sql/verify-development-schema.sql index 9ee8e2c..ba67376 100644 --- a/sql/verify-development-schema.sql +++ b/sql/verify-development-schema.sql @@ -7,6 +7,8 @@ WITH expected(table_name) AS ( ('tags'), ('miner_tags'), ('miner_asset_id_history'), + ('repairs'), + ('repair_events'), ('miner_status_snapshots'), ('miner_status_hourly_rollups'), ('miner_status_daily_rollups'), From 36a0caecd826d3f40a978b599a7e56d01f23bf88 Mon Sep 17 00:00:00 2001 From: JC Date: Wed, 29 Jul 2026 16:28:44 +0000 Subject: [PATCH 16/37] feat: add repair timeline read APIs --- .../alembic/versions/0011_repair_timeline.py | 55 ++ backend/app/identity.py | 13 + backend/app/main.py | 5 +- backend/app/repairs/__init__.py | 10 + backend/app/repairs/exceptions.py | 8 + backend/app/repairs/models.py | 50 +- backend/app/repairs/repository.py | 70 ++ backend/app/repairs/router.py | 50 +- backend/app/repairs/service.py | 57 ++ backend/tests/test_repair_timeline.py | 804 ++++++++++++++++++ backend/tests/test_repairs_router.py | 2 +- docs/ROADMAP.md | 9 +- docs/tasks/002-repair-timeline.md | 183 ++++ scripts/test-dev-baseline.sh | 25 +- scripts/test-repair-timeline-migration.sh | 171 ++++ scripts/test-repair-timeline.sh | 59 ++ 16 files changed, 1562 insertions(+), 9 deletions(-) create mode 100644 backend/alembic/versions/0011_repair_timeline.py create mode 100644 backend/tests/test_repair_timeline.py create mode 100644 docs/tasks/002-repair-timeline.md create mode 100755 scripts/test-repair-timeline-migration.sh create mode 100755 scripts/test-repair-timeline.sh 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/app/identity.py b/backend/app/identity.py index 333672c..5330a6e 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -671,11 +671,24 @@ def machine_agent_request(path: str) -> bool: return path in exact +def repair_timeline_request(path: str) -> bool: + parts = path.split("/") + if len(parts) == 4 and parts[:3] == ["", "api", "repairs"]: + return parts[3] not in {"queue", "parts-summary"} + return ( + len(parts) == 5 + and parts[:3] == ["", "api", "repairs"] + and parts[4] == "events" + ) + + def required_api_permission(method: str, path: str) -> str | None: if path.startswith("/api/admin/"): return "users.manage" if method == "POST" and path == "/api/repairs": return "repairs.create" + if method == "GET" and repair_timeline_request(path): + return "repairs.read" if method == "GET": if path.startswith("/api/system/"): return "system_health.view" diff --git a/backend/app/main.py b/backend/app/main.py index 037b697..8f17469 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,7 +24,6 @@ from app.repairs.router import router as repairs_router app = FastAPI(title=settings.app_name) app.include_router(identity_router) app.include_router(inventory_router) -app.include_router(repairs_router) install_identity_middleware(app) @@ -35671,3 +35670,7 @@ 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) diff --git a/backend/app/repairs/__init__.py b/backend/app/repairs/__init__.py index 7f2d3a7..365f364 100644 --- a/backend/app/repairs/__init__.py +++ b/backend/app/repairs/__init__.py @@ -8,13 +8,18 @@ from app.repairs.exceptions import ( RepairConflict, RepairError, RepairMinerNotFound, + RepairNotFound, RepairPersistenceError, ) from app.repairs.models import ( BeginRepairRequest, BeginRepairResult, + RepairActor, + RepairDetail, RepairReason, RepairStatus, + RepairTimeline, + RepairTimelineEvent, ) from app.repairs.repository import RepairRepository from app.repairs.service import RepairService @@ -25,13 +30,18 @@ __all__ = [ "BeginRepairResult", "IncompatibleMinerLifecycle", "InconsistentRepairState", + "RepairActor", "RepairActorRequired", "RepairConflict", + "RepairDetail", "RepairError", "RepairMinerNotFound", + "RepairNotFound", "RepairPersistenceError", "RepairReason", "RepairRepository", "RepairService", "RepairStatus", + "RepairTimeline", + "RepairTimelineEvent", ] diff --git a/backend/app/repairs/exceptions.py b/backend/app/repairs/exceptions.py index b570d65..9b7938e 100644 --- a/backend/app/repairs/exceptions.py +++ b/backend/app/repairs/exceptions.py @@ -13,6 +13,14 @@ class RepairMinerNotFound(RepairError): 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.""" diff --git a/backend/app/repairs/models.py b/backend/app/repairs/models.py index bc96be4..5129618 100644 --- a/backend/app/repairs/models.py +++ b/backend/app/repairs/models.py @@ -1,10 +1,10 @@ """Typed contracts for the Repairs domain.""" from datetime import datetime -from typing import Literal +from typing import Any, Literal from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field RepairReason = Literal[ @@ -30,6 +30,7 @@ RepairStatus = Literal[ ] RepairEventType = Literal["REPAIR_STARTED", "INITIAL_NOTE"] RepairEligibleLifecycle = Literal["active", "spare"] +RepairMinerLifecycle = Literal["active", "spare", "retired", "in_repair"] class BeginRepairRequest(BaseModel): @@ -63,3 +64,48 @@ 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 diff --git a/backend/app/repairs/repository.py b/backend/app/repairs/repository.py index 215d51f..4013a47 100644 --- a/backend/app/repairs/repository.py +++ b/backend/app/repairs/repository.py @@ -223,3 +223,73 @@ class RepairRepository: raise RepairPersistenceError( "Miner lifecycle transition did not update one record" ) + + 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 index a369bbf..5ae9ec7 100644 --- a/backend/app/repairs/router.py +++ b/backend/app/repairs/router.py @@ -11,19 +11,27 @@ from app.repairs.exceptions import ( RepairActorRequired, RepairConflict, RepairMinerNotFound, + RepairNotFound, +) +from app.repairs.models import ( + BeginRepairRequest, + BeginRepairResponse, + RepairDetailResponse, + RepairTimelineResponse, ) -from app.repairs.models import BeginRepairRequest, BeginRepairResponse from app.repairs.service import RepairService router = APIRouter(prefix="/api/repairs", tags=["repairs"]) require_repair_create = require_permission("repairs.create") +require_repair_read = require_permission("repairs.read") RepairServiceDependency = Annotated[ RepairService, Depends(get_repair_service), ] RepairCreateUser = Annotated[dict, Depends(require_repair_create)] +RepairReadUser = Annotated[dict, Depends(require_repair_read)] @router.post( @@ -58,3 +66,43 @@ def begin_repair( ) from exc return BeginRepairResponse(data=repair) + + +@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 index af63ffa..e841377 100644 --- a/backend/app/repairs/service.py +++ b/backend/app/repairs/service.py @@ -8,11 +8,16 @@ from app.repairs.exceptions import ( InconsistentRepairState, RepairActorRequired, RepairMinerNotFound, + RepairNotFound, ) from app.repairs.models import ( BeginRepairRequest, BeginRepairResult, InitialRepairNotePayload, + RepairActor, + RepairDetail, + RepairTimeline, + RepairTimelineEvent, RepairStartedPayload, ) from app.repairs.repository import RepairRepository @@ -101,3 +106,55 @@ class RepairService: opened_at=repair["opened_at"], opened_by=repair["opened_by"], ) + + def get_repair(self, repair_id: UUID) -> RepairDetail: + repair = self.repository.get_repair_detail(repair_id) + if repair is None: + raise RepairNotFound(repair_id) + + 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 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/tests/test_repair_timeline.py b/backend/tests/test_repair_timeline.py new file mode 100644 index 0000000..7a6a605 --- /dev/null +++ b/backend/tests/test_repair_timeline.py @@ -0,0 +1,804 @@ +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("DELETE FROM repair_events") + 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_exactly_two_timeline_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", + }, + ) + + +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}", "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_router.py b/backend/tests/test_repairs_router.py index bf4d121..cc0f111 100644 --- a/backend/tests/test_repairs_router.py +++ b/backend/tests/test_repairs_router.py @@ -236,7 +236,7 @@ class RepairRouterTests(unittest.TestCase): routes = [ (route.path, route.methods) for route in router.routes - if hasattr(route, "methods") + if hasattr(route, "methods") and "POST" in route.methods ] self.assertEqual(routes, [("/api/repairs", {"POST"})]) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 783db9e..deb62ca 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -99,9 +99,11 @@ 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 workflow is complete. An authorized actor can open a repair -against a durable miner UUID, record sequenced starting events, and move the -miner into repair atomically. Later repair lifecycle workflows remain planned. +The Begin Repair and read-only Repair Timeline workflows are complete. An +authorized actor can open a repair against a durable miner UUID, record +sequenced starting events, and move the miner into repair atomically. 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. @@ -214,6 +216,7 @@ Major completed foundations include: - 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 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/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh index 1382623..f9ccfb7 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -88,7 +88,7 @@ health_json="$(cat "${health_file}")" echo "Checking migrations and required schema..." current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" -[[ "${current_revision}" == *"0010_begin_repair"* ]] +[[ "${current_revision}" == *"0011_repair_timeline"* ]] missing_tables="$( "${compose[@]}" exec -T postgres sh -c \ @@ -197,6 +197,29 @@ begin_repair_response="$(curl --fail --silent -b "${technician_cookie}" \ '{"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}" ]] +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":2'* ]] +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}" == *'"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" ]] diff --git a/scripts/test-repair-timeline-migration.sh b/scripts/test-repair-timeline-migration.sh new file mode 100755 index 0000000..08dd87c --- /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 head +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 head +reupgraded_revision="$(run_alembic current 2>/dev/null)" +[[ "${reupgraded_revision}" == *"0011_repair_timeline"* ]] + +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_timeline_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}" == *"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." From 72ad19a071316594d8a6fc803e8840b8ca0074a9 Mon Sep 17 00:00:00 2001 From: JC Date: Wed, 29 Jul 2026 17:11:12 +0000 Subject: [PATCH 17/37] docs: add development and release standards --- docs/CODING_STANDARDS.md | 142 ++++++++++++++++++++++++++ docs/CONTRIBUTING.md | 68 ++++++++++++ docs/releases/v0.2-repair-workflow.md | 50 +++++++++ docs/releases/v0.3-operations.md | 38 +++++++ docs/releases/v0.4-monitoring.md | 38 +++++++ docs/releases/v1.0.md | 34 ++++++ docs/tasks/TEMPLATE.md | 88 ++++++++++++++++ 7 files changed, 458 insertions(+) create mode 100644 docs/CODING_STANDARDS.md create mode 100644 docs/CONTRIBUTING.md create mode 100644 docs/releases/v0.2-repair-workflow.md create mode 100644 docs/releases/v0.3-operations.md create mode 100644 docs/releases/v0.4-monitoring.md create mode 100644 docs/releases/v1.0.md create mode 100644 docs/tasks/TEMPLATE.md 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/releases/v0.2-repair-workflow.md b/docs/releases/v0.2-repair-workflow.md new file mode 100644 index 0000000..915d758 --- /dev/null +++ b/docs/releases/v0.2-repair-workflow.md @@ -0,0 +1,50 @@ +# 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 + +## Planned + +- [ ] Return to Service +- [ ] Control Board Replacement +- [ ] 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 + +## Candidate Task Order + +1. `003-return-to-service.md` +2. `004-control-board-replacement.md` +3. `005-repair-parts-tracking.md` + +These are proposed future files under `docs/tasks/`. They do not exist yet and +must not be treated as approved specifications until they are created and +reviewed. 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/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. From f2b736b08e012ffac50b4f7ac734dc96b6c1e523 Mon Sep 17 00:00:00 2001 From: JC Date: Wed, 29 Jul 2026 19:02:23 +0000 Subject: [PATCH 18/37] feat: add return to service workflow --- .../versions/0012_return_to_service.py | 253 ++++ backend/app/identity.py | 11 + backend/app/repairs/__init__.py | 14 + backend/app/repairs/exceptions.py | 43 + backend/app/repairs/models.py | 21 +- backend/app/repairs/repository.py | 120 +- backend/app/repairs/router.py | 44 + backend/app/repairs/service.py | 139 ++- backend/tests/test_repair_timeline.py | 5 + backend/tests/test_repairs_router.py | 6 +- backend/tests/test_return_to_service.py | 1070 +++++++++++++++++ docs/ROADMAP.md | 10 +- docs/releases/v0.2-repair-workflow.md | 7 +- docs/tasks/003-return-to-service.md | 814 +++++++++++++ scripts/test-begin-repair-migration.sh | 8 +- scripts/test-dev-baseline.sh | 2 +- scripts/test-repair-timeline-migration.sh | 8 +- scripts/test-return-to-service-migration.sh | 295 +++++ scripts/test-return-to-service.sh | 59 + 19 files changed, 2883 insertions(+), 46 deletions(-) create mode 100644 backend/alembic/versions/0012_return_to_service.py create mode 100644 backend/tests/test_return_to_service.py create mode 100644 docs/tasks/003-return-to-service.md create mode 100755 scripts/test-return-to-service-migration.sh create mode 100755 scripts/test-return-to-service.sh 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/app/identity.py b/backend/app/identity.py index 5330a6e..eabf514 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -682,11 +682,22 @@ def repair_timeline_request(path: str) -> bool: ) +def repair_completion_request(path: str) -> bool: + parts = path.split("/") + return ( + len(parts) == 5 + and parts[:3] == ["", "api", "repairs"] + and parts[4] == "complete" + ) + + def required_api_permission(method: str, path: str) -> str | None: if path.startswith("/api/admin/"): return "users.manage" 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": diff --git a/backend/app/repairs/__init__.py b/backend/app/repairs/__init__.py index 365f364..40c5fd5 100644 --- a/backend/app/repairs/__init__.py +++ b/backend/app/repairs/__init__.py @@ -4,10 +4,14 @@ from app.repairs.exceptions import ( ActiveRepairConflict, IncompatibleMinerLifecycle, InconsistentRepairState, + RepairActiveMismatch, RepairActorRequired, + RepairCompletionActorRequired, RepairConflict, RepairError, RepairMinerNotFound, + RepairMinerNotInRepair, + RepairNotActive, RepairNotFound, RepairPersistenceError, ) @@ -15,7 +19,10 @@ from app.repairs.models import ( BeginRepairRequest, BeginRepairResult, RepairActor, + RepairCompleteRequest, + RepairCompletedPayload, RepairDetail, + RepairPostLifecycle, RepairReason, RepairStatus, RepairTimeline, @@ -30,14 +37,21 @@ __all__ = [ "BeginRepairResult", "IncompatibleMinerLifecycle", "InconsistentRepairState", + "RepairActiveMismatch", "RepairActor", "RepairActorRequired", + "RepairCompleteRequest", + "RepairCompletedPayload", + "RepairCompletionActorRequired", "RepairConflict", "RepairDetail", "RepairError", "RepairMinerNotFound", + "RepairMinerNotInRepair", + "RepairNotActive", "RepairNotFound", "RepairPersistenceError", + "RepairPostLifecycle", "RepairReason", "RepairRepository", "RepairService", diff --git a/backend/app/repairs/exceptions.py b/backend/app/repairs/exceptions.py index 9b7938e..65ac36c 100644 --- a/backend/app/repairs/exceptions.py +++ b/backend/app/repairs/exceptions.py @@ -62,5 +62,48 @@ class RepairActorRequired(RepairError): 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.""" diff --git a/backend/app/repairs/models.py b/backend/app/repairs/models.py index 5129618..5ae1cb4 100644 --- a/backend/app/repairs/models.py +++ b/backend/app/repairs/models.py @@ -28,8 +28,13 @@ RepairStatus = Literal[ "COMPLETED", "CANCELLED", ] -RepairEventType = Literal["REPAIR_STARTED", "INITIAL_NOTE"] +RepairEventType = Literal[ + "REPAIR_STARTED", + "INITIAL_NOTE", + "REPAIR_COMPLETED", +] RepairEligibleLifecycle = Literal["active", "spare"] +RepairPostLifecycle = Literal["active", "spare"] RepairMinerLifecycle = Literal["active", "spare", "retired", "in_repair"] @@ -41,6 +46,13 @@ class BeginRepairRequest(BaseModel): 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) + + class RepairStartedPayload(BaseModel): reason: RepairReason from_lifecycle: RepairEligibleLifecycle @@ -51,6 +63,13 @@ 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 BeginRepairResult(BaseModel): repair_id: UUID miner_id: UUID diff --git a/backend/app/repairs/repository.py b/backend/app/repairs/repository.py index 4013a47..dd20ba1 100644 --- a/backend/app/repairs/repository.py +++ b/backend/app/repairs/repository.py @@ -39,7 +39,7 @@ def default_connection_factory() -> psycopg.Connection: class RepairRepository: - """Persistence boundary for the atomic Begin Repair workflow.""" + """Persistence boundary for explicit atomic repair workflows.""" def __init__( self, @@ -224,6 +224,124 @@ class RepairRepository: "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 get_repair_detail(self, repair_id: UUID) -> dict | None: with self._connection_scope() as connection: with connection.cursor(row_factory=dict_row) as cur: diff --git a/backend/app/repairs/router.py b/backend/app/repairs/router.py index 5ae9ec7..7f010ca 100644 --- a/backend/app/repairs/router.py +++ b/backend/app/repairs/router.py @@ -9,6 +9,7 @@ from app.identity import require_permission from app.repairs.dependencies import get_repair_service from app.repairs.exceptions import ( RepairActorRequired, + RepairCompletionActorRequired, RepairConflict, RepairMinerNotFound, RepairNotFound, @@ -16,6 +17,7 @@ from app.repairs.exceptions import ( from app.repairs.models import ( BeginRepairRequest, BeginRepairResponse, + RepairCompleteRequest, RepairDetailResponse, RepairTimelineResponse, ) @@ -25,6 +27,7 @@ from app.repairs.service import RepairService router = APIRouter(prefix="/api/repairs", tags=["repairs"]) require_repair_create = require_permission("repairs.create") require_repair_read = require_permission("repairs.read") +require_repair_complete = require_permission("repairs.complete") RepairServiceDependency = Annotated[ RepairService, @@ -32,6 +35,7 @@ RepairServiceDependency = Annotated[ ] RepairCreateUser = Annotated[dict, Depends(require_repair_create)] RepairReadUser = Annotated[dict, Depends(require_repair_read)] +RepairCompleteUser = Annotated[dict, Depends(require_repair_complete)] @router.post( @@ -68,6 +72,46 @@ def begin_repair( 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}", response_model=RepairDetailResponse, diff --git a/backend/app/repairs/service.py b/backend/app/repairs/service.py index e841377..648036c 100644 --- a/backend/app/repairs/service.py +++ b/backend/app/repairs/service.py @@ -6,15 +6,22 @@ from app.repairs.exceptions import ( ActiveRepairConflict, IncompatibleMinerLifecycle, InconsistentRepairState, + RepairActiveMismatch, RepairActorRequired, + RepairCompletionActorRequired, RepairMinerNotFound, + RepairMinerNotInRepair, + RepairNotActive, RepairNotFound, + RepairPersistenceError, ) from app.repairs.models import ( BeginRepairRequest, BeginRepairResult, InitialRepairNotePayload, RepairActor, + RepairCompleteRequest, + RepairCompletedPayload, RepairDetail, RepairTimeline, RepairTimelineEvent, @@ -24,6 +31,9 @@ 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" @@ -40,6 +50,32 @@ class RepairService: 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, @@ -107,34 +143,89 @@ class RepairService: opened_by=repair["opened_by"], ) + 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) + def get_repair(self, repair_id: UUID) -> RepairDetail: repair = self.repository.get_repair_detail(repair_id) if repair is None: raise RepairNotFound(repair_id) - - 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"], - ) + return self._map_repair_detail(repair) def get_repair_timeline(self, repair_id: UUID) -> RepairTimeline: events = self.repository.get_repair_events(repair_id) diff --git a/backend/tests/test_repair_timeline.py b/backend/tests/test_repair_timeline.py index 7a6a605..e0ad5c9 100644 --- a/backend/tests/test_repair_timeline.py +++ b/backend/tests/test_repair_timeline.py @@ -715,6 +715,11 @@ class RepairTimelineArchitectureTests(unittest.TestCase): "minerops_repair_parts_summary_api", ), ("POST", "/api/repairs", "begin_repair"), + ( + "POST", + "/api/repairs/{repair_id}/complete", + "complete_repair", + ), ("GET", "/api/repairs/{repair_id}", "get_repair"), ( "GET", diff --git a/backend/tests/test_repairs_router.py b/backend/tests/test_repairs_router.py index cc0f111..0ce9ef1 100644 --- a/backend/tests/test_repairs_router.py +++ b/backend/tests/test_repairs_router.py @@ -232,11 +232,13 @@ class RepairRouterTests(unittest.TestCase): self.assertEqual(response.status_code, 422) self.assertIsNone(self.service.request) - def test_router_registers_exactly_one_post_route(self): + 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 + if hasattr(route, "methods") + and "POST" in route.methods + and route.path == "/api/repairs" ] self.assertEqual(routes, [("/api/repairs", {"POST"})]) diff --git a/backend/tests/test_return_to_service.py b/backend/tests/test_return_to_service.py new file mode 100644 index 0000000..ed78dcb --- /dev/null +++ b/backend/tests/test_return_to_service.py @@ -0,0 +1,1070 @@ +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("DELETE FROM repair_events") + 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: + cur.execute(change_sql, (repair_id,)) + + 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/docs/ROADMAP.md b/docs/ROADMAP.md index deb62ca..c681da1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -99,11 +99,11 @@ 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 and read-only Repair Timeline workflows are complete. An -authorized actor can open a repair against a durable miner UUID, record -sequenced starting events, and move the miner into repair atomically. Authorized -readers can inspect the repair and its ordered immutable events. Later repair -lifecycle workflows remain planned. +The Begin Repair, read-only Repair Timeline, and Return to Service workflows are +complete. An authorized actor can open a repair against a durable miner UUID, +record sequenced events, and move the miner into or out of repair atomically. +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. diff --git a/docs/releases/v0.2-repair-workflow.md b/docs/releases/v0.2-repair-workflow.md index 915d758..d185fce 100644 --- a/docs/releases/v0.2-repair-workflow.md +++ b/docs/releases/v0.2-repair-workflow.md @@ -15,10 +15,10 @@ and immutable history. - [x] Begin Repair (`POST /api/repairs`) - [x] Repair detail and timeline read APIs +- [x] Return to Service (`POST /api/repairs/{repair_id}/complete`) ## Planned -- [ ] Return to Service - [ ] Control Board Replacement - [ ] Repair Parts Tracking @@ -41,9 +41,8 @@ not make these workflows complete. ## Candidate Task Order -1. `003-return-to-service.md` -2. `004-control-board-replacement.md` -3. `005-repair-parts-tracking.md` +1. `004-control-board-replacement.md` +2. `005-repair-parts-tracking.md` These are proposed future files under `docs/tasks/`. They do not exist yet and must not be treated as approved specifications until they are created and 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/scripts/test-begin-repair-migration.sh b/scripts/test-begin-repair-migration.sh index a43dc3e..c6f4886 100755 --- a/scripts/test-begin-repair-migration.sh +++ b/scripts/test-begin-repair-migration.sh @@ -57,7 +57,7 @@ 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 head +run_alembic upgrade 0010_begin_repair current_revision="$(run_alembic current 2>/dev/null)" [[ "${current_revision}" == *"0010_begin_repair"* ]] @@ -534,17 +534,17 @@ SELECT SQL )" [[ "${downgrade_state}" == "t" ]] -run_alembic upgrade head +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 head..." +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 head + backend alembic upgrade 0010_begin_repair empty_revision="$( "${compose[@]}" run --rm --no-deps \ diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh index f9ccfb7..379b887 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -88,7 +88,7 @@ health_json="$(cat "${health_file}")" echo "Checking migrations and required schema..." current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" -[[ "${current_revision}" == *"0011_repair_timeline"* ]] +[[ "${current_revision}" == *"0012_return_to_service"* ]] missing_tables="$( "${compose[@]}" exec -T postgres sh -c \ diff --git a/scripts/test-repair-timeline-migration.sh b/scripts/test-repair-timeline-migration.sh index 08dd87c..78c47a9 100755 --- a/scripts/test-repair-timeline-migration.sh +++ b/scripts/test-repair-timeline-migration.sh @@ -57,7 +57,7 @@ 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 head +run_alembic upgrade 0011_repair_timeline current_revision="$(run_alembic current 2>/dev/null)" [[ "${current_revision}" == *"0011_repair_timeline"* ]] @@ -149,17 +149,17 @@ SQL downgraded_revision="$(run_alembic current 2>/dev/null)" [[ "${downgraded_revision}" == *"0010_begin_repair"* ]] -run_alembic upgrade head +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 head..." +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 head + backend alembic upgrade 0011_repair_timeline empty_revision="$( "${compose[@]}" run --rm --no-deps \ diff --git a/scripts/test-return-to-service-migration.sh b/scripts/test-return-to-service-migration.sh new file mode 100755 index 0000000..964cf70 --- /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 head +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 head +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 head +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." From 62c91a2c3eae4da1c625d0b78cd56e3c79ea748e Mon Sep 17 00:00:00 2001 From: JC Date: Sun, 2 Aug 2026 03:44:15 +0000 Subject: [PATCH 19/37] docs: add repair domain design documents --- docs/design/API_STYLE_GUIDE.md | 156 ++++++++++++ docs/design/REPAIR_DOMAIN_MODEL.md | 174 +++++++++++++ docs/tasks/004-control-board-replacement.md | 259 ++++++++++++++++++++ docs/tasks/005-repair-parts-tracking.md | 189 ++++++++++++++ 4 files changed, 778 insertions(+) create mode 100644 docs/design/API_STYLE_GUIDE.md create mode 100644 docs/design/REPAIR_DOMAIN_MODEL.md create mode 100644 docs/tasks/004-control-board-replacement.md create mode 100644 docs/tasks/005-repair-parts-tracking.md 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/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/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..fcc6d49 --- /dev/null +++ b/docs/tasks/005-repair-parts-tracking.md @@ -0,0 +1,189 @@ +# Task 005: Repair Parts Tracking + +## Status +Design specification. Not yet implemented. + +## Objective +Allow authorized operators to record parts installed, removed, or consumed +during a repair while preserving immutable history and leaving room for a later +spare-parts inventory system. + +## Product Intent +A technician should be able to determine what parts were installed, removed, or +consumed; their quantity and identifying serial number where relevant; any +concise notes; and the authoritative actor and timestamp. + +## Scope +- proposed focused repair-part read and action APIs +- proposed capability-based permissions +- a deliberately small part type and action vocabulary +- part type, action, quantity, optional serial number, and notes +- serialized component identifiers where applicable +- explicit void/correction rather than physical deletion +- immutable repair events +- migration, constraints, tests, and documentation + +## Out of Scope +Purchasing, suppliers, pricing, invoices, warehouse transfers, reorder automation, warranties, RMA, full inventory, generalized bill of materials, bulk import, automatic stock deduction, photos, work orders, and control-board identity replacement. + +Task 004 remains authoritative for control-board identity and MAC changes. +Task 005 must not accept `control_board` as a generic part type or update +`miners.control_board_sn`, `miners.current_mac`, or +`miner_network_identities`. + +## API +Proposed routes: + +```http +GET /api/repairs/{repair_id}/parts +POST /api/repairs/{repair_id}/parts +POST /api/repairs/{repair_id}/parts/{repair_part_id}/void +``` + +Do not physically delete authoritative entries. + +## Permissions +The proposed authorization model is: + +- use implemented `repairs.read` for the read route +- introduce `repairs.parts.manage` for add and void actions + +Grant the proposed write permission to Admin, Manager, and Technician. Do not +grant it to Read Only. `repairs.parts.manage` does not exist yet and must be +introduced by the Task 005 migration. The router checks capabilities rather than +role names. + +## Add Request +```json +{ + "part_type": "fan", + "action": "installed", + "quantity": 1, + "serial_number": null, + "notes": "Replacement fan installed." +} +``` + +## Initial Vocabulary +Proposed part types: + +- `fan` +- `power_supply` +- `hashboard` +- `cable` +- `consumable` +- `other` + +Proposed actions: + +- `installed` +- `removed` +- `consumed` + +Task 005 does not model source inventory, stock movement, return destination, +condition, or disposition. Those concepts require a later inventory decision +and must not be approximated with overlapping action/value combinations here. +Use notes for concise context that is not authoritative structured inventory +data. + +## Quantity Rules +- quantity greater than zero +- integer quantities for this task +- serialized components normally quantity 1 +- consumables may exceed 1 +- voiding does not create a negative entry or reverse inventory; it marks the + original record void + +## Repair State Rules +- add entries only while the repair is active +- void entries only while the repair is active +- read entries and void state after completion +- completed repairs remain read-only +- no unrestricted editing in any repair state + +## Data Model +Proposed table: `repair_parts`. No such table currently exists. The legacy +`/api/repairs/parts-summary` route summarizes generic maintenance-event data and +is not an implementation of this model. + +Potential fields: +- UUID +- repair UUID +- part type +- action +- quantity +- serial number +- notes +- recorded actor and timestamp +- void actor, timestamp, and reason +- related event UUID + +Entries are never physically deleted. A void changes only the one-way void +metadata. It must not edit the original part type, action, quantity, serial +number, notes, recording actor, or recording timestamp. The void reason is +required; void actor and timestamp come from authentication and the server. + +## Constraints +- quantity > 0 +- void fields all-null or all-present +- void timestamp after recorded timestamp +- actor and recorded timestamp required +- valid constrained values and combinations +- serialized entries with a serial number have quantity 1 + +## Events +Proposed types: +- `REPAIR_PART_RECORDED` +- `REPAIR_PART_VOIDED` + +The events are immutable history; the table is the queryable projection. + +## Atomicity +Adding a part and appending its event occur in one transaction. Voiding and appending the void event occur in one transaction. + +Voiding must lock the repair-part row and succeed only when its void fields are +still null. Concurrent void requests must produce one success and one conflict, +with exactly one `REPAIR_PART_VOIDED` event. + +## Architecture +Preserve `Router -> Service -> Repository -> PostgreSQL`. No generic parts CRUD, router SQL, or physical deletion. + +## Tests +Cover: +- authorization +- serialized and consumable entries +- removed part +- list chronology +- read after completion +- explicit void +- invalid quantities and combinations +- control-board part rejection +- add to completed repair +- already-voided conflict +- cross-repair entry conflict +- concurrent void +- rollback on event failure +- direct constraints +- Tasks 001-004 regressions +- migration lifecycle and full backend suite + +## Migration +Create the focused table, add permissions and event constraints, add justified indexes, preserve legacy truth, and refuse destructive downgrade when authoritative Task 005 history exists. + +## Acceptance Criteria +1. Parts can be recorded against an active repair. +2. Historical entries remain readable. +3. Entries are never physically deleted. +4. Voids are explicit and audited. +5. Actor and timestamps are authoritative. +6. Writes and events are atomic. +7. Values and quantities are constrained. +8. No warehouse or purchasing system is introduced. +9. Task 004 remains authoritative for control-board identity changes. +10. No commit or push is performed. + +## Completion Report +Report files, migration, routes, permissions, schema, constraints, events, tests, migration lifecycle, architecture audit, limitations, and final status. + +Do not commit. +Do not push. From bcf1b4e3cfd39a7002e18e2a4b68534e81ed5815 Mon Sep 17 00:00:00 2001 From: JC Date: Sun, 2 Aug 2026 14:34:16 +0000 Subject: [PATCH 20/37] feat: add control board replacement workflow --- .../0013_control_board_replacement.py | 436 ++++++++++ backend/app/identity.py | 9 + backend/app/main.py | 340 ++------ backend/app/repairs/exceptions.py | 28 + backend/app/repairs/models.py | 66 ++ backend/app/repairs/repository.py | 259 +++++- backend/app/repairs/router.py | 37 + backend/app/repairs/service.py | 157 ++++ .../tests/test_control_board_replacement.py | 804 ++++++++++++++++++ docs/ROADMAP.md | 11 +- docs/releases/v0.2-repair-workflow.md | 12 +- ...est-control-board-replacement-migration.sh | 131 +++ scripts/test-control-board-replacement.sh | 35 + scripts/test-dev-baseline.sh | 68 +- scripts/test-return-to-service-migration.sh | 6 +- sql/verify-development-schema.sql | 2 + 16 files changed, 2122 insertions(+), 279 deletions(-) create mode 100644 backend/alembic/versions/0013_control_board_replacement.py create mode 100644 backend/tests/test_control_board_replacement.py create mode 100755 scripts/test-control-board-replacement-migration.sh create mode 100755 scripts/test-control-board-replacement.sh 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/app/identity.py b/backend/app/identity.py index eabf514..4a734e0 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -694,6 +694,15 @@ def repair_completion_request(path: str) -> bool: def required_api_permission(method: str, path: str) -> str | None: 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): diff --git a/backend/app/main.py b/backend/app/main.py index 8f17469..e61915d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta, timezone from typing import Any +from uuid import UUID import jwt import psycopg @@ -7,7 +8,7 @@ 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 pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError from app.config import settings from app.identity import ( @@ -18,7 +19,23 @@ from app.identity import ( router as identity_router, ) from app.inventory.router import router as inventory_router -from app.repairs.router import router as repairs_router +from app.repairs.exceptions import ( + ControlBoardReplacementActorRequired, + InvalidMacAddress, + RepairConflict, + RepairMinerNotFound, +) +from app.repairs.models import ( + ControlBoardReplacementReason, + ControlBoardReplacementRequest, + RemovedBoardDisposition, +) +from app.repairs.repository import RepairRepository +from app.repairs.service import RepairService +from app.repairs.router import ( + control_board_router, + router as repairs_router, +) app = FastAPI(title=settings.app_name) @@ -15430,33 +15447,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( @@ -15464,244 +15465,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( @@ -35674,3 +35497,4 @@ def minerops_repair_parts_summary_api( # 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/repairs/exceptions.py b/backend/app/repairs/exceptions.py index 65ac36c..03142b4 100644 --- a/backend/app/repairs/exceptions.py +++ b/backend/app/repairs/exceptions.py @@ -107,3 +107,31 @@ class RepairActiveMismatch(RepairConflict): 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" + ) diff --git a/backend/app/repairs/models.py b/backend/app/repairs/models.py index 5ae1cb4..1d4b990 100644 --- a/backend/app/repairs/models.py +++ b/backend/app/repairs/models.py @@ -32,6 +32,7 @@ RepairEventType = Literal[ "REPAIR_STARTED", "INITIAL_NOTE", "REPAIR_COMPLETED", + "CONTROL_BOARD_REPLACED", ] RepairEligibleLifecycle = Literal["active", "spare"] RepairPostLifecycle = Literal["active", "spare"] @@ -53,6 +54,37 @@ class RepairCompleteRequest(BaseModel): 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 @@ -70,6 +102,40 @@ class RepairCompletedPayload(BaseModel): 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 diff --git a/backend/app/repairs/repository.py b/backend/app/repairs/repository.py index dd20ba1..3b7f35f 100644 --- a/backend/app/repairs/repository.py +++ b/backend/app/repairs/repository.py @@ -6,13 +6,15 @@ from typing import Any from uuid import UUID import psycopg -from psycopg.errors import UniqueViolation +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, ) @@ -64,6 +66,26 @@ class RepairRepository: 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 @@ -97,7 +119,8 @@ class RepairRepository: with connection.cursor(row_factory=dict_row) as cur: cur.execute( """ - SELECT id, lifecycle_status + SELECT id, asset_tag, lifecycle_status, current_mac, control_board_sn, + firmware_version FROM miners WHERE id = %s FOR UPDATE @@ -107,6 +130,27 @@ class RepairRepository: 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: @@ -342,6 +386,217 @@ class RepairRepository: 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 get_repair_detail(self, repair_id: UUID) -> dict | None: with self._connection_scope() as connection: with connection.cursor(row_factory=dict_row) as cur: diff --git a/backend/app/repairs/router.py b/backend/app/repairs/router.py index 7f010ca..7f20261 100644 --- a/backend/app/repairs/router.py +++ b/backend/app/repairs/router.py @@ -8,6 +8,8 @@ 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, @@ -16,6 +18,8 @@ from app.repairs.exceptions import ( ) from app.repairs.models import ( BeginRepairRequest, + ControlBoardReplacementRequest, + ControlBoardReplacementResponse, BeginRepairResponse, RepairCompleteRequest, RepairDetailResponse, @@ -25,9 +29,11 @@ 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_control_board_replace = require_permission("miners.replace_control_board") RepairServiceDependency = Annotated[ RepairService, @@ -36,6 +42,37 @@ RepairServiceDependency = Annotated[ RepairCreateUser = Annotated[dict, Depends(require_repair_create)] RepairReadUser = Annotated[dict, Depends(require_repair_read)] RepairCompleteUser = Annotated[dict, Depends(require_repair_complete)] +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( diff --git a/backend/app/repairs/service.py b/backend/app/repairs/service.py index 648036c..3beb1cb 100644 --- a/backend/app/repairs/service.py +++ b/backend/app/repairs/service.py @@ -2,8 +2,13 @@ from uuid import UUID +import re + from app.repairs.exceptions import ( ActiveRepairConflict, + ControlBoardReplacementActorRequired, + ControlBoardReplacementConflict, + InvalidMacAddress, IncompatibleMinerLifecycle, InconsistentRepairState, RepairActiveMismatch, @@ -17,6 +22,9 @@ from app.repairs.exceptions import ( ) from app.repairs.models import ( BeginRepairRequest, + ControlBoardReplacedPayload, + ControlBoardReplacementRequest, + ControlBoardReplacementResult, BeginRepairResult, InitialRepairNotePayload, RepairActor, @@ -143,6 +151,155 @@ class RepairService: 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, diff --git a/backend/tests/test_control_board_replacement.py b/backend/tests/test_control_board_replacement.py new file mode 100644 index 0000000..f571c99 --- /dev/null +++ b/backend/tests/test_control_board_replacement.py @@ -0,0 +1,804 @@ +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("DELETE FROM repair_events") + 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("DELETE FROM repair_events") + 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/docs/ROADMAP.md b/docs/ROADMAP.md index c681da1..0cf013c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -99,11 +99,12 @@ 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, and Return to Service workflows are -complete. An authorized actor can open a repair against a durable miner UUID, -record sequenced events, and move the miner into or out of repair atomically. -Authorized readers can inspect the repair and its ordered immutable events. -Later repair lifecycle workflows remain planned. +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. diff --git a/docs/releases/v0.2-repair-workflow.md b/docs/releases/v0.2-repair-workflow.md index d185fce..2f76d6d 100644 --- a/docs/releases/v0.2-repair-workflow.md +++ b/docs/releases/v0.2-repair-workflow.md @@ -16,10 +16,10 @@ and immutable history. - [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 -- [ ] Control Board Replacement - [ ] Repair Parts Tracking These planned items refer to explicit workflows in the modular Repairs domain. @@ -39,11 +39,9 @@ not make these workflows complete. - operator behavior is documented - the release candidate is built from a clean, reproducible repository state -## Candidate Task Order +## Remaining Task -1. `004-control-board-replacement.md` -2. `005-repair-parts-tracking.md` +1. `005-repair-parts-tracking.md` -These are proposed future files under `docs/tasks/`. They do not exist yet and -must not be treated as approved specifications until they are created and -reviewed. +This task is specified under `docs/tasks/`, but remains a planned workflow and +must not be treated as implemented. 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-dev-baseline.sh b/scripts/test-dev-baseline.sh index 379b887..323a8a4 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -62,8 +62,52 @@ fi echo "Validating Compose configuration..." MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" config --quiet -echo "Building and starting disposable development stack..." -MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --build +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" @@ -88,7 +132,7 @@ health_json="$(cat "${health_file}")" echo "Checking migrations and required schema..." current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" -[[ "${current_revision}" == *"0012_return_to_service"* ]] +[[ "${current_revision}" == *"0013_control_board_replacement"* ]] missing_tables="$( "${compose[@]}" exec -T postgres sh -c \ @@ -168,6 +212,8 @@ echo "Checking Begin Repair permission and workflow..." INSERT INTO miners ( id, asset_tag, + current_mac, + control_board_sn, status, lifecycle_status, first_seen_at, @@ -176,6 +222,8 @@ INSERT INTO miners ( VALUES ( '00000000-0000-0000-0000-000000000981', 'SMOKE-REPAIR-001', + '02:AA:BB:CC:EE:01', + 'SMOKE-CB-OLD', 'unknown', 'active', NULL, @@ -202,19 +250,31 @@ smoke_repair_id="$( | 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":2'* ]] +[[ "${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" ]] diff --git a/scripts/test-return-to-service-migration.sh b/scripts/test-return-to-service-migration.sh index 964cf70..ab4ea0d 100755 --- a/scripts/test-return-to-service-migration.sh +++ b/scripts/test-return-to-service-migration.sh @@ -96,7 +96,7 @@ INSERT INTO repairs ( SQL echo "Upgrading previous head to Return to Service..." -run_alembic upgrade head +run_alembic upgrade 0012_return_to_service current_revision="$(run_alembic current 2>/dev/null)" [[ "${current_revision}" == *"0012_return_to_service"* ]] @@ -244,7 +244,7 @@ SQL [[ "${downgrade_state}" == "t" ]] downgraded_revision="$(run_alembic current 2>/dev/null)" [[ "${downgraded_revision}" == *"0011_repair_timeline"* ]] -run_alembic upgrade head +run_alembic upgrade 0012_return_to_service reupgraded_revision="$(run_alembic current 2>/dev/null)" [[ "${reupgraded_revision}" == *"0012_return_to_service"* ]] @@ -284,7 +284,7 @@ echo "Migrating an empty database from base through head..." -c "CREATE DATABASE minerops_return_empty"' >/dev/null "${compose[@]}" run --rm --no-deps \ -e "POSTGRES_DB=${empty_database}" \ - backend alembic upgrade head + backend alembic upgrade 0012_return_to_service empty_revision="$( "${compose[@]}" run --rm --no-deps \ -e "POSTGRES_DB=${empty_database}" \ diff --git a/sql/verify-development-schema.sql b/sql/verify-development-schema.sql index ba67376..5887ce5 100644 --- a/sql/verify-development-schema.sql +++ b/sql/verify-development-schema.sql @@ -9,6 +9,8 @@ WITH expected(table_name) AS ( ('miner_asset_id_history'), ('repairs'), ('repair_events'), + ('miner_control_board_installations'), + ('miner_mac_ownerships'), ('miner_status_snapshots'), ('miner_status_hourly_rollups'), ('miner_status_daily_rollups'), From 73f98685ce750c39b280de2acb6747442d5f0f5b Mon Sep 17 00:00:00 2001 From: JC Date: Sun, 2 Aug 2026 15:13:20 +0000 Subject: [PATCH 21/37] docs: add component domain model --- docs/design/COMPONENT_DOMAIN_MODEL.md | 457 ++++++++++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 docs/design/COMPONENT_DOMAIN_MODEL.md 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. From 6e550c1c136bea6f87d66e4dccc8d60de8267c49 Mon Sep 17 00:00:00 2001 From: JC Date: Sun, 2 Aug 2026 19:15:55 +0000 Subject: [PATCH 22/37] docs: finalize repair parts tracking task --- docs/tasks/005-repair-parts-tracking.md | 756 +++++++++++++++++++----- 1 file changed, 623 insertions(+), 133 deletions(-) diff --git a/docs/tasks/005-repair-parts-tracking.md b/docs/tasks/005-repair-parts-tracking.md index fcc6d49..e3b7517 100644 --- a/docs/tasks/005-repair-parts-tracking.md +++ b/docs/tasks/005-repair-parts-tracking.md @@ -1,38 +1,192 @@ # Task 005: Repair Parts Tracking ## Status -Design specification. Not yet implemented. + +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 parts installed, removed, or consumed -during a repair while preserving immutable history and leaving room for a later -spare-parts inventory system. + +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 what parts were installed, removed, or -consumed; their quantity and identifying serial number where relevant; any -concise notes; and the authoritative actor and timestamp. + +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 -- proposed focused repair-part read and action APIs -- proposed capability-based permissions -- a deliberately small part type and action vocabulary -- part type, action, quantity, optional serial number, and notes -- serialized component identifiers where applicable -- explicit void/correction rather than physical deletion -- immutable repair events -- migration, constraints, tests, and documentation -## Out of Scope -Purchasing, suppliers, pricing, invoices, warehouse transfers, reorder automation, warranties, RMA, full inventory, generalized bill of materials, bulk import, automatic stock deduction, photos, work orders, and control-board identity replacement. +- 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. -Task 004 remains authoritative for control-board identity and MAC changes. -Task 005 must not accept `control_board` as a generic part type or update -`miners.control_board_sn`, `miners.current_mac`, or -`miner_network_identities`. +## 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 -Proposed routes: + +### 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 @@ -40,150 +194,486 @@ POST /api/repairs/{repair_id}/parts POST /api/repairs/{repair_id}/parts/{repair_part_id}/void ``` -Do not physically delete authoritative entries. +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 -The proposed authorization model is: -- use implemented `repairs.read` for the read route -- introduce `repairs.parts.manage` for add and void actions +Introduce these proposed capabilities: -Grant the proposed write permission to Admin, Manager, and Technician. Do not -grant it to Read Only. `repairs.parts.manage` does not exist yet and must be -introduced by the Task 005 migration. The router checks capabilities rather than -role names. +- `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 -## Add Request ```json { - "part_type": "fan", - "action": "installed", - "quantity": 1, - "serial_number": null, - "notes": "Replacement fan installed." + "component_type": "hashboard", + "asset_id": "HB-0042", + "manufacturer": "MicroBT", + "model": "M50 Hashboard", + "manufacturer_serial": "HB-SN-12345" } ``` -## Initial Vocabulary -Proposed part types: +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. -- `fan` -- `power_supply` -- `hashboard` -- `cable` -- `consumable` -- `other` +### Record Consumable -Proposed actions: +```json +{ + "consumable": { + "description": "Thermal paste", + "quantity": 1, + "unit": "gram" + }, + "tracked_component_uuid": null, + "notes": "Applied during board service." +} +``` -- `installed` -- `removed` -- `consumed` +### Record Tracked Component -Task 005 does not model source inventory, stock movement, return destination, -condition, or disposition. Those concepts require a later inventory decision -and must not be approximated with overlapping action/value combinations here. -Use notes for concise context that is not authoritative structured inventory -data. +```json +{ + "consumable": null, + "tracked_component_uuid": "c79f9f36-1e89-4dbc-8a84-9ff3167614be", + "notes": "Used during this repair." +} +``` -## Quantity Rules -- quantity greater than zero -- integer quantities for this task -- serialized components normally quantity 1 -- consumables may exceed 1 -- voiding does not create a negative entry or reverse inventory; it marks the - original record void +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 -- add entries only while the repair is active -- void entries only while the repair is active -- read entries and void state after completion -- completed repairs remain read-only -- no unrestricted editing in any repair state -## Data Model -Proposed table: `repair_parts`. No such table currently exists. The legacy -`/api/repairs/parts-summary` route summarizes generic maintenance-event data and -is not an implementation of this model. +- 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. -Potential fields: -- UUID -- repair UUID -- part type -- action -- quantity -- serial number -- notes -- recorded actor and timestamp -- void actor, timestamp, and reason -- related event UUID - -Entries are never physically deleted. A void changes only the one-way void -metadata. It must not edit the original part type, action, quantity, serial -number, notes, recording actor, or recording timestamp. The void reason is -required; void actor and timestamp come from authentication and the server. - -## Constraints -- quantity > 0 -- void fields all-null or all-present -- void timestamp after recorded timestamp -- actor and recorded timestamp required -- valid constrained values and combinations -- serialized entries with a serial number have quantity 1 +Begin Repair, Repair Timeline, Return to Service, and Task 004 behavior must +remain unchanged. ## Events -Proposed types: -- `REPAIR_PART_RECORDED` -- `REPAIR_PART_VOIDED` -The events are immutable history; the table is the queryable projection. +Add these immutable repair event types: -## Atomicity -Adding a part and appending its event occur in one transaction. Voiding and appending the void event occur in one transaction. +- `REPAIR_PART_RECORDED`; and +- `REPAIR_PART_VOIDED`. -Voiding must lock the repair-part row and succeed only when its void fields are -still null. Concurrent void requests must produce one success and one conflict, -with exactly one `REPAIR_PART_VOIDED` event. +`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 `Router -> Service -> Repository -> PostgreSQL`. No generic parts CRUD, router SQL, or physical deletion. -## Tests -Cover: -- authorization -- serialized and consumable entries -- removed part -- list chronology -- read after completion -- explicit void -- invalid quantities and combinations -- control-board part rejection -- add to completed repair -- already-voided conflict -- cross-repair entry conflict -- concurrent void -- rollback on event failure -- direct constraints -- Tasks 001-004 regressions -- migration lifecycle and full backend suite +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 the focused table, add permissions and event constraints, add justified indexes, preserve legacy truth, and refuse destructive downgrade when authoritative Task 005 history exists. + +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. Parts can be recorded against an active repair. -2. Historical entries remain readable. -3. Entries are never physically deleted. -4. Voids are explicit and audited. -5. Actor and timestamps are authoritative. -6. Writes and events are atomic. -7. Values and quantities are constrained. -8. No warehouse or purchasing system is introduced. -9. Task 004 remains authoritative for control-board identity changes. -10. No commit or push is performed. + +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, routes, permissions, schema, constraints, events, tests, migration lifecycle, architecture audit, limitations, and final status. + +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. From eab964696043caedb32be7989ad3c0e0c4af11bb Mon Sep 17 00:00:00 2001 From: JC Date: Sun, 2 Aug 2026 21:26:15 +0000 Subject: [PATCH 23/37] feat: add repair parts tracking --- .../versions/0014_repair_parts_tracking.py | 590 ++++++++++++++++++ backend/app/components/__init__.py | 1 + backend/app/components/dependencies.py | 8 + backend/app/components/exceptions.py | 32 + backend/app/components/models.py | 36 ++ backend/app/components/repository.py | 143 +++++ backend/app/components/router.py | 80 +++ backend/app/components/service.py | 75 +++ backend/app/identity.py | 25 + backend/app/main.py | 2 + backend/app/repairs/exceptions.py | 35 ++ backend/app/repairs/models.py | 94 ++- backend/app/repairs/repository.py | 178 ++++++ backend/app/repairs/router.py | 111 ++++ backend/app/repairs/service.py | 278 ++++++++- .../tests/test_control_board_replacement.py | 4 + backend/tests/test_repair_parts_tracking.py | 455 ++++++++++++++ backend/tests/test_repair_timeline.py | 20 +- backend/tests/test_repairs_domain.py | 2 + backend/tests/test_return_to_service.py | 9 + scripts/test-dev-baseline.sh | 2 +- .../test-repair-parts-tracking-migration.sh | 208 ++++++ sql/verify-development-schema.sql | 2 + 23 files changed, 2386 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/0014_repair_parts_tracking.py create mode 100644 backend/app/components/__init__.py create mode 100644 backend/app/components/dependencies.py create mode 100644 backend/app/components/exceptions.py create mode 100644 backend/app/components/models.py create mode 100644 backend/app/components/repository.py create mode 100644 backend/app/components/router.py create mode 100644 backend/app/components/service.py create mode 100644 backend/tests/test_repair_parts_tracking.py create mode 100755 scripts/test-repair-parts-tracking-migration.sh 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/identity.py b/backend/app/identity.py index 4a734e0..c22edae 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -691,7 +691,32 @@ def repair_completion_request(path: str) -> bool: ) +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: + 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/"): diff --git a/backend/app/main.py b/backend/app/main.py index e61915d..1d25b4e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,6 +19,7 @@ from app.identity import ( router as identity_router, ) from app.inventory.router import router as inventory_router +from app.components.router import router as components_router from app.repairs.exceptions import ( ControlBoardReplacementActorRequired, InvalidMacAddress, @@ -41,6 +42,7 @@ from app.repairs.router import ( app = FastAPI(title=settings.app_name) app.include_router(identity_router) app.include_router(inventory_router) +app.include_router(components_router) install_identity_middleware(app) diff --git a/backend/app/repairs/exceptions.py b/backend/app/repairs/exceptions.py index 03142b4..2e73dd9 100644 --- a/backend/app/repairs/exceptions.py +++ b/backend/app/repairs/exceptions.py @@ -135,3 +135,38 @@ class MacOwnershipConflict(ControlBoardReplacementConflict): 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 index 1d4b990..55e8f8c 100644 --- a/backend/app/repairs/models.py +++ b/backend/app/repairs/models.py @@ -1,10 +1,12 @@ """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 +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator +from app.components.models import ComponentType RepairReason = Literal[ @@ -33,6 +35,8 @@ RepairEventType = Literal[ "INITIAL_NOTE", "REPAIR_COMPLETED", "CONTROL_BOARD_REPLACED", + "REPAIR_PART_RECORDED", + "REPAIR_PART_VOIDED", ] RepairEligibleLifecycle = Literal["active", "spare"] RepairPostLifecycle = Literal["active", "spare"] @@ -194,3 +198,91 @@ 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 diff --git a/backend/app/repairs/repository.py b/backend/app/repairs/repository.py index 3b7f35f..5e695af 100644 --- a/backend/app/repairs/repository.py +++ b/backend/app/repairs/repository.py @@ -597,6 +597,184 @@ class RepairRepository: 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: diff --git a/backend/app/repairs/router.py b/backend/app/repairs/router.py index 7f20261..6da37d1 100644 --- a/backend/app/repairs/router.py +++ b/backend/app/repairs/router.py @@ -15,6 +15,10 @@ from app.repairs.exceptions import ( RepairConflict, RepairMinerNotFound, RepairNotFound, + RepairPartActorRequired, + RepairPartComponentNotFound, + RepairPartNotFound, + RepairPartValidationError, ) from app.repairs.models import ( BeginRepairRequest, @@ -24,6 +28,10 @@ from app.repairs.models import ( RepairCompleteRequest, RepairDetailResponse, RepairTimelineResponse, + RepairPartListResponse, + RepairPartRecordRequest, + RepairPartResponse, + RepairPartVoidRequest, ) from app.repairs.service import RepairService @@ -33,6 +41,7 @@ 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[ @@ -42,6 +51,7 @@ RepairServiceDependency = Annotated[ 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)] @@ -149,6 +159,107 @@ def complete_repair( 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, diff --git a/backend/app/repairs/service.py b/backend/app/repairs/service.py index 3beb1cb..f4eef43 100644 --- a/backend/app/repairs/service.py +++ b/backend/app/repairs/service.py @@ -1,6 +1,7 @@ """Business rules and orchestration for repair workflows.""" -from uuid import UUID +from decimal import Decimal +from uuid import UUID, uuid4 import re @@ -19,6 +20,11 @@ from app.repairs.exceptions import ( RepairNotActive, RepairNotFound, RepairPersistenceError, + RepairPartActorRequired, + RepairPartAlreadyVoided, + RepairPartComponentNotFound, + RepairPartNotFound, + RepairPartValidationError, ) from app.repairs.models import ( BeginRepairRequest, @@ -30,6 +36,12 @@ from app.repairs.models import ( RepairActor, RepairCompleteRequest, RepairCompletedPayload, + RepairPartComponentSnapshot, + RepairPartConsumable, + RepairPartDetail, + RepairPartList, + RepairPartRecordRequest, + RepairPartVoidRequest, RepairDetail, RepairTimeline, RepairTimelineEvent, @@ -378,6 +390,270 @@ class RepairService: 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: diff --git a/backend/tests/test_control_board_replacement.py b/backend/tests/test_control_board_replacement.py index f571c99..c246f01 100644 --- a/backend/tests/test_control_board_replacement.py +++ b/backend/tests/test_control_board_replacement.py @@ -474,7 +474,9 @@ class ControlBoardReplacementDatabaseTests(unittest.TestCase): "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") @@ -500,7 +502,9 @@ class ControlBoardReplacementDatabaseTests(unittest.TestCase): "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") 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 index e0ad5c9..ea23425 100644 --- a/backend/tests/test_repair_timeline.py +++ b/backend/tests/test_repair_timeline.py @@ -219,7 +219,9 @@ class RepairTimelineRepositoryTests(unittest.TestCase): 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") @@ -611,7 +613,7 @@ class RepairTimelineRouterTests(unittest.TestCase): self.assertEqual(response.status_code, 403) self.assertFalse(self.service.begin_called) - def test_exactly_two_timeline_get_routes_are_registered(self): + def test_timeline_and_parts_get_routes_are_registered(self): get_routes = { route.path for route in router.routes @@ -622,6 +624,7 @@ class RepairTimelineRouterTests(unittest.TestCase): { "/api/repairs/{repair_id}", "/api/repairs/{repair_id}/events", + "/api/repairs/{repair_id}/parts", }, ) @@ -715,6 +718,21 @@ class RepairTimelineArchitectureTests(unittest.TestCase): "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", diff --git a/backend/tests/test_repairs_domain.py b/backend/tests/test_repairs_domain.py index cb472a5..bd2457d 100644 --- a/backend/tests/test_repairs_domain.py +++ b/backend/tests/test_repairs_domain.py @@ -372,7 +372,9 @@ class RepairRepositoryIntegrationTests(unittest.TestCase): 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") diff --git a/backend/tests/test_return_to_service.py b/backend/tests/test_return_to_service.py index ed78dcb..251123c 100644 --- a/backend/tests/test_return_to_service.py +++ b/backend/tests/test_return_to_service.py @@ -417,7 +417,9 @@ class ReturnToServiceRepositoryTests(unittest.TestCase): 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") @@ -586,7 +588,14 @@ class ReturnToServiceRepositoryTests(unittest.TestCase): _, 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, diff --git a/scripts/test-dev-baseline.sh b/scripts/test-dev-baseline.sh index 323a8a4..832d690 100755 --- a/scripts/test-dev-baseline.sh +++ b/scripts/test-dev-baseline.sh @@ -132,7 +132,7 @@ health_json="$(cat "${health_file}")" echo "Checking migrations and required schema..." current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)" -[[ "${current_revision}" == *"0013_control_board_replacement"* ]] +[[ "${current_revision}" == *"0014_repair_parts_tracking"* ]] missing_tables="$( "${compose[@]}" exec -T postgres sh -c \ 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/sql/verify-development-schema.sql b/sql/verify-development-schema.sql index 5887ce5..f9a9546 100644 --- a/sql/verify-development-schema.sql +++ b/sql/verify-development-schema.sql @@ -9,6 +9,8 @@ WITH expected(table_name) AS ( ('miner_asset_id_history'), ('repairs'), ('repair_events'), + ('components'), + ('repair_parts'), ('miner_control_board_installations'), ('miner_mac_ownerships'), ('miner_status_snapshots'), From 39832fcf80c3ce2cd586a020b1aef602c05faa52 Mon Sep 17 00:00:00 2001 From: JC Date: Sun, 2 Aug 2026 22:05:02 +0000 Subject: [PATCH 24/37] docs: add project vision --- docs/VISION.md | 223 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 docs/VISION.md 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. From 4656714837a4eefed3e3514768cd1a82bfb0ae47 Mon Sep 17 00:00:00 2001 From: JC Date: Sun, 2 Aug 2026 22:15:51 +0000 Subject: [PATCH 25/37] docs: add UX and UI guide --- docs/design/UX_UI_GUIDE.md | 559 +++++++++++++++++++++++++++++++++++++ 1 file changed, 559 insertions(+) create mode 100644 docs/design/UX_UI_GUIDE.md 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. From eeb3c7634e8d7664329888a9fdb2bd8654598d72 Mon Sep 17 00:00:00 2001 From: JC Date: Sun, 2 Aug 2026 22:28:52 +0000 Subject: [PATCH 26/37] docs: add navigation design --- docs/design/NAVIGATION.md | 407 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 docs/design/NAVIGATION.md 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. From b741fd799bbb2cf8662ca37ab677d41d9c16ecb8 Mon Sep 17 00:00:00 2001 From: JC Date: Mon, 10 Aug 2026 17:26:07 +0000 Subject: [PATCH 27/37] feat: add internal alpha frontend shell --- backend/app/frontend/__init__.py | 1 + backend/app/frontend/router.py | 77 +++ backend/app/frontend/static/app.css | 755 +++++++++++++++++++++++++++ backend/app/frontend/static/app.js | 348 ++++++++++++ backend/app/frontend/views.py | 301 +++++++++++ backend/app/identity.py | 75 ++- backend/app/main.py | 2 + backend/tests/test_frontend_shell.py | 394 ++++++++++++++ scripts/test-dev-baseline.sh | 10 +- scripts/test-frontend-shell.sh | 34 ++ 10 files changed, 1982 insertions(+), 15 deletions(-) create mode 100644 backend/app/frontend/__init__.py create mode 100644 backend/app/frontend/router.py create mode 100644 backend/app/frontend/static/app.css create mode 100644 backend/app/frontend/static/app.js create mode 100644 backend/app/frontend/views.py create mode 100644 backend/tests/test_frontend_shell.py create mode 100755 scripts/test-frontend-shell.sh 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/router.py b/backend/app/frontend/router.py new file mode 100644 index 0000000..070d797 --- /dev/null +++ b/backend/app/frontend/router.py @@ -0,0 +1,77 @@ +"""HTTP routes for the MinerOps Internal Alpha application shell.""" + +from pathlib import Path +from typing import Annotated + +from fastapi import APIRouter, Depends +from fastapi.responses import HTMLResponse, Response + +from app.frontend.views import render_app_page +from app.identity import require_permission, 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) -> str: + return render_app_page( + view, + current_user, + lambda permission: user_has_permission(current_user, permission), + ) + + +@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("/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) diff --git a/backend/app/frontend/static/app.css b/backend/app/frontend/static/app.css new file mode 100644 index 0000000..88b10d6 --- /dev/null +++ b/backend/app/frontend/static/app.css @@ -0,0 +1,755 @@ +: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; + } +} diff --git a/backend/app/frontend/static/app.js b/backend/app/frontend/static/app.js new file mode 100644 index 0000000..d9aac49 --- /dev/null +++ b/backend/app/frontend/static/app.js @@ -0,0 +1,348 @@ +(function () { + "use strict"; + + const view = document.body.dataset.view || ""; + const canViewMiners = + document.body.dataset.canViewMiners === "true"; + + function text(value, fallback) { + if (value === null || value === undefined || String(value).trim() === "") { + return fallback || "Not available"; + } + return String(value); + } + + function statusName(value) { + return text(value, "unknown").toLowerCase().replaceAll(" ", "_"); + } + + function create(tag, options) { + const element = document.createElement(tag); + const values = options || {}; + if (values.className) element.className = values.className; + if (values.text !== undefined) element.textContent = values.text; + if (values.href) element.href = values.href; + return element; + } + + function showState(name, message) { + for (const state of document.querySelectorAll("[data-view-state]")) { + state.hidden = state.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; + } + + async function api(path) { + const controller = new AbortController(); + const timeout = window.setTimeout(function () { + controller.abort(); + }, 15000); + let response; + try { + response = await fetch(path, { + credentials: "same-origin", + headers: { Accept: "application/json" }, + signal: controller.signal + }); + } catch (error) { + if (error && error.name === "AbortError") { + throw new Error( + "MinerOps took too long to respond. Retry in a moment." + ); + } + throw new Error( + "MinerOps could not reach the backend. Check the connection and retry." + ); + } finally { + window.clearTimeout(timeout); + } + if (response.status === 401) { + window.location.replace( + "/login?next=" + + encodeURIComponent(window.location.pathname + window.location.search) + ); + throw new Error("Your session expired. Sign in again to continue."); + } + if (response.status === 403) { + const denied = new Error( + "Your account does not have permission to view this information." + ); + denied.permissionDenied = true; + throw denied; + } + if (!response.ok) { + throw new Error( + "MinerOps could not load this information. Retry in a moment." + ); + } + try { + return await response.json(); + } catch (error) { + throw new Error( + "MinerOps returned an invalid response. Retry in a moment." + ); + } + } + + 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"); + } + }); + } + } + + 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 status(value) { + const label = create("span", { + className: "status-text", + text: text(value, "Unknown").replaceAll("_", " ") + }); + label.dataset.status = statusName(value); + return label; + } + + function dateTime(value) { + if (!value) return "Not available"; + const parsed = new Date(value); + return Number.isNaN(parsed.valueOf()) + ? String(value) + : parsed.toLocaleString(); + } + + async function loadRepairs() { + const rows = document.getElementById("repairRows"); + if (!rows) return; + showState("loading"); + try { + const envelope = await api("/api/repairs/queue"); + if ( + !envelope || + !envelope.data || + typeof envelope.data !== "object" || + !Array.isArray(envelope.data.items) + ) { + throw new Error( + "MinerOps returned an invalid repair response." + ); + } + const items = envelope.data.items; + if (!items.every(function (item) { + return item && + typeof item === "object" && + typeof item.miner_id === "string" && + item.miner_id.trim() !== ""; + })) { + throw new Error( + "MinerOps returned an incomplete repair response." + ); + } + rows.replaceChildren(); + if (!items.length) { + showState("empty"); + return; + } + for (const repair of items) { + const row = create("tr"); + const identity = create("div"); + const minerLabel = text( + repair.current_mac, + text(repair.miner_id, "Unknown miner") + ); + if (canViewMiners) { + identity.appendChild(create("a", { + className: "object-link", + text: minerLabel, + href: "/miners/" + + encodeURIComponent(repair.miner_id) + + "/detail" + })); + } else { + identity.appendChild(create("strong", { + text: minerLabel + })); + } + identity.appendChild(create("span", { + className: "secondary", + text: text(repair.current_ip, "No current IP") + })); + addCell(row, "Miner", identity); + addCell(row, "Model", repair.model); + addCell( + row, + "Repair state", + status(repair.latest_event_status || repair.asset_state) + ); + addCell( + row, + "Reported issue", + repair.reported_error || + repair.latest_event_title || + "No reported issue" + ); + addCell( + row, + "Last update", + dateTime(repair.latest_event_at || repair.state_updated_at) + ); + rows.appendChild(row); + } + showState("ready"); + } catch (error) { + showState( + error.permissionDenied ? "denied" : "error", + error.message + ); + } + } + + function locationText(miner) { + if (miner.room_id) return "Room " + miner.room_id; + if (miner.site_id) return "Site " + miner.site_id; + return "Not assigned"; + } + + async function loadMiners(query) { + const rows = document.getElementById("minerRows"); + if (!rows) return; + showState("loading"); + const params = new URLSearchParams({ limit: "100", offset: "0" }); + if (query) params.set("query", query); + try { + const envelope = await api( + "/api/inventory/miners?" + params.toString() + ); + if (!envelope || !Array.isArray(envelope.data)) { + throw new Error( + "MinerOps returned an invalid inventory response." + ); + } + const miners = envelope.data; + if (!miners.every(function (miner) { + return miner && + typeof miner === "object" && + typeof miner.id === "string" && + miner.id.trim() !== ""; + })) { + throw new Error( + "MinerOps returned an incomplete inventory response." + ); + } + rows.replaceChildren(); + if (!miners.length) { + showState( + "empty", + query + ? "No authorized miners match this search." + : "No miners have been added or discovered yet." + ); + return; + } + for (const miner of miners) { + const row = create("tr"); + const identity = create("div"); + const link = create("a", { + className: "object-link", + text: text(miner.asset_id, "No Asset ID"), + href: "/miners/" + + encodeURIComponent(miner.id) + + "/detail" + }); + identity.appendChild(link); + identity.appendChild(create("span", { + className: "secondary", + text: miner.id + })); + addCell(row, "Asset ID", identity); + addCell( + row, + "Current MAC", + miner.current_mac || "Not observed" + ); + addCell(row, "Model", miner.model); + addCell(row, "Lifecycle", status(miner.lifecycle_status)); + addCell(row, "Runtime", status(miner.runtime_status)); + addCell(row, "Location", locationText(miner)); + rows.appendChild(row); + } + showState("ready"); + } catch (error) { + showState( + error.permissionDenied ? "denied" : "error", + error.message + ); + } + } + + function setupMiners() { + const form = document.getElementById("minerSearchForm"); + const input = document.getElementById("minerSearchInput"); + if (!form || !input) return; + const initial = + new URLSearchParams(window.location.search).get("query") || ""; + input.value = initial; + form.addEventListener("submit", function (event) { + event.preventDefault(); + const query = input.value.trim(); + const url = query + ? "/assets/miners?query=" + encodeURIComponent(query) + : "/assets/miners"; + window.history.pushState({ query: query }, "", url); + loadMiners(query); + }); + window.addEventListener("popstate", function () { + const query = + new URLSearchParams(window.location.search).get("query") || ""; + input.value = query; + loadMiners(query); + }); + loadMiners(initial); + } + + setupShell(); + if (view === "repairs") loadRepairs(); + if (view === "miners") setupMiners(); +})(); diff --git a/backend/app/frontend/views.py b/backend/app/frontend/views.py new file mode 100644 index 0000000..42ef0a6 --- /dev/null +++ b/backend/app/frontend/views.py @@ -0,0 +1,301 @@ +"""Shared semantic HTML for the MinerOps Internal Alpha shell.""" + +from html import escape +from typing import Callable + + +NAVIGATION = ( + ("dashboard", "Dashboard", "/dashboard", "dashboard.view"), + ("map", "Mine Map", "/monitoring/map", "locations.view"), + ("repairs", "Repair Center", "/operations/repairs", "repairs.view"), + ("miners", "Miners", "/assets/miners", "miners.view"), +) + +PAGE_DETAILS = { + "dashboard": ( + "Fleet 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.", + ), + "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.", + ), +} + + +def _dashboard_content() -> str: + metrics = ( + ("Site health", "--", "Live health summary not yet connected"), + ("Total miners", "--", "Inventory summary not yet connected"), + ("Miners in repair", "--", "Repair summary not yet connected"), + ("Current hashrate", "-- TH/s", "Telemetry not yet connected"), + ("Current power", "-- kW", "Telemetry not yet connected"), + ) + metric_html = "".join( + "
" + f"{escape(label)}" + f"{escape(value)}" + f"{escape(state)}" + "
" + for label, value, state in metrics + ) + return f""" +
+ {metric_html} +
+
+
+
+
+

Recent activity

+

Operational events will appear here when + the dashboard activity feed is connected.

+
+
+
+ No activity feed connected + No events are fabricated for this placeholder. +
+
+ +
+""" + + +def _map_content() -> str: + return """ +
Live layout and telemetry data are not +connected to this Internal Alpha canvas. No production state is represented +below.
+
+ +
+
+ Operational canvas + Site, room, rack, miner, and status overlays will render here + when authoritative layout and telemetry sources are connected. +
+
+ +
+""" + + +def _state_messages(noun: str) -> str: + return f""" +
+ Loading {noun} + Requesting current information from MinerOps. +
+ + + +""" + + +def _repairs_content() -> str: + return f""" +
+
+
+

Current repair queue

+

Real records from the existing repair queue + API. Open a miner to continue in its current detail view.

+
+ Loaded on request +
+ {_state_messages("repair records")} + +
+""" + + +def _miners_content() -> str: + return f""" +
+
+
+

Miner inventory

+

Real inventory records. Search matches the + fields supported by the Inventory API.

+
+ Up to 100 results +
+ + {_state_messages("miners")} + +
+""" + + +CONTENT = { + "dashboard": _dashboard_content, + "map": _map_content, + "repairs": _repairs_content, + "miners": _miners_content, +} + + +def render_app_page( + view: str, + current_user: dict, + has_permission: Callable[[str], bool], +) -> 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") + navigation = "".join( + ( + f'
  • {escape(label)}
  • ' + if key == view + else f'
  • {escape(label)}
  • ' + ) + for key, label, href, permission in NAVIGATION + if has_permission(permission) + ) + global_search = ( + '' + if can_view_miners + else '' + ) + content = CONTENT[view]() + return f""" + + + + + {escape(title)} | MinerOps + + + + +
    +
    + + {global_search} +
    + +
    + {display_name} + {role} +
    + +
    +
    + +
    +
    +
    +

    {escape(context)}

    +

    {escape(title)}

    +

    {escape(summary)}

    +
    +

    Current view: {escape(title)}

    +
    + {content} +
    +
    + + +""" diff --git a/backend/app/identity.py b/backend/app/identity.py index c22edae..c888b19 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta, timezone from html import escape from typing import Any, Callable -from urllib.parse import quote +from urllib.parse import quote, urlsplit import jwt import psycopg @@ -633,20 +633,60 @@ def reset_password( def protected_page(path: str) -> bool: - exact = {"/dashboard", "/repairs", "/locations", "/system-health", "/accounts"} + exact = { + "/dashboard", + "/repairs", + "/locations", + "/system-health", + "/accounts", + "/monitoring/map", + "/operations/repairs", + "/assets/miners", + } prefixes = ("/miners/", "/agent/", "/tools/") return path in exact or path.startswith(prefixes) +def safe_next_path(value: str | None) -> str: + """Return a safe local application destination for login redirects.""" + fallback = "/dashboard" + if not value or value != value.strip(): + return fallback + if any(ord(character) < 32 or ord(character) == 127 for character in value): + return fallback + + try: + target = urlsplit(value) + except ValueError: + return fallback + + path = target.path + if ( + target.scheme + or target.netloc + or target.fragment + or not path.startswith("/") + or path.startswith("//") + or "\\" in path + or "%" in path + or any(segment in {".", ".."} for segment in path.split("/")) + or not protected_page(path) + ): + return fallback + return value + + def required_page_permission(path: str) -> str: if path == "/accounts": return "users.manage" if path == "/system-health": return "system_health.view" - if path == "/repairs": + if path in {"/repairs", "/operations/repairs"}: return "repairs.view" - if path == "/locations": + if path in {"/locations", "/monitoring/map"}: return "locations.view" + if path == "/assets/miners": + return "miners.view" if path.startswith("/agent/"): return "agents.view" if path.startswith("/miners/"): @@ -781,16 +821,19 @@ def install_identity_middleware(app) -> None: if path == "/login": user = request_user_or_none(request) if user: - next_path = request.query_params.get("next") or "/dashboard" - if not next_path.startswith("/") or next_path.startswith("//"): - next_path = "/dashboard" - return RedirectResponse(next_path, status_code=303) + return RedirectResponse( + safe_next_path(request.query_params.get("next")), + status_code=303, + ) return await call_next(request) if protected_page(path): user = request_user_or_none(request) if not user: - destination = "/login?next=" + quote(path, safe="/") + requested_path = path + if request.url.query: + requested_path += "?" + request.url.query + destination = "/login?next=" + quote(requested_path, safe="") response = RedirectResponse(destination, status_code=303) clear_session_cookie(response, request) return response @@ -853,7 +896,8 @@ button:disabled { opacity: .6; cursor: wait; }
    MinerOps
    -
    - {map_script} + {page_script} """ diff --git a/backend/app/inventory/models.py b/backend/app/inventory/models.py index 2c5204e..eb7b8a5 100644 --- a/backend/app/inventory/models.py +++ b/backend/app/inventory/models.py @@ -17,16 +17,36 @@ class InventoryTag(BaseModel): updated_at: datetime +class InventoryActiveRepair(BaseModel): + id: UUID + status: str + reason: str + + class InventoryMinerSummary(BaseModel): id: UUID asset_id: str | None = None current_mac: str | None = None current_ip: str | None = None model: str | None = None + miner_sn: str | None = None lifecycle_status: LifecycleStatus runtime_status: str site_id: UUID | None = None + site_name: str | None = None room_id: UUID | None = None + room_name: str | None = None + location_position_id: UUID | None = None + rack_label: str | None = None + shelf_label: str | None = None + position_label: str | None = None + position_index: int | None = None + observed_at: datetime | None = None + hashrate_ths: float | None = None + power_w: float | None = None + chip_temp_max_c: float | None = None + hash_stable: bool | None = None + active_repair: InventoryActiveRepair | None = None tags: list[InventoryTag] = Field(default_factory=list) created_at: datetime updated_at: datetime @@ -35,7 +55,6 @@ class InventoryMinerSummary(BaseModel): class InventoryMinerDetail(InventoryMinerSummary): hostname: str | None = None - miner_sn: str | None = None control_board_sn: str | None = None psu_serial: str | None = None firmware_version: str | None = None @@ -99,15 +118,41 @@ class InventorySearchFilters(BaseModel): query: str | None = None asset_id: str | None = None lifecycle_status: str | None = None + model: str | None = None + runtime_status: str | None = None + repair: Literal["active", "none"] | None = None + operational_state: Literal[ + "online", "offline", "unknown", "unstable", "repair", "spare", "retired" + ] | None = None + problems: bool = False + site_id: UUID | None = None + room_id: UUID | None = None tags: list[str] = Field(default_factory=list) limit: int = Field(default=100, ge=1, le=500) offset: int = Field(default=0, ge=0) +class InventorySiteOption(BaseModel): + id: UUID + name: str + + +class InventoryRoomOption(BaseModel): + id: UUID + site_id: UUID + name: str + + +class InventoryLocationOptions(BaseModel): + sites: list[InventorySiteOption] = Field(default_factory=list) + rooms: list[InventoryRoomOption] = Field(default_factory=list) + + class InventoryListMeta(BaseModel): limit: int offset: int returned: int + location_options: InventoryLocationOptions = Field(default_factory=InventoryLocationOptions) class InventoryMinerListResponse(BaseModel): diff --git a/backend/app/inventory/repository.py b/backend/app/inventory/repository.py index c1ec443..5e3421b 100644 --- a/backend/app/inventory/repository.py +++ b/backend/app/inventory/repository.py @@ -59,6 +59,101 @@ MINER_SELECT = f""" ) tag_data 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", @@ -184,8 +279,18 @@ class InventoryRepository: (asset_id,), ) - def list_miners(self, *, limit: int = 100, offset: int = 0) -> list[dict]: - return self.search_miners(limit=limit, offset=offset) + def list_miners( + self, + *, + limit: int = 100, + offset: int = 0, + include_location_details: bool = False, + ) -> list[dict]: + return self.search_miners( + limit=limit, + offset=offset, + include_location_details=include_location_details, + ) def search_miners( self, @@ -193,12 +298,20 @@ class InventoryRepository: query: str | None = None, asset_id: str | None = None, lifecycle_status: str | None = None, + model: str | None = None, + runtime_status: str | None = None, + repair: str | None = None, + operational_state: str | None = None, + problems: bool = False, + site_id: UUID | None = None, + room_id: UUID | None = None, tag_names: Sequence[str] = (), limit: int = 100, offset: int = 0, + include_location_details: bool = False, ) -> list[dict]: conditions: list[str] = [] - params: list[Any] = [] + params: list[Any] = [list(ACTIVE_REPAIR_STATUSES)] if query: pattern = f"%{query}%" @@ -217,15 +330,36 @@ class InventoryRepository: """ ) params.extend([pattern] * 8) - if asset_id: conditions.append("lower(trim(miners.asset_tag)) = lower(trim(%s))") params.append(asset_id) - if lifecycle_status: conditions.append("miners.lifecycle_status = %s") params.append(lifecycle_status) - + if model: + conditions.append("lower(trim(miners.model)) = lower(trim(%s))") + params.append(model) + if runtime_status: + conditions.append("lower(trim(miners.status)) = lower(trim(%s))") + params.append(runtime_status) + if repair == "active": + conditions.append("active_repair.id IS NOT NULL") + elif repair == "none": + conditions.append("active_repair.id IS NULL") + if operational_state: + conditions.append(f"({OPERATIONAL_STATE_SQL}) = %s") + params.append(operational_state) + if problems: + conditions.append( + f"({OPERATIONAL_STATE_SQL}) = ANY(%s)" + ) + params.append(["offline", "unknown", "unstable", "repair"]) + if site_id: + conditions.append("mapped.site_id = %s") + params.append(site_id) + if room_id: + conditions.append("mapped.room_id = %s") + params.append(room_id) for tag_name in tag_names: conditions.append( f""" @@ -241,6 +375,14 @@ class InventoryRepository: ) params.append(tag_name) + location_fields = ( + LOCATION_FIELDS if include_location_details else REDACTED_LOCATION_FIELDS + ) + location_join = LOCATION_JOIN if include_location_details else "" + select_sql = FLEET_SELECT.format( + location_fields=location_fields, + location_join=location_join, + ) where_sql = " AND ".join(conditions) if conditions else "true" params.extend([limit, offset]) @@ -248,18 +390,44 @@ class InventoryRepository: with connection.cursor(row_factory=dict_row) as cur: cur.execute( f""" - {MINER_SELECT} + {select_sql} WHERE {where_sql} - ORDER BY - lower(trim(miners.asset_tag)) NULLS LAST, - miners.created_at, - miners.id + ORDER BY lower(trim(miners.asset_tag)) NULLS LAST, + miners.created_at, miners.id LIMIT %s OFFSET %s """, params, ) return [dict(row) for row in cur.fetchall()] + def list_location_filter_options(self) -> dict[str, list[dict]]: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT sites.id, sites.name + FROM location_positions lp + JOIN miners ON miners.id = lp.miner_id + JOIN rooms ON rooms.id = lp.room_id + JOIN sites ON sites.id = rooms.site_id + GROUP BY sites.id, sites.name + ORDER BY lower(sites.name), sites.id + """ + ) + sites = [dict(row) for row in cur.fetchall()] + cur.execute( + """ + SELECT rooms.id, rooms.site_id, rooms.name + FROM location_positions lp + JOIN miners ON miners.id = lp.miner_id + JOIN rooms ON rooms.id = lp.room_id + GROUP BY rooms.id, rooms.site_id, rooms.name + ORDER BY lower(rooms.name), rooms.id + """ + ) + rooms = [dict(row) for row in cur.fetchall()] + return {"sites": sites, "rooms": rooms} + def create_miner(self, values: Mapping[str, Any]) -> dict: clean_values = { key: value for key, value in values.items() if key in MINER_WRITE_COLUMNS diff --git a/backend/app/inventory/router.py b/backend/app/inventory/router.py index 980cf22..5c3fafe 100644 --- a/backend/app/inventory/router.py +++ b/backend/app/inventory/router.py @@ -5,7 +5,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status -from app.identity import require_permission +from app.identity import require_permission, user_has_permission from app.inventory.dependencies import get_inventory_service from app.inventory.exceptions import ( DuplicateAssetID, @@ -16,6 +16,7 @@ from app.inventory.models import ( InventoryAssetIDHistoryResponse, InventoryCreateRequest, InventoryListMeta, + InventoryLocationOptions, InventoryMetadataUpdateRequest, InventoryMinerDetailResponse, InventoryMinerListResponse, @@ -102,21 +103,35 @@ def list_inventory_miners( service: InventoryServiceDependency, current_user: InventoryReadUser, ) -> InventoryMinerListResponse: - del current_user + include_location_details = user_has_permission(current_user, "locations.view") + if (filters.site_id or filters.room_id) and not include_location_details: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Location viewing permission is required for location filters", + ) try: - miners = service.search_miners(filters) + miners = service.search_miners( + filters, + include_location_details=include_location_details, + ) except InventoryValidationError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc), ) from exc + location_options = InventoryLocationOptions() + if include_location_details: + location_options = InventoryLocationOptions.model_validate( + service.list_location_filter_options() + ) return InventoryMinerListResponse( data=miners, meta=InventoryListMeta( limit=filters.limit, offset=filters.offset, returned=len(miners), + location_options=location_options, ), ) diff --git a/backend/app/inventory/service.py b/backend/app/inventory/service.py index 2237811..73dc960 100644 --- a/backend/app/inventory/service.py +++ b/backend/app/inventory/service.py @@ -79,16 +79,37 @@ class InventoryService: @classmethod def _summary_model(cls, row: dict) -> InventoryMinerSummary: + active_repair = None + if row.get("active_repair_id") is not None: + active_repair = { + "id": row["active_repair_id"], + "status": row["active_repair_status"], + "reason": row["active_repair_reason"], + } return InventoryMinerSummary( id=row["id"], asset_id=row.get("asset_tag"), current_mac=row.get("current_mac"), current_ip=row.get("current_ip"), model=row.get("model"), + miner_sn=row.get("miner_sn"), lifecycle_status=row["lifecycle_status"], runtime_status=row["status"], site_id=row.get("site_id"), + site_name=row.get("site_name"), room_id=row.get("room_id"), + room_name=row.get("room_name"), + location_position_id=row.get("location_position_id"), + rack_label=row.get("rack_label"), + shelf_label=row.get("shelf_label"), + position_label=row.get("position_label"), + position_index=row.get("position_index"), + observed_at=row.get("observed_at"), + hashrate_ths=row.get("hashrate_ths"), + power_w=row.get("power_w"), + chip_temp_max_c=row.get("chip_temp_max_c"), + hash_stable=row.get("hash_stable"), + active_repair=active_repair, tags=[cls._tag_model(tag) for tag in row.get("tags") or []], created_at=row["created_at"], updated_at=row["updated_at"], @@ -101,7 +122,6 @@ class InventoryService: return InventoryMinerDetail( **summary.model_dump(), hostname=row.get("hostname"), - miner_sn=row.get("miner_sn"), control_board_sn=row.get("control_board_sn"), psu_serial=row.get("power_sn"), firmware_version=row.get("firmware_version"), @@ -140,12 +160,18 @@ class InventoryService: 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) @@ -158,12 +184,23 @@ class InventoryService: query=self._clean_optional_text(filters.query), asset_id=asset_id, lifecycle_status=lifecycle_status, + model=self._clean_optional_text(filters.model), + runtime_status=self._clean_optional_text(filters.runtime_status), + repair=filters.repair, + operational_state=filters.operational_state, + problems=filters.problems, + site_id=filters.site_id, + room_id=filters.room_id, tag_names=tag_names, limit=filters.limit, offset=filters.offset, + include_location_details=include_location_details, ) ] + def list_location_filter_options(self) -> dict[str, list[dict]]: + return self.repository.list_location_filter_options() + def create_miner( self, request: InventoryCreateRequest, diff --git a/backend/tests/test_frontend_shell.py b/backend/tests/test_frontend_shell.py index 79780c6..2cb81fe 100644 --- a/backend/tests/test_frontend_shell.py +++ b/backend/tests/test_frontend_shell.py @@ -363,6 +363,7 @@ class FrontendShellTests(unittest.TestCase): "/assets/minerops-app.css", "/assets/minerops-app.js", "/assets/minerops-mine-map.js", + "/assets/minerops-fleet.js", ): with self.subTest(path=path): response = self.client.get(path) @@ -405,12 +406,76 @@ class FrontendShellTests(unittest.TestCase): self.assertIn("element.textContent = values.text", javascript) self.assertIn("cell.textContent = text(content)", javascript) self.assertIn("encodeURIComponent(repair.miner_id)", javascript) - self.assertIn("encodeURIComponent(miner.id)", javascript) self.assertIn("new AbortController()", javascript) self.assertIn("invalid repair response", javascript) - self.assertIn("invalid inventory response", javascript) self.assertIn("window.location.replace", javascript) + def test_fleet_page_is_semantic_responsive_and_permission_aware(self): + self.authenticate() + response = self.client.get("/assets/miners") + self.assertEqual(response.status_code, 200) + for value in ( + 'id="fleetFilters"', 'id="fleetRows"', 'id="fleetResultSummary"', + '', '', + '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( + '', + 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) diff --git a/backend/tests/test_inventory_domain.py b/backend/tests/test_inventory_domain.py index 091c091..4df887a 100644 --- a/backend/tests/test_inventory_domain.py +++ b/backend/tests/test_inventory_domain.py @@ -45,6 +45,21 @@ def inventory_row(**overrides): "created_at": now, "updated_at": now, "tags": [], + "site_name": None, + "room_name": None, + "location_position_id": None, + "rack_label": None, + "shelf_label": None, + "position_label": None, + "position_index": None, + "observed_at": None, + "hashrate_ths": None, + "power_w": None, + "chip_temp_max_c": None, + "hash_stable": None, + "active_repair_id": None, + "active_repair_status": None, + "active_repair_reason": None, } row.update(overrides) return row @@ -56,6 +71,7 @@ class FakeInventoryRepository: self.created_values = None self.asset_id_history = [] self.update_error = None + self.search_kwargs = None @contextmanager def transaction(self): @@ -89,6 +105,7 @@ class FakeInventoryRepository: return list(self.rows.values())[offset : offset + limit] def search_miners(self, **kwargs): + self.search_kwargs = kwargs rows = list(self.rows.values()) lifecycle = kwargs.get("lifecycle_status") if lifecycle: @@ -416,6 +433,54 @@ class InventoryServiceTests(unittest.TestCase): ) self.assertEqual(self.repository.asset_id_history, []) + def test_fleet_projection_mapping_and_filter_forwarding(self): + miner_id = uuid4() + repair_id = uuid4() + site_id = uuid4() + row = inventory_row( + id=miner_id, + miner_sn="MINER-SN", + site_id=site_id, + site_name="North Site", + observed_at=datetime.now(timezone.utc), + hashrate_ths=0.0, + power_w=0.0, + chip_temp_max_c=0.0, + hash_stable=False, + active_repair_id=repair_id, + active_repair_status="IN_PROGRESS", + active_repair_reason="Hashboard diagnostics", + ) + self.repository.rows[miner_id] = row + filters = InventorySearchFilters( + model=" M50 ", runtime_status="offline", repair="active", + operational_state="repair", problems=True, site_id=site_id, + limit=25, offset=0, + ) + + result = self.service.search_miners( + filters, include_location_details=True + )[0] + + self.assertEqual(result.miner_sn, "MINER-SN") + self.assertEqual(result.hashrate_ths, 0.0) + self.assertEqual(result.power_w, 0.0) + self.assertEqual(result.chip_temp_max_c, 0.0) + self.assertFalse(result.hash_stable) + self.assertEqual(result.active_repair.id, repair_id) + self.assertEqual(self.repository.search_kwargs["model"], "M50") + self.assertEqual(self.repository.search_kwargs["site_id"], site_id) + self.assertTrue(self.repository.search_kwargs["include_location_details"]) + + def test_location_filter_without_projection_is_rejected(self): + with self.assertRaisesRegex( + InventoryValidationError, "Location filters require" + ): + self.service.search_miners( + InventorySearchFilters(site_id=uuid4()) + ) + self.assertIsNone(self.repository.search_kwargs) + def test_search_validation_precedes_repository(self): with self.assertRaises(InventoryValidationError): self.service.search_miners( @@ -485,6 +550,22 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase): miner_id, ) + fleet = self.repository.search_miners( + model="M50", + runtime_status="unknown", + operational_state="unknown", + problems=True, + include_location_details=True, + ) + self.assertEqual(fleet[0]["id"], miner_id) + self.assertEqual(fleet[0]["miner_sn"], "MINER-001") + self.assertIsNone(fleet[0]["observed_at"]) + self.assertIsNone(fleet[0]["location_position_id"]) + self.assertEqual( + self.repository.list_location_filter_options(), + {"sites": [], "rooms": []}, + ) + updated = self.repository.update_miner( miner_id, {"lifecycle_status": "spare", "notes": "Bench stock"}, diff --git a/backend/tests/test_inventory_router.py b/backend/tests/test_inventory_router.py index d4e2228..c307036 100644 --- a/backend/tests/test_inventory_router.py +++ b/backend/tests/test_inventory_router.py @@ -43,16 +43,24 @@ class StubInventoryService: self.history_result: list[InventoryAssetIDHistoryEntry] = [] self.history_error: Exception | None = None self.history_miner_id: UUID | None = None + self.include_location_details = False + self.location_options = {"sites": [], "rooms": []} def search_miners( self, filters: InventorySearchFilters, + *, + include_location_details: bool = False, ) -> list[InventoryMinerSummary]: self.search_filters = filters + self.include_location_details = include_location_details if self.search_error is not None: raise self.search_error return self.search_result + def list_location_filter_options(self): + return self.location_options + def create_miner( self, request: InventoryCreateRequest, @@ -110,10 +118,10 @@ def miner_summary(miner_id: UUID | None = None) -> InventoryMinerSummary: def miner_detail(miner_id: UUID | None = None) -> InventoryMinerDetail: - summary = miner_summary(miner_id) + values = miner_summary(miner_id).model_dump() + values["miner_sn"] = "MINER-001" return InventoryMinerDetail( - **summary.model_dump(), - miner_sn="MINER-001", + **values, control_board_sn="CB-001", psu_serial="PSU-001", ) @@ -410,10 +418,77 @@ class InventoryRouterTests(unittest.TestCase): self.assertEqual(body["data"][0]["asset_id"], "ASSET-001") self.assertEqual( body["meta"], - {"limit": 100, "offset": 0, "returned": 1}, + {"limit": 100, "offset": 0, "returned": 1, + "location_options": {"sites": [], "rooms": []}}, ) self.assertIsNone(body["error"]) + def test_list_projection_and_location_permission_are_explicit(self): + miner_id = uuid4() + site_id = uuid4() + room_id = uuid4() + position_id = uuid4() + self.service.search_result = [ + InventoryMinerSummary( + id=miner_id, + asset_id="FLEET-001", + model="M50", + miner_sn="SN-001", + lifecycle_status="active", + runtime_status="online", + site_id=site_id, + site_name="North Site", + room_id=room_id, + room_name="Room A", + location_position_id=position_id, + rack_label="Rack 1", + shelf_label="Shelf 2", + position_index=3, + observed_at=datetime.now(timezone.utc), + hashrate_ths=120.0, + power_w=3400.0, + chip_temp_max_c=72.0, + hash_stable=True, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + ] + self.service.location_options = { + "sites": [{"id": site_id, "name": "North Site"}], + "rooms": [{"id": room_id, "site_id": site_id, "name": "Room A"}], + } + + self.authenticate(("miners.view", "locations.view")) + response = self.client.get("/api/inventory/miners") + data = response.json()["data"][0] + self.assertEqual(response.status_code, 200) + self.assertEqual(data["miner_sn"], "SN-001") + self.assertEqual(data["location_position_id"], str(position_id)) + self.assertEqual(data["hashrate_ths"], 120.0) + self.assertTrue(self.service.include_location_details) + self.assertEqual( + response.json()["meta"]["location_options"]["sites"][0]["name"], + "North Site", + ) + + self.authenticate(("miners.view",)) + self.service.search_result = [miner_summary(miner_id)] + response = self.client.get("/api/inventory/miners") + data = response.json()["data"][0] + self.assertIsNone(data["site_id"]) + self.assertIsNone(data["room_id"]) + self.assertIsNone(data["location_position_id"]) + self.assertEqual(response.json()["meta"]["location_options"], {"sites": [], "rooms": []}) + self.assertFalse(self.service.include_location_details) + + def test_location_filters_require_locations_view(self): + self.authenticate(("miners.view",)) + response = self.client.get( + "/api/inventory/miners", params={"site_id": str(uuid4())} + ) + self.assertEqual(response.status_code, 403) + self.assertIsNone(self.service.search_filters) + def test_empty_list_returns_successful_empty_collection(self): self.authenticate() @@ -432,6 +507,11 @@ class InventoryRouterTests(unittest.TestCase): ("query", "M50"), ("asset_id", "ASSET-001"), ("lifecycle_status", "spare"), + ("model", "M50"), + ("runtime_status", "offline"), + ("repair", "active"), + ("operational_state", "repair"), + ("problems", "true"), ("tags", "Priority"), ("tags", "Repair"), ("limit", "25"), @@ -445,6 +525,11 @@ class InventoryRouterTests(unittest.TestCase): self.assertEqual(filters.query, "M50") self.assertEqual(filters.asset_id, "ASSET-001") self.assertEqual(filters.lifecycle_status, "spare") + self.assertEqual(filters.model, "M50") + self.assertEqual(filters.runtime_status, "offline") + self.assertEqual(filters.repair, "active") + self.assertEqual(filters.operational_state, "repair") + self.assertTrue(filters.problems) self.assertEqual(filters.tags, ["Priority", "Repair"]) self.assertEqual(filters.limit, 25) self.assertEqual(filters.offset, 50) From 4a1959ecd6c4cfd2ca7e7ceea14d0a22b60365b5 Mon Sep 17 00:00:00 2001 From: JC Date: Tue, 11 Aug 2026 20:17:16 +0000 Subject: [PATCH 34/37] feat: add Repair Center operations view --- backend/app/frontend/router.py | 9 + backend/app/frontend/static/app.css | 132 ++++ backend/app/frontend/static/app.js | 198 ------ backend/app/frontend/static/repair-center.js | 610 +++++++++++++++++++ backend/app/frontend/views.py | 87 ++- backend/app/identity.py | 8 + backend/app/main.py | 213 +++---- backend/app/repairs/center_repository.py | 316 ++++++++++ backend/app/repairs/center_service.py | 96 +++ backend/app/repairs/dependencies.py | 6 + backend/app/repairs/models.py | 136 +++++ backend/tests/test_frontend_shell.py | 27 +- backend/tests/test_repair_center.py | 430 +++++++++++++ scripts/test-repair-center.sh | 33 + 14 files changed, 1963 insertions(+), 338 deletions(-) create mode 100644 backend/app/frontend/static/repair-center.js create mode 100644 backend/app/repairs/center_repository.py create mode 100644 backend/app/repairs/center_service.py create mode 100644 backend/tests/test_repair_center.py create mode 100755 scripts/test-repair-center.sh diff --git a/backend/app/frontend/router.py b/backend/app/frontend/router.py index 03dcd6f..74599ab 100644 --- a/backend/app/frontend/router.py +++ b/backend/app/frontend/router.py @@ -63,6 +63,15 @@ def fleet_script() -> Response: ) + +@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("/dashboard", response_class=HTMLResponse, include_in_schema=False) def dashboard_page(current_user: DashboardUser) -> str: return render_page("dashboard", current_user) diff --git a/backend/app/frontend/static/app.css b/backend/app/frontend/static/app.css index a7ddbaa..d486f5e 100644 --- a/backend/app/frontend/static/app.css +++ b/backend/app/frontend/static/app.css @@ -1357,3 +1357,135 @@ button.map-position:hover { .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); } +} diff --git a/backend/app/frontend/static/app.js b/backend/app/frontend/static/app.js index 3ce7594..e4acbfc 100644 --- a/backend/app/frontend/static/app.js +++ b/backend/app/frontend/static/app.js @@ -1,93 +1,6 @@ (function () { "use strict"; - const view = document.body.dataset.view || ""; - const canViewMiners = - document.body.dataset.canViewMiners === "true"; - - function text(value, fallback) { - if (value === null || value === undefined || String(value).trim() === "") { - return fallback || "Not available"; - } - return String(value); - } - - function statusName(value) { - return text(value, "unknown").toLowerCase().replaceAll(" ", "_"); - } - - function create(tag, options) { - const element = document.createElement(tag); - const values = options || {}; - if (values.className) element.className = values.className; - if (values.text !== undefined) element.textContent = values.text; - if (values.href) element.href = values.href; - return element; - } - - function showState(name, message) { - for (const state of document.querySelectorAll("[data-view-state]")) { - state.hidden = state.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; - } - - async function api(path) { - const controller = new AbortController(); - const timeout = window.setTimeout(function () { - controller.abort(); - }, 15000); - let response; - try { - response = await fetch(path, { - credentials: "same-origin", - headers: { Accept: "application/json" }, - signal: controller.signal - }); - } catch (error) { - if (error && error.name === "AbortError") { - throw new Error( - "MinerOps took too long to respond. Retry in a moment." - ); - } - throw new Error( - "MinerOps could not reach the backend. Check the connection and retry." - ); - } finally { - window.clearTimeout(timeout); - } - if (response.status === 401) { - window.location.replace( - "/login?next=" + - encodeURIComponent(window.location.pathname + window.location.search) - ); - throw new Error("Your session expired. Sign in again to continue."); - } - if (response.status === 403) { - const denied = new Error( - "Your account does not have permission to view this information." - ); - denied.permissionDenied = true; - throw denied; - } - if (!response.ok) { - throw new Error( - "MinerOps could not load this information. Retry in a moment." - ); - } - try { - return await response.json(); - } catch (error) { - throw new Error( - "MinerOps returned an invalid response. Retry in a moment." - ); - } - } - function setupShell() { const menu = document.getElementById("menuButton"); const nav = document.getElementById("primaryNav"); @@ -128,118 +41,7 @@ } } - 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 status(value) { - const label = create("span", { - className: "status-text", - text: text(value, "Unknown").replaceAll("_", " ") - }); - label.dataset.status = statusName(value); - return label; - } - - function dateTime(value) { - if (!value) return "Not available"; - const parsed = new Date(value); - return Number.isNaN(parsed.valueOf()) - ? String(value) - : parsed.toLocaleString(); - } - - async function loadRepairs() { - const rows = document.getElementById("repairRows"); - if (!rows) return; - showState("loading"); - try { - const envelope = await api("/api/repairs/queue"); - if ( - !envelope || - !envelope.data || - typeof envelope.data !== "object" || - !Array.isArray(envelope.data.items) - ) { - throw new Error( - "MinerOps returned an invalid repair response." - ); - } - const items = envelope.data.items; - if (!items.every(function (item) { - return item && - typeof item === "object" && - typeof item.miner_id === "string" && - item.miner_id.trim() !== ""; - })) { - throw new Error( - "MinerOps returned an incomplete repair response." - ); - } - rows.replaceChildren(); - if (!items.length) { - showState("empty"); - return; - } - for (const repair of items) { - const row = create("tr"); - const identity = create("div"); - const minerLabel = text( - repair.current_mac, - text(repair.miner_id, "Unknown miner") - ); - if (canViewMiners) { - identity.appendChild(create("a", { - className: "object-link", - text: minerLabel, - href: "/miners/" + - encodeURIComponent(repair.miner_id) + - "/detail" - })); - } else { - identity.appendChild(create("strong", { - text: minerLabel - })); - } - identity.appendChild(create("span", { - className: "secondary", - text: text(repair.current_ip, "No current IP") - })); - addCell(row, "Miner", identity); - addCell(row, "Model", repair.model); - addCell( - row, - "Repair state", - status(repair.latest_event_status || repair.asset_state) - ); - addCell( - row, - "Reported issue", - repair.reported_error || - repair.latest_event_title || - "No reported issue" - ); - addCell( - row, - "Last update", - dateTime(repair.latest_event_at || repair.state_updated_at) - ); - rows.appendChild(row); - } - showState("ready"); - } catch (error) { - showState( - error.permissionDenied ? "denied" : "error", - error.message - ); - } - } setupShell(); - if (view === "repairs") loadRepairs(); })(); diff --git a/backend/app/frontend/static/repair-center.js b/backend/app/frontend/static/repair-center.js new file mode 100644 index 0000000..b713956 --- /dev/null +++ b/backend/app/frontend/static/repair-center.js @@ -0,0 +1,610 @@ +(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); + link.href = "/miners/" + encodeURIComponent(item.miner_id) + "/detail"; + 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 index 0913af3..6ac084a 100644 --- a/backend/app/frontend/views.py +++ b/backend/app/frontend/views.py @@ -199,26 +199,79 @@ def _state_messages(noun: str) -> str: """ -def _repairs_content() -> str: +def _repairs_content(can_view_locations: bool, can_view_miners: bool) -> str: + location_filters = """ +
    +
    +
    +
    + """ 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 = """ +
    +
    + """ if can_view_miners else "" return f""" -
    +
    -
    -

    Current repair queue

    -

    Real records from the existing repair queue - API. Open a miner to continue in its current detail view.

    +

    Repair Center

    +

    Active work and historical modular repairs, with legacy queue records clearly identified.

    + Loading repair work +
    +
    +
    Total results-
    +
    Active-
    +
    Historical-
    +
    + + +
    +
    +
    +
    + {model_filter} +
    +
    + {location_filters} +
    + +
    - Loaded on request -
    + {_state_messages("repair records")} -
    Operational
    - - - -
    MinerModelRepair stateReported issueLast update
    + + + """ @@ -338,6 +391,10 @@ def render_app_page( 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 CONTENT[view]() ) page_script = ( @@ -345,6 +402,8 @@ def render_app_page( if view == "map" else '' if view == "miners" + else '' + if view == "repairs" else "" ) return f""" diff --git a/backend/app/identity.py b/backend/app/identity.py index c888b19..dab91b8 100644 --- a/backend/app/identity.py +++ b/backend/app/identity.py @@ -753,6 +753,14 @@ def component_request(path: str) -> bool: 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): diff --git a/backend/app/main.py b/backend/app/main.py index 5be2b39..63e144d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ import psycopg import redis from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError -from fastapi import Request, Depends, FastAPI, Header, HTTPException, status +from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, status from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError from app.config import settings @@ -17,6 +17,7 @@ from app.identity import ( get_current_user, install_identity_middleware, require_permission, + user_has_permission, router as identity_router, ) from app.frontend.router import router as frontend_router @@ -30,11 +31,17 @@ from app.repairs.exceptions import ( 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, @@ -31732,122 +31739,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, @@ -31874,7 +31831,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,), ) @@ -31884,6 +31842,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 ( @@ -31966,7 +31926,8 @@ def minerops_repair_update_status_api( SELECT id::text FROM miners WHERE id = %s::uuid - LIMIT 1; + LIMIT 1 + FOR UPDATE; """, (miner_id,), ) @@ -31976,6 +31937,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 @@ -32121,7 +32084,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,), ) @@ -32131,6 +32095,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 @@ -32937,8 +32903,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."); diff --git a/backend/app/repairs/center_repository.py b/backend/app/repairs/center_repository.py new file mode 100644 index 0000000..fb090bc --- /dev/null +++ b/backend/app/repairs/center_repository.py @@ -0,0 +1,316 @@ +"""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} diff --git a/backend/app/repairs/center_service.py b/backend/app/repairs/center_service.py new file mode 100644 index 0000000..00ad594 --- /dev/null +++ b/backend/app/repairs/center_service.py @@ -0,0 +1,96 @@ +"""Application service for the consolidated Repair Center read model.""" + +from app.repairs.center_repository import RepairCenterRepository +from app.repairs.models import ( + 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, + ), + ) diff --git a/backend/app/repairs/dependencies.py b/backend/app/repairs/dependencies.py index 8fafe37..586a989 100644 --- a/backend/app/repairs/dependencies.py +++ b/backend/app/repairs/dependencies.py @@ -1,8 +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/models.py b/backend/app/repairs/models.py index 55e8f8c..c370c9f 100644 --- a/backend/app/repairs/models.py +++ b/backend/app/repairs/models.py @@ -286,3 +286,139 @@ 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 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/tests/test_frontend_shell.py b/backend/tests/test_frontend_shell.py index 2cb81fe..8e40376 100644 --- a/backend/tests/test_frontend_shell.py +++ b/backend/tests/test_frontend_shell.py @@ -209,8 +209,11 @@ class FrontendShellTests(unittest.TestCase): self.assertIn("@media (max-width: 560px)", css) self.assertIn(":focus-visible", css) self.assertIn(".skip-link", css) - self.assertIn('response.status === 401', javascript) - self.assertIn('response.status === 403', javascript) + 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): @@ -401,14 +404,18 @@ class FrontendShellTests(unittest.TestCase): def test_javascript_uses_safe_dom_and_strict_failure_states(self): javascript = (STATIC_ROOT / "app.js").read_text(encoding="utf-8") - for unsafe in ("innerHTML", "insertAdjacentHTML", "document.write"): - self.assertNotIn(unsafe, javascript) - self.assertIn("element.textContent = values.text", javascript) - self.assertIn("cell.textContent = text(content)", javascript) - self.assertIn("encodeURIComponent(repair.miner_id)", javascript) - self.assertIn("new AbortController()", javascript) - self.assertIn("invalid repair response", javascript) - self.assertIn("window.location.replace", javascript) + repair_center = (STATIC_ROOT / "repair-center.js").read_text( + encoding="utf-8" + ) + for source in (javascript, repair_center): + for unsafe in ("innerHTML", "insertAdjacentHTML", "document.write"): + self.assertNotIn(unsafe, source) + self.assertIn("window.location.replace", source) + self.assertNotIn("/api/repairs/queue", javascript) + self.assertIn("new AbortController()", repair_center) + self.assertIn("cell.textContent = text(content)", repair_center) + self.assertIn("encodeURIComponent(item.miner_id)", repair_center) + self.assertIn("invalid Repair Center response", repair_center) def test_fleet_page_is_semantic_responsive_and_permission_aware(self): self.authenticate() 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/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 From b8b741ccee806ee85562461490ad5d5ff07eabe2 Mon Sep 17 00:00:00 2001 From: JC Date: Mon, 17 Aug 2026 17:11:40 +0000 Subject: [PATCH 35/37] feat: add operational Dashboard --- backend/app/dashboard/__init__.py | 1 + backend/app/dashboard/dependencies.py | 14 + backend/app/dashboard/models.py | 32 +++ backend/app/dashboard/router.py | 40 +++ backend/app/dashboard/service.py | 64 +++++ backend/app/frontend/router.py | 9 + backend/app/frontend/static/app.css | 129 +++++++++ backend/app/frontend/static/dashboard.js | 155 ++++++++++ backend/app/frontend/views.py | 152 ++++++---- backend/app/inventory/models.py | 18 ++ backend/app/inventory/repository.py | 64 +++++ backend/app/inventory/service.py | 20 ++ backend/app/locations/models.py | 10 + backend/app/locations/repository.py | 28 ++ backend/app/locations/service.py | 8 + backend/app/main.py | 13 +- backend/app/repairs/center_repository.py | 28 ++ backend/app/repairs/center_service.py | 6 + backend/app/repairs/models.py | 5 + backend/tests/test_dashboard.py | 347 +++++++++++++++++++++++ backend/tests/test_frontend_shell.py | 89 +++++- backend/tests/test_mine_map.py | 41 ++- scripts/test-dashboard.sh | 64 +++++ 23 files changed, 1278 insertions(+), 59 deletions(-) create mode 100644 backend/app/dashboard/__init__.py create mode 100644 backend/app/dashboard/dependencies.py create mode 100644 backend/app/dashboard/models.py create mode 100644 backend/app/dashboard/router.py create mode 100644 backend/app/dashboard/service.py create mode 100644 backend/app/frontend/static/dashboard.js create mode 100644 backend/tests/test_dashboard.py create mode 100755 scripts/test-dashboard.sh 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/router.py b/backend/app/frontend/router.py index 74599ab..557fa5e 100644 --- a/backend/app/frontend/router.py +++ b/backend/app/frontend/router.py @@ -45,6 +45,15 @@ def frontend_script() -> Response: ) +@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( diff --git a/backend/app/frontend/static/app.css b/backend/app/frontend/static/app.css index d486f5e..dd10772 100644 --- a/backend/app/frontend/static/app.css +++ b/backend/app/frontend/static/app.css @@ -1489,3 +1489,132 @@ button.map-position:hover { .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; } +} 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/views.py b/backend/app/frontend/views.py index 6ac084a..e994280 100644 --- a/backend/app/frontend/views.py +++ b/backend/app/frontend/views.py @@ -13,7 +13,7 @@ NAVIGATION = ( PAGE_DETAILS = { "dashboard": ( - "Fleet overview", + "Operational overview", "Dashboard", "A concise starting point for operational state and recent activity.", ), @@ -35,46 +35,104 @@ PAGE_DETAILS = { } -def _dashboard_content() -> str: - metrics = ( - ("Site health", "--", "Live health summary not yet connected"), - ("Total miners", "--", "Inventory summary not yet connected"), - ("Miners in repair", "--", "Repair summary not yet connected"), - ("Current hashrate", "-- TH/s", "Telemetry not yet connected"), - ("Current power", "-- kW", "Telemetry not yet connected"), - ) - metric_html = "".join( - "
    " - f"{escape(label)}" - f"{escape(value)}" - f"{escape(state)}" - "
    " - for label, value, state in metrics +def _dashboard_content( + can_view_miners: bool, + can_view_repairs: bool, + can_view_locations: bool, +) -> str: + fleet = """ +
    +

    Assets

    +

    Fleet overview

    + Open Fleet
    +
    Total miners + -
    +
    +

    Runtime

    +
    +
    Online
    -
    +
    Offline
    -
    +
    Unknown
    -
    +
    +

    Lifecycle

    +
    +
    In repair
    -
    +
    Spare
    -
    +
    Retired
    -
    +
    +
    +

    Runtime and lifecycle are separate dimensions and may overlap.

    +
    +
    +

    Current state

    +

    Needs attention

    + +
    + """ if can_view_miners else "" + repairs = """ +
    +

    Operations

    +

    Repairs

    + Open Repair Center
    +
    Active repair records + -
    +
    +
    + """ if can_view_repairs else "" + mapped_metrics = """ +
    Mapped miners
    -
    +
    Unmapped miners
    -
    + """ if can_view_miners else "" + locations = f""" +
    +

    Spatial operations

    +

    Locations

    + Open Mine Map
    +
    +
    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""" -
    - {metric_html} -
    -
    -
    -
    -
    -

    Recent activity

    -

    Operational events will appear here when - the dashboard activity feed is connected.

    -
    -
    -
    - No activity feed connected - No events are fabricated for this placeholder. -
    -
    - +
    + Loading operational overview + Requesting current fleet, repair, and location summaries. +
    + + + """ @@ -345,14 +403,6 @@ def _miners_content(can_view_locations: bool) -> str: """ -CONTENT = { - "dashboard": _dashboard_content, - "map": _map_content, - "repairs": _repairs_content, - "miners": _miners_content, -} - - def render_app_page( view: str, current_user: dict, @@ -395,7 +445,11 @@ def render_app_page( has_permission("locations.view"), has_permission("miners.view") ) if view == "repairs" - else CONTENT[view]() + else _dashboard_content( + has_permission("miners.view"), + has_permission("repairs.view"), + has_permission("locations.view"), + ) ) page_script = ( '' @@ -404,6 +458,8 @@ def render_app_page( if view == "miners" else '' if view == "repairs" + else '' + if view == "dashboard" else "" ) return f""" diff --git a/backend/app/inventory/models.py b/backend/app/inventory/models.py index eb7b8a5..de278d0 100644 --- a/backend/app/inventory/models.py +++ b/backend/app/inventory/models.py @@ -171,3 +171,21 @@ class InventoryAssetIDHistoryResponse(BaseModel): data: list[InventoryAssetIDHistoryEntry] meta: dict[str, object] = Field(default_factory=dict) error: None = None + + +class InventoryFleetAttentionSummary(BaseModel): + offline: int + unknown: int + unstable: int + repair: int + + +class InventoryFleetSummary(BaseModel): + total: int + runtime_online: int + runtime_offline: int + runtime_unknown: int + lifecycle_in_repair: int + lifecycle_spare: int + lifecycle_retired: int + attention: InventoryFleetAttentionSummary diff --git a/backend/app/inventory/repository.py b/backend/app/inventory/repository.py index 5e3421b..991883d 100644 --- a/backend/app/inventory/repository.py +++ b/backend/app/inventory/repository.py @@ -292,6 +292,70 @@ class InventoryRepository: 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, *, diff --git a/backend/app/inventory/service.py b/backend/app/inventory/service.py index 73dc960..c70175e 100644 --- a/backend/app/inventory/service.py +++ b/backend/app/inventory/service.py @@ -14,6 +14,8 @@ from app.inventory.exceptions import ( from app.inventory.models import ( InventoryAssetIDHistoryEntry, InventoryCreateRequest, + InventoryFleetAttentionSummary, + InventoryFleetSummary, InventoryMetadataUpdateRequest, InventoryMinerDetail, InventoryMinerSummary, @@ -157,6 +159,24 @@ class InventoryService: for row in self.repository.list_miners(limit=limit, offset=offset) ] + def summarize_fleet(self) -> InventoryFleetSummary: + row = self.repository.summarize_fleet() + return InventoryFleetSummary( + total=row["total"], + runtime_online=row["runtime_online"], + runtime_offline=row["runtime_offline"], + runtime_unknown=row["runtime_unknown"], + lifecycle_in_repair=row["lifecycle_in_repair"], + lifecycle_spare=row["lifecycle_spare"], + lifecycle_retired=row["lifecycle_retired"], + attention=InventoryFleetAttentionSummary( + offline=row["attention_offline"], + unknown=row["attention_unknown"], + unstable=row["attention_unstable"], + repair=row["attention_repair"], + ), + ) + def search_miners( self, filters: InventorySearchFilters, diff --git a/backend/app/locations/models.py b/backend/app/locations/models.py index 934b55e..b078f89 100644 --- a/backend/app/locations/models.py +++ b/backend/app/locations/models.py @@ -116,3 +116,13 @@ class MineMapResponse(BaseModel): data: MineMapData meta: MineMapMeta error: 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 index e9681fb..20d17b4 100644 --- a/backend/app/locations/repository.py +++ b/backend/app/locations/repository.py @@ -201,3 +201,31 @@ class LocationMapRepository: "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()) diff --git a/backend/app/locations/service.py b/backend/app/locations/service.py index 6c80028..9858242 100644 --- a/backend/app/locations/service.py +++ b/backend/app/locations/service.py @@ -5,6 +5,7 @@ from typing import Any from uuid import UUID from app.locations.models import ( + LocationSummary, MapActiveRepair, MapPosition, MapRoom, @@ -85,3 +86,10 @@ class LocationMapService: miner_details_included=include_miner_details, ), ) + + 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 63e144d..b6bc3bd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,6 +11,7 @@ from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, sta 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, @@ -20,7 +21,10 @@ from app.identity import ( user_has_permission, router as identity_router, ) -from app.frontend.router import router as frontend_router +from app.frontend.router import ( + dashboard_page as internal_alpha_dashboard_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 @@ -55,6 +59,7 @@ 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) install_identity_middleware(app) @@ -33444,6 +33449,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 @@ -33799,6 +33807,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": diff --git a/backend/app/repairs/center_repository.py b/backend/app/repairs/center_repository.py index fb090bc..3b49dd2 100644 --- a/backend/app/repairs/center_repository.py +++ b/backend/app/repairs/center_repository.py @@ -314,3 +314,31 @@ class RepairCenterRepository: finally: connection.close() return {"items": items, "summary": summary, "location_options": options} + + 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 index 00ad594..d49ba64 100644 --- a/backend/app/repairs/center_service.py +++ b/backend/app/repairs/center_service.py @@ -2,6 +2,7 @@ from app.repairs.center_repository import RepairCenterRepository from app.repairs.models import ( + ActiveRepairSummary, RepairCenterAccess, RepairCenterCapabilities, RepairCenterFilters, @@ -94,3 +95,8 @@ class RepairCenterService: location_options=location_options, ), ) + + def summarize_active_repairs(self) -> ActiveRepairSummary: + return ActiveRepairSummary.model_validate( + self.repository.summarize_active_repairs() + ) diff --git a/backend/app/repairs/models.py b/backend/app/repairs/models.py index c370c9f..7959cac 100644 --- a/backend/app/repairs/models.py +++ b/backend/app/repairs/models.py @@ -389,6 +389,11 @@ class RepairCenterSummary(BaseModel): 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 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 index 8e40376..a18d1f7 100644 --- a/backend/tests/test_frontend_shell.py +++ b/backend/tests/test_frontend_shell.py @@ -132,13 +132,41 @@ class FrontendShellTests(unittest.TestCase): def test_dashboard_does_not_fabricate_operational_values(self): self.authenticate() response = self.client.get("/dashboard") - self.assertIn( - "Live health summary not yet connected", - response.text, - ) - self.assertIn("No events are fabricated", response.text) + 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") @@ -230,6 +258,7 @@ class FrontendShellTests(unittest.TestCase): 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: @@ -250,6 +279,55 @@ class FrontendShellTests(unittest.TestCase): 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 = ( @@ -365,6 +443,7 @@ class FrontendShellTests(unittest.TestCase): for path in ( "/assets/minerops-app.css", "/assets/minerops-app.js", + "/assets/minerops-dashboard.js", "/assets/minerops-mine-map.js", "/assets/minerops-fleet.js", ): diff --git a/backend/tests/test_mine_map.py b/backend/tests/test_mine_map.py index dc0f56b..5a3fed0 100644 --- a/backend/tests/test_mine_map.py +++ b/backend/tests/test_mine_map.py @@ -358,11 +358,42 @@ class MineMapFrontendAuditTests(unittest.TestCase): def test_map_repository_uses_constant_set_based_queries(self): source = (Path(__file__).parents[1] / "app" / "locations" / "repository.py").read_text(encoding="utf-8") - self.assertEqual(source.count("cur.execute("), 4) - self.assertEqual(source.count("LEFT JOIN LATERAL"), 2) - self.assertIn("ORDER BY snapshots.observed_at DESC, snapshots.id DESC", source) - self.assertIn("ORDER BY repairs.opened_at, repairs.id", source) - self.assertNotIn("for miner", source) + + 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__": 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." From 19fc29c72c78154a7865bef6ab8b93b737b772ed Mon Sep 17 00:00:00 2001 From: JC Date: Mon, 17 Aug 2026 18:43:20 +0000 Subject: [PATCH 36/37] feat: add miner detail view --- backend/app/frontend/miner_detail_view.py | 76 +++ backend/app/frontend/router.py | 34 +- backend/app/frontend/static/app.css | 104 +++ backend/app/frontend/static/fleet.js | 4 +- backend/app/frontend/static/miner-detail.js | 421 ++++++++++++ backend/app/frontend/static/repair-center.js | 4 +- backend/app/frontend/views.py | 28 +- backend/app/inventory/repository.py | 71 +++ backend/app/inventory/service.py | 7 + backend/app/locations/models.py | 12 + backend/app/locations/repository.py | 26 + backend/app/locations/service.py | 7 + backend/app/main.py | 14 +- backend/app/miner_detail/__init__.py | 1 + backend/app/miner_detail/dependencies.py | 14 + backend/app/miner_detail/models.py | 76 +++ backend/app/miner_detail/router.py | 59 ++ backend/app/miner_detail/service.py | 152 +++++ backend/app/repairs/center_repository.py | 22 + backend/app/repairs/center_service.py | 22 + backend/tests/test_miner_detail.py | 636 +++++++++++++++++++ scripts/test-miner-detail.sh | 102 +++ 22 files changed, 1882 insertions(+), 10 deletions(-) create mode 100644 backend/app/frontend/miner_detail_view.py create mode 100644 backend/app/frontend/static/miner-detail.js create mode 100644 backend/app/miner_detail/__init__.py create mode 100644 backend/app/miner_detail/dependencies.py create mode 100644 backend/app/miner_detail/models.py create mode 100644 backend/app/miner_detail/router.py create mode 100644 backend/app/miner_detail/service.py create mode 100644 backend/tests/test_miner_detail.py create mode 100755 scripts/test-miner-detail.sh diff --git a/backend/app/frontend/miner_detail_view.py b/backend/app/frontend/miner_detail_view.py new file mode 100644 index 0000000..00ae1f9 --- /dev/null +++ b/backend/app/frontend/miner_detail_view.py @@ -0,0 +1,76 @@ +"""Semantic HTML for the Internal Alpha Miner Detail page.""" + + +def miner_detail_content() -> str: + return """ +
    + Loading miner + Requesting current identity and operational context. +
    + + +""" diff --git a/backend/app/frontend/router.py b/backend/app/frontend/router.py index 557fa5e..458be5b 100644 --- a/backend/app/frontend/router.py +++ b/backend/app/frontend/router.py @@ -2,12 +2,13 @@ from pathlib import Path from typing import Annotated +from uuid import UUID -from fastapi import APIRouter, Depends +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, user_has_permission +from app.identity import require_permission, safe_next_path, user_has_permission router = APIRouter(tags=["frontend"]) @@ -19,11 +20,12 @@ RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))] MinerUser = Annotated[dict, Depends(require_permission("miners.view"))] -def render_page(view: str, current_user: dict) -> str: +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, ) @@ -81,6 +83,15 @@ def repair_center_script() -> Response: 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) @@ -111,3 +122,20 @@ def repair_center_page(current_user: RepairUser) -> str: ) 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 index dd10772..01055f5 100644 --- a/backend/app/frontend/static/app.css +++ b/backend/app/frontend/static/app.css @@ -1618,3 +1618,107 @@ button.map-position:hover { .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/fleet.js b/backend/app/frontend/static/fleet.js index c98a056..1f1728f 100644 --- a/backend/app/frontend/static/fleet.js +++ b/backend/app/frontend/static/fleet.js @@ -112,7 +112,9 @@ } function detailLink(miner) { - return "/miners/" + encodeURIComponent(miner.id) + "/detail"; + const returnTo = window.location.pathname + window.location.search; + const path = "/miners/" + encodeURIComponent(miner.id) + "/detail"; + return path + "?return_to=" + encodeURIComponent(returnTo); } function mapLink(miner) { diff --git a/backend/app/frontend/static/miner-detail.js b/backend/app/frontend/static/miner-detail.js new file mode 100644 index 0000000..120a14d --- /dev/null +++ b/backend/app/frontend/static/miner-detail.js @@ -0,0 +1,421 @@ +(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"), + 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); + 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/repair-center.js b/backend/app/frontend/static/repair-center.js index b713956..4ba75c8 100644 --- a/backend/app/frontend/static/repair-center.js +++ b/backend/app/frontend/static/repair-center.js @@ -193,7 +193,9 @@ 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); - link.href = "/miners/" + encodeURIComponent(item.miner_id) + "/detail"; + 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] diff --git a/backend/app/frontend/views.py b/backend/app/frontend/views.py index e994280..7dda519 100644 --- a/backend/app/frontend/views.py +++ b/backend/app/frontend/views.py @@ -3,6 +3,8 @@ from html import escape from typing import Callable +from app.frontend.miner_detail_view import miner_detail_content + NAVIGATION = ( ("dashboard", "Dashboard", "/dashboard", "dashboard.view"), @@ -32,6 +34,11 @@ PAGE_DETAILS = { "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.", + ), } @@ -407,6 +414,9 @@ 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( @@ -418,10 +428,11 @@ def render_app_page( ) 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 == view + if key == active_navigation else f'
  • {escape(label)}
  • ' ) for key, label, href, permission in NAVIGATION @@ -437,7 +448,9 @@ def render_app_page( else '' ) content = ( - _map_content(has_permission("locations.manage")) + miner_detail_content() + if view == "miner_detail" + else _map_content(has_permission("locations.manage")) if view == "map" else _miners_content(has_permission("locations.view")) if view == "miners" @@ -452,7 +465,9 @@ def render_app_page( ) ) page_script = ( - '' + '' + if view == "miner_detail" + else '' if view == "map" else '' if view == "miners" @@ -462,6 +477,11 @@ def render_app_page( if view == "dashboard" 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""" @@ -472,7 +492,7 @@ def render_app_page( + data-can-view-locations="{str(has_permission("locations.view")).lower()}"{object_attributes}>
    diff --git a/backend/app/inventory/repository.py b/backend/app/inventory/repository.py index 991883d..1005cec 100644 --- a/backend/app/inventory/repository.py +++ b/backend/app/inventory/repository.py @@ -59,6 +59,66 @@ MINER_SELECT = f""" ) tag_data ON true """ +MINER_CURRENT_SELECT = f""" + SELECT + miners.id, + miners.site_id, + miners.room_id, + miners.asset_tag, + miners.current_mac, + host(miners.current_ip) AS current_ip, + miners.hostname, + miners.miner_sn, + miners.control_board_sn, + miners.power_sn, + miners.model, + miners.firmware_version, + miners.lifecycle_status, + miners.status, + miners.notes, + miners.first_seen_at, + miners.last_seen_at, + miners.created_at, + miners.updated_at, + tag_data.tags, + latest.observed_at, + latest.hashrate_ths, + latest.power_w, + latest.chip_temp_max_c, + latest.hash_stable + FROM miners + LEFT JOIN LATERAL ( + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'id', tags.id, + 'name', tags.name, + 'created_at', tags.created_at, + 'updated_at', tags.updated_at + ) + ORDER BY lower({TAG_TRIMMED_NAME_SQL}), tags.id + ), + '[]'::jsonb + ) AS tags + FROM miner_tags + JOIN tags ON tags.id = miner_tags.tag_id + WHERE miner_tags.miner_id = miners.id + ) tag_data ON true + LEFT JOIN LATERAL ( + SELECT snapshots.observed_at, + ROUND((snapshots.mhs_av / 1000000.0)::numeric, 2)::float + AS hashrate_ths, + ROUND(snapshots.power_w::numeric, 2)::float AS power_w, + ROUND(snapshots.chip_temp_max_c::numeric, 2)::float + AS chip_temp_max_c, + snapshots.hash_stable + FROM miner_status_snapshots AS snapshots + WHERE snapshots.miner_id = miners.id + ORDER BY snapshots.observed_at DESC, snapshots.id DESC + LIMIT 1 + ) latest ON true +""" + ACTIVE_REPAIR_STATUSES = ( "OPEN", "IN_PROGRESS", @@ -260,6 +320,17 @@ class InventoryRepository: with connection.cursor(row_factory=dict_row) as cur: return self._fetch_miner(cur, "miners.id = %s", (miner_id,)) + def get_current_miner_by_id(self, miner_id: UUID) -> dict | None: + """Return Inventory identity with its latest factual telemetry.""" + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + f"{MINER_CURRENT_SELECT} WHERE miners.id = %s", + (miner_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + def get_miner_by_id_for_update(self, miner_id: UUID) -> dict | None: with self._connection_scope() as connection: with connection.cursor(row_factory=dict_row) as cur: diff --git a/backend/app/inventory/service.py b/backend/app/inventory/service.py index c70175e..db2d718 100644 --- a/backend/app/inventory/service.py +++ b/backend/app/inventory/service.py @@ -137,6 +137,13 @@ class InventoryService: raise MinerNotFound(miner_id) return self._detail_model(row) + def get_current_miner(self, miner_id: UUID) -> InventoryMinerDetail: + """Return current Inventory identity and the latest observation.""" + row = self.repository.get_current_miner_by_id(miner_id) + if not row: + raise MinerNotFound(miner_id) + return self._detail_model(row) + def get_asset_id_history( self, miner_id: UUID, diff --git a/backend/app/locations/models.py b/backend/app/locations/models.py index b078f89..5986509 100644 --- a/backend/app/locations/models.py +++ b/backend/app/locations/models.py @@ -118,6 +118,18 @@ class MineMapResponse(BaseModel): 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 diff --git a/backend/app/locations/repository.py b/backend/app/locations/repository.py index 20d17b4..10d0550 100644 --- a/backend/app/locations/repository.py +++ b/backend/app/locations/repository.py @@ -229,3 +229,29 @@ class LocationMapRepository: """ ) 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/service.py b/backend/app/locations/service.py index 9858242..3e002d1 100644 --- a/backend/app/locations/service.py +++ b/backend/app/locations/service.py @@ -14,6 +14,7 @@ from app.locations.models import ( MineMapData, MineMapMeta, MineMapResponse, + MinerLocationDetail, ) from app.locations.repository import LocationMapRepository @@ -87,6 +88,12 @@ class LocationMapService: ), ) + 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( diff --git a/backend/app/main.py b/backend/app/main.py index b6bc3bd..e8d2b13 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,11 +23,13 @@ from app.identity import ( ) 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.repairs.exceptions import ( ControlBoardReplacementActorRequired, InvalidMacAddress, @@ -60,6 +62,7 @@ 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) install_identity_middleware(app) @@ -1814,7 +1817,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() @@ -29087,6 +29090,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")): @@ -29683,6 +29689,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")): @@ -30921,6 +30930,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")): 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/repairs/center_repository.py b/backend/app/repairs/center_repository.py index 3b49dd2..3f287cd 100644 --- a/backend/app/repairs/center_repository.py +++ b/backend/app/repairs/center_repository.py @@ -315,6 +315,28 @@ class RepairCenterRepository: 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) diff --git a/backend/app/repairs/center_service.py b/backend/app/repairs/center_service.py index d49ba64..f8fd90c 100644 --- a/backend/app/repairs/center_service.py +++ b/backend/app/repairs/center_service.py @@ -96,6 +96,28 @@ class RepairCenterService: ), ) + 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/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'', + ) + 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/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." From 4d360fac4254a4261ae52c3faf45a5d7ba43623b Mon Sep 17 00:00:00 2001 From: JC Date: Wed, 19 Aug 2026 20:00:52 +0000 Subject: [PATCH 37/37] feat: add monitoring trends --- backend/app/frontend/miner_detail_view.py | 1 + .../app/frontend/monitoring_trends_view.py | 52 ++ backend/app/frontend/static/miner-detail.js | 4 + .../app/frontend/static/monitoring-trends.css | 54 +++ .../app/frontend/static/monitoring-trends.js | 344 ++++++++++++++ backend/app/frontend/views.py | 16 + backend/app/identity.py | 3 + backend/app/main.py | 4 + backend/app/monitoring/__init__.py | 1 + backend/app/monitoring/dependencies.py | 8 + backend/app/monitoring/exceptions.py | 13 + backend/app/monitoring/frontend.py | 70 +++ backend/app/monitoring/models.py | 98 ++++ backend/app/monitoring/repository.py | 237 +++++++++ backend/app/monitoring/router.py | 61 +++ backend/app/monitoring/service.py | 173 +++++++ backend/tests/test_monitoring_trends.py | 448 ++++++++++++++++++ scripts/test-monitoring-trends.sh | 96 ++++ 18 files changed, 1683 insertions(+) create mode 100644 backend/app/frontend/monitoring_trends_view.py create mode 100644 backend/app/frontend/static/monitoring-trends.css create mode 100644 backend/app/frontend/static/monitoring-trends.js create mode 100644 backend/app/monitoring/__init__.py create mode 100644 backend/app/monitoring/dependencies.py create mode 100644 backend/app/monitoring/exceptions.py create mode 100644 backend/app/monitoring/frontend.py create mode 100644 backend/app/monitoring/models.py create mode 100644 backend/app/monitoring/repository.py create mode 100644 backend/app/monitoring/router.py create mode 100644 backend/app/monitoring/service.py create mode 100644 backend/tests/test_monitoring_trends.py create mode 100755 scripts/test-monitoring-trends.sh diff --git a/backend/app/frontend/miner_detail_view.py b/backend/app/frontend/miner_detail_view.py index 00ae1f9..c06723d 100644 --- a/backend/app/frontend/miner_detail_view.py +++ b/backend/app/frontend/miner_detail_view.py @@ -29,6 +29,7 @@ def miner_detail_content() -> str: Back to Fleet + View trends 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 = ( + '' + if can_view_locations + else "" + ) + return f""" +
    +

    Historical scope

    +

    Trend controls

    +

    Not loaded

    +
    + + + +
    + Time range + + + +
    + +
    +

    Fleet trends use current miner inventory.

    +
    +
    + Loading trendsAggregating bounded raw telemetry. +
    + + +""" diff --git a/backend/app/frontend/static/miner-detail.js b/backend/app/frontend/static/miner-detail.js index 120a14d..f128d8f 100644 --- a/backend/app/frontend/static/miner-detail.js +++ b/backend/app/frontend/static/miner-detail.js @@ -20,6 +20,7 @@ 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"), @@ -380,6 +381,9 @@ 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(); 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/views.py b/backend/app/frontend/views.py index 7dda519..2d6a081 100644 --- a/backend/app/frontend/views.py +++ b/backend/app/frontend/views.py @@ -4,11 +4,13 @@ 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"), ) @@ -24,6 +26,11 @@ PAGE_DETAILS = { "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", @@ -450,6 +457,8 @@ def render_app_page( 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")) @@ -467,6 +476,8 @@ def render_app_page( page_script = ( '' if view == "miner_detail" + else '' + if view == "analytics" else '' if view == "map" else '' @@ -477,6 +488,10 @@ def render_app_page( 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)}"' @@ -489,6 +504,7 @@ def render_app_page( {escape(title)} | MinerOps + {page_style} bool: "/system-health", "/accounts", "/monitoring/map", + "/monitoring/analytics", "/operations/repairs", "/assets/miners", } @@ -685,6 +686,8 @@ def required_page_permission(path: str) -> str: 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/"): diff --git a/backend/app/main.py b/backend/app/main.py index e8d2b13..21aa961 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -30,6 +30,8 @@ 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, @@ -63,6 +65,8 @@ 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) 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/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('