Add operational schema and identity roles

This commit is contained in:
lussierj committed 2026-07-28 13:46:10 +00:00
1 parent f5af75f305
commit f181482463
11 files changed
+2068 -284

No files matched your search

+2
View File
@@ -15,6 +15,8 @@ imports/
*.dump
*.bak
*.csv
!sql/
!sql/*.sql
# Python
__pycache__/
+64
View File
@@ -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.
+10
View File
@@ -94,6 +94,16 @@ docker compose --env-file .env.dev -f compose.dev.yaml ps
curl --fail --silent http://127.0.0.1:18000/api/health
```
Run the disposable clean-database baseline:
```bash
./scripts/test-dev-baseline.sh
```
The baseline uses a separate Compose project and port, verifies migrations,
schema, pages, retention rollups, and safety interlocks, then deletes only its
own temporary volumes.
Stop development services without deleting their data:
```bash
@@ -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
View File
@@ -10,10 +10,18 @@ from fastapi import Request, Depends, FastAPI, Header, HTTPException, status
from pydantic import BaseModel, EmailStr, Field
from app.config import settings
from app.identity import (
COOKIE_TOKEN_HINT,
SESSION_COOKIE,
get_current_user,
install_identity_middleware,
router as identity_router,
)
app = FastAPI(title=settings.app_name)
password_hasher = PasswordHasher()
app.include_router(identity_router)
install_identity_middleware(app)
def api_response(data: Any = None, meta: dict | None = None, error: Any = None) -> dict:
@@ -74,83 +82,6 @@ def check_redis() -> str:
return f"error: {exc}"
class SetupAdminRequest(BaseModel):
username: str = Field(min_length=3, max_length=64)
display_name: str = Field(min_length=1, max_length=120)
email: EmailStr | None = None
password: str = Field(min_length=10, max_length=256)
class LoginRequest(BaseModel):
username: str
password: str
def create_access_token(user_id: str, username: str, role: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": user_id,
"username": username,
"role": role,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=settings.jwt_access_token_minutes)).timestamp()),
"type": "access",
}
return jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256")
def decode_access_token(token: str) -> dict:
try:
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=["HS256"])
if payload.get("type") != "access":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
def get_bearer_token(authorization: str | None = Header(default=None)) -> str:
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
return authorization.split(" ", 1)[1].strip()
def get_current_user(token: str = Depends(get_bearer_token)) -> dict:
payload = decode_access_token(token)
user_id = payload["sub"]
conn = None
try:
conn = get_db_connection()
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
users.id::text AS id,
users.username,
users.display_name,
users.email,
users.is_active,
roles.name AS role
FROM users
JOIN roles ON roles.id = users.role_id
WHERE users.id = %s;
""",
(user_id,),
)
user = cur.fetchone()
if not user or not user["is_active"]:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
return dict(user)
finally:
if conn is not None:
conn.close()
@app.get("/api/health")
def health_check():
database_status = check_database()
@@ -171,196 +102,6 @@ def health_check():
)
@app.get("/api/auth/setup-status")
def setup_status():
conn = None
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM users;")
user_count = cur.fetchone()[0]
return api_response(
data={
"setup_required": user_count == 0,
"user_count": user_count,
}
)
finally:
if conn is not None:
conn.close()
@app.post("/api/auth/setup-admin")
def setup_admin(request: SetupAdminRequest):
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute("SELECT COUNT(*) AS user_count FROM users;")
user_count = cur.fetchone()["user_count"]
if user_count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Initial admin setup has already been completed",
)
cur.execute("SELECT id FROM roles WHERE name = 'Admin';")
admin_role = cur.fetchone()
if not admin_role:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Admin role is missing",
)
password_hash = password_hasher.hash(request.password)
cur.execute(
"""
INSERT INTO users (username, display_name, email, password_hash, role_id)
VALUES (%s, %s, %s, %s, %s)
RETURNING id::text, username, display_name, email;
""",
(
request.username.strip(),
request.display_name.strip(),
request.email,
password_hash,
admin_role["id"],
),
)
user = cur.fetchone()
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
VALUES (%s, 'user.created', 'user', %s, %s);
""",
(
user["id"],
user["id"],
psycopg.types.json.Jsonb(
{
"username": user["username"],
"display_name": user["display_name"],
"role": "Admin",
"source": "setup-admin",
}
),
),
)
conn.commit()
return api_response(
data={
"user_id": user["id"],
"username": user["username"],
"display_name": user["display_name"],
}
)
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
@app.post("/api/auth/login")
def login(request: LoginRequest):
conn = None
try:
conn = get_db_connection()
conn.autocommit = False
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
SELECT
users.id::text AS id,
users.username,
users.display_name,
users.email,
users.password_hash,
users.is_active,
roles.name AS role
FROM users
JOIN roles ON roles.id = users.role_id
WHERE lower(users.username) = lower(%s);
""",
(request.username.strip(),),
)
user = cur.fetchone()
if not user or not user["is_active"]:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
try:
password_hasher.verify(user["password_hash"], request.password)
except VerifyMismatchError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
access_token = create_access_token(user["id"], user["username"], user["role"])
cur.execute(
"UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = %s;",
(user["id"],),
)
cur.execute(
"""
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, new_value_json)
VALUES (%s, 'user.login', 'user', %s, %s);
""",
(
user["id"],
user["id"],
psycopg.types.json.Jsonb({"username": user["username"]}),
),
)
conn.commit()
return api_response(
data={
"access_token": access_token,
"token_type": "bearer",
"expires_in": settings.jwt_access_token_minutes * 60,
"user": {
"id": user["id"],
"username": user["username"],
"display_name": user["display_name"],
"email": user["email"],
"role": user["role"],
},
}
)
except HTTPException:
if conn is not None:
conn.rollback()
raise
except Exception as exc:
if conn is not None:
conn.rollback()
raise HTTPException(status_code=500, detail=str(exc))
finally:
if conn is not None:
conn.close()
@app.get("/api/auth/me")
def me(current_user: dict = Depends(get_current_user)):
return api_response(data=current_user)
class CreateRoomRequest(BaseModel):
site_id: str | None = None
name: str = Field(min_length=1, max_length=120)
@@ -4303,7 +4044,7 @@ def minerops_ensure_pool_info_table():
cur.execute(
"""
CREATE TABLE IF NOT EXISTS miner_pool_snapshots (
id TEXT PRIMARY KEY,
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
miner_id UUID,
ip_address INET,
mac_address TEXT,
@@ -4326,6 +4067,25 @@ def minerops_ensure_pool_info_table():
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
DECLARE
id_type TEXT;
BEGIN
SELECT data_type INTO id_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'miner_pool_snapshots'
AND column_name = 'id';
IF id_type = 'text' THEN
ALTER TABLE miner_pool_snapshots
ALTER COLUMN id SET DEFAULT (gen_random_uuid())::text;
ELSIF id_type = 'uuid' THEN
ALTER TABLE miner_pool_snapshots
ALTER COLUMN id SET DEFAULT gen_random_uuid();
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_miner_pool_snapshots_miner_created
ON miner_pool_snapshots (miner_id, created_at DESC);
@@ -4443,7 +4203,6 @@ async def agent_pool_snapshots(request: Request):
cur.execute(
"""
INSERT INTO miner_pool_snapshots (
id,
miner_id,
ip_address,
mac_address,
@@ -4465,7 +4224,6 @@ async def agent_pool_snapshots(request: Request):
agent_id
)
VALUES (
%s,
%s,
%s::inet,
%s,
@@ -4488,7 +4246,6 @@ async def agent_pool_snapshots(request: Request):
);
""",
(
str(_uuid.uuid4()),
miner_id,
ip,
mac,
@@ -6241,22 +5998,25 @@ MINEROPS_SHELL_AUTH_JS = r"""
}
async function getCurrentUser() {
const token = getToken();
if (!token) {
return null;
}
try {
const response = await fetch("/api/auth/me", {
credentials: "same-origin",
headers: authHeaders()
});
if (!response.ok) {
window.location.replace("/login?next=" + encodeURIComponent(window.location.pathname));
return null;
}
return await response.json();
const payload = await response.json();
const user = payload && payload.data ? payload.data : payload;
if (user && !getToken()) {
setToken("cookie-session");
}
return user;
} catch (error) {
return null;
}
@@ -6352,9 +6112,16 @@ MINEROPS_SHELL_AUTH_JS = r"""
}
}
function logoutFromHeader() {
clearToken();
window.location.reload();
async function logoutFromHeader() {
try {
await fetch("/api/auth/logout", {
method: "POST",
credentials: "same-origin"
});
} finally {
clearToken();
window.location.replace("/login");
}
}
async function renderHeaderAuth() {
@@ -6388,6 +6155,13 @@ MINEROPS_SHELL_AUTH_JS = r"""
<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;
return;
}
@@ -23630,9 +23404,11 @@ async def insert_miner_command_event(
def minerops_command_require_bearer(request: Request):
auth_header = request.headers.get("authorization") or ""
cookie_token = request.cookies.get(SESSION_COOKIE) or ""
header_token = auth_header.split(" ", 1)[1].strip() if auth_header.lower().startswith("bearer ") else ""
if not auth_header.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="Login token required.")
if not cookie_token and (not header_token or header_token == COOKIE_TOKEN_HINT):
raise HTTPException(status_code=401, detail="Login required.")
async def minerops_command_user_from_request(request: Request):
+189
View File
@@ -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."
+34
View File
@@ -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();
+34
View File
@@ -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();
+30
View File
@@ -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;