Author SHA1 Message Date
lussierj 390e73d950 merge: promote Internal Alpha 2026-08-19 20:16:17 +00:00
63 changed files with 915 additions and 6765 deletions

No files matched your search

-4
View File
@@ -1,4 +0,0 @@
*.ps1 -text
*.ps1 whitespace=cr-at-eol
-8
View File
@@ -1,8 +0,0 @@
agent-config.json
agent-state.json
logs/
*.bak
*.bak.*
*-probe-*.json
rollback/
-8
View File
@@ -1,8 +0,0 @@
d10c355772381fb0a3953bb8cf3af247be21659a7eee4c489438d30f8bdd2da8 EnrichOneMiner.ps1
ef56e1eca40aa838ff1e3826cdba33dc948064b8d685c6d1d4c365b049984d4e MinerOpsAgentGUI.ps1
51fbcec81331ae9b171783b55f589f7c3d4d0e872ae66611b918703ef2b8725d MinerOpsAgentService.ps1
bee0f9925e5ace27d8d7207d12af0ab7e5cab6b2a655654fca0ecf7dd71d7380 MinerOpsAgentTray.ps1
c68ad90566be4ff5b18142048cdc5186d6c71c900f45e5d15d07ef4ecfc25317 MinerOpsApi3EnrichmentPoller.ps1
90e4bbe7b1fc6467fc37da7c4e67adc01e74f332e9a2d14d1961f1488a984894 MinerOpsPoolPoller.ps1
18b36f45b966d1ff0c0cd008bb25567204e1cb90839b825e80671f1c2071d3e5 MinerOpsPoolPollerTask.ps1
-249
View File
@@ -1,249 +0,0 @@
param(
[Parameter(Mandatory=$true)]
[string]$IpAddress,
[string]$MinerId = ""
)
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
function Read-AgentConfig {
if (!(Test-Path $ConfigPath)) {
throw "Agent config not found: $ConfigPath"
}
return Get-Content $ConfigPath -Raw | ConvertFrom-Json
}
function Invoke-MinerCommandRaw {
param(
[string]$IpAddress,
[string]$Command,
[int]$Port = 4028,
[int]$TimeoutMs = 2500
)
$client = $null
try {
$client = New-Object System.Net.Sockets.TcpClient
$async = $client.BeginConnect($IpAddress, $Port, $null, $null)
if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs)) {
return ""
}
$client.EndConnect($async)
$client.ReceiveTimeout = $TimeoutMs
$client.SendTimeout = $TimeoutMs
$stream = $client.GetStream()
$payload = [System.Text.Encoding]::ASCII.GetBytes("{`"cmd`":`"$Command`"}`n")
$stream.Write($payload, 0, $payload.Length)
try {
$client.Client.Shutdown([System.Net.Sockets.SocketShutdown]::Send)
} catch {
}
$buffer = New-Object byte[] 262144
$chunks = New-Object System.Collections.ArrayList
$started = Get-Date
while (((Get-Date) - $started).TotalMilliseconds -lt $TimeoutMs) {
if (-not $stream.DataAvailable) {
Start-Sleep -Milliseconds 20
continue
}
$read = $stream.Read($buffer, 0, $buffer.Length)
if ($read -le 0) {
break
}
for ($i = 0; $i -lt $read; $i++) {
[void]$chunks.Add([byte]$buffer[$i])
}
if ($chunks.Count -gt 2097152) {
break
}
}
return [System.Text.Encoding]::UTF8.GetString([byte[]]$chunks.ToArray([byte])).Trim()
} catch {
return ""
} finally {
if ($client -ne $null) {
$client.Close()
}
}
}
function Try-ParseJson {
param([string]$Raw)
if (!$Raw -or $Raw.Trim().Length -eq 0) {
return $null
}
try {
return $Raw | ConvertFrom-Json
} catch {
return $null
}
}
function Find-FirstValue {
param(
[object]$Object,
[string[]]$Names
)
if ($null -eq $Object) {
return ""
}
$json = $Object | ConvertTo-Json -Depth 80 -Compress
foreach ($name in $Names) {
$escaped = [regex]::Escape($name)
$pattern = '"' + $escaped + '"\s*:\s*"([^"]+)"'
$m = [regex]::Match($json, $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
if ($m.Success) {
return $m.Groups[1].Value
}
$pattern2 = '"' + $escaped + '"\s*:\s*([0-9A-Za-z\.\-_:]+)'
$m2 = [regex]::Match($json, $pattern2, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
if ($m2.Success) {
return $m2.Groups[1].Value
}
}
return ""
}
function Find-MacAddress {
param([string]$Text)
if (!$Text) {
return ""
}
$m = [regex]::Match($Text, '([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}')
if ($m.Success) {
return $m.Value.Replace("-", ":").ToUpper()
}
return ""
}
$config = Read-AgentConfig
$hubUrl = ([string]$config.hub_url).TrimEnd("/")
$secret = [string]$config.agent_secret
$commands = @(
"summary",
"devs",
"edevs",
"stats",
"pools",
"devdetails",
"version",
"get_version",
"get_miner_info"
)
$raws = [ordered]@{}
$parsed = [ordered]@{}
foreach ($cmd in $commands) {
Write-Host "Querying $IpAddress command: $cmd"
$raw = Invoke-MinerCommandRaw -IpAddress $IpAddress -Command $cmd -TimeoutMs 2500
$raws[$cmd] = $raw
$parsed[$cmd] = Try-ParseJson -Raw $raw
Write-Host " bytes: $($raw.Length)"
}
$allText = ($raws.GetEnumerator() | ForEach-Object { $_.Value }) -join "`n"
$allParsed = [ordered]@{
summary = $parsed["summary"]
devs = $parsed["devs"]
edevs = $parsed["edevs"]
stats = $parsed["stats"]
pools = $parsed["pools"]
devdetails = $parsed["devdetails"]
version = $parsed["version"]
get_version = $parsed["get_version"]
get_miner_info = $parsed["get_miner_info"]
}
$mac = Find-FirstValue -Object $allParsed -Names @("MAC", "Mac", "mac", "macaddr", "mac_address", "Net MAC", "ethaddr", "Ethernet MAC")
if (!$mac) {
$mac = Find-MacAddress -Text $allText
}
$hostname = Find-FirstValue -Object $allParsed -Names @("Hostname", "hostname", "host", "Host")
$minerSn = Find-FirstValue -Object $allParsed -Names @("Miner SN", "miner_sn", "miner sn", "Serial", "serial", "SN", "sn", "M_Serial")
$firmware = Find-FirstValue -Object $allParsed -Names @("Firmware", "firmware", "firmware_version", "Soft Version", "Version", "CompileTime")
$apiVersion = Find-FirstValue -Object $allParsed -Names @("API", "api", "api_version", "Api Version")
$platform = Find-FirstValue -Object $allParsed -Names @("Platform", "platform", "Machine", "machine")
$model = Find-FirstValue -Object $allParsed -Names @("Model", "model", "Miner Type", "miner_type", "Type", "type")
if (!$model) {
$model = "WhatsMiner"
}
$payload = [ordered]@{
ip_address = $IpAddress
miner_id = $MinerId
mac_address = $mac
hostname = $hostname
miner_sn = $minerSn
model = $model
miner_type = $model
api_version = $apiVersion
firmware_version = $firmware
platform = $platform
raw_version = @{
version = $raws["version"]
get_version = $raws["get_version"]
}
raw_miner_info = @{
devs = $raws["devs"]
edevs = $raws["edevs"]
stats = $raws["stats"]
pools = $raws["pools"]
devdetails = $raws["devdetails"]
get_miner_info = $raws["get_miner_info"]
}
}
Write-Host ""
Write-Host "Detected:"
Write-Host " MAC: $mac"
Write-Host " Hostname: $hostname"
Write-Host " SN: $minerSn"
Write-Host " Model: $model"
Write-Host " Firmware: $firmware"
Write-Host " API: $apiVersion"
Write-Host " Platform: $platform"
Write-Host ""
$response = Invoke-RestMethod `
-Method Post `
-Uri "$hubUrl/api/agent/miner-enrichment-v3" `
-Headers @{
"X-MinerOps-Agent-Secret" = $secret
} `
-ContentType "application/json" `
-Body ($payload | ConvertTo-Json -Depth 80)
$response | ConvertTo-Json -Depth 20
-226
View File
@@ -1,226 +0,0 @@
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$BaseDir = "C:\MinerOpsAgent"
$ConfigPath = Join-Path $BaseDir "agent-config.json"
$StatePath = Join-Path $BaseDir "agent-state.json"
$LogPath = Join-Path $BaseDir "logs\agent.log"
$TaskName = "MinerOpsAgent"
function Read-Config {
if (Test-Path $ConfigPath) {
return Get-Content $ConfigPath -Raw | ConvertFrom-Json
}
return $null
}
function Read-ConfigText {
if (Test-Path $ConfigPath) {
return Get-Content $ConfigPath -Raw
}
return ""
}
function Save-ConfigObject {
param([object]$Config)
$Config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8
}
function Save-ConfigText {
param([string]$Text)
$Text | Set-Content -Path $ConfigPath -Encoding UTF8
}
function Get-AgentTaskText {
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if (!$task) { return "Scheduled task not installed" }
$info = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue
return "$($task.State) | Last result: $($info.LastTaskResult) | Last run: $($info.LastRunTime)"
}
$form = New-Object System.Windows.Forms.Form
$form.Text = "MinerOps Agent"
$form.Size = New-Object System.Drawing.Size(950, 780)
$form.StartPosition = "CenterScreen"
$statusLabel = New-Object System.Windows.Forms.Label
$statusLabel.Location = New-Object System.Drawing.Point(15, 15)
$statusLabel.Size = New-Object System.Drawing.Size(900, 24)
$statusLabel.Text = "Task: " + (Get-AgentTaskText)
$form.Controls.Add($statusLabel)
$startButton = New-Object System.Windows.Forms.Button
$startButton.Text = "Start"
$startButton.Location = New-Object System.Drawing.Point(15, 45)
$startButton.Size = New-Object System.Drawing.Size(100, 32)
$form.Controls.Add($startButton)
$stopButton = New-Object System.Windows.Forms.Button
$stopButton.Text = "Stop"
$stopButton.Location = New-Object System.Drawing.Point(125, 45)
$stopButton.Size = New-Object System.Drawing.Size(100, 32)
$form.Controls.Add($stopButton)
$restartButton = New-Object System.Windows.Forms.Button
$restartButton.Text = "Restart"
$restartButton.Location = New-Object System.Drawing.Point(235, 45)
$restartButton.Size = New-Object System.Drawing.Size(100, 32)
$form.Controls.Add($restartButton)
$testButton = New-Object System.Windows.Forms.Button
$testButton.Text = "Test Hub"
$testButton.Location = New-Object System.Drawing.Point(345, 45)
$testButton.Size = New-Object System.Drawing.Size(100, 32)
$form.Controls.Add($testButton)
$saveButton = New-Object System.Windows.Forms.Button
$saveButton.Text = "Save Config"
$saveButton.Location = New-Object System.Drawing.Point(455, 45)
$saveButton.Size = New-Object System.Drawing.Size(120, 32)
$form.Controls.Add($saveButton)
$secretLabel = New-Object System.Windows.Forms.Label
$secretLabel.Text = "Agent Secret:"
$secretLabel.Location = New-Object System.Drawing.Point(15, 90)
$secretLabel.Size = New-Object System.Drawing.Size(100, 22)
$form.Controls.Add($secretLabel)
$secretBox = New-Object System.Windows.Forms.TextBox
$secretBox.Location = New-Object System.Drawing.Point(115, 87)
$secretBox.Size = New-Object System.Drawing.Size(570, 24)
$secretBox.UseSystemPasswordChar = $true
$form.Controls.Add($secretBox)
$showSecretCheck = New-Object System.Windows.Forms.CheckBox
$showSecretCheck.Text = "Show"
$showSecretCheck.Location = New-Object System.Drawing.Point(695, 88)
$showSecretCheck.Size = New-Object System.Drawing.Size(70, 24)
$form.Controls.Add($showSecretCheck)
$saveSecretButton = New-Object System.Windows.Forms.Button
$saveSecretButton.Text = "Save Secret"
$saveSecretButton.Location = New-Object System.Drawing.Point(775, 84)
$saveSecretButton.Size = New-Object System.Drawing.Size(120, 30)
$form.Controls.Add($saveSecretButton)
$configBox = New-Object System.Windows.Forms.TextBox
$configBox.Location = New-Object System.Drawing.Point(15, 125)
$configBox.Size = New-Object System.Drawing.Size(900, 350)
$configBox.Multiline = $true
$configBox.ScrollBars = "Both"
$configBox.Font = New-Object System.Drawing.Font("Consolas", 10)
$configBox.Text = Read-ConfigText
$form.Controls.Add($configBox)
$stateBox = New-Object System.Windows.Forms.TextBox
$stateBox.Location = New-Object System.Drawing.Point(15, 490)
$stateBox.Size = New-Object System.Drawing.Size(900, 85)
$stateBox.Multiline = $true
$stateBox.ScrollBars = "Vertical"
$stateBox.Font = New-Object System.Drawing.Font("Consolas", 9)
$form.Controls.Add($stateBox)
$logBox = New-Object System.Windows.Forms.TextBox
$logBox.Location = New-Object System.Drawing.Point(15, 590)
$logBox.Size = New-Object System.Drawing.Size(900, 130)
$logBox.Multiline = $true
$logBox.ScrollBars = "Vertical"
$logBox.Font = New-Object System.Drawing.Font("Consolas", 9)
$form.Controls.Add($logBox)
function Refresh-Display {
$statusLabel.Text = "Task: " + (Get-AgentTaskText)
$config = Read-Config
if ($config -and $secretBox.Text.Length -eq 0) {
$secretBox.Text = [string]$config.agent_secret
}
if (Test-Path $StatePath) {
$stateBox.Text = Get-Content $StatePath -Raw
} else {
$stateBox.Text = "No state file yet."
}
if (Test-Path $LogPath) {
$logBox.Text = (Get-Content $LogPath -Tail 30) -join [Environment]::NewLine
} else {
$logBox.Text = "No log file yet."
}
}
$showSecretCheck.Add_CheckedChanged({
$secretBox.UseSystemPasswordChar = -not $showSecretCheck.Checked
})
$saveSecretButton.Add_Click({
try {
$config = $configBox.Text | ConvertFrom-Json
$config.agent_secret = $secretBox.Text
$config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8
$configBox.Text = Get-Content $ConfigPath -Raw
[System.Windows.Forms.MessageBox]::Show("Secret saved. Restart the agent to apply changes.")
} catch {
[System.Windows.Forms.MessageBox]::Show("Could not save secret: " + $_.Exception.Message)
}
})
$startButton.Add_Click({
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
Refresh-Display
})
$stopButton.Add_Click({
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
Refresh-Display
})
$restartButton.Add_Click({
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
Refresh-Display
})
$saveButton.Add_Click({
try {
$null = $configBox.Text | ConvertFrom-Json
Save-ConfigText -Text $configBox.Text
[System.Windows.Forms.MessageBox]::Show("Config saved. Restart the agent to apply changes.")
} catch {
[System.Windows.Forms.MessageBox]::Show("Invalid JSON: " + $_.Exception.Message)
}
})
$testButton.Add_Click({
try {
$config = $configBox.Text | ConvertFrom-Json
if ($secretBox.Text.Length -gt 0) {
$config.agent_secret = $secretBox.Text
}
$hub = [string]$config.hub_url
$secret = [string]$config.agent_secret
$headers = @{
"X-MinerOps-Agent-Secret" = $secret
}
$result = Invoke-RestMethod -Uri ($hub.TrimEnd("/") + "/api/agent/config") -Headers $headers -TimeoutSec 20
[System.Windows.Forms.MessageBox]::Show("Hub test OK.`nSite: " + $result.site_name)
} catch {
[System.Windows.Forms.MessageBox]::Show("Hub test failed: " + $_.Exception.Message)
}
})
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 3000
$timer.Add_Tick({ Refresh-Display })
$timer.Start()
Refresh-Display
[void]$form.ShowDialog()
File diff suppressed because it is too large. Load diff
-108
View File
@@ -1,108 +0,0 @@
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$TaskName = "MinerOpsAgent"
$GuiPath = "C:\MinerOpsAgent\MinerOpsAgentGUI.ps1"
$LogPath = "C:\MinerOpsAgent\logs\agent.log"
$DashboardUrl = "https://plexus.lussier.cc/dashboard"
function Start-Agent {
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
}
function Stop-Agent {
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
}
function Restart-Agent {
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
}
function Open-Gui {
Start-Process powershell.exe -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$GuiPath`""
}
function Open-Dashboard {
Start-Process $DashboardUrl
}
function Open-Log {
if (Test-Path $LogPath) {
Start-Process notepad.exe $LogPath
}
}
function Get-AgentStatusText {
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if (!$task) {
return "MinerOps Agent: task not installed"
}
return "MinerOps Agent: $($task.State)"
}
$notify = New-Object System.Windows.Forms.NotifyIcon
$notify.Icon = [System.Drawing.SystemIcons]::Application
$notify.Text = Get-AgentStatusText
$notify.Visible = $true
$menu = New-Object System.Windows.Forms.ContextMenuStrip
$itemOpen = $menu.Items.Add("Open Agent GUI")
$itemOpen.Add_Click({ Open-Gui })
$itemDashboard = $menu.Items.Add("Open Plexus Dashboard")
$itemDashboard.Add_Click({ Open-Dashboard })
$menu.Items.Add("-") | Out-Null
$itemStart = $menu.Items.Add("Start Agent")
$itemStart.Add_Click({
Start-Agent
Start-Sleep -Seconds 1
$notify.Text = Get-AgentStatusText
})
$itemStop = $menu.Items.Add("Stop Agent")
$itemStop.Add_Click({
Stop-Agent
Start-Sleep -Seconds 1
$notify.Text = Get-AgentStatusText
})
$itemRestart = $menu.Items.Add("Restart Agent")
$itemRestart.Add_Click({
Restart-Agent
Start-Sleep -Seconds 1
$notify.Text = Get-AgentStatusText
})
$menu.Items.Add("-") | Out-Null
$itemLog = $menu.Items.Add("Open Log")
$itemLog.Add_Click({ Open-Log })
$itemExit = $menu.Items.Add("Exit Tray Icon")
$itemExit.Add_Click({
$notify.Visible = $false
$notify.Dispose()
[System.Windows.Forms.Application]::Exit()
})
$notify.ContextMenuStrip = $menu
$notify.Add_DoubleClick({
Open-Gui
})
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 5000
$timer.Add_Tick({
$notify.Text = Get-AgentStatusText
})
$timer.Start()
[System.Windows.Forms.Application]::Run()
@@ -1,246 +0,0 @@
param(
[int] $Limit = 25,
[int] $TimeoutMs = 2500
)
$ErrorActionPreference = "Stop"
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
if (-not (Test-Path $ConfigPath)) {
throw "Missing config file: $ConfigPath"
}
$Config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$HubUrl = [string]$Config.hub_url
$HubUrl = $HubUrl.TrimEnd("/")
$AgentId = [string]$Config.agent_id
$SiteName = [string]$Config.site_name
$Secret = [string]$Config.agent_secret
$Headers = @{
"X-MinerOps-Agent-Secret" = $Secret
}
function Invoke-WhatsMinerApi3Read {
param(
[Parameter(Mandatory=$true)] [string] $Ip,
[Parameter(Mandatory=$true)] [hashtable] $Request,
[int] $TimeoutMs = 2500
)
$client = $null
$stream = $null
try {
$json = ($Request | ConvertTo-Json -Depth 20 -Compress)
$payload = [System.Text.Encoding]::ASCII.GetBytes($json)
$length = [BitConverter]::GetBytes([Int32]$payload.Length)
$client = New-Object System.Net.Sockets.TcpClient
$client.ReceiveTimeout = $TimeoutMs
$client.SendTimeout = $TimeoutMs
$connect = $client.BeginConnect($Ip, 4433, $null, $null)
if (-not $connect.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
try { $client.Close() } catch {}
throw "Timed out connecting to $Ip`:4433"
}
$client.EndConnect($connect)
$stream = $client.GetStream()
$stream.ReadTimeout = $TimeoutMs
$stream.WriteTimeout = $TimeoutMs
$stream.Write($length, 0, 4)
$stream.Write($payload, 0, $payload.Length)
$stream.Flush()
$lenBuf = New-Object byte[] 4
$offset = 0
while ($offset -lt 4) {
$read = $stream.Read($lenBuf, $offset, 4 - $offset)
if ($read -le 0) {
throw "No response length received."
}
$offset += $read
}
$responseLength = [BitConverter]::ToInt32($lenBuf, 0)
if ($responseLength -le 0 -or $responseLength -gt 1048576) {
throw "Invalid response length: $responseLength"
}
$buffer = New-Object byte[] $responseLength
$offset = 0
while ($offset -lt $responseLength) {
$read = $stream.Read($buffer, $offset, $responseLength - $offset)
if ($read -le 0) {
throw "Response ended early at $offset of $responseLength bytes."
}
$offset += $read
}
$raw = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $responseLength)
$parsed = $raw | ConvertFrom-Json
return [pscustomobject]@{
ok = $true
parsed = $parsed
error = $null
}
}
catch {
return [pscustomobject]@{
ok = $false
parsed = $null
error = $_.Exception.Message
}
}
finally {
if ($stream) { try { $stream.Close() } catch {} }
if ($client) { try { $client.Close() } catch {} }
}
}
function Invoke-Api3OkCommand {
param(
[string] $Ip,
[hashtable] $Request
)
$result = Invoke-WhatsMinerApi3Read -Ip $Ip -Request $Request -TimeoutMs $TimeoutMs
if (-not $result.ok) {
return $null
}
if ($result.parsed.code -ne 0) {
return $null
}
return $result.parsed
}
Write-Host "Fetching known miners from $HubUrl"
$known = Invoke-RestMethod `
-Uri "$HubUrl/api/agent/known-miners" `
-Headers $Headers `
-Method Get
$miners = @($known.miners)
if ($Limit -gt 0) {
$miners = @($miners | Select-Object -First $Limit)
}
Write-Host "API3 enrichment check count: $($miners.Count)"
$items = New-Object System.Collections.Generic.List[object]
$checked = 0
$api3Ok = 0
$api3Failed = 0
foreach ($miner in $miners) {
$checked += 1
$ip = [string]$miner.ip_address
$mac = [string]$miner.mac_address
if (-not $ip) {
continue
}
Write-Host "[$checked/$($miners.Count)] $ip $mac"
$deviceInfoResult = Invoke-WhatsMinerApi3Read `
-Ip $ip `
-Request @{ cmd = "get.device.info" } `
-TimeoutMs $TimeoutMs
if (-not $deviceInfoResult.ok -or $deviceInfoResult.parsed.code -ne 0) {
$api3Failed += 1
$err = $deviceInfoResult.error
if ($deviceInfoResult.ok) {
$err = "API3 code $($deviceInfoResult.parsed.code): $($deviceInfoResult.parsed.msg)"
}
$items.Add([pscustomobject]@{
ip = $ip
mac = $mac
api3_supported = $false
api3_error = $err
device_info = $null
miner_setting = $null
miner_status = $null
fan_setting = $null
custom_data = $null
})
continue
}
$api3Ok += 1
$minerSetting = Invoke-Api3OkCommand `
-Ip $ip `
-Request @{ cmd = "get.miner.setting" }
$minerStatus = Invoke-Api3OkCommand `
-Ip $ip `
-Request @{ cmd = "get.miner.status"; param = "summary+pools+edevs" }
$fanSetting = Invoke-Api3OkCommand `
-Ip $ip `
-Request @{ cmd = "get.fan.setting" }
$customData = Invoke-Api3OkCommand `
-Ip $ip `
-Request @{ cmd = "get.device.custom_data" }
$items.Add([pscustomobject]@{
ip = $ip
mac = $mac
api3_supported = $true
api3_error = $null
device_info = $deviceInfoResult.parsed
miner_setting = $minerSetting
miner_status = $minerStatus
fan_setting = $fanSetting
custom_data = $customData
})
}
$payload = @{
agent_id = $AgentId
site_name = $SiteName
items = @($items.ToArray())
}
$json = $payload | ConvertTo-Json -Depth 80
Write-Host "Posting API3 enrichment results to hub..."
$result = Invoke-RestMethod `
-Uri "$HubUrl/api/agent/api3-enrichment" `
-Method Post `
-Headers $Headers `
-ContentType "application/json" `
-Body $json
Write-Host ""
Write-Host "Local checked: $checked"
Write-Host "API3 OK: $api3Ok"
Write-Host "API3 failed: $api3Failed"
Write-Host ""
$result | ConvertTo-Json -Depth 20
-201
View File
@@ -1,201 +0,0 @@
# Typed MinerOps miner-control execution. Loaded by MinerOpsAgentService.ps1.
function Get-MinerOpsAgentVersion {
$versionPath = Join-Path $PSScriptRoot "VERSION"
if (Test-Path $versionPath) {
return ([string](Get-Content $versionPath -Raw)).Trim()
}
return "unknown"
}
function Get-MinerOpsControlCredentials {
$config = Read-AgentConfig
$account = ([string]$config.api3_account).Trim()
$password = [string]$config.api3_password
$port = 4433
if ($config.api3_port) {
try { $port = [int]$config.api3_port } catch { $port = 4433 }
}
return @{
available = (-not [string]::IsNullOrWhiteSpace($account)) -and
(-not [string]::IsNullOrWhiteSpace($password))
account = $account
password = $password
port = $port
}
}
function Invoke-MinerOpsControlApi3Command {
param(
[Parameter(Mandatory=$true)][string]$IpAddress,
[Parameter(Mandatory=$true)][string]$Command,
[object]$Param = $null
)
$credentials = Get-MinerOpsControlCredentials
if (-not $credentials.available) { throw "credentials_missing" }
$saltInfo = Get-MinerOpsApi3Salt -IpAddress $IpAddress -Port $credentials.port
$timestamp = [int64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$token = Get-MinerOpsApi3Token -Command $Command -Password $credentials.password `
-Salt ([string]$saltInfo.salt) -Timestamp $timestamp
$payload = @{
cmd = $Command
ts = $timestamp
token = $token
account = $credentials.account
}
if ($null -ne $Param) { $payload["param"] = $Param }
$response = Invoke-MinerOpsApi3Payload -IpAddress $IpAddress `
-Port $credentials.port -Payload $payload
if ([int]$response.response.code -ne 0) { throw "command_rejected" }
return $response
}
function New-MinerOpsControlResult {
param(
[bool]$Accepted,
[bool]$VerificationAttempted,
[string]$VerificationStatus,
[string]$ObservedMode = "",
[string]$FailureCode = "",
[string]$QueueStatus = "completed"
)
return [ordered]@{
schema_version = 1
command_accepted = $Accepted
verification_attempted = $VerificationAttempted
verification_status = $VerificationStatus
observed_service_state = $null
observed_performance_mode = if ($ObservedMode) { $ObservedMode } else { $null }
failure_code = if ($FailureCode) { $FailureCode } else { $null }
_queue_status = $QueueStatus
}
}
function Run-MinerControlCommand {
param([Parameter(Mandatory=$true)][object]$Payload)
try {
if ($Payload -is [string]) { $Payload = $Payload | ConvertFrom-Json }
$action = ([string]$Payload.action).Trim().ToLower()
$ipAddress = ([string]$Payload.ip_address).Trim()
$requested = ([string]$Payload.requested_value).Trim().ToLower()
if (-not $requested -and $Payload.performance_mode) {
$requested = ([string]$Payload.performance_mode).Trim().ToLower()
}
if ($action -notin @("suspend", "resume", "performance_mode")) {
return New-MinerOpsControlResult -Accepted $false `
-VerificationAttempted $false -VerificationStatus "unavailable" `
-FailureCode "invalid_action" -QueueStatus "failed"
}
if ([string]::IsNullOrWhiteSpace($ipAddress)) {
return New-MinerOpsControlResult -Accepted $false `
-VerificationAttempted $false -VerificationStatus "unavailable" `
-FailureCode "target_missing" -QueueStatus "failed"
}
$credentials = Get-MinerOpsControlCredentials
if (-not $credentials.available) {
return New-MinerOpsControlResult -Accepted $false `
-VerificationAttempted $false -VerificationStatus "unavailable" `
-FailureCode "credentials_missing" -QueueStatus "failed"
}
if ($action -eq "performance_mode") {
if ($requested -notin @("low", "normal", "high")) {
return New-MinerOpsControlResult -Accepted $false `
-VerificationAttempted $false -VerificationStatus "unavailable" `
-FailureCode "invalid_mode" -QueueStatus "failed"
}
Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress `
-Command "set.miner.power_mode" -Param $requested | Out-Null
$observed = ""
$readUnavailable = $false
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
$read = Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress `
-Command "get.miner.setting"
$candidate = ([string]$read.response.msg.'power-mode').Trim().ToLower()
if ($candidate -in @("low", "normal", "high")) {
$observed = $candidate
if ($observed -eq $requested) {
return New-MinerOpsControlResult -Accepted $true `
-VerificationAttempted $true -VerificationStatus "confirmed" `
-ObservedMode $observed
}
}
} catch {
if ($_.Exception.Message -match "unsupported|unknown_command") {
$readUnavailable = $true
break
}
}
if ($attempt -lt 3) { Start-Sleep -Milliseconds 750 }
}
if ($readUnavailable) {
return New-MinerOpsControlResult -Accepted $true `
-VerificationAttempted $true -VerificationStatus "unavailable"
}
if ($observed) {
return New-MinerOpsControlResult -Accepted $true `
-VerificationAttempted $true `
-VerificationStatus "verification_failed" -ObservedMode $observed
}
return New-MinerOpsControlResult -Accepted $true `
-VerificationAttempted $true -VerificationStatus "unconfirmed"
}
$serviceAction = if ($action -eq "suspend") { "stop" } else { "start" }
Invoke-MinerOpsControlApi3Command -IpAddress $ipAddress `
-Command "set.miner.service" -Param $serviceAction | Out-Null
return New-MinerOpsControlResult -Accepted $true `
-VerificationAttempted $false -VerificationStatus "unconfirmed"
} catch {
$failure = if ($_.Exception.Message -eq "credentials_missing") {
"credentials_missing"
} else { "write_rejected" }
return New-MinerOpsControlResult -Accepted $false `
-VerificationAttempted $false -VerificationStatus "unavailable" `
-FailureCode $failure -QueueStatus "failed"
}
}
function Send-AgentCommandResult {
param(
[string]$CommandId,
[string]$Status,
[object]$ResultPayload = $null,
[string]$ErrorMessage = $null
)
if ($ResultPayload -and $ResultPayload._queue_status) {
$Status = [string]$ResultPayload._queue_status
$ResultPayload.Remove("_queue_status")
$ErrorMessage = if ($ResultPayload.failure_code) {
[string]$ResultPayload.failure_code
} else { $null }
}
$body = [ordered]@{
command_id = $CommandId
status = $Status
result_payload = $ResultPayload
error_message = $ErrorMessage
}
return Invoke-AgentApi -Method "POST" -Path "/api/agent/commands/result" -Body $body
}
function Send-AgentCheckin {
$config = Read-AgentConfig
$credentials = Get-MinerOpsControlCredentials
$body = [ordered]@{
agent_id = [string]$config.agent_id
site_id = [string]$config.site_id
site_name = [string]$config.site_name
hostname = $env:COMPUTERNAME
version = Get-MinerOpsAgentVersion
ip_address = ""
control_available = [bool]$credentials.available
control_unavailable_reason = if ($credentials.available) { $null } else { "credentials_missing" }
local_time = (Get-Date).ToString("o")
}
return Invoke-AgentApi -Method "POST" -Path "/api/agent/checkin" -Body $body
}
-146
View File
@@ -1,146 +0,0 @@
$ErrorActionPreference = "Stop"
$ConfigPath = "C:\MinerOpsAgent\agent-config.json"
$Config = Get-Content -Raw $ConfigPath | ConvertFrom-Json
$HubUrl = ($Config.hub_url + "").TrimEnd("/")
$AgentId = $Config.agent_id
$AgentSecret = $Config.agent_secret
$Headers = @{
"X-MinerOps-Agent-Secret" = $AgentSecret
}
function Normalize-MinerOpsIp {
param([string] $Ip)
if (-not $Ip) {
return ""
}
return ($Ip -replace "/32$", "").Trim()
}
function Invoke-MinerOps4028PoolsSimple {
param(
[Parameter(Mandatory=$true)] [string] $Ip,
[int] $Port = 4028,
[int] $TimeoutMs = 5000
)
$client = $null
$stream = $null
try {
$json = '{"command":"pools"}'
$client = New-Object System.Net.Sockets.TcpClient
$client.ReceiveTimeout = $TimeoutMs
$client.SendTimeout = $TimeoutMs
$client.Connect($Ip, $Port)
$stream = $client.GetStream()
$stream.ReadTimeout = $TimeoutMs
$stream.WriteTimeout = $TimeoutMs
$bytes = [System.Text.Encoding]::ASCII.GetBytes($json)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
Start-Sleep -Milliseconds 500
$buffer = New-Object byte[] 131072
$read = $stream.Read($buffer, 0, $buffer.Length)
if ($read -le 0) {
return $null
}
$raw = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $read).Trim([char]0).Trim()
if (-not $raw) {
return $null
}
$parsed = $raw | ConvertFrom-Json
if (-not $parsed.POOLS) {
return $null
}
return @($parsed.POOLS)
}
catch {
return $null
}
finally {
if ($stream) {
try { $stream.Close() } catch {}
}
if ($client) {
try { $client.Close() } catch {}
}
}
}
$response = Invoke-RestMethod -Method Get -Uri "$HubUrl/api/agent/known-miners" -Headers $Headers -TimeoutSec 60
$knownMiners = @()
if ($response.miners) {
$knownMiners = @($response.miners)
} elseif ($response.data -and $response.data.miners) {
$knownMiners = @($response.data.miners)
} elseif ($response.data -and $response.data.known_miners) {
$knownMiners = @($response.data.known_miners)
}
Write-Host "Known miners: $($knownMiners.Count)"
$poolSnapshots = New-Object System.Collections.Generic.List[object]
$count = 0
foreach ($miner in $knownMiners) {
$count++
$ip = Normalize-MinerOpsIp -Ip $miner.ip_address
if (-not $ip) {
continue
}
if (($count % 25) -eq 0) {
Write-Host "Checked $count / $($knownMiners.Count). Collected $($poolSnapshots.Count)."
}
$pools = @(Invoke-MinerOps4028PoolsSimple -Ip $ip)
if ($pools.Count -gt 0) {
$poolSnapshots.Add([pscustomobject]@{
miner_id = $miner.miner_id
ip_address = $ip
mac_address = $miner.mac_address
model = $miner.model
pools = @($pools)
})
}
}
Write-Host "Pool snapshots collected: $($poolSnapshots.Count)"
$body = @{
agent_id = $AgentId
pool_snapshots = $poolSnapshots
} | ConvertTo-Json -Depth 20
$result = Invoke-RestMethod `
-Method Post `
-Uri "$HubUrl/api/agent/pool-snapshots" `
-Headers $Headers `
-Body $body `
-ContentType "application/json" `
-TimeoutSec 180
$result | ConvertTo-Json -Depth 10
-28
View File
@@ -1,28 +0,0 @@
$LogDir = "C:\MinerOpsAgent\logs"
$LogFile = Join-Path $LogDir "pool-poller.log"
if (-not (Test-Path $LogDir)) {
New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
}
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
try {
Add-Content -Path $LogFile -Value "$stamp Starting MinerOps pool poller."
$output = powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\MinerOpsAgent\MinerOpsPoolPoller.ps1" 2>&1
foreach ($line in $output) {
Add-Content -Path $LogFile -Value "$line"
}
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Add-Content -Path $LogFile -Value "$stamp Pool poller finished."
Add-Content -Path $LogFile -Value ""
}
catch {
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Add-Content -Path $LogFile -Value "$stamp Pool poller failed: $($_.Exception.Message)"
Add-Content -Path $LogFile -Value ""
exit 1
}
-74
View File
@@ -1,74 +0,0 @@
# MinerOps Windows Agent
The Windows Agent runs at a mining site and performs local discovery, polling,
API3 enrichment, pool polling, and approved queued miner-control operations.
MinerOps does not send API3 credentials through the hub command queue; those
credentials remain in the Agent's local `agent-config.json`.
## Recovered Baseline
The seven runtime and support scripts in this directory were recovered from the
live `C:\MinerOpsAgent` installation. `BASELINE_SHA256SUMS` records their exact
pre-change hashes. Historical backups, live configuration, state, logs, and
probe output are intentionally excluded.
## Local Files
Create `agent-config.json` from `config.example.json` on the Agent host. Never
commit that file. `site_id` is the durable MinerOps site UUID; `site_name` is
retained only for transitional display compatibility.
The Agent writes runtime state to `agent-state.json` and logs under `logs/`.
Installation and upgrades preserve all three paths.
## Security
API3 account and password values are local secrets. Missing credentials make
miner control unavailable. Passwords, API tokens, salts, and the Agent secret
must not be written to normal logs or returned to MinerOps command history.
The Internal Alpha supports one Agent identity protected by the deployment
shared secret. A second active Agent requires per-Agent credentials before it
may be enabled.
## Legacy Compatibility
The older direct Hub API3, `/agent-control`, password-change, bulk-control, and
scheduling paths remain available for compatibility and are deprecated for the
Internal Alpha. The new Miner Detail controls use only the typed control API and
Agent queue; the modular control backend does not connect directly to miners.
## Deployment
Deploy Agent compatibility before enabling Hub enforcement of the
`X-MinerOps-Agent-ID` header:
1. Copy this directory to a temporary folder on the Windows Agent host.
2. Create or update `C:\MinerOpsAgent\agent-config.json` locally. Do not pass
secrets as installer or scheduled-task arguments.
3. Run `install.ps1` from an elevated Windows PowerShell session.
4. Confirm the four scheduled tasks are registered and that Agent check-in
reports the expected non-secret `VERSION`.
5. Enable the matching Agent ID and shared secret on the Hub.
The installer copies only managed runtime code to `C:\MinerOpsAgent` and
preserves `agent-config.json`, `agent-state.json`, `logs\`, backups, probe
output, and other runtime data. During an upgrade, existing definitions for
`MinerOpsAgent`, `MinerOpsAgentTray`, `MinerOpsApi3Enrichment`, and
`MinerOpsPoolPoller` are authoritative and are not rewritten. Missing tasks are
created for a fresh or incomplete installation without hard-coded user names.
If `MinerOpsAgent` was running before an upgrade, the installer stops only that
Scheduled Task, waits for it to stop, replaces the managed files, and starts it
again. A previously stopped Agent remains stopped. The tray and periodic tasks
are not unnecessarily restarted.
Before replacing existing runtime files, the installer copies them to a
timestamped folder under `C:\MinerOpsAgent\backups`. A failed deployment
automatically restores those managed files, removes tasks created by that
failed attempt, and restores the main Agent's prior running state. For a manual
rollback, stop `MinerOpsAgent`, restore the selected backup's runtime files,
and restart it only if it was previously running. `uninstall.ps1` unregisters
the four tasks and removes runtime code while preserving local configuration,
state, logs, and backups.
-1
View File
@@ -1 +0,0 @@
1.0.0-alpha
-21
View File
@@ -1,21 +0,0 @@
{
"hub_url": "https://minerops.example.invalid",
"agent_id": "replace-with-agent-id",
"site_id": "00000000-0000-0000-0000-000000000000",
"site_name": "Example Site",
"agent_secret": "replace-with-agent-secret",
"poll_interval_seconds": 120,
"ip_ranges": [
{
"name": "Example Range",
"cidr": "192.0.2.0/24",
"enabled": false,
"api_port": 4028,
"api3_port": 4433
}
],
"api3_account": "replace-with-api3-account",
"api3_password": "replace-with-api3-password",
"api3_port": 4433
}
-202
View File
@@ -1,202 +0,0 @@
#Requires -RunAsAdministrator
$ErrorActionPreference = "Stop"
$Target = "C:\MinerOpsAgent"
$BackupRoot = Join-Path $Target "backups"
$BackupTimestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$BackupSuffix = [guid]::NewGuid().ToString("N").Substring(0, 8)
$Backup = Join-Path $BackupRoot "$BackupTimestamp-$BackupSuffix"
$RuntimeFiles = @(
"MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1",
"MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1",
"MinerOpsPoolPollerTask.ps1", "EnrichOneMiner.ps1", "MinerOpsControl.ps1",
"VERSION"
)
function Get-MinerOpsScheduledTask {
param([Parameter(Mandatory)][string]$Name)
return Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
}
function Stop-MinerOpsScheduledTaskForUpgrade {
param(
[Parameter(Mandatory)][string]$Name,
[TimeSpan]$Timeout = [TimeSpan]::FromSeconds(30)
)
Stop-ScheduledTask -TaskName $Name -ErrorAction Stop
$deadline = [DateTime]::UtcNow.Add($Timeout)
do {
Start-Sleep -Milliseconds 500
$task = Get-MinerOpsScheduledTask -Name $Name
if ($null -eq $task) {
throw "Scheduled Task $Name disappeared while stopping it."
}
if ([string]$task.State -ne "Running") {
return
}
} while ([DateTime]::UtcNow -lt $deadline)
throw "Scheduled Task $Name did not stop within $($Timeout.TotalSeconds) seconds."
}
function Start-MinerOpsScheduledTask {
param([Parameter(Mandatory)][string]$Name)
Start-ScheduledTask -TaskName $Name -ErrorAction Stop
}
# Validate the complete source before creating directories, stopping tasks, or
# replacing any target file.
foreach ($name in $RuntimeFiles) {
$source = Join-Path $PSScriptRoot $name
if (!(Test-Path -LiteralPath $source -PathType Leaf)) {
throw "Deployment source is missing $name"
}
}
$mainTask = Get-MinerOpsScheduledTask -Name "MinerOpsAgent"
$mainTaskExisted = $null -ne $mainTask
$mainTaskWasRunning = $mainTaskExisted -and [string]$mainTask.State -eq "Running"
$mainTaskStoppedForUpgrade = $false
$deploymentStarted = $false
$existing = @()
$createdTaskNames = [System.Collections.Generic.List[string]]::new()
try {
New-Item -ItemType Directory -Force -Path $Target, $BackupRoot | Out-Null
$existing = @($RuntimeFiles | Where-Object {
Test-Path -LiteralPath (Join-Path $Target $_) -PathType Leaf
})
if ($existing.Count -gt 0) {
New-Item -ItemType Directory -Force -Path $Backup | Out-Null
foreach ($name in $existing) {
Copy-Item -LiteralPath (Join-Path $Target $name) `
-Destination (Join-Path $Backup $name) -Force
}
}
if ($mainTaskWasRunning) {
$mainTaskStoppedForUpgrade = $true
Stop-MinerOpsScheduledTaskForUpgrade -Name "MinerOpsAgent"
}
$deploymentStarted = $true
foreach ($name in $RuntimeFiles) {
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) `
-Destination (Join-Path $Target $name) -Force
}
$powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
$system = New-ScheduledTaskPrincipal -UserId "SYSTEM" `
-LogonType ServiceAccount -RunLevel Highest
$startup = New-ScheduledTaskTrigger -AtStartup
$fiveMinutes = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
-RepetitionInterval (New-TimeSpan -Minutes 5)
$commonSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew `
-StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit (New-TimeSpan -Hours 72)
$agentSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew `
-StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit (New-TimeSpan -Hours 72) -RestartCount 3 `
-RestartInterval (New-TimeSpan -Minutes 1)
$definitions = @(
@{
Name = "MinerOpsAgent"; Script = "MinerOpsAgentService.ps1"
Trigger = $startup; Principal = $system; Settings = $agentSettings
ExtraArguments = ""
},
@{
Name = "MinerOpsAgentTray"; Script = "MinerOpsAgentTray.ps1"
Trigger = (New-ScheduledTaskTrigger -AtLogOn); Principal = $null
Settings = $commonSettings; ExtraArguments = "-WindowStyle Hidden"
},
@{
Name = "MinerOpsApi3Enrichment"
Script = "MinerOpsApi3EnrichmentPoller.ps1"
Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings
ExtraArguments = "-Limit 0 -TimeoutMs 8000"
},
@{
Name = "MinerOpsPoolPoller"; Script = "MinerOpsPoolPollerTask.ps1"
Trigger = $fiveMinutes; Principal = $system; Settings = $commonSettings
ExtraArguments = ""
}
)
foreach ($definition in $definitions) {
if ($null -ne (Get-MinerOpsScheduledTask -Name $definition.Name)) {
Write-Host "Preserving existing Scheduled Task $($definition.Name)."
continue
}
$arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f `
(Join-Path $Target $definition.Script)
if ($definition.ExtraArguments) {
$arguments = "$arguments $($definition.ExtraArguments)"
}
$action = New-ScheduledTaskAction -Execute $powerShell -Argument $arguments
$registration = @{
TaskName = $definition.Name
Action = $action
Trigger = $definition.Trigger
Settings = $definition.Settings
}
if ($definition.Principal) {
$registration.Principal = $definition.Principal
}
Register-ScheduledTask @registration | Out-Null
$createdTaskNames.Add($definition.Name)
}
if ($mainTaskWasRunning) {
Start-MinerOpsScheduledTask -Name "MinerOpsAgent"
$mainTaskStoppedForUpgrade = $false
}
Write-Host "MinerOps Windows Agent installed. Existing task definitions, config, state, logs, and backups were preserved."
} catch {
$deploymentError = $_
$rollbackErrors = [System.Collections.Generic.List[string]]::new()
foreach ($taskName in $createdTaskNames) {
try {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false `
-ErrorAction Stop
} catch {
$rollbackErrors.Add("could not remove new task ${taskName}: $($_.Exception.Message)")
}
}
if ($deploymentStarted) {
foreach ($name in $RuntimeFiles) {
try {
$targetPath = Join-Path $Target $name
if ($existing -contains $name) {
Copy-Item -LiteralPath (Join-Path $Backup $name) `
-Destination $targetPath -Force
} elseif (Test-Path -LiteralPath $targetPath) {
Remove-Item -LiteralPath $targetPath -Force
}
} catch {
$rollbackErrors.Add("could not restore ${name}: $($_.Exception.Message)")
}
}
}
if ($mainTaskWasRunning -and $mainTaskStoppedForUpgrade) {
try {
Start-MinerOpsScheduledTask -Name "MinerOpsAgent"
$mainTaskStoppedForUpgrade = $false
} catch {
$rollbackErrors.Add("could not restart restored MinerOpsAgent: $($_.Exception.Message)")
}
}
if ($rollbackErrors.Count -gt 0) {
$rollbackSummary = $rollbackErrors -join "; "
throw "Deployment failed: $($deploymentError.Exception.Message). Rollback errors: $rollbackSummary"
}
throw $deploymentError
}
@@ -1,84 +0,0 @@
$ErrorActionPreference = "Stop"
$script:config = [pscustomobject]@{ api3_account = "operator"; api3_password = "secret"; api3_port = 4433 }
$script:calls = @()
$script:outcomes = @()
$script:outcomeIndex = 0
function Read-AgentConfig { return $script:config }
. (Join-Path (Split-Path $PSScriptRoot -Parent) "MinerOpsControl.ps1")
function Invoke-MinerOpsControlApi3Command {
param([string]$IpAddress, [string]$Command, [object]$Param = $null)
$script:calls += [pscustomobject]@{ command = $Command; param = $Param }
$outcome = $script:outcomes[$script:outcomeIndex]
$script:outcomeIndex += 1
if ($outcome -is [System.Exception]) { throw $outcome }
return $outcome
}
function Assert-Equal($Actual, $Expected, [string]$Message) {
if ($Actual -ne $Expected) { throw "$Message. Expected '$Expected', got '$Actual'." }
}
function Reset-Harness([object[]]$Outcomes) {
$script:calls = @()
$script:outcomes = @($Outcomes)
$script:outcomeIndex = 0
}
function Response([string]$Mode = "") {
return @{ response = @{ code = 0; msg = @{ "power-mode" = $Mode } } }
}
$payload = @{ action = "performance_mode"; requested_value = "normal"; ip_address = "192.0.2.1" }
$script:config = [pscustomobject]@{ api3_account = ""; api3_password = ""; api3_port = 4433 }
Reset-Harness @()
$result = Run-MinerControlCommand -Payload $payload
Assert-Equal $result.failure_code "credentials_missing" "Missing credentials code"
Assert-Equal $script:calls.Count 0 "Missing credentials must not contact API3"
$script:config = [pscustomobject]@{ api3_account = "operator"; api3_password = "secret"; api3_port = 4433 }
Reset-Harness @((Response), (Response "normal"))
$result = Run-MinerControlCommand -Payload $payload
Assert-Equal $result.verification_status "confirmed" "Performance confirmation"
Assert-Equal @($script:calls | Where-Object command -Like "set.*").Count 1 "Exactly one write"
Reset-Harness @((Response), (Response "low"), (Response "low"), (Response "low"))
$result = Run-MinerControlCommand -Payload $payload
Assert-Equal $result.verification_status "verification_failed" "Contradictory mode"
Assert-Equal $script:calls.Count 4 "One write and three bounded reads"
Reset-Harness @((Response), (Response), (Response), (Response))
$result = Run-MinerControlCommand -Payload $payload
Assert-Equal $result.verification_status "unconfirmed" "Ambiguous reads"
Reset-Harness @((Response), [System.Exception]::new("unknown_command"))
$result = Run-MinerControlCommand -Payload $payload
Assert-Equal $result.verification_status "unavailable" "Unavailable read path"
foreach ($action in @("suspend", "resume")) {
Reset-Harness @((Response))
$result = Run-MinerControlCommand -Payload @{
action = $action; requested_value = $null; ip_address = "192.0.2.1"
}
Assert-Equal $result.command_accepted $true "$action accepted"
Assert-Equal $result.verification_status "unconfirmed" "$action remains unconfirmed"
Assert-Equal $result.observed_service_state $null "$action has no invented state"
Assert-Equal @($script:calls | Where-Object command -Like "set.*").Count 1 "$action one write"
}
Reset-Harness @([System.Exception]::new("vendor response included secret"))
$result = Run-MinerControlCommand -Payload @{
action = "suspend"; requested_value = $null; ip_address = "192.0.2.1"
}
Assert-Equal $result.failure_code "write_rejected" "Write rejection is sanitized"
$json = $result | ConvertTo-Json -Compress
if ($json -match "vendor|secret|192\\.0\\.2\\.1") { throw "Typed result leaked raw data" }
Reset-Harness @()
$result = Run-MinerControlCommand -Payload @{
action = "performance_mode"; requested_value = "turbo"; ip_address = "192.0.2.1"
}
Assert-Equal $result.failure_code "invalid_mode" "Invalid mode code"
Assert-Equal $script:calls.Count 0 "Invalid mode must not contact API3"
Write-Host "MinerOps typed Agent control tests passed."
@@ -1,60 +0,0 @@
$ErrorActionPreference = "Stop"
$installer = Join-Path (Split-Path $PSScriptRoot -Parent) "install.ps1"
$tokens = $null
$parseErrors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
$installer, [ref]$tokens, [ref]$parseErrors
) | Out-Null
if ($parseErrors.Count -gt 0) {
throw "install.ps1 parser errors: $($parseErrors.Message -join '; ')"
}
$source = Get-Content -LiteralPath $installer -Raw
function Assert-SourceContains {
param([Parameter(Mandatory)][string]$Pattern, [Parameter(Mandatory)][string]$Message)
if ($source -notmatch $Pattern) { throw $Message }
}
$runtimeMatch = [regex]::Match(
$source,
'(?s)\$RuntimeFiles\s*=\s*@\((?<files>.*?)\)'
)
if (!$runtimeMatch.Success) { throw "Runtime file allowlist was not found." }
$runtimeFiles = $runtimeMatch.Groups["files"].Value
foreach ($forbidden in @("agent-config.json", "agent-state.json", "logs", "backups")) {
if ($runtimeFiles -match [regex]::Escape($forbidden)) {
throw "$forbidden must not be a managed deployment target."
}
}
$preflight = $source.IndexOf('# Validate the complete source')
$firstTargetChange = $source.IndexOf('New-Item -ItemType Directory')
if ($preflight -lt 0 -or $preflight -gt $firstTargetChange) {
throw "Source preflight must happen before target changes."
}
Assert-SourceContains 'if \(\$null -ne \(Get-MinerOpsScheduledTask' `
"Existing task preservation guard is missing."
Assert-SourceContains 'Preserving existing Scheduled Task' `
"Existing task preservation is not explicit."
Assert-SourceContains 'ExtraArguments\s*=\s*"-Limit 0 -TimeoutMs 8000"' `
"Fresh enrichment task arguments are missing."
Assert-SourceContains 'Stop-MinerOpsScheduledTaskForUpgrade' `
"Bounded main Agent stop behavior is missing."
Assert-SourceContains '\$mainTaskWasRunning' `
"Main Agent prior running state is not represented."
Assert-SourceContains 'Copy-Item[^\r\n]*\(Join-Path \$Backup \$name\)' `
"Rollback does not restore managed files from backup."
Assert-SourceContains 'Unregister-ScheduledTask' `
"Rollback does not remove tasks created by a failed install."
Assert-SourceContains 'could not restart restored MinerOpsAgent' `
"Rollback restart failure is not surfaced."
if ($source -match 'Register-ScheduledTask[^\r\n]*-Force') {
throw "Installer must not force-overwrite Scheduled Tasks."
}
if ($source -match '(?i)agent_secret|api3_password|api3_account') {
throw "Installer must not inspect or print credentials."
}
Write-Host "MinerOps installer source safety tests passed."
-21
View File
@@ -1,21 +0,0 @@
#Requires -RunAsAdministrator
$ErrorActionPreference = "Stop"
$Target = "C:\MinerOpsAgent"
$Tasks = @(
"MinerOpsAgent", "MinerOpsAgentTray", "MinerOpsApi3Enrichment", "MinerOpsPoolPoller"
)
foreach ($name in $Tasks) {
Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue
}
$RuntimeFiles = @(
"MinerOpsAgentService.ps1", "MinerOpsAgentTray.ps1", "MinerOpsAgentGUI.ps1",
"MinerOpsApi3EnrichmentPoller.ps1", "MinerOpsPoolPoller.ps1",
"MinerOpsPoolPollerTask.ps1", "EnrichOneMiner.ps1", "MinerOpsControl.ps1",
"VERSION"
)
foreach ($name in $RuntimeFiles) {
Remove-Item (Join-Path $Target $name) -Force -ErrorAction SilentlyContinue
}
Write-Host "MinerOps Agent tasks and code removed. Config, state, logs, and backups were preserved."
@@ -1,137 +0,0 @@
"""add miner control foundation
Revision ID: 0015_miner_control_foundation
Revises: 0014_repair_parts_tracking
Create Date: 2026-08-24
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0015_miner_control_foundation"
down_revision: Union[str, None] = "0014_repair_parts_tracking"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
QUEUE_STATUSES = ("pending", "claimed", "completed", "failed", "cancelled")
def _format_values(values: object) -> str:
return ", ".join(str(value) for value in (values or []))
def upgrade() -> None:
connection = op.get_bind()
unknown_statuses = connection.execute(
sa.text(
"""
SELECT array_agg(DISTINCT command_status ORDER BY command_status)
FROM minerops_agent_command_queue
WHERE command_status NOT IN ('pending', 'claimed', 'completed', 'failed', 'cancelled')
"""
),
).scalar()
if unknown_statuses:
raise RuntimeError(
"Cannot upgrade to 0015: minerops_agent_command_queue contains "
f"unsupported command_status values: {_format_values(unknown_statuses)}. "
"Correct those rows to pending, claimed, completed, failed, or cancelled "
"before retrying the migration."
)
duplicate_live_miners = connection.execute(
sa.text(
"""
SELECT request_payload ->> 'miner_id' AS miner_id
FROM minerops_agent_command_queue
WHERE command_type = 'miner_control'
AND command_status IN ('pending', 'claimed')
AND nullif(trim(request_payload ->> 'miner_id'), '') IS NOT NULL
GROUP BY request_payload ->> 'miner_id'
HAVING count(*) > 1
ORDER BY request_payload ->> 'miner_id'
"""
)
).scalars().all()
if duplicate_live_miners:
raise RuntimeError(
"Cannot upgrade to 0015: more than one pending or claimed miner_control "
"command exists for miner UUID(s): "
f"{_format_values(duplicate_live_miners)}. Resolve or cancel the duplicate "
"live commands before retrying the migration; no queue history was changed."
)
op.add_column(
"minerops_site_agents",
sa.Column("site_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_minerops_site_agents_site_id",
"minerops_site_agents",
"sites",
["site_id"],
["id"],
ondelete="RESTRICT",
)
op.create_index(
"uq_minerops_site_agents_site_id",
"minerops_site_agents",
["site_id"],
unique=True,
postgresql_where=sa.text("site_id IS NOT NULL"),
)
op.create_check_constraint(
"ck_minerops_agent_command_queue_status",
"minerops_agent_command_queue",
"command_status IN ('pending', 'claimed', 'completed', 'failed', 'cancelled')",
)
op.create_index(
"uq_agent_queue_live_miner_control",
"minerops_agent_command_queue",
[sa.text("(request_payload ->> 'miner_id')")],
unique=True,
postgresql_where=sa.text(
"command_type = 'miner_control' "
"AND command_status IN ('pending', 'claimed')"
),
)
op.create_index(
"idx_agent_queue_miner_control_history",
"minerops_agent_command_queue",
[
sa.text("(request_payload ->> 'miner_id')"),
sa.text("created_at DESC"),
sa.text("id DESC"),
],
postgresql_where=sa.text("command_type = 'miner_control'"),
)
def downgrade() -> None:
op.drop_index(
"idx_agent_queue_miner_control_history",
table_name="minerops_agent_command_queue",
)
op.drop_index(
"uq_agent_queue_live_miner_control",
table_name="minerops_agent_command_queue",
)
op.drop_constraint(
"ck_minerops_agent_command_queue_status",
"minerops_agent_command_queue",
type_="check",
)
op.drop_index(
"uq_minerops_site_agents_site_id",
table_name="minerops_site_agents",
)
op.drop_constraint(
"fk_minerops_site_agents_site_id",
"minerops_site_agents",
type_="foreignkey",
)
op.drop_column("minerops_site_agents", "site_id")
-1
View File
@@ -1 +0,0 @@
"""Authenticated Agent command transport for MinerOps."""
@@ -1,37 +0,0 @@
"""Dependencies for the authenticated Agent queue protocol."""
import hmac
from typing import Annotated
from fastapi import Header, HTTPException
from app.agent_commands.repository import AgentCommandRepository
from app.agent_commands.service import AgentCommandService
from app.config import settings
def get_agent_command_service() -> AgentCommandService:
return AgentCommandService(AgentCommandRepository())
def require_agent_identity(
agent_id: Annotated[str | None, Header(alias="X-MinerOps-Agent-ID")] = None,
provided_secret: Annotated[
str | None, Header(alias="X-MinerOps-Agent-Secret")
] = None,
) -> str:
expected_id = settings.minerops_agent_id.strip()
expected_secret = settings.minerops_agent_shared_secret.strip()
if not expected_id or not expected_secret:
raise HTTPException(status_code=503, detail="Agent authentication is not configured")
secret = (provided_secret or "").strip()
if not secret or not hmac.compare_digest(secret, expected_secret):
raise HTTPException(status_code=401, detail="Invalid or missing Agent secret")
identity = (agent_id or "").strip()
if not identity:
raise HTTPException(status_code=401, detail="Missing Agent identity")
if not hmac.compare_digest(identity, expected_id):
raise HTTPException(status_code=403, detail="Agent identity is not authorized")
return identity
-17
View File
@@ -1,17 +0,0 @@
"""Domain-neutral Agent command protocol errors."""
class AgentCommandError(Exception):
"""Base Agent command protocol error."""
class AgentCommandNotFound(AgentCommandError):
"""The submitted command identifier is unknown."""
class AgentCommandConflict(AgentCommandError):
"""The queue row cannot accept the requested transition."""
class AgentResultValidationError(AgentCommandError):
"""The Agent result does not match its command contract."""
-57
View File
@@ -1,57 +0,0 @@
"""Typed contracts for the Agent queue protocol."""
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
class AgentCommand(BaseModel):
command_id: str
command_type: str
request_payload: dict[str, Any]
created_at: datetime
class AgentCommandListResponse(BaseModel):
ok: Literal[True] = True
commands: list[AgentCommand]
class MinerControlResultPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
schema_version: Literal[1]
command_accepted: bool
verification_attempted: bool
verification_status: Literal[
"confirmed", "verification_failed", "unconfirmed", "unavailable"
]
observed_service_state: None = None
observed_performance_mode: Literal["low", "normal", "high"] | None = None
failure_code: str | None = Field(
default=None, max_length=80, pattern=r"^[a-z0-9_]+$"
)
@model_validator(mode="after")
def validate_truthfulness(self) -> "MinerControlResultPayload":
if self.verification_status == "confirmed" and not self.verification_attempted:
raise ValueError("confirmed results require verification_attempted")
if not self.command_accepted and not self.failure_code:
raise ValueError("rejected commands require a failure_code")
return self
class AgentCommandResultRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
command_id: str = Field(min_length=1, max_length=128)
status: Literal["completed", "failed"]
result_payload: dict[str, Any] = Field(default_factory=dict)
error_message: str | None = Field(default=None, max_length=255)
class AgentCommandResultResponse(BaseModel):
ok: Literal[True] = True
command_id: str
status: Literal["completed", "failed"]
-130
View File
@@ -1,130 +0,0 @@
"""PostgreSQL persistence for Agent command transport."""
from collections.abc import Callable
from typing import Any
import psycopg
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.agent_commands.exceptions import AgentCommandConflict, AgentCommandNotFound
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
CLAIM_COMMANDS_SQL = """
WITH candidates AS (
SELECT id
FROM minerops_agent_command_queue
WHERE agent_id = %s
AND command_status = 'pending'
ORDER BY created_at, id
FOR UPDATE SKIP LOCKED
LIMIT %s
)
UPDATE minerops_agent_command_queue AS queue
SET command_status = 'claimed', claimed_at = now()
FROM candidates
WHERE queue.id = candidates.id
AND queue.command_status = 'pending'
RETURNING queue.id AS command_id, queue.command_type,
queue.request_payload, queue.created_at
"""
def default_connection_factory() -> psycopg.Connection:
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
connect_timeout=5,
)
class AgentCommandRepository:
"""Own queue locking and immutable terminal transitions."""
def __init__(self, connection_factory: ConnectionFactory | None = None):
self._connection_factory = connection_factory or default_connection_factory
def claim_pending(self, agent_id: str, limit: int = 5) -> list[dict]:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, limit))
rows = [dict(row) for row in cur.fetchall()]
connection.commit()
return rows
except Exception:
connection.rollback()
raise
finally:
connection.close()
def get_command(self, command_id: str) -> dict | None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id, agent_id, command_type, command_status, request_payload
FROM minerops_agent_command_queue WHERE id = %s
""",
(command_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
def finish_command(
self,
command_id: str,
agent_id: str,
status: str,
result_payload: dict[str, Any],
error_message: str | None,
) -> None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
UPDATE minerops_agent_command_queue
SET command_status = %s, result_payload = %s,
error_message = %s, completed_at = now()
WHERE id = %s AND agent_id = %s AND command_status = 'claimed'
RETURNING id
""",
(status, Jsonb(result_payload), error_message, command_id, agent_id),
)
if cur.fetchone():
connection.commit()
return
cur.execute(
"""
SELECT agent_id, command_status
FROM minerops_agent_command_queue WHERE id = %s
""",
(command_id,),
)
row = cur.fetchone()
connection.rollback()
if not row:
raise AgentCommandNotFound("Agent command not found")
if row["agent_id"] != agent_id:
raise AgentCommandConflict("Agent command belongs to another Agent")
raise AgentCommandConflict(
f"Agent command is {row['command_status']}, not claimed"
)
except (AgentCommandNotFound, AgentCommandConflict):
raise
except Exception:
connection.rollback()
raise
finally:
connection.close()
-57
View File
@@ -1,57 +0,0 @@
"""HTTP boundary for authenticated Agent command transport."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from app.agent_commands.dependencies import get_agent_command_service, require_agent_identity
from app.agent_commands.exceptions import (
AgentCommandConflict,
AgentCommandNotFound,
AgentResultValidationError,
)
from app.agent_commands.models import (
AgentCommandListResponse,
AgentCommandResultRequest,
AgentCommandResultResponse,
)
from app.agent_commands.service import AgentCommandService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/agent/commands", tags=["agent-commands"])
AgentIdentity = Annotated[str, Depends(require_agent_identity)]
AgentService = Annotated[AgentCommandService, Depends(get_agent_command_service)]
@router.get("/pending", response_model=AgentCommandListResponse)
def claim_pending_commands(
request: Request, agent_id: AgentIdentity, service: AgentService
) -> AgentCommandListResponse:
before_claim = getattr(
request.app.state, "agent_command_before_claim", None
)
if callable(before_claim):
try:
before_claim()
except Exception:
logger.exception("Scheduled command materialization failed before Agent claim")
return AgentCommandListResponse(commands=service.claim(agent_id))
@router.post("/result", response_model=AgentCommandResultResponse)
def submit_command_result(
request: AgentCommandResultRequest,
agent_id: AgentIdentity,
service: AgentService,
) -> AgentCommandResultResponse:
try:
service.submit_result(agent_id, request)
except AgentCommandNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except AgentCommandConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except AgentResultValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return AgentCommandResultResponse(command_id=request.command_id, status=request.status)
-87
View File
@@ -1,87 +0,0 @@
"""Agent command transport coordination and result validation."""
import re
from pydantic import ValidationError
from app.agent_commands.exceptions import (
AgentCommandConflict,
AgentCommandNotFound,
AgentResultValidationError,
)
from app.agent_commands.models import (
AgentCommand,
AgentCommandResultRequest,
MinerControlResultPayload,
)
from app.agent_commands.repository import AgentCommandRepository
class AgentCommandService:
def __init__(self, repository: AgentCommandRepository):
self.repository = repository
def claim(self, agent_id: str) -> list[AgentCommand]:
return [
AgentCommand.model_validate(row)
for row in self.repository.claim_pending(agent_id)
]
@staticmethod
def _legacy_error(value: str | None) -> str | None:
if not value:
return None
sanitized = re.sub(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", "[address]", value)
return sanitized[:255]
def submit_result(
self, agent_id: str, request: AgentCommandResultRequest
) -> None:
command = self.repository.get_command(request.command_id)
if not command:
raise AgentCommandNotFound("Agent command not found")
if command["agent_id"] != agent_id:
raise AgentCommandConflict("Agent command belongs to another Agent")
if command["command_status"] != "claimed":
raise AgentCommandConflict(
f"Agent command is {command['command_status']}, not claimed"
)
result_payload = request.result_payload
error_message = self._legacy_error(request.error_message)
if command["command_type"] == "miner_control":
try:
result = MinerControlResultPayload.model_validate(request.result_payload)
except ValidationError as exc:
raise AgentResultValidationError(
"Invalid typed miner-control result"
) from exc
if request.status == "completed" and not result.command_accepted:
raise AgentResultValidationError(
"Completed miner-control results must be accepted"
)
if request.status == "failed" and result.command_accepted:
raise AgentResultValidationError(
"Failed miner-control results cannot be accepted"
)
action = (command.get("request_payload") or {}).get("action")
if (
action in {"suspend", "resume"}
and result.command_accepted
and result.verification_status != "unconfirmed"
):
raise AgentResultValidationError(
"Accepted suspend and resume results must remain unconfirmed"
)
result_payload = result.model_dump(mode="json")
error_message = (
result.failure_code.replace("_", " ") if result.failure_code else None
)
self.repository.finish_command(
request.command_id,
agent_id,
request.status,
result_payload,
error_message,
)
-5
View File
@@ -5,8 +5,6 @@ class Settings(BaseSettings):
app_name: str = "MinerOps Console"
app_env: str = "development"
app_timezone: str = "America/Winnipeg"
minerops_build_commit: str | None = None
minerops_build_version: str | None = None
postgres_db: str = "minerops"
postgres_user: str = "minerops"
@@ -16,9 +14,6 @@ class Settings(BaseSettings):
redis_url: str = "redis://redis:6379/0"
minerops_agent_id: str = "plexus-windows-agent"
minerops_agent_shared_secret: str = ""
jwt_secret_key: str
jwt_access_token_minutes: int = 30
jwt_refresh_token_days: int = 14
-32
View File
@@ -48,38 +48,6 @@ def miner_detail_content() -> str:
<dl class="metric-grid" id="minerTelemetryDetails"></dl>
</section>
</div>
<section class="panel miner-controls" id="minerControlsSection"
aria-labelledby="minerControlsTitle">
<div class="panel-head"><div><p class="eyebrow">Agent-routed actions</p>
<h2 id="minerControlsTitle">Controls</h2></div>
<button class="quiet-button" id="minerControlsRefresh" type="button">Refresh controls</button>
</div>
<p class="panel-copy" id="minerControlsSummary" aria-live="polite">
Loading factual capability and recent operations.
</p>
<div id="minerControlCapabilities" class="control-capability-grid"></div>
<div id="minerControlActions" class="miner-control-actions" hidden>
<button class="command-button" id="minerServiceControl" type="button" hidden></button>
<form id="minerPerformanceControl" class="performance-control" hidden>
<label for="minerPerformanceMode">Performance mode</label>
<select id="minerPerformanceMode">
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
</select>
<button class="command-button" type="submit">Set mode</button>
</form>
</div>
<p id="minerControlPermission" class="permission-note" hidden>
Your account can inspect control capability and history but cannot queue controls.
</p>
<div id="minerControlResult" class="status-callout" role="status" hidden></div>
<div class="table-wrap compact-history-table">
<table><thead><tr><th>Action</th><th>Requested</th><th>Status</th>
<th>Verification</th><th>Created</th></tr></thead>
<tbody id="minerControlHistory"></tbody></table>
</div>
</section>
<section class="panel" id="minerLocationSection" aria-labelledby="minerLocationTitle">
<div class="panel-head"><div><p class="eyebrow">Physical context</p>
<h2 id="minerLocationTitle">Location</h2></div></div>
-49
View File
@@ -1722,52 +1722,3 @@ button.map-position:hover {
@media (prefers-reduced-motion: reduce) {
.miner-detail-hero *, .disclosure-list * { scroll-behavior: auto; }
}
.control-capability-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 14px 0;
}
.control-capability-grid > div {
padding: 10px;
border: 1px solid var(--line);
background: #f7f8fa;
}
.miner-control-actions,
.performance-control {
display: flex;
align-items: end;
gap: 10px;
}
.miner-control-actions {
margin: 12px 0;
}
.performance-control label {
display: block;
font-size: 12px;
font-weight: 700;
}
.status-callout {
margin: 12px 0;
padding: 10px;
border-left: 4px solid var(--warn);
background: #fff8e6;
}
@media (max-width: 720px) {
.control-capability-grid {
grid-template-columns: 1fr;
}
.miner-control-actions,
.performance-control {
align-items: stretch;
flex-direction: column;
}
}
+2 -144
View File
@@ -5,7 +5,6 @@
const minerId = document.body.dataset.minerId;
const returnTo = document.body.dataset.returnTo || "";
const canControl = document.body.dataset.canControlMiners === "true";
const el = {
loading: document.getElementById("minerDetailLoading"),
error: document.getElementById("minerDetailError"),
@@ -33,17 +32,7 @@
identityHistory: document.getElementById("minerIdentityHistory"),
identityHistoryBody: document.getElementById("minerIdentityHistoryBody"),
observations: document.getElementById("minerObservations"),
observationsBody: document.getElementById("minerObservationsBody"),
controlsRefresh: document.getElementById("minerControlsRefresh"),
controlsSummary: document.getElementById("minerControlsSummary"),
controlCapabilities: document.getElementById("minerControlCapabilities"),
controlActions: document.getElementById("minerControlActions"),
controlPermission: document.getElementById("minerControlPermission"),
controlResult: document.getElementById("minerControlResult"),
serviceControl: document.getElementById("minerServiceControl"),
performanceForm: document.getElementById("minerPerformanceControl"),
performanceMode: document.getElementById("minerPerformanceMode"),
controlHistory: document.getElementById("minerControlHistory")
observationsBody: document.getElementById("minerObservationsBody")
};
function create(tag, className, text) {
@@ -132,123 +121,6 @@
return payload ? payload.data : null;
}
async function mutate(path, body) {
const response = await fetch(path, {
method: "POST",
credentials: "same-origin",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (response.status === 401) {
window.location.assign("/login?next=" + encodeURIComponent(
window.location.pathname + window.location.search
));
throw new Error("Login required");
}
let payload = null;
try { payload = await response.json(); } catch (_error) { payload = null; }
if (!response.ok) {
throw new Error(payload && payload.detail
? String(payload.detail) : "Request failed (" + response.status + ")");
}
return payload ? payload.data : null;
}
function capabilityText(capability) {
if (!capability) return "Unknown";
const reason = capability.reason ? " / " + words(capability.reason) : "";
return words(capability.state) + reason;
}
function operationStatus(operation) {
if (operation.status === "pending") return "Pending";
if (operation.status === "claimed") return "Running";
if (operation.status === "failed") return "Failed";
if (operation.status === "cancelled") return "Cancelled";
if (operation.verification_status === "confirmed") return "Confirmed";
if (operation.verification_status === "unconfirmed") return "Unconfirmed";
if (operation.verification_status === "verification_failed") return "Verification failed";
if (operation.verification_status === "unavailable") return "Verification unavailable";
return "Completed";
}
function renderControls(data) {
el.controlsSummary.textContent = "Runtime: " + words(data.runtime_state) +
" / Known mode: " + words(data.performance_mode);
el.controlCapabilities.replaceChildren();
[["Agent", data.agent], ["Service control", data.service_control],
["Performance mode", data.performance_mode_control]].forEach(function (item) {
const block = create("div");
block.append(create("span", "secondary", item[0]),
create("strong", "", capabilityText(item[1])));
el.controlCapabilities.appendChild(block);
});
el.controlPermission.hidden = canControl;
el.controlActions.hidden = !canControl;
el.serviceControl.hidden = true;
el.performanceForm.hidden = true;
if (canControl && data.available_service_action) {
el.serviceControl.dataset.action = data.available_service_action;
el.serviceControl.textContent = data.available_service_action === "suspend"
? "Suspend miner" : "Resume miner";
el.serviceControl.hidden = false;
}
if (canControl && data.performance_mode_control.state === "supported") {
if (data.performance_mode) el.performanceMode.value = data.performance_mode;
el.performanceForm.hidden = false;
}
el.controlHistory.replaceChildren();
(data.recent_operations || []).forEach(function (operation) {
const row = create("tr");
[words(operation.action), display(operation.requested_value, "None"),
operationStatus(operation), words(operation.verification_status),
dateTime(operation.created_at)].forEach(function (value) {
row.appendChild(create("td", "", value));
});
el.controlHistory.appendChild(row);
});
if (!(data.recent_operations || []).length) {
const row = create("tr");
const cell = create("td", "empty-copy", "No control operations recorded.");
cell.colSpan = 5;
row.appendChild(cell);
el.controlHistory.appendChild(row);
}
}
async function loadControls() {
el.controlsSummary.textContent = "Loading control capability and history.";
try {
const data = await api("/api/miners/" + encodeURIComponent(minerId) + "/controls");
renderControls(data);
} catch (error) {
el.controlsSummary.textContent = "Controls unavailable: " + error.message;
}
}
async function queueControl(action, requestedValue) {
const description = action === "performance_mode"
? "set performance mode to " + requestedValue : action + " this miner";
if (!window.confirm("Confirm: " + description + "?")) return;
const pathAction = action === "performance_mode" ? "performance-mode" : action;
const body = action === "performance_mode" ? { mode: requestedValue } : {};
el.controlResult.hidden = false;
el.controlResult.textContent = "Queueing control operation.";
try {
const operation = await mutate(
"/api/miners/" + encodeURIComponent(minerId) + "/controls/" + pathAction,
body
);
el.controlResult.textContent = "Pending: " + words(operation.action) +
" operation " + operation.operation_id + ".";
await loadControls();
} catch (error) {
el.controlResult.textContent = "Control was not queued: " + error.message;
}
}
function table(headers, rows) {
const wrap = create("div", "table-wrap compact-history-table");
const tableNode = create("table");
@@ -541,21 +413,8 @@
}
}
function refreshAll() {
loadOverview();
loadControls();
}
el.retry.addEventListener("click", loadOverview);
el.refresh.addEventListener("click", refreshAll);
el.controlsRefresh.addEventListener("click", loadControls);
el.serviceControl.addEventListener("click", function () {
queueControl(el.serviceControl.dataset.action, null);
});
el.performanceForm.addEventListener("submit", function (event) {
event.preventDefault();
queueControl("performance_mode", el.performanceMode.value);
});
el.refresh.addEventListener("click", loadOverview);
el.identityHistory.addEventListener("toggle", function () {
if (el.identityHistory.open) loadIdentityHistory();
});
@@ -563,5 +422,4 @@
if (el.observations.open) loadObservations();
});
loadOverview();
loadControls();
})();
@@ -1,139 +0,0 @@
.health-toolbar,
.health-section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.health-toolbar {
margin-bottom: 12px;
}
.health-toolbar h2,
.health-toolbar p,
.health-section h2,
.health-section h3 {
margin-top: 0;
}
.health-toolbar p {
margin-bottom: 0;
color: var(--muted-text, #52606d);
}
.health-notice {
border-left: 4px solid #64748b;
background: #f1f5f9;
margin: 0 0 14px;
padding: 10px 12px;
}
.health-notice[data-state="partial"] {
border-left-color: #b45309;
background: #fff7ed;
}
.health-notice[data-state="error"] {
border-left-color: #b91c1c;
background: #fef2f2;
}
.health-section {
border-top: 1px solid #cbd5e1;
padding: 16px 0;
}
.health-columns,
.health-integrity-grid,
.health-facts {
display: grid;
gap: 14px;
}
.health-columns {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.health-integrity-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.health-facts {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0;
}
.health-facts-compact {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.health-facts div {
min-width: 0;
}
.health-facts dt {
color: #52606d;
font-size: 0.78rem;
font-weight: 700;
}
.health-facts dd {
font-weight: 700;
margin: 3px 0 0;
overflow-wrap: anywhere;
}
.health-links {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.health-links a:focus-visible,
#healthRefresh:focus-visible {
outline: 3px solid #0f766e;
outline-offset: 3px;
}
.table-scroll {
overflow-x: auto;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
}
.table-scroll th,
.table-scroll td {
border-bottom: 1px solid #dbe2e8;
padding: 7px 8px;
text-align: left;
}
@media (max-width: 900px) {
.health-integrity-grid,
.health-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.health-toolbar,
.health-section-head {
align-items: stretch;
flex-direction: column;
}
.health-columns,
.health-integrity-grid,
.health-facts,
.health-facts-compact {
grid-template-columns: 1fr;
}
#healthRefresh {
width: 100%;
}
}
@@ -1,164 +0,0 @@
"use strict";
(function () {
const notice = document.getElementById("healthNotice");
const content = document.getElementById("healthContent");
const refresh = document.getElementById("healthRefresh");
function setText(id, value, fallback) {
const element = document.getElementById(id);
if (element) {
element.textContent = value === null || value === undefined || value === ""
? (fallback || "Unavailable") : String(value);
}
}
function number(value) {
return typeof value === "number" ? value.toLocaleString() : "Unavailable";
}
function timestamp(value) {
if (!value) return "Unavailable";
const date = new Date(value);
return Number.isNaN(date.valueOf()) ? "Unavailable" : date.toLocaleString();
}
function factualAge(value) {
if (!value) return "Unavailable";
const observed = new Date(value);
if (Number.isNaN(observed.valueOf())) return "Unavailable";
const seconds = Math.max(0, Math.floor((Date.now() - observed.valueOf()) / 1000));
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + (minutes === 1 ? " minute ago" : " minutes ago");
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + (hours === 1 ? " hour ago" : " hours ago");
const days = Math.floor(hours / 24);
return days + (days === 1 ? " day ago" : " days ago");
}
function replaceFacts(id, facts) {
const list = document.getElementById(id);
list.replaceChildren();
for (const [label, value] of facts) {
const row = document.createElement("div");
const term = document.createElement("dt");
const detail = document.createElement("dd");
term.textContent = label;
detail.textContent = value;
row.append(term, detail);
list.append(row);
}
}
function render(payload) {
const data = payload.data;
const app = data.application;
const postgres = data.postgresql;
const migration = postgres.migration;
const telemetry = postgres.telemetry;
setText("healthAppStatus", app.status);
setText("healthEnvironment", app.environment);
setText("healthBuildCommit", app.build_commit);
setText("healthBuildVersion", app.build_version);
setText("healthServerTime", timestamp(app.server_time));
setText("healthMigrationState", migration.state);
setText("healthCurrentRevision", migration.current_revision);
setText("healthExpectedRevision", migration.expected_revision);
setText("healthPostgresqlStatus", postgres.status);
setText("healthRedisStatus", data.redis.status);
setText("healthSnapshotCount", telemetry ? number(telemetry.snapshot_count) : null);
setText("healthOldestObservation", telemetry ? timestamp(telemetry.oldest_observed_at) : null);
setText("healthLatestObservation", telemetry ? timestamp(telemetry.latest_observed_at) : null);
setText("healthObservationAge", telemetry ? factualAge(telemetry.latest_observed_at) : null);
const fleet = postgres.fleet;
replaceFacts("healthFleetFacts", fleet ? [
["Total miners", number(fleet.total_miners)],
["Without Asset ID", number(fleet.miners_without_asset_id)],
["Unmapped", number(fleet.unmapped_miners)],
["In multiple positions", number(fleet.miners_in_multiple_positions)],
["Duplicate current MAC groups", number(fleet.duplicate_current_mac_groups)]
] : [["Diagnostics", "Unavailable"]]);
const locations = postgres.locations;
replaceFacts("healthLocationFacts", locations ? [
["Sites", number(locations.sites)], ["Rooms", number(locations.rooms)],
["Positions", number(locations.positions)],
["Occupied", number(locations.occupied_positions)],
["Empty", number(locations.empty_positions)],
["Legacy unscoped", number(locations.unscoped_legacy_positions)],
["Expected MAC mismatches", number(locations.expected_mac_mismatches)]
] : [["Diagnostics", "Unavailable"]]);
const repairs = postgres.repairs;
replaceFacts("healthRepairFacts", repairs ? [
["Active modular repairs", number(repairs.active_modular_repairs)],
["Duplicate active groups", number(repairs.duplicate_active_repair_groups)],
["Legacy repair only", number(repairs.legacy_repair_only_miners)],
["Lifecycle repair without active repair", number(repairs.lifecycle_repair_without_active_repair)],
["Active repair lifecycle mismatches", number(repairs.active_repair_lifecycle_mismatches)],
["Waiting placement", number(repairs.waiting_placement_records)],
["Repair events", number(repairs.repair_events)],
["Repair parts", number(repairs.repair_parts)]
] : [["Diagnostics", "Unavailable"]]);
const retention = postgres.retention;
replaceFacts("healthRetentionFacts", retention ? [
["Raw telemetry retention", retention.raw_retention_enabled ? "Enabled" : "Not enabled"],
["Hourly rollups", retention.hourly.table_present ? "Present; manual population" : "Unavailable"],
["Hourly latest bucket", timestamp(retention.hourly.latest_bucket)],
["Daily rollups", retention.daily.table_present ? "Present; manual population" : "Unavailable"],
["Daily latest bucket", timestamp(retention.daily.latest_bucket)],
["Latest recorded run", timestamp(retention.latest_recorded_run_at)],
["Latest run mode", retention.latest_recorded_run_mode || "None recorded"],
["Completeness", "Not guaranteed"]
] : [["Diagnostics", "Unavailable"]]);
const tableBody = document.getElementById("healthTableCounts");
tableBody.replaceChildren();
const rows = postgres.table_counts || [];
for (const table of rows) {
const row = document.createElement("tr");
const name = document.createElement("th");
const count = document.createElement("td");
name.scope = "row";
name.textContent = table.name;
count.textContent = number(table.row_count);
row.append(name, count);
tableBody.append(row);
}
if (!rows.length) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = 2;
cell.textContent = "Table diagnostics unavailable.";
row.append(cell);
tableBody.append(row);
}
content.hidden = false;
notice.dataset.state = payload.meta.partial ? "partial" : "ready";
notice.textContent = payload.meta.partial
? "Diagnostics loaded with unavailable sections: " + payload.meta.unavailable_sections.join(", ") + "."
: "Diagnostics refreshed at " + timestamp(payload.meta.generated_at) + ".";
}
async function load() {
refresh.disabled = true;
notice.dataset.state = "loading";
notice.textContent = "Loading diagnostics.";
try {
const response = await fetch("/api/system/health-summary", {
credentials: "same-origin", headers: {"Accept": "application/json"}
});
const payload = await response.json().catch(function () { return {}; });
if (!response.ok) throw new Error("System Health request failed");
render(payload);
} catch (error) {
notice.dataset.state = "error";
notice.textContent = "System Health diagnostics could not be loaded.";
} finally {
refresh.disabled = false;
}
}
refresh.addEventListener("click", load);
load();
}());
@@ -1,54 +0,0 @@
"""Semantic content for the Internal Alpha System Health page."""
def system_health_content() -> str:
return """
<section class="health-toolbar" aria-labelledby="healthOverviewTitle">
<div><h2 id="healthOverviewTitle">Operations diagnostics</h2>
<p>Availability, data facts, integrity counts, and maintenance state.</p></div>
<button class="primary-button" id="healthRefresh" type="button">Refresh</button>
</section>
<p class="health-notice" id="healthNotice" role="status" aria-live="polite">Loading diagnostics.</p>
<div id="healthContent" hidden>
<section class="health-section" aria-labelledby="applicationHealthTitle">
<h2 id="applicationHealthTitle">Application</h2>
<dl class="health-facts">
<div><dt>Application</dt><dd id="healthAppStatus">-</dd></div>
<div><dt>Environment</dt><dd id="healthEnvironment">-</dd></div>
<div><dt>Build commit</dt><dd id="healthBuildCommit">-</dd></div>
<div><dt>Build version</dt><dd id="healthBuildVersion">-</dd></div>
<div><dt>Server time</dt><dd id="healthServerTime">-</dd></div>
<div><dt>Schema</dt><dd id="healthMigrationState">-</dd></div>
<div><dt>Database revision</dt><dd id="healthCurrentRevision">-</dd></div>
<div><dt>Expected revision</dt><dd id="healthExpectedRevision">-</dd></div>
</dl>
</section>
<div class="health-columns">
<section class="health-section" aria-labelledby="servicesHealthTitle"><h2 id="servicesHealthTitle">Service availability</h2>
<dl class="health-facts health-facts-compact"><div><dt>PostgreSQL</dt><dd id="healthPostgresqlStatus">-</dd></div>
<div><dt>Redis</dt><dd id="healthRedisStatus">-</dd></div></dl></section>
<section class="health-section" aria-labelledby="telemetryHealthTitle"><h2 id="telemetryHealthTitle">Telemetry</h2>
<dl class="health-facts health-facts-compact"><div><dt>Raw snapshots</dt><dd id="healthSnapshotCount">-</dd></div>
<div><dt>Oldest observation</dt><dd id="healthOldestObservation">-</dd></div>
<div><dt>Latest observation</dt><dd id="healthLatestObservation">-</dd></div>
<div><dt>Latest age</dt><dd id="healthObservationAge">-</dd></div></dl></section>
</div>
<section class="health-section" aria-labelledby="integrityHealthTitle">
<div class="health-section-head"><h2 id="integrityHealthTitle">Operational integrity facts</h2>
<div class="health-links"><a href="/assets/miners">Fleet</a><a href="/monitoring/map">Mine Map</a>
<a href="/operations/repairs">Repair Center</a><a href="/monitoring/analytics">Trends</a></div></div>
<div class="health-integrity-grid">
<section aria-labelledby="fleetIntegrityTitle"><h3 id="fleetIntegrityTitle">Fleet</h3><dl id="healthFleetFacts" class="health-facts health-facts-compact"></dl></section>
<section aria-labelledby="locationIntegrityTitle"><h3 id="locationIntegrityTitle">Locations</h3><dl id="healthLocationFacts" class="health-facts health-facts-compact"></dl></section>
<section aria-labelledby="repairIntegrityTitle"><h3 id="repairIntegrityTitle">Repairs</h3><dl id="healthRepairFacts" class="health-facts health-facts-compact"></dl></section>
</div>
</section>
<div class="health-columns">
<section class="health-section" aria-labelledby="retentionHealthTitle"><h2 id="retentionHealthTitle">Retention and rollups</h2>
<dl id="healthRetentionFacts" class="health-facts health-facts-compact"></dl></section>
<section class="health-section" aria-labelledby="tablesHealthTitle"><h2 id="tablesHealthTitle">Operational tables</h2>
<div class="table-scroll"><table><thead><tr><th scope="col">Table</th><th scope="col">Rows</th></tr></thead>
<tbody id="healthTableCounts"></tbody></table></div></section>
</div>
</div>
"""
+3 -17
View File
@@ -5,7 +5,6 @@ from typing import Callable
from app.frontend.miner_detail_view import miner_detail_content
from app.frontend.monitoring_trends_view import monitoring_trends_content
from app.frontend.system_health_view import system_health_content
NAVIGATION = (
@@ -14,7 +13,6 @@ NAVIGATION = (
("analytics", "Trends", "/monitoring/analytics", "miners.view"),
("repairs", "Repair Center", "/operations/repairs", "repairs.view"),
("miners", "Miners", "/assets/miners", "miners.view"),
("system_health", "System Health", "/system-health", "system_health.view"),
)
PAGE_DETAILS = {
@@ -48,11 +46,6 @@ PAGE_DETAILS = {
"Miner Detail",
"Current identity, operating facts, location, and authorized history.",
),
"system_health": (
"Administration / Diagnostics",
"System Health",
"Inspect application services, operational data, and maintenance facts.",
),
}
@@ -462,9 +455,7 @@ def render_app_page(
else '<div class="global-search-unavailable" aria-hidden="true"></div>'
)
content = (
system_health_content()
if view == "system_health"
else miner_detail_content()
miner_detail_content()
if view == "miner_detail"
else monitoring_trends_content(has_permission("locations.view"))
if view == "analytics"
@@ -483,9 +474,7 @@ def render_app_page(
)
)
page_script = (
'<script src="/assets/minerops-system-health.js" defer></script>'
if view == "system_health"
else '<script src="/assets/minerops-miner-detail.js" defer></script>'
'<script src="/assets/minerops-miner-detail.js" defer></script>'
if view == "miner_detail"
else '<script src="/assets/minerops-monitoring-trends.js" defer></script>'
if view == "analytics"
@@ -500,9 +489,7 @@ def render_app_page(
else ""
)
page_style = (
'<link rel="stylesheet" href="/assets/minerops-system-health.css">'
if view == "system_health"
else '<link rel="stylesheet" href="/assets/minerops-monitoring-trends.css">'
'<link rel="stylesheet" href="/assets/minerops-monitoring-trends.css">'
if view == "analytics" else ""
)
object_attributes = ""
@@ -521,7 +508,6 @@ def render_app_page(
</head>
<body data-view="{escape(view)}"
data-can-view-miners="{str(can_view_miners).lower()}"
data-can-control-miners="{str(has_permission("miners.control")).lower()}"
data-can-view-locations="{str(has_permission("locations.view")).lower()}"{object_attributes}>
<a class="skip-link" href="#mainContent">Skip to main content</a>
<div class="app-shell">
-11
View File
@@ -757,17 +757,6 @@ def component_request(path: str) -> bool:
def required_api_permission(method: str, path: str) -> str | None:
parts = path.split("/")
if (
len(parts) >= 5
and parts[:3] == ["", "api", "miners"]
and parts[4] == "controls"
):
if method == "GET" and len(parts) == 5:
return "miners.view"
if method == "POST" and len(parts) == 6 and parts[5] in {
"suspend", "resume", "performance-mode"
}:
return "miners.control"
if (
method == "GET"
and len(parts) == 5
+903 -24
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
"""Typed miner control domain."""
@@ -1,8 +0,0 @@
"""Dependency construction for miner control."""
from app.miner_control.repository import MinerControlRepository
from app.miner_control.service import MinerControlService
def get_miner_control_service() -> MinerControlService:
return MinerControlService(MinerControlRepository())
-21
View File
@@ -1,21 +0,0 @@
"""Miner control domain exceptions."""
class MinerControlError(Exception):
"""Base miner control error."""
class ControlledMinerNotFound(MinerControlError):
"""The durable miner UUID is unknown."""
class MinerControlUnavailable(MinerControlError):
"""The requested action cannot be safely queued."""
class MinerControlConflict(MinerControlError):
"""Another live control operation already exists."""
class MinerControlActorRequired(MinerControlError):
"""An authenticated actor UUID is required."""
-85
View File
@@ -1,85 +0,0 @@
"""Typed contracts for miner control capabilities and operations."""
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
ControlAction = Literal["suspend", "resume", "performance_mode"]
CapabilityState = Literal["supported", "unavailable", "unknown"]
QueueStatus = Literal["pending", "claimed", "completed", "failed", "cancelled"]
VerificationStatus = Literal[
"confirmed", "verification_failed", "unconfirmed", "unavailable"
]
PerformanceMode = Literal["low", "normal", "high"]
class EmptyControlRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
class PerformanceModeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
mode: PerformanceMode
class ControlCapability(BaseModel):
state: CapabilityState
reason: str | None = None
class ControlAgentAvailability(BaseModel):
state: CapabilityState
reason: str | None = None
last_checkin_at: AwareDatetime | None = None
version: str | None = None
class MinerControlOperation(BaseModel):
operation_id: str
miner_id: UUID
action: ControlAction
requested_value: str | None = None
status: QueueStatus
verification_status: VerificationStatus | None = None
command_accepted: bool | None = None
observed_performance_mode: PerformanceMode | None = None
failure_code: str | None = None
created_at: AwareDatetime
claimed_at: AwareDatetime | None = None
completed_at: AwareDatetime | None = None
class MinerControlProjection(BaseModel):
miner_id: UUID
runtime_state: str | None = None
performance_mode: PerformanceMode | None = None
agent: ControlAgentAvailability
service_control: ControlCapability
performance_mode_control: ControlCapability
available_service_action: Literal["suspend", "resume"] | None = None
recent_operations: list[MinerControlOperation]
class MinerControlResponse(BaseModel):
data: MinerControlProjection
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
class MinerControlAccepted(BaseModel):
operation_id: str
miner_id: UUID
action: ControlAction
requested_value: str | None = None
status: Literal["pending"]
created_at: datetime
class MinerControlAcceptedResponse(BaseModel):
data: MinerControlAccepted
meta: dict[str, object] = Field(default_factory=dict)
error: None = None
-135
View File
@@ -1,135 +0,0 @@
"""PostgreSQL persistence for typed miner control operations."""
from collections.abc import Callable
from typing import Any
from uuid import UUID
import psycopg
from psycopg.errors import UniqueViolation
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from app.config import settings
from app.miner_control.exceptions import MinerControlConflict
ConnectionFactory = Callable[[], psycopg.Connection]
LIVE_CONTROL_CONSTRAINT = "uq_agent_queue_live_miner_control"
def default_connection_factory() -> psycopg.Connection:
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
connect_timeout=5,
)
class MinerControlRepository:
"""Resolve control facts and persist queue/audit rows atomically."""
def __init__(self, connection_factory: ConnectionFactory | None = None):
self._connection_factory = connection_factory or default_connection_factory
def get_control_context(self, miner_id: UUID) -> dict | None:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT miners.id, miners.site_id, host(miners.current_ip) AS current_ip,
miners.status AS runtime_state,
miners.configured_performance_mode,
miners.api3_supported, miners.api3_api_switch,
agents.agent_id, agents.last_checkin_at,
agents.version AS agent_version, agents.last_payload
FROM miners
LEFT JOIN minerops_site_agents AS agents
ON agents.site_id = miners.site_id
WHERE miners.id = %s
""",
(miner_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
connection.close()
def list_recent(self, miner_id: UUID, limit: int = 5) -> list[dict]:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
SELECT id AS operation_id, command_status AS status,
request_payload, result_payload, created_at,
claimed_at, completed_at
FROM minerops_agent_command_queue
WHERE command_type = 'miner_control'
AND request_payload ->> 'miner_id' = %s
AND request_payload ->> 'schema_version' = '1'
ORDER BY created_at DESC, id DESC
LIMIT %s
""",
(str(miner_id), limit),
)
return [dict(row) for row in cur.fetchall()]
finally:
connection.close()
def enqueue_with_audit(
self,
*,
operation_id: str,
miner_id: UUID,
agent_id: str,
actor_user_id: UUID,
actor_username: str,
request_payload: dict[str, Any],
audit_payload: dict[str, Any],
) -> dict:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
"""
INSERT INTO minerops_agent_command_queue (
id, agent_id, command_type, command_status,
requested_by_user_id, requested_by_username, request_payload
)
VALUES (%s, %s, 'miner_control', 'pending', %s, %s, %s)
RETURNING id AS operation_id, command_status AS status, created_at
""",
(
operation_id, agent_id, str(actor_user_id), actor_username,
Jsonb(request_payload),
),
)
operation = dict(cur.fetchone())
cur.execute(
"""
INSERT INTO audit_logs (
actor_user_id, action, entity_type, entity_id,
new_value_json, related_miner_asset_id
)
VALUES (%s, 'miner.control.queued', 'miner', %s, %s, %s)
""",
(actor_user_id, miner_id, Jsonb(audit_payload), miner_id),
)
connection.commit()
return operation
except UniqueViolation as exc:
connection.rollback()
if exc.diag.constraint_name == LIVE_CONTROL_CONSTRAINT:
raise MinerControlConflict(
"A pending or running control operation already exists for this miner"
) from exc
raise
except Exception:
connection.rollback()
raise
finally:
connection.close()
-129
View File
@@ -1,129 +0,0 @@
"""HTTP mapping for typed miner controls."""
from typing import Annotated
from urllib.parse import urlsplit
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.identity import require_permission
from app.miner_control.dependencies import get_miner_control_service
from app.miner_control.exceptions import (
ControlledMinerNotFound,
MinerControlConflict,
MinerControlUnavailable,
)
from app.miner_control.models import (
EmptyControlRequest,
MinerControlAcceptedResponse,
MinerControlResponse,
PerformanceModeRequest,
)
from app.miner_control.service import MinerControlService
router = APIRouter(prefix="/api/miners", tags=["miner-controls"])
ControlService = Annotated[MinerControlService, Depends(get_miner_control_service)]
ControlViewer = Annotated[dict, Depends(require_permission("miners.view"))]
ControlUser = Annotated[dict, Depends(require_permission("miners.control"))]
def require_same_origin(request: Request) -> None:
if request.headers.get("sec-fetch-site", "").lower() == "cross-site":
raise HTTPException(status_code=403, detail="Cross-site control request rejected")
origin = request.headers.get("origin")
if origin:
parsed = urlsplit(origin)
if parsed.scheme not in {"http", "https"} or parsed.netloc != request.url.netloc:
raise HTTPException(status_code=403, detail="Cross-site control request rejected")
def _actor(current_user: dict) -> tuple[UUID, str]:
return UUID(str(current_user["id"])), str(
current_user.get("username") or current_user.get("email") or "unknown"
)
def _queue(
service: MinerControlService,
miner_id: UUID,
action: str,
requested_value: str | None,
current_user: dict,
) -> MinerControlAcceptedResponse:
actor_id, username = _actor(current_user)
try:
operation = service.queue_control(
miner_id,
action,
requested_value=requested_value,
actor_user_id=actor_id,
actor_username=username,
)
except ControlledMinerNotFound as exc:
raise HTTPException(status_code=404, detail="Miner not found") from exc
except MinerControlUnavailable as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except MinerControlConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return MinerControlAcceptedResponse(data=operation)
@router.get("/{miner_id}/controls", response_model=MinerControlResponse)
def get_miner_controls(
miner_id: UUID,
current_user: ControlViewer,
service: ControlService,
) -> MinerControlResponse:
del current_user
try:
return MinerControlResponse(data=service.get_controls(miner_id))
except ControlledMinerNotFound as exc:
raise HTTPException(status_code=404, detail="Miner not found") from exc
@router.post(
"/{miner_id}/controls/suspend",
response_model=MinerControlAcceptedResponse,
status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_same_origin)],
)
def suspend_miner(
miner_id: UUID,
payload: EmptyControlRequest,
current_user: ControlUser,
service: ControlService,
) -> MinerControlAcceptedResponse:
del payload
return _queue(service, miner_id, "suspend", None, current_user)
@router.post(
"/{miner_id}/controls/resume",
response_model=MinerControlAcceptedResponse,
status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_same_origin)],
)
def resume_miner(
miner_id: UUID,
payload: EmptyControlRequest,
current_user: ControlUser,
service: ControlService,
) -> MinerControlAcceptedResponse:
del payload
return _queue(service, miner_id, "resume", None, current_user)
@router.post(
"/{miner_id}/controls/performance-mode",
response_model=MinerControlAcceptedResponse,
status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_same_origin)],
)
def set_performance_mode(
miner_id: UUID,
payload: PerformanceModeRequest,
current_user: ControlUser,
service: ControlService,
) -> MinerControlAcceptedResponse:
return _queue(service, miner_id, "performance_mode", payload.mode, current_user)
-165
View File
@@ -1,165 +0,0 @@
"""Business rules for typed, Agent-routed miner controls."""
from typing import Any
from uuid import UUID, uuid4
from app.miner_control.exceptions import (
ControlledMinerNotFound,
MinerControlActorRequired,
MinerControlUnavailable,
)
from app.miner_control.models import (
ControlAgentAvailability,
ControlCapability,
MinerControlAccepted,
MinerControlOperation,
MinerControlProjection,
)
from app.miner_control.repository import MinerControlRepository
KNOWN_MODES = {"low", "normal", "high"}
RUNNING_STATES = {"online", "running"}
STOPPED_STATES = {"suspended", "stopped"}
class MinerControlService:
def __init__(self, repository: MinerControlRepository):
self.repository = repository
@staticmethod
def _capability(context: dict) -> ControlCapability:
if not context.get("site_id"):
return ControlCapability(state="unavailable", reason="site_unassigned")
if not context.get("agent_id"):
return ControlCapability(state="unavailable", reason="agent_unassigned")
if not context.get("current_ip"):
return ControlCapability(state="unavailable", reason="current_ip_missing")
if context.get("api3_supported") is False:
return ControlCapability(state="unavailable", reason="api3_unsupported")
if context.get("api3_supported") is not True:
return ControlCapability(state="unknown", reason="api3_support_unknown")
payload = context.get("last_payload") or {}
if payload.get("control_available") is False:
reason = payload.get("control_unavailable_reason")
return ControlCapability(
state="unavailable",
reason=reason if reason == "credentials_missing" else "agent_unavailable",
)
if payload.get("control_available") is not True:
return ControlCapability(
state="unknown", reason="agent_capability_unknown"
)
return ControlCapability(state="supported")
@staticmethod
def _operation(miner_id: UUID, row: dict) -> MinerControlOperation:
request = row.get("request_payload") or {}
result = row.get("result_payload") or {}
mode = result.get("observed_performance_mode")
return MinerControlOperation(
operation_id=row["operation_id"],
miner_id=miner_id,
action=request.get("action"),
requested_value=request.get("requested_value"),
status=row["status"],
verification_status=result.get("verification_status"),
command_accepted=result.get("command_accepted"),
observed_performance_mode=mode if mode in KNOWN_MODES else None,
failure_code=result.get("failure_code"),
created_at=row["created_at"],
claimed_at=row.get("claimed_at"),
completed_at=row.get("completed_at"),
)
def get_controls(self, miner_id: UUID) -> MinerControlProjection:
context = self.repository.get_control_context(miner_id)
if not context:
raise ControlledMinerNotFound("Miner not found")
capability = self._capability(context)
runtime = str(context.get("runtime_state") or "").strip().lower() or None
service_action = None
if capability.state == "supported":
if runtime in RUNNING_STATES:
service_action = "suspend"
elif runtime in STOPPED_STATES:
service_action = "resume"
configured_mode = str(context.get("configured_performance_mode") or "").lower()
return MinerControlProjection(
miner_id=miner_id,
runtime_state=runtime,
performance_mode=(configured_mode if configured_mode in KNOWN_MODES else None),
agent=ControlAgentAvailability(
state=capability.state,
reason=capability.reason,
last_checkin_at=context.get("last_checkin_at"),
version=context.get("agent_version"),
),
service_control=capability,
performance_mode_control=capability,
available_service_action=service_action,
recent_operations=[
self._operation(miner_id, row)
for row in self.repository.list_recent(miner_id, limit=5)
],
)
def queue_control(
self,
miner_id: UUID,
action: str,
*,
requested_value: str | None,
actor_user_id: UUID,
actor_username: str,
) -> MinerControlAccepted:
if not actor_user_id:
raise MinerControlActorRequired("Authenticated actor UUID is required")
context = self.repository.get_control_context(miner_id)
if not context:
raise ControlledMinerNotFound("Miner not found")
capability = self._capability(context)
if capability.state != "supported":
raise MinerControlUnavailable(
capability.reason or "control_capability_not_supported"
)
runtime = str(context.get("runtime_state") or "").strip().lower()
if action == "suspend" and runtime not in RUNNING_STATES:
raise MinerControlUnavailable("miner_not_factually_running")
if action == "resume" and runtime not in STOPPED_STATES:
raise MinerControlUnavailable("miner_not_factually_stopped")
if action == "performance_mode" and requested_value not in KNOWN_MODES:
raise MinerControlUnavailable("invalid_performance_mode")
operation_id = str(uuid4())
payload: dict[str, Any] = {
"schema_version": 1,
"miner_id": str(miner_id),
"action": action,
"requested_value": requested_value,
"ip_address": context["current_ip"],
}
operation = self.repository.enqueue_with_audit(
operation_id=operation_id,
miner_id=miner_id,
agent_id=context["agent_id"],
actor_user_id=actor_user_id,
actor_username=actor_username,
request_payload=payload,
audit_payload={
"operation_id": operation_id,
"miner_id": str(miner_id),
"action": action,
"requested_value": requested_value,
},
)
return MinerControlAccepted(
operation_id=operation["operation_id"],
miner_id=miner_id,
action=action,
requested_value=requested_value,
status=operation["status"],
created_at=operation["created_at"],
)
-1
View File
@@ -1 +0,0 @@
"""System Health diagnostics domain."""
-29
View File
@@ -1,29 +0,0 @@
"""External service adapters for System Health."""
from collections.abc import Callable
import redis
from app.config import settings
RedisFactory = Callable[[], redis.Redis]
def default_redis_factory() -> redis.Redis:
return redis.Redis.from_url(
settings.redis_url,
socket_connect_timeout=2,
socket_timeout=2,
)
class RedisHealthAdapter:
def __init__(self, factory: RedisFactory | None = None):
self._factory = factory or default_redis_factory
def ping(self) -> bool:
try:
return bool(self._factory().ping())
except Exception:
return False
-42
View File
@@ -1,42 +0,0 @@
"""Startup-cached, non-secret application metadata."""
from dataclasses import dataclass
from pathlib import Path
from alembic.config import Config
from alembic.script import ScriptDirectory
from app.config import settings
def _clean_build_value(value: str | None) -> str | None:
if value is None:
return None
cleaned = value.strip()
return cleaned[:128] if cleaned else None
def _expected_migration_head() -> str | None:
try:
backend_root = Path(__file__).resolve().parents[2]
config = Config(str(backend_root / "alembic.ini"))
config.set_main_option("script_location", str(backend_root / "alembic"))
return ScriptDirectory.from_config(config).get_current_head()
except Exception:
return None
@dataclass(frozen=True)
class ApplicationMetadata:
environment: str
build_commit: str | None
build_version: str | None
expected_migration_head: str | None
APPLICATION_METADATA = ApplicationMetadata(
environment=settings.app_env,
build_commit=_clean_build_value(settings.minerops_build_commit),
build_version=_clean_build_value(settings.minerops_build_version),
expected_migration_head=_expected_migration_head(),
)
@@ -1,9 +0,0 @@
"""Dependency construction for System Health."""
from app.system_health.adapters import RedisHealthAdapter
from app.system_health.repository import SystemHealthRepository
from app.system_health.service import SystemHealthService
def get_system_health_service() -> SystemHealthService:
return SystemHealthService(SystemHealthRepository(), RedisHealthAdapter())
-35
View File
@@ -1,35 +0,0 @@
"""Authenticated frontend routes for System Health."""
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends
from fastapi.responses import HTMLResponse, Response
from app.frontend.views import render_app_page
from app.identity import require_permission, user_has_permission
router = APIRouter(tags=["frontend"])
STATIC_ROOT = Path(__file__).parents[1] / "frontend" / "static"
SystemHealthUser = Annotated[dict, Depends(require_permission("system_health.view"))]
@router.get("/assets/minerops-system-health.css", include_in_schema=False)
def system_health_styles() -> Response:
return Response((STATIC_ROOT / "system-health.css").read_text(encoding="utf-8"),
media_type="text/css", headers={"Cache-Control": "no-store"})
@router.get("/assets/minerops-system-health.js", include_in_schema=False)
def system_health_script() -> Response:
return Response((STATIC_ROOT / "system-health.js").read_text(encoding="utf-8"),
media_type="text/javascript", headers={"Cache-Control": "no-store"})
@router.get("/system-health", response_class=HTMLResponse, include_in_schema=False)
def system_health_page(current_user: SystemHealthUser) -> str:
return render_app_page(
"system_health", current_user,
lambda permission: user_has_permission(current_user, permission),
)
-124
View File
@@ -1,124 +0,0 @@
"""Typed contracts for the System Health overview."""
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, Field
Availability = Literal["available", "unavailable"]
MigrationState = Literal["current", "mismatch", "unavailable"]
class ApplicationDiagnostics(BaseModel):
status: Literal["available"] = "available"
environment: str
build_commit: str | None = None
build_version: str | None = None
server_time: datetime
expected_migration_head: str | None = None
class MigrationDiagnostics(BaseModel):
state: MigrationState
current_revision: str | None = None
expected_revision: str | None = None
class TableCount(BaseModel):
name: str
row_count: int
class TableSize(BaseModel):
name: str
total_bytes: int
class TelemetryDiagnostics(BaseModel):
snapshot_count: int
oldest_observed_at: datetime | None = None
latest_observed_at: datetime | None = None
class FleetIntegrityDiagnostics(BaseModel):
total_miners: int
miners_without_asset_id: int
unmapped_miners: int
miners_in_multiple_positions: int
duplicate_current_mac_groups: int
class LocationIntegrityDiagnostics(BaseModel):
sites: int
rooms: int
positions: int
occupied_positions: int
empty_positions: int
unscoped_legacy_positions: int
expected_mac_mismatches: int
class RepairIntegrityDiagnostics(BaseModel):
active_modular_repairs: int
duplicate_active_repair_groups: int
legacy_repair_only_miners: int
lifecycle_repair_without_active_repair: int
active_repair_lifecycle_mismatches: int
waiting_placement_records: int
repair_events: int
repair_parts: int
class RollupDiagnostics(BaseModel):
table_present: bool
row_count: int | None = None
latest_bucket: datetime | date | None = None
population: Literal["manual"] = "manual"
class RetentionDiagnostics(BaseModel):
raw_retention_enabled: Literal[False] = False
raw_retention_mode: Literal["not_enabled"] = "not_enabled"
hourly: RollupDiagnostics
daily: RollupDiagnostics
latest_recorded_run_at: datetime | None = None
latest_recorded_run_mode: str | None = None
completeness: Literal["not_guaranteed"] = "not_guaranteed"
class PostgreSQLDiagnostics(BaseModel):
status: Availability
migration: MigrationDiagnostics
table_counts: list[TableCount] = Field(default_factory=list)
largest_tables: list[TableSize] = Field(default_factory=list)
telemetry: TelemetryDiagnostics | None = None
fleet: FleetIntegrityDiagnostics | None = None
locations: LocationIntegrityDiagnostics | None = None
repairs: RepairIntegrityDiagnostics | None = None
retention: RetentionDiagnostics | None = None
class RedisDiagnostics(BaseModel):
status: Availability
class SystemHealthOverviewData(BaseModel):
application: ApplicationDiagnostics
postgresql: PostgreSQLDiagnostics
redis: RedisDiagnostics
class SystemHealthOverviewMeta(BaseModel):
generated_at: datetime
partial: bool
unavailable_sections: list[str] = Field(default_factory=list)
frontend_request_count: Literal[1] = 1
postgresql_statement_count: Literal[3] = 3
redis_call_count: Literal[1] = 1
class SystemHealthOverviewResponse(BaseModel):
data: SystemHealthOverviewData
meta: SystemHealthOverviewMeta
error: None = None
-179
View File
@@ -1,179 +0,0 @@
"""PostgreSQL diagnostics for System Health."""
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from typing import Any
import psycopg
from psycopg.rows import dict_row
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
def default_connection_factory() -> psycopg.Connection:
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
connect_timeout=3,
)
MIGRATION_SQL = """
SELECT clock_timestamp() AS database_time,
(SELECT version_num FROM alembic_version LIMIT 1) AS current_revision
"""
OPERATIONS_SQL = """
WITH telemetry AS (
SELECT count(*)::bigint AS snapshot_count,
min(observed_at) AS oldest_observed_at,
max(observed_at) AS latest_observed_at
FROM miner_status_snapshots
),
hourly AS (
SELECT count(*)::bigint AS row_count, max(bucket_at) AS latest_bucket
FROM miner_status_hourly_rollups
),
daily AS (
SELECT count(*)::bigint AS row_count, max(bucket_date) AS latest_bucket
FROM miner_status_daily_rollups
),
latest_retention AS (
SELECT mode, started_at FROM minerops_retention_runs
ORDER BY started_at DESC LIMIT 1
),
largest AS (
SELECT coalesce(jsonb_agg(to_jsonb(sized) ORDER BY sized.total_bytes DESC), '[]'::jsonb) AS rows
FROM (
SELECT relname AS name, pg_total_relation_size(relid)::bigint AS total_bytes
FROM pg_catalog.pg_statio_user_tables
WHERE relname = ANY(%s)
ORDER BY pg_total_relation_size(relid) DESC, relname
LIMIT 8
) AS sized
)
SELECT
(SELECT count(*) FROM miners)::bigint AS miners,
(SELECT count(*) FROM sites)::bigint AS sites,
(SELECT count(*) FROM rooms)::bigint AS rooms,
(SELECT count(*) FROM location_positions)::bigint AS location_positions,
(SELECT count(*) FROM repairs)::bigint AS repairs,
(SELECT count(*) FROM repair_events)::bigint AS repair_events,
(SELECT count(*) FROM repair_parts)::bigint AS repair_parts,
telemetry.snapshot_count, telemetry.oldest_observed_at,
telemetry.latest_observed_at,
to_regclass('public.miner_status_hourly_rollups') IS NOT NULL AS hourly_present,
hourly.row_count AS hourly_rows, hourly.latest_bucket AS hourly_latest,
to_regclass('public.miner_status_daily_rollups') IS NOT NULL AS daily_present,
daily.row_count AS daily_rows, daily.latest_bucket AS daily_latest,
latest_retention.mode AS retention_mode,
latest_retention.started_at AS retention_started_at,
largest.rows AS largest_tables
FROM telemetry CROSS JOIN hourly CROSS JOIN daily CROSS JOIN largest
LEFT JOIN latest_retention ON true
"""
INTEGRITY_SQL = """
WITH position_counts AS (
SELECT miner_id, count(*) AS position_count
FROM location_positions WHERE miner_id IS NOT NULL GROUP BY miner_id
),
active_repairs AS (
SELECT id, miner_id FROM repairs
WHERE status IN ('OPEN', 'IN_PROGRESS', 'WAITING_FOR_PARTS', 'READY_FOR_TEST')
),
active_repair_groups AS (
SELECT miner_id FROM active_repairs GROUP BY miner_id HAVING count(*) > 1
),
normalized_macs AS (
SELECT lower(regexp_replace(trim(current_mac), '[^0-9A-Fa-f]', '', 'g')) AS mac
FROM miners WHERE current_mac IS NOT NULL AND trim(current_mac) <> ''
GROUP BY lower(regexp_replace(trim(current_mac), '[^0-9A-Fa-f]', '', 'g'))
HAVING count(*) > 1
)
SELECT
(SELECT count(*) FROM miners WHERE asset_tag IS NULL OR trim(asset_tag) = '')::bigint
AS miners_without_asset_id,
(SELECT count(*) FROM miners m WHERE NOT EXISTS (
SELECT 1 FROM location_positions p WHERE p.miner_id = m.id
))::bigint AS unmapped_miners,
(SELECT count(*) FROM position_counts WHERE position_count > 1)::bigint
AS miners_in_multiple_positions,
(SELECT count(*) FROM normalized_macs)::bigint AS duplicate_current_mac_groups,
(SELECT count(*) FROM location_positions WHERE room_id IS NULL)::bigint
AS unscoped_legacy_positions,
(SELECT count(*) FROM location_positions p JOIN miners m ON m.id = p.miner_id
WHERE p.expected_mac IS NOT NULL AND trim(p.expected_mac) <> ''
AND m.current_mac IS NOT NULL AND trim(m.current_mac) <> ''
AND lower(regexp_replace(trim(p.expected_mac), '[^0-9A-Fa-f]', '', 'g'))
<> lower(regexp_replace(trim(m.current_mac), '[^0-9A-Fa-f]', '', 'g'))
)::bigint AS expected_mac_mismatches,
(SELECT count(*) FROM location_positions WHERE miner_id IS NOT NULL)::bigint
AS occupied_positions,
(SELECT count(*) FROM location_positions WHERE miner_id IS NULL)::bigint
AS empty_positions,
(SELECT count(*) FROM active_repairs)::bigint AS active_modular_repairs,
(SELECT count(*) FROM active_repair_groups)::bigint AS duplicate_active_repair_groups,
(SELECT count(*) FROM miner_location_states state
WHERE state.asset_state = 'repair' AND NOT EXISTS (
SELECT 1 FROM active_repairs repair WHERE repair.miner_id = state.miner_id
))::bigint AS legacy_repair_only_miners,
(SELECT count(*) FROM miners m WHERE m.lifecycle_status = 'in_repair'
AND NOT EXISTS (SELECT 1 FROM active_repairs r WHERE r.miner_id = m.id))::bigint
AS lifecycle_repair_without_active_repair,
(SELECT count(*) FROM active_repairs r JOIN miners m ON m.id = r.miner_id
WHERE m.lifecycle_status <> 'in_repair')::bigint
AS active_repair_lifecycle_mismatches,
(SELECT count(*) FROM miner_location_states WHERE asset_state = 'waiting_placement')::bigint
AS waiting_placement_records
"""
RELEVANT_TABLES = [
"miner_status_snapshots", "repair_events", "repair_parts", "repairs",
"location_positions", "miners", "miner_status_hourly_rollups",
"miner_status_daily_rollups",
]
class SystemHealthRepository:
def __init__(
self,
connection_factory: ConnectionFactory | None = None,
*,
connection: psycopg.Connection | None = None,
):
self._connection_factory = connection_factory or default_connection_factory
self._connection = connection
@contextmanager
def _connection_scope(self) -> Iterator[psycopg.Connection]:
if self._connection is not None:
yield self._connection
return
connection = self._connection_factory()
try:
yield connection
finally:
connection.close()
def read_overview(self) -> dict[str, Any]:
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(MIGRATION_SQL)
migration = dict(cur.fetchone())
cur.execute(OPERATIONS_SQL, (RELEVANT_TABLES,))
operations = dict(cur.fetchone())
cur.execute(INTEGRITY_SQL)
integrity = dict(cur.fetchone())
return {
"migration": migration,
"operations": operations,
"integrity": integrity,
}
-27
View File
@@ -1,27 +0,0 @@
"""HTTP boundary for the System Health overview."""
from typing import Annotated
from fastapi import APIRouter, Depends, Response
from app.identity import require_permission
from app.system_health.dependencies import get_system_health_service
from app.system_health.models import SystemHealthOverviewResponse
from app.system_health.service import SystemHealthService
router = APIRouter(prefix="/api/system", tags=["system-health"])
SystemHealthUser = Annotated[dict, Depends(require_permission("system_health.view"))]
SystemHealthServiceDependency = Annotated[
SystemHealthService, Depends(get_system_health_service)
]
@router.get("/health-summary", response_model=SystemHealthOverviewResponse)
def get_system_health_overview(
current_user: SystemHealthUser,
service: SystemHealthServiceDependency,
response: Response,
) -> SystemHealthOverviewResponse:
response.headers["Cache-Control"] = "no-store"
return service.get_overview()
-136
View File
@@ -1,136 +0,0 @@
"""Composition and failure semantics for System Health diagnostics."""
from datetime import datetime, timezone
from app.system_health.adapters import RedisHealthAdapter
from app.system_health.application import APPLICATION_METADATA, ApplicationMetadata
from app.system_health.models import (
ApplicationDiagnostics, FleetIntegrityDiagnostics, LocationIntegrityDiagnostics,
MigrationDiagnostics, PostgreSQLDiagnostics, RedisDiagnostics,
RepairIntegrityDiagnostics, RetentionDiagnostics, RollupDiagnostics,
SystemHealthOverviewData, SystemHealthOverviewMeta,
SystemHealthOverviewResponse, TableCount, TableSize, TelemetryDiagnostics,
)
from app.system_health.repository import SystemHealthRepository
class SystemHealthService:
def __init__(self, repository: SystemHealthRepository,
redis_adapter: RedisHealthAdapter,
metadata: ApplicationMetadata = APPLICATION_METADATA):
self.repository = repository
self.redis_adapter = redis_adapter
self.metadata = metadata
def get_overview(self) -> SystemHealthOverviewResponse:
generated_at = datetime.now(timezone.utc)
unavailable = []
try:
postgresql = self._postgresql_diagnostics(
self.repository.read_overview()
)
except Exception:
unavailable.append("postgresql")
postgresql = PostgreSQLDiagnostics(
status="unavailable",
migration=MigrationDiagnostics(
state="unavailable",
expected_revision=self.metadata.expected_migration_head,
),
)
redis_available = self.redis_adapter.ping()
if not redis_available:
unavailable.append("redis")
return SystemHealthOverviewResponse(
data=SystemHealthOverviewData(
application=ApplicationDiagnostics(
environment=self.metadata.environment,
build_commit=self.metadata.build_commit,
build_version=self.metadata.build_version,
server_time=generated_at,
expected_migration_head=self.metadata.expected_migration_head,
),
postgresql=postgresql,
redis=RedisDiagnostics(
status="available" if redis_available else "unavailable"
),
),
meta=SystemHealthOverviewMeta(
generated_at=generated_at,
partial=bool(unavailable),
unavailable_sections=unavailable,
),
)
def _postgresql_diagnostics(self, payload: dict) -> PostgreSQLDiagnostics:
migration = payload["migration"]
operations = payload["operations"]
integrity = payload["integrity"]
current = migration.get("current_revision")
expected = self.metadata.expected_migration_head
state = (
"unavailable" if not current or not expected
else "current" if current == expected else "mismatch"
)
counts = [
TableCount(name=name, row_count=int(operations[name]))
for name in (
"miners", "sites", "rooms", "location_positions", "repairs",
"repair_events", "repair_parts", "snapshot_count",
)
]
return PostgreSQLDiagnostics(
status="available",
migration=MigrationDiagnostics(
state=state, current_revision=current, expected_revision=expected
),
table_counts=counts,
largest_tables=[
TableSize(name=row["name"], total_bytes=int(row["total_bytes"]))
for row in operations.get("largest_tables") or []
],
telemetry=TelemetryDiagnostics(
snapshot_count=int(operations["snapshot_count"]),
oldest_observed_at=operations.get("oldest_observed_at"),
latest_observed_at=operations.get("latest_observed_at"),
),
fleet=FleetIntegrityDiagnostics(
total_miners=int(operations["miners"]),
miners_without_asset_id=int(integrity["miners_without_asset_id"]),
unmapped_miners=int(integrity["unmapped_miners"]),
miners_in_multiple_positions=int(integrity["miners_in_multiple_positions"]),
duplicate_current_mac_groups=int(integrity["duplicate_current_mac_groups"]),
),
locations=LocationIntegrityDiagnostics(
sites=int(operations["sites"]), rooms=int(operations["rooms"]),
positions=int(operations["location_positions"]),
occupied_positions=int(integrity["occupied_positions"]),
empty_positions=int(integrity["empty_positions"]),
unscoped_legacy_positions=int(integrity["unscoped_legacy_positions"]),
expected_mac_mismatches=int(integrity["expected_mac_mismatches"]),
),
repairs=RepairIntegrityDiagnostics(
active_modular_repairs=int(integrity["active_modular_repairs"]),
duplicate_active_repair_groups=int(integrity["duplicate_active_repair_groups"]),
legacy_repair_only_miners=int(integrity["legacy_repair_only_miners"]),
lifecycle_repair_without_active_repair=int(integrity["lifecycle_repair_without_active_repair"]),
active_repair_lifecycle_mismatches=int(integrity["active_repair_lifecycle_mismatches"]),
waiting_placement_records=int(integrity["waiting_placement_records"]),
repair_events=int(operations["repair_events"]),
repair_parts=int(operations["repair_parts"]),
),
retention=RetentionDiagnostics(
hourly=RollupDiagnostics(
table_present=bool(operations["hourly_present"]),
row_count=int(operations["hourly_rows"]),
latest_bucket=operations.get("hourly_latest"),
),
daily=RollupDiagnostics(
table_present=bool(operations["daily_present"]),
row_count=int(operations["daily_rows"]),
latest_bucket=operations.get("daily_latest"),
),
latest_recorded_run_at=operations.get("retention_started_at"),
latest_recorded_run_mode=operations.get("retention_mode"),
),
)
-235
View File
@@ -1,235 +0,0 @@
"""Focused tests for authenticated Agent queue transport."""
import unittest
from datetime import datetime, timezone
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.agent_commands.dependencies import get_agent_command_service
from app.agent_commands.exceptions import (
AgentCommandConflict,
AgentResultValidationError,
)
from app.agent_commands.models import AgentCommandResultRequest
from app.agent_commands.repository import CLAIM_COMMANDS_SQL
from app.agent_commands.router import router
from app.agent_commands.service import AgentCommandService
from app.config import settings
NOW = datetime(2026, 8, 24, tzinfo=timezone.utc)
def agent_source_root() -> Path | None:
container_root = Path("/agents/windows")
if container_root.is_dir():
return container_root
repository_root = Path(__file__).parents[2] / "agents" / "windows"
return repository_root if repository_root.is_dir() else None
class FakeRepository:
def __init__(self, row=None):
self.row = row or {
"id": "command-1", "agent_id": "test-agent",
"command_type": "miner_control", "command_status": "claimed",
"request_payload": {"action": "suspend"},
}
self.finished = []
def claim_pending(self, agent_id, limit=5):
return [{
"command_id": "command-1", "command_type": "miner_control",
"request_payload": {"action": "suspend"}, "created_at": NOW,
}]
def get_command(self, command_id):
return self.row if command_id == self.row.get("id") else None
def finish_command(self, *args):
self.finished.append(args)
def typed_result(**overrides):
result = {
"schema_version": 1,
"command_accepted": True,
"verification_attempted": False,
"verification_status": "unconfirmed",
"observed_service_state": None,
"observed_performance_mode": None,
"failure_code": None,
}
result.update(overrides)
return result
class AgentCommandServiceTests(unittest.TestCase):
def test_claim_query_uses_skip_locked_and_deterministic_order(self):
self.assertIn("FOR UPDATE SKIP LOCKED", CLAIM_COMMANDS_SQL)
self.assertIn("ORDER BY created_at, id", CLAIM_COMMANDS_SQL)
self.assertIn("queue.command_status = 'pending'", CLAIM_COMMANDS_SQL)
def test_typed_result_is_strict_and_sanitized(self):
repository = FakeRepository()
request = AgentCommandResultRequest(
command_id="command-1", status="completed",
result_payload=typed_result(), error_message="raw 192.0.2.10 vendor exception",
)
AgentCommandService(repository).submit_result("test-agent", request)
finished = repository.finished[0]
self.assertIsNone(finished[4])
self.assertNotIn("192.0.2.10", str(finished))
def test_wrong_agent_pending_and_terminal_results_are_rejected(self):
request = AgentCommandResultRequest(
command_id="command-1", status="completed", result_payload=typed_result()
)
with self.assertRaises(AgentCommandConflict):
AgentCommandService(FakeRepository()).submit_result("wrong-agent", request)
for queue_status in ("pending", "completed", "failed", "cancelled"):
with self.subTest(status=queue_status), self.assertRaises(AgentCommandConflict):
row = FakeRepository().row.copy()
row["command_status"] = queue_status
AgentCommandService(FakeRepository(row)).submit_result("test-agent", request)
def test_invalid_status_and_secret_fields_are_rejected(self):
with self.assertRaises(Exception):
AgentCommandResultRequest(
command_id="command-1", status="unknown", result_payload={}
)
for forbidden in ("password", "account", "token", "raw_response"):
with self.subTest(field=forbidden):
repository = FakeRepository()
request = AgentCommandResultRequest(
command_id="command-1", status="completed",
result_payload={**typed_result(), forbidden: "sensitive"},
)
with self.assertRaises(AgentResultValidationError):
AgentCommandService(repository).submit_result("test-agent", request)
def test_suspend_resume_cannot_claim_confirmation(self):
for status in ("confirmed", "verification_failed"):
with self.subTest(status=status):
request = AgentCommandResultRequest(
command_id="command-1", status="completed",
result_payload=typed_result(
verification_attempted=True, verification_status=status
),
)
with self.assertRaises(AgentResultValidationError):
AgentCommandService(FakeRepository()).submit_result("test-agent", request)
class AgentCommandRouterTests(unittest.TestCase):
def setUp(self):
self.app = FastAPI()
self.app.include_router(router)
self.repository = FakeRepository()
self.app.dependency_overrides[get_agent_command_service] = lambda: AgentCommandService(
self.repository
)
self.client = TestClient(self.app)
self.old_id = settings.minerops_agent_id
self.old_secret = settings.minerops_agent_shared_secret
settings.minerops_agent_id = "test-agent"
settings.minerops_agent_shared_secret = "test-secret"
def tearDown(self):
settings.minerops_agent_id = self.old_id
settings.minerops_agent_shared_secret = self.old_secret
self.client.close()
def headers(self, agent="test-agent"):
return {
"X-MinerOps-Agent-ID": agent,
"X-MinerOps-Agent-Secret": "test-secret",
}
def test_poll_requires_header_identity_and_ignores_query_spoofing(self):
before_claim_calls = []
self.app.state.agent_command_before_claim = lambda: before_claim_calls.append(True)
self.assertEqual(self.client.get("/api/agent/commands/pending").status_code, 401)
self.assertEqual(
self.client.get(
"/api/agent/commands/pending?agent_id=other", headers=self.headers()
).status_code,
200,
)
self.assertEqual(before_claim_calls, [True])
self.assertEqual(
self.client.get(
"/api/agent/commands/pending", headers=self.headers("other")
).status_code,
403,
)
def test_result_requires_strict_claimed_contract(self):
response = self.client.post(
"/api/agent/commands/result",
headers=self.headers(),
json={
"command_id": "command-1", "status": "completed",
"result_payload": typed_result(),
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["status"], "completed")
self.assertEqual(
self.client.post(
"/api/agent/commands/result", headers=self.headers(),
json={"command_id": "command-1", "status": "other",
"result_payload": {}},
).status_code,
422,
)
class AgentSourceContractTests(unittest.TestCase):
def test_agent_typed_path_has_locked_commands_and_no_default_credentials(self):
root = agent_source_root()
if root is None:
self.skipTest("Windows Agent source is not mounted")
service = (root / "MinerOpsAgentService.ps1").read_text()
control = (root / "MinerOpsControl.ps1").read_text()
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
powershell = "\n".join(path.read_text() for path in root.glob("*.ps1"))
self.assertNotIn('DefaultValue "super"', service)
self.assertNotIn('DefaultValue "super"', powershell)
self.assertIn('"X-MinerOps-Agent-ID"', service)
self.assertNotIn("?agent_id=", service)
self.assertIn('"get.miner.setting"', control)
self.assertIn("$attempt -le 3", control)
self.assertIn("$Payload.performance_mode", control)
self.assertNotIn("api3_account =", control)
self.assertNotIn("api3_password =", control)
for forbidden in ("raw_response", "raw_command", "raw_exception"):
with self.subTest(field=forbidden):
self.assertNotIn(forbidden, control.lower())
self.assertNotIn('"agent_secret": secret', main)
self.assertNotIn('"secret_length":', main)
self.assertIn('"secret_configured": bool(secret)', main)
def test_deployment_preserves_local_state_and_registers_established_tasks(self):
root = agent_source_root()
if root is None:
self.skipTest("Windows Agent source is not mounted")
install = (root / "install.ps1").read_text()
uninstall = (root / "uninstall.ps1").read_text()
for task in (
"MinerOpsAgent", "MinerOpsAgentTray",
"MinerOpsApi3Enrichment", "MinerOpsPoolPoller",
):
self.assertIn(task, install)
self.assertIn(task, uninstall)
for local_path in ("agent-config.json", "agent-state.json", "logs"):
self.assertNotIn(f'"{local_path}"', install)
self.assertNotIn(f'"{local_path}"', uninstall)
self.assertNotIn("agent_secret", install.lower())
self.assertNotIn("api3_password", install.lower())
if __name__ == "__main__":
unittest.main()
-227
View File
@@ -1,227 +0,0 @@
"""Focused service, HTTP, frontend, and security tests for miner control v1."""
import unittest
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID, uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.identity import get_current_user, required_api_permission
from app.miner_control.dependencies import get_miner_control_service
from app.miner_control.exceptions import MinerControlConflict, MinerControlUnavailable
from app.miner_control.models import MinerControlAccepted
from app.miner_control.router import router
from app.miner_control.service import MinerControlService
NOW = datetime(2026, 8, 24, tzinfo=timezone.utc)
MINER_ID = UUID("00000000-0000-4000-8000-000000001501")
USER_ID = UUID("00000000-0000-4000-8000-000000001502")
APP_ROOT = Path(__file__).parents[1] / "app"
def context(**overrides):
result = {
"id": MINER_ID,
"site_id": uuid4(),
"current_ip": "192.0.2.10",
"runtime_state": "online",
"configured_performance_mode": "normal",
"api3_supported": True,
"api3_api_switch": "on",
"agent_id": "test-agent",
"last_checkin_at": NOW,
"agent_version": "1.0.0-alpha",
"last_payload": {"control_available": True},
}
result.update(overrides)
return result
class FakeRepository:
def __init__(self, miner_context=None, history=None, conflict=False):
self.miner_context = context() if miner_context is None else miner_context
self.history = history or []
self.conflict = conflict
self.enqueue_calls = []
self.limits = []
def get_control_context(self, miner_id):
self.requested_miner = miner_id
return self.miner_context
def list_recent(self, miner_id, limit=5):
self.limits.append(limit)
return self.history
def enqueue_with_audit(self, **kwargs):
self.enqueue_calls.append(kwargs)
if self.conflict:
raise MinerControlConflict("live command exists")
return {
"operation_id": kwargs["operation_id"],
"status": "pending",
"created_at": NOW,
}
class MinerControlServiceTests(unittest.TestCase):
def test_supported_projection_is_factual_and_bounded(self):
repository = FakeRepository()
result = MinerControlService(repository).get_controls(MINER_ID)
self.assertEqual(result.service_control.state, "supported")
self.assertEqual(result.performance_mode, "normal")
self.assertEqual(result.available_service_action, "suspend")
self.assertEqual(repository.limits, [5])
def test_capability_missing_facts_is_not_optimistic(self):
cases = (
({"site_id": None}, "unavailable", "site_unassigned"),
({"agent_id": None}, "unavailable", "agent_unassigned"),
({"current_ip": None}, "unavailable", "current_ip_missing"),
({"api3_supported": False}, "unavailable", "api3_unsupported"),
({"api3_supported": None}, "unknown", "api3_support_unknown"),
({"last_payload": {}}, "unknown", "agent_capability_unknown"),
(
{"last_payload": {"control_available": False,
"control_unavailable_reason": "credentials_missing"}},
"unavailable", "credentials_missing",
),
)
for changes, state, reason in cases:
with self.subTest(reason=reason):
result = MinerControlService(FakeRepository(context(**changes))).get_controls(MINER_ID)
self.assertEqual(result.service_control.state, state)
self.assertEqual(result.service_control.reason, reason)
def test_resume_requires_known_stopped_state(self):
service = MinerControlService(FakeRepository(context(runtime_state="offline")))
with self.assertRaisesRegex(MinerControlUnavailable, "factually_stopped"):
service.queue_control(
MINER_ID, "resume", requested_value=None,
actor_user_id=USER_ID, actor_username="operator",
)
def test_queue_contains_routing_but_audit_and_response_do_not(self):
repository = FakeRepository()
result = MinerControlService(repository).queue_control(
MINER_ID, "performance_mode", requested_value="high",
actor_user_id=USER_ID, actor_username="operator",
)
call = repository.enqueue_calls[0]
self.assertEqual(call["request_payload"]["ip_address"], "192.0.2.10")
self.assertNotIn("ip_address", call["audit_payload"])
self.assertNotIn("agent_id", call["audit_payload"])
self.assertNotIn("password", str(call).lower())
self.assertNotIn("192.0.2.10", result.model_dump_json())
def test_no_projection_changes_when_work_is_queued(self):
repository = FakeRepository()
MinerControlService(repository).queue_control(
MINER_ID, "suspend", requested_value=None,
actor_user_id=USER_ID, actor_username="operator",
)
self.assertFalse(any("update" in key for key in repository.enqueue_calls[0]))
class MinerControlRouterTests(unittest.TestCase):
def setUp(self):
self.app = FastAPI()
self.app.include_router(router)
self.repository = FakeRepository()
self.service = MinerControlService(self.repository)
self.app.dependency_overrides[get_miner_control_service] = lambda: self.service
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(USER_ID), "username": "operator", "role": "Custom",
"permissions": list(permissions),
}
def test_get_requires_authentication_and_miners_view(self):
path = f"/api/miners/{MINER_ID}/controls"
self.assertEqual(self.client.get(path).status_code, 401)
self.authenticate(("miners.control",))
self.assertEqual(self.client.get(path).status_code, 403)
self.authenticate(("miners.view",))
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["miner_id"], str(MINER_ID))
def test_posts_require_control_and_return_202_bounded_envelope(self):
path = f"/api/miners/{MINER_ID}/controls/suspend"
self.authenticate(("miners.view",))
self.assertEqual(self.client.post(path, json={}).status_code, 403)
self.authenticate(("miners.control",))
response = self.client.post(path, json={})
self.assertEqual(response.status_code, 202)
self.assertEqual(set(response.json()["data"]), {
"operation_id", "miner_id", "action", "requested_value", "status", "created_at"
})
self.assertNotIn("ip", response.text.lower())
self.assertNotIn("agent", response.text.lower())
def test_strict_bodies_reject_routing_commands_and_credentials(self):
self.authenticate(("miners.control",))
base = f"/api/miners/{MINER_ID}/controls"
for body in (
{"ip": "192.0.2.1"}, {"agent_id": "other"}, {"dry_run": True},
{"raw_command": "reboot"}, {"api_password": "secret"},
):
with self.subTest(body=body):
self.assertEqual(self.client.post(base + "/suspend", json=body).status_code, 422)
self.assertEqual(
self.client.post(base + "/performance-mode", json={"mode": "turbo"}).status_code,
422,
)
def test_cross_site_rejected_and_same_origin_allowed(self):
self.authenticate(("miners.control",))
path = f"/api/miners/{MINER_ID}/controls/suspend"
self.assertEqual(
self.client.post(path, json={}, headers={"Sec-Fetch-Site": "cross-site"}).status_code,
403,
)
self.assertEqual(
self.client.post(path, json={}, headers={"Origin": "http://testserver"}).status_code,
202,
)
def test_identity_permission_mapping_is_specific(self):
base = f"/api/miners/{MINER_ID}/controls"
self.assertEqual(required_api_permission("GET", base), "miners.view")
for action in ("suspend", "resume", "performance-mode"):
self.assertEqual(
required_api_permission("POST", f"{base}/{action}"),
"miners.control",
)
self.assertNotEqual(
required_api_permission("POST", base), "miners.control"
)
class MinerControlFrontendTests(unittest.TestCase):
def test_controls_are_safe_confirmation_gated_and_non_polling(self):
source = (APP_ROOT / "frontend" / "static" / "miner-detail.js").read_text()
view = (APP_ROOT / "frontend" / "miner_detail_view.py").read_text()
self.assertIn('window.confirm("Confirm: "', source)
self.assertIn("await loadControls();", source)
self.assertNotIn("setInterval", source)
self.assertNotIn("innerHTML", source)
self.assertIn("minerControlsTitle", view)
self.assertIn("Your account can inspect control capability", view)
def test_new_ui_does_not_use_legacy_control_routes(self):
source = (APP_ROOT / "frontend" / "static" / "miner-detail.js").read_text()
for legacy in ("agent-control", "/commands/", "api3", "change-password", "reboot"):
self.assertNotIn(legacy, source)
if __name__ == "__main__":
unittest.main()
@@ -1,247 +0,0 @@
"""Real PostgreSQL concurrency and transaction regressions for miner control."""
import unittest
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from uuid import UUID
import psycopg
from psycopg.types.json import Jsonb
from app.agent_commands.models import AgentCommandResultRequest
from app.agent_commands.repository import AgentCommandRepository
from app.agent_commands.service import AgentCommandService
from app.config import settings
from app.miner_control.exceptions import MinerControlConflict
from app.miner_control.repository import MinerControlRepository
from app.miner_control.service import MinerControlService
SITE_ID = UUID("00000000-0000-4000-8000-000000001510")
MINER_ID = UUID("00000000-0000-4000-8000-000000001511")
USER_ID = UUID("00000000-0000-4000-8000-000000001512")
OTHER_MINER_ID = UUID("00000000-0000-4000-8000-000000001513")
AGENT_ID = "control-test-agent"
def connect():
return psycopg.connect(
host=settings.postgres_host, port=settings.postgres_port,
dbname=settings.postgres_db, user=settings.postgres_user,
password=settings.postgres_password, connect_timeout=5,
)
def result_payload(accepted=True):
return {
"schema_version": 1,
"command_accepted": accepted,
"verification_attempted": False,
"verification_status": "unconfirmed" if accepted else "unavailable",
"observed_service_state": None,
"observed_performance_mode": None,
"failure_code": None if accepted else "write_rejected",
}
class MinerControlPostgresTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"INSERT INTO sites (id, name) VALUES (%s, 'Control Test Site') "
"ON CONFLICT (id) DO NOTHING", (SITE_ID,)
)
cur.execute(
"""
INSERT INTO users (id, username, display_name, password_hash, role_id)
SELECT %s, 'control-test-user', 'Control Test User', 'unused', id
FROM roles WHERE name = 'Admin' ON CONFLICT (id) DO NOTHING
""", (USER_ID,)
)
cur.execute(
"""
INSERT INTO miners (
id, site_id, asset_tag, current_ip, status, api3_supported,
configured_performance_mode, first_seen_at, last_seen_at
) VALUES (%s, %s, 'CONTROL-TEST', '192.0.2.10', 'online', true,
'normal', NULL, NULL)
ON CONFLICT (id) DO UPDATE SET site_id = EXCLUDED.site_id,
current_ip = EXCLUDED.current_ip, status = EXCLUDED.status,
api3_supported = true
""", (MINER_ID, SITE_ID)
)
cur.execute(
"""
INSERT INTO minerops_site_agents (
agent_id, site_id, site_name, last_payload
) VALUES (%s, %s, 'Control Test Site', %s)
ON CONFLICT (agent_id) DO UPDATE SET site_id = EXCLUDED.site_id,
last_payload = EXCLUDED.last_payload
""", (AGENT_ID, SITE_ID, Jsonb({"control_available": True}))
)
connection.commit()
finally:
connection.close()
@classmethod
def tearDownClass(cls):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute("DELETE FROM audit_logs WHERE entity_id = %s", (MINER_ID,))
cur.execute(
"DELETE FROM minerops_agent_command_queue "
"WHERE request_payload ->> 'miner_id' IN (%s, %s)",
(str(MINER_ID), str(OTHER_MINER_ID)),
)
cur.execute("DELETE FROM minerops_site_agents WHERE agent_id = %s", (AGENT_ID,))
cur.execute("DELETE FROM miners WHERE id = %s", (MINER_ID,))
cur.execute("DELETE FROM users WHERE id = %s", (USER_ID,))
cur.execute("DELETE FROM sites WHERE id = %s", (SITE_ID,))
connection.commit()
finally:
connection.close()
def setUp(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute("DELETE FROM audit_logs WHERE entity_id = %s", (MINER_ID,))
cur.execute(
"DELETE FROM minerops_agent_command_queue "
"WHERE request_payload ->> 'miner_id' IN (%s, %s)",
(str(MINER_ID), str(OTHER_MINER_ID)),
)
connection.commit()
finally:
connection.close()
def insert_command(self, command_id, miner_id, status="pending", created=None):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"""
INSERT INTO minerops_agent_command_queue (
id, agent_id, command_type, command_status, request_payload, created_at
) VALUES (%s, %s, 'miner_control', %s, %s, COALESCE(%s, now()))
""",
(command_id, AGENT_ID, status,
Jsonb({"schema_version": 1, "miner_id": str(miner_id),
"action": "suspend", "requested_value": None}), created),
)
connection.commit()
finally:
connection.close()
def test_two_concurrent_claimers_never_receive_same_command(self):
self.insert_command("concurrent-one", MINER_ID)
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(
lambda _value: AgentCommandRepository(connect).claim_pending(AGENT_ID),
range(2),
))
claimed = [row["command_id"] for result in results for row in result]
self.assertEqual(claimed, ["concurrent-one"])
def test_skip_locked_claims_next_available_row(self):
self.insert_command(
"a-locked", MINER_ID,
created=datetime(2026, 8, 24, 10, tzinfo=timezone.utc),
)
self.insert_command(
"b-available", OTHER_MINER_ID,
created=datetime(2026, 8, 24, 11, tzinfo=timezone.utc),
)
locker = connect()
try:
with locker.cursor() as cur:
cur.execute(
"SELECT id FROM minerops_agent_command_queue "
"WHERE id = 'a-locked' FOR UPDATE"
)
claimed = AgentCommandRepository(connect).claim_pending(AGENT_ID)
self.assertEqual([row["command_id"] for row in claimed], ["b-available"])
finally:
locker.rollback()
locker.close()
def test_result_binding_claim_requirement_and_terminal_immutability(self):
self.insert_command("result-one", MINER_ID, status="claimed")
request = AgentCommandResultRequest(
command_id="result-one", status="completed", result_payload=result_payload()
)
service = AgentCommandService(AgentCommandRepository(connect))
with self.assertRaises(Exception):
service.submit_result("wrong-agent", request)
service.submit_result(AGENT_ID, request)
with self.assertRaises(Exception):
service.submit_result(AGENT_ID, request)
self.insert_command("pending-result", OTHER_MINER_ID, status="pending")
pending = AgentCommandResultRequest(
command_id="pending-result", status="failed", result_payload=result_payload(False)
)
with self.assertRaises(Exception):
service.submit_result(AGENT_ID, pending)
def test_atomic_queue_audit_duplicate_live_and_completed_history(self):
service = MinerControlService(MinerControlRepository(connect))
first = service.queue_control(
MINER_ID, "suspend", requested_value=None,
actor_user_id=USER_ID, actor_username="control-test-user",
)
with self.assertRaises(MinerControlConflict):
service.queue_control(
MINER_ID, "suspend", requested_value=None,
actor_user_id=USER_ID, actor_username="control-test-user",
)
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"SELECT count(*) FROM audit_logs WHERE entity_id = %s", (MINER_ID,)
)
self.assertEqual(cur.fetchone()[0], 1)
cur.execute(
"UPDATE minerops_agent_command_queue SET command_status = 'completed', "
"completed_at = now() WHERE id = %s", (first.operation_id,)
)
connection.commit()
finally:
connection.close()
second = service.queue_control(
MINER_ID, "suspend", requested_value=None,
actor_user_id=USER_ID, actor_username="control-test-user",
)
self.assertNotEqual(first.operation_id, second.operation_id)
def test_audit_failure_rolls_back_queue_row(self):
service = MinerControlService(MinerControlRepository(connect))
operation_count_before = self._operation_count()
with self.assertRaises(Exception):
service.queue_control(
MINER_ID, "suspend", requested_value=None,
actor_user_id=UUID("00000000-0000-4000-8000-000000009999"),
actor_username="missing-user",
)
self.assertEqual(self._operation_count(), operation_count_before)
def _operation_count(self):
connection = connect()
try:
with connection.cursor() as cur:
cur.execute(
"SELECT count(*) FROM minerops_agent_command_queue "
"WHERE request_payload ->> 'miner_id' = %s", (str(MINER_ID),)
)
return cur.fetchone()[0]
finally:
connection.close()
if __name__ == "__main__":
unittest.main()
+4 -11
View File
@@ -498,12 +498,10 @@ class MinerDetailFrontendTests(unittest.TestCase):
r'type="button">Refresh</button>',
)
source = (STATIC_ROOT / "miner-detail.js").read_text(encoding="utf-8")
self.assertIn('el.refresh.addEventListener("click", refreshAll);', source)
refresh = re.search(
r"function refreshAll\(\) \{\s*loadOverview\(\);\s*loadControls\(\);\s*\}",
self.assertIn(
'el.refresh.addEventListener("click", loadOverview);',
source,
)
self.assertIsNotNone(refresh)
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
self.assertIn(".miner-detail-actions .quiet-button { color: var(--ink); }", css)
@@ -564,14 +562,9 @@ class MinerDetailFrontendTests(unittest.TestCase):
self.assertIn("/events", source)
self.assertIn("/parts", source)
self.assertNotIn("setInterval", source)
self.assertEqual(source.count('method: "POST"'), 1)
self.assertIn('"/controls/" + pathAction', source)
self.assertIn(
'action === "performance_mode" ? "performance-mode" : action', source
)
for mutation in (
'method: "PATCH"', 'method: "PUT"', 'method: "DELETE"',
"reboot", "change-mac", "manual-details",
'method: "POST"', 'method: "PATCH"', 'method: "PUT"',
'method: "DELETE"', "reboot", "change-mac", "manual-details",
):
self.assertNotIn(mutation, source)
for unsafe in ("innerHTML", "outerHTML", "insertAdjacentHTML", "document.write"):
-304
View File
@@ -1,304 +0,0 @@
import ast
import unittest
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
import psycopg
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.config import settings
from app.identity import get_current_user
from app.system_health.application import ApplicationMetadata
from app.system_health.dependencies import get_system_health_service
from app.system_health.frontend import router as frontend_router
from app.system_health.repository import SystemHealthRepository
from app.system_health.router import router as api_router
from app.system_health.service import SystemHealthService
APP_ROOT = Path(__file__).parents[1] / "app"
EXPECTED_HEAD = "0015_miner_control_foundation"
def database_payload(revision=EXPECTED_HEAD):
return {
"migration": {"current_revision": revision},
"operations": {
"miners": 4, "sites": 1, "rooms": 2, "location_positions": 3,
"repairs": 1, "repair_events": 2, "repair_parts": 1,
"snapshot_count": 5,
"oldest_observed_at": datetime(2026, 8, 18, tzinfo=timezone.utc),
"latest_observed_at": datetime(2026, 8, 19, tzinfo=timezone.utc),
"hourly_present": True, "hourly_rows": 2,
"hourly_latest": datetime(2026, 8, 19, tzinfo=timezone.utc),
"daily_present": True, "daily_rows": 1,
"daily_latest": datetime(2026, 8, 19, tzinfo=timezone.utc).date(),
"retention_mode": "dry_run",
"retention_started_at": datetime(2026, 8, 19, tzinfo=timezone.utc),
"largest_tables": [{"name": "miners", "total_bytes": 8192}],
},
"integrity": {
"miners_without_asset_id": 1, "unmapped_miners": 1,
"miners_in_multiple_positions": 0, "duplicate_current_mac_groups": 0,
"unscoped_legacy_positions": 1, "expected_mac_mismatches": 1,
"occupied_positions": 2, "empty_positions": 1,
"active_modular_repairs": 1, "duplicate_active_repair_groups": 0,
"legacy_repair_only_miners": 0,
"lifecycle_repair_without_active_repair": 0,
"active_repair_lifecycle_mismatches": 0,
"waiting_placement_records": 1,
},
}
class FakeRepository:
def __init__(self, payload=None, error=None):
self.payload = payload or database_payload()
self.error = error
self.calls = 0
def read_overview(self):
self.calls += 1
if self.error:
raise self.error
return self.payload
class FakeRedis:
def __init__(self, available=True):
self.available = available
self.calls = 0
def ping(self):
self.calls += 1
return self.available
def metadata(head=EXPECTED_HEAD):
return ApplicationMetadata(
environment="test", build_commit=None, build_version="0.1-test",
expected_migration_head=head,
)
class SystemHealthServiceTests(unittest.TestCase):
def test_builds_typed_factual_overview_without_health_score(self):
repository, redis = FakeRepository(), FakeRedis()
result = SystemHealthService(repository, redis, metadata()).get_overview()
self.assertEqual(result.data.postgresql.migration.state, "current")
self.assertEqual(result.data.postgresql.telemetry.snapshot_count, 5)
self.assertEqual(result.data.postgresql.fleet.unmapped_miners, 1)
self.assertFalse(result.data.postgresql.retention.raw_retention_enabled)
self.assertEqual(result.data.postgresql.retention.hourly.population, "manual")
self.assertEqual(result.data.postgresql.retention.completeness, "not_guaranteed")
self.assertIsNone(result.data.application.build_commit)
self.assertFalse(result.meta.partial)
self.assertEqual((repository.calls, redis.calls), (1, 1))
self.assertNotIn("score", result.model_dump_json().lower())
def test_migration_mismatch_is_factual(self):
result = SystemHealthService(
FakeRepository(database_payload("0013_control_board_replacement")),
FakeRedis(), metadata(),
).get_overview()
self.assertEqual(result.data.postgresql.migration.state, "mismatch")
self.assertEqual(
result.data.postgresql.migration.current_revision,
"0013_control_board_replacement",
)
def test_postgresql_failure_is_sanitized_and_redis_remains_available(self):
secret = "postgresql://user:password@private-host/minerops"
result = SystemHealthService(
FakeRepository(error=RuntimeError(secret)), FakeRedis(), metadata()
).get_overview()
self.assertTrue(result.meta.partial)
self.assertEqual(result.meta.unavailable_sections, ["postgresql"])
self.assertEqual(result.data.postgresql.status, "unavailable")
self.assertEqual(result.data.redis.status, "available")
self.assertNotIn(secret, result.model_dump_json())
def test_redis_failure_preserves_database_diagnostics(self):
result = SystemHealthService(
FakeRepository(), FakeRedis(False), metadata()
).get_overview()
self.assertEqual(result.data.postgresql.status, "available")
self.assertEqual(result.data.redis.status, "unavailable")
self.assertEqual(result.meta.unavailable_sections, ["redis"])
class StubService:
def get_overview(self):
return SystemHealthService(
FakeRepository(), FakeRedis(), metadata()
).get_overview()
class SystemHealthRouterTests(unittest.TestCase):
def setUp(self):
self.app = FastAPI()
self.app.include_router(api_router)
self.app.dependency_overrides[get_system_health_service] = StubService
self.client = TestClient(self.app)
def tearDown(self):
self.client.close()
def authenticate(self, permissions):
self.app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "role": "Custom", "permissions": list(permissions)
}
def test_requires_authentication_and_system_health_permission(self):
self.assertEqual(self.client.get("/api/system/health-summary").status_code, 401)
self.authenticate(("dashboard.view",))
self.assertEqual(self.client.get("/api/system/health-summary").status_code, 403)
def test_returns_typed_no_store_envelope(self):
self.authenticate(("system_health.view",))
response = self.client.get("/api/system/health-summary")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["cache-control"], "no-store")
payload = response.json()
self.assertEqual(payload["data"]["postgresql"]["status"], "available")
self.assertEqual(payload["meta"]["postgresql_statement_count"], 3)
self.assertIsNone(payload["error"])
class CountingCursor(psycopg.Cursor):
statements = 0
def execute(self, query, params=None, *, prepare=None, binary=None):
type(self).statements += 1
return super().execute(
query, params, prepare=prepare, binary=binary
)
class SystemHealthRepositoryIntegrationTests(unittest.TestCase):
def setUp(self):
self.connection = psycopg.connect(
host=settings.postgres_host, port=settings.postgres_port,
dbname=settings.postgres_db, user=settings.postgres_user,
password=settings.postgres_password, cursor_factory=CountingCursor,
)
CountingCursor.statements = 0
self.repository = SystemHealthRepository(connection=self.connection)
def tearDown(self):
self.connection.rollback()
self.connection.close()
def test_real_postgresql_overview_uses_exactly_three_statements(self):
site_id, room_id, mapped_id, unmapped_id = (
uuid4(), uuid4(), uuid4(), uuid4()
)
suffix = uuid4().hex[:10]
with self.connection.cursor() as cur:
cur.execute("INSERT INTO sites (id, name) VALUES (%s, %s)",
(site_id, "Health Site " + suffix))
cur.execute("INSERT INTO rooms (id, site_id, name) VALUES (%s, %s, %s)",
(room_id, site_id, "Health Room " + suffix))
cur.execute(
"INSERT INTO miners (id, asset_tag, lifecycle_status, current_mac) "
"VALUES (%s, %s, 'active', %s), (%s, NULL, 'active', NULL)",
(mapped_id, "HEALTH-" + suffix, "02:00:00:00:00:01", unmapped_id),
)
cur.execute(
"INSERT INTO location_positions "
"(room_id, room_name, rack_name, shelf_name, position_index, "
"miner_id, expected_mac) VALUES (%s, %s, %s, '1', 1, %s, %s), "
"(NULL, %s, %s, '1', 2, NULL, NULL)",
(room_id, "Health Room " + suffix, "Rack " + suffix, mapped_id,
"02:00:00:00:00:02", "Legacy " + suffix, "Rack " + suffix),
)
cur.execute(
"INSERT INTO miner_status_snapshots (miner_id, observed_at, mhs_av) "
"VALUES (%s, %s, 0)",
(mapped_id, datetime(2026, 8, 19, 12, tzinfo=timezone.utc)),
)
CountingCursor.statements = 0
result = self.repository.read_overview()
self.assertEqual(CountingCursor.statements, 3)
self.assertGreaterEqual(result["operations"]["miners"], 2)
self.assertGreaterEqual(result["operations"]["snapshot_count"], 1)
self.assertEqual(result["migration"]["current_revision"], EXPECTED_HEAD)
self.assertGreaterEqual(result["integrity"]["unmapped_miners"], 1)
self.assertGreaterEqual(result["integrity"]["unscoped_legacy_positions"], 1)
self.assertGreaterEqual(result["integrity"]["expected_mac_mismatches"], 1)
self.assertTrue(result["operations"]["hourly_present"])
self.assertTrue(result["operations"]["daily_present"])
def test_existing_role_matrix_preserves_read_only_grant(self):
with self.connection.cursor() as cur:
cur.execute(
"SELECT roles.name FROM role_permissions "
"JOIN roles ON roles.id = role_permissions.role_id "
"JOIN permissions ON permissions.id = role_permissions.permission_id "
"WHERE permissions.key = 'system_health.view' ORDER BY roles.name"
)
roles = [row[0] for row in cur.fetchall()]
self.assertEqual(roles, ["Admin", "Manager", "Read Only"])
class SystemHealthFrontendAndArchitectureTests(unittest.TestCase):
def test_page_is_semantic_safe_and_one_request(self):
app = FastAPI()
app.include_router(frontend_router)
app.dependency_overrides[get_current_user] = lambda: {
"id": str(uuid4()), "display_name": "Health <Operator>",
"role": "Manager", "permissions": ["system_health.view"],
}
client = TestClient(app)
response = client.get("/system-health")
self.assertEqual(response.status_code, 200)
self.assertIn("System Health | MinerOps", response.text)
self.assertNotIn("Health <Operator>", response.text)
self.assertIn("aria-live=\"polite\"", response.text)
client.close()
script = (APP_ROOT / "frontend/static/system-health.js").read_text()
self.assertEqual(script.count('fetch("/api/system/health-summary"'), 1)
self.assertNotIn("setInterval", script)
self.assertNotIn("innerHTML", script)
self.assertIn("textContent", script)
def test_routes_are_unique_and_legacy_source_diagnostics_are_removed(self):
source = (APP_ROOT / "main.py").read_text()
self.assertNotIn("source_lines", source)
self.assertNotIn("recent_patch_markers", source)
from app.main import app
page = [r for r in app.routes if getattr(r, "path", None) == "/system-health"]
api = [r for r in app.routes if getattr(r, "path", None) == "/api/system/health-summary"]
self.assertEqual(len(page), 1)
self.assertEqual(len(api), 1)
self.assertEqual(page[0].methods, {"GET"})
self.assertEqual(api[0].methods, {"GET"})
def test_layers_and_security_boundaries(self):
router_source = (APP_ROOT / "system_health/router.py").read_text()
service_source = (APP_ROOT / "system_health/service.py").read_text()
repository_source = (APP_ROOT / "system_health/repository.py").read_text()
ast.parse(router_source)
ast.parse(service_source)
self.assertNotIn("SELECT ", router_source)
self.assertNotIn("SELECT ", service_source)
self.assertNotIn("fastapi", repository_source.lower())
self.assertNotIn("fastapi", service_source.lower())
static = "\n".join(
(APP_ROOT / path).read_text()
for path in (
"frontend/static/system-health.js",
"frontend/static/system-health.css",
)
).lower()
for forbidden in (
"postgres_password", "redis_url", "jwt_secret", "connection string",
"stack trace", "password hash", "api key",
):
self.assertNotIn(forbidden, static)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -78,8 +78,8 @@ migration_head="$(
"${compose[@]}" exec -T postgres sh -ec \
'psql -v ON_ERROR_STOP=1 -At -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT version_num FROM alembic_version"'
)"
if [[ "${migration_head}" != "0015_miner_control_foundation" ]]; then
echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0015_miner_control_foundation." >&2
if [[ "${migration_head}" != "0014_repair_parts_tracking" ]]; then
echo "Refusing demo seed: database Alembic head is ${migration_head:-missing}, expected 0014_repair_parts_tracking." >&2
exit 1
fi
+1 -1
View File
@@ -132,7 +132,7 @@ health_json="$(cat "${health_file}")"
echo "Checking migrations and required schema..."
current_revision="$("${compose[@]}" exec -T backend alembic current 2>/dev/null)"
[[ "${current_revision}" == *"0015_miner_control_foundation"* ]]
[[ "${current_revision}" == *"0014_repair_parts_tracking"* ]]
missing_tables="$(
"${compose[@]}" exec -T postgres sh -c \
-137
View File
@@ -1,137 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
project_name="${MINEROPS_CONTROL_MIGRATION_PROJECT:-minerops-control-migration-$$}"
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
cleanup() {
status=$?
if (( status != 0 )); then
"${compose[@]}" logs --no-color --tail 120 postgres || true
fi
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
exit "${status}"
}
trap cleanup EXIT
run_alembic() { "${compose[@]}" run --rm --no-deps backend alembic "$@"; }
psql_db() {
"${compose[@]}" exec -T postgres sh -ec \
'exec psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" "$@"' sh "$@"
}
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
"${compose[@]}" up --detach postgres
for attempt in $(seq 1 60); do
"${compose[@]}" exec -T postgres sh -ec \
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
sleep 1
done
"${compose[@]}" build backend
echo "Testing 0015 preflight failures from the previous head..."
run_alembic upgrade 0014_repair_parts_tracking
psql_db -c "INSERT INTO minerops_agent_command_queue (id, agent_id, command_type, command_status) VALUES ('unknown-status', 'test-agent', 'test', 'mystery')"
if output="$(run_alembic upgrade 0015_miner_control_foundation 2>&1)"; then
echo "Unknown queue status unexpectedly passed migration preflight" >&2
exit 1
fi
[[ "${output}" == *"unsupported command_status values: mystery"* ]]
psql_db -c "DELETE FROM minerops_agent_command_queue WHERE id = 'unknown-status'"
duplicate_miner="00000000-0000-4000-8000-000000001599"
psql_db <<SQL
INSERT INTO minerops_agent_command_queue (id, agent_id, command_type, command_status, request_payload)
VALUES
('duplicate-one', 'test-agent', 'miner_control', 'pending', '{"miner_id":"${duplicate_miner}"}'),
('duplicate-two', 'test-agent', 'miner_control', 'claimed', '{"miner_id":"${duplicate_miner}"}');
SQL
if output="$(run_alembic upgrade 0015_miner_control_foundation 2>&1)"; then
echo "Duplicate live controls unexpectedly passed migration preflight" >&2
exit 1
fi
[[ "${output}" == *"more than one pending or claimed miner_control"* ]]
[[ "${output}" == *"${duplicate_miner}"* ]]
psql_db -c "DELETE FROM minerops_agent_command_queue WHERE id IN ('duplicate-one', 'duplicate-two')"
psql_db <<'SQL'
INSERT INTO minerops_site_agents (agent_id, site_name)
VALUES ('legacy-unassigned-agent', 'Legacy Display Name');
INSERT INTO minerops_agent_command_queue (
id, agent_id, command_type, command_status, request_payload
) VALUES (
'preserved-history', 'legacy-unassigned-agent', 'miner_control', 'completed',
'{"miner_id":"00000000-0000-4000-8000-000000001598","action":"suspend"}'
);
SQL
run_alembic upgrade 0015_miner_control_foundation
[[ "$(run_alembic current 2>/dev/null)" == *"0015_miner_control_foundation"* ]]
echo "Checking assignment, status, uniqueness, history, and preservation..."
psql_db <<'SQL'
DO $$
BEGIN
IF (SELECT site_id IS NOT NULL FROM minerops_site_agents
WHERE agent_id = 'legacy-unassigned-agent') THEN
RAISE EXCEPTION 'Legacy site_name was silently converted to site_id';
END IF;
IF NOT EXISTS (SELECT 1 FROM minerops_agent_command_queue
WHERE id = 'preserved-history' AND command_status = 'completed') THEN
RAISE EXCEPTION 'Historical queue row was not preserved';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint
WHERE conname = 'ck_minerops_agent_command_queue_status') THEN
RAISE EXCEPTION 'Queue status constraint missing';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes
WHERE indexname = 'uq_agent_queue_live_miner_control')
OR NOT EXISTS (SELECT 1 FROM pg_indexes
WHERE indexname = 'idx_agent_queue_miner_control_history') THEN
RAISE EXCEPTION 'Miner control queue indexes missing';
END IF;
END $$;
INSERT INTO sites (id, name)
VALUES ('00000000-0000-4000-8000-000000001590', 'Control Migration Site');
UPDATE minerops_site_agents SET site_id = '00000000-0000-4000-8000-000000001590'
WHERE agent_id = 'legacy-unassigned-agent';
DO $$
BEGIN
BEGIN
INSERT INTO minerops_site_agents (agent_id, site_id, site_name)
VALUES ('duplicate-site-agent', '00000000-0000-4000-8000-000000001590', 'Duplicate');
RAISE EXCEPTION 'Duplicate durable site assignment was accepted';
EXCEPTION WHEN unique_violation THEN NULL;
END;
BEGIN
DELETE FROM sites WHERE id = '00000000-0000-4000-8000-000000001590';
RAISE EXCEPTION 'Assigned site deletion was accepted';
EXCEPTION WHEN foreign_key_violation THEN NULL;
END;
BEGIN
INSERT INTO minerops_agent_command_queue (id, agent_id, command_type, command_status)
VALUES ('bad-status-after', 'legacy-unassigned-agent', 'test', 'mystery');
RAISE EXCEPTION 'Unknown status passed CHECK';
EXCEPTION WHEN check_violation THEN NULL;
END;
END $$;
SQL
echo "Testing downgrade, historical preservation, and re-upgrade..."
run_alembic downgrade 0014_repair_parts_tracking
[[ "$(run_alembic current 2>/dev/null)" == *"0014_repair_parts_tracking"* ]]
[[ "$(psql_db -Atc "SELECT count(*) FROM minerops_agent_command_queue WHERE id = 'preserved-history'")" == "1" ]]
[[ "$(psql_db -Atc "SELECT count(*) FROM information_schema.columns WHERE table_name='minerops_site_agents' AND column_name='site_id'")" == "0" ]]
run_alembic upgrade 0015_miner_control_foundation
echo "Testing empty database through head..."
"${compose[@]}" exec -T postgres sh -ec 'createdb -U "$POSTGRES_USER" minerops_control_empty'
"${compose[@]}" run --rm --no-deps -e POSTGRES_DB=minerops_control_empty backend alembic upgrade head
empty_revision="$("${compose[@]}" exec -T postgres sh -ec \
'psql -U "$POSTGRES_USER" -d minerops_control_empty -Atc "SELECT version_num FROM alembic_version"')"
[[ "${empty_revision}" == "0015_miner_control_foundation" ]]
echo "Miner Control Foundation migration tests passed."
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
project_name="${MINEROPS_CONTROL_TEST_PROJECT:-minerops-control-test-$$}"
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
cleanup() {
status=$?
if (( status != 0 )); then
"${compose[@]}" logs --no-color --tail 120 postgres || true
fi
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
exit "${status}"
}
trap cleanup EXIT
[[ -f .env.dev ]] || { echo "Missing .env.dev" >&2; exit 1; }
for source_file in backend/tests/test_miner_control.py backend/tests/test_agent_commands.py backend/tests/test_miner_control_postgres.py; do
permissions="$(stat -c '%a' "${source_file}")"
(( (10#${permissions} % 10) & 4 )) || {
echo "${source_file} is not readable by the non-root test container" >&2
exit 1
}
done
for source_directory in agents agents/windows agents/windows/tests; do
permissions="$(stat -c '%a' "${source_directory}")"
(( (10#${permissions} % 10) & 1 )) || {
echo "${source_directory} is not traversable by the non-root test container" >&2
exit 1
}
done
"${compose[@]}" up --detach postgres
for attempt in $(seq 1 60); do
"${compose[@]}" exec -T postgres sh -ec \
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 && break
(( attempt < 60 )) || { echo "PostgreSQL did not become ready" >&2; exit 1; }
sleep 1
done
"${compose[@]}" build backend
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
"${compose[@]}" run --rm --no-deps \
-v "${repo_root}/backend/tests:/app/tests:ro" \
-v "${repo_root}/agents:/agents:ro" \
backend python -m unittest -v \
tests.test_miner_control \
tests.test_agent_commands \
tests.test_miner_control_postgres
echo "Miner Control Foundation focused tests passed."
-70
View File
@@ -1,70 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
readonly source_files=(
backend/app/config.py
backend/app/frontend/static/system-health.css
backend/app/frontend/static/system-health.js
backend/app/frontend/system_health_view.py
backend/app/frontend/views.py
backend/app/main.py
backend/app/system_health/__init__.py
backend/app/system_health/adapters.py
backend/app/system_health/application.py
backend/app/system_health/dependencies.py
backend/app/system_health/frontend.py
backend/app/system_health/models.py
backend/app/system_health/repository.py
backend/app/system_health/router.py
backend/app/system_health/service.py
backend/tests/__init__.py
backend/tests/test_frontend_shell.py
backend/tests/test_system_health.py
)
for file in "${source_files[@]}"; do
[[ -r "${file}" ]] || { echo "Unreadable System Health input: ${file}" >&2; exit 1; }
[[ "$(stat -c '%a' "${file}")" == "644" ]] || {
echo "System Health source/test mode must be 0644: ${file}" >&2; exit 1;
}
done
[[ "$(stat -c '%a' scripts/test-system-health.sh)" == "755" ]] || {
echo "scripts/test-system-health.sh must have mode 0755." >&2; exit 1;
}
echo "System Health file-mode preflight passed."
python3 -m py_compile backend/app/config.py backend/app/frontend/system_health_view.py backend/app/frontend/views.py backend/app/main.py backend/app/system_health/*.py backend/tests/__init__.py backend/tests/test_frontend_shell.py backend/tests/test_system_health.py
node --check backend/app/frontend/static/system-health.js
bash -n scripts/test-system-health.sh
[[ -f .env.dev ]] || { echo "Missing .env.dev." >&2; exit 1; }
project_name="${MINEROPS_SYSTEM_HEALTH_TEST_PROJECT:-minerops-system-health-$$}"
compose=(docker compose --project-name "${project_name}" --env-file .env.dev -f compose.dev.yaml)
cleanup() {
status=$?
if (( status != 0 )); then
"${compose[@]}" logs --no-color --tail 100 postgres redis || true
fi
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
exit "${status}"
}
trap cleanup EXIT
"${compose[@]}" up --detach postgres redis
for attempt in $(seq 1 60); do
if "${compose[@]}" exec -T postgres sh -ec \
'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' >/dev/null 2>&1 \
&& "${compose[@]}" exec -T redis redis-cli ping 2>/dev/null | grep -qx PONG; then
break
fi
(( attempt < 60 )) || { echo "Dependencies did not become ready." >&2; exit 1; }
sleep 1
done
"${compose[@]}" build backend
"${compose[@]}" run --rm --no-deps backend alembic upgrade head
"${compose[@]}" run --rm --no-deps \
-v "${repo_root}/backend/tests:/app/tests:ro" \
backend python -m unittest -v tests.test_system_health tests.test_frontend_shell
-33
View File
@@ -1,33 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
service="agents/windows/MinerOpsAgentService.ps1"
control="agents/windows/MinerOpsControl.ps1"
installer="agents/windows/install.ps1"
rg -q 'X-MinerOps-Agent-ID' "${service}"
! rg -q 'commands/pending\?agent_id=' "${service}"
! rg -q 'DefaultValue "super"' "${service}"
rg -q 'get\.miner\.setting' "${control}"
rg -q '\$attempt -le 3' "${control}"
rg -q 'VerificationStatus "unconfirmed"' "${control}"
for secret_field in 'api3_password =' 'api3_account =' 'raw_vendor' 'salt_response'; do
! rg -q "${secret_field}" "${control}"
done
rg -q 'Get-MinerOpsScheduledTask' "${installer}"
rg -q 'Stop-MinerOpsScheduledTaskForUpgrade' "${installer}"
rg -q 'mainTaskWasRunning' "${installer}"
rg -q 'Preserving existing Scheduled Task' "${installer}"
rg -q -- '-Limit 0 -TimeoutMs 8000' "${installer}"
rg -q 'Unregister-ScheduledTask' "${installer}"
rg -q 'could not restart restored MinerOpsAgent' "${installer}"
! rg -q 'Register-ScheduledTask.*-Force' "${installer}"
! rg -q 'agent_secret|api3_password|api3_account' "${installer}"
if command -v pwsh >/dev/null 2>&1; then
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsControl.ps1
pwsh -NoProfile -File agents/windows/tests/Test-MinerOpsInstaller.ps1
else
echo "pwsh is not installed; deterministic PowerShell harnesses retained but not executed."
fi
echo "Windows Agent source contract checks passed."