diff --git a/.env.dev.example b/.env.dev.example new file mode 100644 index 0000000..940c122 --- /dev/null +++ b/.env.dev.example @@ -0,0 +1,20 @@ +# Copying this file is optional. scripts/dev-env-init.sh generates strong secrets. +APP_ENV=development +APP_TIMEZONE=America/Winnipeg +POSTGRES_DB=minerops_dev +POSTGRES_USER=minerops_dev +POSTGRES_PASSWORD=replace-with-a-random-local-only-secret +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +DATABASE_URL=postgresql://minerops_dev:replace-with-a-random-local-only-secret@postgres:5432/minerops_dev +REDIS_URL=redis://redis:6379/0 +JWT_SECRET_KEY=replace-with-a-random-local-only-secret +MINEROPS_AGENT_SHARED_SECRET=replace-with-a-random-local-only-secret +MINEROPS_DEV_PORT=18000 + +# Safety interlocks: keep these values disabled for development. +SCHEDULED_SCAN_ENABLED=false +MINEROPS_HUB_MODE=true +MINEROPS_DISABLE_LOCAL_SCANNER=true +MINEROPS_ENABLE_REAL_REBOOT=false +MINEROPS_ENABLE_REAL_POWER_CONTROL=false diff --git a/.gitignore b/.gitignore index cffc07c..478675f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ .env .env.* *.env +!.env.example +!.env.dev.example # Backups / dumps / generated data backups/ @@ -13,6 +15,8 @@ imports/ *.dump *.bak *.csv +!sql/ +!sql/*.sql # Python __pycache__/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..dbaf6e3 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,64 @@ +# MinerOps Target Architecture + +MinerOps will move from its current working checkpoint toward a modular +operations platform without a big-bang rewrite. Every milestone must leave the +production application deployable and preserve verified workflows. + +## Boundaries + +- The hub owns users, authorization, fleet inventory, repairs, locations, + reporting, command policy, audit history, and long-term telemetry. +- Site agents own network-local discovery, polling, and miner command + execution. +- The hub must not require a route to miner VLANs. +- WhatsMiner protocols are adapters behind one capability-based interface. + +## Backend + +The target backend is organized into FastAPI routers, domain services, and +database repositories for: + +- identity and access +- fleet inventory +- telemetry and retention +- repairs and parts +- locations +- agents and command queues +- WhatsMiner protocol capabilities +- system health + +Alembic is the only authority for persistent schema changes. Runtime table +creation remains temporarily for backward compatibility and will be removed +after migrations and tests cover every current path. + +## Frontend + +The target frontend is a typed, responsive application with a shared design +system. Authentication is established before protected pages render. Views +adapt to desktop, tablet, and phone layouts and expose actions according to +server-enforced permissions. + +## Identity + +MinerOps application accounts are distinct from WhatsMiner device accounts. +The default application roles will be Admin, Manager, Technician, and Read +Only. Authorization is permission-based and enforced by backend endpoints. + +## Safety + +- Scanning and real controls are disabled by default in development. +- Site-agent commands are authenticated, durable, expiring, idempotent, and + audited. +- Destructive operations support dry-run behavior and explicit authorization. +- Protocol adapters are tested with sanitized fixtures before real controls are + enabled. + +## Migration Order + +1. Reproducible schema and development baseline. +2. Accounts, roles, secure sessions, and login gate. +3. Responsive application shell and shared UI components. +4. Optimized dashboard loading. +5. WhatsMiner 4028 and 4433 protocol adapters. +6. Incremental extraction of repairs, locations, agents, commands, and health + from the legacy application module. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..db484a1 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +*.bak +*.bak.* +.pytest_cache/ +.mypy_cache/ diff --git a/backend/alembic/versions/0006_operational_schema_baseline.py b/backend/alembic/versions/0006_operational_schema_baseline.py new file mode 100644 index 0000000..7e18404 --- /dev/null +++ b/backend/alembic/versions/0006_operational_schema_baseline.py @@ -0,0 +1,478 @@ +"""capture the operational schema baseline + +Revision ID: 0006_operational_schema_baseline +Revises: 0005_miner_inventory +Create Date: 2026-07-27 + +This migration adopts tables that older MinerOps builds created at runtime. +The IF NOT EXISTS clauses allow it to run against both an existing deployment +and a new database. +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0006_operational_schema_baseline" +down_revision: Union[str, None] = "0005_miner_inventory" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + ALTER TABLE miners + ADD COLUMN IF NOT EXISTS manual_details JSONB NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS manual_details_updated_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS manual_details_updated_by TEXT, + ADD COLUMN IF NOT EXISTS psu_model TEXT, + ADD COLUMN IF NOT EXISTS customer_sn TEXT, + ADD COLUMN IF NOT EXISTS api3_supported BOOLEAN, + ADD COLUMN IF NOT EXISTS api3_api_version TEXT, + ADD COLUMN IF NOT EXISTS api3_api_switch TEXT, + ADD COLUMN IF NOT EXISTS api3_last_seen_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS api3_last_error TEXT, + ADD COLUMN IF NOT EXISTS configured_performance_mode TEXT, + ADD COLUMN IF NOT EXISTS configured_performance_mode_source TEXT, + ADD COLUMN IF NOT EXISTS configured_performance_mode_updated_at TIMESTAMPTZ; + + ALTER TABLE miner_status_snapshots + ADD COLUMN IF NOT EXISTS ip_address INET, + ADD COLUMN IF NOT EXISTS mac_address TEXT, + ADD COLUMN IF NOT EXISTS fan_speed_in DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS fan_speed_out DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS elapsed_seconds DOUBLE PRECISION; + """ + ) + + op.execute( + """ + CREATE TABLE IF NOT EXISTS miner_maintenance_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + miner_id UUID NOT NULL REFERENCES miners(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + event_status TEXT NOT NULL DEFAULT 'completed', + event_title TEXT NOT NULL, + description TEXT, + event_at TIMESTAMPTZ NOT NULL DEFAULT now(), + old_mac TEXT, + new_mac TEXT, + old_ip INET, + new_ip INET, + component_type TEXT, + component_slot TEXT, + component_serial TEXT, + cost_amount NUMERIC(12, 2), + downtime_minutes INTEGER, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_miner_id + ON miner_maintenance_events(miner_id); + CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_event_at + ON miner_maintenance_events(event_at DESC); + CREATE INDEX IF NOT EXISTS idx_miner_maintenance_events_event_type + ON miner_maintenance_events(event_type); + + CREATE TABLE IF NOT EXISTS location_positions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + room_id UUID REFERENCES rooms(id) ON DELETE SET NULL, + room_name TEXT NOT NULL, + rack_name TEXT NOT NULL, + shelf_name TEXT NOT NULL, + shelf_index INTEGER, + position_index INTEGER NOT NULL, + position_label TEXT, + position_kind TEXT NOT NULL DEFAULT 'miner_slot', + position_status TEXT NOT NULL DEFAULT 'available', + expected_mac TEXT, + miner_id UUID REFERENCES miners(id) ON DELETE SET NULL, + equipment_label TEXT, + notes TEXT, + source_file TEXT, + source_sheet TEXT, + source_cell TEXT, + raw_value TEXT, + conflict_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + DO $$ + BEGIN + ALTER TABLE location_positions + ADD CONSTRAINT uq_location_positions_slot + UNIQUE (room_name, rack_name, shelf_name, position_index); + EXCEPTION + WHEN duplicate_object OR duplicate_table THEN NULL; + END $$; + + CREATE INDEX IF NOT EXISTS idx_location_positions_room + ON location_positions(room_name); + CREATE INDEX IF NOT EXISTS idx_location_positions_rack + ON location_positions(room_name, rack_name); + CREATE INDEX IF NOT EXISTS idx_location_positions_expected_mac + ON location_positions(expected_mac); + CREATE INDEX IF NOT EXISTS idx_location_positions_miner_id + ON location_positions(miner_id); + + CREATE TABLE IF NOT EXISTS miner_location_states ( + miner_id UUID PRIMARY KEY REFERENCES miners(id) ON DELETE CASCADE, + asset_state TEXT NOT NULL DEFAULT 'waiting_placement', + location_position_id UUID REFERENCES location_positions(id) ON DELETE SET NULL, + state_notes TEXT, + updated_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX IF NOT EXISTS idx_miner_location_states_asset_state + ON miner_location_states(asset_state); + + CREATE TABLE IF NOT EXISTS location_import_issues ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + import_batch_id UUID NOT NULL, + issue_type TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'warning', + message TEXT NOT NULL, + mac_address TEXT, + room_name TEXT, + rack_name TEXT, + shelf_name TEXT, + position_index INTEGER, + source_file TEXT, + source_cell TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ + ); + + CREATE INDEX IF NOT EXISTS idx_location_import_issues_batch + ON location_import_issues(import_batch_id); + CREATE INDEX IF NOT EXISTS idx_location_import_issues_mac + ON location_import_issues(mac_address); + """ + ) + + op.execute( + """ + CREATE TABLE IF NOT EXISTS miner_command_events ( + id TEXT PRIMARY KEY, + miner_id TEXT NOT NULL, + command_type TEXT NOT NULL, + command_status TEXT NOT NULL, + dry_run BOOLEAN NOT NULL DEFAULT true, + requested_by_user_id TEXT, + requested_by_username TEXT, + target_ip TEXT, + target_mac TEXT, + request_payload JSONB, + response_payload JSONB, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ + ); + + CREATE INDEX IF NOT EXISTS idx_miner_command_events_miner_id_created + ON miner_command_events(miner_id, created_at DESC); + + CREATE TABLE IF NOT EXISTS miner_api_credentials ( + miner_id TEXT PRIMARY KEY, + api_account TEXT NOT NULL DEFAULT 'super', + api_password TEXT NOT NULL, + password_source TEXT NOT NULL DEFAULT 'minerops', + updated_by_username TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS minerops_site_agents ( + agent_id TEXT PRIMARY KEY, + site_name TEXT NOT NULL, + hostname TEXT, + version TEXT, + ip_address TEXT, + last_checkin_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_payload JSONB NOT NULL DEFAULT '{}'::jsonb + ); + + CREATE TABLE IF NOT EXISTS minerops_agent_snapshots ( + id BIGSERIAL PRIMARY KEY, + agent_id TEXT NOT NULL, + site_name TEXT NOT NULL, + snapshot_kind TEXT NOT NULL DEFAULT 'miner_poll', + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + miner_count INTEGER NOT NULL DEFAULT 0, + payload JSONB NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_minerops_agent_snapshots_received + ON minerops_agent_snapshots(received_at DESC); + + CREATE TABLE IF NOT EXISTS minerops_agent_command_results ( + id BIGSERIAL PRIMARY KEY, + agent_id TEXT NOT NULL, + site_name TEXT NOT NULL, + command_id TEXT, + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + status TEXT NOT NULL, + payload JSONB NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_results_received + ON minerops_agent_command_results(received_at DESC); + + CREATE TABLE IF NOT EXISTS minerops_agent_command_queue ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + command_type TEXT NOT NULL, + command_status TEXT NOT NULL DEFAULT 'pending', + requested_by_user_id TEXT, + requested_by_username TEXT, + request_payload JSONB NOT NULL DEFAULT '{}'::jsonb, + result_payload JSONB, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + claimed_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ + ); + + CREATE INDEX IF NOT EXISTS idx_minerops_agent_command_queue_agent_status + ON minerops_agent_command_queue(agent_id, command_status, created_at); + + CREATE TABLE IF NOT EXISTS minerops_agent_discoveries ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + site_name TEXT NOT NULL, + room_name TEXT, + ip_address INET, + mac_address TEXT, + model TEXT, + hostname TEXT, + miner_sn TEXT, + raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb, + discovery_status TEXT NOT NULL DEFAULT 'new', + matched_miner_id UUID, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX IF NOT EXISTS idx_minerops_agent_discoveries_mac + ON minerops_agent_discoveries(mac_address); + CREATE INDEX IF NOT EXISTS idx_minerops_agent_discoveries_ip + ON minerops_agent_discoveries(ip_address); + """ + ) + + op.execute( + """ + CREATE TABLE IF NOT EXISTS minerops_scheduled_bulk_actions ( + id TEXT PRIMARY KEY, + name TEXT, + target_type TEXT NOT NULL, + target_value TEXT, + action TEXT NOT NULL DEFAULT 'performance_mode', + performance_mode TEXT, + repeat_rule TEXT NOT NULL DEFAULT 'once', + scheduled_for TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + agent_id TEXT NOT NULL DEFAULT 'plexus-windows-agent', + requested_by_user_id TEXT, + requested_by_username TEXT, + queued_command_count INTEGER NOT NULL DEFAULT 0, + last_queued_command_count INTEGER NOT NULL DEFAULT 0, + run_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + processed_at TIMESTAMPTZ + ); + + CREATE INDEX IF NOT EXISTS idx_minerops_scheduled_bulk_actions_due + ON minerops_scheduled_bulk_actions(status, scheduled_for); + CREATE INDEX IF NOT EXISTS idx_minerops_scheduled_bulk_actions_created + ON minerops_scheduled_bulk_actions(created_at DESC); + + CREATE TABLE IF NOT EXISTS miner_pool_snapshots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + miner_id UUID, + ip_address INET, + mac_address TEXT, + model TEXT, + pool_index INTEGER, + url TEXT, + worker_user TEXT, + pool_status TEXT, + stratum_active BOOLEAN, + accepted DOUBLE PRECISION, + rejected DOUBLE PRECISION, + stale DOUBLE PRECISION, + get_failures DOUBLE PRECISION, + remote_failures DOUBLE PRECISION, + pool_rejected_percent DOUBLE PRECISION, + pool_stale_percent DOUBLE PRECISION, + last_share_time_epoch DOUBLE PRECISION, + raw_pool_json JSONB NOT NULL DEFAULT '{}'::jsonb, + agent_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + ALTER TABLE miner_pool_snapshots + ADD COLUMN IF NOT EXISTS model TEXT, + ADD COLUMN IF NOT EXISTS pool_index INTEGER, + ADD COLUMN IF NOT EXISTS url TEXT, + ADD COLUMN IF NOT EXISTS worker_user TEXT, + ADD COLUMN IF NOT EXISTS pool_status TEXT, + ADD COLUMN IF NOT EXISTS stratum_active BOOLEAN, + ADD COLUMN IF NOT EXISTS accepted DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS rejected DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS stale DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS get_failures DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS remote_failures DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS pool_rejected_percent DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS pool_stale_percent DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS last_share_time_epoch DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS raw_pool_json JSONB NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS agent_id TEXT; + + DO $$ + DECLARE + id_type TEXT; + BEGIN + SELECT data_type INTO id_type + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'miner_pool_snapshots' + AND column_name = 'id'; + + IF id_type = 'text' THEN + ALTER TABLE miner_pool_snapshots + ALTER COLUMN id SET DEFAULT (gen_random_uuid())::text; + ELSIF id_type = 'uuid' THEN + ALTER TABLE miner_pool_snapshots + ALTER COLUMN id SET DEFAULT gen_random_uuid(); + END IF; + END $$; + + CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_miner_created + ON miner_pool_snapshots(miner_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_ip_created + ON miner_pool_snapshots(ip_address, created_at DESC); + + CREATE TABLE IF NOT EXISTS miner_api3_enrichment_snapshots ( + id BIGSERIAL PRIMARY KEY, + miner_id UUID, + agent_id TEXT, + site_name TEXT, + ip_address INET, + mac_address TEXT, + api_supported BOOLEAN, + api_error TEXT, + api_version TEXT, + api_switch TEXT, + model TEXT, + miner_sn TEXT, + power_sn TEXT, + psu_model TEXT, + device_info JSONB, + miner_setting JSONB, + miner_status JSONB, + fan_setting JSONB, + custom_data JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX IF NOT EXISTS idx_miner_api3_enrichment_miner_created + ON miner_api3_enrichment_snapshots(miner_id, created_at DESC); + + CREATE TABLE IF NOT EXISTS miner_api3_hashboard_snapshots ( + id BIGSERIAL PRIMARY KEY, + miner_id UUID, + ip_address INET, + mac_address TEXT, + slot INTEGER, + pcb_sn TEXT, + hash_average_ths DOUBLE PRECISION, + factory_hash_ths DOUBLE PRECISION, + freq DOUBLE PRECISION, + effective_chips INTEGER, + chip_temp_min_c DOUBLE PRECISION, + chip_temp_avg_c DOUBLE PRECISION, + chip_temp_max_c DOUBLE PRECISION, + raw_hashboard_json JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX IF NOT EXISTS idx_miner_api3_hashboards_miner_created + ON miner_api3_hashboard_snapshots(miner_id, created_at DESC); + """ + ) + + op.execute( + """ + CREATE TABLE IF NOT EXISTS miner_status_hourly_rollups ( + miner_id UUID NOT NULL REFERENCES miners(id) ON DELETE CASCADE, + bucket_at TIMESTAMPTZ NOT NULL, + sample_count BIGINT NOT NULL, + avg_mhs_av DOUBLE PRECISION, + max_mhs_av DOUBLE PRECISION, + avg_power_w DOUBLE PRECISION, + max_chip_temp_c DOUBLE PRECISION, + avg_chip_temp_c DOUBLE PRECISION, + avg_pool_rejected_percent DOUBLE PRECISION, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (miner_id, bucket_at) + ); + + CREATE INDEX IF NOT EXISTS idx_miner_status_hourly_bucket + ON miner_status_hourly_rollups(bucket_at); + + CREATE TABLE IF NOT EXISTS miner_status_daily_rollups ( + miner_id UUID NOT NULL REFERENCES miners(id) ON DELETE CASCADE, + bucket_date DATE NOT NULL, + sample_count BIGINT NOT NULL, + avg_mhs_av DOUBLE PRECISION, + max_mhs_av DOUBLE PRECISION, + avg_power_w DOUBLE PRECISION, + max_chip_temp_c DOUBLE PRECISION, + avg_chip_temp_c DOUBLE PRECISION, + avg_pool_rejected_percent DOUBLE PRECISION, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (miner_id, bucket_date) + ); + + CREATE INDEX IF NOT EXISTS idx_miner_status_daily_bucket + ON miner_status_daily_rollups(bucket_date); + + CREATE TABLE IF NOT EXISTS minerops_retention_runs ( + id BIGSERIAL PRIMARY KEY, + mode TEXT NOT NULL, + raw_status_retention_days INTEGER NOT NULL, + hourly_rollup_rows_before BIGINT NOT NULL DEFAULT 0, + hourly_rollup_rows_after BIGINT NOT NULL DEFAULT 0, + daily_rollup_rows_before BIGINT NOT NULL DEFAULT 0, + daily_rollup_rows_after BIGINT NOT NULL DEFAULT 0, + raw_status_rows_before BIGINT NOT NULL DEFAULT 0, + raw_status_rows_after BIGINT NOT NULL DEFAULT 0, + raw_status_rows_eligible_delete BIGINT NOT NULL DEFAULT 0, + raw_status_rows_deleted BIGINT NOT NULL DEFAULT 0, + started_at TIMESTAMPTZ NOT NULL, + finished_at TIMESTAMPTZ NOT NULL, + notes TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_minerops_retention_runs_started + ON minerops_retention_runs(started_at DESC); + """ + ) + + +def downgrade() -> None: + raise RuntimeError( + "0006 adopts pre-existing operational tables and is intentionally irreversible" + ) diff --git a/backend/alembic/versions/0007_identity_and_roles.py b/backend/alembic/versions/0007_identity_and_roles.py new file mode 100644 index 0000000..83d6224 --- /dev/null +++ b/backend/alembic/versions/0007_identity_and_roles.py @@ -0,0 +1,161 @@ +"""add application roles and permission matrix + +Revision ID: 0007_identity_and_roles +Revises: 0006_operational_schema_baseline +Create Date: 2026-07-27 +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0007_identity_and_roles" +down_revision: Union[str, None] = "0006_operational_schema_baseline" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +PERMISSIONS = ( + ("dashboard.view", "View fleet dashboards"), + ("miners.view", "View miners and telemetry"), + ("miners.update", "Update miner inventory and details"), + ("miners.control", "Run approved miner control operations"), + ("repairs.view", "View repair queues and history"), + ("repairs.manage", "Manage repairs, notes, parts, and status"), + ("locations.view", "View rooms, racks, shelves, and placements"), + ("locations.manage", "Manage layouts and miner placements"), + ("agents.view", "View site agents and command status"), + ("agents.manage", "Queue and manage site-agent work"), + ("system_health.view", "View system health and retention status"), + ("users.manage", "Create and manage MinerOps accounts"), + ("credentials.manage", "Manage protected miner credentials"), + ("scan_ranges.manage", "Manage scan ranges"), + ("scans.run", "Run discovery and polling operations"), + ("exports.create", "Export operational data"), + ("audit.view", "View audit history"), +) + +ROLE_PERMISSIONS = { + "Manager": ( + "dashboard.view", + "miners.view", + "miners.update", + "miners.control", + "repairs.view", + "repairs.manage", + "locations.view", + "locations.manage", + "agents.view", + "agents.manage", + "system_health.view", + "scan_ranges.manage", + "scans.run", + "exports.create", + "audit.view", + ), + "Technician": ( + "dashboard.view", + "miners.view", + "miners.update", + "miners.control", + "repairs.view", + "repairs.manage", + "locations.view", + "locations.manage", + "agents.view", + "scans.run", + "exports.create", + ), + "Read Only": ( + "dashboard.view", + "miners.view", + "repairs.view", + "locations.view", + "agents.view", + "system_health.view", + ), +} + + +def upgrade() -> None: + connection = op.get_bind() + + for key, description in PERMISSIONS: + connection.exec_driver_sql( + """ + INSERT INTO permissions (key, description) + VALUES (%s, %s) + ON CONFLICT (key) DO UPDATE + SET description = EXCLUDED.description + """, + (key, description), + ) + + connection.exec_driver_sql( + """ + INSERT INTO roles (name, description) + VALUES + ('Manager', 'Operational manager with fleet and workflow control'), + ('Read Only', 'Read-only access to operational views') + ON CONFLICT (name) DO UPDATE + SET description = EXCLUDED.description + """ + ) + + connection.exec_driver_sql( + """ + UPDATE roles + SET description = CASE name + WHEN 'Admin' THEN 'Full MinerOps administrator' + WHEN 'Technician' THEN 'Technician for repair and fleet operations' + ELSE description + END + WHERE name IN ('Admin', 'Technician') + """ + ) + + connection.exec_driver_sql( + """ + UPDATE users + SET role_id = (SELECT id FROM roles WHERE name = 'Read Only') + WHERE role_id IN ( + SELECT id FROM roles WHERE name IN ('Viewer', 'Auditor') + ) + """ + ) + connection.exec_driver_sql( + "DELETE FROM role_permissions WHERE role_id IN (SELECT id FROM roles WHERE name IN ('Viewer', 'Auditor'))" + ) + connection.exec_driver_sql("DELETE FROM roles WHERE name IN ('Viewer', 'Auditor')") + + connection.exec_driver_sql( + "DELETE FROM role_permissions WHERE role_id IN (SELECT id FROM roles WHERE name IN ('Admin', 'Manager', 'Technician', 'Read Only'))" + ) + connection.exec_driver_sql( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles + CROSS JOIN permissions + WHERE roles.name = 'Admin' + ON CONFLICT DO NOTHING + """ + ) + + for role_name, keys in ROLE_PERMISSIONS.items(): + connection.exec_driver_sql( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles + JOIN permissions ON permissions.key = ANY(%s) + WHERE roles.name = %s + ON CONFLICT DO NOTHING + """, + (list(keys), role_name), + ) + + +def downgrade() -> None: + raise RuntimeError("0007 changes live role assignments and is intentionally irreversible") diff --git a/backend/alembic/versions/0008_manual_miner_inventory.py b/backend/alembic/versions/0008_manual_miner_inventory.py new file mode 100644 index 0000000..9f1cafc --- /dev/null +++ b/backend/alembic/versions/0008_manual_miner_inventory.py @@ -0,0 +1,193 @@ +"""add manual miner inventory schema + +Revision ID: 0008_manual_miner_inventory +Revises: 0007_identity_and_roles +Create Date: 2026-07-28 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "0008_manual_miner_inventory" +down_revision: Union[str, None] = "0007_identity_and_roles" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +TAG_TRIMMED_NAME_SQL = ( + "regexp_replace(name, '^[[:space:]]+|[[:space:]]+$', '', 'g')" +) + + +def upgrade() -> None: + op.add_column("miners", sa.Column("control_board_sn", sa.Text(), nullable=True)) + op.add_column( + "miners", + sa.Column( + "lifecycle_status", + sa.Text(), + nullable=False, + server_default=sa.text("'active'"), + ), + ) + op.create_check_constraint( + "ck_miners_lifecycle_status", + "miners", + "lifecycle_status IN ('active', 'spare', 'retired')", + ) + + op.execute( + """ + DO $$ + DECLARE + conflicting_asset_ids TEXT; + BEGIN + SELECT string_agg( + format('%L (%s records)', normalized_asset_id, record_count), + ', ' ORDER BY normalized_asset_id + ) + INTO conflicting_asset_ids + FROM ( + SELECT + lower(trim(asset_tag)) AS normalized_asset_id, + COUNT(*) AS record_count + FROM miners + WHERE asset_tag IS NOT NULL + AND trim(asset_tag) <> '' + GROUP BY lower(trim(asset_tag)) + HAVING COUNT(*) > 1 + ) conflicts; + + IF conflicting_asset_ids IS NOT NULL THEN + RAISE EXCEPTION USING + MESSAGE = 'Cannot enforce globally unique Asset IDs. ' + || 'Conflicting normalized values: ' + || conflicting_asset_ids, + HINT = 'Correct the conflicting miners.asset_tag values ' + || 'before upgrading, then run the migration again.'; + END IF; + END + $$; + """ + ) + op.create_index( + "uq_miners_asset_tag_ci", + "miners", + [sa.text("lower(trim(asset_tag))")], + unique=True, + postgresql_where=sa.text("asset_tag IS NOT NULL AND trim(asset_tag) <> ''"), + ) + + op.create_table( + "tags", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column("name", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.CheckConstraint( + f"{TAG_TRIMMED_NAME_SQL} <> ''", + name="ck_tags_name_not_blank", + ), + ) + op.create_index( + "uq_tags_name_ci", + "tags", + [sa.text(f"lower({TAG_TRIMMED_NAME_SQL})")], + unique=True, + ) + + op.create_table( + "miner_tags", + sa.Column( + "miner_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("miners.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "tag_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("tags.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.PrimaryKeyConstraint("miner_id", "tag_id", name="pk_miner_tags"), + ) + op.create_index("idx_miner_tags_tag_id", "miner_tags", ["tag_id"]) + + # Discovery continues to receive now() defaults. Manual creation can + # explicitly store NULL to represent an asset that has never been observed. + op.alter_column( + "miners", + "first_seen_at", + existing_type=sa.DateTime(timezone=True), + existing_server_default=sa.text("now()"), + nullable=True, + ) + op.alter_column( + "miners", + "last_seen_at", + existing_type=sa.DateTime(timezone=True), + existing_server_default=sa.text("now()"), + nullable=True, + ) + + +def downgrade() -> None: + # created_at is non-null in the pre-0008 schema, making this deterministic. + op.execute( + """ + UPDATE miners + SET first_seen_at = COALESCE(first_seen_at, created_at), + last_seen_at = COALESCE(last_seen_at, created_at) + WHERE first_seen_at IS NULL + OR last_seen_at IS NULL; + """ + ) + op.alter_column( + "miners", + "last_seen_at", + existing_type=sa.DateTime(timezone=True), + existing_server_default=sa.text("now()"), + nullable=False, + ) + op.alter_column( + "miners", + "first_seen_at", + existing_type=sa.DateTime(timezone=True), + existing_server_default=sa.text("now()"), + nullable=False, + ) + + op.drop_index("idx_miner_tags_tag_id", table_name="miner_tags") + op.drop_table("miner_tags") + op.drop_index("uq_tags_name_ci", table_name="tags") + op.drop_table("tags") + + op.drop_index("uq_miners_asset_tag_ci", table_name="miners") + op.drop_constraint("ck_miners_lifecycle_status", "miners", type_="check") + op.drop_column("miners", "lifecycle_status") + op.drop_column("miners", "control_board_sn") diff --git a/backend/alembic/versions/0009_inventory_asset_id_history.py b/backend/alembic/versions/0009_inventory_asset_id_history.py new file mode 100644 index 0000000..df956c2 --- /dev/null +++ b/backend/alembic/versions/0009_inventory_asset_id_history.py @@ -0,0 +1,81 @@ +"""add inventory Asset ID history + +Revision ID: 0009_inventory_asset_id_history +Revises: 0008_manual_miner_inventory +Create Date: 2026-07-28 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "0009_inventory_asset_id_history" +down_revision: Union[str, None] = "0008_manual_miner_inventory" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "miner_asset_id_history", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "miner_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("miners.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("old_asset_id", sa.Text(), nullable=True), + sa.Column("new_asset_id", sa.Text(), nullable=False), + sa.Column( + "changed_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "changed_by", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.CheckConstraint( + "old_asset_id IS NULL OR " + "(old_asset_id = trim(old_asset_id) AND old_asset_id <> '')", + name="ck_miner_asset_id_history_old_normalized", + ), + sa.CheckConstraint( + "new_asset_id = trim(new_asset_id) AND new_asset_id <> ''", + name="ck_miner_asset_id_history_new_normalized", + ), + ) + op.create_index( + "idx_miner_asset_id_history_miner_changed_at", + "miner_asset_id_history", + ["miner_id", "changed_at", "id"], + ) + op.create_index( + "idx_miner_asset_id_history_changed_by", + "miner_asset_id_history", + ["changed_by"], + ) + + +def downgrade() -> None: + op.drop_index( + "idx_miner_asset_id_history_changed_by", + table_name="miner_asset_id_history", + ) + op.drop_index( + "idx_miner_asset_id_history_miner_changed_at", + table_name="miner_asset_id_history", + ) + op.drop_table("miner_asset_id_history") diff --git a/backend/alembic/versions/0010_begin_repair.py b/backend/alembic/versions/0010_begin_repair.py new file mode 100644 index 0000000..df31b97 --- /dev/null +++ b/backend/alembic/versions/0010_begin_repair.py @@ -0,0 +1,322 @@ +"""add begin repair workflow + +Revision ID: 0010_begin_repair +Revises: 0009_inventory_asset_id_history +Create Date: 2026-07-29 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "0010_begin_repair" +down_revision: Union[str, None] = "0009_inventory_asset_id_history" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +REPAIR_STATUSES = ( + "OPEN", + "IN_PROGRESS", + "WAITING_FOR_PARTS", + "READY_FOR_TEST", + "COMPLETED", + "CANCELLED", +) +ACTIVE_REPAIR_STATUSES = ( + "OPEN", + "IN_PROGRESS", + "WAITING_FOR_PARTS", + "READY_FOR_TEST", +) +REPAIR_REASONS = ( + "HARDWARE_FAILURE", + "LOW_HASHRATE", + "CONTROL_BOARD", + "HASHBOARD", + "PSU", + "FAN", + "OVERHEATING", + "NETWORKING", + "PREVENTIVE_MAINTENANCE", + "INSPECTION", + "OTHER", +) +REPAIR_EVENT_TYPES = ("REPAIR_STARTED", "INITIAL_NOTE") + + +def _quoted(values: Sequence[str]) -> str: + return ", ".join(f"'{value}'" for value in values) + + +def upgrade() -> None: + connection = op.get_bind() + + op.drop_constraint("ck_miners_lifecycle_status", "miners", type_="check") + op.create_check_constraint( + "ck_miners_lifecycle_status", + "miners", + "lifecycle_status IN ('active', 'spare', 'retired', 'in_repair')", + ) + + connection.exec_driver_sql( + """ + INSERT INTO permissions (key, description) + VALUES ('repairs.create', 'Begin repairs for miner assets') + ON CONFLICT (key) DO UPDATE + SET description = EXCLUDED.description + """ + ) + connection.exec_driver_sql( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles + JOIN permissions ON permissions.key = 'repairs.create' + WHERE roles.name IN ('Admin', 'Manager', 'Technician') + ON CONFLICT DO NOTHING + """ + ) + + op.create_table( + "repairs", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "miner_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("miners.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "status", + sa.Text(), + nullable=False, + server_default=sa.text("'OPEN'"), + ), + sa.Column("reason", sa.Text(), nullable=False), + sa.Column( + "opened_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "opened_by", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "completed_at", + sa.DateTime(timezone=True), + nullable=True, + ), + sa.Column( + "completed_by", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="RESTRICT"), + nullable=True, + ), + sa.CheckConstraint( + f"status IN ({_quoted(REPAIR_STATUSES)})", + name="ck_repairs_status", + ), + sa.CheckConstraint( + f"reason IN ({_quoted(REPAIR_REASONS)})", + name="ck_repairs_reason", + ), + sa.CheckConstraint( + "(completed_at IS NULL AND completed_by IS NULL) OR " + "(completed_at IS NOT NULL AND completed_by IS NOT NULL)", + name="ck_repairs_completion_pair", + ), + sa.CheckConstraint( + "(status = 'COMPLETED') = (completed_at IS NOT NULL)", + name="ck_repairs_completed_state", + ), + ) + op.create_index( + "idx_repairs_miner_opened_at", + "repairs", + ["miner_id", "opened_at", "id"], + ) + op.create_index("idx_repairs_opened_by", "repairs", ["opened_by"]) + op.create_index("idx_repairs_completed_by", "repairs", ["completed_by"]) + op.create_index( + "uq_repairs_one_active_per_miner", + "repairs", + ["miner_id"], + unique=True, + postgresql_where=sa.text( + f"status IN ({_quoted(ACTIVE_REPAIR_STATUSES)})" + ), + ) + + op.create_table( + "repair_events", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "repair_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("repairs.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column("sequence", sa.Integer(), nullable=False), + sa.Column("event_type", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "created_by", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "payload", + postgresql.JSONB(), + nullable=False, + ), + sa.CheckConstraint( + f"event_type IN ({_quoted(REPAIR_EVENT_TYPES)})", + name="ck_repair_events_type", + ), + sa.CheckConstraint( + "sequence > 0", + name="ck_repair_events_sequence_positive", + ), + sa.CheckConstraint( + "jsonb_typeof(payload) = 'object'", + name="ck_repair_events_payload_object", + ), + sa.CheckConstraint( + "(" + "event_type = 'REPAIR_STARTED' " + "AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] " + f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) " + "AND payload ->> 'from_lifecycle' IN ('active', 'spare') " + "AND payload ->> 'to_lifecycle' = 'in_repair'" + ") OR (" + "event_type = 'INITIAL_NOTE' " + "AND jsonb_typeof(payload -> 'note') = 'string' " + "AND trim(payload ->> 'note') <> ''" + ")", + name="ck_repair_events_payload_shape", + ), + sa.UniqueConstraint( + "repair_id", + "sequence", + name="uq_repair_events_repair_sequence", + ), + ) + op.create_index( + "idx_repair_events_repair_created", + "repair_events", + ["repair_id", "created_at", "sequence", "id"], + ) + op.create_index( + "idx_repair_events_created_by", + "repair_events", + ["created_by"], + ) + + +def downgrade() -> None: + connection = op.get_bind() + + connection.exec_driver_sql( + f""" + DO $$ + DECLARE + invalid_miner_ids TEXT; + BEGIN + SELECT string_agg(miner.id::text, ', ' ORDER BY miner.id::text) + INTO invalid_miner_ids + FROM miners AS miner + LEFT JOIN LATERAL ( + SELECT count(*) AS started_event_count + FROM repairs AS repair + JOIN repair_events AS event + ON event.repair_id = repair.id + AND event.event_type = 'REPAIR_STARTED' + WHERE repair.miner_id = miner.id + AND repair.status IN ({_quoted(ACTIVE_REPAIR_STATUSES)}) + ) AS history ON TRUE + WHERE miner.lifecycle_status = 'in_repair' + AND history.started_event_count <> 1; + + IF invalid_miner_ids IS NOT NULL THEN + RAISE EXCEPTION USING + MESSAGE = format( + 'Cannot downgrade Begin Repair: miners %s do not have exactly one active REPAIR_STARTED event', + invalid_miner_ids + ), + HINT = 'Repair the affected lifecycle history before downgrading; MinerOps will not invent a prior lifecycle value.'; + END IF; + END + $$; + """ + ) + connection.exec_driver_sql( + f""" + UPDATE miners AS miner + SET lifecycle_status = history.from_lifecycle, + updated_at = now() + FROM ( + SELECT + repair.miner_id, + event.payload ->> 'from_lifecycle' AS from_lifecycle + FROM repairs AS repair + JOIN repair_events AS event + ON event.repair_id = repair.id + AND event.event_type = 'REPAIR_STARTED' + WHERE repair.status IN ({_quoted(ACTIVE_REPAIR_STATUSES)}) + ) AS history + WHERE miner.id = history.miner_id + AND miner.lifecycle_status = 'in_repair' + """ + ) + + op.drop_index("idx_repair_events_created_by", table_name="repair_events") + op.drop_index("idx_repair_events_repair_created", table_name="repair_events") + op.drop_table("repair_events") + + op.drop_index("uq_repairs_one_active_per_miner", table_name="repairs") + op.drop_index("idx_repairs_completed_by", table_name="repairs") + op.drop_index("idx_repairs_opened_by", table_name="repairs") + op.drop_index("idx_repairs_miner_opened_at", table_name="repairs") + op.drop_table("repairs") + + connection.exec_driver_sql( + """ + DELETE FROM role_permissions + WHERE permission_id = ( + SELECT id FROM permissions WHERE key = 'repairs.create' + ) + """ + ) + connection.exec_driver_sql( + "DELETE FROM permissions WHERE key = 'repairs.create'" + ) + + op.drop_constraint("ck_miners_lifecycle_status", "miners", type_="check") + op.create_check_constraint( + "ck_miners_lifecycle_status", + "miners", + "lifecycle_status IN ('active', 'spare', 'retired')", + ) diff --git a/backend/alembic/versions/0011_repair_timeline.py b/backend/alembic/versions/0011_repair_timeline.py new file mode 100644 index 0000000..2acacd1 --- /dev/null +++ b/backend/alembic/versions/0011_repair_timeline.py @@ -0,0 +1,55 @@ +"""add repair timeline read permission + +Revision ID: 0011_repair_timeline +Revises: 0010_begin_repair +Create Date: 2026-07-29 +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0011_repair_timeline" +down_revision: Union[str, None] = "0010_begin_repair" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + connection = op.get_bind() + + connection.exec_driver_sql( + """ + INSERT INTO permissions (key, description) + VALUES ('repairs.read', 'View repair details and timeline events') + ON CONFLICT (key) DO UPDATE + SET description = EXCLUDED.description + """ + ) + connection.exec_driver_sql( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles + JOIN permissions ON permissions.key = 'repairs.read' + WHERE roles.name IN ('Admin', 'Manager', 'Technician', 'Read Only') + ON CONFLICT DO NOTHING + """ + ) + + +def downgrade() -> None: + connection = op.get_bind() + + connection.exec_driver_sql( + """ + DELETE FROM role_permissions + WHERE permission_id = ( + SELECT id FROM permissions WHERE key = 'repairs.read' + ) + """ + ) + connection.exec_driver_sql( + "DELETE FROM permissions WHERE key = 'repairs.read'" + ) diff --git a/backend/alembic/versions/0012_return_to_service.py b/backend/alembic/versions/0012_return_to_service.py new file mode 100644 index 0000000..46e1359 --- /dev/null +++ b/backend/alembic/versions/0012_return_to_service.py @@ -0,0 +1,253 @@ +"""add return to service workflow + +Revision ID: 0012_return_to_service +Revises: 0011_repair_timeline +Create Date: 2026-07-29 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0012_return_to_service" +down_revision: Union[str, None] = "0011_repair_timeline" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +REPAIR_REASONS = ( + "HARDWARE_FAILURE", + "LOW_HASHRATE", + "CONTROL_BOARD", + "HASHBOARD", + "PSU", + "FAN", + "OVERHEATING", + "NETWORKING", + "PREVENTIVE_MAINTENANCE", + "INSPECTION", + "OTHER", +) + + +def _quoted(values: Sequence[str]) -> str: + return ", ".join(f"'{value}'" for value in values) + + +def _create_event_constraints() -> None: + op.create_check_constraint( + "ck_repair_events_type", + "repair_events", + "event_type IN ('REPAIR_STARTED', 'INITIAL_NOTE', " + "'REPAIR_COMPLETED')", + ) + op.create_check_constraint( + "ck_repair_events_payload_shape", + "repair_events", + "(" + "event_type = 'REPAIR_STARTED' " + "AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] " + f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) " + "AND payload ->> 'from_lifecycle' IN ('active', 'spare') " + "AND payload ->> 'to_lifecycle' = 'in_repair'" + ") OR (" + "event_type = 'INITIAL_NOTE' " + "AND jsonb_typeof(payload -> 'note') = 'string' " + "AND trim(payload ->> 'note') <> ''" + ") OR (" + "event_type = 'REPAIR_COMPLETED' " + "AND payload ?& ARRAY[" + "'previous_miner_lifecycle', 'resulting_miner_lifecycle'" + "] " + "AND payload ->> 'previous_miner_lifecycle' = 'in_repair' " + "AND payload ->> 'resulting_miner_lifecycle' " + "IN ('active', 'spare') " + "AND (NOT payload ? 'pre_repair_lifecycle' " + "OR payload ->> 'pre_repair_lifecycle' IN ('active', 'spare')) " + "AND (NOT payload ? 'completion_summary' OR (" + "jsonb_typeof(payload -> 'completion_summary') = 'string' " + "AND trim(payload ->> 'completion_summary') <> '' " + "AND char_length(payload ->> 'completion_summary') <= 4000" + "))" + ")", + ) + + +def _create_previous_event_constraints() -> None: + op.create_check_constraint( + "ck_repair_events_type", + "repair_events", + "event_type IN ('REPAIR_STARTED', 'INITIAL_NOTE')", + ) + op.create_check_constraint( + "ck_repair_events_payload_shape", + "repair_events", + "(" + "event_type = 'REPAIR_STARTED' " + "AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] " + f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) " + "AND payload ->> 'from_lifecycle' IN ('active', 'spare') " + "AND payload ->> 'to_lifecycle' = 'in_repair'" + ") OR (" + "event_type = 'INITIAL_NOTE' " + "AND jsonb_typeof(payload -> 'note') = 'string' " + "AND trim(payload ->> 'note') <> ''" + ")", + ) + + +def upgrade() -> None: + connection = op.get_bind() + + connection.exec_driver_sql( + """ + INSERT INTO permissions (key, description) + VALUES ('repairs.complete', 'Return repaired miners to service') + ON CONFLICT (key) DO UPDATE + SET description = EXCLUDED.description + """ + ) + connection.exec_driver_sql( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles + JOIN permissions ON permissions.key = 'repairs.complete' + WHERE roles.name IN ('Admin', 'Manager', 'Technician') + ON CONFLICT DO NOTHING + """ + ) + + op.add_column( + "repairs", + sa.Column("completion_summary", sa.Text(), nullable=True), + ) + op.add_column( + "repairs", + sa.Column("resulting_lifecycle", sa.Text(), nullable=True), + ) + op.create_check_constraint( + "ck_repairs_resulting_lifecycle", + "repairs", + "resulting_lifecycle IS NULL " + "OR resulting_lifecycle IN ('active', 'spare')", + ) + op.create_check_constraint( + "ck_repairs_completion_summary", + "repairs", + "completion_summary IS NULL OR (" + "trim(completion_summary) <> '' " + "AND char_length(completion_summary) <= 4000" + ")", + ) + # NOT VALID preserves truthful legacy COMPLETED rows whose destination was + # never recorded, while PostgreSQL still enforces the check for every new + # or updated row after this migration. + connection.exec_driver_sql( + """ + ALTER TABLE repairs + ADD CONSTRAINT ck_repairs_completion_metadata_state + CHECK ( + ( + status = 'COMPLETED' + AND resulting_lifecycle IS NOT NULL + ) + OR ( + status <> 'COMPLETED' + AND completion_summary IS NULL + AND resulting_lifecycle IS NULL + ) + ) + NOT VALID + """ + ) + + op.drop_constraint( + "ck_repair_events_payload_shape", + "repair_events", + type_="check", + ) + op.drop_constraint( + "ck_repair_events_type", + "repair_events", + type_="check", + ) + _create_event_constraints() + + +def downgrade() -> None: + connection = op.get_bind() + + connection.exec_driver_sql( + """ + DO $$ + DECLARE + completion_count BIGINT; + BEGIN + SELECT count(*) + INTO completion_count + FROM repairs + WHERE resulting_lifecycle IS NOT NULL + OR completion_summary IS NOT NULL; + + completion_count := completion_count + ( + SELECT count(*) + FROM repair_events + WHERE event_type = 'REPAIR_COMPLETED' + ); + + IF completion_count > 0 THEN + RAISE EXCEPTION USING + MESSAGE = format( + 'Cannot downgrade Return to Service: %s completion records or events would lose authoritative history', + completion_count + ), + HINT = 'Preserve or explicitly migrate Return to Service history before downgrading; MinerOps will not invent or discard lifecycle, actor, summary, or event values.'; + END IF; + END + $$; + """ + ) + + op.drop_constraint( + "ck_repair_events_payload_shape", + "repair_events", + type_="check", + ) + op.drop_constraint( + "ck_repair_events_type", + "repair_events", + type_="check", + ) + _create_previous_event_constraints() + + op.drop_constraint( + "ck_repairs_completion_metadata_state", + "repairs", + type_="check", + ) + op.drop_constraint( + "ck_repairs_completion_summary", + "repairs", + type_="check", + ) + op.drop_constraint( + "ck_repairs_resulting_lifecycle", + "repairs", + type_="check", + ) + op.drop_column("repairs", "resulting_lifecycle") + op.drop_column("repairs", "completion_summary") + + connection.exec_driver_sql( + """ + DELETE FROM role_permissions + WHERE permission_id = ( + SELECT id FROM permissions WHERE key = 'repairs.complete' + ) + """ + ) + connection.exec_driver_sql( + "DELETE FROM permissions WHERE key = 'repairs.complete'" + ) diff --git a/backend/alembic/versions/0013_control_board_replacement.py b/backend/alembic/versions/0013_control_board_replacement.py new file mode 100644 index 0000000..fc5600f --- /dev/null +++ b/backend/alembic/versions/0013_control_board_replacement.py @@ -0,0 +1,436 @@ +"""add control board replacement workflow + +Revision ID: 0013_control_board_replacement +Revises: 0012_return_to_service +Create Date: 2026-08-02 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "0013_control_board_replacement" +down_revision: Union[str, None] = "0012_return_to_service" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +REPAIR_REASONS = ( + "HARDWARE_FAILURE", "LOW_HASHRATE", "CONTROL_BOARD", "HASHBOARD", + "PSU", "FAN", "OVERHEATING", "NETWORKING", + "PREVENTIVE_MAINTENANCE", "INSPECTION", "OTHER", +) +REPLACEMENT_REASONS = ( + "failed", "upgrade", "diagnostic_swap", "preventive_maintenance", + "temporary_donor", "unknown", +) +DISPOSITIONS = ( + "repair_inventory", "sent_for_repair", "scrapped", + "retained_for_diagnosis", "returned_to_vendor", "unknown", +) + + +def _quoted(values: Sequence[str]) -> str: + return ", ".join(f"'{value}'" for value in values) + + +def _create_event_constraints(include_replacement: bool) -> None: + event_types = ["REPAIR_STARTED", "INITIAL_NOTE", "REPAIR_COMPLETED"] + if include_replacement: + event_types.append("CONTROL_BOARD_REPLACED") + op.create_check_constraint( + "ck_repair_events_type", + "repair_events", + f"event_type IN ({_quoted(event_types)})", + ) + replacement_clause = "" + if include_replacement: + replacement_clause = ( + ") OR (event_type = 'CONTROL_BOARD_REPLACED' " + "AND payload ?& ARRAY['old_mac_address', 'new_mac_address', " + "'old_control_board_serial', 'new_control_board_serial', " + "'replacement_reason', 'removed_board_disposition'] " + "AND jsonb_typeof(payload -> 'old_mac_address') IN ('string', 'null') " + "AND jsonb_typeof(payload -> 'new_mac_address') = 'string' " + "AND payload ->> 'new_mac_address' ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' " + "AND regexp_replace(lower(payload ->> 'new_mac_address'), '[^0-9a-f]', '', 'g') " + "NOT IN ('000000000000', 'ffffffffffff') " + "AND get_byte(decode(regexp_replace(lower(payload ->> 'new_mac_address'), " + "'[^0-9a-f]', '', 'g'), 'hex'), 0) % 2 = 0 " + "AND (jsonb_typeof(payload -> 'old_control_board_serial') = 'null' OR (" + "jsonb_typeof(payload -> 'old_control_board_serial') = 'string' " + "AND trim(payload ->> 'old_control_board_serial') <> '' " + "AND char_length(payload ->> 'old_control_board_serial') <= 255)) " + "AND (jsonb_typeof(payload -> 'new_control_board_serial') = 'null' OR (" + "jsonb_typeof(payload -> 'new_control_board_serial') = 'string' " + "AND trim(payload ->> 'new_control_board_serial') <> '' " + "AND char_length(payload ->> 'new_control_board_serial') <= 255)) " + f"AND payload ->> 'replacement_reason' IN ({_quoted(REPLACEMENT_REASONS)}) " + f"AND payload ->> 'removed_board_disposition' IN ({_quoted(DISPOSITIONS)}) " + "AND (NOT payload ? 'notes' OR jsonb_typeof(payload -> 'notes') = 'null' OR (" + "jsonb_typeof(payload -> 'notes') = 'string' " + "AND trim(payload ->> 'notes') <> '' " + "AND char_length(payload ->> 'notes') <= 4000))" + ) + op.create_check_constraint( + "ck_repair_events_payload_shape", + "repair_events", + "(event_type = 'REPAIR_STARTED' " + "AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] " + f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) " + "AND payload ->> 'from_lifecycle' IN ('active', 'spare') " + "AND payload ->> 'to_lifecycle' = 'in_repair') OR (" + "event_type = 'INITIAL_NOTE' " + "AND jsonb_typeof(payload -> 'note') = 'string' " + "AND trim(payload ->> 'note') <> '') OR (" + "event_type = 'REPAIR_COMPLETED' " + "AND payload ?& ARRAY['previous_miner_lifecycle', 'resulting_miner_lifecycle'] " + "AND payload ->> 'previous_miner_lifecycle' = 'in_repair' " + "AND payload ->> 'resulting_miner_lifecycle' IN ('active', 'spare') " + "AND (NOT payload ? 'pre_repair_lifecycle' " + "OR payload ->> 'pre_repair_lifecycle' IN ('active', 'spare')) " + "AND (NOT payload ? 'completion_summary' OR (" + "jsonb_typeof(payload -> 'completion_summary') = 'string' " + "AND trim(payload ->> 'completion_summary') <> '' " + "AND char_length(payload ->> 'completion_summary') <= 4000))" + f"{replacement_clause})", + ) + + +def upgrade() -> None: + connection = op.get_bind() + connection.exec_driver_sql( + """ + DO $$ + DECLARE conflicts TEXT; + BEGIN + WITH identities AS ( + SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac, + miner_id + FROM miner_network_identities + WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12 + UNION ALL + SELECT regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g'), id + FROM miners + WHERE length(regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')) = 12 + ), duplicate_owners AS ( + SELECT mac + FROM identities + GROUP BY mac + HAVING count(DISTINCT miner_id) > 1 + ) + SELECT string_agg(mac, ', ' ORDER BY mac) INTO conflicts FROM duplicate_owners; + IF conflicts IS NOT NULL THEN + RAISE EXCEPTION USING + MESSAGE = 'Cannot upgrade Control Board Replacement: normalized MAC values have multiple miner owners: ' || conflicts, + HINT = 'Correct the conflicting miner and network-identity records before upgrading; MinerOps will not transfer, merge, or delete MAC history automatically.'; + END IF; + + SELECT string_agg(mac, ', ' ORDER BY mac) INTO conflicts + FROM ( + SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac + FROM miner_network_identities + WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12 + GROUP BY regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') + HAVING count(*) > 1 + ) AS duplicate_identity_mac; + IF conflicts IS NOT NULL THEN + RAISE EXCEPTION USING + MESSAGE = 'Cannot upgrade Control Board Replacement: duplicate normalized network-identity MAC values: ' || conflicts, + HINT = 'Correct duplicate identity rows before upgrading; MinerOps will not delete or merge history automatically.'; + END IF; + + SELECT string_agg(miner_id::text, ', ' ORDER BY miner_id::text) + INTO conflicts + FROM ( + SELECT miner_id FROM miner_network_identities + WHERE is_current GROUP BY miner_id HAVING count(*) > 1 + ) AS duplicate_current; + IF conflicts IS NOT NULL THEN + RAISE EXCEPTION USING + MESSAGE = 'Cannot upgrade Control Board Replacement: miners have multiple current network identities: ' || conflicts, + HINT = 'Choose one current network identity for each listed miner before upgrading.'; + END IF; + END $$; + """ + ) + + connection.exec_driver_sql( + """ + INSERT INTO permissions (key, description) + VALUES ('miners.replace_control_board', 'Replace miner control boards during repair') + ON CONFLICT (key) DO UPDATE SET description = EXCLUDED.description; + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id FROM roles + JOIN permissions ON permissions.key = 'miners.replace_control_board' + WHERE roles.name IN ('Admin', 'Manager', 'Technician') + ON CONFLICT DO NOTHING; + """ + ) + + op.add_column( + "miner_network_identities", + sa.Column("associated_at", sa.DateTime(timezone=True), nullable=True), + ) + op.alter_column("miner_network_identities", "first_seen_at", nullable=True) + op.alter_column("miner_network_identities", "last_seen_at", nullable=True) + connection.exec_driver_sql( + """ + CREATE UNIQUE INDEX uq_miner_network_identities_normalized_mac + ON miner_network_identities ( + regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') + ) + WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12; + CREATE UNIQUE INDEX uq_miner_network_identities_one_current_per_miner + ON miner_network_identities (miner_id) WHERE is_current; + CREATE UNIQUE INDEX uq_miners_current_normalized_mac + ON miners (regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')) + WHERE length(regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')) = 12; + ALTER TABLE miner_network_identities + ADD CONSTRAINT ck_miner_network_identities_mac_valid + CHECK ( + source NOT IN ('control_board_replacement', 'legacy_projection') OR ( + mac_address ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' + AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') NOT IN ('000000000000', 'ffffffffffff') + AND get_byte(decode(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g'), 'hex'), 0) %% 2 = 0 + ) + ) NOT VALID; + """ + ) + + op.create_table( + "miner_mac_ownerships", + sa.Column("normalized_mac", sa.Text(), primary_key=True), + sa.Column( + "miner_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("miners.id", ondelete="RESTRICT"), nullable=False, + ), + sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "claimed_by", postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True, + ), + sa.CheckConstraint( + "normalized_mac ~ '^[0-9a-f]{12}$' " + "AND normalized_mac NOT IN ('000000000000', 'ffffffffffff') " + "AND get_byte(decode(normalized_mac, 'hex'), 0) % 2 = 0", + name="ck_miner_mac_ownerships_valid", + ), + ) + op.create_index("idx_miner_mac_ownerships_miner_id", "miner_mac_ownerships", ["miner_id"]) + connection.exec_driver_sql( + """ + INSERT INTO miner_mac_ownerships (normalized_mac, miner_id) + SELECT mac, min(miner_id::text)::uuid FROM ( + SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac, miner_id + FROM miner_network_identities + UNION ALL + SELECT regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g'), id FROM miners + ) AS known + WHERE length(mac) = 12 AND mac NOT IN ('000000000000', 'ffffffffffff') + AND get_byte(decode(mac, 'hex'), 0) %% 2 = 0 + GROUP BY mac; + """ + ) + + op.create_table( + "miner_control_board_installations", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("miner_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("miners.id", ondelete="RESTRICT"), nullable=False), + sa.Column("record_origin", sa.Text(), nullable=False), + sa.Column("installed_repair_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=True), + sa.Column("removed_repair_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=True), + sa.Column("mac_address", sa.Text(), nullable=True), + sa.Column("normalized_mac", sa.Text(), nullable=True), + sa.Column("board_serial", sa.Text(), nullable=True), + sa.Column("board_model", sa.Text(), nullable=True), + sa.Column("board_revision", sa.Text(), nullable=True), + sa.Column("firmware_version", sa.Text(), nullable=True), + sa.Column("replacement_reason", sa.Text(), nullable=True), + sa.Column("installed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("installed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True), + sa.Column("removed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("removed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True), + sa.Column("removed_disposition", sa.Text(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.CheckConstraint("(mac_address IS NULL) = (normalized_mac IS NULL)", name="ck_control_board_installations_mac_pair"), + sa.CheckConstraint( + "normalized_mac IS NULL OR (" + "mac_address ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' " + "AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = normalized_mac " + "AND normalized_mac ~ '^[0-9a-f]{12}$' " + "AND normalized_mac NOT IN ('000000000000', 'ffffffffffff') " + "AND get_byte(decode(normalized_mac, 'hex'), 0) % 2 = 0)", + name="ck_control_board_installations_mac_normalized", + ), + sa.CheckConstraint("board_serial IS NULL OR (trim(board_serial) <> '' AND char_length(board_serial) <= 255)", name="ck_control_board_installations_serial"), + sa.CheckConstraint("board_model IS NULL OR (trim(board_model) <> '' AND char_length(board_model) <= 255)", name="ck_control_board_installations_model"), + sa.CheckConstraint("board_revision IS NULL OR (trim(board_revision) <> '' AND char_length(board_revision) <= 255)", name="ck_control_board_installations_revision"), + sa.CheckConstraint("firmware_version IS NULL OR (trim(firmware_version) <> '' AND char_length(firmware_version) <= 255)", name="ck_control_board_installations_firmware"), + sa.CheckConstraint(f"replacement_reason IS NULL OR replacement_reason IN ({_quoted(REPLACEMENT_REASONS)})", name="ck_control_board_installations_reason"), + sa.CheckConstraint(f"removed_disposition IS NULL OR removed_disposition IN ({_quoted(DISPOSITIONS)})", name="ck_control_board_installations_disposition"), + sa.CheckConstraint("notes IS NULL OR (trim(notes) <> '' AND char_length(notes) <= 4000)", name="ck_control_board_installations_notes"), + sa.CheckConstraint("(removed_at IS NULL AND removed_by IS NULL AND removed_repair_id IS NULL AND removed_disposition IS NULL) OR (removed_at IS NOT NULL AND removed_by IS NOT NULL AND removed_repair_id IS NOT NULL AND removed_disposition IS NOT NULL)", name="ck_control_board_installations_removed_state"), + sa.CheckConstraint("installed_at IS NULL OR removed_at IS NULL OR removed_at >= installed_at", name="ck_control_board_installations_chronology"), + sa.CheckConstraint( + "(record_origin = 'legacy_projection' AND installed_repair_id IS NULL " + "AND installed_at IS NULL AND installed_by IS NULL " + "AND replacement_reason IS NULL) OR " + "(record_origin = 'replacement' AND installed_repair_id IS NOT NULL " + "AND installed_at IS NOT NULL AND installed_by IS NOT NULL " + "AND replacement_reason IS NOT NULL)", + name="ck_control_board_installations_install_state", + ), + ) + op.create_index("uq_control_board_installations_current_miner", "miner_control_board_installations", ["miner_id"], unique=True, postgresql_where=sa.text("removed_at IS NULL")) + op.create_index("uq_control_board_installations_current_mac", "miner_control_board_installations", ["normalized_mac"], unique=True, postgresql_where=sa.text("removed_at IS NULL AND normalized_mac IS NOT NULL")) + op.create_index("idx_control_board_installations_miner_history", "miner_control_board_installations", ["miner_id", "installed_at", "created_at", "id"]) + op.create_index("idx_control_board_installations_mac_history", "miner_control_board_installations", ["normalized_mac", "created_at", "id"]) + op.create_index("idx_control_board_installations_installed_repair", "miner_control_board_installations", ["installed_repair_id"]) + op.create_index("idx_control_board_installations_removed_repair", "miner_control_board_installations", ["removed_repair_id"]) + + connection.exec_driver_sql( + """ + CREATE FUNCTION minerops_claim_mac_owner() RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE mac text; existing_owner uuid; target_owner uuid; row_data jsonb; + BEGIN + row_data := to_jsonb(NEW); + mac := regexp_replace(lower(CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'current_mac' ELSE row_data ->> 'mac_address' END), '[^0-9a-f]', '', 'g'); + target_owner := (CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'id' ELSE row_data ->> 'miner_id' END)::uuid; + IF mac IS NULL OR length(mac) <> 12 OR mac IN ('000000000000', 'ffffffffffff') OR get_byte(decode(mac, 'hex'), 0) %% 2 <> 0 THEN RETURN NEW; END IF; + INSERT INTO miner_mac_ownerships (normalized_mac, miner_id, claimed_at) + VALUES (mac, target_owner, now()) + ON CONFLICT (normalized_mac) DO NOTHING; + SELECT miner_id INTO existing_owner FROM miner_mac_ownerships WHERE normalized_mac = mac FOR UPDATE; + IF existing_owner <> (target_owner) THEN + RAISE EXCEPTION 'MAC %% is historically owned by miner %%', mac, existing_owner USING ERRCODE = '23505', CONSTRAINT = 'uq_miner_mac_ownerships_owner'; + END IF; + RETURN NEW; + END $$; + CREATE TRIGGER trg_miners_claim_mac_owner AFTER INSERT OR UPDATE OF current_mac ON miners FOR EACH ROW EXECUTE FUNCTION minerops_claim_mac_owner(); + CREATE TRIGGER trg_network_identities_claim_mac_owner BEFORE INSERT OR UPDATE OF mac_address, miner_id ON miner_network_identities FOR EACH ROW EXECUTE FUNCTION minerops_claim_mac_owner(); + CREATE FUNCTION minerops_protect_mac_ownership() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + RAISE EXCEPTION 'Historical MAC ownership is immutable; use an explicit transfer workflow'; + END $$; + CREATE TRIGGER trg_protect_mac_ownership BEFORE UPDATE OR DELETE ON miner_mac_ownerships FOR EACH ROW EXECUTE FUNCTION minerops_protect_mac_ownership(); + + + CREATE FUNCTION minerops_protect_network_identity_history() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'Network identity history is immutable'; + END IF; + IF NEW.miner_id <> OLD.miner_id + OR regexp_replace(lower(NEW.mac_address), '[^0-9a-f]', '', 'g') + <> regexp_replace(lower(OLD.mac_address), '[^0-9a-f]', '', 'g') THEN + RAISE EXCEPTION 'Network identity owner and normalized MAC are immutable'; + END IF; + RETURN NEW; + END $$; + CREATE TRIGGER trg_protect_network_identity_history BEFORE UPDATE OF miner_id, mac_address OR DELETE ON miner_network_identities FOR EACH ROW EXECUTE FUNCTION minerops_protect_network_identity_history(); + + + CREATE FUNCTION minerops_check_current_board_projection() RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE target_miner uuid; mismatch boolean; row_data jsonb; + BEGIN + IF TG_OP = 'DELETE' THEN + row_data := to_jsonb(OLD); + ELSE + row_data := to_jsonb(NEW); + END IF; + target_miner := (CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'id' ELSE row_data ->> 'miner_id' END)::uuid; + SELECT EXISTS ( + SELECT 1 FROM miner_control_board_installations + WHERE miner_id = target_miner + ) AND NOT EXISTS ( + SELECT 1 + FROM miner_control_board_installations board + JOIN miners miner ON miner.id = board.miner_id + JOIN miner_network_identities identity + ON identity.miner_id = board.miner_id AND identity.is_current + WHERE board.miner_id = target_miner AND board.removed_at IS NULL + AND regexp_replace(lower(COALESCE(miner.current_mac, '')), '[^0-9a-f]', '', 'g') = COALESCE(board.normalized_mac, '') + AND miner.control_board_sn IS NOT DISTINCT FROM board.board_serial + AND regexp_replace(lower(identity.mac_address), '[^0-9a-f]', '', 'g') = COALESCE(board.normalized_mac, '') + ) INTO mismatch; + IF mismatch THEN RAISE EXCEPTION 'Current control-board projection does not match installation history for miner %%', target_miner USING ERRCODE = '23514', CONSTRAINT = 'ck_current_control_board_projection'; END IF; + RETURN NULL; + END $$; + CREATE CONSTRAINT TRIGGER trg_miners_check_current_board_projection AFTER INSERT OR UPDATE ON miners DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection(); + CREATE CONSTRAINT TRIGGER trg_installations_check_current_board_projection AFTER INSERT OR UPDATE OR DELETE ON miner_control_board_installations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection(); + CREATE CONSTRAINT TRIGGER trg_identities_check_current_board_projection AFTER INSERT OR UPDATE OR DELETE ON miner_network_identities DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection(); + + CREATE FUNCTION minerops_protect_control_board_history() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Control-board installation history is immutable'; END IF; + IF OLD.removed_at IS NOT NULL THEN RAISE EXCEPTION 'Removed control-board installation history is immutable'; END IF; + IF NEW.id <> OLD.id OR NEW.miner_id <> OLD.miner_id OR NEW.record_origin <> OLD.record_origin OR NEW.installed_repair_id IS DISTINCT FROM OLD.installed_repair_id OR NEW.mac_address IS DISTINCT FROM OLD.mac_address OR NEW.normalized_mac IS DISTINCT FROM OLD.normalized_mac OR NEW.board_serial IS DISTINCT FROM OLD.board_serial OR NEW.board_model IS DISTINCT FROM OLD.board_model OR NEW.board_revision IS DISTINCT FROM OLD.board_revision OR NEW.firmware_version IS DISTINCT FROM OLD.firmware_version OR NEW.replacement_reason IS DISTINCT FROM OLD.replacement_reason OR NEW.installed_at IS DISTINCT FROM OLD.installed_at OR NEW.installed_by IS DISTINCT FROM OLD.installed_by OR NEW.notes IS DISTINCT FROM OLD.notes OR NEW.created_at <> OLD.created_at THEN + RAISE EXCEPTION 'Control-board installation identity is immutable'; + END IF; + RETURN NEW; + END $$; + CREATE TRIGGER trg_protect_control_board_history BEFORE UPDATE OR DELETE ON miner_control_board_installations FOR EACH ROW EXECUTE FUNCTION minerops_protect_control_board_history(); + """ + ) + + op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check") + op.drop_constraint("ck_repair_events_type", "repair_events", type_="check") + _create_event_constraints(True) + + +def downgrade() -> None: + connection = op.get_bind() + connection.exec_driver_sql( + """ + DO $$ DECLARE history_count bigint; BEGIN + SELECT count(*) INTO history_count FROM miner_control_board_installations; + history_count := history_count + (SELECT count(*) FROM repair_events WHERE event_type = 'CONTROL_BOARD_REPLACED'); + IF history_count > 0 THEN + RAISE EXCEPTION USING MESSAGE = format('Cannot downgrade Control Board Replacement: %s authoritative installation records or events would be destroyed', history_count), HINT = 'Preserve or explicitly migrate control-board history before downgrading; MinerOps will not discard or invent component, MAC, actor, or timestamp history.'; + END IF; + IF EXISTS (SELECT 1 FROM miner_network_identities WHERE first_seen_at IS NULL OR last_seen_at IS NULL) THEN + RAISE EXCEPTION USING MESSAGE = 'Cannot downgrade Control Board Replacement: network identities contain unknown observation timestamps', HINT = 'Do not invent discovery timestamps; preserve or explicitly migrate these associations before downgrading.'; + END IF; + IF EXISTS (SELECT 1 FROM miner_network_identities WHERE associated_at IS NOT NULL) + OR EXISTS (SELECT 1 FROM miner_mac_ownerships WHERE claimed_at IS NOT NULL) THEN + RAISE EXCEPTION USING MESSAGE = 'Cannot downgrade Control Board Replacement: authoritative MAC association or ownership history would be destroyed', HINT = 'Preserve or explicitly migrate MAC association and ownership history before downgrading.'; + END IF; + END $$; + """ + ) + op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check") + op.drop_constraint("ck_repair_events_type", "repair_events", type_="check") + _create_event_constraints(False) + connection.exec_driver_sql( + """ + DROP TRIGGER trg_protect_control_board_history ON miner_control_board_installations; + DROP FUNCTION minerops_protect_control_board_history(); + DROP TRIGGER trg_installations_check_current_board_projection ON miner_control_board_installations; + DROP TRIGGER trg_identities_check_current_board_projection ON miner_network_identities; + DROP TRIGGER trg_miners_check_current_board_projection ON miners; + DROP FUNCTION minerops_check_current_board_projection(); + DROP TRIGGER trg_protect_mac_ownership ON miner_mac_ownerships; + DROP FUNCTION minerops_protect_mac_ownership(); + DROP TRIGGER trg_protect_network_identity_history ON miner_network_identities; + DROP FUNCTION minerops_protect_network_identity_history(); + DROP TRIGGER trg_network_identities_claim_mac_owner ON miner_network_identities; + DROP TRIGGER trg_miners_claim_mac_owner ON miners; + DROP FUNCTION minerops_claim_mac_owner(); + """ + ) + op.drop_table("miner_control_board_installations") + op.drop_index("idx_miner_mac_ownerships_miner_id", table_name="miner_mac_ownerships") + op.drop_table("miner_mac_ownerships") + op.drop_constraint("ck_miner_network_identities_mac_valid", "miner_network_identities", type_="check") + op.drop_index("uq_miners_current_normalized_mac", table_name="miners") + op.drop_index("uq_miner_network_identities_one_current_per_miner", table_name="miner_network_identities") + op.drop_index("uq_miner_network_identities_normalized_mac", table_name="miner_network_identities") + op.alter_column("miner_network_identities", "last_seen_at", nullable=False) + op.alter_column("miner_network_identities", "first_seen_at", nullable=False) + op.drop_column("miner_network_identities", "associated_at") + connection.exec_driver_sql("DELETE FROM role_permissions WHERE permission_id = (SELECT id FROM permissions WHERE key = 'miners.replace_control_board')") + connection.exec_driver_sql("DELETE FROM permissions WHERE key = 'miners.replace_control_board'") diff --git a/backend/alembic/versions/0014_repair_parts_tracking.py b/backend/alembic/versions/0014_repair_parts_tracking.py new file mode 100644 index 0000000..1ca5b6b --- /dev/null +++ b/backend/alembic/versions/0014_repair_parts_tracking.py @@ -0,0 +1,590 @@ +"""add repair parts tracking + +Revision ID: 0014_repair_parts_tracking +Revises: 0013_control_board_replacement +Create Date: 2026-08-02 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "0014_repair_parts_tracking" +down_revision: Union[str, None] = "0013_control_board_replacement" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +COMPONENT_TYPES = ("control_board", "hashboard", "power_supply", "fan") +CONSUMABLE_UNITS = ("each", "gram", "millilitre", "metre", "centimetre") +REPAIR_REASONS = ( + "HARDWARE_FAILURE", "LOW_HASHRATE", "CONTROL_BOARD", "HASHBOARD", + "PSU", "FAN", "OVERHEATING", "NETWORKING", + "PREVENTIVE_MAINTENANCE", "INSPECTION", "OTHER", +) +REPLACEMENT_REASONS = ( + "failed", "upgrade", "diagnostic_swap", "preventive_maintenance", + "temporary_donor", "unknown", +) +DISPOSITIONS = ( + "repair_inventory", "sent_for_repair", "scrapped", + "retained_for_diagnosis", "returned_to_vendor", "unknown", +) + + +def _quoted(values: Sequence[str]) -> str: + return ", ".join(f"'{value}'" for value in values) + + +def _create_event_constraints(include_parts: bool) -> None: + event_types = [ + "REPAIR_STARTED", + "INITIAL_NOTE", + "REPAIR_COMPLETED", + "CONTROL_BOARD_REPLACED", + ] + if include_parts: + event_types.extend(("REPAIR_PART_RECORDED", "REPAIR_PART_VOIDED")) + + op.create_check_constraint( + "ck_repair_events_type", + "repair_events", + f"event_type IN ({_quoted(event_types)})", + ) + + parts_clause = "" + if include_parts: + parts_clause = ( + ") OR (event_type = 'REPAIR_PART_RECORDED' " + "AND payload ?& ARRAY['repair_part_id', 'entry_kind', 'notes'] " + "AND payload ->> 'repair_part_id' ~ " + "'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-" + "[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' " + "AND (jsonb_typeof(payload -> 'notes') = 'null' OR (" + "jsonb_typeof(payload -> 'notes') = 'string' " + "AND trim(payload ->> 'notes') <> '' " + "AND char_length(payload ->> 'notes') <= 4000)) " + "AND ((payload ->> 'entry_kind' = 'consumable' " + "AND payload ?& ARRAY['description', 'quantity', 'unit'] " + "AND NOT payload ? 'component_id' " + "AND jsonb_typeof(payload -> 'description') = 'string' " + "AND trim(payload ->> 'description') <> '' " + "AND char_length(payload ->> 'description') <= 255 " + "AND jsonb_typeof(payload -> 'quantity') = 'string' " + "AND payload ->> 'quantity' ~ '^[0-9]{1,9}(\\.[0-9]{1,3})?$' " + "AND (payload ->> 'quantity')::numeric > 0 " + f"AND payload ->> 'unit' IN ({_quoted(CONSUMABLE_UNITS)}) " + "AND (payload ->> 'unit' <> 'each' OR " + "(payload ->> 'quantity')::numeric = " + "trunc((payload ->> 'quantity')::numeric))) OR (" + "payload ->> 'entry_kind' = 'tracked_component' " + "AND payload ?& ARRAY['component_id', 'component_type', " + "'component_asset_id', 'component_manufacturer', " + "'component_model', 'component_manufacturer_serial'] " + "AND NOT payload ? 'description' " + "AND NOT payload ? 'quantity' " + "AND NOT payload ? 'unit' " + "AND payload ->> 'component_id' ~ " + "'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-" + "[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' " + f"AND payload ->> 'component_type' IN ({_quoted(COMPONENT_TYPES)}) " + "AND jsonb_typeof(payload -> 'component_asset_id') IN ('string', 'null') " + "AND jsonb_typeof(payload -> 'component_manufacturer') IN ('string', 'null') " + "AND jsonb_typeof(payload -> 'component_model') IN ('string', 'null') " + "AND jsonb_typeof(payload -> 'component_manufacturer_serial') " + "IN ('string', 'null'))))" + " OR (event_type = 'REPAIR_PART_VOIDED' " + "AND payload ?& ARRAY['repair_part_id', 'reason'] " + "AND payload ->> 'repair_part_id' ~ " + "'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-" + "[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' " + "AND jsonb_typeof(payload -> 'reason') = 'string' " + "AND trim(payload ->> 'reason') <> '' " + "AND char_length(payload ->> 'reason') <= 1000" + ) + + op.create_check_constraint( + "ck_repair_events_payload_shape", + "repair_events", + "(event_type = 'REPAIR_STARTED' " + "AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] " + f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) " + "AND payload ->> 'from_lifecycle' IN ('active', 'spare') " + "AND payload ->> 'to_lifecycle' = 'in_repair') OR (" + "event_type = 'INITIAL_NOTE' " + "AND jsonb_typeof(payload -> 'note') = 'string' " + "AND trim(payload ->> 'note') <> '') OR (" + "event_type = 'REPAIR_COMPLETED' " + "AND payload ?& ARRAY['previous_miner_lifecycle', 'resulting_miner_lifecycle'] " + "AND payload ->> 'previous_miner_lifecycle' = 'in_repair' " + "AND payload ->> 'resulting_miner_lifecycle' IN ('active', 'spare') " + "AND (NOT payload ? 'pre_repair_lifecycle' " + "OR payload ->> 'pre_repair_lifecycle' IN ('active', 'spare')) " + "AND (NOT payload ? 'completion_summary' OR (" + "jsonb_typeof(payload -> 'completion_summary') = 'string' " + "AND trim(payload ->> 'completion_summary') <> '' " + "AND char_length(payload ->> 'completion_summary') <= 4000))) OR (" + "event_type = 'CONTROL_BOARD_REPLACED' " + "AND payload ?& ARRAY['old_mac_address', 'new_mac_address', " + "'old_control_board_serial', 'new_control_board_serial', " + "'replacement_reason', 'removed_board_disposition'] " + "AND jsonb_typeof(payload -> 'old_mac_address') IN ('string', 'null') " + "AND jsonb_typeof(payload -> 'new_mac_address') = 'string' " + "AND payload ->> 'new_mac_address' ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' " + "AND regexp_replace(lower(payload ->> 'new_mac_address'), '[^0-9a-f]', '', 'g') " + "NOT IN ('000000000000', 'ffffffffffff') " + "AND get_byte(decode(regexp_replace(lower(payload ->> 'new_mac_address'), " + "'[^0-9a-f]', '', 'g'), 'hex'), 0) % 2 = 0 " + "AND (jsonb_typeof(payload -> 'old_control_board_serial') = 'null' OR (" + "jsonb_typeof(payload -> 'old_control_board_serial') = 'string' " + "AND trim(payload ->> 'old_control_board_serial') <> '' " + "AND char_length(payload ->> 'old_control_board_serial') <= 255)) " + "AND (jsonb_typeof(payload -> 'new_control_board_serial') = 'null' OR (" + "jsonb_typeof(payload -> 'new_control_board_serial') = 'string' " + "AND trim(payload ->> 'new_control_board_serial') <> '' " + "AND char_length(payload ->> 'new_control_board_serial') <= 255)) " + f"AND payload ->> 'replacement_reason' IN ({_quoted(REPLACEMENT_REASONS)}) " + f"AND payload ->> 'removed_board_disposition' IN ({_quoted(DISPOSITIONS)}) " + "AND (NOT payload ? 'notes' OR jsonb_typeof(payload -> 'notes') = 'null' OR (" + "jsonb_typeof(payload -> 'notes') = 'string' " + "AND trim(payload ->> 'notes') <> '' " + "AND char_length(payload ->> 'notes') <= 4000))" + f"{parts_clause})", + ) + + +def upgrade() -> None: + connection = op.get_bind() + + connection.exec_driver_sql( + """ + INSERT INTO permissions (key, description) + VALUES + ('components.read', 'View tracked component identity'), + ('components.create', 'Register tracked component identity'), + ('repairs.parts.manage', 'Record and void repair-part usage') + ON CONFLICT (key) DO UPDATE SET description = EXCLUDED.description; + + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles + JOIN permissions ON permissions.key IN ( + 'components.read', 'components.create', 'repairs.parts.manage' + ) + WHERE roles.name IN ('Admin', 'Manager', 'Technician') + ON CONFLICT DO NOTHING; + + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles + JOIN permissions ON permissions.key = 'components.read' + WHERE roles.name = 'Read Only' + ON CONFLICT DO NOTHING; + """ + ) + + op.create_table( + "components", + sa.Column( + "id", postgresql.UUID(as_uuid=True), primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column("component_type", sa.Text(), nullable=False), + sa.Column("asset_id", sa.Text(), nullable=True), + sa.Column("manufacturer", sa.Text(), nullable=True), + sa.Column("model", sa.Text(), nullable=True), + sa.Column("manufacturer_serial", sa.Text(), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "created_by", postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, + ), + sa.CheckConstraint( + f"component_type IN ({_quoted(COMPONENT_TYPES)})", + name="ck_components_type", + ), + sa.CheckConstraint( + "asset_id IS NULL OR (trim(asset_id) <> '' " + "AND asset_id = trim(asset_id) AND char_length(asset_id) <= 255)", + name="ck_components_asset_id", + ), + sa.CheckConstraint( + "manufacturer IS NULL OR (trim(manufacturer) <> '' " + "AND manufacturer = trim(manufacturer) " + "AND char_length(manufacturer) <= 255)", + name="ck_components_manufacturer", + ), + sa.CheckConstraint( + "model IS NULL OR (trim(model) <> '' AND model = trim(model) " + "AND char_length(model) <= 255)", + name="ck_components_model", + ), + sa.CheckConstraint( + "manufacturer_serial IS NULL OR (trim(manufacturer_serial) <> '' " + "AND manufacturer_serial = trim(manufacturer_serial) " + "AND char_length(manufacturer_serial) <= 255)", + name="ck_components_manufacturer_serial", + ), + ) + op.create_index("idx_components_created_by", "components", ["created_by"]) + op.create_index("idx_components_type", "components", ["component_type"]) + op.create_index( + "uq_components_normalized_asset_id", + "components", + [sa.text("lower(trim(asset_id))")], + unique=True, + postgresql_where=sa.text("asset_id IS NOT NULL"), + ) + + op.create_table( + "repair_parts", + sa.Column( + "id", postgresql.UUID(as_uuid=True), primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "repair_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=False, + ), + sa.Column("consumable_description", sa.Text(), nullable=True), + sa.Column("quantity", sa.Numeric(precision=12, scale=3), nullable=True), + sa.Column("unit", sa.Text(), nullable=True), + sa.Column( + "component_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("components.id", ondelete="RESTRICT"), nullable=True, + ), + sa.Column("component_type_snapshot", sa.Text(), nullable=True), + sa.Column("component_asset_id_snapshot", sa.Text(), nullable=True), + sa.Column("component_manufacturer_snapshot", sa.Text(), nullable=True), + sa.Column("component_model_snapshot", sa.Text(), nullable=True), + sa.Column("component_manufacturer_serial_snapshot", sa.Text(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column( + "recorded_by", postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, + ), + sa.Column( + "recorded_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "recorded_event_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("repair_events.id", ondelete="RESTRICT"), + nullable=False, + ), + sa.Column( + "voided_by", postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True, + ), + sa.Column("voided_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("void_reason", sa.Text(), nullable=True), + sa.Column( + "void_event_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("repair_events.id", ondelete="RESTRICT"), + nullable=True, + ), + sa.CheckConstraint( + "((consumable_description IS NOT NULL AND quantity IS NOT NULL " + "AND unit IS NOT NULL AND component_id IS NULL " + "AND component_type_snapshot IS NULL " + "AND component_asset_id_snapshot IS NULL " + "AND component_manufacturer_snapshot IS NULL " + "AND component_model_snapshot IS NULL " + "AND component_manufacturer_serial_snapshot IS NULL) OR " + "(consumable_description IS NULL AND quantity IS NULL " + "AND unit IS NULL AND component_id IS NOT NULL " + "AND component_type_snapshot IS NOT NULL))", + name="ck_repair_parts_entry_xor", + ), + sa.CheckConstraint( + "quantity IS NULL OR quantity > 0", + name="ck_repair_parts_quantity_positive", + ), + sa.CheckConstraint( + "unit IS NULL OR unit <> 'each' OR quantity = trunc(quantity)", + name="ck_repair_parts_each_whole", + ), + sa.CheckConstraint( + f"unit IS NULL OR unit IN ({_quoted(CONSUMABLE_UNITS)})", + name="ck_repair_parts_unit", + ), + sa.CheckConstraint( + f"component_type_snapshot IS NULL OR " + f"component_type_snapshot IN ({_quoted(COMPONENT_TYPES)})", + name="ck_repair_parts_component_type", + ), + sa.CheckConstraint( + "consumable_description IS NULL OR (" + "consumable_description = trim(consumable_description) " + "AND trim(consumable_description) <> '' " + "AND char_length(consumable_description) <= 255)", + name="ck_repair_parts_description", + ), + sa.CheckConstraint( + "component_asset_id_snapshot IS NULL OR (" + "component_asset_id_snapshot = trim(component_asset_id_snapshot) " + "AND trim(component_asset_id_snapshot) <> '' " + "AND char_length(component_asset_id_snapshot) <= 255)", + name="ck_repair_parts_asset_snapshot", + ), + sa.CheckConstraint( + "component_manufacturer_snapshot IS NULL OR (" + "component_manufacturer_snapshot = trim(component_manufacturer_snapshot) " + "AND trim(component_manufacturer_snapshot) <> '' " + "AND char_length(component_manufacturer_snapshot) <= 255)", + name="ck_repair_parts_manufacturer_snapshot", + ), + sa.CheckConstraint( + "component_model_snapshot IS NULL OR (" + "component_model_snapshot = trim(component_model_snapshot) " + "AND trim(component_model_snapshot) <> '' " + "AND char_length(component_model_snapshot) <= 255)", + name="ck_repair_parts_model_snapshot", + ), + sa.CheckConstraint( + "component_manufacturer_serial_snapshot IS NULL OR (" + "component_manufacturer_serial_snapshot = " + "trim(component_manufacturer_serial_snapshot) " + "AND trim(component_manufacturer_serial_snapshot) <> '' " + "AND char_length(component_manufacturer_serial_snapshot) <= 255)", + name="ck_repair_parts_serial_snapshot", + ), + sa.CheckConstraint( + "notes IS NULL OR (notes = trim(notes) AND trim(notes) <> '' " + "AND char_length(notes) <= 4000)", + name="ck_repair_parts_notes", + ), + sa.CheckConstraint( + "(voided_by IS NULL AND voided_at IS NULL AND void_reason IS NULL " + "AND void_event_id IS NULL) OR " + "(voided_by IS NOT NULL AND voided_at IS NOT NULL " + "AND void_reason IS NOT NULL AND void_event_id IS NOT NULL)", + name="ck_repair_parts_void_state", + ), + sa.CheckConstraint( + "void_reason IS NULL OR (void_reason = trim(void_reason) " + "AND trim(void_reason) <> '' AND char_length(void_reason) <= 1000)", + name="ck_repair_parts_void_reason", + ), + sa.CheckConstraint( + "voided_at IS NULL OR voided_at >= recorded_at", + name="ck_repair_parts_void_chronology", + ), + ) + op.create_index( + "idx_repair_parts_repair", + "repair_parts", + ["repair_id", "recorded_event_id", "id"], + ) + op.create_index("idx_repair_parts_component", "repair_parts", ["component_id"]) + op.create_index("idx_repair_parts_recorded_by", "repair_parts", ["recorded_by"]) + op.create_index("idx_repair_parts_voided_by", "repair_parts", ["voided_by"]) + op.create_index( + "uq_repair_parts_recorded_event", + "repair_parts", + ["recorded_event_id"], + unique=True, + ) + op.create_index( + "uq_repair_parts_void_event", + "repair_parts", + ["void_event_id"], + unique=True, + postgresql_where=sa.text("void_event_id IS NOT NULL"), + ) + + op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check") + op.drop_constraint("ck_repair_events_type", "repair_events", type_="check") + _create_event_constraints(True) + + connection.exec_driver_sql( + """ + CREATE FUNCTION minerops_protect_component_identity() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'Durable component identity cannot be deleted'; + END IF; + IF NEW.id <> OLD.id + OR NEW.component_type <> OLD.component_type + OR NEW.created_at <> OLD.created_at + OR NEW.created_by <> OLD.created_by THEN + RAISE EXCEPTION 'Component identity, type, and creation provenance are immutable'; + END IF; + RETURN NEW; + END $$; + CREATE TRIGGER trg_protect_component_identity + BEFORE UPDATE OR DELETE ON components + FOR EACH ROW EXECUTE FUNCTION minerops_protect_component_identity(); + + CREATE FUNCTION minerops_protect_repair_event_history() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + RAISE EXCEPTION 'Repair event history is immutable'; + END $$; + CREATE TRIGGER trg_protect_repair_event_history + BEFORE UPDATE OR DELETE ON repair_events + FOR EACH ROW EXECUTE FUNCTION minerops_protect_repair_event_history(); + + CREATE FUNCTION minerops_protect_repair_part_history() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'Repair-part history is immutable'; + END IF; + IF NEW.id <> OLD.id + OR NEW.repair_id <> OLD.repair_id + OR NEW.consumable_description IS DISTINCT FROM OLD.consumable_description + OR NEW.quantity IS DISTINCT FROM OLD.quantity + OR NEW.unit IS DISTINCT FROM OLD.unit + OR NEW.component_id IS DISTINCT FROM OLD.component_id + OR NEW.component_type_snapshot IS DISTINCT FROM OLD.component_type_snapshot + OR NEW.component_asset_id_snapshot IS DISTINCT FROM OLD.component_asset_id_snapshot + OR NEW.component_manufacturer_snapshot IS DISTINCT FROM OLD.component_manufacturer_snapshot + OR NEW.component_model_snapshot IS DISTINCT FROM OLD.component_model_snapshot + OR NEW.component_manufacturer_serial_snapshot IS DISTINCT FROM OLD.component_manufacturer_serial_snapshot + OR NEW.notes IS DISTINCT FROM OLD.notes + OR NEW.recorded_by <> OLD.recorded_by + OR NEW.recorded_at <> OLD.recorded_at + OR NEW.recorded_event_id <> OLD.recorded_event_id THEN + RAISE EXCEPTION 'Original repair-part fields are immutable'; + END IF; + IF OLD.voided_by IS NOT NULL OR OLD.voided_at IS NOT NULL + OR OLD.void_reason IS NOT NULL OR OLD.void_event_id IS NOT NULL THEN + RAISE EXCEPTION 'Voided repair-part history is immutable'; + END IF; + IF NEW.voided_by IS NULL OR NEW.voided_at IS NULL + OR NEW.void_reason IS NULL OR NEW.void_event_id IS NULL THEN + RAISE EXCEPTION 'Repair-part void metadata must be written atomically'; + END IF; + RETURN NEW; + END $$; + CREATE TRIGGER trg_protect_repair_part_history + BEFORE UPDATE OR DELETE ON repair_parts + FOR EACH ROW EXECUTE FUNCTION minerops_protect_repair_part_history(); + + CREATE FUNCTION minerops_validate_repair_part_history() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE recorded repair_events%%ROWTYPE; + DECLARE voided repair_events%%ROWTYPE; + DECLARE component components%%ROWTYPE; + BEGIN + IF TG_OP = 'INSERT' AND ( + NEW.voided_by IS NOT NULL OR NEW.voided_at IS NOT NULL + OR NEW.void_reason IS NOT NULL OR NEW.void_event_id IS NOT NULL) THEN + RAISE EXCEPTION 'A repair part must be recorded before it can be voided'; + END IF; + SELECT * INTO recorded FROM repair_events WHERE id = NEW.recorded_event_id; + IF recorded.id IS NULL + OR recorded.repair_id <> NEW.repair_id + OR recorded.event_type <> 'REPAIR_PART_RECORDED' + OR recorded.created_by <> NEW.recorded_by + OR recorded.created_at <> NEW.recorded_at + OR recorded.payload ->> 'repair_part_id' <> NEW.id::text THEN + RAISE EXCEPTION 'Recorded repair-part event does not match its entry'; + END IF; + + IF TG_OP = 'INSERT' AND NEW.component_id IS NOT NULL THEN + SELECT * INTO component FROM components WHERE id = NEW.component_id; + IF component.id IS NULL + OR component.component_type <> NEW.component_type_snapshot + OR component.asset_id IS DISTINCT FROM NEW.component_asset_id_snapshot + OR component.manufacturer IS DISTINCT FROM NEW.component_manufacturer_snapshot + OR component.model IS DISTINCT FROM NEW.component_model_snapshot + OR component.manufacturer_serial IS DISTINCT FROM NEW.component_manufacturer_serial_snapshot THEN + RAISE EXCEPTION 'Tracked-component snapshot does not match the component'; + END IF; + END IF; + + IF NEW.void_event_id IS NOT NULL THEN + SELECT * INTO voided FROM repair_events WHERE id = NEW.void_event_id; + IF voided.id IS NULL + OR voided.repair_id <> NEW.repair_id + OR voided.event_type <> 'REPAIR_PART_VOIDED' + OR voided.created_by <> NEW.voided_by + OR voided.created_at <> NEW.voided_at + OR voided.payload ->> 'repair_part_id' <> NEW.id::text + OR voided.payload ->> 'reason' <> NEW.void_reason THEN + RAISE EXCEPTION 'Voided repair-part event does not match its entry'; + END IF; + END IF; + RETURN NEW; + END $$; + CREATE TRIGGER trg_validate_repair_part_history + BEFORE INSERT OR UPDATE ON repair_parts + FOR EACH ROW EXECUTE FUNCTION minerops_validate_repair_part_history(); + """ + ) + + +def downgrade() -> None: + connection = op.get_bind() + connection.exec_driver_sql( + """ + DO $$ + DECLARE history_count bigint; + BEGIN + SELECT count(*) INTO history_count FROM components; + history_count := history_count + (SELECT count(*) FROM repair_parts); + history_count := history_count + ( + SELECT count(*) FROM repair_events + WHERE event_type IN ('REPAIR_PART_RECORDED', 'REPAIR_PART_VOIDED') + ); + IF history_count > 0 THEN + RAISE EXCEPTION USING + MESSAGE = format( + 'Cannot downgrade Repair Parts Tracking: %s component, repair-part, or event records would be destroyed', + history_count + ), + HINT = 'Preserve or explicitly migrate Task 005 identity and history before downgrading; MinerOps will not delete or invent component, actor, timestamp, snapshot, or repair-part history.'; + END IF; + END $$; + """ + ) + + connection.exec_driver_sql( + """ + DROP TRIGGER trg_validate_repair_part_history ON repair_parts; + DROP FUNCTION minerops_validate_repair_part_history(); + DROP TRIGGER trg_protect_repair_part_history ON repair_parts; + DROP FUNCTION minerops_protect_repair_part_history(); + DROP TRIGGER trg_protect_repair_event_history ON repair_events; + DROP FUNCTION minerops_protect_repair_event_history(); + DROP TRIGGER trg_protect_component_identity ON components; + DROP FUNCTION minerops_protect_component_identity(); + """ + ) + + op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check") + op.drop_constraint("ck_repair_events_type", "repair_events", type_="check") + _create_event_constraints(False) + + op.drop_index("uq_repair_parts_void_event", table_name="repair_parts") + op.drop_index("uq_repair_parts_recorded_event", table_name="repair_parts") + op.drop_index("idx_repair_parts_voided_by", table_name="repair_parts") + op.drop_index("idx_repair_parts_recorded_by", table_name="repair_parts") + op.drop_index("idx_repair_parts_component", table_name="repair_parts") + op.drop_index("idx_repair_parts_repair", table_name="repair_parts") + op.drop_table("repair_parts") + + op.drop_index("uq_components_normalized_asset_id", table_name="components") + op.drop_index("idx_components_type", table_name="components") + op.drop_index("idx_components_created_by", table_name="components") + op.drop_table("components") + + connection.exec_driver_sql( + """ + DELETE FROM role_permissions + WHERE permission_id IN ( + SELECT id FROM permissions + WHERE key IN ('components.read', 'components.create', 'repairs.parts.manage') + ); + DELETE FROM permissions + WHERE key IN ('components.read', 'components.create', 'repairs.parts.manage'); + """ + ) diff --git a/backend/app/components/__init__.py b/backend/app/components/__init__.py new file mode 100644 index 0000000..5618361 --- /dev/null +++ b/backend/app/components/__init__.py @@ -0,0 +1 @@ +"""Minimal durable component registry for repair-part references.""" diff --git a/backend/app/components/dependencies.py b/backend/app/components/dependencies.py new file mode 100644 index 0000000..03a8dc4 --- /dev/null +++ b/backend/app/components/dependencies.py @@ -0,0 +1,8 @@ +"""Dependency construction for the Components domain.""" + +from app.components.repository import ComponentRepository +from app.components.service import ComponentService + + +def get_component_service() -> ComponentService: + return ComponentService(ComponentRepository()) diff --git a/backend/app/components/exceptions.py b/backend/app/components/exceptions.py new file mode 100644 index 0000000..9f3c90a --- /dev/null +++ b/backend/app/components/exceptions.py @@ -0,0 +1,32 @@ +"""Domain exceptions for the minimal component registry.""" + + +class ComponentError(Exception): + """Base exception for component operations.""" + + +class ComponentNotFound(ComponentError): + """Raised when a component UUID does not exist.""" + + def __init__(self, component_id: object): + self.component_id = component_id + super().__init__(f"Component not found: {component_id}") + + +class DuplicateComponentAssetID(ComponentError): + """Raised when a normalized component Asset ID already exists.""" + + def __init__(self, asset_id: str): + self.asset_id = asset_id + super().__init__(f"Component Asset ID already exists: {asset_id}") + + +class ComponentValidationError(ComponentError): + """Raised when component metadata violates a domain rule.""" + + +class ComponentActorRequired(ComponentError): + """Raised when component creation lacks an authenticated actor.""" + + def __init__(self): + super().__init__("An authenticated actor is required to create a component") diff --git a/backend/app/components/models.py b/backend/app/components/models.py new file mode 100644 index 0000000..d6513c5 --- /dev/null +++ b/backend/app/components/models.py @@ -0,0 +1,36 @@ +"""Typed contracts for the minimal component registry.""" + +from typing import Literal +from uuid import UUID + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field + + +ComponentType = Literal["control_board", "hashboard", "power_supply", "fan"] + + +class ComponentCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + component_type: ComponentType + asset_id: str | None = Field(default=None, max_length=255) + manufacturer: str | None = Field(default=None, max_length=255) + model: str | None = Field(default=None, max_length=255) + manufacturer_serial: str | None = Field(default=None, max_length=255) + + +class ComponentDetail(BaseModel): + component_id: UUID + component_type: ComponentType + asset_id: str | None = None + manufacturer: str | None = None + model: str | None = None + manufacturer_serial: str | None = None + created_at: AwareDatetime + created_by: UUID + + +class ComponentResponse(BaseModel): + data: ComponentDetail + meta: dict[str, object] = Field(default_factory=dict) + error: None = None diff --git a/backend/app/components/repository.py b/backend/app/components/repository.py new file mode 100644 index 0000000..7827848 --- /dev/null +++ b/backend/app/components/repository.py @@ -0,0 +1,143 @@ +"""PostgreSQL persistence for the minimal component registry.""" + +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from uuid import UUID + +import psycopg +from psycopg.errors import UniqueViolation +from psycopg.rows import dict_row + +from app.components.exceptions import DuplicateComponentAssetID +from app.config import settings + + +ConnectionFactory = Callable[[], psycopg.Connection] +COMPONENT_ASSET_ID_CONSTRAINT = "uq_components_normalized_asset_id" + + +def default_connection_factory() -> psycopg.Connection: + return psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + connect_timeout=5, + ) + + +class ComponentRepository: + """Persistence boundary for minimal component identity.""" + + def __init__( + self, + connection_factory: ConnectionFactory | None = None, + *, + connection: psycopg.Connection | None = None, + ): + self._connection_factory = connection_factory or default_connection_factory + self._connection = connection + + @contextmanager + def transaction(self) -> Iterator["ComponentRepository"]: + if self._connection is not None: + yield self + return + + connection = self._connection_factory() + repository = ComponentRepository( + self._connection_factory, + connection=connection, + ) + try: + yield repository + connection.commit() + except UniqueViolation as exc: + connection.rollback() + if exc.diag.constraint_name == COMPONENT_ASSET_ID_CONSTRAINT: + raise DuplicateComponentAssetID("normalized Asset ID") from exc + raise + except Exception: + connection.rollback() + raise + finally: + connection.close() + + @contextmanager + def _connection_scope(self) -> Iterator[psycopg.Connection]: + if self._connection is not None: + yield self._connection + return + + connection = self._connection_factory() + try: + yield connection + finally: + connection.close() + + def create_component( + self, + *, + component_type: str, + asset_id: str | None, + manufacturer: str | None, + model: str | None, + manufacturer_serial: str | None, + created_by: UUID, + ) -> dict: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + INSERT INTO components ( + component_type, + asset_id, + manufacturer, + model, + manufacturer_serial, + created_by + ) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING + id AS component_id, + component_type, + asset_id, + manufacturer, + model, + manufacturer_serial, + created_at, + created_by + """, + ( + component_type, + asset_id, + manufacturer, + model, + manufacturer_serial, + created_by, + ), + ) + return dict(cur.fetchone()) + + def get_component(self, component_id: UUID) -> dict | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT + id AS component_id, + component_type, + asset_id, + manufacturer, + model, + manufacturer_serial, + created_at, + created_by + FROM components + WHERE id = %s + """, + (component_id,), + ) + row = cur.fetchone() + return dict(row) if row else None diff --git a/backend/app/components/router.py b/backend/app/components/router.py new file mode 100644 index 0000000..4d9c90e --- /dev/null +++ b/backend/app/components/router.py @@ -0,0 +1,80 @@ +"""HTTP mapping for the minimal component registry.""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.components.dependencies import get_component_service +from app.components.exceptions import ( + ComponentActorRequired, + ComponentNotFound, + ComponentValidationError, + DuplicateComponentAssetID, +) +from app.components.models import ( + ComponentCreateRequest, + ComponentResponse, +) +from app.components.service import ComponentService +from app.identity import require_permission + + +router = APIRouter(prefix="/api/components", tags=["components"]) +require_component_read = require_permission("components.read") +require_component_create = require_permission("components.create") + +ComponentServiceDependency = Annotated[ + ComponentService, + Depends(get_component_service), +] +ComponentReadUser = Annotated[dict, Depends(require_component_read)] +ComponentCreateUser = Annotated[dict, Depends(require_component_create)] + + +@router.post( + "", + response_model=ComponentResponse, + status_code=status.HTTP_201_CREATED, +) +def create_component( + request: ComponentCreateRequest, + service: ComponentServiceDependency, + current_user: ComponentCreateUser, +) -> ComponentResponse: + try: + component = service.create_component( + request, + actor_user_id=UUID(str(current_user["id"])), + ) + except DuplicateComponentAssetID as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + except (ComponentActorRequired, ComponentValidationError) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + return ComponentResponse(data=component) + + +@router.get( + "/{component_id}", + response_model=ComponentResponse, +) +def get_component( + component_id: UUID, + service: ComponentServiceDependency, + current_user: ComponentReadUser, +) -> ComponentResponse: + del current_user + try: + component = service.get_component(component_id) + except ComponentNotFound as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Component not found", + ) from exc + return ComponentResponse(data=component) diff --git a/backend/app/components/service.py b/backend/app/components/service.py new file mode 100644 index 0000000..29d47aa --- /dev/null +++ b/backend/app/components/service.py @@ -0,0 +1,75 @@ +"""Business rules for the minimal component registry.""" + +from uuid import UUID + +from app.components.exceptions import ( + ComponentActorRequired, + ComponentNotFound, + ComponentValidationError, + DuplicateComponentAssetID, +) +from app.components.models import ComponentCreateRequest, ComponentDetail +from app.components.repository import ComponentRepository + + +class ComponentService: + """Minimal component workflows independent of FastAPI.""" + + def __init__(self, repository: ComponentRepository): + self.repository = repository + + @staticmethod + def _normalize_optional_text(value: str | None, field_name: str) -> str | None: + if value is None: + return None + normalized = str(value).strip() + if not normalized: + raise ComponentValidationError( + f"{field_name} must not be blank when supplied" + ) + if len(normalized) > 255: + raise ComponentValidationError( + f"{field_name} must not exceed 255 characters" + ) + return normalized + + @staticmethod + def _map_component(component: dict) -> ComponentDetail: + return ComponentDetail(**component) + + def create_component( + self, + request: ComponentCreateRequest, + *, + actor_user_id: UUID | None, + ) -> ComponentDetail: + if actor_user_id is None: + raise ComponentActorRequired() + + asset_id = self._normalize_optional_text(request.asset_id, "asset_id") + values = { + "manufacturer": self._normalize_optional_text( + request.manufacturer, "manufacturer" + ), + "model": self._normalize_optional_text(request.model, "model"), + "manufacturer_serial": self._normalize_optional_text( + request.manufacturer_serial, "manufacturer_serial" + ), + } + try: + with self.repository.transaction() as repository: + component = repository.create_component( + component_type=request.component_type, + asset_id=asset_id, + created_by=actor_user_id, + **values, + ) + except DuplicateComponentAssetID as exc: + raise DuplicateComponentAssetID(asset_id or "") from exc + return self._map_component(component) + + def get_component(self, component_id: UUID) -> ComponentDetail: + component = self.repository.get_component(component_id) + if component is None: + raise ComponentNotFound(component_id) + return self._map_component(component) diff --git a/backend/app/dashboard/__init__.py b/backend/app/dashboard/__init__.py new file mode 100644 index 0000000..988f3f4 --- /dev/null +++ b/backend/app/dashboard/__init__.py @@ -0,0 +1 @@ +"""Permission-aware operational Dashboard read model.""" diff --git a/backend/app/dashboard/dependencies.py b/backend/app/dashboard/dependencies.py new file mode 100644 index 0000000..9fa3da4 --- /dev/null +++ b/backend/app/dashboard/dependencies.py @@ -0,0 +1,14 @@ +"""Dependency construction for the Dashboard composition service.""" + +from app.dashboard.service import DashboardService +from app.inventory.dependencies import get_inventory_service +from app.locations.dependencies import get_location_map_service +from app.repairs.dependencies import get_repair_center_service + + +def get_dashboard_service() -> DashboardService: + return DashboardService( + inventory_service=get_inventory_service(), + repair_center_service=get_repair_center_service(), + location_service=get_location_map_service(), + ) diff --git a/backend/app/dashboard/models.py b/backend/app/dashboard/models.py new file mode 100644 index 0000000..6b85138 --- /dev/null +++ b/backend/app/dashboard/models.py @@ -0,0 +1,32 @@ +"""Typed contracts for the operational Dashboard overview.""" + +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.inventory.models import InventoryFleetSummary +from app.locations.models import LocationSummary +from app.repairs.models import ActiveRepairSummary + + +class DashboardAccess(BaseModel): + can_view_miners: bool = False + can_view_repairs: bool = False + can_view_locations: bool = False + + +class DashboardOverviewData(BaseModel): + fleet: InventoryFleetSummary | None = None + repairs: ActiveRepairSummary | None = None + locations: LocationSummary | None = None + generated_at: datetime + + +class DashboardOverviewMeta(BaseModel): + available_sections: list[str] = Field(default_factory=list) + + +class DashboardOverviewResponse(BaseModel): + data: DashboardOverviewData + meta: DashboardOverviewMeta = Field(default_factory=DashboardOverviewMeta) + error: None = None diff --git a/backend/app/dashboard/router.py b/backend/app/dashboard/router.py new file mode 100644 index 0000000..5d13892 --- /dev/null +++ b/backend/app/dashboard/router.py @@ -0,0 +1,40 @@ +"""HTTP boundary for the operational Dashboard overview.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Response + +from app.dashboard.dependencies import get_dashboard_service +from app.dashboard.models import ( + DashboardAccess, + DashboardOverviewResponse, +) +from app.dashboard.service import DashboardService +from app.identity import require_permission, user_has_permission + + +router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) +DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))] +DashboardServiceDependency = Annotated[ + DashboardService, + Depends(get_dashboard_service), +] + + +@router.get("/overview", response_model=DashboardOverviewResponse) +def get_dashboard_overview( + current_user: DashboardUser, + service: DashboardServiceDependency, + response: Response, +) -> DashboardOverviewResponse: + response.headers["Cache-Control"] = "no-store" + return service.get_overview( + DashboardAccess( + can_view_miners=user_has_permission(current_user, "miners.view"), + can_view_repairs=user_has_permission(current_user, "repairs.view"), + can_view_locations=user_has_permission( + current_user, + "locations.view", + ), + ) + ) diff --git a/backend/app/dashboard/service.py b/backend/app/dashboard/service.py new file mode 100644 index 0000000..c344623 --- /dev/null +++ b/backend/app/dashboard/service.py @@ -0,0 +1,64 @@ +"""Composition service for permission-aware Dashboard summaries.""" + +from datetime import datetime, timezone + +from app.dashboard.models import ( + DashboardAccess, + DashboardOverviewData, + DashboardOverviewMeta, + DashboardOverviewResponse, +) +from app.inventory.service import InventoryService +from app.locations.service import LocationMapService +from app.repairs.center_service import RepairCenterService + + +class DashboardService: + """Coordinate domain-owned summaries without introducing new rules.""" + + def __init__( + self, + inventory_service: InventoryService, + repair_center_service: RepairCenterService, + location_service: LocationMapService, + ): + self.inventory_service = inventory_service + self.repair_center_service = repair_center_service + self.location_service = location_service + + def get_overview(self, access: DashboardAccess) -> DashboardOverviewResponse: + fleet = ( + self.inventory_service.summarize_fleet() + if access.can_view_miners + else None + ) + repairs = ( + self.repair_center_service.summarize_active_repairs() + if access.can_view_repairs + else None + ) + locations = ( + self.location_service.summarize_locations( + include_miner_counts=access.can_view_miners + ) + if access.can_view_locations + else None + ) + available_sections = [ + name + for name, value in ( + ("fleet", fleet), + ("repairs", repairs), + ("locations", locations), + ) + if value is not None + ] + return DashboardOverviewResponse( + data=DashboardOverviewData( + fleet=fleet, + repairs=repairs, + locations=locations, + generated_at=datetime.now(timezone.utc), + ), + meta=DashboardOverviewMeta(available_sections=available_sections), + ) diff --git a/backend/app/frontend/__init__.py b/backend/app/frontend/__init__.py new file mode 100644 index 0000000..ca6f16c --- /dev/null +++ b/backend/app/frontend/__init__.py @@ -0,0 +1 @@ +"""Server-rendered frontend foundation for MinerOps.""" diff --git a/backend/app/frontend/miner_detail_view.py b/backend/app/frontend/miner_detail_view.py new file mode 100644 index 0000000..c06723d --- /dev/null +++ b/backend/app/frontend/miner_detail_view.py @@ -0,0 +1,77 @@ +"""Semantic HTML for the Internal Alpha Miner Detail page.""" + + +def miner_detail_content() -> str: + return """ +
+ Loading miner + Requesting current identity and operational context. +
+ + +""" 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/router.py b/backend/app/frontend/router.py new file mode 100644 index 0000000..458be5b --- /dev/null +++ b/backend/app/frontend/router.py @@ -0,0 +1,141 @@ +"""HTTP routes for the MinerOps Internal Alpha application shell.""" + +from pathlib import Path +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import HTMLResponse, Response + +from app.frontend.views import render_app_page +from app.identity import require_permission, safe_next_path, user_has_permission + + +router = APIRouter(tags=["frontend"]) +STATIC_ROOT = Path(__file__).with_name("static") + +DashboardUser = Annotated[dict, Depends(require_permission("dashboard.view"))] +MapUser = Annotated[dict, Depends(require_permission("locations.view"))] +RepairUser = Annotated[dict, Depends(require_permission("repairs.view"))] +MinerUser = Annotated[dict, Depends(require_permission("miners.view"))] + + +def render_page(view: str, current_user: dict, **context) -> str: + return render_app_page( + view, + current_user, + lambda permission: user_has_permission(current_user, permission), + **context, + ) + + +@router.get("/assets/minerops-app.css", include_in_schema=False) +def frontend_styles() -> Response: + return Response( + (STATIC_ROOT / "app.css").read_text(encoding="utf-8"), + media_type="text/css", + headers={"Cache-Control": "no-store"}, + ) + + +@router.get("/assets/minerops-app.js", include_in_schema=False) +def frontend_script() -> Response: + return Response( + (STATIC_ROOT / "app.js").read_text(encoding="utf-8"), + media_type="text/javascript", + headers={"Cache-Control": "no-store"}, + ) + + +@router.get("/assets/minerops-dashboard.js", include_in_schema=False) +def dashboard_script() -> Response: + return Response( + (STATIC_ROOT / "dashboard.js").read_text(encoding="utf-8"), + media_type="text/javascript", + headers={"Cache-Control": "no-store"}, + ) + + +@router.get("/assets/minerops-mine-map.js", include_in_schema=False) +def mine_map_script() -> Response: + return Response( + (STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8"), + media_type="text/javascript", + headers={"Cache-Control": "no-store"}, + ) + + +@router.get("/assets/minerops-fleet.js", include_in_schema=False) +def fleet_script() -> Response: + return Response( + (STATIC_ROOT / "fleet.js").read_text(encoding="utf-8"), + media_type="text/javascript", + headers={"Cache-Control": "no-store"}, + ) + + + +@router.get("/assets/minerops-repair-center.js", include_in_schema=False) +def repair_center_script() -> Response: + return Response( + (STATIC_ROOT / "repair-center.js").read_text(encoding="utf-8"), + media_type="text/javascript", + headers={"Cache-Control": "no-store"}, + ) + + +@router.get("/assets/minerops-miner-detail.js", include_in_schema=False) +def miner_detail_script() -> Response: + return Response( + (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8"), + media_type="text/javascript", + headers={"Cache-Control": "no-store"}, + ) + +@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False) +def dashboard_page(current_user: DashboardUser) -> str: + return render_page("dashboard", current_user) + + +@router.get( + "/monitoring/map", + response_class=HTMLResponse, + include_in_schema=False, +) +def mine_map_page(current_user: MapUser) -> str: + return render_page("map", current_user) + + +@router.get( + "/operations/repairs", + response_class=HTMLResponse, + include_in_schema=False, +) +def repair_center_page(current_user: RepairUser) -> str: + return render_page("repairs", current_user) + + +@router.get( + "/assets/miners", + response_class=HTMLResponse, + include_in_schema=False, +) +def miners_page(current_user: MinerUser) -> str: + return render_page("miners", current_user) + + +@router.get( + "/miners/{miner_id}/detail", + response_class=HTMLResponse, + include_in_schema=False, +) +def miner_detail_page( + miner_id: UUID, + current_user: MinerUser, + return_to: Annotated[str | None, Query(max_length=2048)] = None, +) -> str: + safe_return = safe_next_path(return_to) if return_to else None + if safe_return != return_to: + safe_return = None + return render_page("miner_detail", current_user, miner_id=str(miner_id), + return_to=safe_return) diff --git a/backend/app/frontend/static/app.css b/backend/app/frontend/static/app.css new file mode 100644 index 0000000..01055f5 --- /dev/null +++ b/backend/app/frontend/static/app.css @@ -0,0 +1,1724 @@ +:root { + color-scheme: light; + font-family: Inter, "Segoe UI", Arial, sans-serif; + font-synthesis: none; + --ink: #17202a; + --muted: #5a6773; + --line: #ccd4dc; + --surface: #ffffff; + --canvas: #f3f5f7; + --nav: #0f172a; + --accent: #f97316; + --focus: #1677b8; + --good: #177245; + --warn: #9a5b00; + --danger: #b42318; +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + background: var(--canvas); +} + +body { + margin: 0; + color: var(--ink); + background: var(--canvas); +} + +button, +input, +select { + font: inherit; +} + +a { + color: inherit; +} + +.skip-link { + position: fixed; + left: 12px; + top: -60px; + z-index: 100; + padding: 10px 14px; + background: var(--surface); + border: 2px solid var(--focus); +} + +.skip-link:focus { + top: 12px; +} + +:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 3px; +} + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 224px minmax(0, 1fr); + grid-template-rows: 68px minmax(0, 1fr); +} + +.topbar { + grid-column: 1 / -1; + display: grid; + grid-template-columns: 224px minmax(240px, 620px) 1fr; + align-items: center; + gap: 20px; + min-height: 68px; + padding: 10px 18px; + color: #ffffff; + background: var(--nav); + border-bottom: 3px solid var(--accent); +} + +.brand-area { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.brand { + display: block; + min-width: 0; + text-decoration: none; +} + +.brand-name { + display: block; + font-size: 20px; + font-weight: 800; +} + +.brand-tagline { + display: block; + margin-top: 2px; + color: #d3dae2; + font-size: 11px; +} + +.global-search { + position: relative; +} + +.global-search label { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; +} + +.global-search input { + width: 100%; + min-height: 42px; + padding: 9px 14px; + border: 1px solid #647385; + border-radius: 5px; + color: var(--ink); + background: #ffffff; +} + +.session-tools { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 10px; + min-width: 0; +} + +.session-copy { + min-width: 0; + text-align: right; +} + +.session-user, +.session-role { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-user { + font-size: 13px; + font-weight: 750; +} + +.session-role { + color: #d3dae2; + font-size: 11px; +} + +.quiet-button, +.menu-button { + min-height: 40px; + border: 1px solid #8390a0; + border-radius: 5px; + padding: 8px 11px; + color: #ffffff; + background: transparent; + cursor: pointer; +} + +.menu-button { + display: none; +} + +.primary-nav { + grid-column: 1; + grid-row: 2; + padding: 20px 12px; + color: #eef2f6; + background: #17212d; +} + +.primary-nav ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 5px; +} + +.primary-nav a { + display: block; + min-height: 42px; + padding: 11px 12px; + border-left: 3px solid transparent; + text-decoration: none; + font-size: 14px; + font-weight: 650; +} + +.primary-nav a:hover { + background: #263343; +} + +.primary-nav a[aria-current="page"] { + border-color: var(--accent); + background: #2c3948; +} + +.nav-note { + margin: 22px 12px 0; + color: #aeb9c5; + font-size: 12px; + line-height: 1.45; +} + +.main-content { + grid-column: 2; + grid-row: 2; + min-width: 0; + padding: 26px clamp(16px, 3vw, 42px) 48px; +} + +.page-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 22px; +} + +.eyebrow { + margin: 0 0 5px; + color: var(--muted); + font-size: 12px; + font-weight: 750; + text-transform: uppercase; +} + +h1 { + margin: 0; + font-size: 32px; + letter-spacing: 0; +} + +.page-summary { + max-width: 760px; + margin: 8px 0 0; + color: var(--muted); + line-height: 1.5; +} + +.freshness { + margin: 4px 0 0; + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +.metrics { + display: grid; + grid-template-columns: repeat(5, minmax(130px, 1fr)); + gap: 12px; + margin-bottom: 24px; +} + +.metric, +.panel, +.map-inspector { + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface); +} + +.metric { + min-height: 122px; + padding: 15px; +} + +.metric-label { + display: block; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.metric-value { + display: block; + margin: 14px 0 6px; + font-size: 25px; + font-weight: 800; +} + +.metric-state { + display: block; + color: var(--warn); + font-size: 12px; +} + +.content-grid { + display: grid; + grid-template-columns: minmax(0, 1.7fr) minmax(260px, .7fr); + gap: 18px; +} + +.panel { + padding: 18px; + min-width: 0; +} + +.panel h2 { + margin: 0; + font-size: 18px; +} + +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 15px; +} + +.panel-copy { + margin: 7px 0 0; + color: var(--muted); + font-size: 13px; + line-height: 1.45; +} + +.state-message { + padding: 26px 16px; + border: 1px dashed #aeb8c2; + text-align: center; + color: var(--muted); +} + +.state-message strong { + display: block; + margin-bottom: 6px; + color: var(--ink); +} + +.state-message[data-kind="error"] { + border-color: #d4a09b; + color: var(--danger); +} + +.state-message[hidden] { + display: none; +} + +.table-wrap { + width: 100%; + overflow-x: auto; + border: 1px solid var(--line); +} + +table { + width: 100%; + border-collapse: collapse; + background: var(--surface); +} + +th, +td { + padding: 11px 12px; + border-bottom: 1px solid #e3e8ed; + text-align: left; + vertical-align: top; +} + +th { + color: #40505f; + background: #f7f8fa; + font-size: 12px; +} + +td { + font-size: 13px; +} + +tbody tr:last-child td { + border-bottom: 0; +} + +.object-link { + color: #075b91; + font-weight: 750; +} + +.secondary { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 11px; +} + +.status-text { + font-weight: 750; +} + +.status-text::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + margin-right: 7px; + border-radius: 50%; + background: #758493; +} + +.status-text[data-status="active"]::before, +.status-text[data-status="online"]::before { + background: var(--good); +} + +.status-text[data-status="in_repair"]::before, +.status-text[data-status="repair"]::before { + background: var(--warn); +} + +.status-text[data-status="critical"]::before { + background: var(--danger); +} + +.filter-bar { + display: flex; + align-items: end; + gap: 10px; + margin-bottom: 14px; +} + +.field { + display: grid; + gap: 6px; + min-width: min(100%, 320px); +} + +.field label { + color: #40505f; + font-size: 12px; + font-weight: 750; +} + +.field input, +.field select { + min-height: 42px; + border: 1px solid #9da9b5; + border-radius: 5px; + padding: 8px 10px; + background: #ffffff; +} + +.command-button { + min-height: 42px; + border: 1px solid #1b5f89; + border-radius: 5px; + padding: 8px 15px; + color: #ffffff; + background: #176b9a; + font-weight: 750; + cursor: pointer; +} + +.command-button:disabled { + cursor: not-allowed; + opacity: .58; +} + +.map-layout { + display: grid; + grid-template-columns: 210px minmax(340px, 1fr) 280px; + min-height: 520px; + border: 1px solid var(--line); + background: var(--surface); +} + +.map-controls, +.map-inspector { + padding: 16px; +} + +.map-controls { + border-right: 1px solid var(--line); +} + +.map-controls h2, +.map-inspector h2 { + margin: 0 0 14px; + font-size: 16px; +} + +.hierarchy { + margin: 0 0 20px; + padding-left: 18px; + color: #40505f; + line-height: 1.8; +} + +.control-stack { + display: grid; + gap: 12px; +} + +.map-viewport { + display: grid; + place-items: center; + min-height: 420px; + padding: 28px; + background: #e8edf1; + box-shadow: inset 0 0 0 10px #dde4ea; +} + +.map-empty { + max-width: 460px; + padding: 22px; + border: 1px solid #9da9b5; + background: #ffffff; + text-align: center; +} + +.map-empty strong { + display: block; + margin-bottom: 8px; +} + +.map-inspector { + border: 0; + border-left: 1px solid var(--line); + border-radius: 0; +} + +.definition-list { + display: grid; + grid-template-columns: auto 1fr; + gap: 10px 14px; + margin: 0; + font-size: 13px; +} + +.definition-list dt { + color: var(--muted); +} + +.definition-list dd { + margin: 0; + font-weight: 650; +} + +.notice { + padding: 12px 14px; + border-left: 4px solid var(--warn); + background: #fff8e8; + color: #694100; + font-size: 13px; + line-height: 1.45; +} + +@media (max-width: 1120px) { + .metrics { + grid-template-columns: repeat(3, minmax(140px, 1fr)); + } + + .map-layout { + grid-template-columns: 190px minmax(320px, 1fr); + } + + .map-inspector { + grid-column: 1 / -1; + border-top: 1px solid var(--line); + border-left: 0; + } +} + +@media (max-width: 820px) { + .app-shell { + grid-template-columns: 1fr; + grid-template-rows: auto auto minmax(0, 1fr); + } + + .topbar { + position: sticky; + top: 0; + z-index: 30; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + } + + .brand-tagline, + .session-copy { + display: none; + } + + .menu-button { + display: inline-block; + } + + .global-search { + grid-column: 1 / -1; + grid-row: 2; + } + + .primary-nav { + display: none; + grid-column: 1; + grid-row: 2; + padding: 10px 12px; + } + + .primary-nav[data-open="true"] { + display: block; + } + + .primary-nav ul { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .primary-nav a { + min-height: 46px; + padding: 12px 8px; + text-align: center; + } + + .nav-note { + display: none; + } + + .main-content { + grid-column: 1; + grid-row: 3; + padding-top: 20px; + } + + .metrics { + grid-template-columns: repeat(2, minmax(130px, 1fr)); + } + + .content-grid { + grid-template-columns: 1fr; + } + + .map-layout { + grid-template-columns: 1fr; + } + + .map-controls, + .map-inspector { + border: 0; + border-bottom: 1px solid var(--line); + } +} + +@media (max-width: 560px) { + .topbar { + padding: 9px 10px; + } + + .brand-name { + font-size: 18px; + } + + .quiet-button { + padding: 7px 9px; + } + + .main-content { + padding: 18px 12px 36px; + } + + .page-head { + display: block; + } + + .freshness { + margin-top: 10px; + white-space: normal; + } + + .metrics { + grid-template-columns: 1fr; + } + + .metric { + min-height: 100px; + } + + .primary-nav ul { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .filter-bar { + align-items: stretch; + flex-direction: column; + } + + .field { + min-width: 0; + width: 100%; + } + + .command-button { + width: 100%; + } + + .table-wrap { + border: 0; + overflow: visible; + } + + table, + thead, + tbody, + tr, + th, + td { + display: block; + } + + thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + } + + tbody { + display: grid; + gap: 10px; + } + + tr { + border: 1px solid var(--line); + background: var(--surface); + } + + td { + display: grid; + grid-template-columns: minmax(90px, .45fr) 1fr; + gap: 10px; + border-bottom: 1px solid #e3e8ed; + } + + td::before { + content: attr(data-label); + color: var(--muted); + font-size: 11px; + font-weight: 750; + } + + .map-viewport { + min-height: 340px; + padding: 14px; + } +} + +/* Mine Map v1 */ +.map-context { + padding: 10px 12px; + border: 1px solid var(--line); + background: var(--surface); +} + +.map-context-head, +.map-status-band, +.inspector-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.map-context h2, +.map-inspector-v1 h2, +.unplaced-section h2 { + margin: 0; + font-size: 15px; +} + +.map-control-grid { + display: grid; + grid-template-columns: minmax(130px, .8fr) minmax(130px, .8fr) + minmax(150px, .9fr) minmax(190px, 1.35fr) auto; + align-items: end; + gap: 7px; + margin-top: 7px; +} + +.map-context .field { + gap: 3px; +} + +.map-context .field label { + font-size: 11px; +} + +.map-context select, +.map-context input[type="search"] { + min-height: 36px; + padding-top: 6px; + padding-bottom: 6px; +} + +.map-problems-control { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 36px; + padding: 6px 9px; + border: 1px solid #9aa6b1; + border-radius: 4px; + background: #f8fafb; + color: #263442; + font-size: 12px; + font-weight: 750; + cursor: pointer; + white-space: nowrap; +} + +.map-problems-control input { + width: 16px; + height: 16px; + margin: 0; + accent-color: #176b9a; +} + +.map-problems-control:has(input:checked) { + border-color: #176b9a; + background: #edf7fb; +} + +.map-filters { + margin-top: 7px; + border-top: 1px solid #e3e8ed; + padding-top: 5px; +} + +.map-filters summary { + min-height: 28px; + cursor: pointer; + color: #334252; + font-size: 13px; + font-weight: 750; +} + +.map-filter-grid { + display: grid; + grid-template-columns: repeat(4, minmax(135px, 1fr)) auto; + align-items: end; + gap: 7px; +} + +.map-filter-grid .quiet-button, +.icon-button { + color: var(--ink); + border-color: #8e9ba8; + background: #fff; +} + +.map-status-band { + align-items: flex-start; + margin: 7px 0; +} + +.map-summary, +.map-legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.summary-fact { + display: inline-flex; + align-items: baseline; + gap: 5px; + padding-right: 9px; + border-right: 1px solid var(--line); + color: var(--muted); + font-size: 12px; +} + +.summary-fact strong { + color: var(--ink); + font-size: 15px; +} + +.mine-map-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + border: 1px solid var(--line); + background: var(--surface); +} + +.mine-map-workspace[hidden], +.map-inspector-v1[hidden], +.unplaced-section[hidden], +.map-notice[hidden] { + display: none; +} + +.map-main-column { + min-width: 0; +} + +.map-legend { + min-height: 38px; + padding: 6px 9px; + border-bottom: 1px solid var(--line); + background: #f7f8fa; + font-size: 12px; +} + +.legend-item { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.legend-marker, +.status-marker { + display: inline-grid; + place-items: center; + border: 1px solid #758493; + border-radius: 3px; + color: #263442; + background: #eef1f4; + font-weight: 850; +} + +.legend-marker { + min-height: 21px; + padding: 2px 5px; + font-size: 9px; +} + +.status-marker { + flex: 0 1 auto; + min-height: 17px; + max-width: 100%; + padding: 1px 4px; + font-size: 8px; + line-height: 1.2; + text-align: center; + overflow-wrap: anywhere; +} + +.legend-marker[data-status="online"], +.map-position[data-status="online"] .status-marker { border-color: #658675; color: #315c45; background: #f0f6f2; } +.legend-marker[data-status="offline"], +.map-position[data-status="offline"] .status-marker { border-color: #b42318; color: #8f1d14; background: #fff; } +.legend-marker[data-status="unstable"], +.map-position[data-status="unstable"] .status-marker { border-color: #9a5b00; color: #754600; background: #fff; } +.legend-marker[data-status="repair"], +.map-position[data-status="repair"] .status-marker { border-color: #6b4ca5; color: #523884; background: #fff; } +.legend-marker[data-status="unknown"], +.map-position[data-status="unknown"] .status-marker { border-color: #596675; color: #354351; background: #fff; } + +.relative-scale { + display: inline-flex; + align-items: center; + gap: 5px; + padding-right: 8px; + border-right: 1px solid var(--line); + color: #40505f; +} + +.relative-scale-label { + font-weight: 750; +} + +.relative-scale-value { + font-variant-numeric: tabular-nums; +} + +.relative-scale-track { + width: 64px; + height: 6px; + border: 1px solid #8e9ba8; + background: linear-gradient(90deg, #dfe9ee, #176b9a); +} + +.map-notice { + margin: 14px; + padding: 13px; + border-left: 4px solid var(--warn); + background: #fff8e8; + color: #694100; + font-size: 13px; +} + +.map-viewport-v1 { + display: grid; + gap: 8px; + min-height: 380px; + max-height: calc(100vh - 265px); + overflow: auto; + padding: 8px; + background: #e9edf0; +} + +.map-rack { + min-width: 0; + border: 1px solid #aeb8c2; + background: #fff; +} + +.rack-title { + margin: 0; + padding: 6px 9px; + color: #fff; + background: #263442; + font-size: 14px; +} + +.map-shelf { + display: grid; + grid-template-columns: 78px minmax(0, 1fr); + border-top: 1px solid var(--line); +} + +.map-shelf:first-of-type { + border-top: 0; +} + +.shelf-title { + margin: 0; + padding: 7px; + color: #40505f; + background: #f3f5f7; + font-size: 12px; +} + +.position-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); + gap: 4px; + padding: 5px; +} + +.map-position { + position: relative; + display: grid; + grid-template-rows: auto auto auto auto; + gap: 2px; + min-width: 0; + min-height: 84px; + padding: 6px; + border: 1px solid #8e9ba8; + border-left: 3px solid #758493; + border-radius: 4px; + color: var(--ink); + background: #fff; + text-align: left; +} + +button.map-position { + cursor: pointer; +} + +button.map-position:hover { + border-color: #40505f; + background: #f8fafb; +} + +.map-position[data-selected="true"] { + box-shadow: 0 0 0 3px var(--focus); +} + +.map-position[data-filtered="true"] { + opacity: .28; + filter: grayscale(1); +} + +.map-position[data-problems-muted="true"] { + opacity: .16; + filter: grayscale(.8); +} + +.map-position[data-selected="true"] { + opacity: 1; + filter: none; +} + +.map-position[data-problem="true"] { + border-left-width: 5px; + font-weight: 650; +} + +.map-position[data-measurement="true"]::after { + position: absolute; + right: auto; + bottom: 0; + left: 0; + width: var(--relative-percent); + height: 4px; + background: #176b9a; + content: ""; +} + +.map-position[data-status="online"] { border-left-color: #658675; } +.map-position[data-status="offline"] { border-left-color: #b42318; background: #fff5f4; } +.map-position[data-status="unstable"] { border-left-color: #9a5b00; background: #fff9ed; } +.map-position[data-status="repair"] { border-left-color: #6b4ca5; background: #f7f3fc; } +.map-position[data-status="spare"] { border-left-color: #1677b8; background: #f1f8fc; } +.map-position[data-status="retired"] { border-left-color: #4d5660; background: #eef0f2; } +.map-position[data-status="empty"] { min-height: 56px; border-style: dashed; border-left-color: #aeb8c2; color: #687785; background: transparent; opacity: .62; } +.map-position[data-status="expected"] { border-left-color: #996600; background: #fff9e8; } +.map-position[data-status="conflict"] { border-left-color: #b42318; background: #fff0ee; } +.map-position[data-status="blocked"] { border-left-color: #4d5660; background: #e5e8eb; } +.map-position[data-status="switch"] { border-left-color: #28706d; background: #eff8f7; } +.map-position[data-status="restricted"] { border-left-color: #596675; background: #f3f5f7; } + +.position-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 6px; +} + +.position-label, +.position-miner, +.position-value, +.position-freshness { + overflow-wrap: anywhere; +} + +.position-label { flex: 1 1 auto; min-width: 0; font-size: 10px; line-height: 1.2; } +.position-miner { font-size: 11px; font-weight: 750; line-height: 1.2; } +.position-value { color: #334252; font-size: 10px; font-variant-numeric: tabular-nums; } +.position-freshness { color: var(--muted); font-size: 9px; } + +.map-inspector-v1 { + position: sticky; + top: 12px; + align-self: start; + min-width: 0; + max-height: calc(100vh - 90px); + overflow-y: auto; + padding: 16px; + border-left: 1px solid var(--line); + background: #fff; +} + +.icon-button { + width: 42px; + height: 42px; + border-radius: 4px; + cursor: pointer; + font-weight: 800; +} + +.status-callout { + padding: 10px; + border-left: 4px solid #758493; + background: #f3f5f7; + font-size: 13px; + font-weight: 750; +} + +.status-callout[data-status="online"] { border-color: var(--good); } +.status-callout[data-status="offline"] { border-color: var(--danger); } +.status-callout[data-status="repair"] { border-color: #6b4ca5; } + +.inspector-details { + grid-template-columns: minmax(92px, auto) minmax(0, 1fr); + margin-bottom: 18px; +} + +.inspector-details dd { + overflow-wrap: anywhere; +} + +.command-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 8px 15px; + border: 1px solid #1b5f89; + border-radius: 5px; + color: #fff; + background: #176b9a; + text-decoration: none; + font-weight: 750; +} + +.unplaced-section { + padding: 14px; + border-top: 1px solid var(--line); +} + +#mapUnplaced, +.legacy-position-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 8px; +} + +.legacy-position-entry { + display: grid; + gap: 7px; + padding: 10px; + border: 1px solid var(--line); + background: #fff; +} + +.legacy-position-path { + color: var(--muted); + font-size: 11px; +} + +.unplaced-miner { + display: grid; + gap: 4px; + padding: 10px; + border-left: 4px solid #758493; + background: #f3f5f7; + font-size: 12px; +} + +@media (max-width: 1180px) { + .map-control-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .map-filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .mine-map-workspace { grid-template-columns: 1fr; } + .map-inspector-v1 { position: static; max-height: none; border-top: 1px solid var(--line); border-left: 0; } +} + +@media (max-width: 680px) { + .map-context-head, + .map-status-band { align-items: flex-start; flex-direction: column; } + .map-control-grid, + .map-filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .map-search { grid-column: 1 / -1; } + .map-problems-control { min-height: 44px; } + .map-viewport-v1 { max-height: none; padding: 9px; } + .map-shelf { grid-template-columns: 1fr; } + .shelf-title { position: sticky; left: 0; z-index: 1; } + .position-grid { grid-template-columns: repeat(2, minmax(108px, 1fr)); overflow-x: auto; } + .map-inspector-v1 { position: fixed; inset: 0; z-index: 80; max-height: none; overflow-y: auto; } +} + +@media (max-width: 420px) { + .map-control-grid, + .map-filter-grid { grid-template-columns: 1fr; } + .map-search { grid-column: auto; } + .position-grid { grid-template-columns: repeat(2, minmax(104px, 1fr)); } +} + +/* Fleet operations */ +.fleet-panel { padding: 14px; } +.fleet-filters { + display: grid; + grid-template-columns: minmax(220px, 1.6fr) repeat(5, minmax(130px, 1fr)); + align-items: end; + gap: 9px; + margin-bottom: 12px; +} +.fleet-filters .field { min-width: 0; } +.fleet-filters .field input, +.fleet-filters .field select { min-height: 38px; } +.fleet-search { grid-column: span 2; } +.check-control { + display: flex; + align-items: center; + min-height: 38px; + gap: 7px; + color: #40505f; + font-size: 12px; + font-weight: 750; +} +.check-control input { width: 17px; height: 17px; } +.fleet-filter-actions { display: flex; gap: 7px; } +.fleet-filter-actions .quiet-button { color: var(--ink); } +.fleet-table { min-width: 1320px; } +.fleet-table th, +.fleet-table td { padding: 8px 9px; } +.fleet-table th { white-space: nowrap; } +.fleet-row { border-left: 4px solid transparent; } +.fleet-row[data-status="offline"] { border-left-color: #b42318; background: #fff5f4; } +.fleet-row[data-status="unstable"] { border-left-color: #9a5b00; background: #fff9ed; } +.fleet-row[data-status="repair"] { border-left-color: #6b4ca5; background: #f7f3fc; } +.fleet-row[data-status="unknown"] { border-left-color: #596675; background: #f7f8fa; } +.fleet-row[data-status="online"], +.fleet-row[data-status="spare"], +.fleet-row[data-status="retired"] { border-left-color: #c6ced5; } +.fleet-stack { display: grid; gap: 2px; } +.fleet-primary { font-weight: 700; } +.fleet-status { font-weight: 750; white-space: nowrap; } +.fleet-status::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + margin-right: 7px; + border: 2px solid currentColor; + border-radius: 50%; +} +.fleet-status[data-status="online"] { color: #315c45; } +.fleet-status[data-status="offline"] { color: #8f1d14; } +.fleet-status[data-status="unstable"] { color: #754600; } +.fleet-status[data-status="repair"] { color: #523884; } +.fleet-status[data-status="unknown"] { color: #354351; } +.fleet-status[data-status="spare"] { color: #075b91; } +.fleet-status[data-status="retired"] { color: #4d5660; } +.fleet-uuid { max-width: 150px; overflow: hidden; text-overflow: ellipsis; } +.fleet-location { min-width: 180px; max-width: 260px; } +.fleet-actions { display: grid; gap: 6px; white-space: nowrap; } +.quiet-link { color: #40505f; font-weight: 700; } +.fleet-result-summary { margin: 0 0 8px; color: var(--muted); font-size: 12px; } +.fleet-pagination { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 9px; + margin-top: 12px; + color: #40505f; + font-size: 12px; + font-weight: 700; +} +.fleet-pagination select { min-height: 34px; } + +@media (max-width: 1180px) { + .fleet-filters { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .fleet-search { grid-column: span 2; } +} + +@media (max-width: 680px) { + .fleet-panel { padding: 12px; } + .fleet-panel .panel-head { align-items: flex-start; } + .fleet-filters { grid-template-columns: 1fr 1fr; } + .fleet-search { grid-column: 1 / -1; } + .fleet-filter-actions { grid-column: 1 / -1; } + .fleet-filter-actions > * { flex: 1; } + .fleet-table { min-width: 0; } + .fleet-table tbody { gap: 12px; } + .fleet-table tr { border-left-width: 4px; padding: 8px; } + .fleet-table td { + display: grid; + grid-template-columns: minmax(95px, .8fr) minmax(0, 1.6fr); + gap: 10px; + padding: 7px 5px; + } + .fleet-table td::before { + content: attr(data-label); + color: #40505f; + font-size: 11px; + font-weight: 750; + } + .fleet-location { min-width: 0; max-width: none; } + .fleet-actions { white-space: normal; } + .fleet-pagination { justify-content: space-between; flex-wrap: wrap; } +} + +@media (max-width: 420px) { + .fleet-filters { grid-template-columns: 1fr; } + .fleet-search, .fleet-filter-actions { grid-column: auto; } + .fleet-table td { grid-template-columns: 88px minmax(0, 1fr); } +} + +/* Repair Center */ +.repair-center { padding: 14px; } +.repair-summary { + display: grid; + grid-template-columns: repeat(3, minmax(100px, 160px)); + gap: 8px; + margin: 10px 0 12px; +} +.repair-summary > div { + display: flex; + justify-content: space-between; + gap: 12px; + border-left: 3px solid var(--accent); + padding: 7px 9px; + background: #f6f8fa; +} +.repair-summary span { color: var(--muted); font-size: 12px; } +.repair-filters { + display: grid; + grid-template-columns: minmax(230px, 1.6fr) repeat(6, minmax(125px, 1fr)); + align-items: end; + gap: 8px; + margin-bottom: 12px; +} +.repair-filters .field { min-width: 0; } +.repair-filters input, +.repair-filters select { min-height: 38px; } +.repair-search { grid-column: span 2; } +.repair-filter-actions { display: flex; gap: 7px; } +.repair-filter-actions .quiet-button, +.repair-pagination .quiet-button, +.repair-row-actions .quiet-button { color: var(--ink); background: #fff; } +.repair-table { min-width: 1260px; } +.repair-table th, +.repair-table td { padding: 8px 9px; vertical-align: top; } +.repair-row[data-source="legacy"] { border-left: 4px solid #8390a0; } +.repair-row[data-source="modular"] { border-left: 4px solid #176b9a; } +.repair-row[data-active="false"] { opacity: .78; } +.repair-miner { display: grid; gap: 3px; min-width: 150px; } +.source-label { color: var(--muted); font-size: 11px; font-weight: 700; } +.repair-status { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 800; +} +.repair-status::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #718096; } +.repair-status[data-status="waiting_for_parts"]::before, +.repair-status[data-status="waiting_parts"]::before, +.repair-status[data-status="needs_followup"]::before { background: var(--warn); } +.repair-status[data-status="completed"]::before { background: var(--good); } +.repair-status[data-status="failed"]::before, +.repair-status[data-status="cancelled"]::before { background: var(--danger); } +.repair-row-actions { display: flex; flex-wrap: wrap; gap: 6px; } +.quiet-link { + display: inline-flex; + align-items: center; + min-height: 40px; + border: 1px solid #8390a0; + border-radius: 5px; + padding: 8px 11px; + background: #fff; + text-decoration: none; +} +.repair-result-summary { color: var(--muted); font-size: 13px; } +.repair-pagination { display: flex; justify-content: flex-end; align-items: center; gap: 9px; margin-top: 12px; } +.repair-inspector { + position: fixed; + inset: 68px 0 0 auto; + z-index: 70; + width: min(520px, 100%); + overflow-y: auto; + border-left: 1px solid var(--line); + padding: 18px; + background: var(--surface); + box-shadow: -10px 0 26px rgb(15 23 42 / 18%); +} +.inspector-head { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; } +.repair-detail-section { padding: 14px 0; border-top: 1px solid var(--line); } +.repair-detail-section h3 { margin: 0 0 9px; font-size: 16px; } +.repair-timeline, +.repair-parts-list { display: grid; gap: 9px; margin: 0; padding-left: 22px; } +.repair-timeline li, +.repair-parts-list li { padding: 7px 0; } +.repair-timeline li > span, +.repair-parts-list li > span { display: block; } +.voided-part { text-decoration: line-through; } +.repair-action-form { display: grid; gap: 8px; margin: 10px 0; } +.repair-action-form input, +.repair-action-form select, +.repair-action-form textarea { width: 100%; min-height: 40px; } +.repair-action-form textarea { min-height: 78px; resize: vertical; } +.danger-button { + min-height: 40px; + border: 1px solid var(--danger); + border-radius: 5px; + padding: 8px 11px; + color: var(--danger); + background: #fff; + font-weight: 750; + cursor: pointer; +} +.state-inline-error { color: var(--danger); font-weight: 700; } + +@media (max-width: 980px) { + .repair-filters { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .repair-search { grid-column: span 2; } +} + +@media (max-width: 680px) { + .repair-center { padding: 12px; } + .repair-summary { grid-template-columns: repeat(3, 1fr); } + .repair-summary > div { display: grid; gap: 2px; } + .repair-filters { grid-template-columns: 1fr 1fr; } + .repair-search, .repair-filter-actions { grid-column: 1 / -1; } + .repair-filter-actions > * { flex: 1; } + .repair-table { min-width: 0; } + .repair-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } + .repair-table tbody { display: grid; gap: 12px; } + .repair-table tr { display: block; border: 1px solid var(--line); padding: 8px; } + .repair-table td { display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: 8px; border: 0; } + .repair-table td::before { content: attr(data-label); color: var(--muted); font-size: 11px; font-weight: 800; } + .repair-pagination { justify-content: space-between; flex-wrap: wrap; } + .repair-inspector { inset: 0; width: 100%; } +} + +@media (max-width: 420px) { + .repair-filters { grid-template-columns: 1fr; } + .repair-search, .repair-filter-actions { grid-column: auto; } + .repair-table td { grid-template-columns: 90px minmax(0, 1fr); } +} + +/* Dashboard */ +.dashboard-status-band { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.dashboard-status-band p { margin: 0; } +.dashboard-permission-note { color: var(--muted); font-size: 12px; } + +.dashboard-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.dashboard-section { + min-width: 0; + border: 1px solid var(--line); + border-radius: 6px; + padding: 16px; + background: var(--surface); +} + +.dashboard-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.dashboard-section-head .eyebrow { margin: 0 0 3px; } +.dashboard-section h2 { margin: 0; font-size: 19px; } +.dashboard-section h3 { margin: 0 0 8px; font-size: 14px; } +.dashboard-section .object-link { white-space: nowrap; } + +.dashboard-total { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + border-left: 4px solid var(--accent); + padding: 9px 11px; + background: #f6f8fa; +} + +.dashboard-total span { color: var(--muted); font-weight: 700; } +.dashboard-total strong { font-size: 25px; } + +.dashboard-dimension-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + margin-top: 16px; +} + +.dashboard-count-list, +.dashboard-status-list { + display: grid; + gap: 0; + margin: 0; +} + +.dashboard-count-list > div, +.dashboard-status-list > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 36px; + border-top: 1px solid #e3e7eb; +} + +.dashboard-count-list dt, +.dashboard-status-list dt { color: #40505f; } +.dashboard-count-list dd, +.dashboard-status-list dd { margin: 0; font-weight: 800; } +.dashboard-status-list { margin-top: 10px; } +.dashboard-status-list a { font-weight: 700; } +.dashboard-boundary { margin: 13px 0 0; color: var(--muted); font-size: 12px; } + +.attention-list { display: grid; gap: 7px; } +.attention-list a { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 42px; + border: 1px solid var(--line); + border-left-width: 4px; + border-radius: 5px; + padding: 8px 11px; + background: #fff; + text-decoration: none; +} + +.attention-list a[data-state="offline"] { border-left-color: var(--danger); } +.attention-list a[data-state="unknown"] { border-left-color: #596675; } +.attention-list a[data-state="unstable"] { border-left-color: var(--warn); } +.attention-list a[data-state="repair"] { border-left-color: #6b4ca5; } +.attention-list span { font-weight: 750; } +.attention-list strong { font-size: 18px; } + +.dashboard-location-counts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 18px; +} + +@media (max-width: 900px) { + .dashboard-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 560px) { + .dashboard-status-band, + .dashboard-section-head { align-items: flex-start; flex-direction: column; } + .dashboard-dimension-grid { grid-template-columns: 1fr; } + .dashboard-location-counts { grid-template-columns: 1fr; } + .dashboard-section { padding: 13px; } + .dashboard-section .object-link { white-space: normal; } +} + +@media (prefers-reduced-motion: reduce) { + .dashboard-section *, + .attention-list * { scroll-behavior: auto; } +} + +/* Internal Alpha Miner Detail */ +.miner-detail-hero { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(220px, .8fr); + gap: 18px; + align-items: start; + border: 1px solid var(--line); + border-left: 5px solid var(--accent); + background: #fff; + padding: 18px; + margin-bottom: 16px; +} + +.miner-detail-hero h2 { margin: 2px 0 5px; font-size: 24px; } +.miner-detail-uuid { color: var(--muted); font-family: ui-monospace, monospace; font-size: 12px; overflow-wrap: anywhere; } +.miner-detail-state { display: grid; gap: 6px; justify-items: start; } +.miner-detail-state strong { + border: 1px solid var(--line); + border-left: 4px solid #596675; + padding: 8px 10px; + background: #f7f8f9; +} +.miner-detail-state strong[data-status="online"] { border-left-color: var(--success); } +.miner-detail-state strong[data-status="offline"] { border-left-color: var(--danger); } +.miner-detail-state strong[data-status="unstable"] { border-left-color: var(--warn); } +.miner-detail-state strong[data-status="repair"] { border-left-color: #6b4ca5; } +.miner-detail-actions { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + gap: 8px; + padding-top: 12px; + border-top: 1px solid var(--line); +} +.miner-detail-actions .quiet-button { color: var(--ink); } + +.miner-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.miner-detail-grid .panel { margin: 0 0 16px; } +.metric-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin: 0; +} +.metric-grid > div { + border: 1px solid var(--line); + border-left: 3px solid #7b8792; + padding: 9px 10px; + background: #f8f9fa; +} +.metric-grid dt { color: var(--muted); font-size: 12px; font-weight: 700; } +.metric-grid dd { margin: 4px 0 0; font-size: 16px; font-weight: 800; overflow-wrap: anywhere; } +.tag-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; } +.tag { border: 1px solid #aeb8c2; background: #f4f6f7; padding: 3px 7px; font-size: 12px; } + +.disclosure-list { display: grid; gap: 9px; margin-top: 12px; } +.disclosure-list details { + border: 1px solid var(--line); + background: #fff; +} +.disclosure-list summary { + cursor: pointer; + min-height: 44px; + padding: 11px 13px; + font-weight: 750; +} +.disclosure-list summary:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.lazy-content, +.disclosure-list details > .definition-list, +.disclosure-list details > .permission-note { padding: 0 13px 13px; } +.repair-episode-summary { display: flex; justify-content: space-between; gap: 12px; } +.repair-episode-summary .secondary { font-weight: 500; } +.repair-timeline-list { display: grid; gap: 10px; padding-left: 24px; } +.repair-timeline-list li { padding-left: 4px; } +.repair-timeline-list li > span { display: block; margin-top: 3px; } +.event-payload { margin-top: 7px; } +.compact-history-table { margin-top: 8px; max-height: 420px; overflow: auto; } +.compact-history-table th { position: sticky; top: 0; background: #eef1f3; } +.permission-note { color: #5b466f; border-left: 4px solid #8b6cad; padding: 9px 11px; background: #f7f3fa; } +.empty-copy { color: var(--muted); } + +@media (max-width: 820px) { + .miner-detail-hero, + .miner-detail-grid { grid-template-columns: 1fr; } + .miner-detail-actions { grid-column: 1; } +} + +@media (max-width: 560px) { + .miner-detail-hero { padding: 13px; } + .metric-grid { grid-template-columns: 1fr; } + .miner-detail-actions > * { width: 100%; text-align: center; } + .repair-episode-summary { align-items: flex-start; flex-direction: column; } + .compact-history-table table { min-width: 620px; } +} + +@media (prefers-reduced-motion: reduce) { + .miner-detail-hero *, .disclosure-list * { scroll-behavior: auto; } +} diff --git a/backend/app/frontend/static/app.js b/backend/app/frontend/static/app.js new file mode 100644 index 0000000..e4acbfc --- /dev/null +++ b/backend/app/frontend/static/app.js @@ -0,0 +1,47 @@ +(function () { + "use strict"; + + function setupShell() { + const menu = document.getElementById("menuButton"); + const nav = document.getElementById("primaryNav"); + if (menu && nav) { + menu.addEventListener("click", function () { + const open = nav.dataset.open !== "true"; + nav.dataset.open = String(open); + menu.setAttribute("aria-expanded", String(open)); + }); + } + + const search = document.getElementById("globalSearchForm"); + const searchInput = document.getElementById("globalSearchInput"); + if (search && searchInput) { + search.addEventListener("submit", function (event) { + event.preventDefault(); + const query = searchInput.value.trim(); + const target = query + ? "/assets/miners?query=" + encodeURIComponent(query) + : "/assets/miners"; + window.location.assign(target); + }); + } + + const logout = document.getElementById("logoutButton"); + if (logout) { + logout.addEventListener("click", async function () { + logout.disabled = true; + try { + await fetch("/api/auth/logout", { + method: "POST", + credentials: "same-origin" + }); + } finally { + window.location.replace("/login"); + } + }); + } + } + + + + setupShell(); +})(); diff --git a/backend/app/frontend/static/dashboard.js b/backend/app/frontend/static/dashboard.js new file mode 100644 index 0000000..bb7e8db --- /dev/null +++ b/backend/app/frontend/static/dashboard.js @@ -0,0 +1,155 @@ +(function () { + "use strict"; + + if (document.body.dataset.view !== "dashboard") return; + + const ids = [ + "dashboardLoading", "dashboardError", "dashboardErrorDetail", + "dashboardRetry", "dashboardEmpty", "dashboardContent", + "dashboardLoadStatus", "dashboardFleetTotal", "dashboardRuntimeOnline", + "dashboardRuntimeOffline", "dashboardRuntimeUnknown", + "dashboardLifecycleRepair", "dashboardLifecycleSpare", + "dashboardLifecycleRetired", "dashboardAttentionOffline", + "dashboardAttentionUnknown", "dashboardAttentionUnstable", + "dashboardAttentionRepair", "dashboardActiveRepairs", + "dashboardRepairStatuses", "dashboardSites", "dashboardRooms", + "dashboardPositions", "dashboardOccupied", "dashboardEmptyPositions", + "dashboardMappedMiners", "dashboardUnmappedMiners" + ]; + const el = {}; + for (const id of ids) el[id] = document.getElementById(id); + let controller = null; + + const STATUS_LABELS = { + OPEN: "Open", + IN_PROGRESS: "In progress", + WAITING_FOR_PARTS: "Waiting for parts", + READY_FOR_TEST: "Ready for test", + open: "Open", + diagnosing: "Diagnosing", + waiting_parts: "Waiting parts", + in_progress: "In progress", + testing: "Testing", + ready_to_shelf: "Ready to shelf", + needs_followup: "Needs follow-up", + repair: "In repair", + unknown: "Unknown" + }; + + function setCount(node, value) { + if (node) node.textContent = Number.isInteger(value) ? String(value) : "Unavailable"; + } + + function show(name) { + el.dashboardLoading.hidden = name !== "loading"; + el.dashboardError.hidden = name !== "error"; + el.dashboardEmpty.hidden = name !== "empty"; + el.dashboardContent.hidden = name !== "ready"; + } + + function statusLabel(value) { + if (Object.prototype.hasOwnProperty.call(STATUS_LABELS, value)) { + return STATUS_LABELS[value]; + } + return String(value || "Unknown").replaceAll("_", " "); + } + + function renderFleet(fleet) { + if (!fleet || !el.dashboardFleetTotal) return; + setCount(el.dashboardFleetTotal, fleet.total); + setCount(el.dashboardRuntimeOnline, fleet.runtime_online); + setCount(el.dashboardRuntimeOffline, fleet.runtime_offline); + setCount(el.dashboardRuntimeUnknown, fleet.runtime_unknown); + setCount(el.dashboardLifecycleRepair, fleet.lifecycle_in_repair); + setCount(el.dashboardLifecycleSpare, fleet.lifecycle_spare); + setCount(el.dashboardLifecycleRetired, fleet.lifecycle_retired); + const attention = fleet.attention || {}; + setCount(el.dashboardAttentionOffline, attention.offline); + setCount(el.dashboardAttentionUnknown, attention.unknown); + setCount(el.dashboardAttentionUnstable, attention.unstable); + setCount(el.dashboardAttentionRepair, attention.repair); + } + + function renderRepairs(repairs) { + if (!repairs || !el.dashboardActiveRepairs) return; + setCount(el.dashboardActiveRepairs, repairs.active); + const entries = Object.entries(repairs.status_counts || {}).sort(function (left, right) { + return statusLabel(left[0]).localeCompare(statusLabel(right[0])); + }); + el.dashboardRepairStatuses.replaceChildren(); + if (!entries.length) { + const row = document.createElement("div"); + const term = document.createElement("dt"); + const value = document.createElement("dd"); + term.textContent = "No active repair statuses"; + value.textContent = "0"; + row.append(term, value); + el.dashboardRepairStatuses.appendChild(row); + return; + } + for (const [status, count] of entries) { + const row = document.createElement("div"); + const term = document.createElement("dt"); + const link = document.createElement("a"); + const value = document.createElement("dd"); + const params = new URLSearchParams({ scope: "active", status: status }); + link.href = "/operations/repairs?" + params.toString(); + link.textContent = statusLabel(status); + term.appendChild(link); + value.textContent = Number.isInteger(count) ? String(count) : "Unavailable"; + row.append(term, value); + el.dashboardRepairStatuses.appendChild(row); + } + } + + function renderLocations(locations) { + if (!locations || !el.dashboardSites) return; + setCount(el.dashboardSites, locations.sites); + setCount(el.dashboardRooms, locations.rooms); + setCount(el.dashboardPositions, locations.positions); + setCount(el.dashboardOccupied, locations.occupied_positions); + setCount(el.dashboardEmptyPositions, locations.empty_positions); + setCount(el.dashboardMappedMiners, locations.mapped_miners); + setCount(el.dashboardUnmappedMiners, locations.unmapped_miners); + } + + async function loadOverview() { + if (controller) controller.abort(); + controller = new AbortController(); + show("loading"); + try { + const response = await fetch("/api/dashboard/overview", { + credentials: "same-origin", + headers: { Accept: "application/json" }, + signal: controller.signal + }); + if (response.status === 401) { + window.location.replace("/login?next=" + encodeURIComponent("/dashboard")); + return; + } + if (!response.ok) throw new Error("The overview service returned " + response.status + "."); + const payload = await response.json(); + const data = payload && payload.data ? payload.data : {}; + const available = payload && payload.meta ? payload.meta.available_sections || [] : []; + if (!available.length) { + show("empty"); + return; + } + renderFleet(data.fleet); + renderRepairs(data.repairs); + renderLocations(data.locations); + const generated = new Date(data.generated_at); + el.dashboardLoadStatus.textContent = Number.isNaN(generated.valueOf()) + ? "Operational summary loaded" + : "Operational summary generated " + generated.toLocaleString(); + show("ready"); + } catch (error) { + if (error.name === "AbortError") return; + el.dashboardErrorDetail.textContent = error.message || "The operational overview could not be loaded."; + show("error"); + } + } + + el.dashboardRetry.addEventListener("click", loadOverview); + loadOverview(); +}()); diff --git a/backend/app/frontend/static/fleet.js b/backend/app/frontend/static/fleet.js new file mode 100644 index 0000000..1f1728f --- /dev/null +++ b/backend/app/frontend/static/fleet.js @@ -0,0 +1,359 @@ +(function () { + "use strict"; + + if (document.body.dataset.view !== "miners") return; + + const canViewLocations = document.body.dataset.canViewLocations === "true"; + const ids = [ + "fleetFilters", "fleetQuery", "fleetLifecycle", "fleetModel", + "fleetRuntime", "fleetRepair", "fleetOperational", "fleetTags", + "fleetProblems", "fleetReset", "fleetRows", "fleetResultSummary", + "fleetFreshness", "fleetPrevious", "fleetNext", "fleetLimit" + ]; + const el = {}; + for (const id of ids) el[id] = document.getElementById(id); + if (canViewLocations) { + el.fleetSite = document.getElementById("fleetSite"); + el.fleetRoom = document.getElementById("fleetRoom"); + } + + const limits = new Set([25, 50, 100, 250]); + const state = { offset: 0, limit: 100, returned: 0, locationOptions: null, + siteId: "", roomId: "" }; + + function display(value, fallback) { + if (value === null || value === undefined || String(value).trim() === "") { + return fallback || "Unavailable"; + } + return String(value); + } + + function words(value) { + return display(value, "unknown").replaceAll("_", " ").toLowerCase(); + } + + function create(tag, className, value) { + const node = document.createElement(tag); + if (className) node.className = className; + if (value !== undefined) node.textContent = value; + return node; + } + + function showState(name, message) { + for (const node of document.querySelectorAll("[data-view-state]")) { + node.hidden = node.dataset.viewState !== name; + } + const active = document.querySelector('[data-view-state="' + name + '"]'); + const detail = active && active.querySelector("[data-state-detail]"); + if (detail && message) detail.textContent = message; + } + + function finite(value) { + return typeof value === "number" && Number.isFinite(value); + } + + function metric(value, suffix) { + return finite(value) ? String(value) + suffix : "Unavailable"; + } + + function age(value) { + if (!value) return "Never observed"; + const time = new Date(value).valueOf(); + if (Number.isNaN(time)) return "Observation time unavailable"; + const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000)); + if (seconds < 60) return seconds + "s ago"; + if (seconds < 3600) return Math.floor(seconds / 60) + "m ago"; + if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago"; + return Math.floor(seconds / 86400) + "d ago"; + } + + function operational(miner) { + if (miner.active_repair || miner.lifecycle_status === "in_repair") { + return { key: "repair", label: "In repair", problem: true }; + } + if (miner.lifecycle_status === "retired") { + return { key: "retired", label: "Retired", problem: false }; + } + if (miner.lifecycle_status === "spare") { + return { key: "spare", label: "Spare", problem: false }; + } + const runtime = words(miner.runtime_status); + if (runtime === "offline") { + return { key: "offline", label: "Offline", problem: true }; + } + if (miner.hash_stable === false) { + return { key: "unstable", label: "Hash unstable", problem: true }; + } + if (runtime === "online") { + return { key: "online", label: "Online", problem: false }; + } + return { key: "unknown", label: "Operational state unknown", problem: true }; + } + + function prefixedLabel(value, prefix) { + const label = display(value, ""); + if (!label) return ""; + return new RegExp("^" + prefix + "\\b", "i").test(label) + ? label : prefix + " " + label; + } + + function locationText(miner) { + if (!canViewLocations || !miner.location_position_id) return "Not mapped"; + const parts = []; + if (miner.site_name) parts.push(miner.site_name); + if (miner.room_name) parts.push(miner.room_name); + if (miner.rack_label) parts.push(prefixedLabel(miner.rack_label, "Rack")); + if (miner.shelf_label) parts.push(prefixedLabel(miner.shelf_label, "Shelf")); + parts.push(display( + miner.position_label, + miner.position_index === null ? "Position unavailable" : "Position " + miner.position_index + )); + return parts.join(" / "); + } + + function detailLink(miner) { + const returnTo = window.location.pathname + window.location.search; + const path = "/miners/" + encodeURIComponent(miner.id) + "/detail"; + return path + "?return_to=" + encodeURIComponent(returnTo); + } + + function mapLink(miner) { + if (!canViewLocations || !miner.location_position_id) return null; + const params = new URLSearchParams({ miner_id: miner.id }); + if (miner.site_id && miner.room_id) { + params.set("site_id", miner.site_id); + params.set("room_id", miner.room_id); + } + return "/monitoring/map?" + params.toString(); + } + + function addCell(row, label, content, className) { + const cell = create("td", className || ""); + cell.dataset.label = label; + if (content instanceof Node) cell.appendChild(content); + else cell.textContent = display(content); + row.appendChild(cell); + } + + function stack(primary, secondary) { + const box = create("div", "fleet-stack"); + box.append(create("span", "fleet-primary", display(primary))); + if (secondary) box.append(create("span", "secondary", secondary)); + return box; + } + + function renderRow(miner) { + const condition = operational(miner); + const row = create("tr", "fleet-row"); + row.dataset.status = condition.key; + + const identity = create("div", "fleet-stack"); + const link = create("a", "object-link", display(miner.asset_id, "No Asset ID")); + link.href = detailLink(miner); + identity.append(link, create("span", "secondary fleet-uuid", miner.id)); + addCell(row, "Miner", identity, "fleet-identity"); + + const status = create("div", "fleet-stack"); + const marker = create("span", "fleet-status", condition.label); + marker.dataset.status = condition.key; + status.append(marker, create("span", "secondary", age(miner.observed_at))); + addCell(row, "Operational", status); + addCell(row, "Model / Serial", stack(miner.model, display(miner.miner_sn))); + addCell(row, "Network", stack(miner.current_mac, display(miner.current_ip))); + addCell(row, "Location", locationText(miner), "fleet-location"); + addCell(row, "Hashrate", metric(miner.hashrate_ths, " TH/s")); + addCell(row, "Power", metric(miner.power_w, " W")); + addCell(row, "Max chip temp", metric(miner.chip_temp_max_c, " C")); + const repair = miner.active_repair + ? words(miner.active_repair.status) + " / " + display(miner.active_repair.reason) + : "No active repair"; + addCell(row, "Lifecycle / Repair", stack(words(miner.lifecycle_status), repair)); + + const actions = create("div", "fleet-actions"); + const open = create("a", "command-link", "Open miner"); + open.href = detailLink(miner); + actions.appendChild(open); + const mapHref = mapLink(miner); + if (mapHref) { + const map = create("a", "quiet-link", "View on Mine Map"); + map.href = mapHref; + actions.appendChild(map); + } + addCell(row, "Actions", actions); + return row; + } + + function option(value, label) { + const node = document.createElement("option"); + node.value = value; + node.textContent = label; + return node; + } + + function syncLocationOptions(selectedSite, selectedRoom) { + if (!canViewLocations || !state.locationOptions) return; + el.fleetSite.replaceChildren(option("", "All sites")); + for (const site of state.locationOptions.sites || []) { + el.fleetSite.appendChild(option(String(site.id), site.name)); + } + el.fleetSite.value = Array.from(el.fleetSite.options).some(function (item) { + return item.value === selectedSite; + }) ? selectedSite : ""; + el.fleetRoom.replaceChildren(option("", "All rooms")); + for (const room of state.locationOptions.rooms || []) { + if (!el.fleetSite.value || String(room.site_id) === el.fleetSite.value) { + el.fleetRoom.appendChild(option(String(room.id), room.name)); + } + } + el.fleetRoom.value = Array.from(el.fleetRoom.options).some(function (item) { + return item.value === selectedRoom; + }) ? selectedRoom : ""; + state.siteId = el.fleetSite.value; + state.roomId = el.fleetRoom.value; + } + + function readUrl() { + const params = new URLSearchParams(window.location.search); + el.fleetQuery.value = (params.get("query") || "").slice(0, 255); + el.fleetLifecycle.value = params.get("lifecycle_status") || ""; + el.fleetModel.value = (params.get("model") || "").slice(0, 255); + el.fleetRuntime.value = params.get("runtime_status") || ""; + el.fleetRepair.value = params.get("repair") || ""; + el.fleetOperational.value = params.get("operational_state") || ""; + el.fleetTags.value = params.getAll("tags").join(", "); + el.fleetProblems.checked = params.get("problems") === "1"; + const requestedLimit = Number(params.get("limit")); + state.limit = limits.has(requestedLimit) ? requestedLimit : 100; + state.offset = Math.max(0, Number.parseInt(params.get("offset") || "0", 10) || 0); + el.fleetLimit.value = String(state.limit); + if (canViewLocations) { + state.siteId = params.get("site_id") || ""; + state.roomId = params.get("room_id") || ""; + syncLocationOptions(state.siteId, state.roomId); + } + } + + function requestParams() { + const params = new URLSearchParams(); + const values = { + query: el.fleetQuery.value.trim(), + lifecycle_status: el.fleetLifecycle.value, + model: el.fleetModel.value.trim(), + runtime_status: el.fleetRuntime.value, + repair: el.fleetRepair.value, + operational_state: el.fleetOperational.value, + problems: el.fleetProblems.checked ? "1" : "", + site_id: canViewLocations ? state.siteId : "", + room_id: canViewLocations ? state.roomId : "", + limit: String(state.limit), offset: String(state.offset) + }; + for (const pair of Object.entries(values)) { + if (pair[1]) params.set(pair[0], pair[1]); + } + for (const tag of el.fleetTags.value.split(",")) { + const clean = tag.trim(); + if (clean) params.append("tags", clean); + } + return params; + } + + function setUrl(mode) { + const params = requestParams(); + window.history[mode === "push" ? "pushState" : "replaceState"]( + {}, "", "/assets/miners?" + params.toString() + ); + } + + async function load() { + showState("loading"); + const controller = new AbortController(); + const timer = window.setTimeout(function () { controller.abort(); }, 15000); + try { + const response = await fetch("/api/inventory/miners?" + requestParams().toString(), { + credentials: "same-origin", headers: { Accept: "application/json" }, + signal: controller.signal + }); + if (response.status === 401) { + window.location.replace("/login?next=" + encodeURIComponent( + window.location.pathname + window.location.search + )); + return; + } + if (response.status === 403) { + const denied = new Error("Your account cannot view this fleet information."); + denied.permissionDenied = true; + throw denied; + } + if (!response.ok) throw new Error("The fleet service returned " + response.status + "."); + const envelope = await response.json(); + if (!envelope || !Array.isArray(envelope.data) || !envelope.meta) { + throw new Error("MinerOps returned an incomplete fleet response."); + } + state.returned = envelope.data.length; + state.locationOptions = envelope.meta.location_options || null; + const selectedSite = canViewLocations ? state.siteId : ""; + const selectedRoom = canViewLocations ? state.roomId : ""; + syncLocationOptions(selectedSite, selectedRoom); + el.fleetRows.replaceChildren(); + for (const miner of envelope.data) el.fleetRows.appendChild(renderRow(miner)); + if (!envelope.data.length) { + el.fleetPrevious.disabled = state.offset === 0; + el.fleetNext.disabled = true; + showState("empty", state.offset + ? "No miners are present on this page. Return to the previous page." + : "No authorized miners match the current filters."); + return; + } + el.fleetPrevious.disabled = state.offset === 0; + el.fleetNext.disabled = state.returned < state.limit; + const first = state.offset + 1; + const last = state.offset + state.returned; + el.fleetResultSummary.textContent = "Showing miners " + first + "-" + last + + ". Total fleet size is not calculated."; + el.fleetFreshness.textContent = state.returned + " miners on this page"; + showState("ready"); + } catch (error) { + showState(error.permissionDenied ? "denied" : "error", + error && error.name === "AbortError" + ? "MinerOps took too long to respond. Retry in a moment." + : display(error && error.message, "The fleet could not be loaded.")); + } finally { + window.clearTimeout(timer); + } + } + + el.fleetFilters.addEventListener("submit", function (event) { + event.preventDefault(); + if (canViewLocations) { + state.siteId = el.fleetSite.value; state.roomId = el.fleetRoom.value; + } + state.offset = 0; setUrl("push"); load(); + }); + el.fleetReset.addEventListener("click", function () { + el.fleetFilters.reset(); state.offset = 0; state.limit = 100; + state.siteId = ""; state.roomId = ""; + syncLocationOptions("", ""); setUrl("push"); load(); + }); + el.fleetPrevious.addEventListener("click", function () { + state.offset = Math.max(0, state.offset - state.limit); setUrl("push"); load(); + }); + el.fleetNext.addEventListener("click", function () { + state.offset += state.limit; setUrl("push"); load(); + }); + el.fleetLimit.addEventListener("change", function () { + state.limit = Number(el.fleetLimit.value); state.offset = 0; setUrl("push"); load(); + }); + if (canViewLocations) { + el.fleetSite.addEventListener("change", function () { + state.siteId = el.fleetSite.value; state.roomId = ""; + syncLocationOptions(state.siteId, ""); + }); + el.fleetRoom.addEventListener("change", function () { + state.roomId = el.fleetRoom.value; + }); + } + window.addEventListener("popstate", function () { readUrl(); load(); }); + + readUrl(); + load(); +}()); diff --git a/backend/app/frontend/static/mine-map.js b/backend/app/frontend/static/mine-map.js new file mode 100644 index 0000000..cea47a3 --- /dev/null +++ b/backend/app/frontend/static/mine-map.js @@ -0,0 +1,784 @@ +(function () { + "use strict"; + + if (document.body.dataset.view !== "map") return; + + const ids = [ + "mapSite", "mapRoom", "mapLayer", "mapSearch", "mapLifecycle", + "mapModel", "mapRepair", "mapStatus", "mapClearFilters", + "mapProblemsOnly", + "mapFilterCount", "mapScope", "mapSummary", "mapFreshness", + "mapCriticalState", "mapCriticalTitle", "mapCriticalDetail", + "mapRetry", "mapLoading", "mapWorkspace", "mapLegend", "mapNotice", + "mapPermissionNote", "mapViewport", "mapLegacySection", + "mapLegacyPositions", "mapUnplacedSection", "mapUnplaced", + "mapInspector", "mapInspectorTitle", "mapInspectorStatus", + "mapInspectorDetails", "mapInspectorClose", "mapOpenMiner" + ]; + const el = {}; + for (const id of ids) el[id] = document.getElementById(id); + + const state = { + data: null, + siteId: "", + roomId: "", + layer: "status", + query: "", + lifecycle: "", + model: "", + repair: "", + status: "", + problemsOnly: false, + minerId: "", + minerDetailsIncluded: false + }; + + function create(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function display(value, fallback) { + if (value === null || value === undefined || String(value).trim() === "") { + return fallback || "Unavailable"; + } + return String(value); + } + + function shelfLabel(value) { + const label = display(value, "Unlabelled shelf"); + return /^shelf\b/i.test(label) ? label : "Shelf " + label; + } + + function words(value) { + return display(value, "Unknown").replaceAll("_", " ").toLowerCase(); + } + + function dateTime(value) { + if (!value) return "Unavailable"; + const parsed = new Date(value); + return Number.isNaN(parsed.valueOf()) ? "Unavailable" : parsed.toLocaleString(); + } + + function metric(value, suffix) { + return typeof value === "number" && Number.isFinite(value) + ? value + suffix : "Unavailable"; + } + + function age(value) { + if (!value) return "Never observed"; + const time = new Date(value).valueOf(); + if (Number.isNaN(time)) return "Observation time unavailable"; + const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000)); + if (seconds < 60) return seconds + "s ago"; + if (seconds < 3600) return Math.floor(seconds / 60) + "m ago"; + if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago"; + return Math.floor(seconds / 86400) + "d ago"; + } + + const PROBLEM_KEYS = new Set([ + "offline", "unknown", "unstable", "repair", "conflict", "blocked", + "expected" + ]); + + const MEASUREMENT_LAYERS = { + temperature: { field: "chip_temp_max_c", suffix: " C max" }, + hashrate: { field: "hashrate_ths", suffix: " TH/s" }, + power: { field: "power_w", suffix: " W" } + }; + + function operational(position) { + const kind = words(position.position_kind); + const status = words(position.position_status); + if (position.conflict_reason || status === "conflict") { + return { key: "conflict", label: "Location conflict", mark: "Conflict" }; + } + if (kind === "blocked" || status === "blocked") { + return { key: "blocked", label: "Blocked position", mark: "Blocked" }; + } + if (kind === "switch" || status === "switch") { + return { key: "switch", label: "Switch / equipment", mark: "Equipment" }; + } + if (!position.occupied) { + if (position.expected_mac) { + return { key: "expected", label: "Expected miner not resolved", mark: "Expected" }; + } + if (kind === "miner slot" && status === "available") { + return { key: "empty", label: "Empty position", mark: "Empty" }; + } + return { key: "unknown", label: "Unknown position state", mark: "Unknown" }; + } + if (!state.minerDetailsIncluded) { + return { key: "restricted", label: "Occupied; miner details restricted", mark: "Restricted" }; + } + if (position.active_repair || position.lifecycle_status === "in_repair") { + return { key: "repair", label: "In repair", mark: "Repair" }; + } + if (position.lifecycle_status === "retired" || position.asset_state === "decommissioned") { + return { key: "retired", label: "Retired / decommissioned", mark: "Retired" }; + } + if (position.lifecycle_status === "spare") { + return { key: "spare", label: "Spare inventory", mark: "Spare" }; + } + const runtime = words(position.miner_status); + if (runtime === "offline") { + return { key: "offline", label: "Offline", mark: "Offline" }; + } + if (position.hash_stable === false) { + return { key: "unstable", label: "Hash unstable", mark: "Unstable" }; + } + if (runtime === "online") { + return { key: "online", label: "Online", mark: "Online" }; + } + return { key: "unknown", label: "Operational state unknown", mark: "Unknown" }; + } + + function isProblem(position) { + return PROBLEM_KEYS.has(operational(position).key); + } + + function measurementScale(positions) { + const config = MEASUREMENT_LAYERS[state.layer]; + if (!config) return null; + const values = positions.filter(function (position) { + return position.occupied && matches(position) && + (!state.problemsOnly || isProblem(position)); + }).map(function (position) { + return position[config.field]; + }).filter(function (value) { + return typeof value === "number" && Number.isFinite(value); + }); + if (!values.length) return { config: config, min: null, max: null }; + let min = values[0]; + let max = values[0]; + for (const value of values.slice(1)) { + min = Math.min(min, value); + max = Math.max(max, value); + } + return { + config: config, + min: min, + max: max + }; + } + + function relativePercent(value, scale) { + if (!scale || scale.min === null || scale.max === null || + typeof value !== "number" || !Number.isFinite(value)) return null; + if (scale.min === scale.max) return 50; + return Math.max(0, Math.min( + 100, + ((value - scale.min) / (scale.max - scale.min)) * 100 + )); + } + + function layerValue(position, condition) { + if (!position.occupied) return condition.label; + if (!state.minerDetailsIncluded) return condition.label; + if (state.layer === "temperature") { + return metric(position.chip_temp_max_c, " C max"); + } + if (state.layer === "hashrate") { + return metric(position.hashrate_ths, " TH/s"); + } + if (state.layer === "power") { + return metric(position.power_w, " W"); + } + if (state.layer === "repair") { + if (position.active_repair) return words(position.active_repair.status); + if (position.lifecycle_status === "in_repair") return "In-repair lifecycle"; + if (position.asset_state === "repair") return "Legacy repair location"; + return "No active repair"; + } + return condition.label; + } + + function identity(position) { + if (!state.minerDetailsIncluded) return "Miner details restricted"; + return display(position.asset_id, display(position.current_mac, "Unidentified miner")); + } + + function matches(position) { + if (!position.occupied) return true; + if (!state.minerDetailsIncluded) return true; + const condition = operational(position); + const haystack = [position.asset_id, position.miner_sn, position.current_mac, + position.current_ip, position.model].map(function (value) { + return display(value, "").toLowerCase(); + }).join(" "); + return (!state.query || haystack.includes(state.query.toLowerCase())) && + (!state.lifecycle || position.lifecycle_status === state.lifecycle) && + (!state.model || position.model === state.model) && + (!state.status || condition.key === state.status) && + (!state.repair || + (state.repair === "active" && Boolean(position.active_repair)) || + (state.repair === "none" && !position.active_repair) || + (state.repair === "legacy" && position.asset_state === "repair" && !position.active_repair)); + } + + function option(value, label) { + const node = create("option", "", label); + node.value = value; + return node; + } + + function unique(values) { + return Array.from(new Set(values.filter(Boolean))).sort(function (a, b) { + return String(a).localeCompare(String(b), undefined, { numeric: true }); + }); + } + + function syncSelectors() { + const sites = state.data.sites; + if (!sites.some(function (site) { return String(site.id) === state.siteId; })) { + state.siteId = sites.length ? String(sites[0].id) : ""; + } + el.mapSite.replaceChildren(); + for (const site of sites) el.mapSite.appendChild(option(String(site.id), site.name)); + el.mapSite.value = state.siteId; + + const rooms = state.data.rooms.filter(function (room) { + return String(room.site_id) === state.siteId; + }); + if (!rooms.some(function (room) { return String(room.id) === state.roomId; })) { + state.roomId = rooms.length ? String(rooms[0].id) : ""; + } + el.mapRoom.replaceChildren(); + if (!rooms.length) el.mapRoom.appendChild(option("", "No rooms at this site")); + for (const room of rooms) { + el.mapRoom.appendChild(option(String(room.id), room.name)); + } + el.mapRoom.value = state.roomId; + el.mapRoom.disabled = !rooms.length; + + const site = sites.find(function (item) { return String(item.id) === state.siteId; }); + const room = rooms.find(function (item) { return String(item.id) === state.roomId; }); + el.mapScope.textContent = site && room + ? site.name + " / " + room.name + : site ? site.name + " / No configured rooms" : "No configured sites"; + } + + function syncPermissionControls() { + const allowed = state.minerDetailsIncluded; + if (!allowed) { + state.layer = "status"; + state.query = state.lifecycle = state.model = state.repair = state.status = ""; + state.problemsOnly = false; + el.mapSearch.value = ""; + el.mapRepair.value = ""; + el.mapProblemsOnly.checked = false; + } + for (const item of el.mapLayer.options) { + item.disabled = !allowed && item.value !== "status"; + } + el.mapLayer.value = state.layer; + el.mapSearch.disabled = !allowed; + el.mapLifecycle.disabled = !allowed; + el.mapModel.disabled = !allowed; + el.mapRepair.disabled = !allowed; + el.mapStatus.disabled = !allowed; + el.mapClearFilters.disabled = !allowed; + el.mapProblemsOnly.disabled = !allowed; + el.mapSearch.closest(".field").hidden = !allowed; + document.getElementById("mapFilters").hidden = !allowed; + el.mapPermissionNote.hidden = allowed; + } + + function syncFilterOptions() { + const positions = state.data.positions.filter(function (position) { + return String(position.room_id || "") === state.roomId && position.occupied; + }); + const lifecycle = unique(positions.map(function (item) { return item.lifecycle_status; })); + const models = unique(positions.map(function (item) { return item.model; })); + const statuses = unique(positions.map(function (item) { return operational(item).key; })); + const selectedLifecycle = state.lifecycle; + const selectedModel = state.model; + const selectedStatus = state.status; + el.mapLifecycle.replaceChildren(option("", "All lifecycle states")); + lifecycle.forEach(function (value) { el.mapLifecycle.appendChild(option(value, words(value))); }); + el.mapModel.replaceChildren(option("", "All models")); + models.forEach(function (value) { el.mapModel.appendChild(option(value, value)); }); + el.mapStatus.replaceChildren(option("", "All operational states")); + statuses.forEach(function (value) { el.mapStatus.appendChild(option(value, words(value))); }); + if (lifecycle.includes(selectedLifecycle)) el.mapLifecycle.value = selectedLifecycle; + else state.lifecycle = ""; + if (models.includes(selectedModel)) el.mapModel.value = selectedModel; + else state.model = ""; + if (statuses.includes(selectedStatus)) el.mapStatus.value = selectedStatus; + else state.status = ""; + } + + function stateUrl() { + const params = new URLSearchParams(); + const values = { + site_id: state.siteId, room_id: state.roomId, + layer: state.layer === "status" ? "" : state.layer, + q: state.query, lifecycle: state.lifecycle, model: state.model, + repair: state.repair, status: state.status, + problems: state.problemsOnly ? "1" : "", miner_id: state.minerId + }; + for (const pair of Object.entries(values)) { + if (pair[1]) params.set(pair[0], pair[1]); + } + return window.location.pathname + + (params.size ? "?" + params.toString() : ""); + } + + function setUrl(mode) { + window.history[mode === "push" ? "pushState" : "replaceState"]( + {}, "", stateUrl() + ); + } + + function readUrl() { + const params = new URLSearchParams(window.location.search); + state.siteId = params.get("site_id") || ""; + state.roomId = params.get("room_id") || ""; + state.layer = ["status", "temperature", "hashrate", "power", "repair"] + .includes(params.get("layer")) ? params.get("layer") : "status"; + state.query = (params.get("q") || "").slice(0, 160); + state.lifecycle = params.get("lifecycle") || ""; + state.model = params.get("model") || ""; + state.repair = ["active", "none", "legacy"].includes(params.get("repair")) + ? params.get("repair") : ""; + state.status = params.get("status") || ""; + state.problemsOnly = params.get("problems") === "1"; + state.minerId = params.get("miner_id") || ""; + syncPermissionControls(); + el.mapLayer.value = state.layer; + el.mapSearch.value = state.query; + el.mapRepair.value = state.repair; + el.mapProblemsOnly.checked = state.problemsOnly; + } + + function appendLegendItem(key, label, mark) { + const item = create("span", "legend-item"); + const marker = create("span", "legend-marker", mark); + marker.dataset.status = key; + marker.setAttribute("aria-hidden", "true"); + item.append(marker, document.createTextNode(label)); + el.mapLegend.appendChild(item); + } + + function legend(scale) { + const entries = !state.minerDetailsIncluded + ? [["restricted", "Miner details restricted", "Restricted"], + ["empty", "Empty position", "Empty"], + ["conflict", "Location conflict", "Conflict"], + ["blocked", "Blocked position", "Blocked"], + ["switch", "Switch or equipment", "Equipment"]] + : state.layer === "status" + ? [["online", "Online", "Online"], ["offline", "Offline", "Offline"], + ["unstable", "Hash unstable", "Unstable"], + ["repair", "In repair", "Repair"], ["spare", "Spare", "Spare"], + ["retired", "Retired", "Retired"], + ["unknown", "Operational state unknown", "Unknown"], + ["empty", "Empty position", "Empty"], + ["conflict", "Location conflict", "Conflict"]] + : [["online", "Online marker", "Online"], + ["offline", "Offline marker", "Offline"], + ["unstable", "Hash unstable marker", "Unstable"], + ["repair", "In-repair marker", "Repair"], + ["unknown", "Unknown-state marker", "Unknown"], + ["empty", "Empty position", "Empty"]]; + el.mapLegend.replaceChildren(); + el.mapLegend.appendChild(create( + "strong", "legend-title", el.mapLayer.options[el.mapLayer.selectedIndex].text + )); + if (scale) { + const range = create("span", "relative-scale"); + if (scale.min === null || scale.max === null) { + range.textContent = "Relative range unavailable: no valid displayed readings"; + } else { + const low = metric(scale.min, scale.config.suffix); + const high = metric(scale.max, scale.config.suffix); + range.setAttribute( + "aria-label", + "Relative range across emphasized miners from " + low + " to " + high + ); + range.append( + create("span", "relative-scale-label", "Relative range (emphasized)"), + create("span", "relative-scale-value", low), + create("span", "relative-scale-track"), + create("span", "relative-scale-value", high) + ); + } + el.mapLegend.appendChild(range); + } + for (const entry of entries) { + appendLegendItem(entry[0], entry[1], entry[2]); + } + } + + function positionTile(position, scale) { + const condition = operational(position); + const tile = create(position.occupied && position.miner_id ? "button" : "div", "map-position"); + tile.dataset.status = condition.key; + tile.dataset.positionId = position.location_position_id; + tile.dataset.occupied = String(position.occupied); + tile.dataset.problem = String(isProblem(position)); + if (position.miner_id) tile.dataset.minerId = position.miner_id; + if (position.occupied && !matches(position)) tile.dataset.filtered = "true"; + if (state.problemsOnly && position.occupied && !isProblem(position)) { + tile.dataset.problemsMuted = "true"; + } + if (String(position.miner_id) === state.minerId) tile.dataset.selected = "true"; + const scaleEligible = position.occupied && matches(position) && + (!state.problemsOnly || isProblem(position)); + const relative = scale && scaleEligible + ? relativePercent(position[scale.config.field], scale) : null; + if (relative !== null) { + tile.dataset.measurement = "true"; + tile.style.setProperty("--relative-percent", relative.toFixed(2) + "%"); + } + if (tile.tagName === "BUTTON") { + tile.type = "button"; + tile.tabIndex = -1; + tile.setAttribute("aria-pressed", String(String(position.miner_id) === state.minerId)); + tile.setAttribute("aria-label", display(position.position_label, "Position " + position.position_index) + + ", " + identity(position) + ", " + condition.label + ", " + age(position.observed_at)); + } else { + tile.setAttribute("role", "group"); + tile.setAttribute("aria-label", display(position.position_label, "Position " + position.position_index) + + ", " + condition.label); + } + const head = create("span", "position-head"); + const marker = create("span", "status-marker", condition.mark); + marker.setAttribute("aria-hidden", "true"); + head.append(create("strong", "position-label", display(position.position_label, "Position " + position.position_index)), + marker); + const name = create("span", "position-miner", position.occupied ? identity(position) : condition.label); + tile.append(head, name); + if (position.occupied) { + tile.append( + create("span", "position-value", layerValue(position, condition)), + create("span", "position-freshness", state.minerDetailsIncluded + ? age(position.observed_at) : "Miner details restricted") + ); + } + return tile; + } + + function renderMap() { + syncSelectors(); + const positions = state.data.positions.filter(function (position) { + return String(position.room_id || "") === state.roomId; + }); + if (state.minerId && !state.data.positions.some(function (position) { + const visible = String(position.room_id || "") === state.roomId || !position.room_id; + return visible && position.miner_id && String(position.miner_id) === state.minerId; + })) state.minerId = ""; + syncPermissionControls(); + syncFilterOptions(); + const scale = measurementScale(positions); + legend(scale); + el.mapViewport.replaceChildren(); + if (!state.data.sites.length) { + el.mapNotice.hidden = false; + el.mapNotice.textContent = "No sites are configured. Use location management to create the physical hierarchy."; + } else if (!state.roomId) { + el.mapNotice.hidden = false; + el.mapNotice.textContent = "This site has no configured rooms."; + } else if (!positions.length) { + el.mapNotice.hidden = false; + el.mapNotice.textContent = "No positions are configured for this room."; + } else { + el.mapNotice.hidden = true; + } + const racks = new Map(); + for (const position of positions) { + const rack = display(position.rack_name, "Unlabelled rack"); + const shelf = display(position.shelf_name, "Unlabelled shelf"); + if (!racks.has(rack)) racks.set(rack, new Map()); + if (!racks.get(rack).has(shelf)) racks.get(rack).set(shelf, []); + racks.get(rack).get(shelf).push(position); + } + const fragment = document.createDocumentFragment(); + for (const rackEntry of racks) { + const rack = create("section", "map-rack"); + rack.setAttribute("aria-label", "Rack " + rackEntry[0]); + rack.appendChild(create("h2", "rack-title", "Rack " + rackEntry[0])); + for (const shelfEntry of rackEntry[1]) { + const shelf = create("section", "map-shelf"); + shelf.setAttribute("aria-label", shelfLabel(shelfEntry[0])); + shelf.appendChild(create("h3", "shelf-title", shelfLabel(shelfEntry[0]))); + const grid = create("div", "position-grid"); + for (const position of shelfEntry[1]) { + grid.appendChild(positionTile(position, scale)); + } + shelf.appendChild(grid); + rack.appendChild(shelf); + } + fragment.appendChild(rack); + } + el.mapViewport.appendChild(fragment); + renderSummary(positions); + renderLegacyPositions(); + renderUnplaced(); + renderInspector(); + syncTileTabStops(); + const count = [state.query, state.lifecycle, state.model, state.repair, + state.status, state.problemsOnly].filter(Boolean).length; + el.mapFilterCount.textContent = count ? "(" + count + " active)" : ""; + el.mapFreshness.textContent = "Map generated " + dateTime(state.meta.generated_at) + + (state.minerDetailsIncluded ? ". Telemetry age is shown per miner." + : ". Miner details are restricted for this account."); + } + + function renderSummary(positions) { + const facts = { occupied: 0, empty: 0, conflict: 0, offline: 0, + repair: 0, unknown: 0, restricted: 0, problems: 0 }; + for (const position of positions) { + const condition = operational(position); + if (position.occupied) facts.occupied += 1; + if (condition.key in facts) facts[condition.key] += 1; + if (isProblem(position)) facts.problems += 1; + } + el.mapSummary.replaceChildren(); + const pairs = [["Positions", positions.length], ["Occupied", facts.occupied], + ["Empty", facts.empty], ["Conflicts", facts.conflict]]; + if (state.minerDetailsIncluded) pairs.splice(3, 0, + ["Problems", facts.problems], ["Offline", facts.offline], + ["In repair", facts.repair], ["Unknown", facts.unknown]); + else pairs.splice(3, 0, ["Restricted", facts.restricted]); + for (const pair of pairs) { + const item = create("span", "summary-fact"); + item.append(create("strong", "", String(pair[1])), document.createTextNode(pair[0])); + el.mapSummary.appendChild(item); + } + } + + function renderLegacyPositions() { + const positions = state.data.positions.filter(function (position) { + return !position.room_id; + }); + el.mapLegacySection.hidden = !positions.length; + el.mapLegacyPositions.replaceChildren(); + for (const position of positions) { + const item = create("article", "legacy-position-entry"); + item.append(create("strong", "", display(position.position_label, + "Position " + position.position_index)), + create("span", "legacy-position-path", "Unscoped / Room label " + + display(position.room_name, "Unavailable") + " / Rack " + + display(position.rack_name, "Unavailable") + " / Shelf " + + display(position.shelf_name, "Unavailable")), + positionTile(position)); + el.mapLegacyPositions.appendChild(item); + } + } + + function renderUnplaced() { + const miners = state.data.virtual_miners.filter(function (miner) { + return (!state.siteId || String(miner.site_id || "") === state.siteId) && + (!state.roomId || !miner.room_id || String(miner.room_id) === state.roomId); + }); + el.mapUnplacedSection.hidden = !miners.length; + el.mapUnplaced.replaceChildren(); + for (const miner of miners) { + const item = create("div", "unplaced-miner"); + item.append(create("strong", "", display(miner.asset_id, display(miner.current_mac, "Unidentified miner"))), + create("span", "", words(miner.asset_state) + " / " + words(miner.lifecycle_status)), + create("span", "", miner.active_repair + ? "Active repair: " + words(miner.active_repair.status) + : "No active repair record")); + el.mapUnplaced.appendChild(item); + } + } + + function detailRow(term, value) { + el.mapInspectorDetails.append(create("dt", "", term), create("dd", "", display(value))); + } + + function renderInspector() { + const position = state.data.positions.find(function (item) { + return item.miner_id && String(item.miner_id) === state.minerId; + }); + if (!position) { + el.mapInspector.hidden = true; + return; + } + const condition = operational(position); + el.mapInspector.hidden = false; + el.mapInspectorTitle.textContent = identity(position); + el.mapInspectorStatus.textContent = condition.label + " / " + age(position.observed_at); + el.mapInspectorStatus.dataset.status = condition.key; + el.mapInspectorDetails.replaceChildren(); + detailRow("Asset ID", position.asset_id); + detailRow("Model", position.model || position.miner_type); + detailRow("Serial", position.miner_sn); + detailRow("Current MAC", position.current_mac); + detailRow("Current IP", position.current_ip); + const location = position.room_id + ? [position.site_name, position.room_name, "Rack " + position.rack_name, + shelfLabel(position.shelf_name), display(position.position_label, + "Position " + position.position_index)].join(" / ") + : ["Unscoped", "Room label " + display(position.room_name), + "Rack " + display(position.rack_name), shelfLabel(position.shelf_name), + display(position.position_label, "Position " + position.position_index)].join(" / "); + detailRow("Location", location); + detailRow("Lifecycle", words(position.lifecycle_status)); + detailRow("Runtime", words(position.miner_status)); + detailRow("Repair", position.active_repair + ? words(position.active_repair.status) + " / " + words(position.active_repair.reason) + : position.asset_state === "repair" ? "Legacy repair location state" : "No active repair"); + detailRow("Hashrate", metric(position.hashrate_ths, " TH/s")); + detailRow("Power", metric(position.power_w, " W")); + detailRow("Chip temperature", metric(position.chip_temp_max_c, " C max")); + detailRow("Observed", dateTime(position.observed_at)); + const returnTo = stateUrl(); + el.mapOpenMiner.href = "/miners/" + encodeURIComponent(position.miner_id) + + "/detail?return_to=" + encodeURIComponent(returnTo); + } + + function mapTiles() { + return Array.from(el.mapWorkspace.querySelectorAll("button.map-position[data-miner-id]")); + } + + function syncTileTabStops() { + const tiles = mapTiles(); + const current = tiles.find(function (tile) { + return tile.dataset.minerId === state.minerId; + }); + for (const tile of tiles) tile.tabIndex = tile === current ? 0 : -1; + if (!current && tiles.length) tiles[0].tabIndex = 0; + } + + function syncSelection() { + for (const tile of mapTiles()) { + const selected = tile.dataset.minerId === state.minerId; + tile.dataset.selected = String(selected); + tile.setAttribute("aria-pressed", String(selected)); + } + renderInspector(); + syncTileTabStops(); + } + + function bind() { + const controls = [el.mapLayer, el.mapSearch, el.mapLifecycle, el.mapModel, el.mapRepair, el.mapStatus]; + let renderFrame = null; + function scheduleRender() { + if (renderFrame !== null) window.cancelAnimationFrame(renderFrame); + renderFrame = window.requestAnimationFrame(function () { + renderFrame = null; + renderMap(); + setUrl("replace"); + }); + } + el.mapSite.addEventListener("change", function () { + state.siteId = el.mapSite.value; state.roomId = ""; state.minerId = ""; + renderMap(); setUrl("push"); + }); + el.mapRoom.addEventListener("change", function () { + state.roomId = el.mapRoom.value; state.minerId = ""; renderMap(); setUrl("push"); + }); + for (const control of controls) { + control.addEventListener(control === el.mapSearch ? "input" : "change", function () { + state.layer = el.mapLayer.value; state.query = el.mapSearch.value.trim(); + state.lifecycle = el.mapLifecycle.value; state.model = el.mapModel.value; + state.repair = el.mapRepair.value; state.status = el.mapStatus.value; + scheduleRender(); + }); + } + el.mapProblemsOnly.addEventListener("change", function () { + state.problemsOnly = el.mapProblemsOnly.checked; + scheduleRender(); + }); + el.mapClearFilters.addEventListener("click", function () { + state.query = state.lifecycle = state.model = state.repair = state.status = ""; + state.problemsOnly = false; + el.mapSearch.value = ""; el.mapRepair.value = ""; + el.mapProblemsOnly.checked = false; + renderMap(); setUrl("replace"); + }); + el.mapWorkspace.addEventListener("click", function (event) { + const tile = event.target.closest("button[data-miner-id]"); + if (!tile || !el.mapWorkspace.contains(tile)) return; + state.minerId = tile.dataset.minerId; + syncSelection(); setUrl("push"); + el.mapInspectorClose.focus(); + }); + el.mapWorkspace.addEventListener("keydown", function (event) { + if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return; + const tile = event.target.closest("button.map-position[data-miner-id]"); + if (!tile) return; + const tiles = mapTiles(); + const current = tiles.indexOf(tile); + const delta = ["ArrowLeft", "ArrowUp"].includes(event.key) ? -1 : 1; + const next = tiles[(current + delta + tiles.length) % tiles.length]; + event.preventDefault(); + for (const item of tiles) item.tabIndex = item === next ? 0 : -1; + next.focus(); + }); + el.mapInspectorClose.addEventListener("click", function () { + const restoreId = state.minerId; state.minerId = ""; + syncSelection(); setUrl("push"); + const restore = mapTiles().find(function (tile) { + return tile.dataset.minerId === restoreId; + }); + if (restore) { + for (const tile of mapTiles()) tile.tabIndex = -1; + restore.tabIndex = 0; + restore.focus(); + } + }); + el.mapRetry.addEventListener("click", load); + window.addEventListener("popstate", function () { + readUrl(); renderMap(); setUrl("replace"); + }); + } + + function validate(envelope) { + return envelope && envelope.data && envelope.meta && + Array.isArray(envelope.data.sites) && Array.isArray(envelope.data.rooms) && + Array.isArray(envelope.data.positions) && Array.isArray(envelope.data.virtual_miners) && + envelope.data.positions.every(function (item) { + return item && typeof item.location_position_id === "string" && + typeof item.position_index === "number"; + }); + } + + async function load() { + el.mapLoading.hidden = false; + el.mapCriticalState.hidden = true; + el.mapWorkspace.hidden = true; + const controller = new AbortController(); + const timer = window.setTimeout(function () { controller.abort(); }, 15000); + try { + const response = await fetch("/api/location/map", { + credentials: "same-origin", headers: { Accept: "application/json" }, + signal: controller.signal + }); + if (response.status === 401) { + window.location.replace("/login?next=" + encodeURIComponent(window.location.pathname + window.location.search)); + return; + } + if (response.status === 403) { + el.mapCriticalTitle.textContent = "Map access unavailable"; + el.mapCriticalDetail.textContent = "Your account cannot view location data."; + el.mapCriticalState.hidden = false; + return; + } + if (!response.ok) throw new Error("The map service returned " + response.status + "."); + const envelope = await response.json(); + if (!validate(envelope)) throw new Error("The map response was incomplete."); + state.data = envelope.data; state.meta = envelope.meta; + state.minerDetailsIncluded = envelope.meta.miner_details_included === true; + readUrl(); syncSelectors(); + el.mapWorkspace.hidden = false; + renderMap(); setUrl("replace"); + } catch (error) { + el.mapCriticalTitle.textContent = "Map unavailable"; + el.mapCriticalDetail.textContent = error && error.name === "AbortError" + ? "MinerOps took too long to respond. Retry in a moment." + : display(error && error.message, "The map could not be loaded."); + el.mapCriticalState.hidden = false; + } finally { + window.clearTimeout(timer); el.mapLoading.hidden = true; + } + } + + bind(); + load(); +}()); diff --git a/backend/app/frontend/static/miner-detail.js b/backend/app/frontend/static/miner-detail.js new file mode 100644 index 0000000..f128d8f --- /dev/null +++ b/backend/app/frontend/static/miner-detail.js @@ -0,0 +1,425 @@ +(function () { + "use strict"; + + if (document.body.dataset.view !== "miner_detail") return; + + const minerId = document.body.dataset.minerId; + const returnTo = document.body.dataset.returnTo || ""; + const el = { + loading: document.getElementById("minerDetailLoading"), + error: document.getElementById("minerDetailError"), + errorText: document.getElementById("minerDetailErrorText"), + content: document.getElementById("minerDetailContent"), + retry: document.getElementById("minerDetailRetry"), + refresh: document.getElementById("minerDetailRefresh"), + title: document.getElementById("minerIdentityTitle"), + model: document.getElementById("minerDetailModel"), + uuid: document.getElementById("minerDetailUuid"), + operational: document.getElementById("minerDetailOperational"), + lifecycle: document.getElementById("minerDetailLifecycle"), + locationSummary: document.getElementById("minerDetailLocationSummary"), + returnLink: document.getElementById("minerDetailReturn"), + mapLink: document.getElementById("minerDetailMap"), + trendsLink: document.getElementById("minerDetailTrends"), + repairsLink: document.getElementById("minerDetailRepairs"), + identity: document.getElementById("minerIdentityDetails"), + tags: document.getElementById("minerTags"), + telemetry: document.getElementById("minerTelemetryDetails"), + telemetryAge: document.getElementById("minerTelemetryAge"), + location: document.getElementById("minerLocationBody"), + repair: document.getElementById("minerRepairBody"), + episodes: document.getElementById("minerRepairEpisodes"), + identityHistory: document.getElementById("minerIdentityHistory"), + identityHistoryBody: document.getElementById("minerIdentityHistoryBody"), + observations: document.getElementById("minerObservations"), + observationsBody: document.getElementById("minerObservationsBody") + }; + + function create(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function display(value, fallback) { + if (value === null || value === undefined || value === "") { + return fallback === undefined ? "Unavailable" : fallback; + } + return String(value); + } + + function words(value) { + const text = display(value, "unknown").replaceAll("_", " ").toLowerCase(); + return text.charAt(0).toUpperCase() + text.slice(1); + } + + function dateTime(value) { + if (!value) return "Unavailable"; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? "Unavailable" : parsed.toLocaleString(); + } + + function age(value) { + if (!value) return "No observation available"; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return "Observation time unavailable"; + const seconds = Math.max(0, Math.floor((Date.now() - parsed.getTime()) / 1000)); + if (seconds < 60) return seconds + " seconds ago"; + if (seconds < 3600) return Math.floor(seconds / 60) + " minutes ago"; + if (seconds < 86400) return Math.floor(seconds / 3600) + " hours ago"; + return Math.floor(seconds / 86400) + " days ago"; + } + + function metric(value, suffix) { + if (value === null || value === undefined || value === "") return "Unavailable"; + const number = Number(value); + if (!Number.isFinite(number)) return "Unavailable"; + return String(number) + suffix; + } + + function addDefinition(list, label, value) { + const row = create("div"); + row.append(create("dt", "", label), create("dd", "", display(value))); + list.appendChild(row); + } + + function prefixed(value, prefix) { + const label = display(value, ""); + if (!label) return ""; + return new RegExp("^" + prefix + "\\b", "i").test(label) + ? label : prefix + " " + label; + } + + function locationText(location) { + if (!location) return "Not mapped"; + const parts = [location.site_name, location.room_name, + prefixed(location.rack_label, "Rack"), + prefixed(location.shelf_label, "Shelf"), + location.position_label || (location.position_index === null + ? "Position unavailable" : "Position " + location.position_index)]; + return parts.filter(Boolean).join(" / "); + } + + async function api(path) { + const response = await fetch(path, { + credentials: "same-origin", + headers: { Accept: "application/json" } + }); + if (response.status === 401) { + window.location.assign("/login?next=" + encodeURIComponent( + window.location.pathname + window.location.search + )); + throw new Error("Login required"); + } + let payload = null; + try { payload = await response.json(); } catch (_error) { payload = null; } + if (!response.ok) { + throw new Error(payload && payload.detail + ? String(payload.detail) : "Request failed (" + response.status + ")"); + } + return payload ? payload.data : null; + } + + function table(headers, rows) { + const wrap = create("div", "table-wrap compact-history-table"); + const tableNode = create("table"); + const head = create("thead"); + const headRow = create("tr"); + headers.forEach(function (header) { headRow.appendChild(create("th", "", header)); }); + head.appendChild(headRow); + const body = create("tbody"); + rows.forEach(function (values) { + const row = create("tr"); + values.forEach(function (value) { row.appendChild(create("td", "", display(value))); }); + body.appendChild(row); + }); + tableNode.append(head, body); + wrap.appendChild(tableNode); + return wrap; + } + + function renderIdentity(miner) { + const identity = miner.asset_id || miner.current_mac || "Miner " + miner.id.slice(0, 8); + el.title.textContent = identity; + el.model.textContent = display(miner.model, "Model unavailable"); + el.uuid.textContent = "Miner UUID: " + miner.id; + const pageTitle = document.querySelector(".page-head h1"); + if (pageTitle) pageTitle.textContent = identity; + document.title = identity + " | MinerOps"; + el.identity.replaceChildren(); + [ + ["Asset ID", miner.asset_id], ["Model", miner.model], + ["Miner serial", miner.miner_sn], ["Control-board serial", miner.control_board_sn], + ["PSU serial", miner.psu_serial], ["Current MAC", miner.current_mac], + ["Current IP", miner.current_ip], ["Hostname", miner.hostname], + ["Firmware", miner.firmware_version], ["Created", dateTime(miner.created_at)], + ["First seen", dateTime(miner.first_seen_at)], ["Last seen", dateTime(miner.last_seen_at)] + ].forEach(function (item) { addDefinition(el.identity, item[0], item[1]); }); + el.tags.replaceChildren(); + (miner.tags || []).forEach(function (tag) { + el.tags.appendChild(create("span", "tag", tag.name)); + }); + } + + function renderTelemetry(miner) { + el.telemetry.replaceChildren(); + [ + ["Runtime", words(miner.runtime_status)], + ["Observed", dateTime(miner.observed_at)], + ["Hashrate", metric(miner.hashrate_ths, " TH/s")], + ["Power", metric(miner.power_w, " W")], + ["Max chip temperature", metric(miner.chip_temp_max_c, " C")], + ["Hash stability", miner.hash_stable === null || miner.hash_stable === undefined + ? "Unavailable" : miner.hash_stable ? "Stable" : "Unstable"] + ].forEach(function (item) { addDefinition(el.telemetry, item[0], item[1]); }); + el.telemetryAge.textContent = age(miner.observed_at); + } + + function renderLocation(section, minerIdValue, canViewMap) { + el.location.replaceChildren(); + if (section.restricted) { + el.location.appendChild(create("p", "permission-note", "Location information restricted.")); + el.locationSummary.textContent = "Location restricted"; + return; + } + if (!section.location) { + el.location.appendChild(create("p", "empty-copy", "Not mapped")); + el.locationSummary.textContent = "Not mapped"; + return; + } + const location = section.location; + const list = create("dl", "definition-list"); + [["Site", location.site_name], ["Room", location.room_name], + ["Rack", prefixed(location.rack_label, "Rack")], + ["Shelf", prefixed(location.shelf_label, "Shelf")], + ["Position", location.position_label || (location.position_index === null + ? null : "Position " + location.position_index)] + ].forEach(function (item) { addDefinition(list, item[0], item[1]); }); + el.location.appendChild(list); + const summary = locationText(location); + el.locationSummary.textContent = summary; + if (canViewMap) { + const params = new URLSearchParams({ miner_id: minerIdValue }); + params.set("site_id", location.site_id); + params.set("room_id", location.room_id); + el.mapLink.href = "/monitoring/map?" + params.toString(); + el.mapLink.hidden = false; + } + } + + function eventPayload(payload) { + const list = create("dl", "definition-list event-payload"); + Object.entries(payload || {}).forEach(function (entry) { + const value = typeof entry[1] === "object" + ? JSON.stringify(entry[1]) : entry[1]; + addDefinition(list, words(entry[0]), value); + }); + return list; + } + + function renderRepairDetails(target, timeline, parts, canReadComponents) { + target.replaceChildren(); + const events = timeline && timeline.events ? timeline.events : []; + const heading = create("h4", "", "Timeline"); + const timelineList = create("ol", "repair-timeline-list"); + events.forEach(function (event) { + const item = create("li"); + item.append(create("strong", "", words(event.event_type)), + create("span", "secondary", dateTime(event.occurred_at) + " / " + + display(event.actor && event.actor.display_name, "Unknown actor")), + eventPayload(event.payload)); + timelineList.appendChild(item); + }); + if (!events.length) timelineList.appendChild(create("li", "empty-copy", "No timeline events recorded.")); + target.append(heading, timelineList, create("h4", "", "Parts and components")); + const entries = parts && parts.entries ? parts.entries : []; + if (!entries.length) { + target.appendChild(create("p", "empty-copy", "No parts recorded for this repair.")); + return; + } + const rows = entries.map(function (entry) { + let description = "Tracked component"; + if (entry.consumable) { + description = entry.consumable.description + " / " + + entry.consumable.quantity + " " + entry.consumable.unit; + } else if (entry.component_snapshot) { + const snapshot = entry.component_snapshot; + description = [words(snapshot.component_type), snapshot.asset_id, + snapshot.manufacturer, snapshot.model, snapshot.manufacturer_serial] + .filter(Boolean).join(" / "); + if (canReadComponents && entry.component_id) { + description += " / Component UUID " + entry.component_id; + } + } + return [description, entry.notes, dateTime(entry.recorded_at), + entry.voided_at ? "Voided: " + display(entry.void_reason) : "Recorded"]; + }); + target.appendChild(table(["Part or component", "Notes", "Recorded", "State"], rows)); + } + + function repairEpisode(episode, canReadComponents) { + const details = create("details"); + const summary = create("summary", "repair-episode-summary"); + summary.append(create("strong", "", (episode.source === "legacy" ? "Legacy compatibility" : words(episode.status))), + create("span", "secondary", [words(episode.reason), dateTime(episode.opened_at)] + .filter(function (value) { return value !== "Unavailable"; }).join(" / "))); + details.appendChild(summary); + const overview = create("dl", "definition-list"); + [["Status", words(episode.status)], ["Reason", words(episode.reason)], + ["Opened", dateTime(episode.opened_at)], ["Closed", dateTime(episode.closed_at)], + ["Latest context", episode.latest_context], ["Parts", episode.parts_count] + ].forEach(function (item) { addDefinition(overview, item[0], item[1]); }); + details.appendChild(overview); + if (episode.source === "modular" && episode.repair_id) { + const lazy = create("div", "lazy-content", "Open to load timeline and parts."); + details.appendChild(lazy); + details.addEventListener("toggle", async function () { + if (!details.open || details.dataset.loaded || details.dataset.loading) return; + details.dataset.loading = "true"; + lazy.textContent = "Loading repair timeline and parts..."; + try { + const results = await Promise.all([ + api("/api/repairs/" + encodeURIComponent(episode.repair_id) + "/events"), + api("/api/repairs/" + encodeURIComponent(episode.repair_id) + "/parts") + ]); + renderRepairDetails(lazy, results[0], results[1], canReadComponents); + details.dataset.loaded = "true"; + } catch (error) { + lazy.textContent = "Repair history unavailable: " + error.message; + } finally { delete details.dataset.loading; } + }); + } else { + details.appendChild(create("p", "permission-note", + "Legacy compatibility context has no modular repair timeline.")); + } + return details; + } + + function renderRepair(section, capabilities) { + el.repair.replaceChildren(); + el.episodes.replaceChildren(); + if (section.restricted) { + el.repair.appendChild(create("p", "permission-note", "Repair information restricted.")); + return; + } + if (section.active_repair) { + const active = section.active_repair; + const list = create("dl", "definition-list"); + [["Status", words(active.status)], ["Reason", words(active.reason)], + ["Opened", dateTime(active.opened_at)], ["Source", "Modular repair workflow"], + ["Latest context", active.latest_context], ["Parts", active.parts_count] + ].forEach(function (item) { addDefinition(list, item[0], item[1]); }); + el.repair.appendChild(list); + } else if ((section.episodes || []).some(function (item) { return item.source === "legacy" && item.active; })) { + el.repair.appendChild(create("p", "permission-note", "Legacy compatibility repair context is recorded. No modular repair UUID exists.")); + } else { + el.repair.appendChild(create("p", "empty-copy", "No active repair")); + } + (section.episodes || []).forEach(function (episode) { + el.episodes.appendChild(repairEpisode(episode, capabilities.can_read_components)); + }); + } + + async function loadIdentityHistory() { + if (el.identityHistory.dataset.loaded || el.identityHistory.dataset.loading) return; + el.identityHistory.dataset.loading = "true"; + el.identityHistoryBody.textContent = "Loading identity history..."; + try { + const results = await Promise.all([ + api("/api/inventory/miners/" + encodeURIComponent(minerId) + "/asset-id-history"), + api("/api/miners/" + encodeURIComponent(minerId) + "/network-identities") + ]); + el.identityHistoryBody.replaceChildren(create("h3", "", "Asset ID history")); + const assetRows = (results[0] || []).map(function (entry) { + return [entry.old_asset_id, entry.new_asset_id, dateTime(entry.changed_at), + entry.changed_by_display_name]; + }); + el.identityHistoryBody.appendChild(assetRows.length + ? table(["Previous", "New", "Changed", "Actor"], assetRows) + : create("p", "empty-copy", "No Asset ID changes recorded.")); + el.identityHistoryBody.appendChild(create("h3", "", "Network identity history")); + const networkRows = (results[1] || []).map(function (entry) { + return [entry.mac_address, entry.ip_address, entry.hostname, + entry.is_current ? "Current" : "Historical", dateTime(entry.last_seen_at)]; + }); + el.identityHistoryBody.appendChild(networkRows.length + ? table(["MAC", "IP", "Hostname", "State", "Last seen"], networkRows) + : create("p", "empty-copy", "No network identity history recorded.")); + el.identityHistory.dataset.loaded = "true"; + } catch (error) { + el.identityHistoryBody.textContent = "Identity history unavailable: " + error.message; + } finally { delete el.identityHistory.dataset.loading; } + } + + async function loadObservations() { + if (el.observations.dataset.loaded || el.observations.dataset.loading) return; + el.observations.dataset.loading = "true"; + el.observationsBody.textContent = "Loading recent observations..."; + try { + const observations = await api("/api/miners/" + encodeURIComponent(minerId) + "/status-snapshots"); + const rows = (observations || []).map(function (item) { + return [dateTime(item.observed_at), metric(item.mhs_av === null ? null : Number(item.mhs_av) / 1000000, " TH/s"), + metric(item.power_w, " W"), metric(item.chip_temp_max_c, " C"), + item.hash_stable === null || item.hash_stable === undefined + ? "Unavailable" : item.hash_stable ? "Stable" : "Unstable"]; + }); + el.observationsBody.replaceChildren(rows.length + ? table(["Observed", "Hashrate", "Power", "Max chip temp", "Hash stability"], rows) + : create("p", "empty-copy", "No observations recorded.")); + el.observations.dataset.loaded = "true"; + } catch (error) { + el.observationsBody.textContent = "Recent observations unavailable: " + error.message; + } finally { delete el.observations.dataset.loading; } + } + + function renderOverview(data) { + renderIdentity(data.miner); + renderTelemetry(data.miner); + el.operational.textContent = words(data.operational_state); + el.operational.dataset.status = data.operational_state; + el.lifecycle.textContent = "Lifecycle: " + words(data.miner.lifecycle_status); + renderLocation(data.location, data.miner.id, data.capabilities.can_view_map); + renderRepair(data.repair, data.capabilities); + el.trendsLink.href = "/monitoring/analytics?" + new URLSearchParams({ + scope: "miner", scope_id: data.miner.id, range: "24h" + }).toString(); + if (data.capabilities.can_open_repair_center) { + const params = new URLSearchParams({ scope: "all", q: data.miner.id }); + el.repairsLink.href = "/operations/repairs?" + params.toString(); + el.repairsLink.hidden = false; + } + if (returnTo) { + el.returnLink.href = returnTo; + el.returnLink.textContent = returnTo.startsWith("/monitoring/map") + ? "Return to Mine Map" : "Return to previous view"; + el.returnLink.hidden = false; + } + } + + async function loadOverview() { + el.loading.hidden = false; + el.error.hidden = true; + el.content.hidden = true; + try { + const data = await api("/api/miners/" + encodeURIComponent(minerId) + "/overview"); + renderOverview(data); + el.loading.hidden = true; + el.content.hidden = false; + } catch (error) { + el.loading.hidden = true; + el.error.hidden = false; + el.errorText.textContent = error.message; + } + } + + el.retry.addEventListener("click", loadOverview); + el.refresh.addEventListener("click", loadOverview); + el.identityHistory.addEventListener("toggle", function () { + if (el.identityHistory.open) loadIdentityHistory(); + }); + el.observations.addEventListener("toggle", function () { + if (el.observations.open) loadObservations(); + }); + loadOverview(); +})(); diff --git a/backend/app/frontend/static/monitoring-trends.css b/backend/app/frontend/static/monitoring-trends.css new file mode 100644 index 0000000..0638d08 --- /dev/null +++ b/backend/app/frontend/static/monitoring-trends.css @@ -0,0 +1,54 @@ +.trend-controls form { + display: grid; + grid-template-columns: minmax(140px, 0.7fr) minmax(160px, 1fr) + minmax(160px, 1fr) auto auto; + gap: 12px; + align-items: end; +} + +.trend-controls form > label, +.trend-ranges { + display: grid; + gap: 5px; + margin: 0; +} + +.trend-ranges { + grid-auto-flow: column; + justify-content: start; + border: 0; + padding: 0; +} + +.trend-ranges legend { margin-bottom: 5px; font-weight: 700; } +.trend-ranges label { display: flex; gap: 6px; align-items: center; white-space: nowrap; } +.trend-summary { display: flex; flex-wrap: wrap; gap: 8px 24px; margin: 0 0 14px; } +.trend-summary p { margin: 0; } +.trend-charts { display: grid; gap: 16px; } +.trend-panel { min-width: 0; } +.trend-chart { width: 100%; min-height: 220px; overflow: hidden; } +.trend-chart svg { display: block; width: 100%; height: auto; min-height: 220px; } +.trend-grid { stroke: #cbd5e1; stroke-width: 1; } +.trend-line { fill: none; stroke: #c65312; stroke-width: 2.5; stroke-linejoin: round; } +.trend-point { fill: #ffffff; stroke: #0f172a; stroke-width: 2; } +.trend-point:focus { outline: none; stroke: #2563eb; stroke-width: 5; } +.trend-axis-label { fill: #475569; font-size: 11px; } +.trend-disclosure { color: var(--muted); font-size: 13px; } +.trend-data-table { margin-top: 12px; } +.trend-data-table summary { cursor: pointer; font-weight: 700; } + +@media (max-width: 900px) { + .trend-controls form { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .trend-ranges { grid-column: 1 / -1; } +} + +@media (max-width: 560px) { + .trend-controls form { grid-template-columns: 1fr; } + .trend-ranges { grid-column: auto; grid-auto-flow: row; } + .trend-chart { min-height: 180px; } + .trend-chart svg { min-height: 180px; } +} + +@media (prefers-reduced-motion: reduce) { + .trend-line, .trend-point { transition: none; animation: none; } +} diff --git a/backend/app/frontend/static/monitoring-trends.js b/backend/app/frontend/static/monitoring-trends.js new file mode 100644 index 0000000..8bbd04a --- /dev/null +++ b/backend/app/frontend/static/monitoring-trends.js @@ -0,0 +1,344 @@ +(function () { + "use strict"; + + if (document.body.dataset.view !== "analytics") return; + + const canViewLocations = document.body.dataset.canViewLocations === "true"; + const allowedScopes = new Set(["fleet", "site", "room", "miner"]); + const allowedRanges = new Set(["6h", "24h", "7d"]); + const params = new URLSearchParams(window.location.search); + const initialScope = allowedScopes.has(params.get("scope")) + ? params.get("scope") : "fleet"; + const state = { + scope: initialScope, + scopeId: params.get("scope_id") || "", + minerId: initialScope === "miner" ? (params.get("scope_id") || "") : "", + range: allowedRanges.has(params.get("range")) ? params.get("range") : "24h", + available: { sites: [], rooms: [] }, + controller: null + }; + const el = { + form: document.getElementById("trendControls"), + scope: document.getElementById("trendScope"), + site: document.getElementById("trendSite"), + siteLabel: document.getElementById("trendSiteLabel"), + room: document.getElementById("trendRoom"), + roomLabel: document.getElementById("trendRoomLabel"), + loading: document.getElementById("trendLoading"), + error: document.getElementById("trendError"), + errorText: document.getElementById("trendErrorText"), + retry: document.getElementById("trendRetry"), + content: document.getElementById("trendContent"), + freshness: document.getElementById("trendFreshness"), + scopeNote: document.getElementById("trendScopeNote"), + scopeLabel: document.getElementById("trendScopeLabel"), + rangeSummary: document.getElementById("trendRangeSummary"), + timezone: document.getElementById("trendTimezone"), + charts: document.getElementById("trendCharts") + }; + + function create(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function svgNode(tag, attributes) { + const node = document.createElementNS("http://www.w3.org/2000/svg", tag); + Object.entries(attributes || {}).forEach(function (entry) { + node.setAttribute(entry[0], String(entry[1])); + }); + return node; + } + + function dateTime(value) { + if (!value) return "Unavailable"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "Unavailable" : date.toLocaleString(); + } + + function compactTime(value) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "Unavailable" : date.toLocaleString([], { + month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" + }); + } + + function number(value, unit) { + if (value === null || value === undefined || !Number.isFinite(Number(value))) { + return "Unavailable"; + } + return Number(value).toLocaleString([], { maximumFractionDigits: 2 }) + " " + unit; + } + + async function api(url, signal) { + const response = await fetch(url, { + headers: { Accept: "application/json" }, signal: signal + }); + const payload = await response.json().catch(function () { return {}; }); + if (!response.ok) { + const detail = payload.detail; + throw new Error(typeof detail === "string" ? detail : "Request failed (" + response.status + ")"); + } + if (!payload || !payload.data || !payload.meta) { + throw new Error("The trends response was incomplete."); + } + return payload; + } + + function replaceOptions(select, options, selected, label) { + select.replaceChildren(); + if (!options.length) { + const empty = create("option", "", "No " + label + " available"); + empty.value = ""; + select.appendChild(empty); + return; + } + options.forEach(function (item) { + const option = create("option", "", item.name); + option.value = item.id; + option.selected = String(item.id) === String(selected); + select.appendChild(option); + }); + } + + function ensureMinerScopeOption() { + if (state.scope !== "miner" || el.scope.querySelector('option[value="miner"]')) return; + const option = create("option", "", "Miner"); + option.value = "miner"; + el.scope.appendChild(option); + } + + function renderScopeControls() { + ensureMinerScopeOption(); + el.scope.value = state.scope; + const selectedSite = state.scope === "site" ? state.scopeId : el.site.value; + let rooms = state.available.rooms || []; + if (state.scope === "room") { + const selectedRoom = rooms.find(function (room) { + return String(room.id) === String(state.scopeId); + }); + if (selectedRoom) replaceOptions(el.site, state.available.sites, selectedRoom.site_id, "sites"); + } else { + replaceOptions(el.site, state.available.sites, selectedSite, "sites"); + } + if (el.site.value) { + rooms = rooms.filter(function (room) { + return String(room.site_id) === String(el.site.value); + }); + } + replaceOptions(el.room, rooms, state.scope === "room" ? state.scopeId : "", "rooms"); + el.siteLabel.hidden = !canViewLocations || !["site", "room"].includes(state.scope); + el.roomLabel.hidden = !canViewLocations || state.scope !== "room"; + const checked = el.form.querySelector('input[name="trendRange"][value="' + state.range + '"]'); + if (checked) checked.checked = true; + } + + function pointStatus(point) { + if (point.observation_count === 0) return "No observations"; + if (point.metric_sample_count === 0) return "Metric unavailable"; + return point.incomplete ? "Recorded; current bucket incomplete" : "Recorded"; + } + + function renderTable(series, tableId) { + const details = create("details", "trend-data-table"); + details.appendChild(create("summary", "", "View " + series.metric + " data table")); + const wrapper = create("div", "table-scroll"); + const table = create("table"); + table.id = tableId; + const caption = create("caption", "", series.label + " bucket values and coverage"); + const head = create("thead"); + const headRow = create("tr"); + ["Bucket start", "Value", "Status", "Samples", "Contributors", "Observed miners", "Scoped miners"] + .forEach(function (label) { headRow.appendChild(create("th", "", label)); }); + head.appendChild(headRow); + const body = create("tbody"); + series.points.forEach(function (point) { + const row = create("tr"); + [dateTime(point.bucket_start), number(point.value, series.unit), pointStatus(point), + point.metric_sample_count, point.contributing_miner_count, + point.observed_miner_count, point.scoped_miner_count] + .forEach(function (value) { row.appendChild(create("td", "", String(value))); }); + body.appendChild(row); + }); + table.append(caption, head, body); + wrapper.appendChild(table); + details.appendChild(wrapper); + return details; + } + + function renderChart(series, index) { + const panel = create("section", "panel trend-panel"); + const headingId = "trendSeriesTitle" + index; + const title = create("h2", "", series.label); + title.id = headingId; + const valid = series.points.filter(function (point) { + return point.value !== null && Number.isFinite(Number(point.value)); + }); + const rangeCopy = valid.length + ? "Observed range " + number(Math.min.apply(null, valid.map(function (p) { return Number(p.value); })), series.unit) + + " to " + number(Math.max.apply(null, valid.map(function (p) { return Number(p.value); })), series.unit) + : "No valid values in this range"; + const header = create("div", "panel-head"); + const heading = create("div"); + heading.append(create("p", "eyebrow", series.metric === "hashrate" ? "TH/s" : "Watts"), title); + header.append(heading, create("p", "freshness", rangeCopy)); + panel.appendChild(header); + + const chart = create("div", "trend-chart"); + if (!valid.length) { + chart.appendChild(create("p", "empty-copy", "No valid " + series.metric + " observations were recorded.")); + } else { + const width = 800; + const height = 250; + const left = 72; + const right = 20; + const top = 20; + const bottom = 48; + const plotWidth = width - left - right; + const plotHeight = height - top - bottom; + let minimum = Math.min.apply(null, valid.map(function (point) { return Number(point.value); })); + let maximum = Math.max.apply(null, valid.map(function (point) { return Number(point.value); })); + if (minimum === maximum) { + const padding = Math.max(Math.abs(minimum) * 0.05, 1); + minimum -= padding; + maximum += padding; + } + const svg = svgNode("svg", { + viewBox: "0 0 " + width + " " + height, + role: "img", + "aria-labelledby": headingId + " chartDescription" + index + }); + const description = svgNode("desc", { id: "chartDescription" + index }); + description.textContent = series.label + " in " + series.unit + ". Gaps represent missing values."; + svg.appendChild(description); + [0, 0.5, 1].forEach(function (ratio) { + const y = top + ratio * plotHeight; + svg.appendChild(svgNode("line", { x1: left, x2: width - right, y1: y, y2: y, class: "trend-grid" })); + const label = svgNode("text", { x: left - 8, y: y + 4, "text-anchor": "end", class: "trend-axis-label" }); + label.textContent = number(maximum - ratio * (maximum - minimum), series.unit); + svg.appendChild(label); + }); + let pathData = ""; + series.points.forEach(function (point, pointIndex) { + const x = left + (series.points.length === 1 ? 0 : pointIndex / (series.points.length - 1)) * plotWidth; + if (point.value === null || !Number.isFinite(Number(point.value))) { + pathData += " M " + x + " " + (top + plotHeight); + return; + } + const y = top + (maximum - Number(point.value)) / (maximum - minimum) * plotHeight; + const previous = pointIndex > 0 ? series.points[pointIndex - 1] : null; + const command = previous && previous.value !== null && Number.isFinite(Number(previous.value)) ? " L " : " M "; + pathData += command + x + " " + y; + const marker = svgNode("circle", { + cx: x, cy: y, r: 3.5, class: "trend-point", tabindex: 0, + "aria-label": compactTime(point.bucket_start) + ", " + number(point.value, series.unit) + + ", " + pointStatus(point) + }); + const markerTitle = svgNode("title"); + markerTitle.textContent = marker.getAttribute("aria-label"); + marker.appendChild(markerTitle); + svg.appendChild(marker); + }); + const path = svgNode("path", { d: pathData, class: "trend-line" }); + svg.insertBefore(path, svg.querySelector("circle")); + const startLabel = svgNode("text", { x: left, y: height - 12, class: "trend-axis-label" }); + startLabel.textContent = compactTime(series.points[0].bucket_start); + const endLabel = svgNode("text", { x: width - right, y: height - 12, "text-anchor": "end", class: "trend-axis-label" }); + endLabel.textContent = compactTime(series.points[series.points.length - 1].bucket_start); + svg.append(startLabel, endLabel); + chart.appendChild(svg); + } + panel.append(chart, create("p", "trend-disclosure", "Gaps indicate no valid metric value. Recorded zero remains zero; values are not interpolated."), + renderTable(series, "trendTable" + index)); + return panel; + } + + function render(payload) { + state.available = payload.data.available_scopes || { sites: [], rooms: [] }; + renderScopeControls(); + el.scopeLabel.textContent = payload.data.scope.label; + el.rangeSummary.textContent = payload.meta.range + " range / " + + Math.round(payload.meta.bucket_seconds / 60) + " minute buckets / raw snapshots"; + const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || "browser local time"; + el.timezone.textContent = "Times shown in " + zone + ". API timestamps use UTC."; + el.freshness.textContent = payload.meta.latest_observed_at + ? "Latest observation in range: " + dateTime(payload.meta.latest_observed_at) + : "No observations in selected range"; + el.scopeNote.textContent = payload.meta.attribution === "current_placement_cohort" + ? "Site and room trends use miners currently assigned to this location. Historical placement is not tracked." + : payload.meta.attribution === "miner_identity" + ? "Trend values follow this miner's durable UUID." + : "Fleet trends use the current miner inventory cohort."; + el.charts.replaceChildren(); + payload.data.series.forEach(function (series, index) { + el.charts.appendChild(renderChart(series, index)); + }); + } + + function selectedScopeId() { + if (state.scope === "fleet") return ""; + if (state.scope === "site") return el.site.value || state.scopeId; + if (state.scope === "room") return el.room.value || state.scopeId; + return state.minerId || state.scopeId; + } + + function requestUrl() { + const query = new URLSearchParams({ scope: state.scope, range: state.range }); + const scopeId = selectedScopeId(); + if (state.scope !== "fleet") query.set("scope_id", scopeId); + return "/api/monitoring/trends?" + query.toString(); + } + + async function load() { + const scopeId = selectedScopeId(); + if (state.scope !== "fleet" && !scopeId) { + el.error.hidden = false; + el.errorText.textContent = "Choose a valid scope before applying filters."; + return; + } + if (state.controller) state.controller.abort(); + state.controller = new AbortController(); + el.loading.hidden = false; + el.error.hidden = true; + try { + const payload = await api(requestUrl(), state.controller.signal); + state.scopeId = payload.data.scope.id || ""; + if (state.scope === "miner") state.minerId = state.scopeId; + render(payload); + const url = new URL(window.location.href); + url.search = new URL(requestUrl(), window.location.origin).search; + window.history.replaceState({}, "", url.pathname + url.search); + el.content.hidden = false; + } catch (error) { + if (error.name === "AbortError") return; + el.errorText.textContent = error.message; + el.error.hidden = false; + } finally { + el.loading.hidden = true; + } + } + + el.scope.addEventListener("change", function () { + state.scope = el.scope.value; + if (state.scope !== "miner") state.scopeId = ""; + renderScopeControls(); + }); + el.site.addEventListener("change", function () { + if (state.scope === "site") state.scopeId = el.site.value; + if (state.scope === "room") state.scopeId = ""; + renderScopeControls(); + }); + el.form.addEventListener("submit", function (event) { + event.preventDefault(); + state.scope = el.scope.value; + const selectedRange = el.form.querySelector('input[name="trendRange"]:checked'); + state.range = selectedRange ? selectedRange.value : "24h"; + state.scopeId = selectedScopeId(); + load(); + }); + el.retry.addEventListener("click", load); + renderScopeControls(); + load(); +})(); diff --git a/backend/app/frontend/static/repair-center.js b/backend/app/frontend/static/repair-center.js new file mode 100644 index 0000000..4ba75c8 --- /dev/null +++ b/backend/app/frontend/static/repair-center.js @@ -0,0 +1,612 @@ +(function () { + "use strict"; + + const optionalFieldIds = ["repairModel", "repairSite", "repairRoom"]; + const ids = [ + "repairFilters", "repairQuery", "repairScope", "repairStatus", + "repairLifecycle", "repairReset", "repairRows", + "repairTotal", "repairActive", "repairHistorical", "repairFreshness", + "repairResultSummary", "repairPrevious", "repairNext", "repairLimit", + "repairInspector", "repairInspectorTitle", "repairInspectorBody", + "repairInspectorClose" + ]; + const el = {}; + for (const id of ids) el[id] = document.getElementById(id); + for (const id of optionalFieldIds) el[id] = document.getElementById(id); + if (!el.repairFilters || !el.repairRows) return; + + const STATUS_LABELS = { + OPEN: "Open", IN_PROGRESS: "In progress", + WAITING_FOR_PARTS: "Waiting for parts", READY_FOR_TEST: "Ready for test", + COMPLETED: "Completed", CANCELLED: "Cancelled", + open: "Open", diagnosing: "Diagnosing", waiting_parts: "Waiting parts", + in_progress: "In progress", testing: "Testing", + ready_to_shelf: "Ready to shelf", needs_followup: "Needs follow-up", + completed: "Completed", failed: "Failed", cancelled: "Cancelled", + repair: "In repair", unknown: "Unknown" + }; + const state = { offset: 0, limit: 50, items: new Map(), rooms: [], opener: null }; + let controller = null; + + function text(value, fallback) { + if (value === null || value === undefined || String(value).trim() === "") { + return fallback || "Not available"; + } + return String(value); + } + + function create(tag, className, value) { + const node = document.createElement(tag); + if (className) node.className = className; + if (value !== undefined) node.textContent = value; + return node; + } + + function button(label, action, className) { + const node = create("button", className || "quiet-button", label); + node.type = "button"; + node.dataset.action = action; + return node; + } + + function showState(name, message) { + for (const node of document.querySelectorAll("[data-view-state]")) { + node.hidden = node.dataset.viewState !== name; + } + const active = document.querySelector('[data-view-state="' + name + '"]'); + const detail = active && active.querySelector("[data-state-detail]"); + if (detail && message) detail.textContent = message; + } + + function friendlyStatus(value) { + return STATUS_LABELS[value] || text(value, "Unknown").replaceAll("_", " "); + } + + function dateTime(value) { + if (!value) return "Not available"; + const parsed = new Date(value); + return Number.isNaN(parsed.valueOf()) ? String(value) : parsed.toLocaleString(); + } + + function age(value) { + if (!value) return "Age unavailable"; + const milliseconds = Date.now() - new Date(value).valueOf(); + if (!Number.isFinite(milliseconds) || milliseconds < 0) return "Age unavailable"; + const hours = Math.floor(milliseconds / 3600000); + if (hours < 24) return hours + "h open"; + return Math.floor(hours / 24) + "d open"; + } + + function labelPart(prefix, value) { + if (!value) return null; + const clean = String(value).trim(); + return clean.toLowerCase().startsWith(prefix.toLowerCase() + " ") + ? clean : prefix + " " + clean; + } + + function locationText(item) { + const parts = [item.site_name, item.room_name, + labelPart("Rack", item.rack_label), labelPart("Shelf", item.shelf_label), + item.position_label || (item.position_index !== null ? "Position " + item.position_index : null)]; + return parts.filter(Boolean).join(" / ") || "Unplaced"; + } + + async function api(path, options) { + const response = await fetch(path, Object.assign({ + credentials: "same-origin", headers: { Accept: "application/json" } + }, options || {})); + if (response.status === 401) { + window.location.replace("/login?next=" + encodeURIComponent( + window.location.pathname + window.location.search)); + throw new Error("Your session expired."); + } + const payload = await response.json().catch(function () { return null; }); + if (!response.ok) { + const error = new Error(payload && payload.detail + ? String(payload.detail) : "The repair request failed."); + error.permissionDenied = response.status === 403; + throw error; + } + return payload; + } + + function jsonOptions(method, body) { + return { method: method, headers: { Accept: "application/json", + "Content-Type": "application/json" }, body: JSON.stringify(body) }; + } + + function readUrl() { + const params = new URLSearchParams(window.location.search); + el.repairQuery.value = params.get("query") || ""; + el.repairScope.value = params.get("scope") || "active"; + el.repairStatus.value = params.get("status") || ""; + if (el.repairModel) el.repairModel.value = params.get("model") || ""; + el.repairLifecycle.value = params.get("lifecycle_status") || ""; + if (el.repairSite) el.repairSite.value = params.get("site_id") || ""; + state.pendingRoom = params.get("room_id") || ""; + state.offset = Math.max(0, Number(params.get("offset")) || 0); + state.limit = [25, 50, 100, 250].includes(Number(params.get("limit"))) + ? Number(params.get("limit")) : 50; + el.repairLimit.value = String(state.limit); + } + + function currentParams(includePaging) { + const params = new URLSearchParams(); + const values = { + query: el.repairQuery.value.trim(), scope: el.repairScope.value, + status: el.repairStatus.value, + model: el.repairModel ? el.repairModel.value.trim() : "", + lifecycle_status: el.repairLifecycle.value, + site_id: el.repairSite ? el.repairSite.value : "", + room_id: el.repairRoom ? el.repairRoom.value : "" + }; + for (const [key, value] of Object.entries(values)) { + if (value && !(key === "scope" && value === "active")) params.set(key, value); + } + if (includePaging) { + params.set("limit", String(state.limit)); + if (state.offset) params.set("offset", String(state.offset)); + } + return params; + } + + function syncUrl(mode) { + const query = currentParams(true).toString(); + const target = window.location.pathname + (query ? "?" + query : ""); + window.history[mode === "replace" ? "replaceState" : "pushState"]({}, "", target); + } + + function syncLocationOptions(options) { + if (!el.repairSite || !el.repairRoom) return; + const selectedSite = el.repairSite.value; + const selectedRoom = state.pendingRoom || el.repairRoom.value; + el.repairSite.replaceChildren(new Option("All sites", "")); + for (const site of options.sites || []) { + el.repairSite.appendChild(new Option(site.name, site.id)); + } + if ([...el.repairSite.options].some(function (option) { + return option.value === selectedSite; + })) el.repairSite.value = selectedSite; + state.rooms = options.rooms || []; + el.repairRoom.replaceChildren(new Option("All rooms", "")); + for (const room of state.rooms) { + if (!el.repairSite.value || String(room.site_id) === el.repairSite.value) { + el.repairRoom.appendChild(new Option(room.name, room.id)); + } + } + if ([...el.repairRoom.options].some(function (option) { + return option.value === selectedRoom; + })) el.repairRoom.value = selectedRoom; + state.pendingRoom = ""; + } + + function addCell(row, label, content) { + const cell = create("td"); + cell.dataset.label = label; + if (content instanceof Node) cell.appendChild(content); + else cell.textContent = text(content); + row.appendChild(cell); + } + + function identityCell(item) { + const wrap = create("div", "repair-miner"); + const label = item.asset_id || item.current_mac || "Miner " + item.miner_id.slice(0, 8); + if (item.capabilities.can_open_miner) { + const link = create("a", "object-link", label); + const returnTo = window.location.pathname + window.location.search; + const path = "/miners/" + encodeURIComponent(item.miner_id) + "/detail"; + link.href = path + "?return_to=" + encodeURIComponent(returnTo); + wrap.appendChild(link); + } else wrap.appendChild(create("strong", "", label)); + const details = [item.model, item.miner_sn, item.current_mac, item.current_ip] + .filter(Boolean).join(" / "); + if (details) wrap.appendChild(create("span", "secondary", details)); + wrap.appendChild(create("span", "source-label", item.source === "modular" + ? "Repair domain" : "Legacy compatibility")); + return wrap; + } + + function actionCell(item) { + const wrap = create("div", "repair-row-actions"); + if (item.capabilities.can_open_map) { + const params = new URLSearchParams({ site_id: item.site_id, + room_id: item.room_id, miner_id: item.miner_id }); + const link = create("a", "quiet-link", "Mine Map"); + link.href = "/monitoring/map?" + params.toString(); + wrap.appendChild(link); + } + if (Object.values(item.capabilities).some(Boolean)) { + const details = button("Details", "details"); + details.dataset.key = item.source + ":" + (item.repair_id || item.miner_id); + wrap.appendChild(details); + } + return wrap; + } + + function renderRows(items) { + el.repairRows.replaceChildren(); + state.items.clear(); + for (const item of items) { + const key = item.source + ":" + (item.repair_id || item.miner_id); + state.items.set(key, item); + const row = create("tr", "repair-row"); + row.dataset.source = item.source; + row.dataset.active = String(item.active); + addCell(row, "Miner", identityCell(item)); + const status = create("span", "repair-status", friendlyStatus(item.repair_status)); + status.dataset.status = String(item.repair_status).toLowerCase(); + const stateWrap = create("div"); + stateWrap.append(status, create("span", "secondary", text(item.lifecycle_status))); + addCell(row, "Repair state", stateWrap); + addCell(row, "Reported problem", item.reported_problem || item.reason); + addCell(row, "Latest context", item.latest_context); + addCell(row, "Location", locationText(item)); + const opened = create("div"); + opened.append(create("span", "", dateTime(item.opened_at)), + create("span", "secondary", item.active ? age(item.opened_at) : "Historical")); + addCell(row, "Opened / age", opened); + addCell(row, "Parts", item.parts_count === null + ? "Legacy record" : item.parts_count + " recorded"); + addCell(row, "Actions", actionCell(item)); + el.repairRows.appendChild(row); + } + } + + function section(title) { + const node = create("section", "repair-detail-section"); + node.appendChild(create("h3", "", title)); + return node; + } + + function renderTimeline(events, legacy) { + const node = section(legacy ? "Legacy repair history" : "Repair timeline"); + if (!events.length) { + node.appendChild(create("p", "secondary", "No history entries recorded.")); + return node; + } + const list = create("ol", "repair-timeline"); + for (const event of events) { + const entry = create("li"); + const title = legacy + ? text(event.event_title, event.event_type) + : friendlyStatus(event.event_type); + entry.append(create("strong", "", title), + create("span", "secondary", dateTime(event.occurred_at || event.event_at || event.created_at))); + const payload = legacy ? event.description : Object.entries(event.payload || {}) + .map(function (pair) { return pair[0].replaceAll("_", " ") + ": " + pair[1]; }) + .join(" / "); + if (payload) entry.appendChild(create("p", "", String(payload))); + list.appendChild(entry); + } + node.appendChild(list); + return node; + } + + function partLabel(part) { + if (part.consumable) { + return part.consumable.quantity + " " + part.consumable.unit + " " + + part.consumable.description; + } + const snapshot = part.component_snapshot || {}; + return [snapshot.component_type, snapshot.asset_id, + snapshot.manufacturer_serial].filter(Boolean).join(" / ") || "Tracked component"; + } + + function renderParts(item, parts) { + const node = section("Parts used"); + if (!parts.length) node.appendChild(create("p", "secondary", "No parts recorded.")); + const list = create("ul", "repair-parts-list"); + for (const part of parts) { + const entry = create("li"); + entry.appendChild(create("span", part.voided_at ? "voided-part" : "", partLabel(part))); + if (part.notes) entry.appendChild(create("span", "secondary", part.notes)); + if (part.voided_at) { + entry.appendChild(create("span", "secondary", "Voided: " + text(part.void_reason))); + } else if (item.capabilities.can_manage_modular_parts) { + const voidButton = button("Void entry", "void-part", "danger-button"); + voidButton.dataset.partId = part.repair_part_id; + entry.appendChild(voidButton); + } + list.appendChild(entry); + } + node.appendChild(list); + if (item.capabilities.can_manage_modular_parts) { + const form = create("form", "repair-action-form"); + form.dataset.action = "record-part"; + const kind = create("select"); + kind.name = "kind"; + kind.append(new Option("Consumable", "consumable"), + new Option("Tracked component", "tracked_component")); + const description = create("input"); + description.name = "description"; description.placeholder = "Consumable description"; + const quantity = create("input"); + quantity.name = "quantity"; quantity.type = "number"; + quantity.min = "0.001"; quantity.step = "0.001"; quantity.value = "1"; + const unit = create("select"); unit.name = "unit"; + for (const value of ["each", "gram", "millilitre", "metre", "centimetre"]) { + unit.appendChild(new Option(value, value)); + } + const component = create("input"); + component.name = "component"; component.placeholder = "Tracked component UUID"; + const notes = create("input"); notes.name = "notes"; notes.placeholder = "Optional notes"; + form.append(kind, description, quantity, unit, component, notes, + create("button", "command-button", "Record part")); + form.lastChild.type = "submit"; + node.appendChild(form); + } + return node; + } + + function modularActions(item) { + const node = section("Next action"); + if (item.capabilities.can_complete_modular_repair) { + const form = create("form", "repair-action-form"); + form.dataset.action = "complete-repair"; + const target = create("select"); target.name = "target"; + target.append(new Option("Return active", "active"), new Option("Return as spare", "spare")); + const summary = create("textarea"); summary.name = "summary"; + summary.placeholder = "Optional completion summary"; + const submit = create("button", "command-button", "Complete repair"); + submit.type = "submit"; + form.append(target, summary, submit); + node.appendChild(form); + } else node.appendChild(create("p", "secondary", "No repair action is available.")); + return node; + } + + function legacyActions(item) { + const node = section("Legacy actions"); + if (!item.capabilities.can_manage_legacy) { + node.appendChild(create("p", "secondary", "Legacy repair actions are read-only.")); + return node; + } + const form = create("form", "repair-action-form"); + form.dataset.action = "legacy-update"; + const status = create("select"); status.name = "status"; + for (const value of ["open", "diagnosing", "waiting_parts", "in_progress", + "testing", "ready_to_shelf", "needs_followup", "completed", "failed", "cancelled"]) { + const option = new Option(friendlyStatus(value), value); + option.selected = value === item.repair_status; + status.appendChild(option); + } + const note = create("textarea"); note.name = "note"; note.placeholder = "Repair note"; + const noteButton = create("button", "quiet-button", "Add note"); + noteButton.type = "submit"; noteButton.value = "note"; noteButton.name = "intent"; + const statusButton = create("button", "quiet-button", "Save status"); + statusButton.type = "submit"; statusButton.value = "status"; statusButton.name = "intent"; + form.append(status, note, noteButton, statusButton); + node.append(form, button("Mark repaired", "legacy-repaired", "command-button"), + button("Decommission", "legacy-decommission", "danger-button")); + return node; + } + + function closeInspector() { + el.repairInspector.hidden = true; + el.repairInspectorBody.replaceChildren(); + if (state.opener) state.opener.focus(); + } + + async function openInspector(item, opener) { + state.currentItem = item; + state.opener = opener; + el.repairInspector.hidden = false; + el.repairInspectorTitle.textContent = item.asset_id || item.current_mac || "Repair details"; + el.repairInspectorBody.replaceChildren(); + const overview = section("Repair overview"); + overview.append( + create("p", "", friendlyStatus(item.repair_status) + " / " + text(item.lifecycle_status)), + create("p", "", text(item.reported_problem, "No reported problem")), + create("p", "secondary", locationText(item)), + create("p", "secondary", "Opened " + dateTime(item.opened_at)) + ); + el.repairInspectorBody.appendChild(overview); + el.repairInspectorClose.focus(); + + try { + if (item.source === "modular") { + const canRead = item.capabilities.can_read_modular_timeline; + const requests = canRead ? [ + api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/events"), + api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/parts") + ] : []; + const results = await Promise.all(requests); + if (canRead) { + el.repairInspectorBody.append( + renderTimeline(results[0].data.events || [], false), + renderParts(item, results[1].data.entries || []) + ); + } + el.repairInspectorBody.appendChild(modularActions(item)); + } else { + if (item.capabilities.can_read_legacy_history) { + const history = await api("/api/miners/" + + encodeURIComponent(item.miner_id) + "/maintenance-events"); + const events = (history.data || []).filter(function (event) { + return String(event.event_type || "").toLowerCase() === "repair"; + }); + el.repairInspectorBody.appendChild(renderTimeline(events, true)); + } + el.repairInspectorBody.appendChild(legacyActions(item)); + } + } catch (error) { + el.repairInspectorBody.appendChild(create("p", "state-inline-error", error.message)); + } + } + + function selectedItem(target) { + const keyNode = target.closest("[data-key]"); + return keyNode ? state.items.get(keyNode.dataset.key) : null; + } + + async function reloadAfterMutation(message) { + closeInspector(); + await load(); + el.repairResultSummary.textContent = message + " " + el.repairResultSummary.textContent; + } + + async function handleInspectorSubmit(event) { + event.preventDefault(); + const form = event.target; + const item = state.currentItem; + if (!item) return; + const data = new FormData(form); + try { + if (form.dataset.action === "complete-repair") { + if (!window.confirm("Complete this repair and change the miner lifecycle?")) return; + await api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/complete", + jsonOptions("POST", { target_lifecycle: data.get("target"), + completion_summary: String(data.get("summary") || "").trim() || null })); + await reloadAfterMutation("Repair completed."); + } else if (form.dataset.action === "record-part") { + const kind = data.get("kind"); + const body = { notes: String(data.get("notes") || "").trim() || null }; + if (kind === "consumable") { + body.consumable = { description: String(data.get("description") || "").trim(), + quantity: String(data.get("quantity") || ""), unit: data.get("unit") }; + } else body.tracked_component_uuid = String(data.get("component") || "").trim(); + await api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/parts", + jsonOptions("POST", body)); + await openInspector(item, state.opener); + } else if (form.dataset.action === "legacy-update") { + const intent = event.submitter && event.submitter.value; + const note = String(data.get("note") || "").trim(); + const path = "/api/repairs/" + encodeURIComponent(item.miner_id) + + (intent === "note" ? "/note" : "/status"); + const body = intent === "note" + ? { note: note, event_status: data.get("status") } + : { event_status: data.get("status"), note: note || null }; + await api(path, jsonOptions("POST", body)); + await reloadAfterMutation(intent === "note" ? "Repair note added." : "Repair status updated."); + } + } catch (error) { + form.prepend(create("p", "state-inline-error", error.message)); + } + } + + async function handleInspectorClick(event) { + const action = event.target.closest("[data-action]"); + const item = state.currentItem; + if (!action || !item) return; + try { + if (action.dataset.action === "void-part") { + const reason = window.prompt("Reason for voiding this repair-part entry:", ""); + if (!reason || !reason.trim() || !window.confirm("Void this repair-part entry? Its history will remain recorded.")) return; + await api("/api/repairs/" + encodeURIComponent(item.repair_id) + "/parts/" + + encodeURIComponent(action.dataset.partId) + "/void", + jsonOptions("POST", { reason: reason.trim() })); + await openInspector(item, state.opener); + } else if (action.dataset.action === "legacy-repaired") { + if (!window.confirm("Mark this miner repaired and move it to Waiting Placement?")) return; + await api("/api/repairs/" + encodeURIComponent(item.miner_id) + "/state", + jsonOptions("POST", { asset_state: "waiting_placement", + note: "Repair marked complete. Miner is waiting for placement." })); + await reloadAfterMutation("Miner moved to Waiting Placement."); + } else if (action.dataset.action === "legacy-decommission") { + const typed = window.prompt("Type DECOMMISSION to confirm:", ""); + if (typed !== "DECOMMISSION") return; + await api("/api/repairs/" + encodeURIComponent(item.miner_id) + "/state", + jsonOptions("POST", { asset_state: "decommissioned", + note: "Miner decommissioned from Repair Center." })); + await reloadAfterMutation("Miner decommissioned."); + } + } catch (error) { + el.repairInspectorBody.prepend(create("p", "state-inline-error", error.message)); + } + } + + function populateStatusOptions(counts) { + const selected = el.repairStatus.value; + const values = Object.keys(counts || {}).sort(function (left, right) { + return friendlyStatus(left).localeCompare(friendlyStatus(right)); + }); + el.repairStatus.replaceChildren(new Option("All statuses", "")); + for (const value of values) { + el.repairStatus.appendChild(new Option( + friendlyStatus(value) + " (" + counts[value] + ")", value)); + } + if ([...el.repairStatus.options].some(function (option) { + return option.value === selected; + })) el.repairStatus.value = selected; + } + + async function load() { + if (controller) controller.abort(); + controller = new AbortController(); + showState("loading"); + el.repairFreshness.textContent = "Loading repair work"; + try { + const params = currentParams(true); + const envelope = await api("/api/repairs/queue?" + params.toString(), + { signal: controller.signal }); + if (!envelope || !envelope.data || !Array.isArray(envelope.data.items) || + !envelope.data.summary || !envelope.meta) { + throw new Error("MinerOps returned an invalid Repair Center response."); + } + const items = envelope.data.items; + if (!items.every(function (item) { + return item && item.miner_id && item.source && item.capabilities; + })) throw new Error("MinerOps returned incomplete repair records."); + const summary = envelope.data.summary; + el.repairTotal.textContent = String(summary.total); + el.repairActive.textContent = String(summary.active); + el.repairHistorical.textContent = String(summary.historical); + populateStatusOptions(summary.status_counts); + syncLocationOptions(envelope.meta.location_options || {}); + renderRows(items); + const start = summary.total ? state.offset + 1 : 0; + const end = state.offset + items.length; + el.repairResultSummary.textContent = "Showing " + start + "-" + end + + " of " + summary.total + " matching repairs."; + el.repairPrevious.disabled = state.offset === 0; + el.repairNext.disabled = end >= summary.total; + el.repairFreshness.textContent = "Loaded " + new Date().toLocaleTimeString(); + showState(items.length ? "ready" : "empty"); + } catch (error) { + if (error.name === "AbortError") return; + showState(error.permissionDenied ? "denied" : "error", error.message); + el.repairFreshness.textContent = "Repair data unavailable"; + } + } + + el.repairRows.addEventListener("click", function (event) { + const action = event.target.closest('[data-action="details"]'); + const item = selectedItem(event.target); + if (!action || !item) return; + state.currentItem = item; + openInspector(item, action); + }); + el.repairInspectorClose.addEventListener("click", closeInspector); + el.repairInspector.addEventListener("submit", handleInspectorSubmit); + el.repairInspector.addEventListener("click", handleInspectorClick); + document.addEventListener("keydown", function (event) { + if (event.key === "Escape" && !el.repairInspector.hidden) closeInspector(); + }); + el.repairFilters.addEventListener("submit", function (event) { + event.preventDefault(); state.offset = 0; syncUrl("push"); load(); + }); + el.repairReset.addEventListener("click", function () { + el.repairFilters.reset(); state.offset = 0; state.limit = 50; + state.pendingRoom = ""; el.repairLimit.value = "50"; + syncUrl("push"); load(); + }); + if (el.repairSite) el.repairSite.addEventListener("change", function () { + state.pendingRoom = ""; + syncLocationOptions({ sites: [...el.repairSite.options].slice(1).map(function (option) { + return { id: option.value, name: option.textContent }; + }), rooms: state.rooms }); + }); + el.repairPrevious.addEventListener("click", function () { + state.offset = Math.max(0, state.offset - state.limit); syncUrl("push"); load(); + }); + el.repairNext.addEventListener("click", function () { + state.offset += state.limit; syncUrl("push"); load(); + }); + el.repairLimit.addEventListener("change", function () { + state.limit = Number(el.repairLimit.value); state.offset = 0; syncUrl("push"); load(); + }); + window.addEventListener("popstate", function () { readUrl(); load(); }); + + readUrl(); + syncUrl("replace"); + load(); +})(); diff --git a/backend/app/frontend/views.py b/backend/app/frontend/views.py new file mode 100644 index 0000000..2d6a081 --- /dev/null +++ b/backend/app/frontend/views.py @@ -0,0 +1,558 @@ +"""Shared semantic HTML for the MinerOps Internal Alpha shell.""" + +from html import escape +from typing import Callable + +from app.frontend.miner_detail_view import miner_detail_content +from app.frontend.monitoring_trends_view import monitoring_trends_content + + +NAVIGATION = ( + ("dashboard", "Dashboard", "/dashboard", "dashboard.view"), + ("map", "Mine Map", "/monitoring/map", "locations.view"), + ("analytics", "Trends", "/monitoring/analytics", "miners.view"), + ("repairs", "Repair Center", "/operations/repairs", "repairs.view"), + ("miners", "Miners", "/assets/miners", "miners.view"), +) + +PAGE_DETAILS = { + "dashboard": ( + "Operational overview", + "Dashboard", + "A concise starting point for operational state and recent activity.", + ), + "map": ( + "Monitoring / Spatial operations", + "Mine Map", + "Inspect where miners and operational conditions are concentrated.", + ), + "analytics": ( + "Monitoring / Analytics", + "Trends", + "Review bounded historical hashrate and power observations.", + ), + "repairs": ( + "Operations", + "Repair Center", + "Review miners currently represented in the backend repair queue.", + ), + "miners": ( + "Assets", + "Miners", + "Search durable miner inventory and inspect current identity and state.", + ), + "miner_detail": ( + "Assets / Miners", + "Miner Detail", + "Current identity, operating facts, location, and authorized history.", + ), +} + + +def _dashboard_content( + can_view_miners: bool, + can_view_repairs: bool, + can_view_locations: bool, +) -> str: + fleet = """ +
+

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""" +
+ Loading operational overview + Requesting current fleet, repair, and location summaries. +
+ + + +""" + + +def _map_content(can_manage_locations: bool = False) -> str: + manage_link = ( + 'Manage locations' + if can_manage_locations + else "" + ) + return f""" +
+
+
+

Map context

+

Select a site and room.

+
+ {manage_link} +
+
+
+
+
+
+
+
+ + +
+
+ Filters +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
Waiting for map data
+
+ +
+ Loading Mine Map + Requesting the current physical layout and operational observations. +
+ +""" + + +def _state_messages(noun: str) -> str: + return f""" +
+ Loading {noun} + Requesting current information from MinerOps. +
+ + + +""" + + +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""" +
+
+

Repair Center

+

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

+ Loading repair work +
+
+
Total results-
+
Active-
+
Historical-
+
+ + {_state_messages("repair records")} + + +
+ +""" + + +def _miners_content(can_view_locations: bool) -> str: + location_filters = """ +
+
+
+
+ """ if can_view_locations else "" + return f""" +
+
+

Fleet

+

Current miner identity, operating state, telemetry, and repair context.

+ Loading current fleet +
+ + {_state_messages("miners")} + + +
+""" + + +def render_app_page( + view: str, + current_user: dict, + has_permission: Callable[[str], bool], + *, + miner_id: str | None = None, + return_to: str | None = None, +) -> str: + context, title, summary = PAGE_DETAILS[view] + display_name = escape( + str( + current_user.get("display_name") + or current_user.get("username") + or "MinerOps user" + ) + ) + role = escape(str(current_user.get("role") or "Authenticated user")) + can_view_miners = has_permission("miners.view") + active_navigation = "miners" if view == "miner_detail" else view + navigation = "".join( + ( + f'
  • {escape(label)}
  • ' + if key == active_navigation + else f'
  • {escape(label)}
  • ' + ) + for key, label, href, permission in NAVIGATION + if has_permission(permission) + ) + global_search = ( + '' + if can_view_miners + else '' + ) + content = ( + miner_detail_content() + if view == "miner_detail" + else monitoring_trends_content(has_permission("locations.view")) + if view == "analytics" + else _map_content(has_permission("locations.manage")) + if view == "map" + else _miners_content(has_permission("locations.view")) + if view == "miners" + else _repairs_content( + has_permission("locations.view"), has_permission("miners.view") + ) + if view == "repairs" + else _dashboard_content( + has_permission("miners.view"), + has_permission("repairs.view"), + has_permission("locations.view"), + ) + ) + page_script = ( + '' + if view == "miner_detail" + else '' + if view == "analytics" + else '' + if view == "map" + else '' + if view == "miners" + else '' + if view == "repairs" + else '' + if view == "dashboard" + else "" + ) + page_style = ( + '' + if view == "analytics" else "" + ) + object_attributes = "" + if miner_id is not None: + object_attributes += f' data-miner-id="{escape(miner_id)}"' + if return_to is not None: + object_attributes += f' data-return-to="{escape(return_to)}"' + return f""" + + + + + {escape(title)} | MinerOps + + {page_style} + + + +
    +
    + + {global_search} +
    + +
    + {display_name} + {role} +
    + +
    +
    + +
    +
    +
    +

    {escape(context)}

    +

    {escape(title)}

    +

    {escape(summary)}

    +
    +

    Current view: {escape(title)}

    +
    + {content} +
    +
    + + {page_script} + +""" diff --git a/backend/app/identity.py b/backend/app/identity.py new file mode 100644 index 0000000..15d8dae --- /dev/null +++ b/backend/app/identity.py @@ -0,0 +1,1130 @@ +from datetime import datetime, timedelta, timezone +from html import escape +from typing import Any, Callable +from urllib.parse import quote, urlsplit + +import jwt +import psycopg +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError +from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from pydantic import BaseModel, EmailStr, Field + +from app.config import settings + + +router = APIRouter() +password_hasher = PasswordHasher() +SESSION_COOKIE = "minerops_session" +COOKIE_TOKEN_HINT = "cookie-session" +DEFAULT_ROLES = ("Admin", "Manager", "Technician", "Read Only") + + +class SetupAdminRequest(BaseModel): + username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$") + display_name: str = Field(min_length=1, max_length=120) + email: EmailStr | None = None + password: str = Field(min_length=12, max_length=256) + + +class LoginRequest(BaseModel): + username: str = Field(min_length=1, max_length=64) + password: str = Field(min_length=1, max_length=256) + + +class CreateUserRequest(BaseModel): + username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$") + display_name: str = Field(min_length=1, max_length=120) + email: EmailStr | None = None + role: str + password: str = Field(min_length=12, max_length=256) + + +class UpdateUserRequest(BaseModel): + display_name: str | None = Field(default=None, min_length=1, max_length=120) + email: EmailStr | None = None + role: str | None = None + is_active: bool | None = None + + +class ResetPasswordRequest(BaseModel): + password: str = Field(min_length=12, max_length=256) + + +def api_response(data: Any = None, meta: dict | None = None, error: Any = None) -> dict: + return {"data": data, "meta": meta or {}, "error": error} + + +def get_db_connection(): + return psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + connect_timeout=5, + ) + + +def create_access_token(user_id: str, username: str, role: str) -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": user_id, + "username": username, + "role": role, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(minutes=settings.jwt_access_token_minutes)).timestamp()), + "type": "access", + } + return jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256") + + +def create_session_token(user_id: str, username: str, role: str) -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": user_id, + "username": username, + "role": role, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(days=settings.jwt_refresh_token_days)).timestamp()), + "type": "session", + } + return jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256") + + +def decode_auth_token(token: str) -> dict: + try: + payload = jwt.decode(token, settings.jwt_secret_key, algorithms=["HS256"]) + if payload.get("type") not in {"access", "session"}: + raise HTTPException(status_code=401, detail="Invalid token type") + return payload + except jwt.ExpiredSignatureError as exc: + raise HTTPException(status_code=401, detail="Session expired") from exc + except jwt.InvalidTokenError as exc: + raise HTTPException(status_code=401, detail="Invalid session") from exc + + +def token_from_request(request: Request, authorization: str | None = None) -> str: + header = authorization or request.headers.get("authorization") or "" + if header.lower().startswith("bearer "): + token = header.split(" ", 1)[1].strip() + if token and token != COOKIE_TOKEN_HINT: + return token + + cookie_token = request.cookies.get(SESSION_COOKIE, "").strip() + if cookie_token: + return cookie_token + + raise HTTPException(status_code=401, detail="Login required") + + +def get_auth_token( + request: Request, + authorization: str | None = Header(default=None), +) -> str: + return token_from_request(request, authorization) + + +def load_user_for_token(token: str) -> dict: + payload = decode_auth_token(token) + conn = None + try: + conn = get_db_connection() + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute( + """ + SELECT + users.id::text AS id, + users.username, + users.display_name, + users.email, + users.is_active, + roles.name AS role, + COALESCE( + jsonb_agg(DISTINCT permissions.key) + FILTER (WHERE permissions.key IS NOT NULL), + '[]'::jsonb + ) AS permissions + FROM users + JOIN roles ON roles.id = users.role_id + LEFT JOIN role_permissions ON role_permissions.role_id = roles.id + LEFT JOIN permissions ON permissions.id = role_permissions.permission_id + WHERE users.id = %s + GROUP BY users.id, roles.name + """, + (payload["sub"],), + ) + user = cur.fetchone() + + if not user or not user["is_active"]: + raise HTTPException(status_code=401, detail="User not found or inactive") + + result = dict(user) + result["permissions"] = list(result.get("permissions") or []) + return result + finally: + if conn is not None: + conn.close() + + +def get_current_user(token: str = Depends(get_auth_token)) -> dict: + return load_user_for_token(token) + + +def user_has_permission(user: dict, permission: str) -> bool: + return user.get("role") == "Admin" or permission in set(user.get("permissions") or []) + + +def require_permission(permission: str) -> Callable: + def dependency(current_user: dict = Depends(get_current_user)) -> dict: + if not user_has_permission(current_user, permission): + raise HTTPException(status_code=403, detail="You do not have permission for this action") + return current_user + + return dependency + + +def request_uses_https(request: Request) -> bool: + forwarded = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip() + return forwarded == "https" or request.url.scheme == "https" + + +def set_session_cookie(response: Response, request: Request, token: str) -> None: + response.set_cookie( + key=SESSION_COOKIE, + value=token, + max_age=settings.jwt_refresh_token_days * 24 * 60 * 60, + httponly=True, + secure=request_uses_https(request), + samesite="lax", + path="/", + ) + + +def clear_session_cookie(response: Response, request: Request) -> None: + response.delete_cookie( + SESSION_COOKIE, + path="/", + secure=request_uses_https(request), + httponly=True, + samesite="lax", + ) + + +def audit_event( + cur, + actor_user_id: str | None, + action: str, + entity_id: str | None, + details: dict, +) -> None: + cur.execute( + """ + INSERT INTO audit_logs ( + actor_user_id, action, entity_type, entity_id, new_value_json + ) + VALUES (%s, %s, 'user', %s, %s) + """, + ( + actor_user_id, + action, + entity_id, + psycopg.types.json.Jsonb(details), + ), + ) + + +def serialize_user(row: dict) -> dict: + result = dict(row) + for key in ("created_at", "updated_at", "last_login_at"): + if result.get(key) is not None: + result[key] = result[key].isoformat() + return result + + +def validate_role(role: str) -> str: + cleaned = str(role or "").strip() + if cleaned not in DEFAULT_ROLES: + raise HTTPException(status_code=400, detail="Invalid account role") + return cleaned + + +@router.get("/api/auth/setup-status") +def setup_status(): + conn = None + try: + conn = get_db_connection() + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) FROM users") + user_count = cur.fetchone()[0] + return api_response(data={"setup_required": user_count == 0, "user_count": user_count}) + finally: + if conn is not None: + conn.close() + + +@router.post("/api/auth/setup-admin") +def setup_admin(payload: SetupAdminRequest, request: Request, response: Response): + conn = None + try: + conn = get_db_connection() + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute("SELECT COUNT(*) AS user_count FROM users") + if cur.fetchone()["user_count"] > 0: + raise HTTPException(status_code=409, detail="Initial setup is already complete") + + cur.execute("SELECT id FROM roles WHERE name = 'Admin'") + role = cur.fetchone() + if not role: + raise HTTPException(status_code=500, detail="Admin role is missing") + + cur.execute( + """ + INSERT INTO users (username, display_name, email, password_hash, role_id) + VALUES (%s, %s, %s, %s, %s) + RETURNING id::text, username, display_name, email + """, + ( + payload.username.strip(), + payload.display_name.strip(), + str(payload.email) if payload.email else None, + password_hasher.hash(payload.password), + role["id"], + ), + ) + user = dict(cur.fetchone()) + audit_event( + cur, + user["id"], + "user.created", + user["id"], + {"username": user["username"], "role": "Admin", "source": "initial-setup"}, + ) + conn.commit() + + session_token = create_session_token(user["id"], user["username"], "Admin") + set_session_cookie(response, request, session_token) + return api_response(data={"user": {**user, "role": "Admin"}}) + except HTTPException: + if conn is not None: + conn.rollback() + raise + except psycopg.errors.UniqueViolation as exc: + if conn is not None: + conn.rollback() + raise HTTPException(status_code=409, detail="Username already exists") from exc + finally: + if conn is not None: + conn.close() + + +@router.post("/api/auth/login") +def login(payload: LoginRequest, request: Request, response: Response): + conn = None + try: + conn = get_db_connection() + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute( + """ + SELECT users.id::text AS id, users.username, users.display_name, + users.email, users.password_hash, users.is_active, + roles.name AS role + FROM users + JOIN roles ON roles.id = users.role_id + WHERE lower(users.username) = lower(%s) + """, + (payload.username.strip(),), + ) + user = cur.fetchone() + if not user or not user["is_active"]: + raise HTTPException(status_code=401, detail="Invalid username or password") + + try: + password_hasher.verify(user["password_hash"], payload.password) + except VerifyMismatchError as exc: + raise HTTPException(status_code=401, detail="Invalid username or password") from exc + + access_token = create_access_token(user["id"], user["username"], user["role"]) + session_token = create_session_token(user["id"], user["username"], user["role"]) + cur.execute( + "UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = %s", + (user["id"],), + ) + audit_event( + cur, + user["id"], + "user.login", + user["id"], + {"username": user["username"]}, + ) + conn.commit() + set_session_cookie(response, request, session_token) + return api_response( + data={ + "access_token": access_token, + "token_type": "bearer", + "expires_in": settings.jwt_access_token_minutes * 60, + "user": { + "id": user["id"], + "username": user["username"], + "display_name": user["display_name"], + "email": user["email"], + "role": user["role"], + }, + } + ) + except HTTPException: + if conn is not None: + conn.rollback() + raise + finally: + if conn is not None: + conn.close() + + +@router.post("/api/auth/logout") +def logout(request: Request, response: Response): + clear_session_cookie(response, request) + return api_response(data={"logged_out": True}) + + +@router.get("/api/auth/me") +def me(current_user: dict = Depends(get_current_user)): + return api_response(data=current_user) + + +@router.get("/api/admin/roles") +def list_roles(current_user: dict = Depends(require_permission("users.manage"))): + del current_user + conn = None + try: + conn = get_db_connection() + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute( + """ + SELECT roles.name, roles.description, + COALESCE(jsonb_agg(permissions.key ORDER BY permissions.key) + FILTER (WHERE permissions.key IS NOT NULL), '[]'::jsonb) AS permissions + FROM roles + LEFT JOIN role_permissions ON role_permissions.role_id = roles.id + LEFT JOIN permissions ON permissions.id = role_permissions.permission_id + WHERE roles.name = ANY(%s) + GROUP BY roles.id + ORDER BY array_position(%s::text[], roles.name) + """, + (list(DEFAULT_ROLES), list(DEFAULT_ROLES)), + ) + rows = [dict(row) for row in cur.fetchall()] + return api_response(data=rows) + finally: + if conn is not None: + conn.close() + + +@router.get("/api/admin/users") +def list_users(current_user: dict = Depends(require_permission("users.manage"))): + del current_user + conn = None + try: + conn = get_db_connection() + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute( + """ + SELECT users.id::text AS id, users.username, users.display_name, + users.email, users.is_active, users.last_login_at, + users.created_at, users.updated_at, roles.name AS role + FROM users + JOIN roles ON roles.id = users.role_id + ORDER BY users.is_active DESC, lower(users.display_name), lower(users.username) + """ + ) + rows = [serialize_user(dict(row)) for row in cur.fetchall()] + return api_response(data=rows) + finally: + if conn is not None: + conn.close() + + +@router.post("/api/admin/users", status_code=201) +def create_user( + payload: CreateUserRequest, + current_user: dict = Depends(require_permission("users.manage")), +): + role_name = validate_role(payload.role) + conn = None + try: + conn = get_db_connection() + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,)) + role = cur.fetchone() + if not role: + raise HTTPException(status_code=400, detail="Role is not available") + + cur.execute( + "SELECT 1 FROM users WHERE lower(username) = lower(%s)", + (payload.username.strip(),), + ) + if cur.fetchone(): + raise HTTPException(status_code=409, detail="Username already exists") + + cur.execute( + """ + INSERT INTO users (username, display_name, email, password_hash, role_id) + VALUES (%s, %s, %s, %s, %s) + RETURNING id::text, username, display_name, email, is_active, + created_at, updated_at + """, + ( + payload.username.strip(), + payload.display_name.strip(), + str(payload.email) if payload.email else None, + password_hasher.hash(payload.password), + role["id"], + ), + ) + user = serialize_user(dict(cur.fetchone())) + user["role"] = role_name + audit_event( + cur, + current_user["id"], + "user.created", + user["id"], + {"username": user["username"], "role": role_name}, + ) + conn.commit() + return api_response(data=user) + except HTTPException: + if conn is not None: + conn.rollback() + raise + except psycopg.errors.UniqueViolation as exc: + if conn is not None: + conn.rollback() + raise HTTPException(status_code=409, detail="Username already exists") from exc + finally: + if conn is not None: + conn.close() + + +def active_admin_count(cur) -> int: + cur.execute( + """ + SELECT COUNT(*) + FROM users + JOIN roles ON roles.id = users.role_id + WHERE users.is_active = true AND roles.name = 'Admin' + """ + ) + return cur.fetchone()[0] + + +@router.patch("/api/admin/users/{user_id}") +def update_user( + user_id: str, + payload: UpdateUserRequest, + current_user: dict = Depends(require_permission("users.manage")), +): + conn = None + try: + conn = get_db_connection() + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute( + """ + SELECT users.id::text AS id, users.username, users.display_name, + users.email, users.is_active, roles.name AS role + FROM users JOIN roles ON roles.id = users.role_id + WHERE users.id = %s + """, + (user_id,), + ) + existing = cur.fetchone() + if not existing: + raise HTTPException(status_code=404, detail="User not found") + + new_role = validate_role(payload.role) if payload.role is not None else existing["role"] + new_active = payload.is_active if payload.is_active is not None else existing["is_active"] + removing_admin = existing["role"] == "Admin" and (new_role != "Admin" or not new_active) + if removing_admin and active_admin_count(cur) <= 1: + raise HTTPException(status_code=409, detail="The last active administrator cannot be disabled or demoted") + if user_id == current_user["id"] and not new_active: + raise HTTPException(status_code=409, detail="You cannot disable your own account") + + cur.execute("SELECT id FROM roles WHERE name = %s", (new_role,)) + role = cur.fetchone() + cur.execute( + """ + UPDATE users + SET display_name = %s, email = %s, role_id = %s, + is_active = %s, updated_at = now() + WHERE id = %s + RETURNING id::text, username, display_name, email, is_active, + last_login_at, created_at, updated_at + """, + ( + payload.display_name.strip() if payload.display_name is not None else existing["display_name"], + str(payload.email) if payload.email else None, + role["id"], + new_active, + user_id, + ), + ) + user = serialize_user(dict(cur.fetchone())) + user["role"] = new_role + audit_event( + cur, + current_user["id"], + "user.updated", + user_id, + { + "username": user["username"], + "role": new_role, + "is_active": new_active, + }, + ) + conn.commit() + return api_response(data=user) + except HTTPException: + if conn is not None: + conn.rollback() + raise + finally: + if conn is not None: + conn.close() + + +@router.post("/api/admin/users/{user_id}/reset-password") +def reset_password( + user_id: str, + payload: ResetPasswordRequest, + current_user: dict = Depends(require_permission("users.manage")), +): + conn = None + try: + conn = get_db_connection() + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute( + """ + UPDATE users SET password_hash = %s, updated_at = now() + WHERE id = %s + RETURNING id::text, username + """, + (password_hasher.hash(payload.password), user_id), + ) + user = cur.fetchone() + if not user: + raise HTTPException(status_code=404, detail="User not found") + audit_event( + cur, + current_user["id"], + "user.password_reset", + user_id, + {"username": user["username"]}, + ) + conn.commit() + return api_response(data={"user_id": user_id, "password_reset": True}) + except HTTPException: + if conn is not None: + conn.rollback() + raise + finally: + if conn is not None: + conn.close() + + +def protected_page(path: str) -> bool: + exact = { + "/dashboard", + "/repairs", + "/locations", + "/system-health", + "/accounts", + "/monitoring/map", + "/monitoring/analytics", + "/operations/repairs", + "/assets/miners", + } + prefixes = ("/miners/", "/agent/", "/tools/") + return path in exact or path.startswith(prefixes) + + +def safe_next_path(value: str | None) -> str: + """Return a safe local application destination for login redirects.""" + fallback = "/dashboard" + if not value or value != value.strip(): + return fallback + if any(ord(character) < 32 or ord(character) == 127 for character in value): + return fallback + + try: + target = urlsplit(value) + except ValueError: + return fallback + + path = target.path + if ( + target.scheme + or target.netloc + or target.fragment + or not path.startswith("/") + or path.startswith("//") + or "\\" in path + or "%" in path + or any(segment in {".", ".."} for segment in path.split("/")) + or not protected_page(path) + ): + return fallback + return value + + +def required_page_permission(path: str) -> str: + if path == "/accounts": + return "users.manage" + if path == "/system-health": + return "system_health.view" + if path in {"/repairs", "/operations/repairs"}: + return "repairs.view" + if path in {"/locations", "/monitoring/map"}: + return "locations.view" + if path == "/monitoring/analytics": + return "miners.view" + if path == "/assets/miners": + return "miners.view" + if path.startswith("/agent/"): + return "agents.view" + if path.startswith("/miners/"): + return "miners.view" + return "dashboard.view" + + +def machine_agent_request(path: str) -> bool: + exact = { + "/api/agent/checkin", + "/api/agent/config", + "/api/agent/miner-snapshots", + "/api/agent/pool-snapshots", + "/api/agent/commands/pending", + "/api/agent/commands/result", + "/api/agent/discoveries", + "/api/agent/miner-enrichment", + "/api/agent/miner-enrichment-v2", + "/api/agent/miner-enrichment-v3", + "/api/agent/api3-enrichment", + } + return path in exact + + +def repair_timeline_request(path: str) -> bool: + parts = path.split("/") + if len(parts) == 4 and parts[:3] == ["", "api", "repairs"]: + return parts[3] not in {"queue", "parts-summary"} + return ( + len(parts) == 5 + and parts[:3] == ["", "api", "repairs"] + and parts[4] == "events" + ) + + +def repair_completion_request(path: str) -> bool: + parts = path.split("/") + return ( + len(parts) == 5 + and parts[:3] == ["", "api", "repairs"] + and parts[4] == "complete" + ) + + +def repair_parts_request(path: str) -> bool: + parts = path.split("/") + return ( + len(parts) == 5 + and parts[:3] == ["", "api", "repairs"] + and parts[4] == "parts" + ) or ( + len(parts) == 7 + and parts[:3] == ["", "api", "repairs"] + and parts[4] == "parts" + and parts[6] == "void" + ) + + +def component_request(path: str) -> bool: + parts = path.split("/") + return path == "/api/components" or ( + len(parts) == 4 and parts[:3] == ["", "api", "components"] + ) + + +def required_api_permission(method: str, path: str) -> str | None: + parts = path.split("/") + if ( + method == "GET" + and len(parts) == 5 + and parts[:3] == ["", "api", "miners"] + and parts[4] == "maintenance-events" + ): + return "repairs.read" + if component_request(path): + return "components.create" if method == "POST" else "components.read" + if repair_parts_request(path): + return "repairs.read" if method == "GET" else "repairs.parts.manage" + if path.startswith("/api/admin/"): + return "users.manage" + if method == "POST" and path.endswith("/change-mac") and path.startswith("/api/miners/"): + return "miners.replace_control_board" + if ( + method == "POST" + and len(path.split("/")) == 5 + and path.split("/")[:3] == ["", "api", "miners"] + and path.split("/")[4] == "replace-control-board" + ): + return "miners.replace_control_board" + if method == "POST" and path == "/api/repairs": + return "repairs.create" + if method == "POST" and repair_completion_request(path): + return "repairs.complete" + if method == "GET" and repair_timeline_request(path): + return "repairs.read" + if method == "GET": + if path.startswith("/api/system/"): + return "system_health.view" + if path.startswith("/api/repairs") or path.startswith("/api/maintenance"): + return "repairs.view" + if path.startswith("/api/location") or path in {"/api/rooms", "/api/sites"}: + return "locations.view" + return None + + if path.startswith("/api/repairs") or "/maintenance-events" in path: + return "repairs.manage" + if path.startswith("/api/location") or path.startswith("/api/rooms"): + return "locations.manage" + if "/commands/" in path or path.endswith("/agent-control"): + return "miners.control" + if path.endswith("/api3/change-password"): + return "credentials.manage" + if path.startswith("/api/agent/") or path.startswith("/api/dashboard/scheduled-bulk-actions"): + return "agents.manage" + if path.startswith("/api/scan-ranges"): + return "scan_ranges.manage" if method in {"POST", "PATCH", "DELETE"} else "scans.run" + if path.startswith("/api/imports") or path.startswith("/api/tools"): + return "miners.update" + if path.startswith("/api/miners"): + return "miners.update" + return None + + +def request_user_or_none(request: Request) -> dict | None: + try: + return load_user_for_token(token_from_request(request)) + except HTTPException: + return None + + +def install_identity_middleware(app) -> None: + @app.middleware("http") + async def identity_gate(request: Request, call_next): + path = request.url.path + + if path == "/": + destination = "/dashboard" if request_user_or_none(request) else "/login" + return RedirectResponse(destination, status_code=303) + + if path == "/login": + user = request_user_or_none(request) + if user: + return RedirectResponse( + safe_next_path(request.query_params.get("next")), + status_code=303, + ) + return await call_next(request) + + if protected_page(path): + user = request_user_or_none(request) + if not user: + requested_path = path + if request.url.query: + requested_path += "?" + request.url.query + destination = "/login?next=" + quote(requested_path, safe="") + response = RedirectResponse(destination, status_code=303) + clear_session_cookie(response, request) + return response + permission = required_page_permission(path) + if not user_has_permission(user, permission): + return HTMLResponse( + "

    Access denied

    Your account does not have permission to view this page.

    Return to dashboard

    ", + status_code=403, + ) + request.state.current_user = user + + if path.startswith("/api/") and not path.startswith("/api/auth/") and not machine_agent_request(path): + permission = required_api_permission(request.method.upper(), path) + if permission: + user = request_user_or_none(request) + if not user: + return JSONResponse({"detail": "Login required"}, status_code=401) + if not user_has_permission(user, permission): + return JSONResponse( + {"detail": "You do not have permission for this action"}, + status_code=403, + ) + request.state.current_user = user + + return await call_next(request) + + +LOGIN_HTML = r""" + + + + +Sign in | MinerOps + + + +
    MinerOps
    +
    +
    +

    Sign in

    Use your MinerOps account to continue.

    +
    +
    +
    +
    +
    + +

    Your session is stored in a protected browser cookie and expires automatically.

    +
    + +
    +
    + + +""" + + +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(request: Request): + next_path = escape( + safe_next_path(request.query_params.get("next")), + quote=True, + ) + return LOGIN_HTML.replace("__MINEROPS_SAFE_NEXT_PATH__", next_path) + + +@router.get("/accounts", response_class=HTMLResponse) +def accounts_page(): + return ACCOUNTS_HTML diff --git a/backend/app/inventory/__init__.py b/backend/app/inventory/__init__.py new file mode 100644 index 0000000..a22ddc7 --- /dev/null +++ b/backend/app/inventory/__init__.py @@ -0,0 +1,35 @@ +"""Inventory domain models, persistence, and business services.""" + +from app.inventory.exceptions import ( + DuplicateAssetID, + InventoryError, + InventoryValidationError, + MinerNotFound, +) +from app.inventory.models import ( + InventoryCreateRequest, + InventoryMetadataUpdateRequest, + InventoryMinerDetail, + InventoryMinerSummary, + InventorySearchFilters, + InventoryTag, + InventoryUpdateRequest, +) +from app.inventory.repository import InventoryRepository +from app.inventory.service import InventoryService + +__all__ = [ + "DuplicateAssetID", + "InventoryCreateRequest", + "InventoryError", + "InventoryMetadataUpdateRequest", + "InventoryMinerDetail", + "InventoryMinerSummary", + "InventoryRepository", + "InventorySearchFilters", + "InventoryService", + "InventoryTag", + "InventoryUpdateRequest", + "InventoryValidationError", + "MinerNotFound", +] diff --git a/backend/app/inventory/dependencies.py b/backend/app/inventory/dependencies.py new file mode 100644 index 0000000..7b2465b --- /dev/null +++ b/backend/app/inventory/dependencies.py @@ -0,0 +1,8 @@ +"""Dependency construction for the Inventory domain.""" + +from app.inventory.repository import InventoryRepository +from app.inventory.service import InventoryService + + +def get_inventory_service() -> InventoryService: + return InventoryService(InventoryRepository()) diff --git a/backend/app/inventory/exceptions.py b/backend/app/inventory/exceptions.py new file mode 100644 index 0000000..3bc9ebf --- /dev/null +++ b/backend/app/inventory/exceptions.py @@ -0,0 +1,25 @@ +"""Inventory domain exceptions.""" + + +class InventoryError(Exception): + """Base exception for inventory operations.""" + + +class InventoryValidationError(InventoryError): + """Raised when an inventory business rule is violated.""" + + +class DuplicateAssetID(InventoryError): + """Raised when an Asset ID is already assigned to another miner.""" + + def __init__(self, asset_id: str): + self.asset_id = asset_id + super().__init__(f"Asset ID already exists: {asset_id}") + + +class MinerNotFound(InventoryError): + """Raised when an inventory miner cannot be found.""" + + def __init__(self, miner_id: object): + self.miner_id = miner_id + super().__init__(f"Miner not found: {miner_id}") diff --git a/backend/app/inventory/models.py b/backend/app/inventory/models.py new file mode 100644 index 0000000..de278d0 --- /dev/null +++ b/backend/app/inventory/models.py @@ -0,0 +1,191 @@ +"""Pydantic contracts for the inventory domain.""" + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress + + +LifecycleStatus = Literal["active", "spare", "retired", "in_repair"] + + +class InventoryTag(BaseModel): + id: UUID + name: str + created_at: datetime + updated_at: datetime + + +class InventoryActiveRepair(BaseModel): + id: UUID + status: str + reason: str + + +class InventoryMinerSummary(BaseModel): + id: UUID + asset_id: str | None = None + current_mac: str | None = None + current_ip: str | None = None + model: str | None = None + miner_sn: str | None = None + lifecycle_status: LifecycleStatus + runtime_status: str + site_id: UUID | None = None + site_name: str | None = None + room_id: UUID | None = None + room_name: str | None = None + location_position_id: UUID | None = None + rack_label: str | None = None + shelf_label: str | None = None + position_label: str | None = None + position_index: int | None = None + observed_at: datetime | None = None + hashrate_ths: float | None = None + power_w: float | None = None + chip_temp_max_c: float | None = None + hash_stable: bool | None = None + active_repair: InventoryActiveRepair | None = None + tags: list[InventoryTag] = Field(default_factory=list) + created_at: datetime + updated_at: datetime + last_seen_at: datetime | None = None + + +class InventoryMinerDetail(InventoryMinerSummary): + hostname: str | None = None + control_board_sn: str | None = None + psu_serial: str | None = None + firmware_version: str | None = None + notes: str | None = None + first_seen_at: datetime | None = None + + +class InventoryAssetIDHistoryEntry(BaseModel): + old_asset_id: str | None = None + new_asset_id: str + changed_at: datetime + changed_by: UUID + changed_by_display_name: str + + +class InventoryCreateRequest(BaseModel): + asset_id: str = Field(min_length=1, max_length=120) + current_mac: str | None = Field(default=None, max_length=64) + current_ip: IPvAnyAddress | None = None + hostname: str | None = Field(default=None, max_length=255) + miner_sn: str | None = Field(default=None, max_length=255) + control_board_sn: str | None = Field(default=None, max_length=255) + psu_serial: str | None = Field(default=None, max_length=255) + model: str | None = Field(default=None, max_length=255) + firmware_version: str | None = Field(default=None, max_length=255) + lifecycle_status: str = "active" + site_id: UUID | None = None + room_id: UUID | None = None + notes: str | None = None + tags: list[str] = Field(default_factory=list) + + +class InventoryMetadataUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + asset_id: str | None = Field(default=None, max_length=120) + miner_sn: str | None = Field(default=None, max_length=255) + psu_serial: str | None = Field(default=None, max_length=255) + model: str | None = Field(default=None, max_length=255) + notes: str | None = None + + +class InventoryUpdateRequest(BaseModel): + asset_id: str | None = Field(default=None, max_length=120) + current_mac: str | None = Field(default=None, max_length=64) + current_ip: IPvAnyAddress | None = None + hostname: str | None = Field(default=None, max_length=255) + miner_sn: str | None = Field(default=None, max_length=255) + control_board_sn: str | None = Field(default=None, max_length=255) + psu_serial: str | None = Field(default=None, max_length=255) + model: str | None = Field(default=None, max_length=255) + firmware_version: str | None = Field(default=None, max_length=255) + lifecycle_status: str | None = None + site_id: UUID | None = None + room_id: UUID | None = None + notes: str | None = None + tags: list[str] | None = None + + +class InventorySearchFilters(BaseModel): + query: str | None = None + asset_id: str | None = None + lifecycle_status: str | None = None + model: str | None = None + runtime_status: str | None = None + repair: Literal["active", "none"] | None = None + operational_state: Literal[ + "online", "offline", "unknown", "unstable", "repair", "spare", "retired" + ] | None = None + problems: bool = False + site_id: UUID | None = None + room_id: UUID | None = None + tags: list[str] = Field(default_factory=list) + limit: int = Field(default=100, ge=1, le=500) + offset: int = Field(default=0, ge=0) + + +class InventorySiteOption(BaseModel): + id: UUID + name: str + + +class InventoryRoomOption(BaseModel): + id: UUID + site_id: UUID + name: str + + +class InventoryLocationOptions(BaseModel): + sites: list[InventorySiteOption] = Field(default_factory=list) + rooms: list[InventoryRoomOption] = Field(default_factory=list) + + +class InventoryListMeta(BaseModel): + limit: int + offset: int + returned: int + location_options: InventoryLocationOptions = Field(default_factory=InventoryLocationOptions) + + +class InventoryMinerListResponse(BaseModel): + data: list[InventoryMinerSummary] + meta: InventoryListMeta + error: None = None + + +class InventoryMinerDetailResponse(BaseModel): + data: InventoryMinerDetail + meta: dict[str, object] = Field(default_factory=dict) + error: None = None + + +class InventoryAssetIDHistoryResponse(BaseModel): + data: list[InventoryAssetIDHistoryEntry] + meta: dict[str, object] = Field(default_factory=dict) + error: None = None + + +class InventoryFleetAttentionSummary(BaseModel): + offline: int + unknown: int + unstable: int + repair: int + + +class InventoryFleetSummary(BaseModel): + total: int + runtime_online: int + runtime_offline: int + runtime_unknown: int + lifecycle_in_repair: int + lifecycle_spare: int + lifecycle_retired: int + attention: InventoryFleetAttentionSummary diff --git a/backend/app/inventory/repository.py b/backend/app/inventory/repository.py new file mode 100644 index 0000000..1005cec --- /dev/null +++ b/backend/app/inventory/repository.py @@ -0,0 +1,714 @@ +"""PostgreSQL persistence for the inventory domain.""" + +from collections.abc import Callable, Iterator, Mapping, Sequence +from contextlib import contextmanager +from typing import Any +from uuid import UUID + +import psycopg +from psycopg.rows import dict_row + +from app.config import settings + + +ConnectionFactory = Callable[[], psycopg.Connection] + +TAG_TRIMMED_NAME_SQL = ( + "regexp_replace(name, '^[[:space:]]+|[[:space:]]+$', '', 'g')" +) + +MINER_SELECT = f""" + SELECT + miners.id, + miners.site_id, + miners.room_id, + miners.asset_tag, + miners.current_mac, + host(miners.current_ip) AS current_ip, + miners.hostname, + miners.miner_sn, + miners.control_board_sn, + miners.power_sn, + miners.model, + miners.firmware_version, + miners.lifecycle_status, + miners.status, + miners.notes, + miners.first_seen_at, + miners.last_seen_at, + miners.created_at, + miners.updated_at, + tag_data.tags + FROM miners + LEFT JOIN LATERAL ( + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'id', tags.id, + 'name', tags.name, + 'created_at', tags.created_at, + 'updated_at', tags.updated_at + ) + ORDER BY lower({TAG_TRIMMED_NAME_SQL}), tags.id + ), + '[]'::jsonb + ) AS tags + FROM miner_tags + JOIN tags ON tags.id = miner_tags.tag_id + WHERE miner_tags.miner_id = miners.id + ) tag_data ON true +""" + +MINER_CURRENT_SELECT = f""" + SELECT + miners.id, + miners.site_id, + miners.room_id, + miners.asset_tag, + miners.current_mac, + host(miners.current_ip) AS current_ip, + miners.hostname, + miners.miner_sn, + miners.control_board_sn, + miners.power_sn, + miners.model, + miners.firmware_version, + miners.lifecycle_status, + miners.status, + miners.notes, + miners.first_seen_at, + miners.last_seen_at, + miners.created_at, + miners.updated_at, + tag_data.tags, + latest.observed_at, + latest.hashrate_ths, + latest.power_w, + latest.chip_temp_max_c, + latest.hash_stable + FROM miners + LEFT JOIN LATERAL ( + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'id', tags.id, + 'name', tags.name, + 'created_at', tags.created_at, + 'updated_at', tags.updated_at + ) + ORDER BY lower({TAG_TRIMMED_NAME_SQL}), tags.id + ), + '[]'::jsonb + ) AS tags + FROM miner_tags + JOIN tags ON tags.id = miner_tags.tag_id + WHERE miner_tags.miner_id = miners.id + ) tag_data ON true + LEFT JOIN LATERAL ( + SELECT snapshots.observed_at, + ROUND((snapshots.mhs_av / 1000000.0)::numeric, 2)::float + AS hashrate_ths, + ROUND(snapshots.power_w::numeric, 2)::float AS power_w, + ROUND(snapshots.chip_temp_max_c::numeric, 2)::float + AS chip_temp_max_c, + snapshots.hash_stable + FROM miner_status_snapshots AS snapshots + WHERE snapshots.miner_id = miners.id + ORDER BY snapshots.observed_at DESC, snapshots.id DESC + LIMIT 1 + ) latest ON true +""" + +ACTIVE_REPAIR_STATUSES = ( + "OPEN", + "IN_PROGRESS", + "WAITING_FOR_PARTS", + "READY_FOR_TEST", +) + +OPERATIONAL_STATE_SQL = """ + CASE + WHEN active_repair.id IS NOT NULL OR miners.lifecycle_status = 'in_repair' + THEN 'repair' + WHEN miners.lifecycle_status = 'retired' THEN 'retired' + WHEN miners.lifecycle_status = 'spare' THEN 'spare' + WHEN lower(trim(miners.status)) = 'offline' THEN 'offline' + WHEN latest.hash_stable = false THEN 'unstable' + WHEN lower(trim(miners.status)) = 'online' THEN 'online' + ELSE 'unknown' + END +""" + +FLEET_SELECT = f""" + SELECT + miners.id, miners.asset_tag, miners.current_mac, + host(miners.current_ip) AS current_ip, miners.model, miners.miner_sn, + miners.lifecycle_status, miners.status, miners.created_at, + miners.updated_at, miners.last_seen_at, tag_data.tags, + latest.observed_at, latest.hashrate_ths, latest.power_w, + latest.chip_temp_max_c, latest.hash_stable, + active_repair.id AS active_repair_id, + active_repair.status AS active_repair_status, + active_repair.reason AS active_repair_reason, + {{location_fields}} + FROM miners + LEFT JOIN LATERAL ( + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'id', tags.id, 'name', tags.name, + 'created_at', tags.created_at, 'updated_at', tags.updated_at + ) ORDER BY lower({TAG_TRIMMED_NAME_SQL}), tags.id + ), '[]'::jsonb + ) AS tags + FROM miner_tags JOIN tags ON tags.id = miner_tags.tag_id + WHERE miner_tags.miner_id = miners.id + ) tag_data ON true + LEFT JOIN LATERAL ( + SELECT snapshots.observed_at, + ROUND((snapshots.mhs_av / 1000000.0)::numeric, 2)::float AS hashrate_ths, + ROUND(snapshots.power_w::numeric, 2)::float AS power_w, + ROUND(snapshots.chip_temp_max_c::numeric, 2)::float AS chip_temp_max_c, + snapshots.hash_stable + FROM miner_status_snapshots snapshots + WHERE snapshots.miner_id = miners.id + ORDER BY snapshots.observed_at DESC, snapshots.id DESC + LIMIT 1 + ) latest ON true + LEFT JOIN LATERAL ( + SELECT repairs.id, repairs.status, repairs.reason + FROM repairs + WHERE repairs.miner_id = miners.id AND repairs.status = ANY(%s) + ORDER BY repairs.opened_at, repairs.id + LIMIT 1 + ) active_repair ON true + {{location_join}} +""" + +LOCATION_FIELDS = """ + mapped.site_id, mapped.site_name, mapped.room_id, mapped.room_name, + mapped.location_position_id, mapped.rack_label, mapped.shelf_label, + mapped.position_label, mapped.position_index +""" + +REDACTED_LOCATION_FIELDS = """ + NULL::uuid AS site_id, NULL::text AS site_name, + NULL::uuid AS room_id, NULL::text AS room_name, + NULL::uuid AS location_position_id, NULL::text AS rack_label, + NULL::text AS shelf_label, NULL::text AS position_label, + NULL::integer AS position_index +""" + +LOCATION_JOIN = """ + LEFT JOIN LATERAL ( + SELECT sites.id AS site_id, sites.name AS site_name, rooms.id AS room_id, + COALESCE(rooms.name, lp.room_name) AS room_name, + lp.id AS location_position_id, lp.rack_name AS rack_label, + lp.shelf_name AS shelf_label, lp.position_label, lp.position_index + FROM location_positions lp + LEFT JOIN rooms ON rooms.id = lp.room_id + LEFT JOIN sites ON sites.id = rooms.site_id + WHERE lp.miner_id = miners.id + ORDER BY lp.id + LIMIT 1 + ) mapped ON true +""" + +MINER_WRITE_COLUMNS = { + "site_id", + "room_id", + "asset_tag", + "current_mac", + "current_ip", + "hostname", + "miner_sn", + "control_board_sn", + "power_sn", + "model", + "firmware_version", + "lifecycle_status", + "status", + "notes", + "first_seen_at", + "last_seen_at", +} + + +def default_connection_factory() -> psycopg.Connection: + return psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + connect_timeout=5, + ) + + +class InventoryRepository: + """Database-only inventory persistence with optional transaction binding.""" + + def __init__( + self, + connection_factory: ConnectionFactory | None = None, + *, + connection: psycopg.Connection | None = None, + ): + self._connection_factory = connection_factory or default_connection_factory + self._connection = connection + + @contextmanager + def transaction(self) -> Iterator["InventoryRepository"]: + if self._connection is not None: + yield self + return + + connection = self._connection_factory() + transaction_repository = InventoryRepository( + self._connection_factory, + connection=connection, + ) + try: + yield transaction_repository + connection.commit() + except Exception: + connection.rollback() + raise + finally: + connection.close() + + @contextmanager + def _connection_scope( + self, + *, + write: bool = False, + ) -> Iterator[psycopg.Connection]: + if self._connection is not None: + yield self._connection + return + + connection = self._connection_factory() + try: + yield connection + if write: + connection.commit() + except Exception: + if write: + connection.rollback() + raise + finally: + connection.close() + + @staticmethod + def _fetch_miner( + cur, + where_sql: str, + params: Sequence[Any], + *, + for_update: bool = False, + ) -> dict | None: + lock_sql = " FOR UPDATE OF miners" if for_update else "" + cur.execute( + f"{MINER_SELECT} WHERE {where_sql}{lock_sql}", + params, + ) + row = cur.fetchone() + return dict(row) if row else None + + def get_miner_by_id(self, miner_id: UUID) -> dict | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + return self._fetch_miner(cur, "miners.id = %s", (miner_id,)) + + def get_current_miner_by_id(self, miner_id: UUID) -> dict | None: + """Return Inventory identity with its latest factual telemetry.""" + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + f"{MINER_CURRENT_SELECT} WHERE miners.id = %s", + (miner_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + + def get_miner_by_id_for_update(self, miner_id: UUID) -> dict | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + return self._fetch_miner( + cur, + "miners.id = %s", + (miner_id,), + for_update=True, + ) + + def get_miner_by_asset_id(self, asset_id: str) -> dict | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + return self._fetch_miner( + cur, + "lower(trim(miners.asset_tag)) = lower(trim(%s))", + (asset_id,), + ) + + def list_miners( + self, + *, + limit: int = 100, + offset: int = 0, + include_location_details: bool = False, + ) -> list[dict]: + return self.search_miners( + limit=limit, + offset=offset, + include_location_details=include_location_details, + ) + + def summarize_fleet(self) -> dict[str, Any]: + """Return exact fleet-wide counts using Inventory state semantics.""" + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + f""" + WITH fleet_state AS ( + SELECT miners.status, miners.lifecycle_status, + ({OPERATIONAL_STATE_SQL}) AS operational_state + FROM miners + LEFT JOIN LATERAL ( + SELECT snapshots.hash_stable + FROM miner_status_snapshots AS snapshots + WHERE snapshots.miner_id = miners.id + ORDER BY snapshots.observed_at DESC, snapshots.id DESC + LIMIT 1 + ) AS latest ON true + LEFT JOIN LATERAL ( + SELECT repairs.id + FROM repairs + WHERE repairs.miner_id = miners.id + AND repairs.status = ANY(%s) + ORDER BY repairs.opened_at, repairs.id + LIMIT 1 + ) AS active_repair ON true + ) + SELECT count(*)::integer AS total, + count(*) FILTER ( + WHERE lower(trim(status)) = 'online' + )::integer AS runtime_online, + count(*) FILTER ( + WHERE lower(trim(status)) = 'offline' + )::integer AS runtime_offline, + count(*) FILTER ( + WHERE lower(trim(status)) NOT IN ('online', 'offline') + OR status IS NULL + )::integer AS runtime_unknown, + count(*) FILTER ( + WHERE lifecycle_status = 'in_repair' + )::integer AS lifecycle_in_repair, + count(*) FILTER ( + WHERE lifecycle_status = 'spare' + )::integer AS lifecycle_spare, + count(*) FILTER ( + WHERE lifecycle_status = 'retired' + )::integer AS lifecycle_retired, + count(*) FILTER ( + WHERE operational_state = 'offline' + )::integer AS attention_offline, + count(*) FILTER ( + WHERE operational_state = 'unknown' + )::integer AS attention_unknown, + count(*) FILTER ( + WHERE operational_state = 'unstable' + )::integer AS attention_unstable, + count(*) FILTER ( + WHERE operational_state = 'repair' + )::integer AS attention_repair + FROM fleet_state + """, + (list(ACTIVE_REPAIR_STATUSES),), + ) + return dict(cur.fetchone()) + + def search_miners( + self, + *, + query: str | None = None, + asset_id: str | None = None, + lifecycle_status: str | None = None, + model: str | None = None, + runtime_status: str | None = None, + repair: str | None = None, + operational_state: str | None = None, + problems: bool = False, + site_id: UUID | None = None, + room_id: UUID | None = None, + tag_names: Sequence[str] = (), + limit: int = 100, + offset: int = 0, + include_location_details: bool = False, + ) -> list[dict]: + conditions: list[str] = [] + params: list[Any] = [list(ACTIVE_REPAIR_STATUSES)] + + if query: + pattern = f"%{query}%" + conditions.append( + """ + ( + miners.asset_tag ILIKE %s + OR miners.current_mac ILIKE %s + OR host(miners.current_ip) ILIKE %s + OR miners.model ILIKE %s + OR miners.miner_sn ILIKE %s + OR miners.control_board_sn ILIKE %s + OR miners.power_sn ILIKE %s + OR miners.notes ILIKE %s + ) + """ + ) + params.extend([pattern] * 8) + if asset_id: + conditions.append("lower(trim(miners.asset_tag)) = lower(trim(%s))") + params.append(asset_id) + if lifecycle_status: + conditions.append("miners.lifecycle_status = %s") + params.append(lifecycle_status) + if model: + conditions.append("lower(trim(miners.model)) = lower(trim(%s))") + params.append(model) + if runtime_status: + conditions.append("lower(trim(miners.status)) = lower(trim(%s))") + params.append(runtime_status) + if repair == "active": + conditions.append("active_repair.id IS NOT NULL") + elif repair == "none": + conditions.append("active_repair.id IS NULL") + if operational_state: + conditions.append(f"({OPERATIONAL_STATE_SQL}) = %s") + params.append(operational_state) + if problems: + conditions.append( + f"({OPERATIONAL_STATE_SQL}) = ANY(%s)" + ) + params.append(["offline", "unknown", "unstable", "repair"]) + if site_id: + conditions.append("mapped.site_id = %s") + params.append(site_id) + if room_id: + conditions.append("mapped.room_id = %s") + params.append(room_id) + for tag_name in tag_names: + conditions.append( + f""" + EXISTS ( + SELECT 1 + FROM miner_tags search_miner_tags + JOIN tags search_tags ON search_tags.id = search_miner_tags.tag_id + WHERE search_miner_tags.miner_id = miners.id + AND lower({TAG_TRIMMED_NAME_SQL.replace("name", "search_tags.name")}) + = %s + ) + """ + ) + params.append(tag_name) + + location_fields = ( + LOCATION_FIELDS if include_location_details else REDACTED_LOCATION_FIELDS + ) + location_join = LOCATION_JOIN if include_location_details else "" + select_sql = FLEET_SELECT.format( + location_fields=location_fields, + location_join=location_join, + ) + where_sql = " AND ".join(conditions) if conditions else "true" + params.extend([limit, offset]) + + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + f""" + {select_sql} + WHERE {where_sql} + ORDER BY lower(trim(miners.asset_tag)) NULLS LAST, + miners.created_at, miners.id + LIMIT %s OFFSET %s + """, + params, + ) + return [dict(row) for row in cur.fetchall()] + + def list_location_filter_options(self) -> dict[str, list[dict]]: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT sites.id, sites.name + FROM location_positions lp + JOIN miners ON miners.id = lp.miner_id + JOIN rooms ON rooms.id = lp.room_id + JOIN sites ON sites.id = rooms.site_id + GROUP BY sites.id, sites.name + ORDER BY lower(sites.name), sites.id + """ + ) + sites = [dict(row) for row in cur.fetchall()] + cur.execute( + """ + SELECT rooms.id, rooms.site_id, rooms.name + FROM location_positions lp + JOIN miners ON miners.id = lp.miner_id + JOIN rooms ON rooms.id = lp.room_id + GROUP BY rooms.id, rooms.site_id, rooms.name + ORDER BY lower(rooms.name), rooms.id + """ + ) + rooms = [dict(row) for row in cur.fetchall()] + return {"sites": sites, "rooms": rooms} + + def create_miner(self, values: Mapping[str, Any]) -> dict: + clean_values = { + key: value for key, value in values.items() if key in MINER_WRITE_COLUMNS + } + columns = list(clean_values) + params = [clean_values[column] for column in columns] + + with self._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + if columns: + placeholders = ", ".join(["%s"] * len(columns)) + cur.execute( + f""" + INSERT INTO miners ({", ".join(columns)}) + VALUES ({placeholders}) + RETURNING id + """, + params, + ) + else: + cur.execute("INSERT INTO miners DEFAULT VALUES RETURNING id") + miner_id = cur.fetchone()["id"] + return self._fetch_miner(cur, "miners.id = %s", (miner_id,)) + + def update_miner(self, miner_id: UUID, values: Mapping[str, Any]) -> dict | None: + clean_values = { + key: value for key, value in values.items() if key in MINER_WRITE_COLUMNS + } + if not clean_values: + return self.get_miner_by_id(miner_id) + + assignments = [f"{column} = %s" for column in clean_values] + params = list(clean_values.values()) + params.append(miner_id) + + with self._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + f""" + UPDATE miners + SET {", ".join(assignments)}, updated_at = now() + WHERE id = %s + RETURNING id + """, + params, + ) + row = cur.fetchone() + if not row: + return None + return self._fetch_miner(cur, "miners.id = %s", (miner_id,)) + + def add_asset_id_history( + self, + miner_id: UUID, + old_asset_id: str | None, + new_asset_id: str, + changed_by: UUID, + ) -> dict: + with self._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + INSERT INTO miner_asset_id_history ( + miner_id, + old_asset_id, + new_asset_id, + changed_by + ) + VALUES (%s, %s, %s, %s) + RETURNING + id, + miner_id, + old_asset_id, + new_asset_id, + changed_at, + changed_by + """, + (miner_id, old_asset_id, new_asset_id, changed_by), + ) + return dict(cur.fetchone()) + + def list_asset_id_history(self, miner_id: UUID) -> list[dict]: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT + history.id, + history.miner_id, + history.old_asset_id, + history.new_asset_id, + history.changed_at, + history.changed_by, + changed_by_user.display_name + AS changed_by_display_name + FROM miner_asset_id_history AS history + JOIN users AS changed_by_user + ON changed_by_user.id = history.changed_by + WHERE history.miner_id = %s + ORDER BY history.changed_at, history.id + """, + (miner_id,), + ) + return [dict(row) for row in cur.fetchall()] + + def list_tags(self) -> list[dict]: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + f""" + SELECT id, name, created_at, updated_at + FROM tags + ORDER BY lower({TAG_TRIMMED_NAME_SQL}), id + """ + ) + return [dict(row) for row in cur.fetchall()] + + def add_tag(self, miner_id: UUID, tag_name: str) -> dict: + with self._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + f""" + INSERT INTO tags (name) + VALUES (%s) + ON CONFLICT (lower({TAG_TRIMMED_NAME_SQL})) + DO UPDATE SET updated_at = tags.updated_at + RETURNING id, name, created_at, updated_at + """, + (tag_name,), + ) + tag = dict(cur.fetchone()) + cur.execute( + """ + INSERT INTO miner_tags (miner_id, tag_id) + VALUES (%s, %s) + ON CONFLICT (miner_id, tag_id) DO NOTHING + """, + (miner_id, tag["id"]), + ) + return tag + + def remove_tag(self, miner_id: UUID, tag_id: UUID) -> bool: + with self._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute( + "DELETE FROM miner_tags WHERE miner_id = %s AND tag_id = %s", + (miner_id, tag_id), + ) + return cur.rowcount > 0 diff --git a/backend/app/inventory/router.py b/backend/app/inventory/router.py new file mode 100644 index 0000000..5c3fafe --- /dev/null +++ b/backend/app/inventory/router.py @@ -0,0 +1,175 @@ +"""Read-only HTTP routes for the Inventory domain.""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from app.identity import require_permission, user_has_permission +from app.inventory.dependencies import get_inventory_service +from app.inventory.exceptions import ( + DuplicateAssetID, + InventoryValidationError, + MinerNotFound, +) +from app.inventory.models import ( + InventoryAssetIDHistoryResponse, + InventoryCreateRequest, + InventoryListMeta, + InventoryLocationOptions, + InventoryMetadataUpdateRequest, + InventoryMinerDetailResponse, + InventoryMinerListResponse, + InventorySearchFilters, +) +from app.inventory.service import InventoryService + + +router = APIRouter(prefix="/api/inventory/miners", tags=["inventory"]) +require_inventory_read = require_permission("miners.view") +require_inventory_write = require_permission("miners.update") + +InventoryServiceDependency = Annotated[ + InventoryService, + Depends(get_inventory_service), +] +InventoryReadUser = Annotated[dict, Depends(require_inventory_read)] +InventoryWriteUser = Annotated[dict, Depends(require_inventory_write)] +InventoryQuery = Annotated[InventorySearchFilters, Query()] + + +@router.post( + "", + response_model=InventoryMinerDetailResponse, + status_code=status.HTTP_201_CREATED, +) +def create_inventory_miner( + request: InventoryCreateRequest, + service: InventoryServiceDependency, + current_user: InventoryWriteUser, +) -> InventoryMinerDetailResponse: + del current_user + try: + miner = service.create_miner(request) + except DuplicateAssetID as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + except InventoryValidationError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + return InventoryMinerDetailResponse(data=miner) + + +@router.patch("/{miner_id}", response_model=InventoryMinerDetailResponse) +def update_inventory_miner_metadata( + miner_id: UUID, + request: InventoryMetadataUpdateRequest, + service: InventoryServiceDependency, + current_user: InventoryWriteUser, +) -> InventoryMinerDetailResponse: + try: + miner = service.update_metadata( + miner_id, + request, + actor_user_id=UUID(str(current_user["id"])), + ) + except MinerNotFound as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Miner not found", + ) from exc + except DuplicateAssetID as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + except InventoryValidationError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + return InventoryMinerDetailResponse(data=miner) + + +@router.get("", response_model=InventoryMinerListResponse) +def list_inventory_miners( + filters: InventoryQuery, + service: InventoryServiceDependency, + current_user: InventoryReadUser, +) -> InventoryMinerListResponse: + include_location_details = user_has_permission(current_user, "locations.view") + if (filters.site_id or filters.room_id) and not include_location_details: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Location viewing permission is required for location filters", + ) + try: + miners = service.search_miners( + filters, + include_location_details=include_location_details, + ) + except InventoryValidationError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + location_options = InventoryLocationOptions() + if include_location_details: + location_options = InventoryLocationOptions.model_validate( + service.list_location_filter_options() + ) + return InventoryMinerListResponse( + data=miners, + meta=InventoryListMeta( + limit=filters.limit, + offset=filters.offset, + returned=len(miners), + location_options=location_options, + ), + ) + + +@router.get( + "/{miner_id}/asset-id-history", + response_model=InventoryAssetIDHistoryResponse, +) +def get_inventory_miner_asset_id_history( + miner_id: UUID, + service: InventoryServiceDependency, + current_user: InventoryReadUser, +) -> InventoryAssetIDHistoryResponse: + del current_user + try: + history = service.get_asset_id_history(miner_id) + except MinerNotFound as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Miner not found", + ) from exc + + return InventoryAssetIDHistoryResponse(data=history) + + +@router.get("/{miner_id}", response_model=InventoryMinerDetailResponse) +def get_inventory_miner( + miner_id: UUID, + service: InventoryServiceDependency, + current_user: InventoryReadUser, +) -> InventoryMinerDetailResponse: + del current_user + try: + miner = service.get_miner(miner_id) + except MinerNotFound as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Miner not found", + ) from exc + + return InventoryMinerDetailResponse(data=miner) diff --git a/backend/app/inventory/service.py b/backend/app/inventory/service.py new file mode 100644 index 0000000..db2d718 --- /dev/null +++ b/backend/app/inventory/service.py @@ -0,0 +1,451 @@ +"""Business rules and orchestration for manual miner inventory.""" + +from collections.abc import Iterable +from typing import Any +from uuid import UUID + +from psycopg.errors import UniqueViolation + +from app.inventory.exceptions import ( + DuplicateAssetID, + InventoryValidationError, + MinerNotFound, +) +from app.inventory.models import ( + InventoryAssetIDHistoryEntry, + InventoryCreateRequest, + InventoryFleetAttentionSummary, + InventoryFleetSummary, + InventoryMetadataUpdateRequest, + InventoryMinerDetail, + InventoryMinerSummary, + InventorySearchFilters, + InventoryTag, + InventoryUpdateRequest, +) +from app.inventory.repository import InventoryRepository + + +ALLOWED_LIFECYCLE_STATUSES = frozenset( + {"active", "spare", "retired", "in_repair"} +) + + +class InventoryService: + """Inventory business rules independent of FastAPI and other transports.""" + + def __init__(self, repository: InventoryRepository): + self.repository = repository + + @staticmethod + def normalize_asset_id(asset_id: str) -> str: + normalized = str(asset_id or "").strip() + if not normalized: + raise InventoryValidationError("Asset ID cannot be blank") + return normalized + + @staticmethod + def validate_lifecycle_status(lifecycle_status: str) -> str: + normalized = str(lifecycle_status or "").strip().lower() + if normalized not in ALLOWED_LIFECYCLE_STATUSES: + allowed = ", ".join(sorted(ALLOWED_LIFECYCLE_STATUSES)) + raise InventoryValidationError( + f"Lifecycle status must be one of: {allowed}" + ) + return normalized + + @staticmethod + def normalize_tags(tags: Iterable[str]) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for raw_tag in tags: + tag = str(raw_tag or "").strip() + if not tag: + raise InventoryValidationError("Tag names cannot be blank") + key = tag.casefold() + if key not in seen: + normalized.append(tag) + seen.add(key) + return normalized + + @staticmethod + def _clean_optional_text(value: str | None) -> str | None: + if value is None: + return None + cleaned = str(value).strip() + return cleaned or None + + @staticmethod + def _tag_model(row: dict) -> InventoryTag: + return InventoryTag.model_validate(row) + + @classmethod + def _summary_model(cls, row: dict) -> InventoryMinerSummary: + active_repair = None + if row.get("active_repair_id") is not None: + active_repair = { + "id": row["active_repair_id"], + "status": row["active_repair_status"], + "reason": row["active_repair_reason"], + } + return InventoryMinerSummary( + id=row["id"], + asset_id=row.get("asset_tag"), + current_mac=row.get("current_mac"), + current_ip=row.get("current_ip"), + model=row.get("model"), + miner_sn=row.get("miner_sn"), + lifecycle_status=row["lifecycle_status"], + runtime_status=row["status"], + site_id=row.get("site_id"), + site_name=row.get("site_name"), + room_id=row.get("room_id"), + room_name=row.get("room_name"), + location_position_id=row.get("location_position_id"), + rack_label=row.get("rack_label"), + shelf_label=row.get("shelf_label"), + position_label=row.get("position_label"), + position_index=row.get("position_index"), + observed_at=row.get("observed_at"), + hashrate_ths=row.get("hashrate_ths"), + power_w=row.get("power_w"), + chip_temp_max_c=row.get("chip_temp_max_c"), + hash_stable=row.get("hash_stable"), + active_repair=active_repair, + tags=[cls._tag_model(tag) for tag in row.get("tags") or []], + created_at=row["created_at"], + updated_at=row["updated_at"], + last_seen_at=row.get("last_seen_at"), + ) + + @classmethod + def _detail_model(cls, row: dict) -> InventoryMinerDetail: + summary = cls._summary_model(row) + return InventoryMinerDetail( + **summary.model_dump(), + hostname=row.get("hostname"), + control_board_sn=row.get("control_board_sn"), + psu_serial=row.get("power_sn"), + firmware_version=row.get("firmware_version"), + notes=row.get("notes"), + first_seen_at=row.get("first_seen_at"), + ) + + def get_miner(self, miner_id: UUID) -> InventoryMinerDetail: + row = self.repository.get_miner_by_id(miner_id) + if not row: + raise MinerNotFound(miner_id) + return self._detail_model(row) + + def get_current_miner(self, miner_id: UUID) -> InventoryMinerDetail: + """Return current Inventory identity and the latest observation.""" + row = self.repository.get_current_miner_by_id(miner_id) + if not row: + raise MinerNotFound(miner_id) + return self._detail_model(row) + + def get_asset_id_history( + self, + miner_id: UUID, + ) -> list[InventoryAssetIDHistoryEntry]: + if not self.repository.get_miner_by_id(miner_id): + raise MinerNotFound(miner_id) + return [ + InventoryAssetIDHistoryEntry.model_validate(row) + for row in self.repository.list_asset_id_history(miner_id) + ] + + def list_miners( + self, + *, + limit: int = 100, + offset: int = 0, + ) -> list[InventoryMinerSummary]: + return [ + self._summary_model(row) + for row in self.repository.list_miners(limit=limit, offset=offset) + ] + + def summarize_fleet(self) -> InventoryFleetSummary: + row = self.repository.summarize_fleet() + return InventoryFleetSummary( + total=row["total"], + runtime_online=row["runtime_online"], + runtime_offline=row["runtime_offline"], + runtime_unknown=row["runtime_unknown"], + lifecycle_in_repair=row["lifecycle_in_repair"], + lifecycle_spare=row["lifecycle_spare"], + lifecycle_retired=row["lifecycle_retired"], + attention=InventoryFleetAttentionSummary( + offline=row["attention_offline"], + unknown=row["attention_unknown"], + unstable=row["attention_unstable"], + repair=row["attention_repair"], + ), + ) + + def search_miners( + self, + filters: InventorySearchFilters, + *, + include_location_details: bool = False, + ) -> list[InventoryMinerSummary]: + lifecycle_status = None + if filters.lifecycle_status: + lifecycle_status = self.validate_lifecycle_status( + filters.lifecycle_status + ) + if (filters.site_id or filters.room_id) and not include_location_details: + raise InventoryValidationError( + "Location filters require location viewing permission" + ) + asset_id = None + if filters.asset_id: + asset_id = self.normalize_asset_id(filters.asset_id) + tag_names = [ + tag.casefold() for tag in self.normalize_tags(filters.tags) + ] + return [ + self._summary_model(row) + for row in self.repository.search_miners( + query=self._clean_optional_text(filters.query), + asset_id=asset_id, + lifecycle_status=lifecycle_status, + model=self._clean_optional_text(filters.model), + runtime_status=self._clean_optional_text(filters.runtime_status), + repair=filters.repair, + operational_state=filters.operational_state, + problems=filters.problems, + site_id=filters.site_id, + room_id=filters.room_id, + tag_names=tag_names, + limit=filters.limit, + offset=filters.offset, + include_location_details=include_location_details, + ) + ] + + def list_location_filter_options(self) -> dict[str, list[dict]]: + return self.repository.list_location_filter_options() + + def create_miner( + self, + request: InventoryCreateRequest, + ) -> InventoryMinerDetail: + asset_id = self.normalize_asset_id(request.asset_id) + lifecycle_status = self.validate_lifecycle_status( + request.lifecycle_status + ) + tags = self.normalize_tags(request.tags) + + if self.repository.get_miner_by_asset_id(asset_id): + raise DuplicateAssetID(asset_id) + + values: dict[str, Any] = { + "asset_tag": asset_id, + "current_mac": self._clean_optional_text(request.current_mac), + "current_ip": str(request.current_ip) if request.current_ip else None, + "hostname": self._clean_optional_text(request.hostname), + "miner_sn": self._clean_optional_text(request.miner_sn), + "control_board_sn": self._clean_optional_text( + request.control_board_sn + ), + "power_sn": self._clean_optional_text(request.psu_serial), + "model": self._clean_optional_text(request.model), + "firmware_version": self._clean_optional_text( + request.firmware_version + ), + "lifecycle_status": lifecycle_status, + "site_id": request.site_id, + "room_id": request.room_id, + "notes": self._clean_optional_text(request.notes), + "status": "unknown", + "first_seen_at": None, + "last_seen_at": None, + } + + try: + with self.repository.transaction() as repository: + row = repository.create_miner(values) + for tag in tags: + repository.add_tag(row["id"], tag) + row = repository.get_miner_by_id(row["id"]) + self._after_create(row) + except UniqueViolation as exc: + raise DuplicateAssetID(asset_id) from exc + + return self._detail_model(row) + + def update_metadata( + self, + miner_id: UUID, + request: InventoryMetadataUpdateRequest, + *, + actor_user_id: UUID, + ) -> InventoryMinerDetail: + fields_set = request.model_fields_set + if not fields_set: + raise InventoryValidationError( + "At least one metadata field must be provided" + ) + + requested_asset_id = None + if "asset_id" in fields_set: + if request.asset_id is None: + raise InventoryValidationError("Asset ID cannot be cleared") + requested_asset_id = self.normalize_asset_id(request.asset_id) + + requested_values: dict[str, Any] = {} + field_map = { + "miner_sn": "miner_sn", + "psu_serial": "power_sn", + "model": "model", + "notes": "notes", + } + for model_field, database_column in field_map.items(): + if model_field in fields_set: + requested_values[database_column] = self._clean_optional_text( + getattr(request, model_field) + ) + + try: + with self.repository.transaction() as repository: + existing = repository.get_miner_by_id_for_update(miner_id) + if not existing: + raise MinerNotFound(miner_id) + + previous_asset_id = ( + str(existing.get("asset_tag") or "").strip() or None + ) + asset_id_changed = ( + requested_asset_id is not None + and requested_asset_id != previous_asset_id + ) + + values = { + key: value + for key, value in requested_values.items() + if existing.get(key) != value + } + if asset_id_changed: + duplicate = repository.get_miner_by_asset_id( + requested_asset_id + ) + if duplicate and duplicate["id"] != miner_id: + raise DuplicateAssetID(requested_asset_id) + values["asset_tag"] = requested_asset_id + + if not values: + return self._detail_model(existing) + + self._before_update(existing, values) + row = repository.update_miner(miner_id, values) + if asset_id_changed: + repository.add_asset_id_history( + miner_id, + previous_asset_id, + requested_asset_id, + actor_user_id, + ) + row = repository.get_miner_by_id(miner_id) + self._after_update(row) + except UniqueViolation as exc: + asset_id = requested_asset_id or "unknown" + raise DuplicateAssetID(asset_id) from exc + + return self._detail_model(row) + + def update_miner( + self, + miner_id: UUID, + request: InventoryUpdateRequest, + ) -> InventoryMinerDetail: + existing = self.repository.get_miner_by_id(miner_id) + if not existing: + raise MinerNotFound(miner_id) + + values: dict[str, Any] = {} + fields_set = request.model_fields_set + + if "asset_id" in fields_set: + if request.asset_id is None: + raise InventoryValidationError("Asset ID cannot be cleared") + asset_id = self.normalize_asset_id(request.asset_id) + existing_asset_id = ( + str(existing.get("asset_tag") or "").strip() or None + ) + if asset_id != existing_asset_id: + raise InventoryValidationError( + "Asset ID changes require actor-attributed metadata update" + ) + else: + asset_id = existing["asset_tag"] + + if "lifecycle_status" in fields_set: + if request.lifecycle_status is None: + raise InventoryValidationError( + "Lifecycle status cannot be cleared" + ) + values["lifecycle_status"] = self.validate_lifecycle_status( + request.lifecycle_status + ) + + field_map = { + "current_mac": "current_mac", + "current_ip": "current_ip", + "hostname": "hostname", + "miner_sn": "miner_sn", + "control_board_sn": "control_board_sn", + "psu_serial": "power_sn", + "model": "model", + "firmware_version": "firmware_version", + "site_id": "site_id", + "room_id": "room_id", + "notes": "notes", + } + for model_field, database_column in field_map.items(): + if model_field not in fields_set: + continue + value = getattr(request, model_field) + if model_field == "current_ip": + values[database_column] = str(value) if value else None + elif isinstance(value, str) or value is None: + values[database_column] = self._clean_optional_text(value) + else: + values[database_column] = value + + desired_tags = None + if "tags" in fields_set: + desired_tags = self.normalize_tags(request.tags or []) + + try: + with self.repository.transaction() as repository: + self._before_update(existing, values) + row = repository.update_miner(miner_id, values) + if desired_tags is not None: + existing_tags = { + str(tag["name"]).strip().casefold(): tag + for tag in row.get("tags") or [] + } + desired_keys = {tag.casefold() for tag in desired_tags} + for key, tag in existing_tags.items(): + if key not in desired_keys: + repository.remove_tag(miner_id, tag["id"]) + for tag in desired_tags: + if tag.casefold() not in existing_tags: + repository.add_tag(miner_id, tag) + row = repository.get_miner_by_id(miner_id) + self._after_update(row) + except UniqueViolation as exc: + raise DuplicateAssetID(asset_id) from exc + + return self._detail_model(row) + + def _after_create(self, miner: dict) -> None: + """Extension point for later inventory integrations.""" + + def _before_update(self, existing: dict, changes: dict[str, Any]) -> None: + """Extension point for later inventory integrations.""" + + def _after_update(self, miner: dict) -> None: + """Extension point for later inventory integrations.""" diff --git a/backend/app/locations/__init__.py b/backend/app/locations/__init__.py new file mode 100644 index 0000000..4ebae0d --- /dev/null +++ b/backend/app/locations/__init__.py @@ -0,0 +1,7 @@ +"""Mine Map location read domain.""" + +from app.locations.models import MineMapResponse +from app.locations.repository import LocationMapRepository +from app.locations.service import LocationMapService + +__all__ = ["LocationMapRepository", "LocationMapService", "MineMapResponse"] diff --git a/backend/app/locations/dependencies.py b/backend/app/locations/dependencies.py new file mode 100644 index 0000000..7d85b5a --- /dev/null +++ b/backend/app/locations/dependencies.py @@ -0,0 +1,8 @@ +"""Dependency construction for the location read domain.""" + +from app.locations.repository import LocationMapRepository +from app.locations.service import LocationMapService + + +def get_location_map_service() -> LocationMapService: + return LocationMapService(LocationMapRepository()) diff --git a/backend/app/locations/models.py b/backend/app/locations/models.py new file mode 100644 index 0000000..5986509 --- /dev/null +++ b/backend/app/locations/models.py @@ -0,0 +1,140 @@ +"""Typed contracts for the read-only Mine Map.""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + + +class MapSite(BaseModel): + id: UUID + name: str + + +class MapRoom(BaseModel): + id: UUID + site_id: UUID + site_name: str + name: str + sort_order: int + + +class MapActiveRepair(BaseModel): + id: UUID + status: str + reason: str + + +class MapPosition(BaseModel): + location_position_id: UUID + site_id: UUID | None = None + site_name: str | None = None + room_id: UUID | None = None + room_name: str + rack_name: str + shelf_name: str + shelf_index: int | None = None + position_index: int + position_label: str | None = None + position_kind: str + position_status: str + occupied: bool + expected_mac: str | None = None + equipment_label: str | None = None + notes: str | None = None + source_file: str | None = None + source_cell: str | None = None + conflict_reason: str | None = None + miner_id: UUID | None = None + asset_id: str | None = None + current_mac: str | None = None + current_ip: str | None = None + miner_status: str | None = None + hostname: str | None = None + miner_sn: str | None = None + power_sn: str | None = None + model: str | None = None + miner_type: str | None = None + firmware_version: str | None = None + lifecycle_status: str | None = None + last_seen_at: datetime | None = None + asset_state: str | None = None + observed_at: datetime | None = None + hashrate_ths: float | None = None + power_w: float | None = None + chip_temp_max_c: float | None = None + chip_temp_avg_c: float | None = None + hash_stable: bool | None = None + pool_rejected_percent: float | None = None + active_repair: MapActiveRepair | None = None + + +class MapVirtualMiner(BaseModel): + miner_id: UUID | None = None + site_id: UUID | None = None + room_id: UUID | None = None + asset_id: str | None = None + asset_state: str + current_mac: str | None = None + current_ip: str | None = None + miner_status: str | None = None + status: str | None = None + hostname: str | None = None + miner_sn: str | None = None + power_sn: str | None = None + model: str | None = None + miner_type: str | None = None + firmware_version: str | None = None + lifecycle_status: str | None = None + last_seen_at: datetime | None = None + observed_at: datetime | None = None + hashrate_ths: float | None = None + power_w: float | None = None + chip_temp_max_c: float | None = None + chip_temp_avg_c: float | None = None + hash_stable: bool | None = None + active_repair: MapActiveRepair | None = None + + +class MineMapData(BaseModel): + sites: list[MapSite] + rooms: list[MapRoom] + positions: list[MapPosition] + virtual_miners: list[MapVirtualMiner] + + +class MineMapMeta(BaseModel): + generated_at: datetime + site_id: UUID | None = None + room_id: UUID | None = None + position_count: int + virtual_miner_count: int + miner_details_included: bool + + +class MineMapResponse(BaseModel): + data: MineMapData + meta: MineMapMeta + error: None = None + + +class MinerLocationDetail(BaseModel): + site_id: UUID | None = None + site_name: str | None = None + room_id: UUID | None = None + room_name: str | None = None + position_id: UUID + rack_label: str | None = None + shelf_label: str | None = None + position_label: str | None = None + position_index: int | None = None + + +class LocationSummary(BaseModel): + sites: int + rooms: int + positions: int + occupied_positions: int + empty_positions: int + mapped_miners: int | None = None + unmapped_miners: int | None = None diff --git a/backend/app/locations/repository.py b/backend/app/locations/repository.py new file mode 100644 index 0000000..10d0550 --- /dev/null +++ b/backend/app/locations/repository.py @@ -0,0 +1,257 @@ +"""Set-based PostgreSQL reads for Mine Map v1.""" + +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from typing import Any +from uuid import UUID + +import psycopg +from psycopg.rows import dict_row + +from app.config import settings + + +ConnectionFactory = Callable[[], psycopg.Connection] +ACTIVE_REPAIR_STATUSES = ( + "OPEN", + "IN_PROGRESS", + "WAITING_FOR_PARTS", + "READY_FOR_TEST", +) + + +def default_connection_factory() -> psycopg.Connection: + return psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + connect_timeout=5, + ) + + +LATEST_TELEMETRY_SQL = """ + LEFT JOIN LATERAL ( + SELECT snapshots.observed_at, + ROUND((snapshots.mhs_av / 1000000.0)::numeric, 2)::float + AS hashrate_ths, + ROUND(snapshots.power_w::numeric, 2)::float AS power_w, + ROUND(snapshots.chip_temp_max_c::numeric, 2)::float + AS chip_temp_max_c, + ROUND(snapshots.chip_temp_avg_c::numeric, 2)::float + AS chip_temp_avg_c, + snapshots.hash_stable, snapshots.pool_rejected_percent + FROM miner_status_snapshots snapshots + WHERE snapshots.miner_id = miners.id + ORDER BY snapshots.observed_at DESC, snapshots.id DESC + LIMIT 1 + ) latest ON true +""" + +ACTIVE_REPAIR_SQL = """ + LEFT JOIN LATERAL ( + SELECT repairs.id, repairs.status, repairs.reason + FROM repairs + WHERE repairs.miner_id = miners.id AND repairs.status = ANY(%s) + ORDER BY repairs.opened_at, repairs.id + LIMIT 1 + ) active_repair ON true +""" + + +class LocationMapRepository: + """Own all persistence used by the Mine Map read model.""" + + def __init__( + self, + connection_factory: ConnectionFactory | None = None, + *, + connection: psycopg.Connection | None = None, + ): + self._connection_factory = connection_factory or default_connection_factory + self._connection = connection + + @contextmanager + def _connection_scope(self) -> Iterator[psycopg.Connection]: + if self._connection is not None: + yield self._connection + return + connection = self._connection_factory() + try: + yield connection + finally: + connection.close() + + def read_map( + self, + *, + site_id: UUID | None = None, + room_id: UUID | None = None, + ) -> dict[str, list[dict[str, Any]]]: + position_where = [] + position_params: list[Any] = [list(ACTIVE_REPAIR_STATUSES)] + virtual_where = [ + "COALESCE(mls.asset_state, 'waiting_placement') = ANY(%s)", + "NOT EXISTS (SELECT 1 FROM location_positions assigned " + "WHERE assigned.miner_id = miners.id)", + ] + virtual_params: list[Any] = [ + list(ACTIVE_REPAIR_STATUSES), + ["waiting_placement", "storage", "repair", "decommissioned"], + ] + if site_id is not None: + position_where.append("rooms.site_id = %s") + position_params.append(site_id) + virtual_where.append("miners.site_id = %s") + virtual_params.append(site_id) + if room_id is not None: + position_where.append("lp.room_id = %s") + position_params.append(room_id) + virtual_where.append("miners.room_id = %s") + virtual_params.append(room_id) + position_filter = ( + "WHERE " + " AND ".join(position_where) if position_where else "" + ) + virtual_filter = "WHERE " + " AND ".join(virtual_where) + + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute("SELECT id, name FROM sites ORDER BY lower(name), id") + sites = [dict(row) for row in cur.fetchall()] + cur.execute( + """ + SELECT rooms.id, rooms.site_id, sites.name AS site_name, + rooms.name, rooms.sort_order + FROM rooms JOIN sites ON sites.id = rooms.site_id + ORDER BY lower(sites.name), rooms.sort_order, + lower(rooms.name), rooms.id + """ + ) + rooms = [dict(row) for row in cur.fetchall()] + cur.execute( + f""" + SELECT lp.id AS location_position_id, sites.id AS site_id, + sites.name AS site_name, rooms.id AS room_id, + COALESCE(rooms.name, lp.room_name) AS room_name, + lp.rack_name, lp.shelf_name, lp.shelf_index, + lp.position_index, lp.position_label, lp.position_kind, + lp.position_status, (miners.id IS NOT NULL) AS occupied, + lp.expected_mac, lp.equipment_label, lp.notes, + lp.source_file, lp.source_cell, lp.conflict_reason, + miners.id AS miner_id, miners.asset_tag AS asset_id, + miners.current_mac, host(miners.current_ip) AS current_ip, + miners.status AS miner_status, miners.hostname, + miners.miner_sn, miners.power_sn, miners.model, + miners.miner_type, miners.firmware_version, + miners.lifecycle_status, miners.last_seen_at, + COALESCE(mls.asset_state, 'unassigned') AS asset_state, + latest.observed_at, latest.hashrate_ths, latest.power_w, + latest.chip_temp_max_c, latest.chip_temp_avg_c, + latest.hash_stable, latest.pool_rejected_percent, + active_repair.id AS active_repair_id, + active_repair.status AS active_repair_status, + active_repair.reason AS active_repair_reason + FROM location_positions lp + LEFT JOIN rooms ON rooms.id = lp.room_id + LEFT JOIN sites ON sites.id = rooms.site_id + LEFT JOIN miners ON miners.id = lp.miner_id + LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id + {LATEST_TELEMETRY_SQL} + {ACTIVE_REPAIR_SQL} + {position_filter} + ORDER BY lower(sites.name) NULLS LAST, + rooms.sort_order NULLS LAST, + lower(COALESCE(rooms.name, lp.room_name)), + lower(lp.rack_name), lp.shelf_index NULLS LAST, + lower(lp.shelf_name), lp.position_index, lp.id + """, + position_params, + ) + positions = [dict(row) for row in cur.fetchall()] + cur.execute( + f""" + SELECT miners.id AS miner_id, miners.site_id, miners.room_id, + miners.asset_tag AS asset_id, + COALESCE(mls.asset_state, 'waiting_placement') AS asset_state, + miners.current_mac, host(miners.current_ip) AS current_ip, + miners.status AS miner_status, miners.status, miners.hostname, + miners.miner_sn, miners.power_sn, miners.model, + miners.miner_type, miners.firmware_version, + miners.lifecycle_status, miners.last_seen_at, + latest.observed_at, latest.hashrate_ths, latest.power_w, + latest.chip_temp_max_c, latest.chip_temp_avg_c, + latest.hash_stable, + active_repair.id AS active_repair_id, + active_repair.status AS active_repair_status, + active_repair.reason AS active_repair_reason + FROM miners + LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id + {LATEST_TELEMETRY_SQL} + {ACTIVE_REPAIR_SQL} + {virtual_filter} + ORDER BY asset_state, lower(miners.asset_tag), miners.id + """, + virtual_params, + ) + virtual_miners = [dict(row) for row in cur.fetchall()] + return { + "sites": sites, + "rooms": rooms, + "positions": positions, + "virtual_miners": virtual_miners, + } + + def summarize_locations(self, *, include_miner_counts: bool) -> dict[str, Any]: + miner_counts = ( + "count(DISTINCT lp.miner_id)::integer AS mapped_miners, " + "(SELECT count(*)::integer FROM miners WHERE NOT EXISTS (" + "SELECT 1 FROM location_positions assigned " + "WHERE assigned.miner_id = miners.id)) AS unmapped_miners" + if include_miner_counts + else "NULL::integer AS mapped_miners, NULL::integer AS unmapped_miners" + ) + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + f""" + SELECT (SELECT count(*)::integer FROM sites) AS sites, + (SELECT count(*)::integer FROM rooms) AS rooms, + count(lp.id)::integer AS positions, + count(lp.id) FILTER ( + WHERE lp.miner_id IS NOT NULL + )::integer AS occupied_positions, + count(lp.id) FILTER ( + WHERE lp.miner_id IS NULL + )::integer AS empty_positions, + {miner_counts} + FROM location_positions AS lp + """ + ) + return dict(cur.fetchone()) + + def get_miner_location(self, miner_id: UUID) -> dict[str, Any] | None: + """Return the one current physical position assigned to a miner.""" + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT sites.id AS site_id, sites.name AS site_name, + rooms.id AS room_id, + COALESCE(rooms.name, position.room_name) AS room_name, + position.id AS position_id, + position.rack_name AS rack_label, + position.shelf_name AS shelf_label, + position.position_label, + position.position_index + FROM location_positions AS position + LEFT JOIN rooms ON rooms.id = position.room_id + LEFT JOIN sites ON sites.id = rooms.site_id + WHERE position.miner_id = %s + ORDER BY position.id + LIMIT 1 + """, + (miner_id,), + ) + row = cur.fetchone() + return dict(row) if row else None diff --git a/backend/app/locations/router.py b/backend/app/locations/router.py new file mode 100644 index 0000000..625f89d --- /dev/null +++ b/backend/app/locations/router.py @@ -0,0 +1,30 @@ +"""HTTP boundary for the read-only Mine Map API.""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Query + +from app.identity import require_permission, user_has_permission +from app.locations.dependencies import get_location_map_service +from app.locations.models import MineMapResponse +from app.locations.service import LocationMapService + + +router = APIRouter(prefix="/api/location", tags=["locations"]) +MapUser = Annotated[dict, Depends(require_permission("locations.view"))] +MapService = Annotated[LocationMapService, Depends(get_location_map_service)] + + +@router.get("/map", response_model=MineMapResponse) +def get_mine_map( + current_user: MapUser, + service: MapService, + site_id: Annotated[UUID | None, Query()] = None, + room_id: Annotated[UUID | None, Query()] = None, +) -> MineMapResponse: + return service.get_map( + site_id=site_id, + room_id=room_id, + include_miner_details=user_has_permission(current_user, "miners.view"), + ) diff --git a/backend/app/locations/service.py b/backend/app/locations/service.py new file mode 100644 index 0000000..3e002d1 --- /dev/null +++ b/backend/app/locations/service.py @@ -0,0 +1,102 @@ +"""Mine Map read-model composition independent of HTTP.""" + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from app.locations.models import ( + LocationSummary, + MapActiveRepair, + MapPosition, + MapRoom, + MapSite, + MapVirtualMiner, + MineMapData, + MineMapMeta, + MineMapResponse, + MinerLocationDetail, +) +from app.locations.repository import LocationMapRepository + + +MINER_FIELDS = { + "miner_id", "asset_id", "current_mac", "expected_mac", "current_ip", "miner_status", + "status", + "hostname", "miner_sn", "power_sn", "model", "miner_type", + "firmware_version", "lifecycle_status", "last_seen_at", "asset_state", + "observed_at", "hashrate_ths", "power_w", "chip_temp_max_c", + "chip_temp_avg_c", "hash_stable", "pool_rejected_percent", +} + + +class LocationMapService: + def __init__(self, repository: LocationMapRepository): + self.repository = repository + + @staticmethod + def _repair(row: dict[str, Any]) -> MapActiveRepair | None: + repair_id = row.pop("active_repair_id", None) + status = row.pop("active_repair_status", None) + reason = row.pop("active_repair_reason", None) + if repair_id is None: + return None + return MapActiveRepair(id=repair_id, status=status, reason=reason) + + @classmethod + def _redact(cls, row: dict[str, Any]) -> dict[str, Any]: + redacted = dict(row) + for field in MINER_FIELDS: + redacted[field] = None + redacted["active_repair_id"] = None + redacted["active_repair_status"] = None + redacted["active_repair_reason"] = None + return redacted + + def get_map( + self, + *, + site_id: UUID | None = None, + room_id: UUID | None = None, + include_miner_details: bool, + ) -> MineMapResponse: + rows = self.repository.read_map(site_id=site_id, room_id=room_id) + positions = [] + for source in rows["positions"]: + row = dict(source) if include_miner_details else self._redact(source) + row["active_repair"] = self._repair(row) + positions.append(MapPosition.model_validate(row)) + virtual_miners = [] + if include_miner_details: + for source in rows["virtual_miners"]: + row = dict(source) + row["active_repair"] = self._repair(row) + virtual_miners.append(MapVirtualMiner.model_validate(row)) + return MineMapResponse( + data=MineMapData( + sites=[MapSite.model_validate(row) for row in rows["sites"]], + rooms=[MapRoom.model_validate(row) for row in rows["rooms"]], + positions=positions, + virtual_miners=virtual_miners, + ), + meta=MineMapMeta( + generated_at=datetime.now(timezone.utc), + site_id=site_id, + room_id=room_id, + position_count=len(positions), + virtual_miner_count=len(virtual_miners), + miner_details_included=include_miner_details, + ), + ) + + def get_miner_location(self, miner_id: UUID) -> MinerLocationDetail | None: + row = self.repository.get_miner_location(miner_id) + if row is None: + return None + return MinerLocationDetail.model_validate(row) + + def summarize_locations(self, *, include_miner_counts: bool) -> LocationSummary: + return LocationSummary.model_validate( + self.repository.summarize_locations( + include_miner_counts=include_miner_counts + ) + ) diff --git a/backend/app/main.py b/backend/app/main.py index 5af3f21..21aa961 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,19 +1,73 @@ from datetime import datetime, timedelta, timezone from typing import Any +from uuid import UUID import jwt import psycopg import redis from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError -from fastapi import Request, Depends, FastAPI, Header, HTTPException, status -from pydantic import BaseModel, EmailStr, Field +from fastapi import Request, Depends, FastAPI, Header, HTTPException, Query, status +from pydantic import BaseModel, ConfigDict, EmailStr, Field, ValidationError from app.config import settings +from app.dashboard.router import router as dashboard_router +from app.identity import ( + COOKIE_TOKEN_HINT, + SESSION_COOKIE, + get_current_user, + install_identity_middleware, + require_permission, + user_has_permission, + router as identity_router, +) +from app.frontend.router import ( + dashboard_page as internal_alpha_dashboard_page, + miner_detail_page as internal_alpha_miner_detail_page, + router as frontend_router, +) +from app.inventory.router import router as inventory_router +from app.locations.router import router as locations_router +from app.components.router import router as components_router +from app.miner_detail.router import router as miner_detail_router +from app.monitoring.router import router as monitoring_router +from app.monitoring.frontend import router as monitoring_frontend_router +from app.repairs.exceptions import ( + ControlBoardReplacementActorRequired, + InvalidMacAddress, + RepairConflict, + RepairMinerNotFound, +) +from app.repairs.models import ( + RepairCenterAccess, + RepairCenterFilters, + RepairCenterScope, + RepairMinerLifecycle, + ControlBoardReplacementReason, + ControlBoardReplacementRequest, + RemovedBoardDisposition, +) +from app.repairs.repository import RepairRepository +from app.repairs.center_repository import RepairCenterRepository +from app.repairs.center_service import RepairCenterService +from app.repairs.service import RepairService +from app.repairs.router import ( + control_board_router, + router as repairs_router, +) app = FastAPI(title=settings.app_name) -password_hasher = PasswordHasher() +app.include_router(identity_router) +app.include_router(frontend_router) +app.include_router(inventory_router) +app.include_router(components_router) +app.include_router(locations_router) +app.include_router(dashboard_router) +app.include_router(miner_detail_router) +app.include_router(monitoring_frontend_router) +app.include_router(monitoring_router) +install_identity_middleware(app) def api_response(data: Any = None, meta: dict | None = None, error: Any = None) -> dict: @@ -74,83 +128,6 @@ def check_redis() -> str: return f"error: {exc}" -class SetupAdminRequest(BaseModel): - username: str = Field(min_length=3, max_length=64) - display_name: str = Field(min_length=1, max_length=120) - email: EmailStr | None = None - password: str = Field(min_length=10, max_length=256) - - -class LoginRequest(BaseModel): - username: str - password: str - - -def create_access_token(user_id: str, username: str, role: str) -> str: - now = datetime.now(timezone.utc) - payload = { - "sub": user_id, - "username": username, - "role": role, - "iat": int(now.timestamp()), - "exp": int((now + timedelta(minutes=settings.jwt_access_token_minutes)).timestamp()), - "type": "access", - } - return jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256") - - -def decode_access_token(token: str) -> dict: - try: - payload = jwt.decode(token, settings.jwt_secret_key, algorithms=["HS256"]) - if payload.get("type") != "access": - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type") - return payload - except jwt.ExpiredSignatureError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") - except jwt.InvalidTokenError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") - - -def get_bearer_token(authorization: str | None = Header(default=None)) -> str: - if not authorization or not authorization.lower().startswith("bearer "): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token") - return authorization.split(" ", 1)[1].strip() - - -def get_current_user(token: str = Depends(get_bearer_token)) -> dict: - payload = decode_access_token(token) - user_id = payload["sub"] - - conn = None - try: - conn = get_db_connection() - with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: - cur.execute( - """ - SELECT - users.id::text AS id, - users.username, - users.display_name, - users.email, - users.is_active, - roles.name AS role - FROM users - JOIN roles ON roles.id = users.role_id - WHERE users.id = %s; - """, - (user_id,), - ) - user = cur.fetchone() - - if not user or not user["is_active"]: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive") - - return dict(user) - finally: - if conn is not None: - conn.close() - - @app.get("/api/health") def health_check(): database_status = check_database() @@ -171,196 +148,6 @@ def health_check(): ) -@app.get("/api/auth/setup-status") -def setup_status(): - conn = None - try: - conn = get_db_connection() - with conn.cursor() as cur: - cur.execute("SELECT COUNT(*) FROM users;") - user_count = cur.fetchone()[0] - - return api_response( - data={ - "setup_required": user_count == 0, - "user_count": user_count, - } - ) - finally: - if conn is not None: - conn.close() - - -@app.post("/api/auth/setup-admin") -def setup_admin(request: SetupAdminRequest): - conn = None - try: - conn = get_db_connection() - conn.autocommit = False - - with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: - cur.execute("SELECT COUNT(*) AS user_count FROM users;") - user_count = cur.fetchone()["user_count"] - - if user_count > 0: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Initial admin setup has already been completed", - ) - - cur.execute("SELECT id FROM roles WHERE name = 'Admin';") - admin_role = cur.fetchone() - if not admin_role: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Admin role is missing", - ) - - password_hash = password_hasher.hash(request.password) - - cur.execute( - """ - INSERT INTO users (username, display_name, email, password_hash, role_id) - VALUES (%s, %s, %s, %s, %s) - RETURNING id::text, username, display_name, email; - """, - ( - request.username.strip(), - request.display_name.strip(), - request.email, - password_hash, - admin_role["id"], - ), - ) - user = cur.fetchone() - - cur.execute( - """ - INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json) - VALUES (%s, 'user.created', 'user', %s, %s); - """, - ( - user["id"], - user["id"], - psycopg.types.json.Jsonb( - { - "username": user["username"], - "display_name": user["display_name"], - "role": "Admin", - "source": "setup-admin", - } - ), - ), - ) - - conn.commit() - - return api_response( - data={ - "user_id": user["id"], - "username": user["username"], - "display_name": user["display_name"], - } - ) - except HTTPException: - if conn is not None: - conn.rollback() - raise - except Exception as exc: - if conn is not None: - conn.rollback() - raise HTTPException(status_code=500, detail=str(exc)) - finally: - if conn is not None: - conn.close() - - -@app.post("/api/auth/login") -def login(request: LoginRequest): - conn = None - try: - conn = get_db_connection() - conn.autocommit = False - - with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: - cur.execute( - """ - SELECT - users.id::text AS id, - users.username, - users.display_name, - users.email, - users.password_hash, - users.is_active, - roles.name AS role - FROM users - JOIN roles ON roles.id = users.role_id - WHERE lower(users.username) = lower(%s); - """, - (request.username.strip(),), - ) - user = cur.fetchone() - - if not user or not user["is_active"]: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password") - - try: - password_hasher.verify(user["password_hash"], request.password) - except VerifyMismatchError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password") - - access_token = create_access_token(user["id"], user["username"], user["role"]) - - cur.execute( - "UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = %s;", - (user["id"],), - ) - - cur.execute( - """ - INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json) - VALUES (%s, 'user.login', 'user', %s, %s); - """, - ( - user["id"], - user["id"], - psycopg.types.json.Jsonb({"username": user["username"]}), - ), - ) - - conn.commit() - - return api_response( - data={ - "access_token": access_token, - "token_type": "bearer", - "expires_in": settings.jwt_access_token_minutes * 60, - "user": { - "id": user["id"], - "username": user["username"], - "display_name": user["display_name"], - "email": user["email"], - "role": user["role"], - }, - } - ) - except HTTPException: - if conn is not None: - conn.rollback() - raise - except Exception as exc: - if conn is not None: - conn.rollback() - raise HTTPException(status_code=500, detail=str(exc)) - finally: - if conn is not None: - conn.close() - - -@app.get("/api/auth/me") -def me(current_user: dict = Depends(get_current_user)): - return api_response(data=current_user) - class CreateRoomRequest(BaseModel): site_id: str | None = None name: str = Field(min_length=1, max_length=120) @@ -2034,7 +1821,7 @@ def get_miner(miner_id: str, current_user: dict = Depends(get_current_user)): @app.get("/api/miners/{miner_id}/network-identities") -def list_miner_network_identities(miner_id: str, current_user: dict = Depends(get_current_user)): +def list_miner_network_identities(miner_id: str, current_user: dict = Depends(require_permission("miners.view"))): conn = None try: conn = get_db_connection() @@ -2108,7 +1895,10 @@ def list_miner_hashboards(miner_id: str, current_user: dict = Depends(get_curren @app.get("/api/miners/{miner_id}/status-snapshots") -def list_miner_status_snapshots(miner_id: str, current_user: dict = Depends(get_current_user)): +def list_miner_status_snapshots( + miner_id: str, + current_user: dict = Depends(require_permission("miners.view")), +): conn = None try: conn = get_db_connection() @@ -4303,7 +4093,7 @@ def minerops_ensure_pool_info_table(): cur.execute( """ CREATE TABLE IF NOT EXISTS miner_pool_snapshots ( - id TEXT PRIMARY KEY, + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), miner_id UUID, ip_address INET, mac_address TEXT, @@ -4326,6 +4116,25 @@ def minerops_ensure_pool_info_table(): created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); + DO $$ + DECLARE + id_type TEXT; + BEGIN + SELECT data_type INTO id_type + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'miner_pool_snapshots' + AND column_name = 'id'; + + IF id_type = 'text' THEN + ALTER TABLE miner_pool_snapshots + ALTER COLUMN id SET DEFAULT (gen_random_uuid())::text; + ELSIF id_type = 'uuid' THEN + ALTER TABLE miner_pool_snapshots + ALTER COLUMN id SET DEFAULT gen_random_uuid(); + END IF; + END $$; + CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_miner_created ON miner_pool_snapshots (miner_id, created_at DESC); @@ -4443,7 +4252,6 @@ async def agent_pool_snapshots(request: Request): cur.execute( """ INSERT INTO miner_pool_snapshots ( - id, miner_id, ip_address, mac_address, @@ -4465,7 +4273,6 @@ async def agent_pool_snapshots(request: Request): agent_id ) VALUES ( - %s, %s, %s::inet, %s, @@ -4488,7 +4295,6 @@ async def agent_pool_snapshots(request: Request): ); """, ( - str(_uuid.uuid4()), miner_id, ip, mac, @@ -6241,22 +6047,25 @@ MINEROPS_SHELL_AUTH_JS = r""" } async function getCurrentUser() { - const token = getToken(); - - if (!token) { - return null; - } - try { const response = await fetch("/api/auth/me", { + credentials: "same-origin", headers: authHeaders() }); if (!response.ok) { + window.location.replace("/login?next=" + encodeURIComponent(window.location.pathname)); return null; } - return await response.json(); + const payload = await response.json(); + const user = payload && payload.data ? payload.data : payload; + + if (user && !getToken()) { + setToken("cookie-session"); + } + + return user; } catch (error) { return null; } @@ -6352,9 +6161,16 @@ MINEROPS_SHELL_AUTH_JS = r""" } } - function logoutFromHeader() { - clearToken(); - window.location.reload(); + async function logoutFromHeader() { + try { + await fetch("/api/auth/logout", { + method: "POST", + credentials: "same-origin" + }); + } finally { + clearToken(); + window.location.replace("/login"); + } } async function renderHeaderAuth() { @@ -6388,6 +6204,13 @@ MINEROPS_SHELL_AUTH_JS = r""" `; + if (user.role === "Admin") { + const accountsLink = document.createElement("a"); + accountsLink.href = "/accounts"; + accountsLink.textContent = "Accounts"; + authArea.prepend(accountsLink); + } + document.getElementById("mineropsHeaderLogout").onclick = logoutFromHeader; return; } @@ -15653,33 +15476,17 @@ def delete_miner_maintenance_event( conn.close() class MinerMacChangeRequest(BaseModel): - new_mac: str + model_config = ConfigDict(extra="forbid") + + new_mac: str = Field(min_length=1, max_length=64) old_mac: str | None = None new_ip: str | None = None event_title: str | None = None - description: str | None = None - component_serial: str | None = None - downtime_minutes: int | None = None - - -def normalize_mac_display(mac: str | None): - if mac is None: - return None - - cleaned = "".join(ch for ch in str(mac).strip().lower() if ch in "0123456789abcdef") - - if len(cleaned) != 12: - return str(mac).strip().upper() - - return ":".join(cleaned[i:i + 2] for i in range(0, 12, 2)).upper() - - -def normalize_mac_key_for_change(mac: str | None): - if mac is None: - return None - - return "".join(ch for ch in str(mac).strip().lower() if ch in "0123456789abcdef") - + description: str | None = Field(default=None, max_length=4000) + component_serial: str | None = Field(default=None, max_length=255) + downtime_minutes: int | None = Field(default=None, ge=0) + removed_board_disposition: RemovedBoardDisposition | None = None + replacement_reason: ControlBoardReplacementReason | None = None @app.post("/api/miners/{miner_id}/change-mac") def change_miner_mac_address( @@ -15687,244 +15494,66 @@ def change_miner_mac_address( request: MinerMacChangeRequest, current_user: dict = Depends(get_current_user), ): - ensure_maintenance_tables() - - new_mac = normalize_mac_display(request.new_mac) - new_mac_key = normalize_mac_key_for_change(new_mac) - - if not new_mac_key or len(new_mac_key) != 12: - raise HTTPException(status_code=400, detail="New MAC address is invalid") - - conn = None + """Compatibility alias for the repair-scoped replacement workflow.""" + if request.replacement_reason is None or request.removed_board_disposition is None: + raise HTTPException( + status_code=422, + detail=( + "The compatibility route requires replacement_reason and " + "removed_board_disposition; use the repair-scoped replacement fields" + ), + ) + if request.new_ip is not None or request.downtime_minutes is not None: + raise HTTPException( + status_code=422, + detail=( + "The repair-scoped compatibility route does not change IP addresses " + "or record mutable downtime values" + ), + ) + try: + parsed_miner_id = UUID(miner_id) + except ValueError as exc: + raise HTTPException(status_code=422, detail="Invalid miner UUID") from exc try: - conn = get_db_connection() - - user_id = get_current_user_id_for_maintenance(current_user) - - with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: - cur.execute( - """ - SELECT - id::text, - current_mac, - host(current_ip) AS current_ip, - hostname - FROM miners - WHERE id = %s::uuid; - """, - (miner_id,), - ) - - miner = cur.fetchone() - - if not miner: - raise HTTPException(status_code=404, detail="Miner not found") - - miner = dict(miner) - - old_mac = normalize_mac_display(request.old_mac or miner.get("current_mac")) - old_ip = miner.get("current_ip") - new_ip = request.new_ip or old_ip - - if normalize_mac_key_for_change(old_mac) == new_mac_key: - raise HTTPException(status_code=400, detail="New MAC is the same as the current/old MAC") - - cur.execute( - """ - UPDATE miners - SET - current_mac = %s, - current_ip = COALESCE(NULLIF(%s, '')::inet, current_ip) - WHERE id = %s::uuid - RETURNING - id::text, - current_mac, - host(current_ip) AS current_ip; - """, - ( - new_mac, - new_ip, - miner_id, - ), - ) - - updated_miner = dict(cur.fetchone()) - - # Mark all known identities for this miner as not current. - cur.execute( - """ - UPDATE miner_network_identities - SET is_current = false - WHERE miner_id = %s::uuid; - """, - (miner_id,), - ) - - # If this MAC already exists in the identity history, mark it current. - cur.execute( - """ - UPDATE miner_network_identities - SET - is_current = true, - ip_address = COALESCE(NULLIF(%s, '')::inet, ip_address), - hostname = COALESCE(NULLIF(%s, ''), hostname), - last_seen_at = now() - WHERE miner_id = %s::uuid - AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = %s - RETURNING id::text; - """, - ( - new_ip, - miner.get("hostname"), - miner_id, - new_mac_key, - ), - ) - - existing_identity = cur.fetchone() - - if not existing_identity: - cur.execute( - """ - INSERT INTO miner_network_identities ( - id, - miner_id, - mac_address, - ip_address, - hostname, - first_seen_at, - last_seen_at, - is_current - ) - VALUES ( - %s::uuid, - %s::uuid, - %s, - NULLIF(%s, '')::inet, - %s, - now(), - now(), - true - ); - """, - ( - str(uuid4()), - miner_id, - new_mac, - new_ip, - miner.get("hostname"), - ), - ) - - event_id = str(uuid4()) - event_title = request.event_title or "Control board / MAC address changed" - - description_parts = [] - - if request.description: - description_parts.append(request.description) - - description_parts.append(f"MAC changed from {old_mac or 'unknown'} to {new_mac}.") - - if old_ip or new_ip: - description_parts.append(f"IP changed/tracked from {old_ip or 'unknown'} to {new_ip or 'unknown'}.") - - event_description = " ".join(description_parts) - - cur.execute( - """ - INSERT INTO miner_maintenance_events ( - id, - miner_id, - event_type, - event_status, - event_title, - description, - event_at, - old_mac, - new_mac, - old_ip, - new_ip, - component_type, - component_slot, - component_serial, - downtime_minutes, - created_by_user_id - ) - VALUES ( - %s::uuid, - %s::uuid, - 'control_board_changed', - 'completed', - %s, - %s, - now(), - %s, - %s, - NULLIF(%s, '')::inet, - NULLIF(%s, '')::inet, - 'control_board', - 'control_board', - %s, - %s, - %s::uuid - ) - RETURNING - id::text, - miner_id::text, - event_type, - event_status, - event_title, - description, - event_at, - old_mac, - new_mac, - host(old_ip) AS old_ip, - host(new_ip) AS new_ip, - component_type, - component_slot, - component_serial, - downtime_minutes, - created_by_user_id::text, - created_at, - updated_at; - """, - ( - event_id, - miner_id, - event_title, - event_description, - old_mac, - new_mac, - old_ip, - new_ip, - request.component_serial, - request.downtime_minutes, - user_id, - ), - ) - - maintenance_event = dict(cur.fetchone()) - - conn.commit() - - return api_response( - data={ - "miner": updated_miner, - "maintenance_event": maintenance_event, - } + replacement = ControlBoardReplacementRequest( + new_mac_address=request.new_mac, + new_control_board_serial=request.component_serial, + removed_board_disposition=request.removed_board_disposition, + replacement_reason=request.replacement_reason, + notes=request.description, ) + except ValidationError as exc: + raise HTTPException( + status_code=422, detail="Legacy change-mac request is invalid" + ) from exc - except Exception: - if conn is not None: - conn.rollback() + try: + result = RepairService(RepairRepository()).replace_control_board( + parsed_miner_id, + replacement, + actor_user_id=UUID(str(current_user["id"])), + ) + except RepairMinerNotFound as exc: + raise HTTPException(status_code=404, detail="Miner not found") from exc + except RepairConflict as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except (InvalidMacAddress, ControlBoardReplacementActorRequired) as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc - raise + replacement_data = result.model_dump(mode="json") + return api_response( + data={ + "miner": { + "id": replacement_data["miner_id"], + "current_mac": replacement_data["current_mac"], + "control_board_sn": replacement_data["control_board_serial"], + }, + "control_board_replacement": replacement_data, + } + ) - finally: - if conn is not None: - conn.close() @app.get("/api/maintenance/events") def list_all_maintenance_events( @@ -16878,8 +16507,7 @@ def list_location_import_issues( conn.close() -@app.get("/api/location/map") -def get_location_map( +def get_legacy_location_map( current_user: dict = Depends(get_current_user), ): ensure_location_tables() @@ -23630,9 +23258,11 @@ async def insert_miner_command_event( def minerops_command_require_bearer(request: Request): auth_header = request.headers.get("authorization") or "" + cookie_token = request.cookies.get(SESSION_COOKIE) or "" + header_token = auth_header.split(" ", 1)[1].strip() if auth_header.lower().startswith("bearer ") else "" - if not auth_header.lower().startswith("bearer "): - raise HTTPException(status_code=401, detail="Login token required.") + if not cookie_token and (not header_token or header_token == COOKIE_TOKEN_HINT): + raise HTTPException(status_code=401, detail="Login required.") async def minerops_command_user_from_request(request: Request): @@ -29464,6 +29094,9 @@ async def minerops_inject_detail_extra_v2(request, call_next): response = await call_next(request) try: + if request.scope.get("endpoint") is internal_alpha_miner_detail_page: + return response + path_value = request.url.path or "" if not (path_value.startswith("/miners/") and path_value.endswith("/detail")): @@ -30060,6 +29693,9 @@ async def minerops_inject_clean_detail_v3(request, call_next): response = await call_next(request) try: + if request.scope.get("endpoint") is internal_alpha_miner_detail_page: + return response + path_value = request.url.path or "" if not (path_value.startswith("/miners/") and path_value.endswith("/detail")): @@ -31298,6 +30934,9 @@ async def minerops_inject_location_management(request, call_next): response = await call_next(request) try: + if request.scope.get("endpoint") is internal_alpha_miner_detail_page: + return response + path = request.url.path or "" if not (path.startswith("/miners/") and path.endswith("/detail")): @@ -32121,122 +31760,72 @@ def minerops_repair_allowed_status(value): @app.get("/api/repairs/queue") def minerops_repair_queue_api( + query: str | None = Query(default=None, max_length=255), + scope: RepairCenterScope = "active", + status_filter: str | None = Query(default=None, alias="status", max_length=64), + model: str | None = Query(default=None, max_length=255), + lifecycle_status: RepairMinerLifecycle | None = None, + site_id: UUID | None = None, + room_id: UUID | None = None, + limit: int = Query(default=250, ge=1, le=250), + offset: int = Query(default=0, ge=0), current_user: dict = Depends(get_current_user), ): - ensure_location_tables() - ensure_maintenance_tables() - - conn = None - - try: - conn = get_db_connection() - - with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: - cur.execute( - """ - WITH latest_repair_event AS ( - SELECT DISTINCT ON (miner_id) - id::text AS event_id, - miner_id, - event_status, - event_title, - description, - event_at, - created_at - FROM miner_maintenance_events - WHERE event_type = 'repair' - ORDER BY miner_id, event_at DESC, created_at DESC - ), - event_counts AS ( - SELECT - miner_id, - COUNT(*) AS repair_event_count, - COUNT(*) FILTER ( - WHERE event_status IN ( - 'open', - 'diagnosing', - 'waiting_parts', - 'in_progress', - 'testing', - 'needs_followup' - ) - ) AS open_repair_event_count - FROM miner_maintenance_events - WHERE event_type = 'repair' - GROUP BY miner_id - ) - SELECT - m.id::text AS miner_id, - m.current_mac, - host(m.current_ip) AS current_ip, - COALESCE(m.miner_type, m.model) AS model, - m.miner_sn, - m.power_sn, - m.status AS miner_status, - m.created_at AS miner_created_at, - m.updated_at AS miner_updated_at, - m.manual_details, - mls.asset_state, - mls.state_notes, - mls.updated_at AS state_updated_at, - - lre.event_id AS latest_event_id, - lre.event_status AS latest_event_status, - lre.event_title AS latest_event_title, - lre.description AS latest_event_description, - lre.event_at AS latest_event_at, - - COALESCE(ec.repair_event_count, 0) AS repair_event_count, - COALESCE(ec.open_repair_event_count, 0) AS open_repair_event_count, - - m.manual_details -> 'office_repair_import' ->> 'reported_error' AS reported_error, - m.manual_details -> 'office_repair_import' ->> 'reported_hashrate_ths' AS reported_hashrate_ths, - m.manual_details -> 'office_repair_import' ->> 'reported_voltage' AS reported_voltage, - m.manual_details -> 'office_repair_import' ->> 'location_note' AS location_note, - m.manual_details -> 'office_repair_import' ->> 'source' AS import_source - FROM miner_location_states mls - JOIN miners m ON m.id = mls.miner_id - LEFT JOIN latest_repair_event lre ON lre.miner_id = m.id - LEFT JOIN event_counts ec ON ec.miner_id = m.id - WHERE mls.asset_state = 'repair' - ORDER BY - COALESCE(mls.updated_at, m.updated_at) DESC, - m.current_mac; - """ - ) - - rows = [dict(row) for row in cur.fetchall()] - - status_counts = {} - model_counts = {} - error_counts = {} - - for row in rows: - status = row.get("latest_event_status") or "unknown" - model = row.get("model") or "unknown" - error = row.get("reported_error") or "No reported error" - - status_counts[status] = status_counts.get(status, 0) + 1 - model_counts[model] = model_counts.get(model, 0) + 1 - error_counts[error] = error_counts.get(error, 0) + 1 - - return api_response( - data={ - "items": rows, - "summary": { - "total": len(rows), - "status_counts": status_counts, - "model_counts": model_counts, - "error_counts": error_counts, - }, - } + can_view_miners = user_has_permission(current_user, "miners.view") + can_view_locations = user_has_permission(current_user, "locations.view") + if (site_id or room_id) and not can_view_locations: + raise HTTPException( + status_code=403, + detail="Location viewing permission is required for location filters", ) + if model and not can_view_miners: + raise HTTPException( + status_code=403, + detail="Miner viewing permission is required for model filters", + ) + filters = RepairCenterFilters( + query=query, + scope=scope, + status=status_filter, + model=model, + lifecycle_status=lifecycle_status, + site_id=site_id, + room_id=room_id, + limit=limit, + offset=offset, + ) + access = RepairCenterAccess( + can_read_repairs=user_has_permission(current_user, "repairs.read"), + can_view_miners=can_view_miners, + can_view_locations=can_view_locations, + can_complete_repairs=user_has_permission(current_user, "repairs.complete"), + can_manage_parts=user_has_permission(current_user, "repairs.parts.manage"), + can_manage_legacy=user_has_permission(current_user, "repairs.manage"), + ) + result = RepairCenterService(RepairCenterRepository()).list_repair_center( + filters, access + ) + return result.model_dump(mode="json") - finally: - if conn is not None: - conn.close() +def minerops_repair_reject_modular_mutation(cur, miner_id: str) -> None: + cur.execute( + """ + SELECT 1 + FROM repairs + WHERE miner_id = %s::uuid + AND status IN ('OPEN', 'IN_PROGRESS', 'WAITING_FOR_PARTS', 'READY_FOR_TEST') + LIMIT 1 + """, + (miner_id,), + ) + if cur.fetchone(): + raise HTTPException( + status_code=409, + detail="This miner has an active modular repair; use the modular repair workflow", + ) + @app.post("/api/repairs/{miner_id}/note") def minerops_repair_add_note_api( miner_id: str, @@ -32263,7 +31852,8 @@ def minerops_repair_add_note_api( SELECT id::text, current_mac FROM miners WHERE id = %s::uuid - LIMIT 1; + LIMIT 1 + FOR UPDATE; """, (miner_id,), ) @@ -32273,6 +31863,8 @@ def minerops_repair_add_note_api( if not miner: raise HTTPException(status_code=404, detail="Miner not found") + minerops_repair_reject_modular_mutation(cur, miner_id) + cur.execute( """ INSERT INTO miner_maintenance_events ( @@ -32355,7 +31947,8 @@ def minerops_repair_update_status_api( SELECT id::text FROM miners WHERE id = %s::uuid - LIMIT 1; + LIMIT 1 + FOR UPDATE; """, (miner_id,), ) @@ -32365,6 +31958,8 @@ def minerops_repair_update_status_api( if not miner: raise HTTPException(status_code=404, detail="Miner not found") + minerops_repair_reject_modular_mutation(cur, miner_id) + cur.execute( """ SELECT id::text @@ -32510,7 +32105,8 @@ def minerops_repair_update_state_api( SELECT id::text, current_mac FROM miners WHERE id = %s::uuid - LIMIT 1; + LIMIT 1 + FOR UPDATE; """, (miner_id,), ) @@ -32520,6 +32116,8 @@ def minerops_repair_update_state_api( if not miner: raise HTTPException(status_code=404, detail="Miner not found") + minerops_repair_reject_modular_mutation(cur, miner_id) + cur.execute( """ UPDATE location_positions @@ -33326,8 +32924,23 @@ MINEROPS_REPAIR_QUEUE_HTML = r""" async function loadRepairs() { try { const data = await apiGet("/api/repairs/queue"); - repairItems = data.items || []; - updateMetrics(data.summary || {}); + repairItems = (data.items || []).filter(function (item) { + return !item.source || item.source === "legacy"; + }); + const legacySummary = { + total: repairItems.length, + status_counts: {}, + model_counts: {} + }; + for (const item of repairItems) { + const status = item.latest_event_status || item.asset_state || "unknown"; + const model = item.model || "unknown"; + legacySummary.status_counts[status] = + (legacySummary.status_counts[status] || 0) + 1; + legacySummary.model_counts[model] = + (legacySummary.model_counts[model] || 0) + 1; + } + updateMetrics(legacySummary); buildModelFilter(); renderTable(); showStatus("ok", "Repair Queue loaded. " + repairItems.length + " miners in repair."); @@ -33852,6 +33465,9 @@ async def minerops_inject_repair_polish_assets(request, call_next): try: path = request.url.path or "" + if request.scope.get("endpoint") is internal_alpha_dashboard_page: + return response + if path not in ("/dashboard", "/locations", "/repairs"): return response @@ -34207,6 +33823,9 @@ async def minerops_inject_dashboard_repair_summary(request, call_next): response = await call_next(request) try: + if request.scope.get("endpoint") is internal_alpha_dashboard_page: + return response + path = request.url.path or "" if path != "/dashboard": @@ -35891,3 +35510,8 @@ def minerops_repair_parts_summary_api( # MINEROPS_REPAIR_HISTORY_API_HELPERS_SCOPE_FIX_V1 # MINEROPS_REPAIR_HISTORY_DELEGATED_ACTIONS_V1 + + +# Register UUID-based Repair routes after legacy static Repair paths. +app.include_router(repairs_router) +app.include_router(control_board_router) diff --git a/backend/app/miner_detail/__init__.py b/backend/app/miner_detail/__init__.py new file mode 100644 index 0000000..42902e3 --- /dev/null +++ b/backend/app/miner_detail/__init__.py @@ -0,0 +1 @@ +"""Permission-aware Miner Detail read model.""" diff --git a/backend/app/miner_detail/dependencies.py b/backend/app/miner_detail/dependencies.py new file mode 100644 index 0000000..bfcf3d3 --- /dev/null +++ b/backend/app/miner_detail/dependencies.py @@ -0,0 +1,14 @@ +"""Dependency construction for the Miner Detail composition service.""" + +from app.inventory.dependencies import get_inventory_service +from app.locations.dependencies import get_location_map_service +from app.miner_detail.service import MinerDetailService +from app.repairs.dependencies import get_repair_center_service + + +def get_miner_detail_service() -> MinerDetailService: + return MinerDetailService( + inventory_service=get_inventory_service(), + location_service=get_location_map_service(), + repair_service=get_repair_center_service(), + ) diff --git a/backend/app/miner_detail/models.py b/backend/app/miner_detail/models.py new file mode 100644 index 0000000..6fd8662 --- /dev/null +++ b/backend/app/miner_detail/models.py @@ -0,0 +1,76 @@ +"""Typed contracts for the Internal Alpha Miner Detail overview.""" + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.inventory.models import InventoryMinerDetail +from app.locations.models import MinerLocationDetail + + +OperationalState = Literal[ + "online", "offline", "unknown", "unstable", "repair", "spare", "retired" +] + + +class MinerDetailAccess(BaseModel): + can_view_locations: bool = False + can_read_repairs: bool = False + can_view_repairs: bool = False + can_read_components: bool = False + + +class MinerDetailLocationSection(BaseModel): + available: bool + restricted: bool + location: MinerLocationDetail | None = None + + +class MinerDetailRepairEpisode(BaseModel): + repair_id: UUID | None = None + source: Literal["modular", "legacy"] + active: bool + status: str + reason: str | None = None + opened_at: datetime | None = None + closed_at: datetime | None = None + latest_context: str | None = None + latest_context_at: datetime | None = None + parts_count: int | None = None + parts_summary: list[str] = Field(default_factory=list) + + +class MinerDetailRepairSection(BaseModel): + available: bool + restricted: bool + active_repair: MinerDetailRepairEpisode | None = None + episodes: list[MinerDetailRepairEpisode] = Field(default_factory=list) + + +class MinerDetailCapabilities(BaseModel): + can_view_map: bool = False + can_open_repair_center: bool = False + can_read_repair_history: bool = False + can_read_components: bool = False + + +class MinerDetailOverviewData(BaseModel): + miner: InventoryMinerDetail + operational_state: OperationalState + location: MinerDetailLocationSection + repair: MinerDetailRepairSection + capabilities: MinerDetailCapabilities + + +class MinerDetailOverviewMeta(BaseModel): + generated_at: datetime + database_query_count: int = Field(ge=1, le=3) + + +class MinerDetailOverviewResponse(BaseModel): + data: MinerDetailOverviewData + meta: MinerDetailOverviewMeta + error: None = None + diff --git a/backend/app/miner_detail/router.py b/backend/app/miner_detail/router.py new file mode 100644 index 0000000..729028d --- /dev/null +++ b/backend/app/miner_detail/router.py @@ -0,0 +1,59 @@ +"""HTTP boundary for the Internal Alpha Miner Detail overview.""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Response, status + +from app.identity import require_permission, user_has_permission +from app.inventory.exceptions import MinerNotFound +from app.miner_detail.dependencies import get_miner_detail_service +from app.miner_detail.models import ( + MinerDetailAccess, + MinerDetailOverviewResponse, +) +from app.miner_detail.service import MinerDetailService + + +router = APIRouter(prefix="/api/miners", tags=["miners"]) +MinerDetailUser = Annotated[dict, Depends(require_permission("miners.view"))] +MinerDetailServiceDependency = Annotated[ + MinerDetailService, + Depends(get_miner_detail_service), +] + + +@router.get( + "/{miner_id}/overview", + response_model=MinerDetailOverviewResponse, +) +def get_miner_detail_overview( + miner_id: UUID, + current_user: MinerDetailUser, + service: MinerDetailServiceDependency, + response: Response, +) -> MinerDetailOverviewResponse: + response.headers["Cache-Control"] = "no-store" + try: + return service.get_overview( + miner_id, + MinerDetailAccess( + can_view_locations=user_has_permission( + current_user, "locations.view" + ), + can_read_repairs=user_has_permission( + current_user, "repairs.read" + ), + can_view_repairs=user_has_permission( + current_user, "repairs.view" + ), + can_read_components=user_has_permission( + current_user, "components.read" + ), + ), + ) + except MinerNotFound as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Miner not found", + ) from exc diff --git a/backend/app/miner_detail/service.py b/backend/app/miner_detail/service.py new file mode 100644 index 0000000..f2e098a --- /dev/null +++ b/backend/app/miner_detail/service.py @@ -0,0 +1,152 @@ +"""Composition service for the Internal Alpha Miner Detail overview.""" + +from datetime import datetime, timezone +from math import isfinite +from uuid import UUID + +from app.inventory.models import InventoryMinerDetail +from app.inventory.service import InventoryService +from app.locations.service import LocationMapService +from app.miner_detail.models import ( + MinerDetailAccess, + MinerDetailCapabilities, + MinerDetailLocationSection, + MinerDetailOverviewData, + MinerDetailOverviewMeta, + MinerDetailOverviewResponse, + MinerDetailRepairEpisode, + MinerDetailRepairSection, + OperationalState, +) +from app.repairs.center_service import RepairCenterService +from app.repairs.models import RepairCenterAccess, RepairCenterItem + + +class MinerDetailService: + """Coordinate domain-owned current projections without transport logic.""" + + def __init__( + self, + inventory_service: InventoryService, + location_service: LocationMapService, + repair_service: RepairCenterService, + ): + self.inventory_service = inventory_service + self.location_service = location_service + self.repair_service = repair_service + + @staticmethod + def _episode(item: RepairCenterItem) -> MinerDetailRepairEpisode: + return MinerDetailRepairEpisode( + repair_id=item.repair_id, + source=item.source, + active=item.active, + status=item.repair_status, + reason=item.reason, + opened_at=item.opened_at, + closed_at=item.closed_at, + latest_context=item.latest_context, + latest_context_at=item.latest_context_at, + parts_count=item.parts_count, + parts_summary=item.parts_summary, + ) + + @staticmethod + def _operational_state( + miner: InventoryMinerDetail, + active_repair: MinerDetailRepairEpisode | None, + ) -> OperationalState: + if active_repair is not None or miner.lifecycle_status == "in_repair": + return "repair" + if miner.lifecycle_status == "retired": + return "retired" + if miner.lifecycle_status == "spare": + return "spare" + runtime = (miner.runtime_status or "").strip().lower() + if runtime == "offline": + return "offline" + if miner.hash_stable is False: + return "unstable" + if runtime == "online": + return "online" + return "unknown" + + def get_overview( + self, + miner_id: UUID, + access: MinerDetailAccess, + ) -> MinerDetailOverviewResponse: + miner = self.inventory_service.get_current_miner(miner_id) + # Location is owned by the permission-aware section below. Keep the + # shared projection from leaking location identifiers. Non-finite + # telemetry is not a factual display value and remains unavailable. + redacted = { + field: None + for field in ( + "site_id", "site_name", "room_id", "room_name", + "location_position_id", "rack_label", "shelf_label", + "position_label", "position_index", + ) + } + for field in ("hashrate_ths", "power_w", "chip_temp_max_c"): + value = getattr(miner, field) + if value is not None and not isfinite(value): + redacted[field] = None + miner = miner.model_copy( + update=redacted + ) + location = ( + self.location_service.get_miner_location(miner_id) + if access.can_view_locations + else None + ) + repair_items = ( + self.repair_service.list_miner_repairs( + miner_id, + RepairCenterAccess( + can_read_repairs=True, + can_view_miners=True, + ), + ) + if access.can_read_repairs + else [] + ) + episodes = [self._episode(item) for item in repair_items] + active_repair = next( + ( + episode for episode in episodes + if episode.active and episode.source == "modular" + ), + None, + ) + query_count = 1 + int(access.can_view_locations) + int(access.can_read_repairs) + return MinerDetailOverviewResponse( + data=MinerDetailOverviewData( + miner=miner, + operational_state=self._operational_state(miner, active_repair), + location=MinerDetailLocationSection( + available=access.can_view_locations, + restricted=not access.can_view_locations, + location=location, + ), + repair=MinerDetailRepairSection( + available=access.can_read_repairs, + restricted=not access.can_read_repairs, + active_repair=active_repair, + episodes=episodes, + ), + capabilities=MinerDetailCapabilities( + can_view_map=bool( + access.can_view_locations and location is not None + and location.site_id and location.room_id + ), + can_open_repair_center=access.can_view_repairs, + can_read_repair_history=access.can_read_repairs, + can_read_components=access.can_read_components, + ), + ), + meta=MinerDetailOverviewMeta( + generated_at=datetime.now(timezone.utc), + database_query_count=query_count, + ), + ) diff --git a/backend/app/monitoring/__init__.py b/backend/app/monitoring/__init__.py new file mode 100644 index 0000000..9e1a6ff --- /dev/null +++ b/backend/app/monitoring/__init__.py @@ -0,0 +1 @@ +"""Historical monitoring read models.""" diff --git a/backend/app/monitoring/dependencies.py b/backend/app/monitoring/dependencies.py new file mode 100644 index 0000000..124c812 --- /dev/null +++ b/backend/app/monitoring/dependencies.py @@ -0,0 +1,8 @@ +"""Dependency construction for the Monitoring domain.""" + +from app.monitoring.repository import MonitoringRepository +from app.monitoring.service import MonitoringService + + +def get_monitoring_service() -> MonitoringService: + return MonitoringService(MonitoringRepository()) diff --git a/backend/app/monitoring/exceptions.py b/backend/app/monitoring/exceptions.py new file mode 100644 index 0000000..81d3e32 --- /dev/null +++ b/backend/app/monitoring/exceptions.py @@ -0,0 +1,13 @@ +"""Monitoring domain exceptions.""" + + +class MonitoringError(Exception): + """Base monitoring-domain error.""" + + +class MonitoringValidationError(MonitoringError): + """The requested trend scope or range is invalid.""" + + +class MonitoringScopeNotFound(MonitoringError): + """The requested durable scope does not exist.""" diff --git a/backend/app/monitoring/frontend.py b/backend/app/monitoring/frontend.py new file mode 100644 index 0000000..3aa55ef --- /dev/null +++ b/backend/app/monitoring/frontend.py @@ -0,0 +1,70 @@ +"""Authenticated frontend routes for Monitoring Trends.""" + +from pathlib import Path +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import HTMLResponse, Response + +from app.frontend.views import render_app_page +from app.identity import require_permission, user_has_permission +from app.monitoring.models import ( + MonitoringTrendQuery, + TrendRange, + TrendScopeType, +) + + +router = APIRouter(tags=["frontend"]) +STATIC_ROOT = Path(__file__).parents[1] / "frontend" / "static" +MonitoringUser = Annotated[dict, Depends(require_permission("miners.view"))] + + +@router.get("/assets/minerops-monitoring-trends.css", include_in_schema=False) +def monitoring_trends_styles() -> Response: + return Response( + (STATIC_ROOT / "monitoring-trends.css").read_text(encoding="utf-8"), + media_type="text/css", + headers={"Cache-Control": "no-store"}, + ) + + +@router.get("/assets/minerops-monitoring-trends.js", include_in_schema=False) +def monitoring_trends_script() -> Response: + return Response( + (STATIC_ROOT / "monitoring-trends.js").read_text(encoding="utf-8"), + media_type="text/javascript", + headers={"Cache-Control": "no-store"}, + ) + + +@router.get( + "/monitoring/analytics", + response_class=HTMLResponse, + include_in_schema=False, +) +def monitoring_analytics_page( + current_user: MonitoringUser, + scope: Annotated[TrendScopeType, Query()] = "fleet", + scope_id: Annotated[UUID | None, Query()] = None, + time_range: Annotated[TrendRange, Query(alias="range")] = "24h", +) -> str: + try: + query = MonitoringTrendQuery( + scope=scope, scope_id=scope_id, range=time_range + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + if query.scope in {"site", "room"} and not user_has_permission( + current_user, "locations.view" + ): + raise HTTPException( + status_code=403, + detail="Location viewing permission is required for this scope", + ) + return render_app_page( + "analytics", + current_user, + lambda permission: user_has_permission(current_user, permission), + ) diff --git a/backend/app/monitoring/models.py b/backend/app/monitoring/models.py new file mode 100644 index 0000000..28179e4 --- /dev/null +++ b/backend/app/monitoring/models.py @@ -0,0 +1,98 @@ +"""Typed contracts for bounded historical telemetry trends.""" + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +TrendScopeType = Literal["fleet", "site", "room", "miner"] +TrendRange = Literal["6h", "24h", "7d"] +TrendMetric = Literal["hashrate", "power"] +TrendAttribution = Literal["current_placement_cohort", "miner_identity", "fleet"] + + +class MonitoringTrendQuery(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + scope: TrendScopeType = "fleet" + scope_id: UUID | None = None + time_range: TrendRange = Field(default="24h", alias="range") + + @model_validator(mode="after") + def validate_scope_identity(self) -> "MonitoringTrendQuery": + if self.scope == "fleet" and self.scope_id is not None: + raise ValueError("scope_id is not allowed for fleet scope") + if self.scope != "fleet" and self.scope_id is None: + raise ValueError("scope_id is required for this scope") + return self + + +class MonitoringScope(BaseModel): + type: TrendScopeType + id: UUID | None = None + label: str + + +class MonitoringSiteOption(BaseModel): + id: UUID + name: str + + +class MonitoringRoomOption(BaseModel): + id: UUID + site_id: UUID + name: str + + +class MonitoringAvailableScopes(BaseModel): + fleet: bool = True + sites: list[MonitoringSiteOption] = Field(default_factory=list) + rooms: list[MonitoringRoomOption] = Field(default_factory=list) + + +class MonitoringTrendPoint(BaseModel): + bucket_start: datetime + bucket_end: datetime + value: float | None = None + observation_count: int + metric_sample_count: int + observed_miner_count: int + contributing_miner_count: int + scoped_miner_count: int + incomplete: bool = False + + +class MonitoringTrendSeries(BaseModel): + metric: TrendMetric + unit: Literal["TH/s", "W"] + label: str + points: list[MonitoringTrendPoint] + + +class MonitoringTrendData(BaseModel): + scope: MonitoringScope + series: list[MonitoringTrendSeries] + available_scopes: MonitoringAvailableScopes + + +class MonitoringTrendMeta(BaseModel): + generated_at: datetime + latest_observed_at: datetime | None = None + source: Literal["raw_snapshots"] = "raw_snapshots" + attribution: TrendAttribution + time_range: TrendRange = Field(alias="range") + bucket_seconds: int + maximum_points: int = 200 + completeness: Literal["not_guaranteed"] = "not_guaranteed" + current_bucket_incomplete: bool + database_query_count: int = Field(ge=1, le=2) + + model_config = ConfigDict(populate_by_name=True) + + +class MonitoringTrendResponse(BaseModel): + data: MonitoringTrendData + meta: MonitoringTrendMeta + error: None = None diff --git a/backend/app/monitoring/repository.py b/backend/app/monitoring/repository.py new file mode 100644 index 0000000..999f534 --- /dev/null +++ b/backend/app/monitoring/repository.py @@ -0,0 +1,237 @@ +"""PostgreSQL persistence for bounded monitoring trends.""" + +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from datetime import datetime, timedelta +from typing import Any +from uuid import UUID + +import psycopg +from psycopg.rows import dict_row + +from app.config import settings + + +ConnectionFactory = Callable[[], psycopg.Connection] + + +def default_connection_factory() -> psycopg.Connection: + return psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + connect_timeout=5, + ) + + +TREND_SQL = """ +WITH requested AS ( + SELECT %s::text AS scope_type, %s::uuid AS scope_id, + %s::timestamptz AS window_start, %s::timestamptz AS window_end, + %s::interval AS bucket_width +), +current_locations AS ( + SELECT miners.id AS miner_id, + COALESCE(position.site_id, miners.site_id) AS site_id, + COALESCE(position.room_id, miners.room_id) AS room_id + FROM miners + LEFT JOIN LATERAL ( + SELECT rooms.site_id, location_positions.room_id + FROM location_positions + LEFT JOIN rooms ON rooms.id = location_positions.room_id + WHERE location_positions.miner_id = miners.id + ORDER BY location_positions.id + LIMIT 1 + ) AS position ON true +), +scope_meta AS ( + SELECT requested.scope_type, requested.scope_id, + CASE requested.scope_type + WHEN 'fleet' THEN true + WHEN 'miner' THEN EXISTS ( + SELECT 1 FROM miners WHERE miners.id = requested.scope_id + ) + WHEN 'site' THEN EXISTS ( + SELECT 1 FROM sites WHERE sites.id = requested.scope_id + ) + WHEN 'room' THEN EXISTS ( + SELECT 1 FROM rooms WHERE rooms.id = requested.scope_id + ) + ELSE false + END AS scope_exists, + CASE requested.scope_type + WHEN 'fleet' THEN 'Fleet' + WHEN 'miner' THEN ( + SELECT COALESCE(NULLIF(trim(miners.asset_tag), ''), miners.id::text) + FROM miners WHERE miners.id = requested.scope_id + ) + WHEN 'site' THEN ( + SELECT sites.name FROM sites WHERE sites.id = requested.scope_id + ) + WHEN 'room' THEN ( + SELECT rooms.name FROM rooms WHERE rooms.id = requested.scope_id + ) + END AS scope_label + FROM requested +), +scoped_miners AS ( + SELECT miners.id + FROM miners + JOIN current_locations ON current_locations.miner_id = miners.id + CROSS JOIN requested + WHERE requested.scope_type = 'fleet' + OR (requested.scope_type = 'miner' AND miners.id = requested.scope_id) + OR (requested.scope_type = 'site' + AND current_locations.site_id = requested.scope_id) + OR (requested.scope_type = 'room' + AND current_locations.room_id = requested.scope_id) +), +scope_count AS ( + SELECT count(*)::integer AS scoped_miner_count FROM scoped_miners +), +buckets AS ( + SELECT bucket_start, + bucket_start + requested.bucket_width AS bucket_end + FROM requested + CROSS JOIN LATERAL generate_series( + requested.window_start, + requested.window_end - requested.bucket_width, + requested.bucket_width + ) AS bucket_start +), +per_miner_bucket AS ( + SELECT date_bin( + requested.bucket_width, + snapshots.observed_at, + '1970-01-01 00:00:00+00'::timestamptz + ) AS bucket_start, + snapshots.miner_id, + count(*)::integer AS observation_count, + count(snapshots.mhs_av)::integer AS hashrate_sample_count, + count(snapshots.power_w)::integer AS power_sample_count, + avg(snapshots.mhs_av) AS average_mhs_av, + avg(snapshots.power_w) AS average_power_w + FROM miner_status_snapshots AS snapshots + JOIN scoped_miners ON scoped_miners.id = snapshots.miner_id + CROSS JOIN requested + WHERE snapshots.observed_at >= requested.window_start + AND snapshots.observed_at < requested.window_end + GROUP BY bucket_start, snapshots.miner_id +), +bucket_totals AS ( + SELECT per_miner_bucket.bucket_start, + sum(per_miner_bucket.observation_count)::integer AS observation_count, + sum(per_miner_bucket.hashrate_sample_count)::integer + AS hashrate_sample_count, + sum(per_miner_bucket.power_sample_count)::integer AS power_sample_count, + count(*)::integer AS observed_miner_count, + count(*) FILTER ( + WHERE per_miner_bucket.hashrate_sample_count > 0 + )::integer AS hashrate_contributing_miner_count, + count(*) FILTER ( + WHERE per_miner_bucket.power_sample_count > 0 + )::integer AS power_contributing_miner_count, + CASE WHEN sum(per_miner_bucket.hashrate_sample_count) > 0 + THEN sum(per_miner_bucket.average_mhs_av) FILTER ( + WHERE per_miner_bucket.hashrate_sample_count > 0 + ) / 1000000.0 + END AS hashrate_ths, + CASE WHEN sum(per_miner_bucket.power_sample_count) > 0 + THEN sum(per_miner_bucket.average_power_w) FILTER ( + WHERE per_miner_bucket.power_sample_count > 0 + ) + END AS power_w + FROM per_miner_bucket + GROUP BY per_miner_bucket.bucket_start +), +latest AS ( + SELECT max(snapshots.observed_at) AS latest_observed_at + FROM miner_status_snapshots AS snapshots + JOIN scoped_miners ON scoped_miners.id = snapshots.miner_id + CROSS JOIN requested + WHERE snapshots.observed_at >= requested.window_start + AND snapshots.observed_at < requested.window_end +) +SELECT scope_meta.scope_exists, scope_meta.scope_label, + buckets.bucket_start, buckets.bucket_end, + bucket_totals.hashrate_ths, bucket_totals.power_w, + COALESCE(bucket_totals.observation_count, 0) AS observation_count, + COALESCE(bucket_totals.hashrate_sample_count, 0) AS hashrate_sample_count, + COALESCE(bucket_totals.power_sample_count, 0) AS power_sample_count, + COALESCE(bucket_totals.observed_miner_count, 0) AS observed_miner_count, + COALESCE(bucket_totals.hashrate_contributing_miner_count, 0) + AS hashrate_contributing_miner_count, + COALESCE(bucket_totals.power_contributing_miner_count, 0) + AS power_contributing_miner_count, + scope_count.scoped_miner_count, latest.latest_observed_at +FROM buckets +CROSS JOIN scope_meta +CROSS JOIN scope_count +CROSS JOIN latest +LEFT JOIN bucket_totals ON bucket_totals.bucket_start = buckets.bucket_start +ORDER BY buckets.bucket_start +""" + + +SCOPE_OPTIONS_SQL = """ +SELECT option_type, id, site_id, name +FROM ( + SELECT 'site'::text AS option_type, sites.id, + NULL::uuid AS site_id, sites.name + FROM sites + UNION ALL + SELECT 'room'::text AS option_type, rooms.id, + rooms.site_id, rooms.name + FROM rooms +) AS options +ORDER BY option_type DESC, lower(name), id +""" + + +class MonitoringRepository: + """Own the set-based raw-snapshot monitoring read path.""" + + def __init__( + self, + connection_factory: ConnectionFactory | None = None, + *, + connection: psycopg.Connection | None = None, + ): + self._connection_factory = connection_factory or default_connection_factory + self._connection = connection + + @contextmanager + def _connection_scope(self) -> Iterator[psycopg.Connection]: + if self._connection is not None: + yield self._connection + return + connection = self._connection_factory() + try: + yield connection + finally: + connection.close() + + def read_trends( + self, + *, + scope: str, + scope_id: UUID | None, + window_start: datetime, + window_end: datetime, + bucket_width: timedelta, + ) -> list[dict[str, Any]]: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + TREND_SQL, + (scope, scope_id, window_start, window_end, bucket_width), + ) + return [dict(row) for row in cur.fetchall()] + + def list_location_scope_options(self) -> list[dict[str, Any]]: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute(SCOPE_OPTIONS_SQL) + return [dict(row) for row in cur.fetchall()] diff --git a/backend/app/monitoring/router.py b/backend/app/monitoring/router.py new file mode 100644 index 0000000..0414e6c --- /dev/null +++ b/backend/app/monitoring/router.py @@ -0,0 +1,61 @@ +"""HTTP boundary for bounded historical monitoring trends.""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status + +from app.identity import require_permission, user_has_permission +from app.monitoring.dependencies import get_monitoring_service +from app.monitoring.exceptions import MonitoringScopeNotFound +from app.monitoring.models import ( + MonitoringTrendQuery, + MonitoringTrendResponse, + TrendRange, + TrendScopeType, +) +from app.monitoring.service import MonitoringService + + +router = APIRouter(prefix="/api/monitoring", tags=["monitoring"]) +MonitoringUser = Annotated[dict, Depends(require_permission("miners.view"))] +MonitoringServiceDependency = Annotated[ + MonitoringService, + Depends(get_monitoring_service), +] + + +@router.get("/trends", response_model=MonitoringTrendResponse) +def get_monitoring_trends( + current_user: MonitoringUser, + service: MonitoringServiceDependency, + response: Response, + scope: Annotated[TrendScopeType, Query()] = "fleet", + scope_id: Annotated[UUID | None, Query()] = None, + time_range: Annotated[TrendRange, Query(alias="range")] = "24h", +) -> MonitoringTrendResponse: + try: + query = MonitoringTrendQuery( + scope=scope, scope_id=scope_id, range=time_range + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc) + ) from exc + can_view_locations = user_has_permission(current_user, "locations.view") + if query.scope in {"site", "room"} and not can_view_locations: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Location viewing permission is required for this scope", + ) + response.headers["Cache-Control"] = "no-store" + try: + return service.get_trends( + query, + include_location_scopes=can_view_locations, + ) + except MonitoringScopeNotFound as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc diff --git a/backend/app/monitoring/service.py b/backend/app/monitoring/service.py new file mode 100644 index 0000000..22e0612 --- /dev/null +++ b/backend/app/monitoring/service.py @@ -0,0 +1,173 @@ +"""Business semantics for bounded historical monitoring trends.""" + +from datetime import datetime, timedelta, timezone +from uuid import UUID + +from app.monitoring.exceptions import MonitoringScopeNotFound +from app.monitoring.models import ( + MonitoringAvailableScopes, + MonitoringRoomOption, + MonitoringScope, + MonitoringSiteOption, + MonitoringTrendData, + MonitoringTrendMeta, + MonitoringTrendPoint, + MonitoringTrendQuery, + MonitoringTrendResponse, + MonitoringTrendSeries, +) +from app.monitoring.repository import MonitoringRepository + + +RANGE_CONFIGURATION = { + "6h": (timedelta(hours=6), timedelta(minutes=5)), + "24h": (timedelta(hours=24), timedelta(minutes=15)), + "7d": (timedelta(days=7), timedelta(hours=1)), +} +MAXIMUM_POINTS = 200 + + +class MonitoringService: + """Coordinate factual telemetry aggregation without HTTP dependencies.""" + + def __init__(self, repository: MonitoringRepository): + self.repository = repository + + @staticmethod + def _window( + query: MonitoringTrendQuery, + generated_at: datetime, + ) -> tuple[datetime, datetime, timedelta]: + duration, bucket_width = RANGE_CONFIGURATION[query.time_range] + generated_at = generated_at.astimezone(timezone.utc) + bucket_seconds = int(bucket_width.total_seconds()) + current_bucket_epoch = ( + int(generated_at.timestamp()) // bucket_seconds * bucket_seconds + ) + current_bucket_start = datetime.fromtimestamp( + current_bucket_epoch, tz=timezone.utc + ) + window_end = current_bucket_start + bucket_width + return window_end - duration, window_end, bucket_width + + def get_trends( + self, + query: MonitoringTrendQuery, + *, + include_location_scopes: bool, + generated_at: datetime | None = None, + ) -> MonitoringTrendResponse: + generated_at = generated_at or datetime.now(timezone.utc) + window_start, window_end, bucket_width = self._window(query, generated_at) + rows = self.repository.read_trends( + scope=query.scope, + scope_id=query.scope_id, + window_start=window_start, + window_end=window_end, + bucket_width=bucket_width, + ) + if not rows or not rows[0]["scope_exists"]: + raise MonitoringScopeNotFound(f"{query.scope.title()} not found") + + options = MonitoringAvailableScopes() + query_count = 1 + if include_location_scopes: + option_rows = self.repository.list_location_scope_options() + query_count += 1 + options = MonitoringAvailableScopes( + sites=[ + MonitoringSiteOption.model_validate(row) + for row in option_rows + if row["option_type"] == "site" + ], + rooms=[ + MonitoringRoomOption.model_validate(row) + for row in option_rows + if row["option_type"] == "room" + ], + ) + + generated_at = generated_at.astimezone(timezone.utc) + hashrate_points = [] + power_points = [] + for row in rows: + shared = { + "bucket_start": row["bucket_start"], + "bucket_end": row["bucket_end"], + "observation_count": row["observation_count"], + "observed_miner_count": row["observed_miner_count"], + "scoped_miner_count": row["scoped_miner_count"], + "incomplete": row["bucket_end"] > generated_at, + } + hashrate_points.append( + MonitoringTrendPoint( + **shared, + value=( + float(row["hashrate_ths"]) + if row["hashrate_ths"] is not None + else None + ), + metric_sample_count=row["hashrate_sample_count"], + contributing_miner_count=( + row["hashrate_contributing_miner_count"] + ), + ) + ) + power_points.append( + MonitoringTrendPoint( + **shared, + value=( + float(row["power_w"]) + if row["power_w"] is not None + else None + ), + metric_sample_count=row["power_sample_count"], + contributing_miner_count=( + row["power_contributing_miner_count"] + ), + ) + ) + + if len(rows) > MAXIMUM_POINTS: + raise RuntimeError("Monitoring query exceeded the fixed point bound") + attribution = ( + "current_placement_cohort" + if query.scope in {"site", "room"} + else "miner_identity" + if query.scope == "miner" + else "fleet" + ) + latest_observed_at = rows[0]["latest_observed_at"] + return MonitoringTrendResponse( + data=MonitoringTrendData( + scope=MonitoringScope( + type=query.scope, + id=query.scope_id, + label=rows[0]["scope_label"], + ), + series=[ + MonitoringTrendSeries( + metric="hashrate", + unit="TH/s", + label="Average recorded hashrate", + points=hashrate_points, + ), + MonitoringTrendSeries( + metric="power", + unit="W", + label="Average observed power", + points=power_points, + ), + ], + available_scopes=options, + ), + meta=MonitoringTrendMeta( + generated_at=generated_at, + latest_observed_at=latest_observed_at, + attribution=attribution, + range=query.time_range, + bucket_seconds=int(bucket_width.total_seconds()), + current_bucket_incomplete=window_end > generated_at, + database_query_count=query_count, + ), + ) diff --git a/backend/app/repairs/__init__.py b/backend/app/repairs/__init__.py new file mode 100644 index 0000000..40c5fd5 --- /dev/null +++ b/backend/app/repairs/__init__.py @@ -0,0 +1,61 @@ +"""Repair workflow models, persistence, and business services.""" + +from app.repairs.exceptions import ( + ActiveRepairConflict, + IncompatibleMinerLifecycle, + InconsistentRepairState, + RepairActiveMismatch, + RepairActorRequired, + RepairCompletionActorRequired, + RepairConflict, + RepairError, + RepairMinerNotFound, + RepairMinerNotInRepair, + RepairNotActive, + RepairNotFound, + RepairPersistenceError, +) +from app.repairs.models import ( + BeginRepairRequest, + BeginRepairResult, + RepairActor, + RepairCompleteRequest, + RepairCompletedPayload, + RepairDetail, + RepairPostLifecycle, + RepairReason, + RepairStatus, + RepairTimeline, + RepairTimelineEvent, +) +from app.repairs.repository import RepairRepository +from app.repairs.service import RepairService + +__all__ = [ + "ActiveRepairConflict", + "BeginRepairRequest", + "BeginRepairResult", + "IncompatibleMinerLifecycle", + "InconsistentRepairState", + "RepairActiveMismatch", + "RepairActor", + "RepairActorRequired", + "RepairCompleteRequest", + "RepairCompletedPayload", + "RepairCompletionActorRequired", + "RepairConflict", + "RepairDetail", + "RepairError", + "RepairMinerNotFound", + "RepairMinerNotInRepair", + "RepairNotActive", + "RepairNotFound", + "RepairPersistenceError", + "RepairPostLifecycle", + "RepairReason", + "RepairRepository", + "RepairService", + "RepairStatus", + "RepairTimeline", + "RepairTimelineEvent", +] diff --git a/backend/app/repairs/center_repository.py b/backend/app/repairs/center_repository.py new file mode 100644 index 0000000..3f287cd --- /dev/null +++ b/backend/app/repairs/center_repository.py @@ -0,0 +1,366 @@ +"""Set-based PostgreSQL read model for the Repair Center.""" + +from collections.abc import Callable, Mapping +from typing import Any + +import psycopg +from psycopg.rows import dict_row + +from app.repairs.repository import default_connection_factory + + +ConnectionFactory = Callable[[], psycopg.Connection] + + +class RepairCenterRepository: + """Read-only persistence boundary for consolidated repair operations.""" + + def __init__(self, connection_factory: ConnectionFactory | None = None): + self._connection_factory = connection_factory or default_connection_factory + + @staticmethod + def _ctes(*, show_miners: bool, show_locations: bool) -> str: + miner_fields = ( + "miner.asset_tag AS asset_id, COALESCE(miner.model, miner.miner_type) AS model, miner.miner_sn, miner.power_sn, " + "miner.current_mac, host(miner.current_ip) AS current_ip, " + "miner.status AS runtime_status" + if show_miners else + "NULL::text AS asset_id, NULL::text AS model, NULL::text AS miner_sn, NULL::text AS power_sn, " + "NULL::text AS current_mac, NULL::text AS current_ip, " + "NULL::text AS runtime_status" + ) + location_fields = ( + "placement.site_id, placement.site_name, placement.room_id, " + "placement.room_name, placement.rack_label, placement.shelf_label, " + "placement.position_id, placement.position_label, placement.position_index" + if show_locations else + "NULL::uuid AS site_id, NULL::text AS site_name, NULL::uuid AS room_id, " + "NULL::text AS room_name, NULL::text AS rack_label, " + "NULL::text AS shelf_label, NULL::uuid AS position_id, " + "NULL::text AS position_label, NULL::integer AS position_index" + ) + return f""" + WITH latest_legacy AS ( + SELECT DISTINCT ON (event.miner_id) + event.miner_id, event.event_status, event.event_title, + event.description, event.event_at, event.created_at + FROM miner_maintenance_events AS event + WHERE event.event_type = 'repair' + ORDER BY event.miner_id, event.event_at DESC, + event.created_at DESC, event.id DESC + ), + legacy_first AS ( + SELECT event.miner_id, + min(COALESCE(event.event_at, event.created_at)) AS opened_at + FROM miner_maintenance_events AS event + WHERE event.event_type = 'repair' + GROUP BY event.miner_id + ), + legacy_counts AS ( + SELECT event.miner_id, + count(*)::integer AS repair_event_count, + count(*) FILTER (WHERE event.event_status IN ( + 'open', 'diagnosing', 'waiting_parts', 'in_progress', + 'testing', 'ready_to_shelf', 'needs_followup' + ))::integer AS open_repair_event_count + FROM miner_maintenance_events AS event + WHERE event.event_type = 'repair' + GROUP BY event.miner_id + ), + latest_modular AS ( + SELECT DISTINCT ON (event.repair_id) + event.repair_id, event.payload, event.created_at + FROM repair_events AS event + ORDER BY event.repair_id, event.sequence DESC, event.id DESC + ), + initial_note AS ( + SELECT DISTINCT ON (event.repair_id) + event.repair_id, event.payload ->> 'note' AS note + FROM repair_events AS event + WHERE event.event_type = 'INITIAL_NOTE' + ORDER BY event.repair_id, event.sequence, event.id + ), + part_rollup AS ( + SELECT part.repair_id, + count(*) FILTER (WHERE part.voided_at IS NULL)::integer AS parts_count, + COALESCE((array_agg( + CASE WHEN part.consumable_description IS NOT NULL + THEN part.consumable_description + ELSE concat_ws(' ', part.component_type_snapshot, + part.component_asset_id_snapshot, + part.component_manufacturer_serial_snapshot) + END ORDER BY part.recorded_at, part.id + ) FILTER (WHERE part.voided_at IS NULL))[1:3], + ARRAY[]::text[]) AS parts_summary + FROM repair_parts AS part GROUP BY part.repair_id + ), + placement AS ( + SELECT DISTINCT ON (position.miner_id) + position.miner_id, site.id AS site_id, site.name AS site_name, + room.id AS room_id, COALESCE(room.name, position.room_name) AS room_name, + position.rack_name AS rack_label, position.shelf_name AS shelf_label, + position.id AS position_id, position.position_label, + position.position_index + FROM location_positions AS position + LEFT JOIN rooms AS room ON room.id = position.room_id + LEFT JOIN sites AS site ON site.id = room.site_id + WHERE position.miner_id IS NOT NULL + ORDER BY position.miner_id, position.id + ), + modular_rows AS ( + SELECT repair.id AS repair_id, 'modular'::text AS source, + repair.status = ANY(ARRAY['OPEN', 'IN_PROGRESS', + 'WAITING_FOR_PARTS', 'READY_FOR_TEST']) AS active, + repair.opened_at, repair.completed_at AS closed_at, + repair.status AS repair_status, repair.reason, + COALESCE(NULLIF(trim(miner.manual_details -> + 'office_repair_import' ->> 'reported_error'), ''), + NULLIF(trim(initial_note.note), ''), + replace(repair.reason, '_', ' ')) AS reported_problem, + COALESCE(NULLIF(trim(latest_modular.payload ->> 'note'), ''), + NULLIF(trim(latest_modular.payload ->> 'notes'), ''), + NULLIF(trim(latest_modular.payload ->> 'completion_summary'), ''), + NULLIF(trim(initial_note.note), ''), + NULLIF(trim(latest_legacy.description), ''), + NULLIF(trim(location_state.state_notes), '')) AS latest_context, + COALESCE(latest_modular.created_at, latest_legacy.event_at, + latest_legacy.created_at, repair.opened_at) AS latest_context_at, + opened_actor.display_name AS opened_by_display_name, + miner.id AS miner_id, {miner_fields}, miner.lifecycle_status, + {location_fields}, + COALESCE(part_rollup.parts_count, 0) AS parts_count, + COALESCE(part_rollup.parts_summary, ARRAY[]::text[]) AS parts_summary, + latest_legacy.event_status AS legacy_event_status, + location_state.asset_state AS legacy_asset_state, + latest_legacy.event_title AS latest_event_title, + COALESCE(legacy_counts.repair_event_count, 0) AS repair_event_count, + COALESCE(legacy_counts.open_repair_event_count, 0) AS open_repair_event_count, + miner.manual_details -> 'office_repair_import' ->> 'reported_hashrate_ths' AS reported_hashrate_ths, + miner.manual_details -> 'office_repair_import' ->> 'reported_voltage' AS reported_voltage, + miner.manual_details -> 'office_repair_import' ->> 'location_note' AS location_note, + miner.manual_details -> 'office_repair_import' ->> 'source' AS import_source, + location_state.updated_at AS state_updated_at + FROM repairs AS repair + JOIN miners AS miner ON miner.id = repair.miner_id + JOIN users AS opened_actor ON opened_actor.id = repair.opened_by + LEFT JOIN latest_modular ON latest_modular.repair_id = repair.id + LEFT JOIN initial_note ON initial_note.repair_id = repair.id + LEFT JOIN part_rollup ON part_rollup.repair_id = repair.id + LEFT JOIN latest_legacy ON latest_legacy.miner_id = miner.id + LEFT JOIN legacy_counts ON legacy_counts.miner_id = miner.id + LEFT JOIN miner_location_states AS location_state + ON location_state.miner_id = miner.id + LEFT JOIN placement ON placement.miner_id = miner.id + ), + legacy_rows AS ( + SELECT NULL::uuid AS repair_id, 'legacy'::text AS source, + true AS active, legacy_first.opened_at, NULL::timestamptz AS closed_at, + COALESCE(latest_legacy.event_status, + location_state.asset_state, 'unknown') AS repair_status, + NULL::text AS reason, + COALESCE(NULLIF(trim(miner.manual_details -> + 'office_repair_import' ->> 'reported_error'), ''), + NULLIF(trim(latest_legacy.event_title), '')) AS reported_problem, + COALESCE(NULLIF(trim(latest_legacy.description), ''), + NULLIF(trim(location_state.state_notes), '')) AS latest_context, + COALESCE(latest_legacy.event_at, latest_legacy.created_at, + location_state.updated_at) AS latest_context_at, + NULL::text AS opened_by_display_name, + miner.id AS miner_id, {miner_fields}, miner.lifecycle_status, + {location_fields}, NULL::integer AS parts_count, + ARRAY[]::text[] AS parts_summary, + latest_legacy.event_status AS legacy_event_status, + location_state.asset_state AS legacy_asset_state, + latest_legacy.event_title AS latest_event_title, + COALESCE(legacy_counts.repair_event_count, 0) AS repair_event_count, + COALESCE(legacy_counts.open_repair_event_count, 0) AS open_repair_event_count, + miner.manual_details -> 'office_repair_import' ->> 'reported_hashrate_ths' AS reported_hashrate_ths, + miner.manual_details -> 'office_repair_import' ->> 'reported_voltage' AS reported_voltage, + miner.manual_details -> 'office_repair_import' ->> 'location_note' AS location_note, + miner.manual_details -> 'office_repair_import' ->> 'source' AS import_source, + location_state.updated_at AS state_updated_at + FROM miner_location_states AS location_state + JOIN miners AS miner ON miner.id = location_state.miner_id + LEFT JOIN latest_legacy ON latest_legacy.miner_id = miner.id + LEFT JOIN legacy_counts ON legacy_counts.miner_id = miner.id + LEFT JOIN legacy_first ON legacy_first.miner_id = miner.id + LEFT JOIN placement ON placement.miner_id = miner.id + WHERE location_state.asset_state = 'repair' + AND NOT EXISTS ( + SELECT 1 FROM repairs AS active_repair + WHERE active_repair.miner_id = miner.id + AND active_repair.status = ANY(ARRAY['OPEN', 'IN_PROGRESS', + 'WAITING_FOR_PARTS', 'READY_FOR_TEST']) + ) + ), + candidates AS ( + SELECT * FROM modular_rows + UNION ALL + SELECT * FROM legacy_rows + ) + """ + + @staticmethod + def _where(filters: Mapping[str, Any]) -> tuple[str, list[Any]]: + conditions: list[str] = [] + params: list[Any] = [] + scope = filters.get("scope", "active") + if scope == "active": + conditions.append("candidate.active") + elif scope == "historical": + conditions.append("NOT candidate.active") + for key, column in ( + ("status", "repair_status"), + ("lifecycle_status", "lifecycle_status"), + ("site_id", "site_id"), + ("room_id", "room_id"), + ): + if filters.get(key): + conditions.append(f"candidate.{column} = %s") + params.append(filters[key]) + if filters.get("model"): + conditions.append("lower(trim(candidate.model)) = lower(trim(%s))") + params.append(filters["model"]) + if filters.get("query"): + conditions.append( + "concat_ws(' ', candidate.repair_id, candidate.miner_id, " + "candidate.asset_id, candidate.model, candidate.miner_sn, " + "candidate.current_mac, candidate.current_ip, " + "candidate.repair_status, candidate.reason, " + "candidate.reported_problem, candidate.latest_context) ILIKE %s" + ) + params.append(f"%{filters['query']}%") + return " AND ".join(conditions) if conditions else "true", params + + def list_repair_center( + self, + filters: Mapping[str, Any], + *, + include_miner_details: bool, + include_location_details: bool, + ) -> dict[str, Any]: + ctes = self._ctes( + show_miners=include_miner_details, + show_locations=include_location_details, + ) + where_sql, params = self._where(filters) + item_sql = f""" + {ctes} + SELECT candidate.* FROM candidates AS candidate + WHERE {where_sql} + ORDER BY candidate.active DESC, + CASE WHEN candidate.active THEN candidate.opened_at END ASC NULLS LAST, + candidate.opened_at DESC NULLS LAST, + candidate.repair_id NULLS LAST, candidate.miner_id + LIMIT %s OFFSET %s + """ + summary_sql = f""" + {ctes}, filtered AS ( + SELECT * FROM candidates AS candidate WHERE {where_sql} + ) + SELECT count(*)::integer AS total, + count(*) FILTER (WHERE active)::integer AS active, + count(*) FILTER (WHERE NOT active)::integer AS historical, + COALESCE((SELECT jsonb_object_agg(status_rows.repair_status, + status_rows.status_count) FROM ( + SELECT repair_status, count(*)::integer AS status_count + FROM filtered GROUP BY repair_status + ) AS status_rows), '{{}}'::jsonb) AS status_counts, + COALESCE((SELECT jsonb_object_agg(model_rows.model, + model_rows.model_count) FROM ( + SELECT model, count(*)::integer AS model_count + FROM filtered WHERE model IS NOT NULL GROUP BY model + ) AS model_rows), '{{}}'::jsonb) AS model_counts, + COALESCE((SELECT jsonb_object_agg(error_rows.error, + error_rows.error_count) FROM ( + SELECT COALESCE(reported_problem, 'No reported error') AS error, + count(*)::integer AS error_count + FROM filtered + GROUP BY COALESCE(reported_problem, 'No reported error') + ) AS error_rows), '{{}}'::jsonb) AS error_counts + FROM filtered + """ + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + item_sql, + [*params, int(filters.get("limit", 50)), + int(filters.get("offset", 0))], + ) + items = [dict(row) for row in cur.fetchall()] + cur.execute(summary_sql, params) + summary = dict(cur.fetchone()) + options = {"sites": [], "rooms": []} + if include_location_details: + cur.execute( + f""" + {ctes} + SELECT jsonb_build_object( + 'sites', COALESCE((SELECT jsonb_agg(site_rows + ORDER BY lower(site_rows.name), site_rows.id) + FROM (SELECT DISTINCT site_id AS id, site_name AS name + FROM candidates WHERE site_id IS NOT NULL) site_rows), + '[]'::jsonb), + 'rooms', COALESCE((SELECT jsonb_agg(room_rows + ORDER BY lower(room_rows.name), room_rows.id) + FROM (SELECT DISTINCT room_id AS id, site_id, + room_name AS name FROM candidates + WHERE room_id IS NOT NULL) room_rows), '[]'::jsonb) + ) AS options + """ + ) + options = cur.fetchone()["options"] + finally: + connection.close() + return {"items": items, "summary": summary, "location_options": options} + + def list_miner_repairs(self, miner_id) -> list[dict[str, Any]]: + """Return a bounded, exact repair history for one durable miner.""" + ctes = self._ctes(show_miners=False, show_locations=False) + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + f""" + {ctes} + SELECT candidate.* FROM candidates AS candidate + WHERE candidate.miner_id = %s + ORDER BY candidate.active DESC, + candidate.opened_at DESC NULLS LAST, + candidate.repair_id NULLS LAST + LIMIT 50 + """, + (miner_id,), + ) + return [dict(row) for row in cur.fetchall()] + finally: + connection.close() + + def summarize_active_repairs(self) -> dict[str, Any]: + """Return exact active counts without loading Repair Center rows.""" + ctes = self._ctes(show_miners=False, show_locations=False) + connection = self._connection_factory() + try: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + f""" + {ctes}, active_rows AS ( + SELECT repair_status FROM candidates WHERE active + ) + SELECT count(*)::integer AS active, + COALESCE((SELECT jsonb_object_agg( + status_rows.repair_status, + status_rows.status_count + ) FROM ( + SELECT repair_status, + count(*)::integer AS status_count + FROM active_rows + GROUP BY repair_status + ) AS status_rows), '{{}}'::jsonb) AS status_counts + FROM active_rows + """ + ) + return dict(cur.fetchone()) + finally: + connection.close() diff --git a/backend/app/repairs/center_service.py b/backend/app/repairs/center_service.py new file mode 100644 index 0000000..f8fd90c --- /dev/null +++ b/backend/app/repairs/center_service.py @@ -0,0 +1,124 @@ +"""Application service for the consolidated Repair Center read model.""" + +from app.repairs.center_repository import RepairCenterRepository +from app.repairs.models import ( + ActiveRepairSummary, + RepairCenterAccess, + RepairCenterCapabilities, + RepairCenterFilters, + RepairCenterItem, + RepairCenterList, + RepairCenterLocationOptions, + RepairCenterMeta, + RepairCenterResponse, + RepairCenterSummary, +) + + +class RepairCenterService: + """Build typed, permission-aware Repair Center projections.""" + + def __init__(self, repository: RepairCenterRepository): + self.repository = repository + + @staticmethod + def _capabilities( + item: RepairCenterItem, + access: RepairCenterAccess, + ) -> RepairCenterCapabilities: + modular = item.source == "modular" and item.repair_id is not None + legacy = item.source == "legacy" + placed = bool(item.site_id and item.room_id and item.position_id) + return RepairCenterCapabilities( + can_open_miner=access.can_view_miners, + can_open_map=access.can_view_locations and placed, + can_read_modular_timeline=modular and access.can_read_repairs, + can_read_modular_parts=modular and access.can_read_repairs, + can_manage_modular_parts=( + modular and item.active and access.can_read_repairs + and access.can_manage_parts + ), + can_complete_modular_repair=( + modular and item.active and access.can_complete_repairs + ), + can_read_legacy_history=legacy and access.can_read_repairs, + can_manage_legacy=legacy and access.can_manage_legacy, + can_mark_legacy_repaired=legacy and access.can_manage_legacy, + can_decommission_legacy=legacy and access.can_manage_legacy, + ) + + def list_repair_center( + self, + filters: RepairCenterFilters, + access: RepairCenterAccess, + ) -> RepairCenterResponse: + query = filters.query.strip() if filters.query else None + model = filters.model.strip() if filters.model else None + status = filters.status.strip() if filters.status else None + repository_filters = filters.model_dump() + repository_filters.update(query=query or None, model=model or None, + status=status or None) + projection = self.repository.list_repair_center( + repository_filters, + include_miner_details=access.can_view_miners, + include_location_details=access.can_view_locations, + ) + items: list[RepairCenterItem] = [] + for row in projection["items"]: + row = { + **row, + "miner_status": row.get("runtime_status"), + "latest_event_status": row.get("legacy_event_status"), + "latest_event_description": row.get("latest_context"), + "latest_event_at": row.get("latest_context_at"), + "asset_state": row.get("legacy_asset_state"), + "state_notes": row.get("latest_context"), + "reported_error": row.get("reported_problem"), + } + item = RepairCenterItem.model_validate( + {**row, "capabilities": RepairCenterCapabilities()} + ) + item.capabilities = self._capabilities(item, access) + items.append(item) + + summary = RepairCenterSummary.model_validate(projection["summary"]) + location_options = RepairCenterLocationOptions.model_validate( + projection.get("location_options") or {} + ) + return RepairCenterResponse( + data=RepairCenterList(items=items, summary=summary), + meta=RepairCenterMeta( + limit=filters.limit, + offset=filters.offset, + returned=len(items), + total=summary.total, + location_options=location_options, + ), + ) + + def list_miner_repairs( + self, miner_id, access: RepairCenterAccess + ) -> list[RepairCenterItem]: + items = [] + for row in self.repository.list_miner_repairs(miner_id): + mapped = { + **row, + "miner_status": row.get("runtime_status"), + "latest_event_status": row.get("legacy_event_status"), + "latest_event_description": row.get("latest_context"), + "latest_event_at": row.get("latest_context_at"), + "asset_state": row.get("legacy_asset_state"), + "state_notes": row.get("latest_context"), + "reported_error": row.get("reported_problem"), + } + item = RepairCenterItem.model_validate( + {**mapped, "capabilities": RepairCenterCapabilities()} + ) + item.capabilities = self._capabilities(item, access) + items.append(item) + return items + + def summarize_active_repairs(self) -> ActiveRepairSummary: + return ActiveRepairSummary.model_validate( + self.repository.summarize_active_repairs() + ) diff --git a/backend/app/repairs/dependencies.py b/backend/app/repairs/dependencies.py new file mode 100644 index 0000000..586a989 --- /dev/null +++ b/backend/app/repairs/dependencies.py @@ -0,0 +1,14 @@ +"""Dependency construction for the Repairs domain.""" + +from app.repairs.repository import RepairRepository +from app.repairs.center_repository import RepairCenterRepository +from app.repairs.center_service import RepairCenterService +from app.repairs.service import RepairService + + +def get_repair_service() -> RepairService: + return RepairService(RepairRepository()) + + +def get_repair_center_service() -> RepairCenterService: + return RepairCenterService(RepairCenterRepository()) diff --git a/backend/app/repairs/exceptions.py b/backend/app/repairs/exceptions.py new file mode 100644 index 0000000..2e73dd9 --- /dev/null +++ b/backend/app/repairs/exceptions.py @@ -0,0 +1,172 @@ +"""Domain exceptions for repair workflows.""" + + +class RepairError(Exception): + """Base exception for repair operations.""" + + +class RepairMinerNotFound(RepairError): + """Raised when the requested miner does not exist.""" + + def __init__(self, miner_id: object): + self.miner_id = miner_id + super().__init__(f"Miner not found: {miner_id}") + + +class RepairNotFound(RepairError): + """Raised when the requested repair does not exist.""" + + def __init__(self, repair_id: object): + self.repair_id = repair_id + super().__init__(f"Repair not found: {repair_id}") + + +class RepairConflict(RepairError): + """Base exception for a conflicting repair state.""" + + +class ActiveRepairConflict(RepairConflict): + """Raised when a miner already has an active repair.""" + + def __init__(self, miner_id: object): + self.miner_id = miner_id + super().__init__(f"Miner already has an active repair: {miner_id}") + + +class IncompatibleMinerLifecycle(RepairConflict): + """Raised when the miner lifecycle cannot enter repair.""" + + def __init__(self, miner_id: object, lifecycle_status: object): + self.miner_id = miner_id + self.lifecycle_status = lifecycle_status + super().__init__( + "Miner lifecycle is incompatible with beginning repair: " + f"{lifecycle_status}" + ) + + +class InconsistentRepairState(RepairConflict): + """Raised when lifecycle state and repair records disagree.""" + + def __init__(self, miner_id: object): + self.miner_id = miner_id + super().__init__( + "Miner is marked in repair but has no active repair record" + ) + + +class RepairActorRequired(RepairError): + """Raised when an operational workflow lacks an authenticated actor.""" + + def __init__(self): + super().__init__("An authenticated actor is required to begin repair") + + +class RepairCompletionActorRequired(RepairError): + """Raised when Return to Service lacks an authenticated actor.""" + + def __init__(self): + super().__init__( + "An authenticated actor is required to return a miner to service" + ) + + +class RepairNotActive(RepairConflict): + """Raised when a repair is no longer active.""" + + def __init__(self, repair_id: object, repair_status: object): + self.repair_id = repair_id + self.repair_status = repair_status + super().__init__( + f"Repair is not active: {repair_id} ({repair_status})" + ) + + +class RepairMinerNotInRepair(RepairConflict): + """Raised when a repair's miner has already left repair lifecycle.""" + + def __init__(self, miner_id: object, lifecycle_status: object): + self.miner_id = miner_id + self.lifecycle_status = lifecycle_status + super().__init__( + "Miner is not in repair lifecycle: " + f"{miner_id} ({lifecycle_status})" + ) + + +class RepairActiveMismatch(RepairConflict): + """Raised when the requested repair is not the miner's active repair.""" + + def __init__(self, repair_id: object, miner_id: object): + self.repair_id = repair_id + self.miner_id = miner_id + super().__init__( + f"Repair is not the active repair for miner: {repair_id}" + ) + + +class RepairPersistenceError(RepairError): + """Raised when a required repair persistence operation fails.""" + + +class ControlBoardReplacementActorRequired(RepairError): + """Raised when a board replacement lacks an authenticated actor.""" + + def __init__(self): + super().__init__("An authenticated actor is required to replace a control board") + + +class ControlBoardReplacementConflict(RepairConflict): + """Raised when current repair, component, or MAC state blocks replacement.""" + + +class InvalidMacAddress(RepairError): + """Raised when a replacement MAC is malformed or prohibited.""" + + def __init__(self, message: str = "New MAC address is invalid"): + super().__init__(message) + + +class MacOwnershipConflict(ControlBoardReplacementConflict): + """Raised when a MAC belongs to another durable miner.""" + + def __init__(self, normalized_mac: str): + self.normalized_mac = normalized_mac + super().__init__( + "MAC address has current or historical ownership on another miner" + ) + + +class RepairPartNotFound(RepairError): + """Raised when a nested repair-part UUID does not exist for a repair.""" + + def __init__(self, repair_part_id: object): + self.repair_part_id = repair_part_id + super().__init__(f"Repair part not found: {repair_part_id}") + + +class RepairPartComponentNotFound(RepairError): + """Raised when a tracked component UUID does not exist.""" + + def __init__(self, component_id: object): + self.component_id = component_id + super().__init__(f"Component not found: {component_id}") + + +class RepairPartValidationError(RepairError): + """Raised when repair-part input violates a domain rule.""" + + +class RepairPartActorRequired(RepairError): + """Raised when a repair-part action lacks an authenticated actor.""" + + def __init__(self): + super().__init__("An authenticated actor is required for repair-part actions") + + +class RepairPartAlreadyVoided(RepairConflict): + """Raised when an immutable repair-part entry was already voided.""" + + def __init__(self, repair_part_id: object): + self.repair_part_id = repair_part_id + super().__init__(f"Repair part is already voided: {repair_part_id}") diff --git a/backend/app/repairs/models.py b/backend/app/repairs/models.py new file mode 100644 index 0000000..7959cac --- /dev/null +++ b/backend/app/repairs/models.py @@ -0,0 +1,429 @@ +"""Typed contracts for the Repairs domain.""" + +from datetime import datetime +from decimal import Decimal +from typing import Any, Literal +from uuid import UUID + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator +from app.components.models import ComponentType + + +RepairReason = Literal[ + "HARDWARE_FAILURE", + "LOW_HASHRATE", + "CONTROL_BOARD", + "HASHBOARD", + "PSU", + "FAN", + "OVERHEATING", + "NETWORKING", + "PREVENTIVE_MAINTENANCE", + "INSPECTION", + "OTHER", +] +RepairStatus = Literal[ + "OPEN", + "IN_PROGRESS", + "WAITING_FOR_PARTS", + "READY_FOR_TEST", + "COMPLETED", + "CANCELLED", +] +RepairEventType = Literal[ + "REPAIR_STARTED", + "INITIAL_NOTE", + "REPAIR_COMPLETED", + "CONTROL_BOARD_REPLACED", + "REPAIR_PART_RECORDED", + "REPAIR_PART_VOIDED", +] +RepairEligibleLifecycle = Literal["active", "spare"] +RepairPostLifecycle = Literal["active", "spare"] +RepairMinerLifecycle = Literal["active", "spare", "retired", "in_repair"] + + +class BeginRepairRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + miner_id: UUID + reason: RepairReason + notes: str | None = Field(default=None, max_length=4000) + + +class RepairCompleteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + target_lifecycle: RepairPostLifecycle + completion_summary: str | None = Field(default=None, max_length=4000) + + +ControlBoardReplacementReason = Literal[ + "failed", + "upgrade", + "diagnostic_swap", + "preventive_maintenance", + "temporary_donor", + "unknown", +] +RemovedBoardDisposition = Literal[ + "repair_inventory", + "sent_for_repair", + "scrapped", + "retained_for_diagnosis", + "returned_to_vendor", + "unknown", +] + + +class ControlBoardReplacementRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + new_mac_address: str = Field(min_length=1, max_length=64) + new_control_board_serial: str | None = Field(..., max_length=255) + board_model: str | None = Field(default=None, max_length=255) + board_revision: str | None = Field(default=None, max_length=255) + firmware_version: str | None = Field(default=None, max_length=255) + removed_board_disposition: RemovedBoardDisposition + replacement_reason: ControlBoardReplacementReason + notes: str | None = Field(default=None, max_length=4000) + + +class RepairStartedPayload(BaseModel): + reason: RepairReason + from_lifecycle: RepairEligibleLifecycle + to_lifecycle: Literal["in_repair"] = "in_repair" + + +class InitialRepairNotePayload(BaseModel): + note: str = Field(min_length=1, max_length=4000) + + +class RepairCompletedPayload(BaseModel): + previous_miner_lifecycle: Literal["in_repair"] = "in_repair" + resulting_miner_lifecycle: RepairPostLifecycle + pre_repair_lifecycle: RepairEligibleLifecycle | None = None + completion_summary: str | None = Field(default=None, max_length=4000) + + +class ControlBoardReplacedPayload(BaseModel): + old_mac_address: str | None + new_mac_address: str + old_control_board_serial: str | None + new_control_board_serial: str | None + old_board_model: str | None = None + new_board_model: str | None = None + old_board_revision: str | None = None + new_board_revision: str | None = None + new_firmware_version: str | None = None + removed_board_disposition: RemovedBoardDisposition + replacement_reason: ControlBoardReplacementReason + notes: str | None = Field(default=None, max_length=4000) + + +class ControlBoardReplacementResult(BaseModel): + miner_id: UUID + asset_id: str | None = None + repair_id: UUID + installation_id: UUID + event_id: UUID + event_sequence: int = Field(ge=1) + current_mac: str + control_board_serial: str | None + replaced_at: AwareDatetime + replaced_by: UUID + + +class ControlBoardReplacementResponse(BaseModel): + data: ControlBoardReplacementResult + meta: dict[str, object] = Field(default_factory=dict) + error: None = None + + +class BeginRepairResult(BaseModel): + repair_id: UUID + miner_id: UUID + status: Literal["OPEN"] + reason: RepairReason + opened_at: datetime + opened_by: UUID + + +class BeginRepairResponse(BaseModel): + data: BeginRepairResult + meta: dict[str, object] = Field(default_factory=dict) + error: None = None + + +class RepairActor(BaseModel): + user_id: UUID + display_name: str + + +class RepairDetail(BaseModel): + repair_id: UUID + miner_id: UUID + miner_asset_id: str | None = None + status: RepairStatus + reason: RepairReason + opened_at: AwareDatetime + opened_by: RepairActor + closed_at: AwareDatetime | None = None + closed_by: RepairActor | None = None + miner_lifecycle: RepairMinerLifecycle + latest_event_sequence: int | None = Field(default=None, ge=1) + + +class RepairTimelineEvent(BaseModel): + event_id: UUID + sequence: int = Field(ge=1) + event_type: RepairEventType + actor: RepairActor + occurred_at: AwareDatetime + payload: dict[str, Any] + + +class RepairTimeline(BaseModel): + repair_id: UUID + events: list[RepairTimelineEvent] = Field(default_factory=list) + + +class RepairDetailResponse(BaseModel): + data: RepairDetail + meta: dict[str, object] = Field(default_factory=dict) + error: None = None + + +class RepairTimelineResponse(BaseModel): + data: RepairTimeline + meta: dict[str, object] = Field(default_factory=dict) + error: None = None + + +ConsumableUnit = Literal["each", "gram", "millilitre", "metre", "centimetre"] +RepairPartEntryKind = Literal["consumable", "tracked_component"] + + +class ConsumableInput(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + description: str = Field(min_length=1, max_length=255) + quantity: Decimal = Field(gt=0, max_digits=12, decimal_places=3) + unit: ConsumableUnit + + @model_validator(mode="after") + def validate_each_quantity(self) -> "ConsumableInput": + if self.unit == "each" and self.quantity != self.quantity.to_integral_value(): + raise ValueError("quantity for unit 'each' must be a whole number") + return self + + +class RepairPartRecordRequest(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + consumable: ConsumableInput | None = None + tracked_component_uuid: UUID | None = None + notes: str | None = Field(default=None, max_length=4000) + + @model_validator(mode="after") + def validate_entry_shape(self) -> "RepairPartRecordRequest": + if (self.consumable is None) == (self.tracked_component_uuid is None): + raise ValueError( + "exactly one of consumable and tracked_component_uuid is required" + ) + return self + + +class RepairPartVoidRequest(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + reason: str = Field(min_length=1, max_length=1000) + + +class RepairPartConsumable(BaseModel): + description: str + quantity: Decimal + unit: ConsumableUnit + + +class RepairPartComponentSnapshot(BaseModel): + component_type: ComponentType + asset_id: str | None = None + manufacturer: str | None = None + model: str | None = None + manufacturer_serial: str | None = None + + +class RepairPartDetail(BaseModel): + repair_part_id: UUID + repair_id: UUID + entry_kind: RepairPartEntryKind + consumable: RepairPartConsumable | None = None + component_id: UUID | None = None + component_snapshot: RepairPartComponentSnapshot | None = None + notes: str | None = None + recorded_by: UUID + recorded_at: AwareDatetime + recorded_event_id: UUID + voided_by: UUID | None = None + voided_at: AwareDatetime | None = None + void_reason: str | None = None + void_event_id: UUID | None = None + + +class RepairPartList(BaseModel): + repair_id: UUID + entries: list[RepairPartDetail] = Field(default_factory=list) + + +class RepairPartResponse(BaseModel): + data: RepairPartDetail + meta: dict[str, object] = Field(default_factory=dict) + error: None = None + + +class RepairPartListResponse(BaseModel): + data: RepairPartList + meta: dict[str, object] = Field(default_factory=dict) + error: None = None + + +RepairCenterSource = Literal["modular", "legacy"] +RepairCenterScope = Literal["active", "historical", "all"] + + +class RepairCenterAccess(BaseModel): + can_read_repairs: bool = False + can_view_miners: bool = False + can_view_locations: bool = False + can_complete_repairs: bool = False + can_manage_parts: bool = False + can_manage_legacy: bool = False + + +class RepairCenterFilters(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + query: str | None = Field(default=None, max_length=255) + scope: RepairCenterScope = "active" + status: str | None = Field(default=None, max_length=64) + model: str | None = Field(default=None, max_length=255) + lifecycle_status: RepairMinerLifecycle | None = None + site_id: UUID | None = None + room_id: UUID | None = None + limit: int = Field(default=50, ge=1, le=250) + offset: int = Field(default=0, ge=0) + + +class RepairCenterCapabilities(BaseModel): + can_open_miner: bool = False + can_open_map: bool = False + can_read_modular_timeline: bool = False + can_read_modular_parts: bool = False + can_manage_modular_parts: bool = False + can_complete_modular_repair: bool = False + can_read_legacy_history: bool = False + can_manage_legacy: bool = False + can_mark_legacy_repaired: bool = False + can_decommission_legacy: bool = False + + +class RepairCenterItem(BaseModel): + repair_id: UUID | None = None + source: RepairCenterSource + active: bool + opened_at: AwareDatetime | None = None + closed_at: AwareDatetime | None = None + repair_status: str + reason: str | None = None + reported_problem: str | None = None + latest_context: str | None = None + latest_context_at: AwareDatetime | None = None + opened_by_display_name: str | None = None + miner_id: UUID + asset_id: str | None = None + model: str | None = None + miner_sn: str | None = None + power_sn: str | None = None + current_mac: str | None = None + current_ip: str | None = None + lifecycle_status: RepairMinerLifecycle + runtime_status: str | None = None + miner_status: str | None = None + site_id: UUID | None = None + site_name: str | None = None + room_id: UUID | None = None + room_name: str | None = None + rack_label: str | None = None + shelf_label: str | None = None + position_id: UUID | None = None + position_label: str | None = None + position_index: int | None = None + parts_count: int | None = None + parts_summary: list[str] = Field(default_factory=list) + legacy_event_status: str | None = None + legacy_asset_state: str | None = None + latest_event_status: str | None = None + latest_event_title: str | None = None + latest_event_description: str | None = None + latest_event_at: AwareDatetime | None = None + asset_state: str | None = None + state_notes: str | None = None + repair_event_count: int = 0 + open_repair_event_count: int = 0 + state_updated_at: AwareDatetime | None = None + reported_error: str | None = None + reported_hashrate_ths: str | None = None + reported_voltage: str | None = None + location_note: str | None = None + import_source: str | None = None + capabilities: RepairCenterCapabilities + + +class RepairCenterSummary(BaseModel): + total: int + active: int + historical: int + status_counts: dict[str, int] = Field(default_factory=dict) + model_counts: dict[str, int] = Field(default_factory=dict) + error_counts: dict[str, int] = Field(default_factory=dict) + + +class ActiveRepairSummary(BaseModel): + active: int + status_counts: dict[str, int] = Field(default_factory=dict) + + +class RepairCenterLocationOption(BaseModel): + id: UUID + name: str + + +class RepairCenterRoomOption(RepairCenterLocationOption): + site_id: UUID + + +class RepairCenterLocationOptions(BaseModel): + sites: list[RepairCenterLocationOption] = Field(default_factory=list) + rooms: list[RepairCenterRoomOption] = Field(default_factory=list) + + +class RepairCenterList(BaseModel): + items: list[RepairCenterItem] + summary: RepairCenterSummary + + +class RepairCenterMeta(BaseModel): + limit: int + offset: int + returned: int + total: int + location_options: RepairCenterLocationOptions = Field( + default_factory=RepairCenterLocationOptions + ) + + +class RepairCenterResponse(BaseModel): + data: RepairCenterList + meta: RepairCenterMeta + error: None = None diff --git a/backend/app/repairs/repository.py b/backend/app/repairs/repository.py new file mode 100644 index 0000000..5e695af --- /dev/null +++ b/backend/app/repairs/repository.py @@ -0,0 +1,846 @@ +"""PostgreSQL persistence for repair workflows.""" + +from collections.abc import Callable, Iterator, Mapping +from contextlib import contextmanager +from typing import Any +from uuid import UUID + +import psycopg +from psycopg.errors import CheckViolation, LockNotAvailable, UniqueViolation +from psycopg.rows import dict_row +from psycopg.types.json import Jsonb + +from app.config import settings +from app.repairs.exceptions import ( + ActiveRepairConflict, + ControlBoardReplacementConflict, + MacOwnershipConflict, + RepairPersistenceError, +) + + +ConnectionFactory = Callable[[], psycopg.Connection] +ACTIVE_REPAIR_STATUSES = ( + "OPEN", + "IN_PROGRESS", + "WAITING_FOR_PARTS", + "READY_FOR_TEST", +) +ACTIVE_REPAIR_CONSTRAINT = "uq_repairs_one_active_per_miner" + + +def default_connection_factory() -> psycopg.Connection: + return psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + connect_timeout=5, + ) + + +class RepairRepository: + """Persistence boundary for explicit atomic repair workflows.""" + + def __init__( + self, + connection_factory: ConnectionFactory | None = None, + *, + connection: psycopg.Connection | None = None, + ): + self._connection_factory = connection_factory or default_connection_factory + self._connection = connection + + @contextmanager + def transaction(self) -> Iterator["RepairRepository"]: + if self._connection is not None: + yield self + return + + connection = self._connection_factory() + transaction_repository = RepairRepository( + self._connection_factory, + connection=connection, + ) + try: + yield transaction_repository + connection.commit() + except UniqueViolation as exc: + connection.rollback() + if exc.diag.constraint_name in { + "uq_miner_mac_ownerships_owner", + "uq_miner_network_identities_normalized_mac", + "uq_miners_current_normalized_mac", + "uq_control_board_installations_current_miner", + "uq_control_board_installations_current_mac", + }: + raise ControlBoardReplacementConflict( + "Concurrent control-board replacement or MAC ownership conflict" + ) from exc + raise + except CheckViolation as exc: + connection.rollback() + if exc.diag.constraint_name == "ck_current_control_board_projection": + raise ControlBoardReplacementConflict( + "Control-board projection would contradict authoritative history" + ) from exc + raise + except Exception: + connection.rollback() + raise + finally: + connection.close() + + @contextmanager + def _connection_scope( + self, + *, + write: bool = False, + ) -> Iterator[psycopg.Connection]: + if self._connection is not None: + yield self._connection + return + + connection = self._connection_factory() + try: + yield connection + if write: + connection.commit() + except Exception: + if write: + connection.rollback() + raise + finally: + connection.close() + + def lock_miner_for_update(self, miner_id: UUID) -> dict | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT id, asset_tag, lifecycle_status, current_mac, control_board_sn, + firmware_version + FROM miners + WHERE id = %s + FOR UPDATE + """, + (miner_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + + def lock_miner_for_control_board_replacement( + self, miner_id: UUID + ) -> dict | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + try: + cur.execute( + """ + SELECT id, asset_tag, lifecycle_status, current_mac, + control_board_sn, firmware_version + FROM miners WHERE id = %s FOR UPDATE NOWAIT + """, + (miner_id,), + ) + except LockNotAvailable as exc: + raise ControlBoardReplacementConflict( + "A control-board replacement is already in progress" + ) from exc + row = cur.fetchone() + return dict(row) if row else None + + def get_active_repair(self, miner_id: UUID) -> dict | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT + id, + miner_id, + status, + reason, + opened_at, + opened_by, + completed_at, + completed_by + FROM repairs + WHERE miner_id = %s + AND status = ANY(%s) + ORDER BY opened_at, id + LIMIT 1 + """, + (miner_id, list(ACTIVE_REPAIR_STATUSES)), + ) + row = cur.fetchone() + return dict(row) if row else None + + def create_repair( + self, + miner_id: UUID, + reason: str, + opened_by: UUID, + ) -> dict: + with self._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + try: + cur.execute( + """ + INSERT INTO repairs (miner_id, reason, opened_by) + VALUES (%s, %s, %s) + RETURNING + id, + miner_id, + status, + reason, + opened_at, + opened_by, + completed_at, + completed_by + """, + (miner_id, reason, opened_by), + ) + except UniqueViolation as exc: + if exc.diag.constraint_name == ACTIVE_REPAIR_CONSTRAINT: + raise ActiveRepairConflict(miner_id) from exc + raise + return dict(cur.fetchone()) + + def create_event( + self, + repair_id: UUID, + *, + sequence: int, + event_type: str, + created_by: UUID, + payload: Mapping[str, Any], + ) -> dict: + with self._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + INSERT INTO repair_events ( + repair_id, + sequence, + event_type, + created_by, + payload + ) + VALUES (%s, %s, %s, %s, %s) + RETURNING + id, + repair_id, + sequence, + event_type, + created_at, + created_by, + payload + """, + ( + repair_id, + sequence, + event_type, + created_by, + Jsonb(dict(payload)), + ), + ) + return dict(cur.fetchone()) + + def set_miner_lifecycle( + self, + miner_id: UUID, + lifecycle_status: str, + ) -> None: + with self._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute( + """ + UPDATE miners + SET lifecycle_status = %s, + updated_at = now() + WHERE id = %s + """, + (lifecycle_status, miner_id), + ) + if cur.rowcount != 1: + raise RepairPersistenceError( + "Miner lifecycle transition did not update one record" + ) + + def get_repair_identity(self, repair_id: UUID) -> dict | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT id, miner_id + FROM repairs + WHERE id = %s + """, + (repair_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + + def lock_repair_for_update(self, repair_id: UUID) -> dict | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT + id, + miner_id, + status, + reason, + opened_at, + opened_by, + completed_at, + completed_by, + completion_summary, + resulting_lifecycle + FROM repairs + WHERE id = %s + FOR UPDATE + """, + (repair_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + + def get_next_event_sequence(self, repair_id: UUID) -> int: + with self._connection_scope() as connection: + with connection.cursor() as cur: + cur.execute( + """ + SELECT COALESCE(max(sequence), 0) + 1 + FROM repair_events + WHERE repair_id = %s + """, + (repair_id,), + ) + return int(cur.fetchone()[0]) + + def get_pre_repair_lifecycle(self, repair_id: UUID) -> str | None: + with self._connection_scope() as connection: + with connection.cursor() as cur: + cur.execute( + """ + SELECT event.payload ->> 'from_lifecycle' + FROM repair_events AS event + WHERE event.repair_id = %s + AND event.event_type = 'REPAIR_STARTED' + ORDER BY event.sequence + LIMIT 2 + """, + (repair_id,), + ) + rows = cur.fetchall() + + if len(rows) != 1: + return None + lifecycle_status = rows[0][0] + if lifecycle_status not in {"active", "spare"}: + return None + return str(lifecycle_status) + + def complete_repair( + self, + repair_id: UUID, + *, + completed_by: UUID, + completion_summary: str | None, + resulting_lifecycle: str, + ) -> dict | None: + with self._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + UPDATE repairs + SET status = 'COMPLETED', + completed_at = now(), + completed_by = %s, + completion_summary = %s, + resulting_lifecycle = %s + WHERE id = %s + AND status = ANY(%s) + RETURNING + id, + miner_id, + status, + reason, + opened_at, + opened_by, + completed_at, + completed_by, + completion_summary, + resulting_lifecycle + """, + ( + completed_by, + completion_summary, + resulting_lifecycle, + repair_id, + list(ACTIVE_REPAIR_STATUSES), + ), + ) + row = cur.fetchone() + return dict(row) if row else None + + def lock_active_repair_for_miner(self, miner_id: UUID) -> dict | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT id, miner_id, status, reason, opened_at, opened_by + FROM repairs + WHERE miner_id = %s AND status = ANY(%s) + ORDER BY opened_at, id + LIMIT 1 + FOR UPDATE + """, + (miner_id, list(ACTIVE_REPAIR_STATUSES)), + ) + row = cur.fetchone() + return dict(row) if row else None + + def lock_current_control_board(self, miner_id: UUID) -> dict | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT * FROM miner_control_board_installations + WHERE miner_id = %s AND removed_at IS NULL + FOR UPDATE + """, + (miner_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + + def create_legacy_control_board(self, miner: Mapping[str, Any]) -> dict: + with self._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + mac_address = miner.get("current_mac") + normalized_mac = ( + "".join(ch for ch in str(mac_address).lower() if ch in "0123456789abcdef") + if mac_address else None + ) + if normalized_mac and len(normalized_mac) == 12: + mac_address = ":".join( + normalized_mac[index:index + 2].upper() + for index in range(0, 12, 2) + ) + elif normalized_mac: + normalized_mac = None + mac_address = None + cur.execute( + """ + INSERT INTO miner_control_board_installations ( + miner_id, record_origin, mac_address, normalized_mac, + board_serial, firmware_version + ) + VALUES (%s, 'legacy_projection', %s, %s, %s, %s) + RETURNING * + """, + (miner["id"], mac_address, normalized_mac, + miner.get("control_board_sn"), miner.get("firmware_version")), + ) + return dict(cur.fetchone()) + + def claim_mac_ownership( + self, normalized_mac: str, miner_id: UUID, actor_user_id: UUID + ) -> None: + with self._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + "SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", + (normalized_mac,), + ) + cur.execute( + "SELECT miner_id FROM miner_mac_ownerships " + "WHERE normalized_mac = %s FOR UPDATE", + (normalized_mac,), + ) + row = cur.fetchone() + if row and row["miner_id"] != miner_id: + raise MacOwnershipConflict(normalized_mac) + if row is None: + cur.execute( + """ + INSERT INTO miner_mac_ownerships ( + normalized_mac, miner_id, claimed_at, claimed_by + ) VALUES (%s, %s, now(), %s) + """, + (normalized_mac, miner_id, actor_user_id), + ) + + def close_control_board( + self, installation_id: UUID, *, repair_id: UUID, actor_user_id: UUID, + disposition: str + ) -> dict: + with self._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + UPDATE miner_control_board_installations + SET removed_at = now(), removed_by = %s, + removed_repair_id = %s, removed_disposition = %s + WHERE id = %s AND removed_at IS NULL + RETURNING * + """, + (actor_user_id, repair_id, disposition, installation_id), + ) + row = cur.fetchone() + if row is None: + raise RepairPersistenceError( + "Current control-board installation was not closed" + ) + return dict(row) + + def create_control_board_installation( + self, *, miner_id: UUID, repair_id: UUID, mac_address: str, + normalized_mac: str, board_serial: str | None, board_model: str | None, + board_revision: str | None, firmware_version: str | None, + replacement_reason: str, notes: str | None, actor_user_id: UUID + ) -> dict: + with self._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + INSERT INTO miner_control_board_installations ( + miner_id, record_origin, installed_repair_id, mac_address, + normalized_mac, board_serial, board_model, board_revision, + firmware_version, replacement_reason, installed_at, + installed_by, notes + ) VALUES ( + %s, 'replacement', %s, %s, %s, %s, %s, %s, %s, %s, + now(), %s, %s + ) RETURNING * + """, + (miner_id, repair_id, mac_address, normalized_mac, board_serial, + board_model, board_revision, firmware_version, + replacement_reason, actor_user_id, notes), + ) + return dict(cur.fetchone()) + + def set_current_control_board( + self, miner_id: UUID, *, mac_address: str, board_serial: str | None + ) -> None: + with self._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute( + """ + UPDATE miners SET current_mac = %s, control_board_sn = %s, + updated_at = now() WHERE id = %s + """, + (mac_address, board_serial, miner_id), + ) + if cur.rowcount != 1: + raise RepairPersistenceError( + "Current miner control-board projection was not updated" + ) + + def ensure_network_identity_history( + self, miner_id: UUID, *, mac_address: str, normalized_mac: str + ) -> None: + with self._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute( + """ + INSERT INTO miner_network_identities ( + miner_id, mac_address, source, is_current, + first_seen_at, last_seen_at, associated_at + ) + SELECT %s, %s, 'legacy_projection', false, NULL, NULL, now() + WHERE NOT EXISTS ( + SELECT 1 FROM miner_network_identities + WHERE miner_id = %s + AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = %s + ) + """, + (miner_id, mac_address, miner_id, normalized_mac), + ) + + def set_current_network_identity( + self, miner_id: UUID, *, mac_address: str, normalized_mac: str + ) -> dict: + with self._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + "UPDATE miner_network_identities SET is_current = false, " + "updated_at = now() WHERE miner_id = %s AND is_current", + (miner_id,), + ) + cur.execute( + """ + UPDATE miner_network_identities + SET is_current = true, associated_at = now(), updated_at = now() + WHERE miner_id = %s + AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = %s + RETURNING * + """, + (miner_id, normalized_mac), + ) + row = cur.fetchone() + if row is None: + cur.execute( + """ + INSERT INTO miner_network_identities ( + miner_id, mac_address, source, is_current, + first_seen_at, last_seen_at, associated_at + ) VALUES (%s, %s, 'control_board_replacement', true, + NULL, NULL, now()) + RETURNING * + """, + (miner_id, mac_address), + ) + row = cur.fetchone() + return dict(row) + + def lock_component_for_update(self, component_id: UUID) -> dict | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT + id, + component_type, + asset_id, + manufacturer, + model, + manufacturer_serial + FROM components + WHERE id = %s + FOR UPDATE + """, + (component_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + + def create_repair_part( + self, + *, + repair_part_id: UUID, + repair_id: UUID, + consumable_description: str | None, + quantity: object | None, + unit: str | None, + component: Mapping[str, Any] | None, + notes: str | None, + recorded_event: Mapping[str, Any], + ) -> dict: + with self._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + INSERT INTO repair_parts ( + id, + repair_id, + consumable_description, + quantity, + unit, + component_id, + component_type_snapshot, + component_asset_id_snapshot, + component_manufacturer_snapshot, + component_model_snapshot, + component_manufacturer_serial_snapshot, + notes, + recorded_by, + recorded_at, + recorded_event_id + ) + VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s, %s + ) + RETURNING * + """, + ( + repair_part_id, + repair_id, + consumable_description, + quantity, + unit, + component.get("id") if component else None, + component.get("component_type") if component else None, + component.get("asset_id") if component else None, + component.get("manufacturer") if component else None, + component.get("model") if component else None, + component.get("manufacturer_serial") if component else None, + notes, + recorded_event["created_by"], + recorded_event["created_at"], + recorded_event["id"], + ), + ) + return dict(cur.fetchone()) + + def get_repair_part( + self, + repair_id: UUID, + repair_part_id: UUID, + ) -> dict | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT part.*, event.sequence AS recorded_event_sequence + FROM repair_parts AS part + JOIN repair_events AS event + ON event.id = part.recorded_event_id + WHERE part.repair_id = %s AND part.id = %s + """, + (repair_id, repair_part_id), + ) + row = cur.fetchone() + return dict(row) if row else None + + def get_repair_parts(self, repair_id: UUID) -> list[dict] | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT + repair.id AS existing_repair_id, + part.*, + event.sequence AS recorded_event_sequence + FROM repairs AS repair + LEFT JOIN repair_parts AS part + ON part.repair_id = repair.id + LEFT JOIN repair_events AS event + ON event.id = part.recorded_event_id + WHERE repair.id = %s + ORDER BY event.sequence ASC, part.id ASC + """, + (repair_id,), + ) + rows = [dict(row) for row in cur.fetchall()] + + if not rows: + return None + return [row for row in rows if row.get("id") is not None] + + def lock_repair_part_for_update( + self, + repair_id: UUID, + repair_part_id: UUID, + ) -> dict | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT * + FROM repair_parts + WHERE repair_id = %s AND id = %s + FOR UPDATE + """, + (repair_id, repair_part_id), + ) + row = cur.fetchone() + return dict(row) if row else None + + def void_repair_part( + self, + repair_id: UUID, + repair_part_id: UUID, + *, + reason: str, + void_event: Mapping[str, Any], + ) -> dict | None: + with self._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + UPDATE repair_parts + SET voided_by = %s, + voided_at = %s, + void_reason = %s, + void_event_id = %s + WHERE repair_id = %s + AND id = %s + AND voided_at IS NULL + RETURNING * + """, + ( + void_event["created_by"], + void_event["created_at"], + reason, + void_event["id"], + repair_id, + repair_part_id, + ), + ) + row = cur.fetchone() + return dict(row) if row else None + + def get_repair_detail(self, repair_id: UUID) -> dict | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT + repair.id AS repair_id, + repair.miner_id, + miner.asset_tag AS miner_asset_id, + repair.status, + repair.reason, + repair.opened_at, + opened_actor.id AS opened_by_user_id, + opened_actor.display_name AS opened_by_display_name, + repair.completed_at AS closed_at, + closed_actor.id AS closed_by_user_id, + closed_actor.display_name AS closed_by_display_name, + miner.lifecycle_status AS miner_lifecycle, + ( + SELECT max(event.sequence) + FROM repair_events AS event + WHERE event.repair_id = repair.id + ) AS latest_event_sequence + FROM repairs AS repair + JOIN miners AS miner ON miner.id = repair.miner_id + JOIN users AS opened_actor + ON opened_actor.id = repair.opened_by + LEFT JOIN users AS closed_actor + ON closed_actor.id = repair.completed_by + WHERE repair.id = %s + """, + (repair_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + + def get_repair_events( + self, + repair_id: UUID, + ) -> list[dict] | None: + with self._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + SELECT + repair.id AS repair_id, + event.id AS event_id, + event.sequence, + event.event_type, + actor.id AS actor_user_id, + actor.display_name AS actor_display_name, + event.created_at AS occurred_at, + event.payload + FROM repairs AS repair + LEFT JOIN ( + repair_events AS event + JOIN users AS actor + ON actor.id = event.created_by + ) ON event.repair_id = repair.id + WHERE repair.id = %s + ORDER BY event.sequence ASC + """, + (repair_id,), + ) + rows = [dict(row) for row in cur.fetchall()] + + if not rows: + return None + return [row for row in rows if row["event_id"] is not None] diff --git a/backend/app/repairs/router.py b/backend/app/repairs/router.py new file mode 100644 index 0000000..6da37d1 --- /dev/null +++ b/backend/app/repairs/router.py @@ -0,0 +1,300 @@ +"""HTTP mapping for explicit repair workflows.""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.identity import require_permission +from app.repairs.dependencies import get_repair_service +from app.repairs.exceptions import ( + ControlBoardReplacementActorRequired, + InvalidMacAddress, + RepairActorRequired, + RepairCompletionActorRequired, + RepairConflict, + RepairMinerNotFound, + RepairNotFound, + RepairPartActorRequired, + RepairPartComponentNotFound, + RepairPartNotFound, + RepairPartValidationError, +) +from app.repairs.models import ( + BeginRepairRequest, + ControlBoardReplacementRequest, + ControlBoardReplacementResponse, + BeginRepairResponse, + RepairCompleteRequest, + RepairDetailResponse, + RepairTimelineResponse, + RepairPartListResponse, + RepairPartRecordRequest, + RepairPartResponse, + RepairPartVoidRequest, +) +from app.repairs.service import RepairService + + +router = APIRouter(prefix="/api/repairs", tags=["repairs"]) +control_board_router = APIRouter(prefix="/api/miners", tags=["miners"]) +require_repair_create = require_permission("repairs.create") +require_repair_read = require_permission("repairs.read") +require_repair_complete = require_permission("repairs.complete") +require_repair_parts_manage = require_permission("repairs.parts.manage") +require_control_board_replace = require_permission("miners.replace_control_board") + +RepairServiceDependency = Annotated[ + RepairService, + Depends(get_repair_service), +] +RepairCreateUser = Annotated[dict, Depends(require_repair_create)] +RepairReadUser = Annotated[dict, Depends(require_repair_read)] +RepairCompleteUser = Annotated[dict, Depends(require_repair_complete)] +RepairPartsManageUser = Annotated[dict, Depends(require_repair_parts_manage)] +ControlBoardReplaceUser = Annotated[dict, Depends(require_control_board_replace)] + + +@control_board_router.post( + "/{miner_id}/replace-control-board", + response_model=ControlBoardReplacementResponse, +) +def replace_control_board( + miner_id: UUID, + request: ControlBoardReplacementRequest, + service: RepairServiceDependency, + current_user: ControlBoardReplaceUser, +) -> ControlBoardReplacementResponse: + try: + result = service.replace_control_board( + miner_id, request, + actor_user_id=UUID(str(current_user["id"])), + ) + except RepairMinerNotFound as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Miner not found" + ) from exc + except RepairConflict as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail=str(exc) + ) from exc + except (InvalidMacAddress, ControlBoardReplacementActorRequired) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc) + ) from exc + return ControlBoardReplacementResponse(data=result) + + +@router.post( + "", + response_model=BeginRepairResponse, + status_code=status.HTTP_201_CREATED, +) +def begin_repair( + request: BeginRepairRequest, + service: RepairServiceDependency, + current_user: RepairCreateUser, +) -> BeginRepairResponse: + try: + repair = service.begin_repair( + request, + actor_user_id=UUID(str(current_user["id"])), + ) + except RepairMinerNotFound as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Miner not found", + ) from exc + except RepairConflict as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + except RepairActorRequired as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + return BeginRepairResponse(data=repair) + + +@router.post( + "/{repair_id}/complete", + response_model=RepairDetailResponse, +) +def complete_repair( + repair_id: UUID, + request: RepairCompleteRequest, + service: RepairServiceDependency, + current_user: RepairCompleteUser, +) -> RepairDetailResponse: + try: + repair = service.complete_repair( + repair_id, + request, + actor_user_id=UUID(str(current_user["id"])), + ) + except RepairNotFound as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Repair not found", + ) from exc + except RepairMinerNotFound as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Repair miner no longer exists", + ) from exc + except RepairConflict as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + except RepairCompletionActorRequired as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + return RepairDetailResponse(data=repair) + + +@router.get( + "/{repair_id}/parts", + response_model=RepairPartListResponse, +) +def list_repair_parts( + repair_id: UUID, + service: RepairServiceDependency, + current_user: RepairReadUser, +) -> RepairPartListResponse: + del current_user + try: + parts = service.list_repair_parts(repair_id) + except RepairNotFound as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Repair not found", + ) from exc + return RepairPartListResponse(data=parts) + + +@router.post( + "/{repair_id}/parts", + response_model=RepairPartResponse, + status_code=status.HTTP_201_CREATED, +) +def record_repair_part( + repair_id: UUID, + request: RepairPartRecordRequest, + service: RepairServiceDependency, + current_user: RepairPartsManageUser, +) -> RepairPartResponse: + try: + part = service.record_repair_part( + repair_id, + request, + actor_user_id=UUID(str(current_user["id"])), + ) + except (RepairNotFound, RepairPartComponentNotFound) as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + except RepairMinerNotFound as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Repair miner no longer exists", + ) from exc + except RepairConflict as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + except (RepairPartActorRequired, RepairPartValidationError) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + return RepairPartResponse(data=part) + + +@router.post( + "/{repair_id}/parts/{repair_part_id}/void", + response_model=RepairPartResponse, +) +def void_repair_part( + repair_id: UUID, + repair_part_id: UUID, + request: RepairPartVoidRequest, + service: RepairServiceDependency, + current_user: RepairPartsManageUser, +) -> RepairPartResponse: + try: + part = service.void_repair_part( + repair_id, + repair_part_id, + request, + actor_user_id=UUID(str(current_user["id"])), + ) + except (RepairNotFound, RepairPartNotFound) as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + except RepairMinerNotFound as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Repair miner no longer exists", + ) from exc + except RepairConflict as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + except (RepairPartActorRequired, RepairPartValidationError) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + return RepairPartResponse(data=part) + + +@router.get( + "/{repair_id}", + response_model=RepairDetailResponse, +) +def get_repair( + repair_id: UUID, + service: RepairServiceDependency, + current_user: RepairReadUser, +) -> RepairDetailResponse: + del current_user + try: + repair = service.get_repair(repair_id) + except RepairNotFound as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Repair not found", + ) from exc + return RepairDetailResponse(data=repair) + + +@router.get( + "/{repair_id}/events", + response_model=RepairTimelineResponse, +) +def get_repair_events( + repair_id: UUID, + service: RepairServiceDependency, + current_user: RepairReadUser, +) -> RepairTimelineResponse: + del current_user + try: + timeline = service.get_repair_timeline(repair_id) + except RepairNotFound as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Repair not found", + ) from exc + return RepairTimelineResponse(data=timeline) diff --git a/backend/app/repairs/service.py b/backend/app/repairs/service.py new file mode 100644 index 0000000..f4eef43 --- /dev/null +++ b/backend/app/repairs/service.py @@ -0,0 +1,684 @@ +"""Business rules and orchestration for repair workflows.""" + +from decimal import Decimal +from uuid import UUID, uuid4 + +import re + +from app.repairs.exceptions import ( + ActiveRepairConflict, + ControlBoardReplacementActorRequired, + ControlBoardReplacementConflict, + InvalidMacAddress, + IncompatibleMinerLifecycle, + InconsistentRepairState, + RepairActiveMismatch, + RepairActorRequired, + RepairCompletionActorRequired, + RepairMinerNotFound, + RepairMinerNotInRepair, + RepairNotActive, + RepairNotFound, + RepairPersistenceError, + RepairPartActorRequired, + RepairPartAlreadyVoided, + RepairPartComponentNotFound, + RepairPartNotFound, + RepairPartValidationError, +) +from app.repairs.models import ( + BeginRepairRequest, + ControlBoardReplacedPayload, + ControlBoardReplacementRequest, + ControlBoardReplacementResult, + BeginRepairResult, + InitialRepairNotePayload, + RepairActor, + RepairCompleteRequest, + RepairCompletedPayload, + RepairPartComponentSnapshot, + RepairPartConsumable, + RepairPartDetail, + RepairPartList, + RepairPartRecordRequest, + RepairPartVoidRequest, + RepairDetail, + RepairTimeline, + RepairTimelineEvent, + RepairStartedPayload, +) +from app.repairs.repository import RepairRepository + + +ELIGIBLE_LIFECYCLE_STATUSES = frozenset({"active", "spare"}) +ACTIVE_REPAIR_STATUSES = frozenset( + {"OPEN", "IN_PROGRESS", "WAITING_FOR_PARTS", "READY_FOR_TEST"} +) +REPAIR_LIFECYCLE_STATUS = "in_repair" + + +class RepairService: + """Explicit operational repair workflows independent of FastAPI.""" + + def __init__(self, repository: RepairRepository): + self.repository = repository + + @staticmethod + def normalize_note(note: str | None) -> str | None: + if note is None: + return None + normalized = str(note).strip() + return normalized or None + + @staticmethod + def _map_repair_detail(repair: dict) -> RepairDetail: + closed_by = None + if repair["closed_by_user_id"] is not None: + closed_by = RepairActor( + user_id=repair["closed_by_user_id"], + display_name=repair["closed_by_display_name"], + ) + + return RepairDetail( + repair_id=repair["repair_id"], + miner_id=repair["miner_id"], + miner_asset_id=repair["miner_asset_id"], + status=repair["status"], + reason=repair["reason"], + opened_at=repair["opened_at"], + opened_by=RepairActor( + user_id=repair["opened_by_user_id"], + display_name=repair["opened_by_display_name"], + ), + closed_at=repair["closed_at"], + closed_by=closed_by, + miner_lifecycle=repair["miner_lifecycle"], + latest_event_sequence=repair["latest_event_sequence"], + ) + + def begin_repair( + self, + request: BeginRepairRequest, + *, + actor_user_id: UUID | None, + ) -> BeginRepairResult: + if actor_user_id is None: + raise RepairActorRequired() + + note = self.normalize_note(request.notes) + + with self.repository.transaction() as repository: + miner = repository.lock_miner_for_update(request.miner_id) + if not miner: + raise RepairMinerNotFound(request.miner_id) + + lifecycle_status = str(miner["lifecycle_status"]) + active_repair = repository.get_active_repair(request.miner_id) + if active_repair: + raise ActiveRepairConflict(request.miner_id) + if lifecycle_status == REPAIR_LIFECYCLE_STATUS: + raise InconsistentRepairState(request.miner_id) + if lifecycle_status not in ELIGIBLE_LIFECYCLE_STATUSES: + raise IncompatibleMinerLifecycle( + request.miner_id, + lifecycle_status, + ) + + repair = repository.create_repair( + request.miner_id, + request.reason, + actor_user_id, + ) + started_payload = RepairStartedPayload( + reason=request.reason, + from_lifecycle=lifecycle_status, + ) + repository.create_event( + repair["id"], + sequence=1, + event_type="REPAIR_STARTED", + created_by=actor_user_id, + payload=started_payload.model_dump(mode="json"), + ) + if note is not None: + note_payload = InitialRepairNotePayload(note=note) + repository.create_event( + repair["id"], + sequence=2, + event_type="INITIAL_NOTE", + created_by=actor_user_id, + payload=note_payload.model_dump(mode="json"), + ) + repository.set_miner_lifecycle( + request.miner_id, + REPAIR_LIFECYCLE_STATUS, + ) + + return BeginRepairResult( + repair_id=repair["id"], + miner_id=repair["miner_id"], + status=repair["status"], + reason=repair["reason"], + opened_at=repair["opened_at"], + opened_by=repair["opened_by"], + ) + + @staticmethod + def normalize_optional_text(value: str | None) -> str | None: + if value is None: + return None + normalized = str(value).strip() + return normalized or None + + @staticmethod + def normalize_mac_address(value: str) -> tuple[str, str]: + raw = str(value).strip() + if not raw or re.fullmatch(r"[0-9A-Fa-f:\-.\s]+", raw) is None: + raise InvalidMacAddress() + normalized = re.sub(r"[^0-9A-Fa-f]", "", raw).lower() + if len(normalized) != 12: + raise InvalidMacAddress() + first_octet = int(normalized[:2], 16) + if normalized == "000000000000": + raise InvalidMacAddress("All-zero MAC addresses are not allowed") + if normalized == "ffffffffffff": + raise InvalidMacAddress("Broadcast MAC addresses are not allowed") + if first_octet & 1: + raise InvalidMacAddress("Multicast MAC addresses are not allowed") + canonical = ":".join( + normalized[index:index + 2].upper() + for index in range(0, 12, 2) + ) + return canonical, normalized + + def replace_control_board( + self, miner_id: UUID, request: ControlBoardReplacementRequest, *, + actor_user_id: UUID | None + ) -> ControlBoardReplacementResult: + if actor_user_id is None: + raise ControlBoardReplacementActorRequired() + + new_mac, new_mac_normalized = self.normalize_mac_address( + request.new_mac_address + ) + new_serial = self.normalize_optional_text( + request.new_control_board_serial + ) + board_model = self.normalize_optional_text(request.board_model) + board_revision = self.normalize_optional_text(request.board_revision) + firmware_version = self.normalize_optional_text(request.firmware_version) + notes = self.normalize_note(request.notes) + + with self.repository.transaction() as repository: + miner = repository.lock_miner_for_control_board_replacement(miner_id) + if miner is None: + raise RepairMinerNotFound(miner_id) + if str(miner["lifecycle_status"]) != REPAIR_LIFECYCLE_STATUS: + raise RepairMinerNotInRepair( + miner_id, miner["lifecycle_status"] + ) + + repair = repository.lock_active_repair_for_miner(miner_id) + if repair is None: + raise ControlBoardReplacementConflict( + "Miner has no active repair" + ) + + current_board = repository.lock_current_control_board(miner_id) + if current_board is None: + if not miner.get("current_mac") and not miner.get("control_board_sn"): + raise ControlBoardReplacementConflict( + "Miner has no current control-board identity to replace" + ) + current_board = repository.create_legacy_control_board(miner) + + old_mac = current_board.get("mac_address") or miner.get("current_mac") + old_mac_canonical = None + old_mac_normalized = None + if old_mac: + try: + old_mac_canonical, old_mac_normalized = ( + self.normalize_mac_address(str(old_mac)) + ) + except InvalidMacAddress: + old_mac_canonical = str(old_mac) + if old_mac_normalized == new_mac_normalized: + raise ControlBoardReplacementConflict( + "New MAC address is the same as the current MAC address" + ) + + if old_mac_normalized is not None: + repository.ensure_network_identity_history( + miner_id, mac_address=old_mac_canonical, + normalized_mac=old_mac_normalized + ) + repository.claim_mac_ownership( + new_mac_normalized, miner_id, actor_user_id + ) + previous_board = repository.close_control_board( + current_board["id"], + repair_id=repair["id"], + actor_user_id=actor_user_id, + disposition=request.removed_board_disposition, + ) + installation = repository.create_control_board_installation( + miner_id=miner_id, + repair_id=repair["id"], + mac_address=new_mac, + normalized_mac=new_mac_normalized, + board_serial=new_serial, + board_model=board_model, + board_revision=board_revision, + firmware_version=firmware_version, + replacement_reason=request.replacement_reason, + notes=notes, + actor_user_id=actor_user_id, + ) + repository.set_current_network_identity( + miner_id, mac_address=new_mac, + normalized_mac=new_mac_normalized + ) + repository.set_current_control_board( + miner_id, mac_address=new_mac, board_serial=new_serial + ) + + next_sequence = repository.get_next_event_sequence(repair["id"]) + payload = ControlBoardReplacedPayload( + old_mac_address=old_mac_canonical, + new_mac_address=new_mac, + old_control_board_serial=previous_board.get("board_serial"), + new_control_board_serial=new_serial, + old_board_model=previous_board.get("board_model"), + new_board_model=board_model, + old_board_revision=previous_board.get("board_revision"), + new_board_revision=board_revision, + new_firmware_version=firmware_version, + removed_board_disposition=request.removed_board_disposition, + replacement_reason=request.replacement_reason, + notes=notes, + ) + event = repository.create_event( + repair["id"], sequence=next_sequence, + event_type="CONTROL_BOARD_REPLACED", + created_by=actor_user_id, + payload=payload.model_dump(mode="json", exclude_none=False), + ) + + return ControlBoardReplacementResult( + miner_id=miner_id, asset_id=miner.get("asset_tag"), + repair_id=repair["id"], installation_id=installation["id"], + event_id=event["id"], event_sequence=event["sequence"], + current_mac=new_mac, control_board_serial=new_serial, + replaced_at=event["created_at"], replaced_by=actor_user_id, + ) + + def complete_repair( + self, + repair_id: UUID, + request: RepairCompleteRequest, + *, + actor_user_id: UUID | None, + ) -> RepairDetail: + if actor_user_id is None: + raise RepairCompletionActorRequired() + + completion_summary = self.normalize_note(request.completion_summary) + + with self.repository.transaction() as repository: + repair_identity = repository.get_repair_identity(repair_id) + if repair_identity is None: + raise RepairNotFound(repair_id) + + miner_id = repair_identity["miner_id"] + # Begin Repair also locks the miner first. Keeping this order makes + # competing lifecycle workflows serialize on the durable asset. + miner = repository.lock_miner_for_update(miner_id) + if miner is None: + raise RepairMinerNotFound(miner_id) + + repair = repository.lock_repair_for_update(repair_id) + if repair is None: + raise RepairNotFound(repair_id) + if repair["miner_id"] != miner_id: + raise RepairActiveMismatch(repair_id, miner_id) + if repair["status"] not in ACTIVE_REPAIR_STATUSES: + raise RepairNotActive(repair_id, repair["status"]) + + active_repair = repository.get_active_repair(miner_id) + if active_repair is None or active_repair["id"] != repair_id: + raise RepairActiveMismatch(repair_id, miner_id) + + lifecycle_status = str(miner["lifecycle_status"]) + if lifecycle_status != REPAIR_LIFECYCLE_STATUS: + raise RepairMinerNotInRepair(miner_id, lifecycle_status) + + next_sequence = repository.get_next_event_sequence(repair_id) + pre_repair_lifecycle = repository.get_pre_repair_lifecycle( + repair_id + ) + completed = repository.complete_repair( + repair_id, + completed_by=actor_user_id, + completion_summary=completion_summary, + resulting_lifecycle=request.target_lifecycle, + ) + if completed is None: + raise RepairNotActive(repair_id, repair["status"]) + + repository.set_miner_lifecycle( + miner_id, + request.target_lifecycle, + ) + payload = RepairCompletedPayload( + resulting_miner_lifecycle=request.target_lifecycle, + pre_repair_lifecycle=pre_repair_lifecycle, + completion_summary=completion_summary, + ) + repository.create_event( + repair_id, + sequence=next_sequence, + event_type="REPAIR_COMPLETED", + created_by=actor_user_id, + payload=payload.model_dump(mode="json", exclude_none=True), + ) + + detail = repository.get_repair_detail(repair_id) + if detail is None: + raise RepairPersistenceError( + "Completed repair could not be read in its transaction" + ) + + return self._map_repair_detail(detail) + + @staticmethod + def _normalize_repair_part_text( + value: str | None, + *, + field_name: str, + max_length: int, + ) -> str | None: + if value is None: + return None + normalized = str(value).strip() + if not normalized: + raise RepairPartValidationError( + f"{field_name} must not be blank when supplied" + ) + if len(normalized) > max_length: + raise RepairPartValidationError( + f"{field_name} must not exceed {max_length} characters" + ) + return normalized + + @staticmethod + def _validate_consumable_quantity(quantity: Decimal, unit: str) -> Decimal: + normalized = Decimal(quantity) + if normalized <= 0: + raise RepairPartValidationError("quantity must be greater than zero") + significant = normalized.normalize() + fractional_places = max(-significant.as_tuple().exponent, 0) + if fractional_places > 3 or normalized >= Decimal("1000000000"): + raise RepairPartValidationError( + "quantity must fit NUMERIC(12,3)" + ) + if unit == "each" and normalized != normalized.to_integral_value(): + raise RepairPartValidationError( + "quantity for unit 'each' must be a whole number" + ) + return normalized + + @staticmethod + def _map_repair_part(part: dict) -> RepairPartDetail: + if part["consumable_description"] is not None: + consumable = RepairPartConsumable( + description=part["consumable_description"], + quantity=part["quantity"], + unit=part["unit"], + ) + component_snapshot = None + entry_kind = "consumable" + else: + consumable = None + component_snapshot = RepairPartComponentSnapshot( + component_type=part["component_type_snapshot"], + asset_id=part["component_asset_id_snapshot"], + manufacturer=part["component_manufacturer_snapshot"], + model=part["component_model_snapshot"], + manufacturer_serial=( + part["component_manufacturer_serial_snapshot"] + ), + ) + entry_kind = "tracked_component" + + return RepairPartDetail( + repair_part_id=part["id"], + repair_id=part["repair_id"], + entry_kind=entry_kind, + consumable=consumable, + component_id=part["component_id"], + component_snapshot=component_snapshot, + notes=part["notes"], + recorded_by=part["recorded_by"], + recorded_at=part["recorded_at"], + recorded_event_id=part["recorded_event_id"], + voided_by=part["voided_by"], + voided_at=part["voided_at"], + void_reason=part["void_reason"], + void_event_id=part["void_event_id"], + ) + + @staticmethod + def _lock_active_repair( + repository: RepairRepository, + repair_id: UUID, + ) -> tuple[dict, dict]: + identity = repository.get_repair_identity(repair_id) + if identity is None: + raise RepairNotFound(repair_id) + + miner_id = identity["miner_id"] + miner = repository.lock_miner_for_update(miner_id) + if miner is None: + raise RepairMinerNotFound(miner_id) + + repair = repository.lock_repair_for_update(repair_id) + if repair is None: + raise RepairNotFound(repair_id) + if repair["miner_id"] != miner_id: + raise RepairActiveMismatch(repair_id, miner_id) + if repair["status"] not in ACTIVE_REPAIR_STATUSES: + raise RepairNotActive(repair_id, repair["status"]) + + active_repair = repository.get_active_repair(miner_id) + if active_repair is None or active_repair["id"] != repair_id: + raise RepairActiveMismatch(repair_id, miner_id) + lifecycle_status = str(miner["lifecycle_status"]) + if lifecycle_status != REPAIR_LIFECYCLE_STATUS: + raise RepairMinerNotInRepair(miner_id, lifecycle_status) + return miner, repair + + def record_repair_part( + self, + repair_id: UUID, + request: RepairPartRecordRequest, + *, + actor_user_id: UUID | None, + ) -> RepairPartDetail: + if actor_user_id is None: + raise RepairPartActorRequired() + + notes = self._normalize_repair_part_text( + request.notes, + field_name="notes", + max_length=4000, + ) + description = None + quantity = None + unit = None + if request.consumable is not None: + description = self._normalize_repair_part_text( + request.consumable.description, + field_name="consumable.description", + max_length=255, + ) + quantity = self._validate_consumable_quantity( + request.consumable.quantity, + request.consumable.unit, + ) + unit = request.consumable.unit + + with self.repository.transaction() as repository: + self._lock_active_repair(repository, repair_id) + + component = None + if request.tracked_component_uuid is not None: + component = repository.lock_component_for_update( + request.tracked_component_uuid + ) + if component is None: + raise RepairPartComponentNotFound( + request.tracked_component_uuid + ) + + repair_part_id = uuid4() + next_sequence = repository.get_next_event_sequence(repair_id) + if component is None: + payload = { + "repair_part_id": str(repair_part_id), + "entry_kind": "consumable", + "description": description, + "quantity": format(quantity, "f"), + "unit": unit, + "notes": notes, + } + else: + payload = { + "repair_part_id": str(repair_part_id), + "entry_kind": "tracked_component", + "component_id": str(component["id"]), + "component_type": component["component_type"], + "component_asset_id": component["asset_id"], + "component_manufacturer": component["manufacturer"], + "component_model": component["model"], + "component_manufacturer_serial": ( + component["manufacturer_serial"] + ), + "notes": notes, + } + event = repository.create_event( + repair_id, + sequence=next_sequence, + event_type="REPAIR_PART_RECORDED", + created_by=actor_user_id, + payload=payload, + ) + repository.create_repair_part( + repair_part_id=repair_part_id, + repair_id=repair_id, + consumable_description=description, + quantity=quantity, + unit=unit, + component=component, + notes=notes, + recorded_event=event, + ) + part = repository.get_repair_part(repair_id, repair_part_id) + if part is None: + raise RepairPersistenceError( + "Recorded repair part could not be read in its transaction" + ) + + return self._map_repair_part(part) + + def list_repair_parts(self, repair_id: UUID) -> RepairPartList: + parts = self.repository.get_repair_parts(repair_id) + if parts is None: + raise RepairNotFound(repair_id) + return RepairPartList( + repair_id=repair_id, + entries=[self._map_repair_part(part) for part in parts], + ) + + def void_repair_part( + self, + repair_id: UUID, + repair_part_id: UUID, + request: RepairPartVoidRequest, + *, + actor_user_id: UUID | None, + ) -> RepairPartDetail: + if actor_user_id is None: + raise RepairPartActorRequired() + reason = self._normalize_repair_part_text( + request.reason, + field_name="reason", + max_length=1000, + ) + assert reason is not None + + with self.repository.transaction() as repository: + self._lock_active_repair(repository, repair_id) + part = repository.lock_repair_part_for_update( + repair_id, + repair_part_id, + ) + if part is None: + raise RepairPartNotFound(repair_part_id) + if part["voided_at"] is not None: + raise RepairPartAlreadyVoided(repair_part_id) + + next_sequence = repository.get_next_event_sequence(repair_id) + event = repository.create_event( + repair_id, + sequence=next_sequence, + event_type="REPAIR_PART_VOIDED", + created_by=actor_user_id, + payload={ + "repair_part_id": str(repair_part_id), + "reason": reason, + }, + ) + voided = repository.void_repair_part( + repair_id, + repair_part_id, + reason=reason, + void_event=event, + ) + if voided is None: + raise RepairPartAlreadyVoided(repair_part_id) + part = repository.get_repair_part(repair_id, repair_part_id) + if part is None: + raise RepairPersistenceError( + "Voided repair part could not be read in its transaction" + ) + + return self._map_repair_part(part) + + def get_repair(self, repair_id: UUID) -> RepairDetail: + repair = self.repository.get_repair_detail(repair_id) + if repair is None: + raise RepairNotFound(repair_id) + return self._map_repair_detail(repair) + + def get_repair_timeline(self, repair_id: UUID) -> RepairTimeline: + events = self.repository.get_repair_events(repair_id) + if events is None: + raise RepairNotFound(repair_id) + + return RepairTimeline( + repair_id=repair_id, + events=[ + RepairTimelineEvent( + event_id=event["event_id"], + sequence=event["sequence"], + event_type=event["event_type"], + actor=RepairActor( + user_id=event["actor_user_id"], + display_name=event["actor_display_name"], + ), + occurred_at=event["occurred_at"], + payload=event["payload"], + ) + for event in events + ], + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index 9e0cb02..b3d5926 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,3 +11,4 @@ email-validator==2.3.0 python-multipart==0.0.20 asyncpg pycryptodome==3.23.0 +httpx==0.28.1 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..387c337 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +"""MinerOps backend tests.""" diff --git a/backend/tests/test_control_board_replacement.py b/backend/tests/test_control_board_replacement.py new file mode 100644 index 0000000..c246f01 --- /dev/null +++ b/backend/tests/test_control_board_replacement.py @@ -0,0 +1,808 @@ +import ast +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from unittest.mock import patch +import unittest +from contextlib import contextmanager +from copy import deepcopy +from datetime import datetime, timezone +from pathlib import Path +from uuid import UUID, uuid4 + +from psycopg.errors import CheckViolation, RaiseException, UniqueViolation +from psycopg.rows import dict_row +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from app.identity import get_current_user, required_api_permission +from app.repairs.dependencies import get_repair_service +from app.repairs.exceptions import ( + ControlBoardReplacementConflict, + InvalidMacAddress, + MacOwnershipConflict, + RepairMinerNotFound, + RepairMinerNotInRepair, +) +from app.repairs.models import ( + BeginRepairRequest, + ControlBoardReplacementRequest, + ControlBoardReplacementResult, +) +from app.repairs.repository import RepairRepository +from app.repairs.router import control_board_router +from app.repairs.service import RepairService + + +NOW = datetime(2026, 8, 2, 12, 0, tzinfo=timezone.utc) + + +def request(**overrides): + values = { + "new_mac_address": "02:AA:BB:CC:DD:02", + "new_control_board_serial": " CB-NEW ", + "board_model": " Model X ", + "board_revision": " Rev B ", + "firmware_version": " 1.2.3 ", + "removed_board_disposition": "sent_for_repair", + "replacement_reason": "failed", + "notes": " Board would not boot. ", + } + values.update(overrides) + return ControlBoardReplacementRequest(**values) + + +class FakeReplacementRepository: + def __init__(self): + self.miner_id = uuid4() + self.repair_id = uuid4() + self.actor_id = uuid4() + self.miner = { + "id": self.miner_id, + "asset_tag": "MINER-004", + "lifecycle_status": "in_repair", + "current_mac": "02:AA:BB:CC:DD:01", + "control_board_sn": "CB-OLD", + "firmware_version": "1.0.0", + } + self.repair = {"id": self.repair_id, "miner_id": self.miner_id} + self.installations = [] + self.events = [{"sequence": 1}] + self.identities = [] + self.calls = [] + self.fail_at = None + self.owner = None + + @contextmanager + def transaction(self): + self.calls.append("transaction") + snapshot = deepcopy( + (self.miner, self.installations, self.events, self.identities) + ) + try: + yield self + except Exception: + self.miner, self.installations, self.events, self.identities = snapshot + raise + + def _fail(self, name): + self.calls.append(name) + if self.fail_at == name: + raise RuntimeError(name) + + def lock_miner_for_control_board_replacement(self, miner_id): + self._fail("lock_miner") + return self.miner if miner_id == self.miner_id else None + + def lock_active_repair_for_miner(self, miner_id): + self._fail("lock_repair") + return self.repair if miner_id == self.miner_id else None + + def lock_current_control_board(self, miner_id): + self._fail("lock_board") + current = [row for row in self.installations if row["removed_at"] is None] + return current[0] if current else None + + def create_legacy_control_board(self, miner): + self._fail("create_legacy_board") + row = { + "id": uuid4(), "miner_id": miner["id"], + "mac_address": miner["current_mac"], + "board_serial": miner["control_board_sn"], + "board_model": None, "board_revision": None, + "removed_at": None, + } + self.installations.append(row) + return row + + def ensure_network_identity_history(self, miner_id, **values): + self._fail("ensure_old_identity") + self.identities.append({"miner_id": miner_id, "is_current": False, **values}) + + def claim_mac_ownership(self, normalized_mac, miner_id, actor_user_id): + self._fail("claim_mac") + if self.owner is not None and self.owner != miner_id: + raise MacOwnershipConflict(normalized_mac) + self.owner = miner_id + + def close_control_board(self, installation_id, **values): + self._fail("close_board") + row = next(row for row in self.installations if row["id"] == installation_id) + row.update(removed_at=NOW, **values) + return row + + def create_control_board_installation(self, **values): + self._fail("create_board") + row = {"id": uuid4(), "removed_at": None, **values, "installed_at": NOW} + self.installations.append(row) + return row + + def set_current_network_identity(self, miner_id, **values): + self._fail("set_identity") + self.identities.append({"miner_id": miner_id, **values}) + return self.identities[-1] + + def set_current_control_board(self, miner_id, *, mac_address, board_serial): + self._fail("set_projection") + self.miner.update(current_mac=mac_address, control_board_sn=board_serial) + + def get_next_event_sequence(self, repair_id): + self._fail("next_sequence") + return max(event["sequence"] for event in self.events) + 1 + + def create_event(self, repair_id, **values): + self._fail("create_event") + row = {"id": uuid4(), "repair_id": repair_id, "created_at": NOW, **values} + self.events.append(row) + return row + + +class ControlBoardReplacementServiceTests(unittest.TestCase): + def setUp(self): + self.repository = FakeReplacementRepository() + self.service = RepairService(self.repository) + + def replace(self, replacement=None): + return self.service.replace_control_board( + self.repository.miner_id, + replacement or request(), + actor_user_id=self.repository.actor_id, + ) + + def test_replacement_is_atomic_and_preserves_durable_identity(self): + miner_id = self.repository.miner["id"] + asset_id = self.repository.miner["asset_tag"] + result = self.replace() + + self.assertEqual(result.miner_id, miner_id) + self.assertEqual(result.asset_id, asset_id) + self.assertEqual(result.current_mac, "02:AA:BB:CC:DD:02") + self.assertEqual(result.control_board_serial, "CB-NEW") + self.assertEqual(self.repository.miner["id"], miner_id) + self.assertEqual(self.repository.miner["asset_tag"], asset_id) + self.assertEqual(len([x for x in self.repository.installations if x["removed_at"] is None]), 1) + event = self.repository.events[-1] + self.assertEqual(event["event_type"], "CONTROL_BOARD_REPLACED") + self.assertEqual(event["sequence"], 2) + self.assertEqual(event["created_by"], self.repository.actor_id) + self.assertEqual(event["payload"]["old_mac_address"], "02:AA:BB:CC:DD:01") + self.assertEqual(event["payload"]["old_control_board_serial"], "CB-OLD") + self.assertEqual(event["payload"]["notes"], "Board would not boot.") + + def test_mac_normalization_and_prohibited_values(self): + self.assertEqual( + self.service.normalize_mac_address("02-aA-bb-cc-dd-02"), + ("02:AA:BB:CC:DD:02", "02aabbccdd02"), + ) + for value in ( + "bad", "00:00:00:00:00:00", "FF:FF:FF:FF:FF:FF", + "01:00:5E:00:00:01", + ): + with self.subTest(value=value), self.assertRaises(InvalidMacAddress): + self.service.normalize_mac_address(value) + + def test_conflicting_states_do_not_write(self): + cases = ("active", "spare", "retired") + for lifecycle in cases: + with self.subTest(lifecycle=lifecycle): + repository = FakeReplacementRepository() + repository.miner["lifecycle_status"] = lifecycle + with self.assertRaises(RepairMinerNotInRepair): + RepairService(repository).replace_control_board( + repository.miner_id, request(), + actor_user_id=repository.actor_id, + ) + self.assertEqual(repository.events, [{"sequence": 1}]) + + def test_same_and_historically_owned_mac_conflict(self): + with self.assertRaises(ControlBoardReplacementConflict): + self.replace(request(new_mac_address="02:AA:BB:CC:DD:01")) + repository = FakeReplacementRepository() + repository.owner = uuid4() + with self.assertRaises(MacOwnershipConflict): + RepairService(repository).replace_control_board( + repository.miner_id, request(), actor_user_id=repository.actor_id + ) + + def test_missing_miner_and_active_repair_conflict(self): + with self.assertRaises(RepairMinerNotFound): + self.service.replace_control_board( + uuid4(), request(), actor_user_id=self.repository.actor_id + ) + self.repository.repair = None + with self.assertRaises(ControlBoardReplacementConflict): + self.replace() + + def test_each_meaningful_write_failure_rolls_back(self): + for step in ( + "claim_mac", "close_board", "create_board", "set_identity", + "set_projection", "create_event", + ): + with self.subTest(step=step): + repository = FakeReplacementRepository() + repository.fail_at = step + original = deepcopy(repository.miner) + with self.assertRaises(RuntimeError): + RepairService(repository).replace_control_board( + repository.miner_id, request(), + actor_user_id=repository.actor_id, + ) + self.assertEqual(repository.miner, original) + self.assertEqual(repository.installations, []) + self.assertEqual(repository.events, [{"sequence": 1}]) + + +class StubReplacementService: + def __init__(self): + self.error = None + self.result = None + self.request = None + self.actor = None + + def replace_control_board(self, miner_id, request, *, actor_user_id): + self.request = request + self.actor = actor_user_id + if self.error: + raise self.error + return self.result + + +class ControlBoardReplacementRouterTests(unittest.TestCase): + def setUp(self): + self.service = StubReplacementService() + self.app = FastAPI() + self.app.include_router(control_board_router) + self.app.dependency_overrides[get_repair_service] = lambda: self.service + self.client = TestClient(self.app) + self.actor = uuid4() + self.miner = uuid4() + + def tearDown(self): + self.client.close() + + def authenticate(self, permissions=("miners.replace_control_board",), role="Technician"): + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(self.actor), "role": role, + "permissions": list(permissions), + } + + def post(self, body=None): + return self.client.post( + f"/api/miners/{self.miner}/replace-control-board", + json=(request().model_dump(mode="json") if body is None else body), + ) + + def test_authentication_and_permission_are_required(self): + self.assertEqual(self.post().status_code, 401) + self.authenticate(permissions=("miners.update",)) + self.assertEqual(self.post().status_code, 403) + + def test_success_uses_typed_envelope_and_authenticated_actor(self): + self.authenticate() + self.service.result = ControlBoardReplacementResult( + miner_id=self.miner, asset_id="MINER-004", repair_id=uuid4(), + installation_id=uuid4(), event_id=uuid4(), event_sequence=4, + current_mac="02:AA:BB:CC:DD:02", control_board_serial="CB-NEW", + replaced_at=NOW, replaced_by=self.actor, + ) + response = self.post() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["miner_id"], str(self.miner)) + self.assertEqual(response.json()["meta"], {}) + self.assertIsNone(response.json()["error"]) + self.assertEqual(self.service.actor, self.actor) + + def test_validation_and_domain_error_mapping(self): + self.authenticate() + self.assertEqual(self.post({"new_mac_address": "bad"}).status_code, 422) + self.service.error = RepairMinerNotFound(self.miner) + self.assertEqual(self.post().status_code, 404) + self.service.error = ControlBoardReplacementConflict("conflict") + self.assertEqual(self.post().status_code, 409) + self.service.error = InvalidMacAddress() + self.assertEqual(self.post().status_code, 422) + + def test_route_and_permission_mapping_are_exact(self): + routes = [ + (route.path, route.methods) for route in control_board_router.routes + if hasattr(route, "methods") + ] + self.assertEqual(routes, [ + ("/api/miners/{miner_id}/replace-control-board", {"POST"}) + ]) + path = f"/api/miners/{self.miner}/replace-control-board" + self.assertEqual( + required_api_permission("POST", path), + "miners.replace_control_board", + ) + + def test_allowed_roles_and_read_only_matrix(self): + self.service.result = ControlBoardReplacementResult( + miner_id=self.miner, asset_id="MINER-004", repair_id=uuid4(), + installation_id=uuid4(), event_id=uuid4(), event_sequence=2, + current_mac="02:AA:BB:CC:DD:02", control_board_serial=None, + replaced_at=NOW, replaced_by=self.actor, + ) + for role in ("Admin", "Manager", "Technician"): + with self.subTest(role=role): + self.authenticate(role=role) + self.assertEqual(self.post().status_code, 200) + self.authenticate(permissions=(), role="Read Only") + self.assertEqual(self.post().status_code, 403) + + def test_request_contract_rejects_invalid_shapes(self): + self.authenticate() + valid = request().model_dump(mode="json") + invalid_bodies = [] + missing_serial = dict(valid) + missing_serial.pop("new_control_board_serial") + invalid_bodies.append(missing_serial) + for field, value in ( + ("new_mac_address", ""), + ("notes", "x" * 4001), + ("replacement_reason", "invented"), + ("removed_board_disposition", "invented"), + ): + body = dict(valid) + body[field] = value + invalid_bodies.append(body) + unknown = dict(valid) + unknown["created_by"] = str(self.actor) + invalid_bodies.append(unknown) + for body in invalid_bodies: + with self.subTest(body=body): + self.assertEqual(self.post(body).status_code, 422) + + + def test_router_has_no_sql_driver_or_transaction_management(self): + path = Path(__file__).parents[1] / "app" / "repairs" / "router.py" + source = path.read_text(encoding="utf-8") + tree = ast.parse(source) + modules = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + modules.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + modules.append(node.module) + self.assertFalse(any(name.startswith(("psycopg", "sqlalchemy", "asyncpg")) for name in modules)) + for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE ", ".COMMIT(", ".ROLLBACK("): + self.assertNotIn(keyword, source.upper()) + + +class LegacyChangeMacCompatibilityTests(unittest.TestCase): + def setUp(self): + from app import main as main_module + + self.main = main_module + self.service = StubReplacementService() + self.actor = uuid4() + self.miner = uuid4() + self.service.result = ControlBoardReplacementResult( + miner_id=self.miner, asset_id="MINER-004", repair_id=uuid4(), + installation_id=uuid4(), event_id=uuid4(), event_sequence=3, + current_mac="02:AA:BB:CC:DD:02", control_board_serial="CB-NEW", + replaced_at=NOW, replaced_by=self.actor, + ) + + def legacy_request(self, **overrides): + values = { + "new_mac": "02:AA:BB:CC:DD:02", + "component_serial": "CB-NEW", + "description": "Replaced during repair.", + "replacement_reason": "failed", + "removed_board_disposition": "sent_for_repair", + } + values.update(overrides) + return self.main.MinerMacChangeRequest(**values) + + def call(self, legacy_request): + with patch.object(self.main, "RepairRepository"), patch.object( + self.main, "RepairService", return_value=self.service + ): + return self.main.change_miner_mac_address( + str(self.miner), legacy_request, {"id": str(self.actor)} + ) + + def test_alias_uses_same_permission_and_domain_workflow(self): + path = f"/api/miners/{self.miner}/change-mac" + self.assertEqual( + required_api_permission("POST", path), + "miners.replace_control_board", + ) + response = self.call(self.legacy_request()) + self.assertEqual(self.service.actor, self.actor) + self.assertEqual(self.service.request.replacement_reason, "failed") + self.assertEqual( + self.service.request.removed_board_disposition, "sent_for_repair" + ) + self.assertEqual( + response["data"]["miner"]["current_mac"], + "02:AA:BB:CC:DD:02", + ) + self.assertIn("control_board_replacement", response["data"]) + self.assertNotIn("maintenance_event", response["data"]) + + def test_alias_does_not_fabricate_required_workflow_fields(self): + for changes in ( + {"replacement_reason": None}, + {"removed_board_disposition": None}, + {"new_ip": "192.0.2.4"}, + {"downtime_minutes": 10}, + ): + with self.subTest(changes=changes), self.assertRaises(HTTPException) as caught: + self.call(self.legacy_request(**changes)) + self.assertEqual(caught.exception.status_code, 422) + + def test_alias_maps_domain_conflicts_without_internal_error(self): + self.service.error = ControlBoardReplacementConflict("Miner has no active repair") + with self.assertRaises(HTTPException) as caught: + self.call(self.legacy_request()) + self.assertEqual(caught.exception.status_code, 409) + + +class ControlBoardReplacementDatabaseTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.repository = RepairRepository() + + def setUp(self): + self.actor = uuid4() + with self.repository._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute("DROP TRIGGER IF EXISTS test_board_event_failure ON repair_events") + cur.execute("DROP FUNCTION IF EXISTS test_fail_board_event()") + cur.execute( + "TRUNCATE miner_control_board_installations, " + "miner_network_identities, miner_mac_ownerships" + ) + cur.execute("ALTER TABLE repair_events DISABLE TRIGGER trg_protect_repair_event_history") + cur.execute("DELETE FROM repair_events") + cur.execute("ALTER TABLE repair_events ENABLE TRIGGER trg_protect_repair_event_history") + cur.execute("DELETE FROM repairs") + cur.execute("DELETE FROM miner_tags") + cur.execute("DELETE FROM miner_asset_id_history") + cur.execute("DELETE FROM miners") + cur.execute("DELETE FROM users WHERE username LIKE 'board-test-%'") + cur.execute( + """ + INSERT INTO users ( + id, username, display_name, password_hash, role_id + ) + SELECT %s, %s, 'Board Test Actor', 'unused', id + FROM roles WHERE name = 'Admin' + """, + (self.actor, f"board-test-{self.actor}"), + ) + + def tearDown(self): + with self.repository._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute("DROP TRIGGER IF EXISTS test_board_event_failure ON repair_events") + cur.execute("DROP FUNCTION IF EXISTS test_fail_board_event()") + cur.execute( + "TRUNCATE miner_control_board_installations, " + "miner_network_identities, miner_mac_ownerships" + ) + cur.execute("ALTER TABLE repair_events DISABLE TRIGGER trg_protect_repair_event_history") + cur.execute("DELETE FROM repair_events") + cur.execute("ALTER TABLE repair_events ENABLE TRIGGER trg_protect_repair_event_history") + cur.execute("DELETE FROM repairs") + cur.execute("DELETE FROM miner_tags") + cur.execute("DELETE FROM miner_asset_id_history") + cur.execute("DELETE FROM miners") + cur.execute("DELETE FROM users WHERE username LIKE 'board-test-%'") + + + def create_repairing_miner(self, mac_address, serial): + asset_id = f"BOARD-{uuid4()}" + with self.repository._connection_scope(write=True) as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute( + """ + INSERT INTO miners ( + asset_tag, current_mac, control_board_sn, status, + lifecycle_status, first_seen_at, last_seen_at + ) VALUES (%s, %s, %s, 'unknown', 'active', NULL, NULL) + RETURNING id + """, + (asset_id, mac_address, serial), + ) + miner_id = cur.fetchone()["id"] + repair = RepairService(self.repository).begin_repair( + BeginRepairRequest(miner_id=miner_id, reason="CONTROL_BOARD"), + actor_user_id=self.actor, + ) + return miner_id, asset_id, repair.repair_id + + def replace(self, miner_id, mac_address="02:AA:BB:CC:EE:02"): + return RepairService(self.repository).replace_control_board( + miner_id, request(new_mac_address=mac_address), + actor_user_id=self.actor, + ) + + def fetchall(self, sql, values=()): + with self.repository._connection_scope() as connection: + with connection.cursor(row_factory=dict_row) as cur: + cur.execute(sql, values) + return [dict(row) for row in cur.fetchall()] + + def test_database_success_preserves_identity_and_authoritative_history(self): + miner_id, asset_id, repair_id = self.create_repairing_miner( + "02:AA:BB:CC:EE:01", "CB-OLD" + ) + result = self.replace(miner_id) + miner = self.fetchall( + "SELECT * FROM miners WHERE id = %s", (miner_id,) + )[0] + boards = self.fetchall( + "SELECT * FROM miner_control_board_installations " + "WHERE miner_id = %s ORDER BY created_at, id", (miner_id,) + ) + identities = self.fetchall( + "SELECT * FROM miner_network_identities " + "WHERE miner_id = %s ORDER BY is_current, mac_address", (miner_id,) + ) + events = self.fetchall( + "SELECT * FROM repair_events WHERE repair_id = %s " + "AND event_type = 'CONTROL_BOARD_REPLACED'", (repair_id,) + ) + self.assertEqual(result.miner_id, miner_id) + self.assertEqual(miner["asset_tag"], asset_id) + self.assertEqual(miner["current_mac"], "02:AA:BB:CC:EE:02") + self.assertEqual(len(boards), 2) + self.assertEqual(sum(row["removed_at"] is None for row in boards), 1) + legacy = next(row for row in boards if row["record_origin"] == "legacy_projection") + current = next(row for row in boards if row["removed_at"] is None) + self.assertIsNone(legacy["installed_at"]) + self.assertIsNone(legacy["installed_by"]) + self.assertEqual(current["record_origin"], "replacement") + self.assertEqual(current["installed_by"], self.actor) + self.assertEqual(current["normalized_mac"], "02aabbccee02") + self.assertEqual(len(identities), 2) + self.assertEqual(sum(row["is_current"] for row in identities), 1) + self.assertEqual( + next(row for row in identities if row["is_current"])["mac_address"], + "02:AA:BB:CC:EE:02", + ) + self.assertTrue(any(row["mac_address"] == "02:AA:BB:CC:EE:01" for row in identities)) + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["created_by"], self.actor) + self.assertEqual(events[0]["sequence"], result.event_sequence) + + def test_database_allows_omitted_optional_notes(self): + miner_id, _, repair_id = self.create_repairing_miner( + "02:AA:BB:CC:EA:01", "CB-OLD" + ) + RepairService(self.repository).replace_control_board( + miner_id, + request(new_mac_address="02:AA:BB:CC:EA:02", notes=None), + actor_user_id=self.actor, + ) + event = self.fetchall( + "SELECT payload FROM repair_events WHERE repair_id = %s " + "AND event_type = 'CONTROL_BOARD_REPLACED'", + (repair_id,), + )[0] + self.assertIn("notes", event["payload"]) + self.assertIsNone(event["payload"]["notes"]) + + def test_database_constraints_prevent_drift_and_history_mutation(self): + miner_id, _, repair_id = self.create_repairing_miner( + "02:AA:BB:CC:ED:01", "CB-OLD" + ) + self.replace(miner_id, "02:AA:BB:CC:ED:02") + + with self.assertRaises(UniqueViolation): + with self.repository._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute( + """ + INSERT INTO miner_control_board_installations ( + miner_id, record_origin, board_serial + ) VALUES (%s, 'legacy_projection', 'SECOND') + """, + (miner_id,), + ) + with self.assertRaises(CheckViolation): + with self.repository._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute( + "UPDATE miners SET control_board_sn = 'DRIFT' " + "WHERE id = %s", + (miner_id,), + ) + with self.assertRaises(CheckViolation): + with self.repository._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute( + "UPDATE miner_network_identities SET is_current = false " + "WHERE miner_id = %s AND is_current", + (miner_id,), + ) + with self.assertRaises(RaiseException): + with self.repository._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute( + "DELETE FROM miner_network_identities " + "WHERE miner_id = %s", + (miner_id,), + ) + with self.assertRaises(RaiseException): + with self.repository._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute( + "DELETE FROM miner_control_board_installations " + "WHERE miner_id = %s", + (miner_id,), + ) + for statement in ( + "UPDATE miner_mac_ownerships SET miner_id = gen_random_uuid() " + "WHERE normalized_mac = '02aabbcced02'", + "DELETE FROM miner_mac_ownerships " + "WHERE normalized_mac = '02aabbcced02'", + ): + with self.assertRaises(RaiseException): + with self.repository._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute(statement) + with self.assertRaises(UniqueViolation): + with self.repository._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute( + """ + INSERT INTO repair_events ( + repair_id, sequence, event_type, created_by, payload + ) + SELECT repair_id, sequence, event_type, created_by, payload + FROM repair_events + WHERE repair_id = %s ORDER BY sequence LIMIT 1 + """, + (repair_id,), + ) + + def test_database_rejects_prohibited_component_mac(self): + miner_id, _, _ = self.create_repairing_miner( + "02:AA:BB:CC:EC:01", "CB-OLD" + ) + for mac_address, normalized_mac in ( + ("01:00:5E:00:00:01", "01005e000001"), + ("00:00:00:00:00:00", "000000000000"), + ("FF:FF:FF:FF:FF:FF", "ffffffffffff"), + ("02-AA-BB-CC-EC-02", "02aabbccec02"), + ): + with self.subTest(mac_address=mac_address), self.assertRaises(CheckViolation): + with self.repository._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute( + """ + INSERT INTO miner_control_board_installations ( + miner_id, record_origin, mac_address, normalized_mac + ) VALUES (%s, 'legacy_projection', %s, %s) + """, + (miner_id, mac_address, normalized_mac), + ) + + + def test_event_failure_rolls_back_every_replacement_write(self): + miner_id, _, repair_id = self.create_repairing_miner( + "02:AA:BB:CC:EF:01", "CB-OLD" + ) + with self.repository._connection_scope(write=True) as connection: + with connection.cursor() as cur: + cur.execute( + """ + CREATE FUNCTION test_fail_board_event() RETURNS trigger AS $$ + BEGIN + IF NEW.event_type = 'CONTROL_BOARD_REPLACED' THEN + RAISE EXCEPTION 'forced replacement event failure'; + END IF; + RETURN NEW; + END $$ LANGUAGE plpgsql + """ + ) + cur.execute( + "CREATE TRIGGER test_board_event_failure BEFORE INSERT ON " + "repair_events FOR EACH ROW EXECUTE FUNCTION test_fail_board_event()" + ) + with self.assertRaises(RaiseException): + self.replace(miner_id, "02:AA:BB:CC:EF:02") + miner = self.fetchall("SELECT * FROM miners WHERE id = %s", (miner_id,))[0] + self.assertEqual(miner["current_mac"], "02:AA:BB:CC:EF:01") + self.assertEqual(self.fetchall( + "SELECT * FROM miner_control_board_installations WHERE miner_id = %s", + (miner_id,), + ), []) + self.assertEqual(self.fetchall( + "SELECT * FROM miner_network_identities WHERE miner_id = %s", + (miner_id,), + ), []) + self.assertEqual(len(self.fetchall( + "SELECT * FROM repair_events WHERE repair_id = %s", (repair_id,) + )), 1) + owners = self.fetchall( + "SELECT normalized_mac FROM miner_mac_ownerships WHERE miner_id = %s", + (miner_id,), + ) + self.assertEqual(owners, [{"normalized_mac": "02aabbccef01"}]) + + def test_concurrent_replacements_on_one_miner_succeed_once(self): + miner_id, _, repair_id = self.create_repairing_miner( + "02:AA:BB:CC:F0:01", "CB-OLD" + ) + barrier = Barrier(2) + + def replace(mac_address): + barrier.wait() + return self.replace(miner_id, mac_address) + + outcomes = [] + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [ + executor.submit(replace, "02:AA:BB:CC:F0:02"), + executor.submit(replace, "02:AA:BB:CC:F0:04"), + ] + for future in futures: + try: + outcomes.append(future.result(timeout=15)) + except ControlBoardReplacementConflict as exc: + outcomes.append(exc) + self.assertEqual(sum(not isinstance(item, Exception) for item in outcomes), 1) + self.assertEqual(sum(isinstance(item, Exception) for item in outcomes), 1) + self.assertEqual(len(self.fetchall( + "SELECT * FROM miner_control_board_installations " + "WHERE miner_id = %s AND removed_at IS NULL", (miner_id,) + )), 1) + self.assertEqual(len(self.fetchall( + "SELECT * FROM repair_events WHERE repair_id = %s " + "AND event_type = 'CONTROL_BOARD_REPLACED'", (repair_id,) + )), 1) + + def test_two_miners_cannot_concurrently_claim_one_mac(self): + first, _, _ = self.create_repairing_miner("02:AA:BB:CC:F1:01", "CB-1") + second, _, _ = self.create_repairing_miner("02:AA:BB:CC:F1:02", "CB-2") + barrier = Barrier(2) + + def replace(miner_id): + barrier.wait() + return self.replace(miner_id, "02:AA:BB:CC:F1:04") + + outcomes = [] + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(replace, first), executor.submit(replace, second)] + for future in futures: + try: + outcomes.append(future.result(timeout=15)) + except ControlBoardReplacementConflict as exc: + outcomes.append(exc) + self.assertEqual(sum(not isinstance(item, Exception) for item in outcomes), 1) + self.assertEqual(sum(isinstance(item, Exception) for item in outcomes), 1) + owner = self.fetchall( + "SELECT miner_id FROM miner_mac_ownerships " + "WHERE normalized_mac = '02aabbccf104'" + ) + self.assertEqual(len(owner), 1) + + + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py new file mode 100644 index 0000000..a4006d3 --- /dev/null +++ b/backend/tests/test_dashboard.py @@ -0,0 +1,347 @@ +import ast +import unittest +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 + +import psycopg +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.config import settings +from app.dashboard.dependencies import get_dashboard_service +from app.dashboard.models import ( + DashboardAccess, + DashboardOverviewData, + DashboardOverviewResponse, +) +from app.dashboard.router import router +from app.dashboard.service import DashboardService +from app.identity import get_current_user +from app.inventory.models import ( + InventoryFleetAttentionSummary, + InventoryFleetSummary, +) +from app.inventory.repository import InventoryRepository +from app.locations.models import LocationSummary +from app.locations.repository import LocationMapRepository +from app.repairs.center_repository import RepairCenterRepository +from app.repairs.models import ActiveRepairSummary + + +NOW = datetime(2026, 8, 11, 12, 0, tzinfo=timezone.utc) +APP_ROOT = Path(__file__).parents[1] / "app" + + +def fleet_summary() -> InventoryFleetSummary: + return InventoryFleetSummary( + total=12, + runtime_online=7, + runtime_offline=3, + runtime_unknown=2, + lifecycle_in_repair=2, + lifecycle_spare=1, + lifecycle_retired=1, + attention=InventoryFleetAttentionSummary( + offline=2, + unknown=1, + unstable=1, + repair=2, + ), + ) + + +class FakeInventoryService: + def __init__(self): + self.calls = 0 + + def summarize_fleet(self): + self.calls += 1 + return fleet_summary() + + +class FakeRepairService: + def __init__(self): + self.calls = 0 + + def summarize_active_repairs(self): + self.calls += 1 + return ActiveRepairSummary( + active=3, + status_counts={"OPEN": 2, "waiting_parts": 1}, + ) + + +class FakeLocationService: + def __init__(self): + self.calls = [] + + def summarize_locations(self, *, include_miner_counts): + self.calls.append(include_miner_counts) + return LocationSummary( + sites=2, + rooms=4, + positions=20, + occupied_positions=12, + empty_positions=8, + mapped_miners=12 if include_miner_counts else None, + unmapped_miners=0 if include_miner_counts else None, + ) + + +class DashboardServiceTests(unittest.TestCase): + def setUp(self): + self.inventory = FakeInventoryService() + self.repairs = FakeRepairService() + self.locations = FakeLocationService() + self.service = DashboardService( + self.inventory, + self.repairs, + self.locations, + ) + + def test_composes_each_authorized_domain_once(self): + result = self.service.get_overview( + DashboardAccess( + can_view_miners=True, + can_view_repairs=True, + can_view_locations=True, + ) + ) + self.assertEqual(result.data.fleet.total, 12) + self.assertEqual(result.data.repairs.active, 3) + self.assertEqual(result.data.locations.mapped_miners, 12) + self.assertEqual(self.inventory.calls, 1) + self.assertEqual(self.repairs.calls, 1) + self.assertEqual(self.locations.calls, [True]) + self.assertEqual( + result.meta.available_sections, + ["fleet", "repairs", "locations"], + ) + + def test_unauthorized_domains_are_not_called_or_returned(self): + result = self.service.get_overview(DashboardAccess()) + self.assertIsNone(result.data.fleet) + self.assertIsNone(result.data.repairs) + self.assertIsNone(result.data.locations) + self.assertEqual(result.meta.available_sections, []) + self.assertEqual(self.inventory.calls, 0) + self.assertEqual(self.repairs.calls, 0) + self.assertEqual(self.locations.calls, []) + + def test_location_only_summary_omits_miner_placement_counts(self): + result = self.service.get_overview( + DashboardAccess(can_view_locations=True) + ) + self.assertEqual(self.locations.calls, [False]) + self.assertIsNone(result.data.locations.mapped_miners) + self.assertIsNone(result.data.locations.unmapped_miners) + self.assertIsNone(result.data.fleet) + + +class StubDashboardService: + def __init__(self): + self.access = None + + def get_overview(self, access): + self.access = access + return DashboardOverviewResponse( + data=DashboardOverviewData( + fleet=fleet_summary() if access.can_view_miners else None, + repairs=( + ActiveRepairSummary(active=1, status_counts={"OPEN": 1}) + if access.can_view_repairs else None + ), + locations=( + LocationSummary( + sites=1, + rooms=1, + positions=2, + occupied_positions=1, + empty_positions=1, + mapped_miners=1 if access.can_view_miners else None, + unmapped_miners=11 if access.can_view_miners else None, + ) + if access.can_view_locations else None + ), + generated_at=NOW, + ), + meta={ + "available_sections": [ + name + for name, allowed in ( + ("fleet", access.can_view_miners), + ("repairs", access.can_view_repairs), + ("locations", access.can_view_locations), + ) + if allowed + ] + }, + ) + + +class DashboardRouterTests(unittest.TestCase): + def setUp(self): + self.service = StubDashboardService() + self.app = FastAPI() + self.app.include_router(router) + self.app.dependency_overrides[get_dashboard_service] = lambda: self.service + self.client = TestClient(self.app) + + def tearDown(self): + self.client.close() + + def authenticate(self, permissions): + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), + "role": "Custom", + "permissions": list(permissions), + } + + def test_requires_authentication_and_dashboard_permission(self): + self.assertEqual(self.client.get("/api/dashboard/overview").status_code, 401) + self.authenticate(("miners.view",)) + self.assertEqual(self.client.get("/api/dashboard/overview").status_code, 403) + + def test_returns_one_typed_permission_aware_envelope(self): + self.authenticate(("dashboard.view", "miners.view", "locations.view")) + response = self.client.get("/api/dashboard/overview") + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["data"]["fleet"]["total"], 12) + self.assertIsNone(payload["data"]["repairs"]) + self.assertEqual(payload["data"]["locations"]["mapped_miners"], 1) + self.assertEqual( + payload["meta"]["available_sections"], + ["fleet", "locations"], + ) + self.assertEqual(response.headers["cache-control"], "no-store") + self.assertFalse(self.service.access.can_view_repairs) + + def test_location_only_response_contains_no_miner_counts(self): + self.authenticate(("dashboard.view", "locations.view")) + payload = self.client.get("/api/dashboard/overview").json() + self.assertIsNone(payload["data"]["fleet"]) + self.assertIsNone(payload["data"]["locations"]["mapped_miners"]) + self.assertIsNone(payload["data"]["locations"]["unmapped_miners"]) + + +class DashboardRepositoryIntegrationTests(unittest.TestCase): + def setUp(self): + self.connection = psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + ) + + def tearDown(self): + self.connection.rollback() + self.connection.close() + + def test_fleet_counts_are_exact_unbounded_and_preserve_overlap(self): + repository = InventoryRepository(connection=self.connection) + before = repository.summarize_fleet() + rows = [] + states = ( + ("offline", "in_repair"), + ("online", "spare"), + ("maintenance", "retired"), + ("online", "active"), + ("offline", "active"), + ("unknown", "active"), + ) + for runtime, lifecycle in states: + rows.append((uuid4(), runtime, lifecycle)) + bulk = [(uuid4(), "online", "active") for _ in range(501)] + with self.connection.cursor() as cur: + cur.executemany( + "INSERT INTO miners (id, status, lifecycle_status, first_seen_at, last_seen_at) " + "VALUES (%s, %s, %s, NULL, NULL)", + [*rows, *bulk], + ) + cur.execute( + "INSERT INTO miner_status_snapshots (miner_id, hash_stable) VALUES (%s, false)", + (rows[3][0],), + ) + after = repository.summarize_fleet() + self.assertEqual(after["total"] - before["total"], 507) + self.assertEqual(after["runtime_online"] - before["runtime_online"], 503) + self.assertEqual(after["runtime_offline"] - before["runtime_offline"], 2) + self.assertEqual(after["runtime_unknown"] - before["runtime_unknown"], 2) + self.assertEqual(after["lifecycle_in_repair"] - before["lifecycle_in_repair"], 1) + self.assertEqual(after["lifecycle_spare"] - before["lifecycle_spare"], 1) + self.assertEqual(after["lifecycle_retired"] - before["lifecycle_retired"], 1) + self.assertEqual(after["attention_repair"] - before["attention_repair"], 1) + self.assertEqual(after["attention_offline"] - before["attention_offline"], 1) + self.assertEqual(after["attention_unknown"] - before["attention_unknown"], 1) + self.assertEqual(after["attention_unstable"] - before["attention_unstable"], 1) + + def test_location_summary_uses_current_positions_not_virtual_map_subset(self): + repository = LocationMapRepository(connection=self.connection) + before = repository.summarize_locations(include_miner_counts=True) + site_id, room_id = uuid4(), uuid4() + mapped_id, unmapped_id = uuid4(), uuid4() + with self.connection.cursor() as cur: + cur.execute("INSERT INTO sites (id, name) VALUES (%s, %s)", (site_id, str(site_id))) + cur.execute( + "INSERT INTO rooms (id, site_id, name) VALUES (%s, %s, %s)", + (room_id, site_id, str(room_id)), + ) + cur.executemany( + "INSERT INTO miners (id, lifecycle_status, first_seen_at, last_seen_at) " + "VALUES (%s, 'active', NULL, NULL)", + [(mapped_id,), (unmapped_id,)], + ) + cur.execute( + "INSERT INTO location_positions (room_id, room_name, rack_name, shelf_name, " + "position_index, miner_id) VALUES (%s, 'Room', 'A', '1', 1, %s), " + "(%s, 'Room', 'A', '1', 2, NULL)", + (room_id, mapped_id, room_id), + ) + after = repository.summarize_locations(include_miner_counts=True) + self.assertEqual(after["sites"] - before["sites"], 1) + self.assertEqual(after["rooms"] - before["rooms"], 1) + self.assertEqual(after["positions"] - before["positions"], 2) + self.assertEqual(after["occupied_positions"] - before["occupied_positions"], 1) + self.assertEqual(after["empty_positions"] - before["empty_positions"], 1) + self.assertEqual(after["mapped_miners"] - before["mapped_miners"], 1) + self.assertEqual(after["unmapped_miners"] - before["unmapped_miners"], 1) + + def test_repair_summary_executes_without_loading_queue_items(self): + summary = RepairCenterRepository().summarize_active_repairs() + self.assertIn("active", summary) + self.assertIn("status_counts", summary) + + +class DashboardArchitectureTests(unittest.TestCase): + def test_router_and_service_have_no_sql_or_database_driver_imports(self): + for relative in ("dashboard/router.py", "dashboard/service.py"): + source = (APP_ROOT / relative).read_text(encoding="utf-8") + modules = [] + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Import): + modules.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + modules.append(node.module) + self.assertFalse( + any(name.startswith(("psycopg", "sqlalchemy", "asyncpg")) for name in modules) + ) + for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE "): + self.assertNotIn(keyword, source.upper()) + + def test_dashboard_frontend_has_one_read_only_request_and_safe_dom_writes(self): + source = (APP_ROOT / "frontend" / "static" / "dashboard.js").read_text( + encoding="utf-8" + ) + self.assertEqual(source.count('fetch("/api/dashboard/overview"'), 1) + self.assertNotIn("innerHTML", source) + self.assertNotIn("setInterval", source) + self.assertNotIn('method: "POST"', source) + self.assertNotIn('method: "PATCH"', source) + self.assertNotIn('method: "DELETE"', source) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_frontend_shell.py b/backend/tests/test_frontend_shell.py new file mode 100644 index 0000000..a18d1f7 --- /dev/null +++ b/backend/tests/test_frontend_shell.py @@ -0,0 +1,572 @@ +import re +import unittest +from pathlib import Path +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.frontend.router import router +from app.identity import ( + get_current_user, + router as identity_router, + safe_next_path, + install_identity_middleware, + protected_page, + required_page_permission, +) + + +ROUTES = ( + "/dashboard", + "/monitoring/map", + "/operations/repairs", + "/assets/miners", +) +STATIC_ROOT = Path(__file__).parents[1] / "app" / "frontend" / "static" + + +class FrontendShellTests(unittest.TestCase): + def setUp(self): + self.app = FastAPI() + self.app.include_router(router) + self.client = TestClient(self.app) + + def tearDown(self): + self.client.close() + + def authenticate(self, permissions=None, role="Read Only"): + self.app.dependency_overrides[get_current_user] = lambda: { + "id": "00000000-0000-0000-0000-000000000321", + "username": "operator", + "display_name": "Floor Operator", + "role": role, + "permissions": list( + permissions + or ( + "dashboard.view", + "locations.view", + "repairs.view", + "miners.view", + ) + ), + } + + def test_routes_require_authentication(self): + for path in ROUTES: + with self.subTest(path=path): + self.assertEqual(self.client.get(path).status_code, 401) + + def test_all_direct_routes_render_authenticated_shell(self): + self.authenticate() + for path in ROUTES: + with self.subTest(path=path): + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertIn("MinerOps", response.text) + self.assertIn( + "Operational Intelligence for Mining", + response.text, + ) + self.assertIn("Floor Operator", response.text) + self.assertIn( + 'aria-label="Primary navigation"', + response.text, + ) + + def test_navigation_connects_all_internal_alpha_views(self): + self.authenticate() + response = self.client.get("/dashboard") + for path in ROUTES: + self.assertIn(f'href="{path}"', response.text) + self.assertIn('aria-current="page"', response.text) + + def test_each_route_requires_its_specific_permission(self): + cases = ( + ("/dashboard", "dashboard.view"), + ("/monitoring/map", "locations.view"), + ("/operations/repairs", "repairs.view"), + ("/assets/miners", "miners.view"), + ) + for path, permission in cases: + with self.subTest(path=path): + self.authenticate(permissions=("unrelated.permission",)) + self.assertEqual(self.client.get(path).status_code, 403) + self.authenticate(permissions=(permission,)) + self.assertEqual(self.client.get(path).status_code, 200) + + def test_main_authentication_gate_redirects_to_existing_login(self): + app = FastAPI() + app.include_router(router) + install_identity_middleware(app) + with TestClient(app) as client, patch( + "app.identity.request_user_or_none", + return_value=None, + ): + for path in ROUTES: + with self.subTest(path=path): + response = client.get(path, follow_redirects=False) + self.assertEqual(response.status_code, 303) + self.assertTrue( + response.headers["location"].startswith( + "/login?next=" + ) + ) + + def test_page_permission_mapping_covers_new_routes(self): + expected = { + "/dashboard": "dashboard.view", + "/monitoring/map": "locations.view", + "/operations/repairs": "repairs.view", + "/assets/miners": "miners.view", + } + for path, permission in expected.items(): + with self.subTest(path=path): + self.assertTrue(protected_page(path)) + self.assertEqual( + required_page_permission(path), + permission, + ) + + def test_dashboard_does_not_fabricate_operational_values(self): + self.authenticate() + response = self.client.get("/dashboard") + for value in ( + 'id="dashboardFleetTotal"', + 'id="dashboardActiveRepairs"', + 'id="dashboardSites"', + "/assets/minerops-dashboard.js", + "Runtime and lifecycle are separate dimensions", + ): + self.assertIn(value, response.text) + self.assertIn("Loading operational overview", response.text) + self.assertNotIn("Live health summary not yet connected", response.text) + self.assertNotIn("100 TH/s", response.text) + + def test_dashboard_html_omits_unauthorized_sections_and_links(self): + self.authenticate(permissions=("dashboard.view", "locations.view")) + response = self.client.get("/dashboard") + self.assertIn("Locations", response.text) + self.assertIn("Open Mine Map", response.text) + self.assertNotIn("Fleet overview", response.text) + self.assertNotIn("Needs attention", response.text) + self.assertNotIn("Open Fleet", response.text) + self.assertNotIn("Active repair records", response.text) + self.assertNotIn("Open Repair Center", response.text) + self.assertNotIn("Mapped miners", response.text) + self.assertNotIn("Unmapped miners", response.text) + + def test_dashboard_script_dom_contract_matches_authorized_page(self): + self.authenticate() + response = self.client.get("/dashboard") + javascript = (STATIC_ROOT / "dashboard.js").read_text(encoding="utf-8") + declared_ids = re.search(r"const ids = \[(.*?)\];", javascript, re.DOTALL) + self.assertIsNotNone(declared_ids) + required = set(re.findall(r'"([A-Za-z][A-Za-z0-9]+)"', declared_ids.group(1))) + rendered = set(re.findall(r'id="([A-Za-z][A-Za-z0-9]+)"', response.text)) + self.assertEqual(required - rendered, set()) + + def test_map_is_a_structured_operational_view(self): + self.authenticate() + response = self.client.get("/monitoring/map") + for label in ( + "Site", + "Room", + "Operational Status", + "Miner details", + "Find miner", + ): + self.assertIn(label, response.text) + for value in ( + 'id="mapSite"', 'id="mapRoom"', 'id="mapLayer"', + 'id="mapProblemsOnly"', + 'id="mapViewport"', 'id="mapInspector"', + '/assets/minerops-mine-map.js', + ): + self.assertIn(value, response.text) + self.assertNotIn("Live layout and telemetry data are not", response.text) + + def test_map_script_dom_contract_matches_rendered_page(self): + self.authenticate() + response = self.client.get("/monitoring/map") + javascript = (STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8") + declared_ids = re.search( + r"const ids = \[(.*?)\];", + javascript, + re.DOTALL, + ) + self.assertIsNotNone(declared_ids) + required = set( + re.findall( + r'"([A-Za-z][A-Za-z0-9]+)"', + declared_ids.group(1), + ) + ) + required.update( + re.findall( + r'document\.getElementById\("([^"]+)"\)', + javascript, + ) + ) + rendered = set(re.findall(r'\bid="([^"]+)"', response.text)) + self.assertEqual(required - rendered, set()) + + def test_data_views_include_all_request_states(self): + self.authenticate() + for path in ("/operations/repairs", "/assets/miners"): + with self.subTest(path=path): + response_text = self.client.get(path).text + for state in ( + "loading", + "empty", + "error", + "denied", + "ready", + ): + self.assertIn( + f'data-view-state="{state}"', + response_text, + ) + + def test_frontend_assets_are_responsive_and_accessible(self): + css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8") + javascript = (STATIC_ROOT / "app.js").read_text( + encoding="utf-8" + ) + self.assertIn("@media (max-width: 560px)", css) + self.assertIn(":focus-visible", css) + self.assertIn(".skip-link", css) + repair_center = (STATIC_ROOT / "repair-center.js").read_text( + encoding="utf-8" + ) + self.assertIn('response.status === 401', repair_center) + self.assertIn('response.status === 403', repair_center) + self.assertIn('credentials: "same-origin"', javascript) + + def test_static_assets_are_served_without_an_api_route(self): + css_response = self.client.get("/assets/minerops-app.css") + script_response = self.client.get("/assets/minerops-app.js") + self.assertEqual(css_response.status_code, 200) + self.assertEqual( + css_response.headers["content-type"], + "text/css; charset=utf-8", + ) + self.assertEqual(script_response.status_code, 200) + paths = {route.path for route in router.routes} + self.assertFalse(any(path.startswith("/api/") for path in paths)) + + def test_live_app_prefers_new_shell_and_preserves_legacy_pages(self): + from app.main import app + from app.frontend.router import dashboard_page + + first_handlers = {} + for route in app.routes: + if route.path not in first_handlers: + first_handlers[route.path] = route.endpoint + + for path in ROUTES: + with self.subTest(path=path): + self.assertEqual( + first_handlers[path].__module__, + "app.frontend.router", + ) + for path in ( + "/repairs", + "/locations", + "/miners/{miner_id}/detail", + ): + with self.subTest(legacy_path=path): + self.assertIn(path, first_handlers) + + dashboard_handlers = [ + route.endpoint + for route in app.routes + if route.path == "/dashboard" and "GET" in (route.methods or set()) + ] + self.assertIs(dashboard_handlers[0], dashboard_page) + self.assertEqual(dashboard_handlers.count(dashboard_page), 1) + + def test_live_dashboard_excludes_legacy_dashboard_injections(self): + from app.main import app + + current_user = { + "id": "00000000-0000-0000-0000-000000000654", + "username": "dashboard-reader", + "display_name": "Dashboard Reader", + "role": "Read Only", + "permissions": [ + "dashboard.view", + "miners.view", + "locations.view", + "repairs.view", + ], + } + app.dependency_overrides[get_current_user] = lambda: current_user + try: + with patch( + "app.identity.request_user_or_none", + return_value=current_user, + ): + client = TestClient(app) + try: + response = client.get("/dashboard") + finally: + client.close() + finally: + app.dependency_overrides.pop(get_current_user, None) + + self.assertEqual(response.status_code, 200) + self.assertIn("Dashboard | MinerOps", response.text) + self.assertIn('id="dashboardFleetTotal"', response.text) + self.assertEqual(response.text.count('id="dashboardFleetTotal"'), 1) + self.assertNotIn("/assets/minerops-repair-quick-nav.js", response.text) + self.assertNotIn( + "/assets/minerops-dashboard-repair-summary.js", + response.text, + ) + self.assertNotIn("mineropsDashboardRepairSummary", response.text) + self.assertNotIn("Repair Summary", response.text) + + + def test_safe_next_path_accepts_only_protected_local_pages(self): + valid = ( + "/dashboard", + "/assets/miners?query=S19%20Pro", + "/miners/00000000-0000-0000-0000-000000000123/detail", + ) + for value in valid: + with self.subTest(valid=value): + self.assertEqual(safe_next_path(value), value) + + invalid = ( + None, + "", + "https://example.invalid/dashboard", + "//example.invalid/dashboard", + r"\\example.invalid\dashboard", + "/../login", + "/assets/%2f%2fexample.invalid", + "/dashboard#fragment", + "/login", + "/api/inventory/miners", + " /dashboard", + "/dashboard\n", + ) + for value in invalid: + with self.subTest(invalid=value): + self.assertEqual(safe_next_path(value), "/dashboard") + + def test_login_redirect_preserves_direct_link_query(self): + app = FastAPI() + app.include_router(identity_router) + app.include_router(router) + install_identity_middleware(app) + with TestClient(app) as client, patch( + "app.identity.request_user_or_none", + return_value=None, + ): + response = client.get( + "/assets/miners?query=S19%20Pro", + follow_redirects=False, + ) + self.assertEqual(response.status_code, 303) + location = urlsplit(response.headers["location"]) + self.assertEqual(location.path, "/login") + self.assertEqual( + parse_qs(location.query)["next"], + ["/assets/miners?query=S19%20Pro"], + ) + + def test_authenticated_login_redirect_rejects_unsafe_next(self): + app = FastAPI() + app.include_router(identity_router) + install_identity_middleware(app) + with TestClient(app) as client, patch( + "app.identity.request_user_or_none", + return_value={"id": "user"}, + ): + for value in ( + "https://example.invalid", + "//example.invalid", + r"\\example.invalid\dashboard", + "/../login", + ): + with self.subTest(value=value): + response = client.get( + "/login", + params={"next": value}, + follow_redirects=False, + ) + self.assertEqual(response.status_code, 303) + self.assertEqual( + response.headers["location"], + "/dashboard", + ) + + def test_login_template_escapes_safe_query_destination(self): + app = FastAPI() + app.include_router(identity_router) + hostile = '/assets/miners?query=">' + with TestClient(app) as client: + response = client.get("/login", params={"next": hostile}) + self.assertEqual(response.status_code, 200) + self.assertNotIn(hostile, response.text) + self.assertIn(""><script>", response.text) + self.assertNotIn("new URLSearchParams(location.search)", response.text) + + def test_user_values_are_html_escaped(self): + hostile = '' + self.app.dependency_overrides[get_current_user] = lambda: { + "id": "00000000-0000-0000-0000-000000000321", + "username": "operator", + "display_name": hostile, + "role": "Read Only", + "permissions": ["dashboard.view", "miners.view"], + } + response = self.client.get("/dashboard") + self.assertEqual(response.status_code, 200) + self.assertNotIn(hostile, response.text) + self.assertIn("<img src=x onerror=alert(1)>", response.text) + + def test_navigation_and_search_are_permission_aware(self): + self.authenticate(permissions=("dashboard.view",)) + response = self.client.get("/dashboard") + self.assertEqual(response.status_code, 200) + self.assertIn('href="/dashboard"', response.text) + for path in ROUTES[1:]: + self.assertNotIn(f'href="{path}"', response.text) + self.assertNotIn('id="globalSearchForm"', response.text) + self.assertIn('data-can-view-miners="false"', response.text) + + def test_assets_disable_caching_and_missing_assets_are_404(self): + for path in ( + "/assets/minerops-app.css", + "/assets/minerops-app.js", + "/assets/minerops-dashboard.js", + "/assets/minerops-mine-map.js", + "/assets/minerops-fleet.js", + ): + with self.subTest(path=path): + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["cache-control"], "no-store") + self.assertEqual( + self.client.get("/assets/missing-minerops-file.js").status_code, + 404, + ) + + def test_trailing_slash_redirects_are_consistent(self): + self.authenticate() + for path in ROUTES: + with self.subTest(path=path): + response = self.client.get( + path + "/", + follow_redirects=False, + ) + self.assertEqual(response.status_code, 307) + self.assertEqual( + urlsplit(response.headers["location"]).path, + path, + ) + + def test_shell_markup_has_accessibility_landmarks(self): + self.authenticate() + for path in ROUTES: + with self.subTest(path=path): + text = self.client.get(path).text + self.assertEqual(text.count("

    "), 1) + self.assertIn('class="skip-link"', text) + self.assertIn('
    ', 'Operational', + 'id="fleetPrevious"', 'id="fleetNext"', + '/assets/minerops-fleet.js', 'data-can-view-locations="true"', + 'id="fleetSite"', 'id="fleetRoom"', + ): + self.assertIn(value, response.text) + + self.authenticate(permissions=("miners.view",)) + restricted = self.client.get("/assets/miners").text + self.assertIn('data-can-view-locations="false"', restricted) + self.assertNotIn('id="fleetSite"', restricted) + self.assertNotIn('id="fleetRoom"', restricted) + + def test_fleet_reset_action_has_a_visible_label(self): + self.authenticate() + response = self.client.get("/assets/miners") + self.assertIn( + '', + response.text, + ) + css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8") + self.assertIn( + ".fleet-filter-actions .quiet-button { color: var(--ink); }", + css, + ) + + def test_fleet_script_dom_contract_matches_authorized_page(self): + self.authenticate() + response = self.client.get("/assets/miners") + source = (STATIC_ROOT / "fleet.js").read_text(encoding="utf-8") + declared = re.search(r"const ids = \[(.*?)\];", source, re.DOTALL) + self.assertIsNotNone(declared) + required = set(re.findall(r'"([A-Za-z][A-Za-z0-9]+)"', declared.group(1))) + required.update(("fleetSite", "fleetRoom")) + rendered = set(re.findall(r'\bid="([^"]+)"', response.text)) + self.assertEqual(required - rendered, set()) + + def test_fleet_script_is_bounded_read_only_and_xss_safe(self): + source = (STATIC_ROOT / "fleet.js").read_text(encoding="utf-8") + self.assertEqual(source.count('fetch("/api/inventory/miners?"'), 1) + self.assertNotIn("/api/location/map", source) + self.assertNotIn("setInterval", source) + for method in ('method: "POST"', 'method: "PATCH"', 'method: "PUT"', 'method: "DELETE"'): + self.assertNotIn(method, source) + for unsafe in ("innerHTML", "insertAdjacentHTML", "document.write"): + self.assertNotIn(unsafe, source) + self.assertIn("textContent", source) + self.assertIn('"/miners/" + encodeURIComponent(miner.id) + "/detail"', source) + self.assertIn('"/monitoring/map?" + params.toString()', source) + self.assertNotRegex(source.lower(), r"stale.{0,20}(threshold|minutes|hours)") + self.assertNotIn("lowHashrate", source) + + def test_fleet_css_has_status_text_mobile_cards_and_visible_focus(self): + css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8") + self.assertIn('.fleet-row[data-status="offline"]', css) + self.assertIn("content: attr(data-label)", css) + self.assertIn(".fleet-pagination", css) + self.assertIn(":focus-visible", css) + + def test_styles_have_no_motion_to_reduce(self): + css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8") + self.assertNotIn("animation:", css) + self.assertNotIn("transition:", css) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_inventory_domain.py b/backend/tests/test_inventory_domain.py new file mode 100644 index 0000000..4df887a --- /dev/null +++ b/backend/tests/test_inventory_domain.py @@ -0,0 +1,718 @@ +import ast +import unittest +from copy import deepcopy +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from uuid import UUID, uuid4 + +from psycopg.errors import ForeignKeyViolation, UniqueViolation + +from app.inventory import ( + DuplicateAssetID, + InventoryCreateRequest, + InventoryMetadataUpdateRequest, + InventoryRepository, + InventorySearchFilters, + InventoryService, + InventoryUpdateRequest, + InventoryValidationError, + MinerNotFound, +) +from app.inventory.models import InventoryAssetIDHistoryEntry + + +def inventory_row(**overrides): + now = datetime.now(timezone.utc) + row = { + "id": uuid4(), + "site_id": None, + "room_id": None, + "asset_tag": "ASSET-001", + "current_mac": None, + "current_ip": None, + "hostname": None, + "miner_sn": None, + "control_board_sn": None, + "power_sn": None, + "model": None, + "firmware_version": None, + "lifecycle_status": "active", + "status": "unknown", + "notes": None, + "first_seen_at": None, + "last_seen_at": None, + "created_at": now, + "updated_at": now, + "tags": [], + "site_name": None, + "room_name": None, + "location_position_id": None, + "rack_label": None, + "shelf_label": None, + "position_label": None, + "position_index": None, + "observed_at": None, + "hashrate_ths": None, + "power_w": None, + "chip_temp_max_c": None, + "hash_stable": None, + "active_repair_id": None, + "active_repair_status": None, + "active_repair_reason": None, + } + row.update(overrides) + return row + + +class FakeInventoryRepository: + def __init__(self): + self.rows = {} + self.created_values = None + self.asset_id_history = [] + self.update_error = None + self.search_kwargs = None + + @contextmanager + def transaction(self): + rows_snapshot = deepcopy(self.rows) + history_snapshot = deepcopy(self.asset_id_history) + try: + yield self + except Exception: + self.rows = rows_snapshot + self.asset_id_history = history_snapshot + raise + + def get_miner_by_id(self, miner_id): + return self.rows.get(miner_id) + + def get_miner_by_id_for_update(self, miner_id): + return self.get_miner_by_id(miner_id) + + def get_miner_by_asset_id(self, asset_id): + key = asset_id.strip().casefold() + return next( + ( + row + for row in self.rows.values() + if str(row.get("asset_tag") or "").strip().casefold() == key + ), + None, + ) + + def list_miners(self, *, limit=100, offset=0): + return list(self.rows.values())[offset : offset + limit] + + def search_miners(self, **kwargs): + self.search_kwargs = kwargs + rows = list(self.rows.values()) + lifecycle = kwargs.get("lifecycle_status") + if lifecycle: + rows = [ + row for row in rows if row["lifecycle_status"] == lifecycle + ] + return rows[kwargs.get("offset", 0) : kwargs.get("limit", 100)] + + def create_miner(self, values): + self.created_values = dict(values) + row = inventory_row(**values) + self.rows[row["id"]] = row + return row + + def update_miner(self, miner_id, values): + if self.update_error is not None: + raise self.update_error + row = self.rows.get(miner_id) + if not row: + return None + row.update(values) + row["updated_at"] = datetime.now(timezone.utc) + return row + + def add_asset_id_history( + self, miner_id, old_asset_id, new_asset_id, changed_by + ): + history = { + "id": uuid4(), + "miner_id": miner_id, + "old_asset_id": old_asset_id, + "new_asset_id": new_asset_id, + "changed_at": datetime.now(timezone.utc), + "changed_by": changed_by, + "changed_by_display_name": "Test Actor", + } + self.asset_id_history.append(history) + return history + + def list_asset_id_history(self, miner_id): + return [ + row + for row in self.asset_id_history + if row["miner_id"] == miner_id + ] + + def add_tag(self, miner_id, tag_name): + now = datetime.now(timezone.utc) + tag = { + "id": uuid4(), + "name": tag_name, + "created_at": now, + "updated_at": now, + } + existing = { + item["name"].strip().casefold() + for item in self.rows[miner_id]["tags"] + } + if tag_name.casefold() not in existing: + self.rows[miner_id]["tags"].append(tag) + return tag + + def remove_tag(self, miner_id, tag_id): + before = len(self.rows[miner_id]["tags"]) + self.rows[miner_id]["tags"] = [ + tag for tag in self.rows[miner_id]["tags"] if tag["id"] != tag_id + ] + return len(self.rows[miner_id]["tags"]) != before + + +class InventoryServiceTests(unittest.TestCase): + def setUp(self): + self.repository = FakeInventoryRepository() + self.service = InventoryService(self.repository) + self.actor_user_id = uuid4() + + def test_create_normalizes_asset_id_tags_and_never_seen(self): + created = self.service.create_miner( + InventoryCreateRequest( + asset_id=" Mine-001 ", + psu_serial=" PSU-9 ", + tags=[" Priority ", "priority", "\tRepair\n"], + ) + ) + + self.assertEqual(created.asset_id, "Mine-001") + self.assertEqual(created.psu_serial, "PSU-9") + self.assertEqual([tag.name for tag in created.tags], ["Priority", "Repair"]) + self.assertIsNone(created.first_seen_at) + self.assertIsNone(created.last_seen_at) + self.assertIsNone(self.repository.created_values["first_seen_at"]) + self.assertIsNone(self.repository.created_values["last_seen_at"]) + + def test_duplicate_asset_id_is_case_and_whitespace_insensitive(self): + row = inventory_row(asset_tag="Mine-001") + self.repository.rows[row["id"]] = row + + with self.assertRaises(DuplicateAssetID): + self.service.create_miner( + InventoryCreateRequest(asset_id=" mine-001 ") + ) + + def test_lifecycle_validation_is_owned_by_service(self): + self.assertEqual( + self.service.validate_lifecycle_status(" SPARE "), + "spare", + ) + with self.assertRaises(InventoryValidationError): + self.service.create_miner( + InventoryCreateRequest( + asset_id="ASSET-BAD", + lifecycle_status="warehouse", + ) + ) + + def test_blank_asset_and_tags_are_rejected(self): + with self.assertRaises(InventoryValidationError): + self.service.normalize_asset_id(" ") + with self.assertRaises(InventoryValidationError): + self.service.normalize_tags(["valid", "\t\n"]) + + def test_asset_id_history_empty_for_known_miner(self): + row = inventory_row() + self.repository.rows[row["id"]] = row + + self.assertEqual( + self.service.get_asset_id_history(row["id"]), + [], + ) + + def test_asset_id_history_maps_populated_repository_results(self): + row = inventory_row() + self.repository.rows[row["id"]] = row + first_changed_at = datetime(2026, 7, 27, tzinfo=timezone.utc) + second_changed_at = datetime(2026, 7, 28, tzinfo=timezone.utc) + self.repository.asset_id_history = [ + { + "id": uuid4(), + "miner_id": row["id"], + "old_asset_id": "ASSET-FIRST", + "new_asset_id": "ASSET-SECOND", + "changed_at": first_changed_at, + "changed_by": self.actor_user_id, + "changed_by_display_name": "Test Actor", + }, + { + "id": uuid4(), + "miner_id": row["id"], + "old_asset_id": "ASSET-SECOND", + "new_asset_id": "ASSET-THIRD", + "changed_at": second_changed_at, + "changed_by": self.actor_user_id, + "changed_by_display_name": "Test Actor", + }, + ] + + history = self.service.get_asset_id_history(row["id"]) + + self.assertEqual(len(history), 2) + self.assertTrue( + all( + isinstance(entry, InventoryAssetIDHistoryEntry) + for entry in history + ) + ) + self.assertEqual( + [entry.new_asset_id for entry in history], + ["ASSET-SECOND", "ASSET-THIRD"], + ) + self.assertEqual(history[0].changed_by_display_name, "Test Actor") + + def test_asset_id_history_unknown_miner_raises_not_found(self): + with self.assertRaises(MinerNotFound): + self.service.get_asset_id_history(uuid4()) + + def test_metadata_update_preserves_unspecified_fields(self): + row = inventory_row( + asset_tag="ASSET-001", + miner_sn="MINER-OLD", + power_sn="PSU-OLD", + model="M50", + notes="Old notes", + ) + self.repository.rows[row["id"]] = row + + updated = self.service.update_metadata( + row["id"], + InventoryMetadataUpdateRequest( + model="M60", + notes=None, + ), + actor_user_id=self.actor_user_id, + ) + + self.assertEqual(updated.id, row["id"]) + self.assertEqual(updated.asset_id, "ASSET-001") + self.assertEqual(updated.miner_sn, "MINER-OLD") + self.assertEqual(updated.psu_serial, "PSU-OLD") + self.assertEqual(updated.model, "M60") + self.assertIsNone(updated.notes) + self.assertEqual(self.repository.asset_id_history, []) + + def test_asset_id_change_preserves_uuid_and_creates_history(self): + row = inventory_row(asset_tag="ASSET-OLD") + self.repository.rows[row["id"]] = row + + updated = self.service.update_metadata( + row["id"], + InventoryMetadataUpdateRequest(asset_id=" ASSET-NEW "), + actor_user_id=self.actor_user_id, + ) + + self.assertEqual(updated.id, row["id"]) + self.assertEqual(updated.asset_id, "ASSET-NEW") + self.assertEqual(len(self.repository.asset_id_history), 1) + history = self.repository.asset_id_history[0] + self.assertEqual(history["miner_id"], row["id"]) + self.assertEqual(history["old_asset_id"], "ASSET-OLD") + self.assertEqual(history["new_asset_id"], "ASSET-NEW") + self.assertEqual(history["changed_by"], self.actor_user_id) + + def test_unchanged_normalized_asset_id_creates_no_history(self): + row = inventory_row(asset_tag="ASSET-001") + original_updated_at = row["updated_at"] + self.repository.rows[row["id"]] = row + + updated = self.service.update_metadata( + row["id"], + InventoryMetadataUpdateRequest(asset_id=" ASSET-001 "), + actor_user_id=self.actor_user_id, + ) + + self.assertEqual(updated.asset_id, "ASSET-001") + self.assertEqual(updated.updated_at, original_updated_at) + self.assertEqual(self.repository.asset_id_history, []) + + def test_duplicate_asset_id_leaves_miner_and_history_unchanged(self): + row = inventory_row(asset_tag="ASSET-OLD") + duplicate = inventory_row(asset_tag="ASSET-TAKEN") + self.repository.rows[row["id"]] = row + self.repository.rows[duplicate["id"]] = duplicate + + with self.assertRaises(DuplicateAssetID): + self.service.update_metadata( + row["id"], + InventoryMetadataUpdateRequest(asset_id=" asset-taken "), + actor_user_id=self.actor_user_id, + ) + + self.assertEqual( + self.repository.rows[row["id"]]["asset_tag"], + "ASSET-OLD", + ) + self.assertEqual(self.repository.asset_id_history, []) + + def test_empty_or_invalid_metadata_update_does_not_write(self): + row = inventory_row(asset_tag="ASSET-OLD", model="M50") + self.repository.rows[row["id"]] = row + + for request in ( + InventoryMetadataUpdateRequest(), + InventoryMetadataUpdateRequest(asset_id=" ", model="M60"), + ): + with self.subTest(request=request): + with self.assertRaises(InventoryValidationError): + self.service.update_metadata( + row["id"], + request, + actor_user_id=self.actor_user_id, + ) + + self.assertEqual(self.repository.rows[row["id"]]["model"], "M50") + self.assertEqual(self.repository.asset_id_history, []) + + def test_uniqueness_race_becomes_duplicate_asset_id(self): + row = inventory_row(asset_tag="ASSET-OLD") + self.repository.rows[row["id"]] = row + self.repository.update_error = UniqueViolation("duplicate Asset ID") + + with self.assertRaises(DuplicateAssetID): + self.service.update_metadata( + row["id"], + InventoryMetadataUpdateRequest(asset_id="ASSET-NEW"), + actor_user_id=self.actor_user_id, + ) + + self.assertEqual( + self.repository.rows[row["id"]]["asset_tag"], + "ASSET-OLD", + ) + self.assertEqual(self.repository.asset_id_history, []) + + def test_update_maps_product_names_without_repository_rules(self): + row = inventory_row(asset_tag="ASSET-OLD", power_sn="PSU-OLD") + self.repository.rows[row["id"]] = row + + updated = self.service.update_miner( + row["id"], + InventoryUpdateRequest( + psu_serial=" PSU-NEW ", + lifecycle_status="retired", + ), + ) + + self.assertEqual(updated.asset_id, "ASSET-OLD") + self.assertEqual(updated.psu_serial, "PSU-NEW") + self.assertEqual(updated.lifecycle_status, "retired") + + def test_legacy_update_rejects_asset_id_change_without_actor(self): + row = inventory_row(asset_tag="ASSET-OLD") + self.repository.rows[row["id"]] = row + + with self.assertRaisesRegex( + InventoryValidationError, + "actor-attributed metadata update", + ): + self.service.update_miner( + row["id"], + InventoryUpdateRequest(asset_id="ASSET-NEW"), + ) + + self.assertEqual( + self.repository.rows[row["id"]]["asset_tag"], + "ASSET-OLD", + ) + self.assertEqual(self.repository.asset_id_history, []) + + def test_fleet_projection_mapping_and_filter_forwarding(self): + miner_id = uuid4() + repair_id = uuid4() + site_id = uuid4() + row = inventory_row( + id=miner_id, + miner_sn="MINER-SN", + site_id=site_id, + site_name="North Site", + observed_at=datetime.now(timezone.utc), + hashrate_ths=0.0, + power_w=0.0, + chip_temp_max_c=0.0, + hash_stable=False, + active_repair_id=repair_id, + active_repair_status="IN_PROGRESS", + active_repair_reason="Hashboard diagnostics", + ) + self.repository.rows[miner_id] = row + filters = InventorySearchFilters( + model=" M50 ", runtime_status="offline", repair="active", + operational_state="repair", problems=True, site_id=site_id, + limit=25, offset=0, + ) + + result = self.service.search_miners( + filters, include_location_details=True + )[0] + + self.assertEqual(result.miner_sn, "MINER-SN") + self.assertEqual(result.hashrate_ths, 0.0) + self.assertEqual(result.power_w, 0.0) + self.assertEqual(result.chip_temp_max_c, 0.0) + self.assertFalse(result.hash_stable) + self.assertEqual(result.active_repair.id, repair_id) + self.assertEqual(self.repository.search_kwargs["model"], "M50") + self.assertEqual(self.repository.search_kwargs["site_id"], site_id) + self.assertTrue(self.repository.search_kwargs["include_location_details"]) + + def test_location_filter_without_projection_is_rejected(self): + with self.assertRaisesRegex( + InventoryValidationError, "Location filters require" + ): + self.service.search_miners( + InventorySearchFilters(site_id=uuid4()) + ) + self.assertIsNone(self.repository.search_kwargs) + + def test_search_validation_precedes_repository(self): + with self.assertRaises(InventoryValidationError): + self.service.search_miners( + InventorySearchFilters(lifecycle_status="broken") + ) + + +class InventoryRepositoryIntegrationTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.repository = InventoryRepository() + + def setUp(self): + self.actor_user_id = UUID("00000000-0000-0000-0000-000000000951") + with self.repository.transaction() as repository: + with repository._connection.cursor() as cur: + cur.execute("DELETE FROM miner_asset_id_history") + cur.execute("DELETE FROM miner_tags") + cur.execute("DELETE FROM tags") + cur.execute("DELETE FROM miners") + cur.execute( + """ + INSERT INTO users ( + id, username, display_name, password_hash, role_id + ) + SELECT %s, %s, %s, %s, id + FROM roles + WHERE name = 'Admin' + ON CONFLICT (id) DO NOTHING + """, + ( + self.actor_user_id, + "inventory-history-test", + "Inventory History Test", + "not-used", + ), + ) + + def test_repository_crud_search_and_tags(self): + created = self.repository.create_miner( + { + "asset_tag": "REPO-001", + "current_ip": "192.0.2.10", + "model": "M50", + "miner_sn": "MINER-001", + "control_board_sn": "CB-001", + "power_sn": "PSU-001", + "lifecycle_status": "active", + "status": "unknown", + "first_seen_at": None, + "last_seen_at": None, + } + ) + miner_id = created["id"] + + self.assertIsInstance(miner_id, UUID) + self.assertIsNone(created["first_seen_at"]) + self.assertIsNone(created["last_seen_at"]) + self.assertEqual( + self.repository.get_miner_by_asset_id(" repo-001 ")["id"], + miner_id, + ) + self.assertEqual(self.repository.get_miner_by_id(miner_id)["model"], "M50") + self.assertEqual(len(self.repository.list_miners()), 1) + self.assertEqual( + self.repository.search_miners(query="CB-001")[0]["id"], + miner_id, + ) + + fleet = self.repository.search_miners( + model="M50", + runtime_status="unknown", + operational_state="unknown", + problems=True, + include_location_details=True, + ) + self.assertEqual(fleet[0]["id"], miner_id) + self.assertEqual(fleet[0]["miner_sn"], "MINER-001") + self.assertIsNone(fleet[0]["observed_at"]) + self.assertIsNone(fleet[0]["location_position_id"]) + self.assertEqual( + self.repository.list_location_filter_options(), + {"sites": [], "rooms": []}, + ) + + updated = self.repository.update_miner( + miner_id, + {"lifecycle_status": "spare", "notes": "Bench stock"}, + ) + self.assertEqual(updated["lifecycle_status"], "spare") + self.assertEqual( + self.repository.search_miners(lifecycle_status="spare")[0]["id"], + miner_id, + ) + + tag = self.repository.add_tag(miner_id, "Priority") + repeated = self.repository.add_tag(miner_id, " priority ") + self.assertEqual(tag["id"], repeated["id"]) + self.assertEqual(len(self.repository.list_tags()), 1) + self.assertEqual( + self.repository.search_miners(tag_names=["priority"])[0]["id"], + miner_id, + ) + self.assertEqual( + len(self.repository.get_miner_by_id(miner_id)["tags"]), + 1, + ) + self.assertTrue(self.repository.remove_tag(miner_id, tag["id"])) + self.assertEqual( + self.repository.get_miner_by_id(miner_id)["tags"], + [], + ) + + def test_repeated_asset_id_changes_preserve_chronological_history(self): + service = InventoryService(self.repository) + created = service.create_miner( + InventoryCreateRequest(asset_id="ASSET-FIRST") + ) + + second = service.update_metadata( + created.id, + InventoryMetadataUpdateRequest(asset_id="ASSET-SECOND"), + actor_user_id=self.actor_user_id, + ) + third = service.update_metadata( + created.id, + InventoryMetadataUpdateRequest(asset_id="ASSET-THIRD"), + actor_user_id=self.actor_user_id, + ) + other = service.create_miner( + InventoryCreateRequest(asset_id="OTHER-FIRST") + ) + service.update_metadata( + other.id, + InventoryMetadataUpdateRequest(asset_id="OTHER-SECOND"), + actor_user_id=self.actor_user_id, + ) + + self.assertEqual(created.id, second.id) + self.assertEqual(second.id, third.id) + history = self.repository.list_asset_id_history(created.id) + self.assertEqual(len(history), 2) + self.assertEqual( + [ + (row["old_asset_id"], row["new_asset_id"]) + for row in history + ], + [ + ("ASSET-FIRST", "ASSET-SECOND"), + ("ASSET-SECOND", "ASSET-THIRD"), + ], + ) + self.assertLessEqual( + history[0]["changed_at"], + history[1]["changed_at"], + ) + self.assertTrue( + all( + row["miner_id"] == created.id + and row["changed_by_display_name"] + == "Inventory History Test" + for row in history + ) + ) + other_history = self.repository.list_asset_id_history(other.id) + self.assertEqual(len(other_history), 1) + self.assertEqual(other_history[0]["new_asset_id"], "OTHER-SECOND") + + def test_history_failure_rolls_back_asset_id_update(self): + service = InventoryService(self.repository) + created = service.create_miner( + InventoryCreateRequest(asset_id="ASSET-ROLLBACK-OLD") + ) + + with self.assertRaises(ForeignKeyViolation): + service.update_metadata( + created.id, + InventoryMetadataUpdateRequest( + asset_id="ASSET-ROLLBACK-NEW" + ), + actor_user_id=uuid4(), + ) + + persisted = self.repository.get_miner_by_id(created.id) + self.assertEqual(persisted["asset_tag"], "ASSET-ROLLBACK-OLD") + self.assertEqual( + self.repository.list_asset_id_history(created.id), + [], + ) + + def test_transaction_rolls_back_repository_writes(self): + with self.assertRaises(RuntimeError): + with self.repository.transaction() as repository: + repository.create_miner( + { + "asset_tag": "ROLLBACK-001", + "status": "unknown", + "first_seen_at": None, + "last_seen_at": None, + } + ) + raise RuntimeError("rollback") + + self.assertIsNone( + self.repository.get_miner_by_asset_id("ROLLBACK-001") + ) + + +class InventoryArchitectureTests(unittest.TestCase): + def test_inventory_domain_layers_have_no_http_dependency(self): + inventory_path = Path(__file__).parents[1] / "app" / "inventory" + domain_files = ( + "dependencies.py", + "exceptions.py", + "models.py", + "repository.py", + "service.py", + ) + for filename in domain_files: + source_path = inventory_path / filename + tree = ast.parse(source_path.read_text(encoding="utf-8")) + imported_modules = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported_modules.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imported_modules.append(node.module) + self.assertFalse( + any(module.startswith("fastapi") for module in imported_modules), + source_path, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_inventory_router.py b/backend/tests/test_inventory_router.py new file mode 100644 index 0000000..c307036 --- /dev/null +++ b/backend/tests/test_inventory_router.py @@ -0,0 +1,689 @@ +import ast +import unittest +from datetime import datetime, timezone +from pathlib import Path +from uuid import UUID, uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.identity import get_current_user +from app.inventory.dependencies import get_inventory_service +from app.inventory.exceptions import ( + DuplicateAssetID, + InventoryValidationError, + MinerNotFound, +) +from app.inventory.models import ( + InventoryAssetIDHistoryEntry, + InventoryCreateRequest, + InventoryMetadataUpdateRequest, + InventoryMinerDetail, + InventoryMinerSummary, + InventorySearchFilters, +) +from app.inventory.router import router + + +class StubInventoryService: + def __init__(self): + self.search_result: list[InventoryMinerSummary] = [] + self.detail_result: InventoryMinerDetail | None = None + self.search_filters: InventorySearchFilters | None = None + self.detail_miner_id: UUID | None = None + self.search_error: InventoryValidationError | None = None + self.create_result: InventoryMinerDetail | None = None + self.create_error: Exception | None = None + self.create_request: InventoryCreateRequest | None = None + self.update_result: InventoryMinerDetail | None = None + self.update_error: Exception | None = None + self.update_miner_id: UUID | None = None + self.update_request: InventoryMetadataUpdateRequest | None = None + self.update_actor_user_id: UUID | None = None + self.history_result: list[InventoryAssetIDHistoryEntry] = [] + self.history_error: Exception | None = None + self.history_miner_id: UUID | None = None + self.include_location_details = False + self.location_options = {"sites": [], "rooms": []} + + def search_miners( + self, + filters: InventorySearchFilters, + *, + include_location_details: bool = False, + ) -> list[InventoryMinerSummary]: + self.search_filters = filters + self.include_location_details = include_location_details + if self.search_error is not None: + raise self.search_error + return self.search_result + + def list_location_filter_options(self): + return self.location_options + + def create_miner( + self, + request: InventoryCreateRequest, + ) -> InventoryMinerDetail: + self.create_request = request + if self.create_error is not None: + raise self.create_error + if self.create_result is None: + raise AssertionError("create_result was not configured") + return self.create_result + + def update_metadata( + self, + miner_id: UUID, + request: InventoryMetadataUpdateRequest, + *, + actor_user_id: UUID, + ) -> InventoryMinerDetail: + self.update_miner_id = miner_id + self.update_request = request + self.update_actor_user_id = actor_user_id + if self.update_error is not None: + raise self.update_error + if self.update_result is None: + raise AssertionError("update_result was not configured") + return self.update_result + + def get_miner(self, miner_id: UUID) -> InventoryMinerDetail: + self.detail_miner_id = miner_id + if self.detail_result is None or self.detail_result.id != miner_id: + raise MinerNotFound(miner_id) + return self.detail_result + + def get_asset_id_history( + self, + miner_id: UUID, + ) -> list[InventoryAssetIDHistoryEntry]: + self.history_miner_id = miner_id + if self.history_error is not None: + raise self.history_error + return self.history_result + + +def miner_summary(miner_id: UUID | None = None) -> InventoryMinerSummary: + now = datetime.now(timezone.utc) + return InventoryMinerSummary( + id=miner_id or uuid4(), + asset_id="ASSET-001", + model="M50", + lifecycle_status="active", + runtime_status="unknown", + created_at=now, + updated_at=now, + ) + + +def miner_detail(miner_id: UUID | None = None) -> InventoryMinerDetail: + values = miner_summary(miner_id).model_dump() + values["miner_sn"] = "MINER-001" + return InventoryMinerDetail( + **values, + control_board_sn="CB-001", + psu_serial="PSU-001", + ) + + +def history_entry( + *, + old_asset_id: str | None, + new_asset_id: str, + changed_at: datetime, +) -> InventoryAssetIDHistoryEntry: + return InventoryAssetIDHistoryEntry( + old_asset_id=old_asset_id, + new_asset_id=new_asset_id, + changed_at=changed_at, + changed_by=UUID("00000000-0000-0000-0000-000000000951"), + changed_by_display_name="Inventory Test User", + ) + + +class InventoryRouterTests(unittest.TestCase): + def setUp(self): + self.service = StubInventoryService() + self.app = FastAPI() + self.app.include_router(router) + self.app.dependency_overrides[get_inventory_service] = ( + lambda: self.service + ) + self.client = TestClient(self.app) + + def tearDown(self): + self.client.close() + + def authenticate(self, permissions=("miners.view",)): + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), + "username": "reader", + "role": "Read Only", + "permissions": list(permissions), + } + + def test_create_requires_authentication(self): + response = self.client.post( + "/api/inventory/miners", + json={"asset_id": "ASSET-NEW"}, + ) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Login required") + + def test_create_requires_miners_update_permission(self): + self.authenticate(permissions=("miners.view",)) + response = self.client.post( + "/api/inventory/miners", + json={"asset_id": "ASSET-NEW"}, + ) + self.assertEqual(response.status_code, 403) + self.assertIsNone(self.service.create_request) + + def test_create_returns_new_miner_in_typed_envelope(self): + self.authenticate(permissions=("miners.update",)) + created = miner_detail() + self.service.create_result = created + + response = self.client.post( + "/api/inventory/miners", + json={ + "asset_id": " ASSET-NEW ", + "lifecycle_status": "spare", + "site_id": str(uuid4()), + "tags": ["Priority", "Repair"], + }, + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()["data"]["id"], str(created.id)) + self.assertEqual(response.json()["meta"], {}) + self.assertIsNone(response.json()["error"]) + request = self.service.create_request + self.assertIsNotNone(request) + self.assertEqual(request.asset_id, " ASSET-NEW ") + self.assertEqual(request.lifecycle_status, "spare") + self.assertEqual(request.tags, ["Priority", "Repair"]) + + def test_duplicate_asset_id_returns_conflict(self): + self.authenticate(permissions=("miners.update",)) + self.service.create_error = DuplicateAssetID("ASSET-001") + + response = self.client.post( + "/api/inventory/miners", + json={"asset_id": "ASSET-001"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.json()["detail"], + "Asset ID already exists: ASSET-001", + ) + + def test_invalid_create_body_returns_validation_error(self): + self.authenticate(permissions=("miners.update",)) + for body in ( + {}, + {"asset_id": "A" * 121}, + {"asset_id": "ASSET-001", "site_id": "not-a-uuid"}, + ): + with self.subTest(body=body): + response = self.client.post( + "/api/inventory/miners", + json=body, + ) + self.assertEqual(response.status_code, 422) + self.assertIsNone(self.service.create_request) + + def test_create_maps_domain_validation_error(self): + self.authenticate(permissions=("miners.update",)) + self.service.create_error = InventoryValidationError( + "Tag names cannot be blank" + ) + + response = self.client.post( + "/api/inventory/miners", + json={"asset_id": "ASSET-001", "tags": [" "]}, + ) + + self.assertEqual(response.status_code, 422) + self.assertEqual( + response.json()["detail"], + "Tag names cannot be blank", + ) + + def test_patch_requires_authentication(self): + response = self.client.patch( + f"/api/inventory/miners/{uuid4()}", + json={"model": "M60"}, + ) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Login required") + + def test_patch_requires_miners_update_permission(self): + self.authenticate(permissions=("miners.view",)) + response = self.client.patch( + f"/api/inventory/miners/{uuid4()}", + json={"model": "M60"}, + ) + self.assertEqual(response.status_code, 403) + self.assertIsNone(self.service.update_request) + + def test_patch_unknown_miner_returns_not_found(self): + self.authenticate(permissions=("miners.update",)) + miner_id = uuid4() + self.service.update_error = MinerNotFound(miner_id) + + response = self.client.patch( + f"/api/inventory/miners/{miner_id}", + json={"model": "M60"}, + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["detail"], "Miner not found") + + def test_patch_malformed_uuid_returns_validation_error(self): + self.authenticate(permissions=("miners.update",)) + response = self.client.patch( + "/api/inventory/miners/not-a-uuid", + json={"model": "M60"}, + ) + self.assertEqual(response.status_code, 422) + self.assertIsNone(self.service.update_request) + + def test_empty_patch_maps_domain_validation_error(self): + self.authenticate(permissions=("miners.update",)) + self.service.update_error = InventoryValidationError( + "At least one metadata field must be provided" + ) + + response = self.client.patch( + f"/api/inventory/miners/{uuid4()}", + json={}, + ) + + self.assertEqual(response.status_code, 422) + self.assertEqual( + response.json()["detail"], + "At least one metadata field must be provided", + ) + + def test_patch_rejects_unknown_and_operational_fields(self): + self.authenticate(permissions=("miners.update",)) + for body in ( + {"unknown_field": "value"}, + {"lifecycle_status": "retired"}, + {"current_ip": "192.0.2.10"}, + {"current_mac": "AA:BB:CC:DD:EE:FF"}, + {"control_board_sn": "CB-NEW"}, + {"site_id": str(uuid4())}, + {"room_id": str(uuid4())}, + {"tags": ["Priority"]}, + {"firmware_version": "1.2.3"}, + {"hostname": "miner-host"}, + ): + with self.subTest(body=body): + response = self.client.patch( + f"/api/inventory/miners/{uuid4()}", + json=body, + ) + self.assertEqual(response.status_code, 422) + self.assertIsNone(self.service.update_request) + + def test_valid_metadata_patch_returns_typed_envelope(self): + self.authenticate(permissions=("miners.update",)) + updated = miner_detail() + self.service.update_result = updated + + response = self.client.patch( + f"/api/inventory/miners/{updated.id}", + json={ + "asset_id": " ASSET-NEW ", + "miner_sn": "MINER-NEW", + "psu_serial": None, + "model": "M60", + "notes": "Bench verified", + }, + ) + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["data"]["id"], str(updated.id)) + self.assertEqual(body["meta"], {}) + self.assertIsNone(body["error"]) + self.assertEqual(self.service.update_miner_id, updated.id) + self.assertIsInstance(self.service.update_actor_user_id, UUID) + request = self.service.update_request + self.assertIsNotNone(request) + self.assertEqual( + request.model_fields_set, + {"asset_id", "miner_sn", "psu_serial", "model", "notes"}, + ) + self.assertIsNone(request.psu_serial) + + def test_patch_duplicate_asset_id_returns_conflict(self): + self.authenticate(permissions=("miners.update",)) + self.service.update_error = DuplicateAssetID("ASSET-TAKEN") + + response = self.client.patch( + f"/api/inventory/miners/{uuid4()}", + json={"asset_id": "ASSET-TAKEN"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.json()["detail"], + "Asset ID already exists: ASSET-TAKEN", + ) + + def test_list_requires_authentication(self): + response = self.client.get("/api/inventory/miners") + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Login required") + + def test_detail_requires_authentication(self): + response = self.client.get(f"/api/inventory/miners/{uuid4()}") + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Login required") + + def test_asset_id_history_requires_authentication(self): + response = self.client.get( + f"/api/inventory/miners/{uuid4()}/asset-id-history" + ) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["detail"], "Login required") + + def test_list_requires_miners_view_permission(self): + self.authenticate(permissions=()) + response = self.client.get("/api/inventory/miners") + self.assertEqual(response.status_code, 403) + + def test_asset_id_history_requires_miners_view_permission(self): + self.authenticate(permissions=()) + response = self.client.get( + f"/api/inventory/miners/{uuid4()}/asset-id-history" + ) + self.assertEqual(response.status_code, 403) + self.assertIsNone(self.service.history_miner_id) + + def test_authenticated_list_returns_typed_envelope(self): + self.authenticate() + self.service.search_result = [miner_summary()] + + response = self.client.get("/api/inventory/miners") + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(len(body["data"]), 1) + self.assertEqual(body["data"][0]["asset_id"], "ASSET-001") + self.assertEqual( + body["meta"], + {"limit": 100, "offset": 0, "returned": 1, + "location_options": {"sites": [], "rooms": []}}, + ) + self.assertIsNone(body["error"]) + + def test_list_projection_and_location_permission_are_explicit(self): + miner_id = uuid4() + site_id = uuid4() + room_id = uuid4() + position_id = uuid4() + self.service.search_result = [ + InventoryMinerSummary( + id=miner_id, + asset_id="FLEET-001", + model="M50", + miner_sn="SN-001", + lifecycle_status="active", + runtime_status="online", + site_id=site_id, + site_name="North Site", + room_id=room_id, + room_name="Room A", + location_position_id=position_id, + rack_label="Rack 1", + shelf_label="Shelf 2", + position_index=3, + observed_at=datetime.now(timezone.utc), + hashrate_ths=120.0, + power_w=3400.0, + chip_temp_max_c=72.0, + hash_stable=True, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + ] + self.service.location_options = { + "sites": [{"id": site_id, "name": "North Site"}], + "rooms": [{"id": room_id, "site_id": site_id, "name": "Room A"}], + } + + self.authenticate(("miners.view", "locations.view")) + response = self.client.get("/api/inventory/miners") + data = response.json()["data"][0] + self.assertEqual(response.status_code, 200) + self.assertEqual(data["miner_sn"], "SN-001") + self.assertEqual(data["location_position_id"], str(position_id)) + self.assertEqual(data["hashrate_ths"], 120.0) + self.assertTrue(self.service.include_location_details) + self.assertEqual( + response.json()["meta"]["location_options"]["sites"][0]["name"], + "North Site", + ) + + self.authenticate(("miners.view",)) + self.service.search_result = [miner_summary(miner_id)] + response = self.client.get("/api/inventory/miners") + data = response.json()["data"][0] + self.assertIsNone(data["site_id"]) + self.assertIsNone(data["room_id"]) + self.assertIsNone(data["location_position_id"]) + self.assertEqual(response.json()["meta"]["location_options"], {"sites": [], "rooms": []}) + self.assertFalse(self.service.include_location_details) + + def test_location_filters_require_locations_view(self): + self.authenticate(("miners.view",)) + response = self.client.get( + "/api/inventory/miners", params={"site_id": str(uuid4())} + ) + self.assertEqual(response.status_code, 403) + self.assertIsNone(self.service.search_filters) + + def test_empty_list_returns_successful_empty_collection(self): + self.authenticate() + + response = self.client.get("/api/inventory/miners") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"], []) + self.assertEqual(response.json()["meta"]["returned"], 0) + + def test_search_filters_are_passed_to_service(self): + self.authenticate() + + response = self.client.get( + "/api/inventory/miners", + params=[ + ("query", "M50"), + ("asset_id", "ASSET-001"), + ("lifecycle_status", "spare"), + ("model", "M50"), + ("runtime_status", "offline"), + ("repair", "active"), + ("operational_state", "repair"), + ("problems", "true"), + ("tags", "Priority"), + ("tags", "Repair"), + ("limit", "25"), + ("offset", "50"), + ], + ) + + self.assertEqual(response.status_code, 200) + filters = self.service.search_filters + self.assertIsNotNone(filters) + self.assertEqual(filters.query, "M50") + self.assertEqual(filters.asset_id, "ASSET-001") + self.assertEqual(filters.lifecycle_status, "spare") + self.assertEqual(filters.model, "M50") + self.assertEqual(filters.runtime_status, "offline") + self.assertEqual(filters.repair, "active") + self.assertEqual(filters.operational_state, "repair") + self.assertTrue(filters.problems) + self.assertEqual(filters.tags, ["Priority", "Repair"]) + self.assertEqual(filters.limit, 25) + self.assertEqual(filters.offset, 50) + + def test_invalid_lifecycle_filter_returns_client_error(self): + self.authenticate() + self.service.search_error = InventoryValidationError( + "Lifecycle status must be active, spare, or retired" + ) + response = self.client.get( + "/api/inventory/miners", + params={"lifecycle_status": "broken"}, + ) + self.assertEqual(response.status_code, 422) + self.assertIn("Lifecycle status", response.json()["detail"]) + + def test_pagination_is_validated_and_bounded(self): + self.authenticate() + for params in ( + {"limit": 0}, + {"limit": 501}, + {"offset": -1}, + ): + with self.subTest(params=params): + response = self.client.get( + "/api/inventory/miners", + params=params, + ) + self.assertEqual(response.status_code, 422) + + def test_existing_uuid_returns_inventory_detail(self): + self.authenticate() + expected = miner_detail() + self.service.detail_result = expected + + response = self.client.get( + f"/api/inventory/miners/{expected.id}" + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["id"], str(expected.id)) + self.assertEqual(response.json()["data"]["psu_serial"], "PSU-001") + self.assertEqual(self.service.detail_miner_id, expected.id) + + def test_unknown_uuid_returns_not_found(self): + self.authenticate() + response = self.client.get(f"/api/inventory/miners/{uuid4()}") + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["detail"], "Miner not found") + + def test_malformed_uuid_returns_client_error(self): + self.authenticate() + response = self.client.get("/api/inventory/miners/not-a-uuid") + self.assertEqual(response.status_code, 422) + + def test_asset_id_history_malformed_uuid_returns_validation_error(self): + self.authenticate() + response = self.client.get( + "/api/inventory/miners/not-a-uuid/asset-id-history" + ) + self.assertEqual(response.status_code, 422) + self.assertIsNone(self.service.history_miner_id) + + def test_asset_id_history_unknown_miner_returns_not_found(self): + self.authenticate() + miner_id = uuid4() + self.service.history_error = MinerNotFound(miner_id) + + response = self.client.get( + f"/api/inventory/miners/{miner_id}/asset-id-history" + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["detail"], "Miner not found") + + def test_asset_id_history_empty_collection_uses_typed_envelope(self): + self.authenticate() + miner_id = uuid4() + + response = self.client.get( + f"/api/inventory/miners/{miner_id}/asset-id-history" + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + {"data": [], "meta": {}, "error": None}, + ) + self.assertEqual(self.service.history_miner_id, miner_id) + + def test_asset_id_history_preserves_chronological_service_order(self): + self.authenticate() + miner_id = uuid4() + first = datetime(2026, 7, 27, 12, 0, tzinfo=timezone.utc) + second = datetime(2026, 7, 28, 12, 0, tzinfo=timezone.utc) + self.service.history_result = [ + history_entry( + old_asset_id="ASSET-FIRST", + new_asset_id="ASSET-SECOND", + changed_at=first, + ), + history_entry( + old_asset_id="ASSET-SECOND", + new_asset_id="ASSET-FIRST", + changed_at=second, + ), + ] + + response = self.client.get( + f"/api/inventory/miners/{miner_id}/asset-id-history" + ) + + self.assertEqual(response.status_code, 200) + data = response.json()["data"] + self.assertEqual( + [entry["new_asset_id"] for entry in data], + ["ASSET-SECOND", "ASSET-FIRST"], + ) + self.assertEqual( + data[0]["changed_by"], + "00000000-0000-0000-0000-000000000951", + ) + self.assertEqual( + data[0]["changed_by_display_name"], + "Inventory Test User", + ) + self.assertEqual(response.json()["meta"], {}) + self.assertIsNone(response.json()["error"]) + + def test_router_has_no_sql_or_database_driver_imports(self): + router_path = ( + Path(__file__).parents[1] / "app" / "inventory" / "router.py" + ) + source = router_path.read_text(encoding="utf-8") + tree = ast.parse(source) + imported_modules = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported_modules.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imported_modules.append(node.module) + + database_modules = ("psycopg", "asyncpg", "sqlalchemy") + self.assertFalse( + any( + module == name or module.startswith(f"{name}.") + for module in imported_modules + for name in database_modules + ) + ) + upper_source = source.upper() + for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE "): + self.assertNotIn(keyword, upper_source) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_mine_map.py b/backend/tests/test_mine_map.py new file mode 100644 index 0000000..5a3fed0 --- /dev/null +++ b/backend/tests/test_mine_map.py @@ -0,0 +1,400 @@ +import ast +import unittest +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch +from uuid import UUID, uuid4 + +from fastapi import FastAPI +import psycopg +from fastapi.testclient import TestClient + +from app.config import settings +from app.identity import get_current_user +from app.locations.dependencies import get_location_map_service +from app.locations.repository import LocationMapRepository +from app.locations.router import router +from app.locations.service import LocationMapService + + +NOW = datetime(2026, 8, 10, 12, 0, tzinfo=timezone.utc) +STATIC_ROOT = Path(__file__).parents[1] / "app" / "frontend" / "static" + + +def map_rows(): + site_a, site_b, room_a, room_b = uuid4(), uuid4(), uuid4(), uuid4() + miner_id, position_id, repair_id = uuid4(), uuid4(), uuid4() + return { + "sites": [{"id": site_a, "name": "North"}, {"id": site_b, "name": "South"}], + "rooms": [ + {"id": room_a, "site_id": site_a, "site_name": "North", "name": "Room 1", "sort_order": 1}, + {"id": room_b, "site_id": site_b, "site_name": "South", "name": "Room 1", "sort_order": 1}, + ], + "positions": [{ + "location_position_id": position_id, "site_id": site_a, + "site_name": "North", "room_id": room_a, "room_name": "Room 1", + "rack_name": "A", "shelf_name": "Top", "shelf_index": 1, + "position_index": 1, "position_label": "A-1", "position_kind": "miner_slot", + "position_status": "occupied", "occupied": True, + "expected_mac": "de:ad:be:ef:00:01", + "equipment_label": None, "notes": None, "source_file": None, + "source_cell": None, "conflict_reason": None, "miner_id": miner_id, + "asset_id": '', "current_mac": "aa:bb:cc:dd:ee:ff", + "current_ip": "192.0.2.10", "miner_status": "online", "hostname": "miner-a", + "miner_sn": "SERIAL-1", "power_sn": None, "model": "M50", + "miner_type": "WhatsMiner", "firmware_version": "1.0", + "lifecycle_status": "in_repair", "last_seen_at": NOW, + "asset_state": "shelved", "observed_at": NOW, "hashrate_ths": 120.5, + "power_w": 3320.0, "chip_temp_max_c": 71.0, "chip_temp_avg_c": 66.0, + "hash_stable": True, "pool_rejected_percent": 0.2, + "active_repair_id": repair_id, "active_repair_status": "OPEN", + "active_repair_reason": "CONTROL_BOARD", + }, { + "location_position_id": uuid4(), "site_id": site_a, "site_name": "North", + "room_id": room_a, "room_name": "Room 1", "rack_name": "A", + "shelf_name": "Top", "shelf_index": 1, "position_index": 2, + "position_label": "A-2", "position_kind": "miner_slot", + "position_status": "available", "occupied": False, + "active_repair_id": None, "active_repair_status": None, + "active_repair_reason": None, + }], + "virtual_miners": [], + } + + +class FakeLocationRepository: + def __init__(self): + self.rows = map_rows() + self.calls = [] + + def read_map(self, *, site_id=None, room_id=None): + self.calls.append((site_id, room_id)) + return self.rows + + +class MineMapServiceTests(unittest.TestCase): + def test_service_builds_typed_bulk_map_with_repair_and_empty_position(self): + repository = FakeLocationRepository() + response = LocationMapService(repository).get_map(include_miner_details=True) + self.assertEqual(len(repository.calls), 1) + self.assertEqual(response.data.rooms[0].name, response.data.rooms[1].name) + self.assertNotEqual(response.data.rooms[0].site_id, response.data.rooms[1].site_id) + self.assertTrue(response.data.positions[0].occupied) + self.assertEqual(response.data.positions[0].active_repair.status, "OPEN") + self.assertFalse(response.data.positions[1].occupied) + self.assertEqual(response.data.positions[0].hashrate_ths, 120.5) + + def test_location_only_user_receives_layout_without_miner_details(self): + response = LocationMapService(FakeLocationRepository()).get_map( + include_miner_details=False + ) + position = response.data.positions[0] + self.assertTrue(position.occupied) + self.assertIsNone(position.miner_id) + self.assertIsNone(position.asset_id) + self.assertIsNone(position.expected_mac) + for field in ("current_mac", "current_ip", "miner_status", "hostname", + "miner_sn", "power_sn", "model", "miner_type", + "firmware_version", "lifecycle_status", "last_seen_at", + "asset_state", "observed_at", "hashrate_ths", "power_w", + "chip_temp_max_c", "chip_temp_avg_c", "hash_stable", + "pool_rejected_percent"): + self.assertIsNone(getattr(position, field), field) + self.assertIsNone(position.active_repair) + self.assertEqual(response.data.virtual_miners, []) + self.assertFalse(response.meta.miner_details_included) + + + def test_service_preserves_legacy_position_without_assigning_durable_scope(self): + repository = FakeLocationRepository() + legacy = dict(repository.rows["positions"][1]) + legacy.update({ + "location_position_id": uuid4(), "site_id": None, "site_name": None, + "room_id": None, "room_name": "Imported Room", "rack_name": "A", + "shelf_name": "Top", "position_index": 3, + }) + repository.rows["positions"].append(legacy) + response = LocationMapService(repository).get_map(include_miner_details=True) + position = response.data.positions[-1] + self.assertIsNone(position.site_id) + self.assertIsNone(position.room_id) + self.assertEqual(position.room_name, "Imported Room") + + +class StubMapService: + def __init__(self): + self.repository = FakeLocationRepository() + self.service = LocationMapService(self.repository) + self.include = None + + def get_map(self, **kwargs): + self.include = kwargs["include_miner_details"] + return self.service.get_map(**kwargs) + + +class MineMapRouterTests(unittest.TestCase): + def setUp(self): + self.service = StubMapService() + self.app = FastAPI() + self.app.include_router(router) + self.app.dependency_overrides[get_location_map_service] = lambda: self.service + self.client = TestClient(self.app) + + def tearDown(self): + self.client.close() + + def authenticate(self, permissions): + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), "role": "Custom", "permissions": list(permissions) + } + + def test_map_requires_authentication_and_locations_permission(self): + self.assertEqual(self.client.get("/api/location/map").status_code, 401) + self.authenticate(("miners.view",)) + self.assertEqual(self.client.get("/api/location/map").status_code, 403) + + def test_map_returns_typed_envelope_and_permission_aware_miner_shape(self): + self.authenticate(("locations.view", "miners.view")) + response = self.client.get("/api/location/map") + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["meta"]["miner_details_included"]) + self.assertEqual(response.json()["data"]["positions"][0]["asset_id"], + '') + self.assertIsNone(response.json()["error"]) + self.authenticate(("locations.view",)) + response = self.client.get("/api/location/map") + redacted = response.json()["data"]["positions"][0] + self.assertIsNone(redacted["miner_id"]) + self.assertIsNone(redacted["expected_mac"]) + self.assertIsNone(redacted["active_repair"]) + self.assertEqual(response.json()["data"]["virtual_miners"], []) + self.assertFalse(response.json()["meta"]["miner_details_included"]) + + def test_uuid_filters_are_validated_and_forwarded(self): + self.authenticate(("locations.view", "miners.view")) + site_id, room_id = uuid4(), uuid4() + response = self.client.get( + "/api/location/map", params={"site_id": site_id, "room_id": room_id} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.service.repository.calls[-1], (site_id, room_id)) + self.assertEqual(self.client.get("/api/location/map?site_id=bad").status_code, 422) + + def test_router_has_no_sql_or_database_driver_imports(self): + path = Path(__file__).parents[1] / "app" / "locations" / "router.py" + source = path.read_text(encoding="utf-8") + modules = [] + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Import): modules.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: modules.append(node.module) + self.assertFalse(any(name.startswith(("psycopg", "sqlalchemy", "asyncpg")) for name in modules)) + for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE "): + self.assertNotIn(keyword, source.upper()) + + +class MineMapRepositoryIntegrationTests(unittest.TestCase): + def test_set_based_query_scopes_same_named_rooms_by_uuid_and_site(self): + connection = psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + ) + try: + repository = LocationMapRepository(connection=connection) + site_a, site_b, room_a, room_b = uuid4(), uuid4(), uuid4(), uuid4() + miner_id = uuid4() + room_name = "Map Same Name " + uuid4().hex[:8] + with connection.cursor() as cur: + cur.execute( + "INSERT INTO sites (id, name) VALUES (%s, %s), (%s, %s)", + (site_a, "Map North " + uuid4().hex[:8], + site_b, "Map South " + uuid4().hex[:8]), + ) + cur.execute( + "INSERT INTO rooms (id, site_id, name, sort_order) " + "VALUES (%s, %s, %s, 1), (%s, %s, %s, 1)", + (room_a, site_a, room_name, room_b, site_b, room_name), + ) + cur.execute( + "INSERT INTO miners (id, site_id, room_id, asset_tag, status, " + "lifecycle_status) VALUES (%s, %s, %s, %s, 'online', 'active')", + (miner_id, site_a, room_a, "MAP-" + uuid4().hex[:8]), + ) + cur.execute( + "INSERT INTO location_positions " + "(room_id, room_name, rack_name, shelf_name, shelf_index, " + "position_index, position_kind, position_status, miner_id) " + "VALUES (%s, %s, 'A', '1', 1, 1, 'miner_slot', 'occupied', %s), " + "(%s, %s, 'B', '1', 1, 1, 'miner_slot', 'available', NULL)", + (room_a, room_name, miner_id, room_b, room_name), + ) + cur.execute( + "INSERT INTO miner_status_snapshots " + "(miner_id, mhs_av, power_w, chip_temp_max_c, hash_stable) " + "VALUES (%s, 120000000, 3300, 70, true)", + (miner_id,), + ) + result = repository.read_map(site_id=site_a, room_id=room_a) + self.assertEqual(len(result["positions"]), 1) + row = result["positions"][0] + self.assertEqual(row["site_id"], site_a) + self.assertEqual(row["room_id"], room_a) + self.assertTrue(row["occupied"]) + self.assertEqual(row["hashrate_ths"], 120.0) + matching_rooms = [room for room in result["rooms"] if room["name"] == room_name] + self.assertEqual({room["site_id"] for room in matching_rooms}, {site_a, site_b}) + finally: + connection.rollback() + connection.close() + + +class DummyCursor: + def __enter__(self): return self + def __exit__(self, *args): return False + def execute(self, *args): return None + def fetchall(self): return [] + + +class DummyConnection: + def cursor(self, **kwargs): return DummyCursor() + def close(self): return None + + +class StatusSnapshotPermissionTests(unittest.TestCase): + def setUp(self): + from app.main import list_miner_status_snapshots + self.app = FastAPI() + self.app.get("/api/miners/{miner_id}/status-snapshots")( + list_miner_status_snapshots + ) + self.client = TestClient(self.app) + + def tearDown(self): + self.app.dependency_overrides.pop(get_current_user, None) + self.client.close() + + def test_status_snapshots_require_authentication_permission_and_allow_reader(self): + miner_id = uuid4() + self.assertEqual(self.client.get(f"/api/miners/{miner_id}/status-snapshots").status_code, 401) + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), "role": "Custom", "permissions": ["locations.view"] + } + self.assertEqual(self.client.get(f"/api/miners/{miner_id}/status-snapshots").status_code, 403) + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), "role": "Custom", "permissions": ["miners.view"] + } + with patch("app.main.get_db_connection", return_value=DummyConnection()): + response = self.client.get(f"/api/miners/{miner_id}/status-snapshots") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"], []) + + def test_established_roles_and_custom_roles_follow_miners_view_permission(self): + miner_id = uuid4() + allowed = (("Admin", []), ("Manager", ["miners.view"]), + ("Technician", ["miners.view"]), ("Read Only", ["miners.view"]), + ("Custom", ["miners.view"])) + with patch("app.main.get_db_connection", return_value=DummyConnection()): + for role, permissions in allowed: + with self.subTest(role=role): + self.app.dependency_overrides[get_current_user] = lambda r=role, p=permissions: { + "id": str(uuid4()), "role": r, "permissions": p + } + response = self.client.get(f"/api/miners/{miner_id}/status-snapshots") + self.assertEqual(response.status_code, 200) + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), "role": "Custom", "permissions": [] + } + self.assertEqual( + self.client.get(f"/api/miners/{miner_id}/status-snapshots").status_code, 403 + ) + + +class MineMapFrontendAuditTests(unittest.TestCase): + def test_map_module_uses_one_bulk_endpoint_and_safe_dom(self): + source = (STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8") + self.assertEqual(source.count('fetch("/api/location/map"'), 1) + self.assertNotIn("/api/inventory/miners", source) + self.assertNotIn("status-snapshots", source) + for unsafe in ("innerHTML", "outerHTML", "insertAdjacentHTML", + "document.write", "eval(", "Function("): + self.assertNotIn(unsafe, source) + self.assertIn("textContent", source) + self.assertIn("encodeURIComponent(position.miner_id)", source) + self.assertIn('window.addEventListener("popstate"', source) + self.assertIn('el.mapWorkspace.addEventListener("click"', source) + self.assertIn('el.mapWorkspace.addEventListener("keydown"', source) + self.assertIn('tile.setAttribute("aria-pressed"', source) + self.assertIn("syncTileTabStops", source) + self.assertIn("renderLegacyPositions", source) + self.assertIn("minerDetailsIncluded", source) + self.assertIn("mapProblemsOnly", source) + + def test_map_has_factual_status_rules_without_thresholds_or_mutation(self): + source = (STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8") + for invented in ("> 90", ">= 90", "staleMinutes", "degradedThreshold"): + self.assertNotIn(invented, source) + self.assertIn('runtime === "offline"', source) + self.assertIn("position.hash_stable === false", source) + self.assertNotIn('method: "POST"', source) + self.assertNotIn('method: "PATCH"', source) + self.assertNotIn('method: "DELETE"', source) + self.assertIn('if (!state.minerDetailsIncluded)', source) + self.assertIn('Number.isFinite(value)', source) + + def test_problems_and_measurement_layers_use_factual_relative_values(self): + source = (STATIC_ROOT / "mine-map.js").read_text(encoding="utf-8") + css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8") + self.assertIn("const PROBLEM_KEYS = new Set", source) + for state in ("offline", "unknown", "unstable", "repair"): + self.assertIn(f'"{state}"', source) + self.assertIn("state.problemsOnly", source) + self.assertIn('"--relative-percent"', source) + self.assertIn("Relative range", source) + self.assertIn('data-problems-muted="true"', css) + self.assertIn(".relative-scale-track", css) + + def test_map_repository_uses_constant_set_based_queries(self): + source = (Path(__file__).parents[1] / "app" / "locations" / + "repository.py").read_text(encoding="utf-8") + + read_map_source = source.split(" def read_map(", 1)[1].split( + " def summarize_locations(", 1 + )[0] + + latest_telemetry_sql = source.split( + 'LATEST_TELEMETRY_SQL = """', 1 + )[1].split('"""', 1)[0] + + active_repair_sql = source.split( + 'ACTIVE_REPAIR_SQL = """', 1 + )[1].split('"""', 1)[0] + + self.assertEqual(read_map_source.count("cur.execute("), 4) + self.assertEqual( + read_map_source.count("{LATEST_TELEMETRY_SQL}"), + 2, + ) + self.assertEqual( + read_map_source.count("{ACTIVE_REPAIR_SQL}"), + 2, + ) + + self.assertIn("LEFT JOIN LATERAL", latest_telemetry_sql) + self.assertIn("LEFT JOIN LATERAL", active_repair_sql) + + self.assertIn( + "ORDER BY snapshots.observed_at DESC, snapshots.id DESC", + latest_telemetry_sql, + ) + self.assertIn( + "ORDER BY repairs.opened_at, repairs.id", + active_repair_sql, + ) + + self.assertNotIn("for miner", read_map_source) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_miner_detail.py b/backend/tests/test_miner_detail.py new file mode 100644 index 0000000..d6679d3 --- /dev/null +++ b/backend/tests/test_miner_detail.py @@ -0,0 +1,636 @@ +import ast +import re +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import patch +from uuid import UUID, uuid4 + +import psycopg +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.config import settings +from app.frontend.router import miner_detail_page, router as frontend_router +from app.identity import get_current_user +from app.inventory.exceptions import MinerNotFound +from app.inventory.models import InventoryMinerDetail +from app.inventory.repository import InventoryRepository +from app.inventory.service import InventoryService +from app.locations.models import MinerLocationDetail +from app.locations.repository import LocationMapRepository +from app.miner_detail.dependencies import get_miner_detail_service +from app.miner_detail.models import MinerDetailAccess +from app.miner_detail.router import router as detail_router +from app.miner_detail.service import MinerDetailService +from app.repairs.center_repository import RepairCenterRepository +from app.repairs.models import RepairCenterCapabilities, RepairCenterItem + + +NOW = datetime(2026, 8, 17, 12, 0, tzinfo=timezone.utc) +APP_ROOT = Path(__file__).parents[1] / "app" +STATIC_ROOT = APP_ROOT / "frontend" / "static" + + +def current_miner(**updates): + values = { + "id": uuid4(), + "asset_id": "MOP-001", + "current_mac": "aa:bb:cc:dd:ee:ff", + "current_ip": "192.0.2.10", + "model": "M50", + "miner_sn": "MINER-SN", + "lifecycle_status": "active", + "runtime_status": "online", + "site_id": uuid4(), + "site_name": "Restricted Site", + "room_id": uuid4(), + "room_name": "Restricted Room", + "location_position_id": uuid4(), + "rack_label": "Rack A", + "shelf_label": "Shelf 1", + "position_label": "A-01", + "position_index": 1, + "observed_at": NOW, + "hashrate_ths": 0.0, + "power_w": 0.0, + "chip_temp_max_c": 0.0, + "hash_stable": None, + "tags": [], + "created_at": NOW - timedelta(days=10), + "updated_at": NOW, + "last_seen_at": NOW, + "hostname": "miner-001", + "control_board_sn": "CB-SN", + "psu_serial": "PSU-SN", + "firmware_version": "1.2.3", + "notes": None, + "first_seen_at": NOW - timedelta(days=9), + } + values.update(updates) + return InventoryMinerDetail(**values) + + +def location(): + return MinerLocationDetail( + site_id=uuid4(), + site_name="North Site", + room_id=uuid4(), + room_name="Room 1", + position_id=uuid4(), + rack_label="Rack A", + shelf_label="Shelf 1", + position_label="A-01", + position_index=1, + ) + + +def repair_item(*, source="modular", active=True): + return RepairCenterItem( + repair_id=uuid4() if source == "modular" else None, + source=source, + active=active, + opened_at=NOW - timedelta(hours=2), + repair_status="OPEN" if active else "COMPLETED", + reason="CONTROL_BOARD" if source == "modular" else None, + latest_context="Board diagnostics", + latest_context_at=NOW, + miner_id=uuid4(), + lifecycle_status="in_repair" if active else "active", + parts_count=1 if source == "modular" else None, + parts_summary=["control board"] if source == "modular" else [], + capabilities=RepairCenterCapabilities(), + ) + + +class FakeInventoryService: + def __init__(self, miner=None, *, missing=False): + self.miner = miner or current_miner() + self.missing = missing + self.calls = [] + + def get_current_miner(self, miner_id): + self.calls.append(miner_id) + if self.missing: + raise MinerNotFound(miner_id) + return self.miner + + +class FakeLocationService: + def __init__(self, value=None): + self.value = value + self.calls = [] + + def get_miner_location(self, miner_id): + self.calls.append(miner_id) + return self.value + + +class FakeRepairService: + def __init__(self, values=None): + self.values = list(values or []) + self.calls = [] + + def list_miner_repairs(self, miner_id, access): + self.calls.append((miner_id, access)) + return self.values + + +class MinerDetailServiceTests(unittest.TestCase): + def build(self, *, miner=None, mapped=None, repairs=None, missing=False): + self.inventory = FakeInventoryService(miner, missing=missing) + self.locations = FakeLocationService(mapped) + self.repairs = FakeRepairService(repairs) + return MinerDetailService(self.inventory, self.locations, self.repairs) + + def test_full_access_uses_three_domain_queries_and_preserves_zero(self): + miner_id = uuid4() + mapped = location() + active = repair_item() + service = self.build(mapped=mapped, repairs=[active]) + response = service.get_overview( + miner_id, + MinerDetailAccess( + can_view_locations=True, + can_read_repairs=True, + can_view_repairs=True, + can_read_components=True, + ), + ) + self.assertEqual(self.inventory.calls, [miner_id]) + self.assertEqual(self.locations.calls, [miner_id]) + self.assertEqual(len(self.repairs.calls), 1) + self.assertEqual(response.meta.database_query_count, 3) + self.assertEqual(response.data.miner.hashrate_ths, 0) + self.assertEqual(response.data.miner.power_w, 0) + self.assertEqual(response.data.miner.chip_temp_max_c, 0) + self.assertIsNone(response.data.miner.hash_stable) + self.assertEqual(response.data.location.location, mapped) + self.assertEqual(response.data.repair.active_repair.repair_id, active.repair_id) + self.assertEqual(response.data.operational_state, "repair") + self.assertTrue(response.data.capabilities.can_view_map) + self.assertTrue(response.data.capabilities.can_open_repair_center) + + def test_restricted_domains_are_not_queried_and_location_never_leaks(self): + service = self.build(mapped=location(), repairs=[repair_item()]) + response = service.get_overview(uuid4(), MinerDetailAccess()) + self.assertEqual(response.meta.database_query_count, 1) + self.assertEqual(self.locations.calls, []) + self.assertEqual(self.repairs.calls, []) + self.assertTrue(response.data.location.restricted) + self.assertIsNone(response.data.location.location) + self.assertTrue(response.data.repair.restricted) + self.assertEqual(response.data.repair.episodes, []) + miner = response.data.miner + for field in ( + "site_id", "site_name", "room_id", "room_name", + "location_position_id", "rack_label", "shelf_label", + "position_label", "position_index", + ): + self.assertIsNone(getattr(miner, field), field) + serialized = response.model_dump_json() + self.assertNotIn("Restricted Site", serialized) + self.assertNotIn("Restricted Room", serialized) + + def test_no_repair_and_legacy_repair_are_truthfully_distinct(self): + no_repair = self.build().get_overview( + uuid4(), MinerDetailAccess(can_read_repairs=True) + ) + self.assertTrue(no_repair.data.repair.available) + self.assertFalse(no_repair.data.repair.restricted) + self.assertIsNone(no_repair.data.repair.active_repair) + self.assertEqual(no_repair.data.repair.episodes, []) + + legacy = repair_item(source="legacy") + legacy_response = self.build(repairs=[legacy]).get_overview( + uuid4(), MinerDetailAccess(can_read_repairs=True) + ) + self.assertIsNone(legacy_response.data.repair.active_repair) + self.assertEqual(legacy_response.data.repair.episodes[0].source, "legacy") + self.assertIsNone(legacy_response.data.repair.episodes[0].repair_id) + + def test_unscoped_legacy_position_is_truthful_but_not_map_linkable(self): + unscoped = location().model_copy( + update={"site_id": None, "site_name": None, "room_id": None} + ) + response = self.build(mapped=unscoped).get_overview( + uuid4(), MinerDetailAccess(can_view_locations=True) + ) + self.assertTrue(response.data.location.available) + self.assertEqual(response.data.location.location.position_label, "A-01") + self.assertFalse(response.data.capabilities.can_view_map) + + def test_factual_state_uses_no_temperature_or_age_threshold(self): + cases = ( + ({"runtime_status": "offline", "chip_temp_max_c": 99.0}, "offline"), + ({"runtime_status": "online", "hash_stable": False}, "unstable"), + ({"runtime_status": "online", "observed_at": NOW - timedelta(days=90)}, "online"), + ({"runtime_status": "unknown", "chip_temp_max_c": 1000.0}, "unknown"), + ({"runtime_status": "online", "lifecycle_status": "spare"}, "spare"), + ({"runtime_status": "online", "lifecycle_status": "retired"}, "retired"), + ) + for updates, expected in cases: + with self.subTest(expected=expected): + response = self.build(miner=current_miner(**updates)).get_overview( + uuid4(), MinerDetailAccess() + ) + self.assertEqual(response.data.operational_state, expected) + + response = self.build( + miner=current_miner(hashrate_ths=float("nan")) + ).get_overview(uuid4(), MinerDetailAccess()) + self.assertIsNone(response.data.miner.hashrate_ths) + self.assertEqual(response.data.operational_state, "online") + + def test_unknown_miner_propagates_domain_not_found_without_other_queries(self): + service = self.build(missing=True, mapped=location(), repairs=[repair_item()]) + with self.assertRaises(MinerNotFound): + service.get_overview( + uuid4(), + MinerDetailAccess(can_view_locations=True, can_read_repairs=True), + ) + self.assertEqual(self.locations.calls, []) + self.assertEqual(self.repairs.calls, []) + + +class StubMinerDetailService: + def __init__(self, *, missing=False): + self.missing = missing + self.calls = [] + self.delegate = MinerDetailService( + FakeInventoryService(), + FakeLocationService(location()), + FakeRepairService([repair_item()]), + ) + + def get_overview(self, miner_id, access): + self.calls.append((miner_id, access)) + if self.missing: + raise MinerNotFound(miner_id) + return self.delegate.get_overview(miner_id, access) + + +class MinerDetailRouterTests(unittest.TestCase): + def setUp(self): + self.service = StubMinerDetailService() + self.app = FastAPI() + self.app.include_router(detail_router) + self.app.dependency_overrides[get_miner_detail_service] = lambda: self.service + self.client = TestClient(self.app) + + def tearDown(self): + self.client.close() + + def authenticate(self, permissions): + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), + "role": "Custom", + "permissions": list(permissions), + } + + def test_requires_authentication_and_miners_view(self): + path = f"/api/miners/{uuid4()}/overview" + self.assertEqual(self.client.get(path).status_code, 401) + self.authenticate(("locations.view", "repairs.read")) + self.assertEqual(self.client.get(path).status_code, 403) + + def test_returns_typed_permission_aware_no_store_envelope(self): + miner_id = uuid4() + self.authenticate(( + "miners.view", "locations.view", "repairs.read", + "repairs.view", "components.read", + )) + response = self.client.get(f"/api/miners/{miner_id}/overview") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["cache-control"], "no-store") + payload = response.json() + self.assertEqual(payload["data"]["miner"]["id"], str(self.service.delegate.inventory_service.miner.id)) + self.assertEqual(payload["meta"]["database_query_count"], 3) + self.assertIsNone(payload["error"]) + _, access = self.service.calls[-1] + self.assertTrue(access.can_view_locations) + self.assertTrue(access.can_read_repairs) + self.assertTrue(access.can_view_repairs) + self.assertTrue(access.can_read_components) + + def test_malformed_and_unknown_uuid_use_existing_validation_and_404(self): + self.authenticate(("miners.view",)) + self.assertEqual(self.client.get("/api/miners/not-a-uuid/overview").status_code, 422) + self.service.missing = True + response = self.client.get(f"/api/miners/{uuid4()}/overview") + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["detail"], "Miner not found") + + +class MinerDetailRepositoryIntegrationTests(unittest.TestCase): + def setUp(self): + self.connection = psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + ) + + def tearDown(self): + self.connection.rollback() + self.connection.close() + + def test_inventory_and_location_projections_use_latest_factual_rows(self): + miner_id, site_id, room_id = uuid4(), uuid4(), uuid4() + earlier = NOW - timedelta(hours=1) + with self.connection.cursor() as cur: + cur.execute("INSERT INTO sites (id, name) VALUES (%s, 'Detail Site')", (site_id,)) + cur.execute( + "INSERT INTO rooms (id, site_id, name) VALUES (%s, %s, 'Detail Room')", + (room_id, site_id), + ) + cur.execute( + "INSERT INTO miners (id, asset_tag, current_mac, current_ip, model, " + "status, lifecycle_status, first_seen_at, last_seen_at) " + "VALUES (%s, %s, 'aa:bb:cc:00:00:01', '192.0.2.22', 'M50', " + "'online', 'active', NULL, NULL)", + (miner_id, "DETAIL-" + uuid4().hex[:8]), + ) + cur.execute( + "INSERT INTO location_positions (room_id, room_name, rack_name, " + "shelf_name, position_index, position_label, miner_id) " + "VALUES (%s, 'Detail Room', 'Rack A', 'Shelf 1', 2, 'A-02', %s)", + (room_id, miner_id), + ) + cur.execute( + "INSERT INTO miner_status_snapshots " + "(id, miner_id, observed_at, mhs_av, power_w, chip_temp_max_c, hash_stable) " + "VALUES (%s, %s, %s, 120000000, 3300, 70, true), " + "(%s, %s, %s, 0, 0, 0, NULL)", + (uuid4(), miner_id, earlier, uuid4(), miner_id, NOW), + ) + inventory = InventoryService( + InventoryRepository(connection=self.connection) + ).get_current_miner(miner_id) + self.assertEqual(inventory.hashrate_ths, 0) + self.assertEqual(inventory.power_w, 0) + self.assertEqual(inventory.chip_temp_max_c, 0) + self.assertIsNone(inventory.hash_stable) + self.assertEqual(inventory.observed_at, NOW) + placement = LocationMapRepository( + connection=self.connection + ).get_miner_location(miner_id) + self.assertEqual(placement["site_id"], site_id) + self.assertEqual(placement["room_id"], room_id) + self.assertEqual(placement["rack_label"], "Rack A") + self.assertIsInstance(placement["position_id"], UUID) + self.assertEqual(RepairCenterRepository().list_miner_repairs(miner_id), []) + + +class DummyCursor: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def execute(self, *args): + return None + + def fetchall(self): + return [] + + +class DummyConnection: + def cursor(self, **kwargs): + return DummyCursor() + + def close(self): + return None + + +class NetworkIdentityPermissionTests(unittest.TestCase): + def setUp(self): + from app.main import list_miner_network_identities + + self.app = FastAPI() + self.app.get("/api/miners/{miner_id}/network-identities")( + list_miner_network_identities + ) + self.client = TestClient(self.app) + + def tearDown(self): + self.client.close() + + def test_network_history_requires_miners_view(self): + path = f"/api/miners/{uuid4()}/network-identities" + self.assertEqual(self.client.get(path).status_code, 401) + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), "role": "Custom", "permissions": ["locations.view"] + } + self.assertEqual(self.client.get(path).status_code, 403) + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), "role": "Custom", "permissions": ["miners.view"] + } + with patch("app.main.get_db_connection", return_value=DummyConnection()): + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"], []) + + +class MinerDetailFrontendTests(unittest.TestCase): + def setUp(self): + self.app = FastAPI() + self.app.include_router(frontend_router) + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), + "username": "reader", + "display_name": "Detail Reader", + "role": "Read Only", + "permissions": [ + "miners.view", "locations.view", "repairs.read", "repairs.view" + ], + } + self.client = TestClient(self.app) + self.miner_id = uuid4() + + def tearDown(self): + self.client.close() + + def test_page_requires_permission_and_renders_only_internal_alpha_shell(self): + path = f"/miners/{self.miner_id}/detail" + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + for expected in ( + "Miner Detail | MinerOps", + 'data-view="miner_detail"', + f'data-miner-id="{self.miner_id}"', + "/assets/minerops-miner-detail.js", + 'id="minerIdentityDetails"', + 'id="minerRepairEpisodes"', + 'id="minerObservations"', + ): + self.assertIn(expected, response.text) + for legacy in ( + "minerops-miner-detail-extra", "minerops-miner-detail-clean-v3", + "minerops-location-management", "Manual Edit Missing Details", + "Reboot", "Performance Mode", + ): + self.assertNotIn(legacy, response.text) + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), "role": "Custom", "permissions": [] + } + self.assertEqual(self.client.get(path).status_code, 403) + + def test_return_to_accepts_protected_local_context_and_rejects_external(self): + path = f"/miners/{self.miner_id}/detail" + local = "/monitoring/map?site_id=" + str(uuid4()) + "&miner_id=" + str(self.miner_id) + response = self.client.get(path, params={"return_to": local}) + self.assertIn("data-return-to=", response.text) + self.assertIn("/monitoring/map?site_id=", response.text) + response = self.client.get( + path, params={"return_to": "https://example.invalid/steal"} + ) + self.assertNotIn("data-return-to=", response.text) + self.assertNotIn("example.invalid", response.text) + + def test_refresh_action_has_visible_name_and_keeps_existing_behavior(self): + response = self.client.get(f"/miners/{self.miner_id}/detail") + self.assertRegex( + response.text, + r'', + ) + source = (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8") + self.assertIn( + 'el.refresh.addEventListener("click", loadOverview);', + source, + ) + css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8") + self.assertIn(".miner-detail-actions .quiet-button { color: var(--ink); }", css) + + def test_live_route_precedence_and_middleware_isolation(self): + from app.main import app + + detail_handlers = [ + route.endpoint for route in app.routes + if route.path == "/miners/{miner_id}/detail" + and "GET" in (route.methods or set()) + ] + self.assertGreaterEqual(len(detail_handlers), 2) + self.assertIs(detail_handlers[0], miner_detail_page) + current_user = { + "id": str(uuid4()), "username": "reader", + "display_name": "Detail Reader", "role": "Read Only", + "permissions": ["miners.view"], + } + app.dependency_overrides[get_current_user] = lambda: current_user + try: + with patch("app.identity.request_user_or_none", return_value=current_user): + with TestClient(app) as client: + response = client.get(f"/miners/{self.miner_id}/detail") + finally: + app.dependency_overrides.pop(get_current_user, None) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.text.count('data-view="miner_detail"'), 1) + self.assertIn("/assets/minerops-miner-detail.js", response.text) + for legacy in ( + "/assets/minerops-miner-detail-extra.js", + "/assets/minerops-miner-detail-extra-v2.js", + "/assets/minerops-miner-detail-clean-v3.js", + "/assets/minerops-location-management.js", + ): + self.assertNotIn(legacy, response.text) + paths = {route.path for route in app.routes} + for legacy_api in ( + "/api/miners/{miner_id}", + "/api/miners/{miner_id}/detail-extra", + "/api/miners/{miner_id}/detail-clean-v3", + "/api/miners/{miner_id}/network-identities", + "/api/miners/{miner_id}/status-snapshots", + ): + self.assertIn(legacy_api, paths) + + def test_frontend_is_lazy_read_only_bounded_and_xss_safe(self): + source = (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8") + self.assertEqual( + source.count( + 'api("/api/miners/" + encodeURIComponent(minerId) + "/overview")' + ), + 1, + ) + self.assertEqual(source.count("Promise.all(["), 2) + self.assertIn("/asset-id-history", source) + self.assertIn("/network-identities", source) + self.assertIn("/status-snapshots", source) + self.assertIn("/events", source) + self.assertIn("/parts", source) + self.assertNotIn("setInterval", source) + for mutation in ( + 'method: "POST"', 'method: "PATCH"', 'method: "PUT"', + 'method: "DELETE"', "reboot", "change-mac", "manual-details", + ): + self.assertNotIn(mutation, source) + for unsafe in ("innerHTML", "outerHTML", "insertAdjacentHTML", "document.write"): + self.assertNotIn(unsafe, source) + self.assertIn("textContent", source) + self.assertIn("new URLSearchParams", source) + self.assertIn("encodeURIComponent", source) + self.assertNotRegex( + source.lower(), + r"(temperature|hashrate|stale).{0,40}(threshold|>=\s*90|>\s*90)", + ) + main_source = (APP_ROOT / "main.py").read_text(encoding="utf-8") + snapshot = re.search( + r'def list_miner_status_snapshots\(.*?LIMIT 100;', + main_source, + re.DOTALL, + ) + self.assertIsNotNone(snapshot) + + def test_cross_navigation_preserves_context_with_durable_uuid(self): + fleet = (STATIC_ROOT / "fleet.js").read_text(encoding="utf-8") + repairs = (STATIC_ROOT / "repair-center.js").read_text(encoding="utf-8") + for source in (fleet, repairs): + self.assertIn('return_to', source) + self.assertIn("window.location.pathname + window.location.search", source) + self.assertIn("encodeURIComponent", source) + detail = (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8") + self.assertIn('new URLSearchParams({ miner_id: minerIdValue })', detail) + self.assertIn('params.set("site_id", location.site_id)', detail) + self.assertIn('params.set("room_id", location.room_id)', detail) + self.assertIn('new URLSearchParams({ scope: "all", q: data.miner.id })', detail) + + +class MinerDetailArchitectureTests(unittest.TestCase): + def test_router_and_service_preserve_dependency_boundaries(self): + for relative in ("miner_detail/router.py", "miner_detail/service.py"): + source = (APP_ROOT / relative).read_text(encoding="utf-8") + modules = [] + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Import): + modules.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + modules.append(node.module) + self.assertFalse( + any(name.startswith(("psycopg", "sqlalchemy", "asyncpg")) + for name in modules) + ) + for keyword in ("SELECT ", "INSERT ", "UPDATE ", "DELETE "): + self.assertNotIn(keyword, source.upper()) + service_source = (APP_ROOT / "miner_detail" / "service.py").read_text( + encoding="utf-8" + ) + self.assertNotIn("fastapi", service_source.lower()) + + def test_exactly_one_new_api_route_and_no_state_changing_page_get(self): + routes = [ + (route.path, route.methods) + for route in detail_router.routes + ] + self.assertEqual(routes, [("/api/miners/{miner_id}/overview", {"GET"})]) + page = next( + route for route in frontend_router.routes + if route.path == "/miners/{miner_id}/detail" + ) + self.assertEqual(page.methods, {"GET"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_monitoring_trends.py b/backend/tests/test_monitoring_trends.py new file mode 100644 index 0000000..075cd06 --- /dev/null +++ b/backend/tests/test_monitoring_trends.py @@ -0,0 +1,448 @@ +import ast +import time +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from uuid import uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient +import psycopg + +from app.config import settings +from app.identity import get_current_user, protected_page, required_page_permission +from app.monitoring.dependencies import get_monitoring_service +from app.monitoring.frontend import router as monitoring_frontend_router +from app.monitoring.models import MonitoringTrendQuery +from app.monitoring.repository import MonitoringRepository, TREND_SQL +from app.monitoring.router import router as monitoring_router +from app.monitoring.service import MonitoringService + + +NOW = datetime(2026, 8, 17, 12, 2, tzinfo=timezone.utc) +APP_ROOT = Path(__file__).parents[1] / "app" +STATIC_ROOT = APP_ROOT / "frontend" / "static" + + +def trend_rows(count=72, *, scope_exists=True, label="Fleet"): + start = datetime(2026, 8, 17, 6, 5, tzinfo=timezone.utc) + rows = [] + for index in range(count): + bucket_start = start + timedelta(minutes=5 * index) + rows.append({ + "scope_exists": scope_exists, + "scope_label": label, + "bucket_start": bucket_start, + "bucket_end": bucket_start + timedelta(minutes=5), + "hashrate_ths": 0 if index == 0 else None, + "power_w": 0 if index == 0 else None, + "observation_count": 1 if index < 2 else 0, + "hashrate_sample_count": 1 if index == 0 else 0, + "power_sample_count": 1 if index == 0 else 0, + "observed_miner_count": 1 if index < 2 else 0, + "hashrate_contributing_miner_count": 1 if index == 0 else 0, + "power_contributing_miner_count": 1 if index == 0 else 0, + "scoped_miner_count": 2, + "latest_observed_at": start + timedelta(minutes=1), + }) + return rows + + +class FakeMonitoringRepository: + def __init__(self, rows=None): + self.rows = rows or trend_rows() + self.trend_calls = [] + self.option_calls = 0 + + def read_trends(self, **kwargs): + self.trend_calls.append(kwargs) + return self.rows + + def list_location_scope_options(self): + self.option_calls += 1 + site_id, room_id = uuid4(), uuid4() + return [ + {"option_type": "site", "id": site_id, "site_id": None, + "name": ''}, + {"option_type": "room", "id": room_id, "site_id": site_id, + "name": "Room 1"}, + ] + + +class MonitoringServiceTests(unittest.TestCase): + def test_fixed_ranges_emit_expected_bounded_points(self): + expected = {"6h": (72, 300), "24h": (96, 900), "7d": (168, 3600)} + for range_name, (count, seconds) in expected.items(): + with self.subTest(range_name=range_name): + rows = trend_rows(count) + repository = FakeMonitoringRepository(rows) + response = MonitoringService(repository).get_trends( + MonitoringTrendQuery(scope="fleet", range=range_name), + include_location_scopes=False, + generated_at=NOW, + ) + self.assertEqual(len(response.data.series), 2) + self.assertEqual(len(response.data.series[0].points), count) + self.assertLessEqual(count, response.meta.maximum_points) + self.assertEqual(response.meta.bucket_seconds, seconds) + self.assertEqual(response.meta.database_query_count, 1) + + def test_zero_null_and_empty_bucket_semantics_remain_distinct(self): + response = MonitoringService(FakeMonitoringRepository()).get_trends( + MonitoringTrendQuery(scope="fleet", range="6h"), + include_location_scopes=False, + generated_at=NOW, + ) + points = response.data.series[0].points + self.assertEqual(points[0].value, 0) + self.assertEqual(points[0].metric_sample_count, 1) + self.assertIsNone(points[1].value) + self.assertEqual(points[1].observation_count, 1) + self.assertEqual(points[1].metric_sample_count, 0) + self.assertIsNone(points[2].value) + self.assertEqual(points[2].observation_count, 0) + + def test_location_options_and_attribution_are_permission_aware(self): + repository = FakeMonitoringRepository(trend_rows(label="North Site")) + response = MonitoringService(repository).get_trends( + MonitoringTrendQuery(scope="site", scope_id=uuid4(), range="6h"), + include_location_scopes=True, + generated_at=NOW, + ) + self.assertEqual(response.meta.attribution, "current_placement_cohort") + self.assertEqual(response.meta.database_query_count, 2) + self.assertEqual(repository.option_calls, 1) + self.assertEqual(len(response.data.available_scopes.sites), 1) + repository = FakeMonitoringRepository() + response = MonitoringService(repository).get_trends( + MonitoringTrendQuery(scope="fleet", range="6h"), + include_location_scopes=False, + generated_at=NOW, + ) + self.assertEqual(response.data.available_scopes.sites, []) + self.assertEqual(repository.option_calls, 0) + + +class MonitoringRouterTests(unittest.TestCase): + def setUp(self): + self.repository = FakeMonitoringRepository() + self.service = MonitoringService(self.repository) + self.app = FastAPI() + self.app.include_router(monitoring_router) + self.app.dependency_overrides[get_monitoring_service] = lambda: self.service + self.client = TestClient(self.app) + + def tearDown(self): + self.client.close() + + def authenticate(self, permissions): + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), "role": "Custom", "permissions": list(permissions) + } + + def test_authentication_permission_and_location_scope_boundaries(self): + self.assertEqual(self.client.get("/api/monitoring/trends").status_code, 401) + self.authenticate(()) + self.assertEqual(self.client.get("/api/monitoring/trends").status_code, 403) + self.authenticate(("miners.view",)) + self.assertEqual(self.client.get("/api/monitoring/trends").status_code, 200) + response = self.client.get( + "/api/monitoring/trends", + params={"scope": "site", "scope_id": uuid4(), "range": "6h"}, + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(self.repository.trend_calls[-1]["scope"], "fleet") + + def test_typed_validation_rejects_unbounded_or_ambiguous_queries(self): + self.authenticate(("miners.view", "locations.view")) + cases = ( + {"scope": "fleet", "scope_id": uuid4()}, + {"scope": "miner"}, + {"scope": "miner", "scope_id": "bad"}, + {"scope": "fleet", "range": "30d"}, + {"scope": "anything"}, + {"scope": "fleet", "resolution": "1 second"}, + ) + for query in cases[:-1]: + with self.subTest(query=query): + self.assertEqual( + self.client.get("/api/monitoring/trends", params=query).status_code, + 422, + ) + response = self.client.get("/api/monitoring/trends", params=cases[-1]) + self.assertEqual(response.status_code, 200) + self.assertNotIn("resolution", self.repository.trend_calls[-1]) + + def test_typed_envelope_returns_both_series_and_no_store(self): + self.authenticate(("miners.view",)) + response = self.client.get("/api/monitoring/trends?scope=fleet&range=6h") + self.assertEqual(response.status_code, 200) + self.assertEqual( + self.repository.trend_calls[-1]["window_end"] + - self.repository.trend_calls[-1]["window_start"], + timedelta(hours=6), + ) + self.assertEqual(response.headers["cache-control"], "no-store") + payload = response.json() + self.assertEqual([item["metric"] for item in payload["data"]["series"]], + ["hashrate", "power"]) + self.assertEqual(payload["meta"]["source"], "raw_snapshots") + self.assertEqual(payload["meta"]["completeness"], "not_guaranteed") + self.assertIsNone(payload["error"]) + + +class MonitoringRepositoryIntegrationTests(unittest.TestCase): + def setUp(self): + self.connection = psycopg.connect( + host=settings.postgres_host, + port=settings.postgres_port, + dbname=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + ) + self.repository = MonitoringRepository(connection=self.connection) + + def tearDown(self): + self.connection.rollback() + self.connection.close() + + def test_location_scope_options_execute_and_order_deterministically(self): + site_alpha, site_beta = uuid4(), uuid4() + room_alpha, room_beta = uuid4(), uuid4() + suffix = uuid4().hex[:10] + with self.connection.cursor() as cur: + cur.execute( + "INSERT INTO sites (id, name) VALUES (%s, %s), (%s, %s)", + (site_alpha, "Scope Alpha " + suffix, + site_beta, "scope Beta " + suffix), + ) + cur.execute( + "INSERT INTO rooms (id, site_id, name) VALUES " + "(%s, %s, %s), (%s, %s, %s)", + (room_alpha, site_alpha, "Scope Alpha Room " + suffix, + room_beta, site_beta, "scope Beta Room " + suffix), + ) + + rows = self.repository.list_location_scope_options() + site_rows = [row for row in rows if row["option_type"] == "site"] + room_rows = [row for row in rows if row["option_type"] == "room"] + + self.assertEqual( + [row["option_type"] for row in rows], + ["site"] * len(site_rows) + ["room"] * len(room_rows), + ) + ordering = lambda row: (row["name"].lower(), str(row["id"])) + self.assertEqual(site_rows, sorted(site_rows, key=ordering)) + self.assertEqual(room_rows, sorted(room_rows, key=ordering)) + + by_id = {row["id"]: row for row in rows} + self.assertIsNone(by_id[site_alpha]["site_id"]) + self.assertIsNone(by_id[site_beta]["site_id"]) + self.assertEqual(by_id[room_alpha]["site_id"], site_alpha) + self.assertEqual(by_id[room_beta]["site_id"], site_beta) + + def test_per_miner_first_math_counts_and_current_location_precedence(self): + site_a, site_b, room_a = uuid4(), uuid4(), uuid4() + miner_a, miner_b = uuid4(), uuid4() + suffix = uuid4().hex[:10] + with self.connection.cursor() as cur: + cur.execute( + "INSERT INTO sites (id, name) VALUES (%s, %s), (%s, %s)", + (site_a, "Trend A " + suffix, site_b, "Trend B " + suffix), + ) + cur.execute( + "INSERT INTO rooms (id, site_id, name) VALUES (%s, %s, %s)", + (room_a, site_a, "Trend Room " + suffix), + ) + cur.execute( + "INSERT INTO miners (id, site_id, room_id, asset_tag, lifecycle_status) " + "VALUES (%s, %s, NULL, %s, 'active'), " + "(%s, %s, %s, %s, 'active')", + (miner_a, site_b, "TREND-A-" + suffix, + miner_b, site_a, room_a, "TREND-B-" + suffix), + ) + cur.execute( + "INSERT INTO location_positions " + "(room_id, room_name, rack_name, shelf_name, position_index, miner_id) " + "VALUES (%s, %s, %s, '1', 1, %s)", + (room_a, "Trend Room " + suffix, "Rack " + suffix, miner_a), + ) + observed = datetime(2026, 8, 17, 11, 56, tzinfo=timezone.utc) + cur.execute( + "INSERT INTO miner_status_snapshots " + "(miner_id, observed_at, mhs_av, power_w) VALUES " + "(%s, %s, 100000000, 1000), (%s, %s, 200000000, NULL), " + "(%s, %s, 0, 0), (%s, %s, NULL, 3000)", + (miner_a, observed, miner_a, observed + timedelta(minutes=1), + miner_b, observed, miner_b, observed + timedelta(minutes=1)), + ) + rows = self.repository.read_trends( + scope="site", scope_id=site_a, + window_start=datetime(2026, 8, 17, 6, 5, tzinfo=timezone.utc), + window_end=datetime(2026, 8, 17, 12, 5, tzinfo=timezone.utc), + bucket_width=timedelta(minutes=5), + ) + point = next(row for row in rows if row["bucket_start"].hour == 11 + and row["bucket_start"].minute == 55) + self.assertEqual(float(point["hashrate_ths"]), 150.0) + self.assertEqual(float(point["power_w"]), 2500.0) + self.assertEqual(point["observation_count"], 4) + self.assertEqual(point["hashrate_sample_count"], 3) + self.assertEqual(point["power_sample_count"], 3) + self.assertEqual(point["observed_miner_count"], 2) + self.assertEqual(point["hashrate_contributing_miner_count"], 2) + self.assertEqual(point["scoped_miner_count"], 2) + empty = next(row for row in rows if row["bucket_start"].hour == 11 + and row["bucket_start"].minute == 50) + self.assertIsNone(empty["hashrate_ths"]) + self.assertEqual(empty["observation_count"], 0) + self.assertEqual(len(rows), 72) + + def test_utc_bucket_is_independent_of_session_timezone(self): + miner_id = uuid4() + with self.connection.cursor() as cur: + cur.execute("SET LOCAL TIME ZONE 'Pacific/Honolulu'") + cur.execute( + "INSERT INTO miners (id, asset_tag, lifecycle_status) " + "VALUES (%s, %s, 'active')", + (miner_id, "TZ-" + uuid4().hex[:10]), + ) + cur.execute( + "INSERT INTO miner_status_snapshots " + "(miner_id, observed_at, mhs_av) VALUES (%s, %s, 1000000)", + (miner_id, datetime.fromisoformat("2026-08-17T07:01:00-05:00")), + ) + rows = self.repository.read_trends( + scope="miner", scope_id=miner_id, + window_start=datetime(2026, 8, 17, 6, 5, tzinfo=timezone.utc), + window_end=datetime(2026, 8, 17, 12, 5, tzinfo=timezone.utc), + bucket_width=timedelta(minutes=5), + ) + value = next(row for row in rows + if row["bucket_start"].astimezone(timezone.utc).hour == 12) + self.assertEqual(float(value["hashrate_ths"]), 1.0) + + def test_representative_raw_query_is_bounded(self): + prefix = "BENCH-" + uuid4().hex[:8] + with self.connection.cursor() as cur: + cur.execute( + "INSERT INTO miners (asset_tag, lifecycle_status) " + "SELECT %s || '-' || value, 'active' " + "FROM generate_series(1, 500) AS value", + (prefix,), + ) + cur.execute( + "INSERT INTO miner_status_snapshots " + "(miner_id, observed_at, mhs_av, power_w) " + "SELECT miners.id, %s::timestamptz - value * interval '1 hour', " + "100000000, 3200 FROM miners " + "CROSS JOIN generate_series(0, 167) AS value " + "WHERE miners.asset_tag LIKE %s", + (NOW, prefix + "%"), + ) + started = time.monotonic() + rows = self.repository.read_trends( + scope="fleet", scope_id=None, + window_start=datetime(2026, 8, 10, 13, 0, tzinfo=timezone.utc), + window_end=datetime(2026, 8, 17, 13, 0, tzinfo=timezone.utc), + bucket_width=timedelta(hours=1), + ) + elapsed = time.monotonic() - started + self.assertEqual(len(rows), 168) + self.assertLess(elapsed, 15.0) + print(f"Monitoring synthetic benchmark: 84,000 rows, 168 points, {elapsed:.3f}s") + + +class MonitoringFrontendTests(unittest.TestCase): + def setUp(self): + self.app = FastAPI() + self.app.include_router(monitoring_frontend_router) + self.client = TestClient(self.app) + + def tearDown(self): + self.client.close() + + def authenticate(self, permissions): + self.app.dependency_overrides[get_current_user] = lambda: { + "id": str(uuid4()), "username": "reader", "display_name": "Reader", + "role": "Custom", "permissions": list(permissions), + } + + def test_page_requires_scope_aligned_permissions_and_renders_shell(self): + self.assertEqual(self.client.get("/monitoring/analytics").status_code, 401) + self.authenticate(("miners.view",)) + response = self.client.get("/monitoring/analytics") + self.assertEqual(response.status_code, 200) + self.assertIn('data-view="analytics"', response.text) + self.assertIn("/assets/minerops-monitoring-trends.js", response.text) + self.assertNotIn('