Add operational schema and identity roles
This commit is contained in:
1 parent
f5af75f305
commit
f181482463
11 files changed
+2068
-284
No files matched your search
@@ -15,6 +15,8 @@ imports/
|
|||||||
*.dump
|
*.dump
|
||||||
*.bak
|
*.bak
|
||||||
*.csv
|
*.csv
|
||||||
|
!sql/
|
||||||
|
!sql/*.sql
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -94,6 +94,16 @@ docker compose --env-file .env.dev -f compose.dev.yaml ps
|
|||||||
curl --fail --silent http://127.0.0.1:18000/api/health
|
curl --fail --silent http://127.0.0.1:18000/api/health
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run the disposable clean-database baseline:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/test-dev-baseline.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The baseline uses a separate Compose project and port, verifies migrations,
|
||||||
|
schema, pages, retention rollups, and safety interlocks, then deletes only its
|
||||||
|
own temporary volumes.
|
||||||
|
|
||||||
Stop development services without deleting their data:
|
Stop development services without deleting their data:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
File diff suppressed because it is too large.
Load diff
+60
-284
@@ -10,10 +10,18 @@ from fastapi import Request, Depends, FastAPI, Header, HTTPException, status
|
|||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.identity import (
|
||||||
|
COOKIE_TOKEN_HINT,
|
||||||
|
SESSION_COOKIE,
|
||||||
|
get_current_user,
|
||||||
|
install_identity_middleware,
|
||||||
|
router as identity_router,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title=settings.app_name)
|
app = FastAPI(title=settings.app_name)
|
||||||
password_hasher = PasswordHasher()
|
app.include_router(identity_router)
|
||||||
|
install_identity_middleware(app)
|
||||||
|
|
||||||
|
|
||||||
def api_response(data: Any = None, meta: dict | None = None, error: Any = None) -> dict:
|
def api_response(data: Any = None, meta: dict | None = None, error: Any = None) -> dict:
|
||||||
@@ -74,83 +82,6 @@ def check_redis() -> str:
|
|||||||
return f"error: {exc}"
|
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")
|
@app.get("/api/health")
|
||||||
def health_check():
|
def health_check():
|
||||||
database_status = check_database()
|
database_status = check_database()
|
||||||
@@ -171,196 +102,6 @@ def health_check():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/auth/setup-status")
|
|
||||||
def setup_status():
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = get_db_connection()
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute("SELECT COUNT(*) FROM users;")
|
|
||||||
user_count = cur.fetchone()[0]
|
|
||||||
|
|
||||||
return api_response(
|
|
||||||
data={
|
|
||||||
"setup_required": user_count == 0,
|
|
||||||
"user_count": user_count,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
if conn is not None:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/setup-admin")
|
|
||||||
def setup_admin(request: SetupAdminRequest):
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = get_db_connection()
|
|
||||||
conn.autocommit = False
|
|
||||||
|
|
||||||
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
||||||
cur.execute("SELECT COUNT(*) AS user_count FROM users;")
|
|
||||||
user_count = cur.fetchone()["user_count"]
|
|
||||||
|
|
||||||
if user_count > 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="Initial admin setup has already been completed",
|
|
||||||
)
|
|
||||||
|
|
||||||
cur.execute("SELECT id FROM roles WHERE name = 'Admin';")
|
|
||||||
admin_role = cur.fetchone()
|
|
||||||
if not admin_role:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Admin role is missing",
|
|
||||||
)
|
|
||||||
|
|
||||||
password_hash = password_hasher.hash(request.password)
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO users (username, display_name, email, password_hash, role_id)
|
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
|
||||||
RETURNING id::text, username, display_name, email;
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
request.username.strip(),
|
|
||||||
request.display_name.strip(),
|
|
||||||
request.email,
|
|
||||||
password_hash,
|
|
||||||
admin_role["id"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
user = cur.fetchone()
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
||||||
VALUES (%s, 'user.created', 'user', %s, %s);
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
user["id"],
|
|
||||||
user["id"],
|
|
||||||
psycopg.types.json.Jsonb(
|
|
||||||
{
|
|
||||||
"username": user["username"],
|
|
||||||
"display_name": user["display_name"],
|
|
||||||
"role": "Admin",
|
|
||||||
"source": "setup-admin",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
return api_response(
|
|
||||||
data={
|
|
||||||
"user_id": user["id"],
|
|
||||||
"username": user["username"],
|
|
||||||
"display_name": user["display_name"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
if conn is not None:
|
|
||||||
conn.rollback()
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
if conn is not None:
|
|
||||||
conn.rollback()
|
|
||||||
raise HTTPException(status_code=500, detail=str(exc))
|
|
||||||
finally:
|
|
||||||
if conn is not None:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/login")
|
|
||||||
def login(request: LoginRequest):
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = get_db_connection()
|
|
||||||
conn.autocommit = False
|
|
||||||
|
|
||||||
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
users.id::text AS id,
|
|
||||||
users.username,
|
|
||||||
users.display_name,
|
|
||||||
users.email,
|
|
||||||
users.password_hash,
|
|
||||||
users.is_active,
|
|
||||||
roles.name AS role
|
|
||||||
FROM users
|
|
||||||
JOIN roles ON roles.id = users.role_id
|
|
||||||
WHERE lower(users.username) = lower(%s);
|
|
||||||
""",
|
|
||||||
(request.username.strip(),),
|
|
||||||
)
|
|
||||||
user = cur.fetchone()
|
|
||||||
|
|
||||||
if not user or not user["is_active"]:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
|
|
||||||
|
|
||||||
try:
|
|
||||||
password_hasher.verify(user["password_hash"], request.password)
|
|
||||||
except VerifyMismatchError:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
|
|
||||||
|
|
||||||
access_token = create_access_token(user["id"], user["username"], user["role"])
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = %s;",
|
|
||||||
(user["id"],),
|
|
||||||
)
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
|
|
||||||
VALUES (%s, 'user.login', 'user', %s, %s);
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
user["id"],
|
|
||||||
user["id"],
|
|
||||||
psycopg.types.json.Jsonb({"username": user["username"]}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
return api_response(
|
|
||||||
data={
|
|
||||||
"access_token": access_token,
|
|
||||||
"token_type": "bearer",
|
|
||||||
"expires_in": settings.jwt_access_token_minutes * 60,
|
|
||||||
"user": {
|
|
||||||
"id": user["id"],
|
|
||||||
"username": user["username"],
|
|
||||||
"display_name": user["display_name"],
|
|
||||||
"email": user["email"],
|
|
||||||
"role": user["role"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
if conn is not None:
|
|
||||||
conn.rollback()
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
if conn is not None:
|
|
||||||
conn.rollback()
|
|
||||||
raise HTTPException(status_code=500, detail=str(exc))
|
|
||||||
finally:
|
|
||||||
if conn is not None:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/auth/me")
|
|
||||||
def me(current_user: dict = Depends(get_current_user)):
|
|
||||||
return api_response(data=current_user)
|
|
||||||
|
|
||||||
class CreateRoomRequest(BaseModel):
|
class CreateRoomRequest(BaseModel):
|
||||||
site_id: str | None = None
|
site_id: str | None = None
|
||||||
name: str = Field(min_length=1, max_length=120)
|
name: str = Field(min_length=1, max_length=120)
|
||||||
@@ -4303,7 +4044,7 @@ def minerops_ensure_pool_info_table():
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS miner_pool_snapshots (
|
CREATE TABLE IF NOT EXISTS miner_pool_snapshots (
|
||||||
id TEXT PRIMARY KEY,
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
miner_id UUID,
|
miner_id UUID,
|
||||||
ip_address INET,
|
ip_address INET,
|
||||||
mac_address TEXT,
|
mac_address TEXT,
|
||||||
@@ -4326,6 +4067,25 @@ def minerops_ensure_pool_info_table():
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
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
|
CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_miner_created
|
||||||
ON miner_pool_snapshots (miner_id, created_at DESC);
|
ON miner_pool_snapshots (miner_id, created_at DESC);
|
||||||
|
|
||||||
@@ -4443,7 +4203,6 @@ async def agent_pool_snapshots(request: Request):
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO miner_pool_snapshots (
|
INSERT INTO miner_pool_snapshots (
|
||||||
id,
|
|
||||||
miner_id,
|
miner_id,
|
||||||
ip_address,
|
ip_address,
|
||||||
mac_address,
|
mac_address,
|
||||||
@@ -4465,7 +4224,6 @@ async def agent_pool_snapshots(request: Request):
|
|||||||
agent_id
|
agent_id
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
%s,
|
|
||||||
%s,
|
%s,
|
||||||
%s::inet,
|
%s::inet,
|
||||||
%s,
|
%s,
|
||||||
@@ -4488,7 +4246,6 @@ async def agent_pool_snapshots(request: Request):
|
|||||||
);
|
);
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
str(_uuid.uuid4()),
|
|
||||||
miner_id,
|
miner_id,
|
||||||
ip,
|
ip,
|
||||||
mac,
|
mac,
|
||||||
@@ -6241,22 +5998,25 @@ MINEROPS_SHELL_AUTH_JS = r"""
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getCurrentUser() {
|
async function getCurrentUser() {
|
||||||
const token = getToken();
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/auth/me", {
|
const response = await fetch("/api/auth/me", {
|
||||||
|
credentials: "same-origin",
|
||||||
headers: authHeaders()
|
headers: authHeaders()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
window.location.replace("/login?next=" + encodeURIComponent(window.location.pathname));
|
||||||
return null;
|
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) {
|
} catch (error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -6352,9 +6112,16 @@ MINEROPS_SHELL_AUTH_JS = r"""
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logoutFromHeader() {
|
async function logoutFromHeader() {
|
||||||
clearToken();
|
try {
|
||||||
window.location.reload();
|
await fetch("/api/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearToken();
|
||||||
|
window.location.replace("/login");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderHeaderAuth() {
|
async function renderHeaderAuth() {
|
||||||
@@ -6388,6 +6155,13 @@ MINEROPS_SHELL_AUTH_JS = r"""
|
|||||||
<button type="button" id="mineropsHeaderLogout">Logout</button>
|
<button type="button" id="mineropsHeaderLogout">Logout</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
if (user.role === "Admin") {
|
||||||
|
const accountsLink = document.createElement("a");
|
||||||
|
accountsLink.href = "/accounts";
|
||||||
|
accountsLink.textContent = "Accounts";
|
||||||
|
authArea.prepend(accountsLink);
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById("mineropsHeaderLogout").onclick = logoutFromHeader;
|
document.getElementById("mineropsHeaderLogout").onclick = logoutFromHeader;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -23630,9 +23404,11 @@ async def insert_miner_command_event(
|
|||||||
|
|
||||||
def minerops_command_require_bearer(request: Request):
|
def minerops_command_require_bearer(request: Request):
|
||||||
auth_header = request.headers.get("authorization") or ""
|
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 "):
|
if not cookie_token and (not header_token or header_token == COOKIE_TOKEN_HINT):
|
||||||
raise HTTPException(status_code=401, detail="Login token required.")
|
raise HTTPException(status_code=401, detail="Login required.")
|
||||||
|
|
||||||
|
|
||||||
async def minerops_command_user_from_request(request: Request):
|
async def minerops_command_user_from_request(request: Request):
|
||||||
|
|||||||
Executable
+189
@@ -0,0 +1,189 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
project_name="${MINEROPS_SMOKE_PROJECT:-minerops-smoke-$$}"
|
||||||
|
dev_port="${MINEROPS_SMOKE_PORT:-18001}"
|
||||||
|
keep_environment="${MINEROPS_SMOKE_KEEP:-false}"
|
||||||
|
base_url="http://127.0.0.1:${dev_port}"
|
||||||
|
compose=(
|
||||||
|
docker compose
|
||||||
|
--project-name "${project_name}"
|
||||||
|
--env-file .env.dev
|
||||||
|
-f compose.dev.yaml
|
||||||
|
)
|
||||||
|
tmp_prefix="/tmp/${project_name}"
|
||||||
|
admin_cookie="${tmp_prefix}-admin.cookies"
|
||||||
|
manager_cookie="${tmp_prefix}-manager.cookies"
|
||||||
|
technician_cookie="${tmp_prefix}-technician.cookies"
|
||||||
|
readonly_cookie="${tmp_prefix}-readonly.cookies"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
|
||||||
|
if (( status != 0 )); then
|
||||||
|
echo
|
||||||
|
echo "Smoke test failed. Recent service logs:"
|
||||||
|
"${compose[@]}" logs --no-color --tail 160 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "${tmp_prefix}"-*.json "${tmp_prefix}"-*.cookies "${tmp_prefix}"-*.headers
|
||||||
|
if [[ "${keep_environment}" != "true" ]]; then
|
||||||
|
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
else
|
||||||
|
echo "Keeping Compose project ${project_name} for inspection."
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
status_for() {
|
||||||
|
local cookie_jar="$1"
|
||||||
|
local method="$2"
|
||||||
|
local path="$3"
|
||||||
|
local body="${4:-}"
|
||||||
|
local args=(--silent --output /dev/null --write-out '%{http_code}' -X "${method}")
|
||||||
|
if [[ -n "${cookie_jar}" ]]; then
|
||||||
|
args+=(-b "${cookie_jar}")
|
||||||
|
fi
|
||||||
|
if [[ -n "${body}" ]]; then
|
||||||
|
args+=(-H 'Content-Type: application/json' --data "${body}")
|
||||||
|
fi
|
||||||
|
curl "${args[@]}" "${base_url}${path}"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ ! -f .env.dev ]]; then
|
||||||
|
echo "Missing .env.dev. Run ./scripts/dev-env-init.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Validating Compose configuration..."
|
||||||
|
MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" config --quiet
|
||||||
|
|
||||||
|
echo "Building and starting disposable development stack..."
|
||||||
|
MINEROPS_DEV_PORT="${dev_port}" "${compose[@]}" up --detach --build
|
||||||
|
|
||||||
|
echo "Waiting for the gateway health endpoint..."
|
||||||
|
health_file="${tmp_prefix}-health.json"
|
||||||
|
for attempt in $(seq 1 90); do
|
||||||
|
if curl --fail --silent "${base_url}/api/health" >"${health_file}"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( attempt == 90 )); then
|
||||||
|
echo "Gateway did not become healthy within 90 seconds." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
health_json="$(cat "${health_file}")"
|
||||||
|
[[ "${health_json}" == *'"status":"ok"'* ]]
|
||||||
|
[[ "${health_json}" == *'"environment":"development"'* ]]
|
||||||
|
[[ "${health_json}" == *'"database":"ok"'* ]]
|
||||||
|
[[ "${health_json}" == *'"redis":"ok"'* ]]
|
||||||
|
|
||||||
|
echo "Checking migrations and required schema..."
|
||||||
|
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
|
||||||
|
[[ "${current_revision}" == *"0007_identity_and_roles"* ]]
|
||||||
|
|
||||||
|
missing_tables="$(
|
||||||
|
"${compose[@]}" exec -T postgres sh -c \
|
||||||
|
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At' \
|
||||||
|
< sql/verify-development-schema.sql
|
||||||
|
)"
|
||||||
|
if [[ -n "${missing_tables}" ]]; then
|
||||||
|
echo "Missing required tables:" >&2
|
||||||
|
echo "${missing_tables}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Checking the unauthenticated login gate..."
|
||||||
|
setup_status="$(curl --fail --silent "${base_url}/api/auth/setup-status")"
|
||||||
|
[[ "${setup_status}" == *'"setup_required":true'* ]]
|
||||||
|
[[ "$(status_for "" GET /login)" == "200" ]]
|
||||||
|
for path in dashboard repairs locations system-health accounts; do
|
||||||
|
[[ "$(status_for "" GET "/${path}")" == "303" ]]
|
||||||
|
done
|
||||||
|
[[ "$(status_for "" GET /api/scanner/status)" == "401" ]]
|
||||||
|
|
||||||
|
echo "Creating the first administrator and role test accounts..."
|
||||||
|
setup_response="$(curl --fail --silent -c "${admin_cookie}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data '{"username":"admin","display_name":"Test Administrator","email":"admin@example.com","password":"MinerOpsTest!123"}' \
|
||||||
|
"${base_url}/api/auth/setup-admin")"
|
||||||
|
[[ "${setup_response}" == *'"role":"Admin"'* ]]
|
||||||
|
[[ "$(status_for "${admin_cookie}" GET /dashboard)" == "200" ]]
|
||||||
|
[[ "$(status_for "${admin_cookie}" GET /accounts)" == "200" ]]
|
||||||
|
|
||||||
|
for account in \
|
||||||
|
'manager|Operations Manager|Manager|manager@example.com' \
|
||||||
|
'technician|Repair Technician|Technician|technician@example.com' \
|
||||||
|
'readonly|Read Only User|Read Only|readonly@example.com'; do
|
||||||
|
IFS='|' read -r username display_name role email <<<"${account}"
|
||||||
|
response="$(curl --fail --silent -b "${admin_cookie}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data "{\"username\":\"${username}\",\"display_name\":\"${display_name}\",\"email\":\"${email}\",\"role\":\"${role}\",\"password\":\"MinerOpsTest!123\"}" \
|
||||||
|
"${base_url}/api/admin/users")"
|
||||||
|
[[ "${response}" == *"\"role\":\"${role}\""* ]]
|
||||||
|
done
|
||||||
|
|
||||||
|
roles_response="$(curl --fail --silent -b "${admin_cookie}" "${base_url}/api/admin/roles")"
|
||||||
|
for role in Admin Manager Technician 'Read Only'; do
|
||||||
|
[[ "${roles_response}" == *"\"name\":\"${role}\""* ]]
|
||||||
|
done
|
||||||
|
users_response="$(curl --fail --silent -b "${admin_cookie}" "${base_url}/api/admin/users")"
|
||||||
|
for username in admin manager technician readonly; do
|
||||||
|
[[ "${users_response}" == *"\"username\":\"${username}\""* ]]
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Logging in each role and checking authorization boundaries..."
|
||||||
|
for login in \
|
||||||
|
"manager|${manager_cookie}|Manager" \
|
||||||
|
"technician|${technician_cookie}|Technician" \
|
||||||
|
"readonly|${readonly_cookie}|Read Only"; do
|
||||||
|
IFS='|' read -r username cookie_jar role <<<"${login}"
|
||||||
|
login_response="$(curl --fail --silent -c "${cookie_jar}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data "{\"username\":\"${username}\",\"password\":\"MinerOpsTest!123\"}" \
|
||||||
|
"${base_url}/api/auth/login")"
|
||||||
|
[[ "${login_response}" == *"\"role\":\"${role}\""* ]]
|
||||||
|
[[ "$(status_for "${cookie_jar}" GET /dashboard)" == "200" ]]
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ "$(status_for "${manager_cookie}" GET /system-health)" == "200" ]]
|
||||||
|
[[ "$(status_for "${manager_cookie}" GET /accounts)" == "403" ]]
|
||||||
|
[[ "$(status_for "${manager_cookie}" GET /api/admin/users)" == "403" ]]
|
||||||
|
[[ "$(status_for "${technician_cookie}" GET /repairs)" == "200" ]]
|
||||||
|
[[ "$(status_for "${technician_cookie}" GET /system-health)" == "403" ]]
|
||||||
|
[[ "$(status_for "${readonly_cookie}" GET /locations)" == "200" ]]
|
||||||
|
[[ "$(status_for "${readonly_cookie}" POST /api/rooms '{"name":"Must Not Be Created"}')" == "403" ]]
|
||||||
|
|
||||||
|
room_status="$(status_for "${technician_cookie}" POST /api/rooms '{"name":"Role Matrix Test Room"}')"
|
||||||
|
[[ "${room_status}" == "200" || "${room_status}" == "201" ]]
|
||||||
|
|
||||||
|
me_response="$(curl --fail --silent -b "${readonly_cookie}" "${base_url}/api/auth/me")"
|
||||||
|
[[ "${me_response}" == *'"role":"Read Only"'* ]]
|
||||||
|
[[ "$(status_for "${readonly_cookie}" POST /api/auth/logout)" == "200" ]]
|
||||||
|
[[ "$(status_for "${readonly_cookie}" GET /dashboard)" == "303" ]]
|
||||||
|
|
||||||
|
echo "Checking development safety interlocks..."
|
||||||
|
"${compose[@]}" exec -T backend sh -ec '
|
||||||
|
test "$SCHEDULED_SCAN_ENABLED" = "false"
|
||||||
|
test "$MINEROPS_HUB_MODE" = "true"
|
||||||
|
test "$MINEROPS_DISABLE_LOCAL_SCANNER" = "true"
|
||||||
|
test "$MINEROPS_ENABLE_REAL_REBOOT" = "false"
|
||||||
|
test "$MINEROPS_ENABLE_REAL_POWER_CONTROL" = "false"
|
||||||
|
'
|
||||||
|
|
||||||
|
echo "Running retention rollups against the disposable baseline..."
|
||||||
|
"${compose[@]}" exec -T postgres sh -c \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< sql/minerops-retention-hourly.sql >/dev/null
|
||||||
|
"${compose[@]}" exec -T postgres sh -c \
|
||||||
|
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||||
|
< sql/minerops-retention-daily.sql >/dev/null
|
||||||
|
|
||||||
|
echo "Development identity baseline passed."
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
INSERT INTO miner_status_daily_rollups (
|
||||||
|
miner_id,
|
||||||
|
bucket_date,
|
||||||
|
sample_count,
|
||||||
|
avg_mhs_av,
|
||||||
|
max_mhs_av,
|
||||||
|
avg_power_w,
|
||||||
|
max_chip_temp_c,
|
||||||
|
avg_chip_temp_c,
|
||||||
|
avg_pool_rejected_percent,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
miner_id,
|
||||||
|
observed_at::date AS bucket_date,
|
||||||
|
COUNT(*) AS sample_count,
|
||||||
|
AVG(mhs_av)::double precision,
|
||||||
|
MAX(mhs_av)::double precision,
|
||||||
|
AVG(power_w)::double precision,
|
||||||
|
MAX(chip_temp_max_c)::double precision,
|
||||||
|
AVG(chip_temp_avg_c)::double precision,
|
||||||
|
AVG(pool_rejected_percent)::double precision,
|
||||||
|
now()
|
||||||
|
FROM miner_status_snapshots
|
||||||
|
GROUP BY miner_id, observed_at::date
|
||||||
|
ON CONFLICT (miner_id, bucket_date) DO UPDATE
|
||||||
|
SET sample_count = EXCLUDED.sample_count,
|
||||||
|
avg_mhs_av = EXCLUDED.avg_mhs_av,
|
||||||
|
max_mhs_av = EXCLUDED.max_mhs_av,
|
||||||
|
avg_power_w = EXCLUDED.avg_power_w,
|
||||||
|
max_chip_temp_c = EXCLUDED.max_chip_temp_c,
|
||||||
|
avg_chip_temp_c = EXCLUDED.avg_chip_temp_c,
|
||||||
|
avg_pool_rejected_percent = EXCLUDED.avg_pool_rejected_percent,
|
||||||
|
updated_at = now();
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
INSERT INTO miner_status_hourly_rollups (
|
||||||
|
miner_id,
|
||||||
|
bucket_at,
|
||||||
|
sample_count,
|
||||||
|
avg_mhs_av,
|
||||||
|
max_mhs_av,
|
||||||
|
avg_power_w,
|
||||||
|
max_chip_temp_c,
|
||||||
|
avg_chip_temp_c,
|
||||||
|
avg_pool_rejected_percent,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
miner_id,
|
||||||
|
date_trunc('hour', observed_at) AS bucket_at,
|
||||||
|
COUNT(*) AS sample_count,
|
||||||
|
AVG(mhs_av)::double precision,
|
||||||
|
MAX(mhs_av)::double precision,
|
||||||
|
AVG(power_w)::double precision,
|
||||||
|
MAX(chip_temp_max_c)::double precision,
|
||||||
|
AVG(chip_temp_avg_c)::double precision,
|
||||||
|
AVG(pool_rejected_percent)::double precision,
|
||||||
|
now()
|
||||||
|
FROM miner_status_snapshots
|
||||||
|
GROUP BY miner_id, date_trunc('hour', observed_at)
|
||||||
|
ON CONFLICT (miner_id, bucket_at) DO UPDATE
|
||||||
|
SET sample_count = EXCLUDED.sample_count,
|
||||||
|
avg_mhs_av = EXCLUDED.avg_mhs_av,
|
||||||
|
max_mhs_av = EXCLUDED.max_mhs_av,
|
||||||
|
avg_power_w = EXCLUDED.avg_power_w,
|
||||||
|
max_chip_temp_c = EXCLUDED.max_chip_temp_c,
|
||||||
|
avg_chip_temp_c = EXCLUDED.avg_chip_temp_c,
|
||||||
|
avg_pool_rejected_percent = EXCLUDED.avg_pool_rejected_percent,
|
||||||
|
updated_at = now();
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
WITH expected(table_name) AS (
|
||||||
|
VALUES
|
||||||
|
('roles'),
|
||||||
|
('permissions'),
|
||||||
|
('users'),
|
||||||
|
('miners'),
|
||||||
|
('miner_status_snapshots'),
|
||||||
|
('miner_status_hourly_rollups'),
|
||||||
|
('miner_status_daily_rollups'),
|
||||||
|
('minerops_retention_runs'),
|
||||||
|
('miner_maintenance_events'),
|
||||||
|
('location_positions'),
|
||||||
|
('miner_location_states'),
|
||||||
|
('location_import_issues'),
|
||||||
|
('miner_command_events'),
|
||||||
|
('miner_api_credentials'),
|
||||||
|
('miner_pool_snapshots'),
|
||||||
|
('miner_api3_enrichment_snapshots'),
|
||||||
|
('miner_api3_hashboard_snapshots'),
|
||||||
|
('minerops_site_agents'),
|
||||||
|
('minerops_agent_snapshots'),
|
||||||
|
('minerops_agent_command_results'),
|
||||||
|
('minerops_agent_command_queue'),
|
||||||
|
('minerops_agent_discoveries'),
|
||||||
|
('minerops_scheduled_bulk_actions')
|
||||||
|
)
|
||||||
|
SELECT table_name
|
||||||
|
FROM expected
|
||||||
|
WHERE to_regclass('public.' || table_name) IS NULL
|
||||||
|
ORDER BY table_name;
|
||||||
Reference in new issue
Block a user