Add local operations dashboard

This commit is contained in:
Meta Vens 2026-04-24 20:11:18 +01:00
commit b5c71d322e
7 changed files with 938 additions and 7 deletions

View file

@ -472,6 +472,14 @@ That check covers:
It writes status to `.voidbot/status/operations-health.json` and appends logs to `.voidbot/logs/operations-watchdog.log`.
If you want an actual local dashboard instead of spelunking JSON by candlelight:
```bash
npm run state:dashboard
```
That renders `.voidbot/status/operations-dashboard.html`, opens it in your default browser, and auto-refreshes every 10 seconds. The watchdog now also writes a live run-state file while it is executing, so a hidden task that wedges should stay visibly stuck on its last known step instead of failing in perfect silence.
Install the recurring watchdog task:
```bash
@ -515,6 +523,8 @@ Useful local state and docs:
- `.voidbot/status/runtime-stack.json`: live stack status, service reachability, and bot/worker PIDs
- `.voidbot/status/offsite-backup.json`: latest offsite backup sync result
- `.voidbot/status/operations-health.json`: latest watchdog health report
- `.voidbot/status/operations-watchdog.json`: live watchdog run-state plus last notification metadata
- `.voidbot/status/operations-dashboard.html`: auto-refreshing local operations dashboard
- `config/system-messages.json`: rotating stock system messages
- `styles/void-default.md`: public default style pack
- `docs/architecture/overview.md`: higher-level architecture notes

View file

@ -17,6 +17,8 @@
"state:restore": "powershell -ExecutionPolicy Bypass -File scripts/restore-voidbot-state.ps1",
"state:verify-backup": "powershell -ExecutionPolicy Bypass -File scripts/verify-voidbot-backup.ps1",
"state:check": "powershell -ExecutionPolicy Bypass -File scripts/check-voidbot-operations.ps1",
"state:dashboard": "powershell -ExecutionPolicy Bypass -File scripts/render-voidbot-operations-dashboard.ps1 -Open",
"state:render-dashboard": "powershell -ExecutionPolicy Bypass -File scripts/render-voidbot-operations-dashboard.ps1",
"state:sync-offsite": "powershell -ExecutionPolicy Bypass -File scripts/sync-voidbot-backup-offsite.ps1",
"state:install-offsite-task": "powershell -ExecutionPolicy Bypass -File scripts/install-offsite-backup-task.ps1",
"state:install-watchdog-task": "powershell -ExecutionPolicy Bypass -File scripts/install-operations-watchdog-task.ps1",

View file

@ -10,6 +10,7 @@ $ProgressPreference = "SilentlyContinue"
$repoRoot = Split-Path -Parent $PSScriptRoot
$envFile = Join-Path $repoRoot ".env"
$verifyScript = Join-Path $PSScriptRoot "verify-voidbot-backup.ps1"
. (Join-Path $PSScriptRoot "voidbot-operations-dashboard-lib.ps1")
function Read-DotEnv {
param(
@ -72,6 +73,10 @@ function Write-StatusFile {
$directory = Split-Path -Parent $Path
New-Item -ItemType Directory -Force -Path $directory | Out-Null
$Status | ConvertTo-Json -Depth 10 | Set-Content -Path $Path -Encoding utf8
if ($script:storageRoot) {
[void](Update-VoidBotOperationsDashboard -RepoRoot $repoRoot -StorageRoot $script:storageRoot)
}
}
function Start-LogCapture {
@ -417,6 +422,20 @@ function Build-OwnerNotificationMessage {
return ($lines -join [Environment]::NewLine).Trim()
}
function Set-WatchdogStep {
param(
[Parameter(Mandatory = $true)]
[string] $CurrentStep,
[Parameter(Mandatory = $false)]
[string] $Detail = ""
)
$script:watchdogState.currentStep = $CurrentStep
$script:watchdogState.currentDetail = $Detail
$script:watchdogState.stepUpdatedAt = (Get-Date).ToString("o")
Write-StatusFile -Path $script:watchdogStatePath -Status $script:watchdogState
}
$checks = @()
$config = Read-DotEnv -Path $envFile
$storageRoot = Resolve-ConfigPath -RepoRoot $repoRoot -Value $config["STORAGE_ROOT"] -Fallback ".voidbot"
@ -436,7 +455,7 @@ $offsiteTarget = if ($config.ContainsKey("OFFSITE_BACKUP_SSH_TARGET")) { $config
$offsiteKeepLatest = if ($config.ContainsKey("OFFSITE_BACKUP_REMOTE_KEEP_LATEST")) { [int]$config["OFFSITE_BACKUP_REMOTE_KEEP_LATEST"] } else { 14 }
$offsiteLabel = if ($config.ContainsKey("OFFSITE_BACKUP_LABEL") -and -not [string]::IsNullOrWhiteSpace($config["OFFSITE_BACKUP_LABEL"])) { $config["OFFSITE_BACKUP_LABEL"] } else { "offsite-auto" }
$logCaptureStarted = Start-LogCapture -LogPath $logPath
$watchdogState = if (Test-Path -LiteralPath $watchdogStatePath) {
$previousWatchdogState = if (Test-Path -LiteralPath $watchdogStatePath) {
try {
Get-Content -LiteralPath $watchdogStatePath -Raw | ConvertFrom-Json
} catch {
@ -446,7 +465,32 @@ $watchdogState = if (Test-Path -LiteralPath $watchdogStatePath) {
$null
}
$script:watchdogStatePath = $watchdogStatePath
$watchdogStartedAt = (Get-Date).ToString("o")
$watchdogState = @{
taskName = $watchdogTaskName
logPath = $logPath
reportPath = $statusPath
startedAt = $watchdogStartedAt
completedAt = $null
runStatus = "running"
reportStatus = $null
currentStep = "initializing"
currentDetail = "Starting watchdog checks."
stepUpdatedAt = $watchdogStartedAt
executionTimeLimitMinutes = 15
lastStatus = if ($previousWatchdogState -and (Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastStatus")) { [string](Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastStatus") } else { $null }
lastFingerprint = if ($previousWatchdogState -and (Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastFingerprint")) { [string](Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastFingerprint") } else { $null }
lastNotifiedAt = if ($previousWatchdogState -and (Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastNotifiedAt")) { [string](Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastNotifiedAt") } else { $null }
lastNotificationReason = if ($previousWatchdogState -and (Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastNotificationReason")) { [string](Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastNotificationReason") } else { $null }
lastCompletedAt = if ($previousWatchdogState -and (Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastCompletedAt")) { [string](Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastCompletedAt") } elseif ($previousWatchdogState -and (Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "checkedAt")) { [string](Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "checkedAt") } else { $null }
lastCompletedRunStatus = if ($previousWatchdogState -and (Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastCompletedRunStatus")) { [string](Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastCompletedRunStatus") } else { $null }
lastRunError = $null
}
Write-StatusFile -Path $watchdogStatePath -Status $watchdogState
try {
Set-WatchdogStep -CurrentStep "runtime_checks" -Detail "Inspecting runtime stack status and bot/worker liveness."
if (-not (Test-Path -LiteralPath $runtimeStatusPath)) {
Add-Check -Name "runtime.status_file" -Status "failed" -Detail "runtime-stack.json is missing at $runtimeStatusPath."
} else {
@ -479,6 +523,7 @@ try {
$historyCollection = if ($config.ContainsKey("QDRANT_HISTORY_COLLECTION")) { $config["QDRANT_HISTORY_COLLECTION"] } else { "voidbot_discord_history_chunks" }
$sourceCollection = if ($config.ContainsKey("QDRANT_SOURCE_COLLECTION")) { $config["QDRANT_SOURCE_COLLECTION"] } else { "voidbot_repository_source_chunks" }
Set-WatchdogStep -CurrentStep "qdrant_checks" -Detail "Verifying Qdrant reachability and required collections."
if ($vectorStoreKind -eq "qdrant") {
$qdrantUrl = if ($config.ContainsKey("QDRANT_URL")) { $config["QDRANT_URL"] } else { "http://127.0.0.1:6333" }
try {
@ -509,6 +554,7 @@ try {
$stateStorageBackend = if ($config.ContainsKey("STATE_STORAGE_BACKEND")) { $config["STATE_STORAGE_BACKEND"] } else { "postgres" }
Set-WatchdogStep -CurrentStep "postgres_checks" -Detail "Checking Postgres container health and required tables."
if ($stateStorageBackend -eq "postgres") {
$databaseDsn = if ($config.ContainsKey("DATABASE_DSN")) { $config["DATABASE_DSN"] } else { "" }
$dsn = Parse-DatabaseDsn -Dsn $databaseDsn
@ -566,6 +612,7 @@ try {
$ragOllamaUrl = if ($config.ContainsKey("RAG_OLLAMA_BASE_URL")) { $config["RAG_OLLAMA_BASE_URL"] } else { "http://127.0.0.1:11434" }
$ragOllamaModel = if ($config.ContainsKey("RAG_OLLAMA_MODEL")) { $config["RAG_OLLAMA_MODEL"] } else { "qwen3-embedding:0.6b" }
Set-WatchdogStep -CurrentStep "ollama_checks" -Detail "Checking embedding and local LLM Ollama endpoints."
try {
$ragTags = Get-Json -Url "$($ragOllamaUrl.TrimEnd('/'))/api/tags"
$ragModels = @($ragTags.models | ForEach-Object { [string]$_.name })
@ -628,6 +675,7 @@ try {
}
if ($config.ContainsKey("DISCORD_BOT_TOKEN") -and -not [string]::IsNullOrWhiteSpace($config["DISCORD_BOT_TOKEN"])) {
Set-WatchdogStep -CurrentStep "discord_checks" -Detail "Validating Discord bot credentials."
try {
$botIdentity = Get-Json -Url "https://discord.com/api/v10/users/@me" -Headers @("Authorization: Bot $($config["DISCORD_BOT_TOKEN"])")
Add-Check -Name "discord.bot_token" -Status "passed" -Detail "Discord bot token is valid for $([string]$botIdentity.username)." -Data @{
@ -645,6 +693,7 @@ try {
Sort-Object Name -Descending |
Select-Object -First 1
Set-WatchdogStep -CurrentStep "backup_checks" -Detail "Verifying the latest local backup and freshness thresholds."
if ($null -eq $latestBackup) {
Add-Check -Name "backup.latest" -Status "failed" -Detail "No local backup directory exists under $backupRoot."
} else {
@ -685,6 +734,7 @@ try {
}
}
Set-WatchdogStep -CurrentStep "scheduled_task_checks" -Detail "Inspecting scheduled task installation and status files."
try {
$offsiteTask = Get-ScheduledTask -TaskName $offsiteTaskName -ErrorAction Stop
$offsiteEnabled = [bool]$offsiteTask.Settings.Enabled
@ -732,6 +782,7 @@ try {
Add-Check -Name "offsite.status_file" -Status "failed" -Detail "offsite-backup.json is missing at $offsiteStatusPath."
}
Set-WatchdogStep -CurrentStep "offsite_remote_checks" -Detail "Checking remote offsite backup freshness and retention."
if ([string]::IsNullOrWhiteSpace($offsiteTarget)) {
Add-Check -Name "offsite.remote" -Status "warning" -Detail "Remote offsite backup check skipped because OFFSITE_BACKUP_SSH_TARGET is blank."
} else {
@ -807,6 +858,7 @@ $files = Get-ChildItem -LiteralPath $dir -Filter $pattern -File | Sort-Object La
}
}
Set-WatchdogStep -CurrentStep "notification_decision" -Detail "Computing report fingerprint and notification rules."
$preNotificationReport = Finalize-Report
$issueLines = @($preNotificationReport.checks | Where-Object { $_.status -ne "passed" } | ForEach-Object { "$($_.name): $($_.detail)" })
$fingerprintSource = if ($issueLines.Count -eq 0) { "healthy" } else { $issueLines -join "`n" }
@ -819,9 +871,9 @@ $files = Get-ChildItem -LiteralPath $dir -Filter $pattern -File | Sort-Object La
}
if ($NotifyOwner -and $config.ContainsKey("DISCORD_BOT_TOKEN") -and -not [string]::IsNullOrWhiteSpace($config["DISCORD_BOT_TOKEN"])) {
$lastFingerprint = if ($watchdogState -and $watchdogState.lastFingerprint) { [string]$watchdogState.lastFingerprint } else { "" }
$lastNotifiedAt = if ($watchdogState -and $watchdogState.lastNotifiedAt) { [DateTimeOffset]::Parse([string]$watchdogState.lastNotifiedAt) } else { $null }
$lastStatus = if ($watchdogState -and $watchdogState.lastStatus) { [string]$watchdogState.lastStatus } else { "" }
$lastFingerprint = if ($previousWatchdogState -and (Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastFingerprint")) { [string](Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastFingerprint") } else { "" }
$lastNotifiedAt = if ($previousWatchdogState -and (Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastNotifiedAt")) { [DateTimeOffset]::Parse([string](Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastNotifiedAt")) } else { $null }
$lastStatus = if ($previousWatchdogState -and (Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastStatus")) { [string](Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastStatus") } else { "" }
$shouldNotify = $false
$notificationReason = $null
@ -842,7 +894,8 @@ $files = Get-ChildItem -LiteralPath $dir -Filter $pattern -File | Sort-Object La
}
if ($shouldNotify) {
$notification.attempted = $true
Set-WatchdogStep -CurrentStep "notification_send" -Detail "Sending owner notification."
$notification.attempted = $true
try {
$message = Build-OwnerNotificationMessage -Status $preNotificationReport.status -IssueLines $issueLines -HealthStatusPath $statusPath
Send-OwnerDm -BotToken $config["DISCORD_BOT_TOKEN"] -OwnerDiscordId $config["DISCORD_OWNER_ID"] -Content $message
@ -857,16 +910,31 @@ $files = Get-ChildItem -LiteralPath $dir -Filter $pattern -File | Sort-Object La
}
}
Set-WatchdogStep -CurrentStep "finalizing" -Detail "Writing final health report."
$report = Finalize-Report
$report.notification = $notification
Write-StatusFile -Path $statusPath -Status $report
$watchdogStatePayload = @{
taskName = $watchdogTaskName
logPath = $logPath
reportPath = $statusPath
startedAt = $watchdogStartedAt
completedAt = (Get-Date).ToString("o")
checkedAt = $report.checkedAt
runStatus = "completed"
reportStatus = $report.status
currentStep = "finished"
currentDetail = "Watchdog run completed."
stepUpdatedAt = (Get-Date).ToString("o")
executionTimeLimitMinutes = 15
lastStatus = $report.status
lastFingerprint = $fingerprint
lastNotifiedAt = if ($notification.sent) { (Get-Date).ToString("o") } elseif ($watchdogState -and $watchdogState.lastNotifiedAt) { [string]$watchdogState.lastNotifiedAt } else { $null }
lastNotifiedAt = if ($notification.sent) { (Get-Date).ToString("o") } elseif ($previousWatchdogState -and (Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastNotifiedAt")) { [string](Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastNotifiedAt") } else { $null }
lastNotificationReason = $notification.reason
lastCompletedAt = (Get-Date).ToString("o")
lastCompletedRunStatus = "completed"
lastRunError = $null
}
Write-StatusFile -Path $watchdogStatePath -Status $watchdogStatePayload
@ -890,6 +958,28 @@ $files = Get-ChildItem -LiteralPath $dir -Filter $pattern -File | Sort-Object La
error = $_.Exception.Message
}
Write-StatusFile -Path $statusPath -Status $report
$watchdogFailureState = @{
taskName = $watchdogTaskName
logPath = $logPath
reportPath = $statusPath
startedAt = $watchdogStartedAt
completedAt = (Get-Date).ToString("o")
checkedAt = $report.checkedAt
runStatus = "failed"
reportStatus = $report.status
currentStep = "crashed"
currentDetail = "Watchdog crashed before finishing."
stepUpdatedAt = (Get-Date).ToString("o")
executionTimeLimitMinutes = 15
lastStatus = $report.status
lastFingerprint = if ($previousWatchdogState -and (Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastFingerprint")) { [string](Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastFingerprint") } else { $null }
lastNotifiedAt = if ($previousWatchdogState -and (Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastNotifiedAt")) { [string](Get-OptionalPropertyValue -InputObject $previousWatchdogState -PropertyName "lastNotifiedAt") } else { $null }
lastNotificationReason = "internal_error"
lastCompletedAt = (Get-Date).ToString("o")
lastCompletedRunStatus = "failed"
lastRunError = $_.Exception.Message
}
Write-StatusFile -Path $watchdogStatePath -Status $watchdogFailureState
Write-Host "VoidBot operations status: failed"
Write-Host "[FAILED] watchdog.internal: Operations watchdog crashed: $($_.Exception.Message)"
exit 1

View file

@ -0,0 +1,71 @@
param(
[switch] $Open
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$envFile = Join-Path $repoRoot ".env"
. (Join-Path $PSScriptRoot "voidbot-operations-dashboard-lib.ps1")
function Read-DotEnv {
param(
[Parameter(Mandatory = $true)]
[string] $Path
)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Missing .env at $Path"
}
$values = @{}
foreach ($line in Get-Content -LiteralPath $Path) {
if ([string]::IsNullOrWhiteSpace($line)) {
continue
}
if ($line.TrimStart().StartsWith("#")) {
continue
}
$separatorIndex = $line.IndexOf("=")
if ($separatorIndex -lt 1) {
continue
}
$key = $line.Substring(0, $separatorIndex).Trim()
$value = $line.Substring($separatorIndex + 1)
$values[$key] = $value
}
return $values
}
function Resolve-ConfigPath {
param(
[Parameter(Mandatory = $true)]
[string] $RepoRoot,
[Parameter(Mandatory = $false)]
[AllowEmptyString()]
[string] $Value,
[Parameter(Mandatory = $true)]
[string] $Fallback
)
$raw = if ([string]::IsNullOrWhiteSpace($Value)) { $Fallback } else { $Value }
return [System.IO.Path]::GetFullPath((Join-Path $RepoRoot $raw))
}
$config = Read-DotEnv -Path $envFile
$storageRoot = Resolve-ConfigPath -RepoRoot $repoRoot -Value $config["STORAGE_ROOT"] -Fallback ".voidbot"
$result = Update-VoidBotOperationsDashboard -RepoRoot $repoRoot -StorageRoot $storageRoot
Write-Host "Dashboard HTML: $($result.DashboardPath)"
Write-Host "Dashboard snapshot: $($result.SnapshotPath)"
if ($Open) {
Invoke-Item -LiteralPath $result.DashboardPath
}

View file

@ -3,6 +3,7 @@ $ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$envFile = Join-Path $repoRoot ".env"
. (Join-Path $PSScriptRoot "voidbot-operations-dashboard-lib.ps1")
function Read-DotEnv {
param(
@ -65,6 +66,10 @@ function Write-StatusFile {
$directory = Split-Path -Parent $Path
New-Item -ItemType Directory -Force -Path $directory | Out-Null
$Status | ConvertTo-Json -Depth 8 | Set-Content -Path $Path -Encoding utf8
if ($script:storageRoot) {
[void](Update-VoidBotOperationsDashboard -RepoRoot $repoRoot -StorageRoot $script:storageRoot)
}
}
function Invoke-JsonGet {
@ -457,6 +462,7 @@ $status = @{
startedAt = (Get-Date).ToString("o")
repoRoot = $repoRoot
statusPath = $statusPath
stage = "bootstrapping"
}
Write-StatusFile -Path $statusPath -Status $status
@ -473,6 +479,8 @@ $localLlmBaseUrl = if ($config.ContainsKey("LOCAL_LLM_OLLAMA_BASE_URL")) { $conf
$localLlmModel = if ($config.ContainsKey("LOCAL_LLM_OLLAMA_MODEL")) { $config["LOCAL_LLM_OLLAMA_MODEL"] } else { "qwen3.5:9b" }
if ($stateStorageBackend -eq "postgres") {
$status.stage = "postgres"
Write-StatusFile -Path $statusPath -Status $status
$status.postgres = Ensure-Postgres -RepoRoot $repoRoot -DatabaseDsn $databaseDsn
Write-StatusFile -Path $statusPath -Status $status
} else {
@ -483,6 +491,8 @@ if ($stateStorageBackend -eq "postgres") {
}
if ($vectorStoreKind -eq "qdrant") {
$status.stage = "qdrant"
Write-StatusFile -Path $statusPath -Status $status
$status.qdrant = Ensure-Qdrant -RepoRoot $repoRoot -QdrantUrl $qdrantUrl
Write-StatusFile -Path $statusPath -Status $status
} else {
@ -493,11 +503,15 @@ if ($vectorStoreKind -eq "qdrant") {
}
if ($ragBackend -eq "ollama") {
$status.stage = "rag_ollama"
Write-StatusFile -Path $statusPath -Status $status
$status.ragOllama = Ensure-OllamaEndpoint -BaseUrl $ragOllamaBaseUrl -Model $ragOllamaModel -Label "Local embedding Ollama" -LogDir $logDir
Write-StatusFile -Path $statusPath -Status $status
}
if ($localLlmEnabled) {
$status.stage = "local_llm"
Write-StatusFile -Path $statusPath -Status $status
$status.localLlmOllama = Ensure-OllamaEndpoint -BaseUrl $localLlmBaseUrl -Model $localLlmModel -Label "Local LLM Ollama" -LogDir $logDir
Write-StatusFile -Path $statusPath -Status $status
}
@ -505,6 +519,9 @@ if ($localLlmEnabled) {
$npmCommand = Get-Command npm.cmd -ErrorAction Stop
$nodeCommand = Get-Command node.exe -ErrorAction Stop
$status.stage = "build"
Write-StatusFile -Path $statusPath -Status $status
Write-Host "Building VoidBot..."
& $npmCommand.Source run build
@ -519,10 +536,18 @@ $botErr = Join-Path $logDir "bot.err.log"
$workerOut = Join-Path $logDir "worker.log"
$workerErr = Join-Path $logDir "worker.err.log"
$status.stage = "bot_start"
Write-StatusFile -Path $statusPath -Status $status
$botProcess = Start-Process -FilePath $nodeCommand.Source -ArgumentList (Join-Path $repoRoot "apps\bot\dist\index.js") -WorkingDirectory $repoRoot -RedirectStandardOutput $botOut -RedirectStandardError $botErr -WindowStyle Hidden -PassThru
$workerProcess = Start-Process -FilePath $nodeCommand.Source -ArgumentList (Join-Path $repoRoot "apps\worker\dist\index.js") -WorkingDirectory $repoRoot -RedirectStandardOutput $workerOut -RedirectStandardError $workerErr -WindowStyle Hidden -PassThru
Wait-ForProcessReady -Process $botProcess -Name "VoidBot bot" -LogPath $botOut -ErrPath $botErr -ReadyText "VoidBot connected as"
$status.stage = "worker_start"
Write-StatusFile -Path $statusPath -Status $status
$workerProcess = Start-Process -FilePath $nodeCommand.Source -ArgumentList (Join-Path $repoRoot "apps\worker\dist\index.js") -WorkingDirectory $repoRoot -RedirectStandardOutput $workerOut -RedirectStandardError $workerErr -WindowStyle Hidden -PassThru
Wait-ForProcessReady -Process $workerProcess -Name "VoidBot worker" -LogPath $workerOut -ErrPath $workerErr -ReadyText "VoidBot worker polling"
$status.bot = @{
@ -537,6 +562,7 @@ $status.worker = @{
errLog = $workerErr
}
$status.stage = "ready"
$status.completedAt = (Get-Date).ToString("o")
$status.ready = $true
Write-StatusFile -Path $statusPath -Status $status

View file

@ -15,6 +15,7 @@ $repoRoot = Split-Path -Parent $PSScriptRoot
$envFile = Join-Path $repoRoot ".env"
$backupScript = Join-Path $PSScriptRoot "backup-voidbot-state.ps1"
$verifyScript = Join-Path $PSScriptRoot "verify-voidbot-backup.ps1"
. (Join-Path $PSScriptRoot "voidbot-operations-dashboard-lib.ps1")
function Read-DotEnv {
param(
@ -77,6 +78,10 @@ function Write-StatusFile {
$directory = Split-Path -Parent $Path
New-Item -ItemType Directory -Force -Path $directory | Out-Null
$Status | ConvertTo-Json -Depth 8 | Set-Content -Path $Path -Encoding utf8
if ($script:storageRoot) {
[void](Update-VoidBotOperationsDashboard -RepoRoot $repoRoot -StorageRoot $script:storageRoot)
}
}
function Start-LogCapture {
@ -385,6 +390,8 @@ $status = @{
startedAt = (Get-Date).ToString("o")
repoRoot = $repoRoot
status = "starting"
statusPath = $statusPath
logPath = $logPath
sshTarget = $sshTargetValue
remoteWindowsDir = $remoteWindowsDirValue
remoteSftpDir = $remoteSftpDirValue

View file

@ -0,0 +1,725 @@
function Get-VoidBotDashboardProperty {
param(
[Parameter(Mandatory = $false)]
[AllowNull()]
$InputObject,
[Parameter(Mandatory = $true)]
[string] $PropertyName
)
if ($null -eq $InputObject) {
return $null
}
$property = $InputObject.PSObject.Properties[$PropertyName]
if ($null -eq $property) {
return $null
}
return $property.Value
}
function Read-VoidBotDashboardJson {
param(
[Parameter(Mandatory = $true)]
[string] $Path
)
if (-not (Test-Path -LiteralPath $Path)) {
return [PSCustomObject]@{
_path = $Path
_missing = $true
}
}
try {
$content = Get-Content -LiteralPath $Path -Raw
if ([string]::IsNullOrWhiteSpace($content)) {
return [PSCustomObject]@{
_path = $Path
_missing = $true
}
}
$parsed = $content | ConvertFrom-Json
Add-Member -InputObject $parsed -NotePropertyName "_path" -NotePropertyValue $Path -Force
return $parsed
} catch {
return [PSCustomObject]@{
_path = $Path
_error = $_.Exception.Message
}
}
}
function ConvertTo-VoidBotDashboardText {
param(
[Parameter(Mandatory = $false)]
[AllowNull()]
$Value
)
if ($null -eq $Value) {
return "missing"
}
if ($Value -is [bool]) {
return $(if ($Value) { "yes" } else { "no" })
}
if ($Value -is [DateTime]) {
return $Value.ToString("yyyy-MM-dd HH:mm:ss")
}
if ($Value -is [DateTimeOffset]) {
return $Value.ToString("yyyy-MM-dd HH:mm:ss zzz")
}
if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) {
$items = @($Value)
if ($items.Count -eq 0) {
return "none"
}
if ($items | Where-Object { $_ -is [string] -or $_ -is [ValueType] }) {
return ($items | ForEach-Object { [string]$_ }) -join ", "
}
return ($Value | ConvertTo-Json -Depth 6 -Compress)
}
return [string]$Value
}
function Escape-VoidBotDashboardHtml {
param(
[Parameter(Mandatory = $false)]
[AllowNull()]
$Value
)
return [System.Net.WebUtility]::HtmlEncode((ConvertTo-VoidBotDashboardText -Value $Value))
}
function ConvertTo-VoidBotDashboardFileUri {
param(
[Parameter(Mandatory = $true)]
[string] $Path
)
return ([System.Uri]::new($Path)).AbsoluteUri
}
function New-VoidBotDashboardFileLink {
param(
[Parameter(Mandatory = $true)]
[string] $Path,
[Parameter(Mandatory = $false)]
[string] $Label
)
$text = if ([string]::IsNullOrWhiteSpace($Label)) { $Path } else { $Label }
return "<a class='file-link' href='" + (Escape-VoidBotDashboardHtml (ConvertTo-VoidBotDashboardFileUri -Path $Path)) + "'>" + (Escape-VoidBotDashboardHtml $text) + "</a>"
}
function Get-VoidBotDashboardTone {
param(
[Parameter(Mandatory = $false)]
[AllowNull()]
[string] $State
)
if ([string]::IsNullOrWhiteSpace($State)) {
return "neutral"
}
switch ($State.ToLowerInvariant()) {
"healthy" { return "good" }
"passed" { return "good" }
"completed" { return "good" }
"ready" { return "good" }
"enabled" { return "good" }
"ok" { return "good" }
"warning" { return "warn" }
"running" { return "info" }
"starting" { return "info" }
"compressing" { return "info" }
"uploading" { return "info" }
"pruning" { return "info" }
"bootstrapping" { return "info" }
"build" { return "info" }
"bot_start" { return "info" }
"worker_start" { return "info" }
"already_running" { return "warn" }
"failed" { return "bad" }
"error" { return "bad" }
"disabled" { return "bad" }
"stalled" { return "bad" }
"missing" { return "bad" }
default { return "neutral" }
}
}
function New-VoidBotDashboardBadge {
param(
[Parameter(Mandatory = $false)]
[AllowNull()]
[string] $State,
[Parameter(Mandatory = $false)]
[string] $Label
)
$text = if ([string]::IsNullOrWhiteSpace($Label)) {
if ([string]::IsNullOrWhiteSpace($State)) { "unknown" } else { $State }
} else {
$Label
}
$tone = Get-VoidBotDashboardTone -State $State
return "<span class='badge badge-" + $tone + "'>" + (Escape-VoidBotDashboardHtml $text) + "</span>"
}
function Get-VoidBotDashboardRelativeTime {
param(
[Parameter(Mandatory = $false)]
[AllowNull()]
[string] $Timestamp
)
if ([string]::IsNullOrWhiteSpace($Timestamp)) {
return "unknown"
}
try {
$target = [DateTimeOffset]::Parse($Timestamp)
$delta = [DateTimeOffset]::Now - $target
if ($delta.TotalSeconds -lt 60) {
return "{0}s ago" -f [Math]::Max([int][Math]::Round($delta.TotalSeconds), 0)
}
if ($delta.TotalMinutes -lt 60) {
return "{0}m ago" -f [int][Math]::Round($delta.TotalMinutes)
}
if ($delta.TotalHours -lt 48) {
return "{0}h ago" -f [int][Math]::Round($delta.TotalHours)
}
return "{0}d ago" -f [int][Math]::Round($delta.TotalDays)
} catch {
return "unknown"
}
}
function Get-VoidBotDashboardElapsedTime {
param(
[Parameter(Mandatory = $false)]
[AllowNull()]
[string] $StartedAt
)
if ([string]::IsNullOrWhiteSpace($StartedAt)) {
return "unknown"
}
try {
$delta = [DateTimeOffset]::Now - [DateTimeOffset]::Parse($StartedAt)
if ($delta.TotalMinutes -lt 1) {
return "{0}s" -f [Math]::Max([int][Math]::Round($delta.TotalSeconds), 0)
}
if ($delta.TotalHours -lt 1) {
return "{0}m" -f [int][Math]::Round($delta.TotalMinutes)
}
return "{0}h {1}m" -f [int][Math]::Floor($delta.TotalHours), $delta.Minutes
} catch {
return "unknown"
}
}
function New-VoidBotDashboardFactRow {
param(
[Parameter(Mandatory = $true)]
[string] $Label,
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string] $ValueHtml
)
return "<tr><th>" + (Escape-VoidBotDashboardHtml $Label) + "</th><td>" + $ValueHtml + "</td></tr>"
}
function New-VoidBotDashboardFactSection {
param(
[Parameter(Mandatory = $true)]
[string] $Title,
[Parameter(Mandatory = $true)]
[string[]] $Rows
)
$body = if ($Rows.Count -gt 0) {
($Rows -join [Environment]::NewLine)
} else {
"<tr><td colspan='2'><span class='muted'>Nothing here yet.</span></td></tr>"
}
return @"
<section class='panel'>
<h2>$(Escape-VoidBotDashboardHtml $Title)</h2>
<table class='facts'>
<tbody>
$body
</tbody>
</table>
</section>
"@
}
function New-VoidBotDashboardCard {
param(
[Parameter(Mandatory = $true)]
[string] $Title,
[Parameter(Mandatory = $false)]
[AllowNull()]
[string] $State,
[Parameter(Mandatory = $true)]
[string] $Subtitle,
[Parameter(Mandatory = $false)]
[string] $Meta = ""
)
$metaHtml = if ([string]::IsNullOrWhiteSpace($Meta)) {
""
} else {
"<div class='card-meta'>" + (Escape-VoidBotDashboardHtml $Meta) + "</div>"
}
return @"
<article class='card'>
<div class='card-head'>
<h2>$(Escape-VoidBotDashboardHtml $Title)</h2>
$(New-VoidBotDashboardBadge -State $State)
</div>
<p class='card-subtitle'>$(Escape-VoidBotDashboardHtml $Subtitle)</p>
$metaHtml
</article>
"@
}
function Update-VoidBotOperationsDashboard {
param(
[Parameter(Mandatory = $true)]
[string] $RepoRoot,
[Parameter(Mandatory = $true)]
[string] $StorageRoot
)
$statusDir = Join-Path $StorageRoot "status"
$logsDir = Join-Path $StorageRoot "logs"
$dashboardPath = Join-Path $statusDir "operations-dashboard.html"
$snapshotPath = Join-Path $statusDir "operations-dashboard.json"
New-Item -ItemType Directory -Force -Path $statusDir | Out-Null
$runtimePath = Join-Path $statusDir "runtime-stack.json"
$operationsPath = Join-Path $statusDir "operations-health.json"
$watchdogPath = Join-Path $statusDir "operations-watchdog.json"
$offsitePath = Join-Path $statusDir "offsite-backup.json"
$runtime = Read-VoidBotDashboardJson -Path $runtimePath
$operations = Read-VoidBotDashboardJson -Path $operationsPath
$watchdog = Read-VoidBotDashboardJson -Path $watchdogPath
$offsite = Read-VoidBotDashboardJson -Path $offsitePath
$operationsState = [string](Get-VoidBotDashboardProperty -InputObject $operations -PropertyName "status")
if ([string]::IsNullOrWhiteSpace($operationsState)) {
$operationsState = if (Get-VoidBotDashboardProperty -InputObject $operations -PropertyName "_missing") { "missing" } else { "unknown" }
}
$runtimeState = if (Get-VoidBotDashboardProperty -InputObject $runtime -PropertyName "_missing") {
"missing"
} elseif ([bool](Get-VoidBotDashboardProperty -InputObject $runtime -PropertyName "ready")) {
"ready"
} else {
$stageValue = [string](Get-VoidBotDashboardProperty -InputObject $runtime -PropertyName "stage")
if ([string]::IsNullOrWhiteSpace($stageValue)) { "starting" } else { $stageValue }
}
$watchdogRunStatus = [string](Get-VoidBotDashboardProperty -InputObject $watchdog -PropertyName "runStatus")
$watchdogStartedAt = [string](Get-VoidBotDashboardProperty -InputObject $watchdog -PropertyName "startedAt")
$watchdogStep = [string](Get-VoidBotDashboardProperty -InputObject $watchdog -PropertyName "currentStep")
$watchdogLimitMinutes = Get-VoidBotDashboardProperty -InputObject $watchdog -PropertyName "executionTimeLimitMinutes"
$watchdogReportStatus = [string](Get-VoidBotDashboardProperty -InputObject $watchdog -PropertyName "reportStatus")
$watchdogState = if ([string]::IsNullOrWhiteSpace($watchdogRunStatus)) {
if (Get-VoidBotDashboardProperty -InputObject $watchdog -PropertyName "_missing") { "missing" } else { $operationsState }
} elseif ($watchdogRunStatus -eq "running" -and -not [string]::IsNullOrWhiteSpace($watchdogStartedAt)) {
try {
$elapsedMinutes = ([DateTimeOffset]::Now - [DateTimeOffset]::Parse($watchdogStartedAt)).TotalMinutes
$limitValue = if ($watchdogLimitMinutes -is [ValueType]) { [double]$watchdogLimitMinutes } else { 15.0 }
if ($elapsedMinutes -gt $limitValue) { "stalled" } else { "running" }
} catch {
"running"
}
} elseif ($watchdogRunStatus -eq "completed" -and -not [string]::IsNullOrWhiteSpace($watchdogReportStatus)) {
$watchdogReportStatus
} else {
$watchdogRunStatus
}
$offsiteState = [string](Get-VoidBotDashboardProperty -InputObject $offsite -PropertyName "status")
if ([string]::IsNullOrWhiteSpace($offsiteState)) {
$offsiteState = if (Get-VoidBotDashboardProperty -InputObject $offsite -PropertyName "_missing") { "missing" } else { "unknown" }
}
$operationsSummary = Get-VoidBotDashboardProperty -InputObject $operations -PropertyName "summary"
$operationsSubtitle = if ($null -ne $operationsSummary) {
"{0} failed, {1} warning, {2} passed" -f `
(Get-VoidBotDashboardProperty -InputObject $operationsSummary -PropertyName "failed"), `
(Get-VoidBotDashboardProperty -InputObject $operationsSummary -PropertyName "warning"), `
(Get-VoidBotDashboardProperty -InputObject $operationsSummary -PropertyName "passed")
} else {
"No health report yet."
}
$watchdogSubtitle = if ($watchdogRunStatus -eq "running") {
"Current step: {0}" -f $(if ([string]::IsNullOrWhiteSpace($watchdogStep)) { "working" } else { $watchdogStep })
} elseif ($watchdogState -eq "stalled") {
"Still marked running after {0}" -f (Get-VoidBotDashboardElapsedTime -StartedAt $watchdogStartedAt)
} elseif ($watchdogRunStatus -eq "failed") {
"Watchdog run crashed before finishing."
} elseif ($watchdogState -eq "healthy") {
"Watchdog run completed cleanly."
} elseif ($watchdogState -eq "warning") {
"Watchdog run finished with warnings."
} elseif ($watchdogState -eq "failed") {
"Watchdog run finished with failed checks."
} else {
"No active watchdog run."
}
$runtimeSubtitle = if ($runtimeState -eq "ready") {
"Bot and worker reported ready."
} elseif ($runtimeState -eq "missing") {
"Runtime stack has not written status yet."
} else {
"Current stage: {0}" -f $runtimeState
}
$offsiteSubtitle = if ($offsiteState -eq "completed") {
"Latest offsite sync finished."
} elseif ($offsiteState -eq "missing") {
"No offsite sync status yet."
} else {
"Current phase: {0}" -f $offsiteState
}
$cardsHtml = @(
New-VoidBotDashboardCard -Title "Operations Health" -State $operationsState -Subtitle $operationsSubtitle -Meta ("Checked " + (Get-VoidBotDashboardRelativeTime -Timestamp ([string](Get-VoidBotDashboardProperty -InputObject $operations -PropertyName "checkedAt"))))
New-VoidBotDashboardCard -Title "Watchdog Run" -State $watchdogState -Subtitle $watchdogSubtitle -Meta ("Started " + (Get-VoidBotDashboardRelativeTime -Timestamp $watchdogStartedAt))
New-VoidBotDashboardCard -Title "Runtime Stack" -State $runtimeState -Subtitle $runtimeSubtitle -Meta ("Updated " + (Get-VoidBotDashboardRelativeTime -Timestamp ([string](Get-VoidBotDashboardProperty -InputObject $runtime -PropertyName "completedAt"))))
New-VoidBotDashboardCard -Title "Offsite Backup" -State $offsiteState -Subtitle $offsiteSubtitle -Meta ("Updated " + (Get-VoidBotDashboardRelativeTime -Timestamp ([string](Get-VoidBotDashboardProperty -InputObject $offsite -PropertyName "completedAt"))))
) -join [Environment]::NewLine
$runtimeRows = @(
New-VoidBotDashboardFactRow -Label "Status file" -ValueHtml (New-VoidBotDashboardFileLink -Path $runtimePath)
New-VoidBotDashboardFactRow -Label "Ready" -ValueHtml (New-VoidBotDashboardBadge -State $runtimeState -Label ([string](Get-VoidBotDashboardProperty -InputObject $runtime -PropertyName "ready")))
New-VoidBotDashboardFactRow -Label "Stage" -ValueHtml ("<span class='mono'>" + (Escape-VoidBotDashboardHtml (Get-VoidBotDashboardProperty -InputObject $runtime -PropertyName "stage")) + "</span>")
New-VoidBotDashboardFactRow -Label "Bot PID" -ValueHtml ("<span class='mono'>" + (Escape-VoidBotDashboardHtml (Get-VoidBotDashboardProperty -InputObject (Get-VoidBotDashboardProperty -InputObject $runtime -PropertyName "bot") -PropertyName "pid")) + "</span>")
New-VoidBotDashboardFactRow -Label "Bot log" -ValueHtml $(if (Get-VoidBotDashboardProperty -InputObject (Get-VoidBotDashboardProperty -InputObject $runtime -PropertyName "bot") -PropertyName "log") { New-VoidBotDashboardFileLink -Path ([string](Get-VoidBotDashboardProperty -InputObject (Get-VoidBotDashboardProperty -InputObject $runtime -PropertyName "bot") -PropertyName "log")) } else { "<span class='muted'>missing</span>" })
New-VoidBotDashboardFactRow -Label "Worker PID" -ValueHtml ("<span class='mono'>" + (Escape-VoidBotDashboardHtml (Get-VoidBotDashboardProperty -InputObject (Get-VoidBotDashboardProperty -InputObject $runtime -PropertyName "worker") -PropertyName "pid")) + "</span>")
New-VoidBotDashboardFactRow -Label "Worker log" -ValueHtml $(if (Get-VoidBotDashboardProperty -InputObject (Get-VoidBotDashboardProperty -InputObject $runtime -PropertyName "worker") -PropertyName "log") { New-VoidBotDashboardFileLink -Path ([string](Get-VoidBotDashboardProperty -InputObject (Get-VoidBotDashboardProperty -InputObject $runtime -PropertyName "worker") -PropertyName "log")) } else { "<span class='muted'>missing</span>" })
New-VoidBotDashboardFactRow -Label "Completed" -ValueHtml (Escape-VoidBotDashboardHtml (Get-VoidBotDashboardProperty -InputObject $runtime -PropertyName "completedAt"))
)
$watchdogRows = @(
New-VoidBotDashboardFactRow -Label "Status file" -ValueHtml (New-VoidBotDashboardFileLink -Path $watchdogPath)
New-VoidBotDashboardFactRow -Label "Run status" -ValueHtml (New-VoidBotDashboardBadge -State $watchdogState -Label $watchdogState)
New-VoidBotDashboardFactRow -Label "Current step" -ValueHtml ("<span class='mono'>" + (Escape-VoidBotDashboardHtml $watchdogStep) + "</span>")
New-VoidBotDashboardFactRow -Label "Started" -ValueHtml (Escape-VoidBotDashboardHtml $watchdogStartedAt)
New-VoidBotDashboardFactRow -Label "Elapsed" -ValueHtml (Escape-VoidBotDashboardHtml (Get-VoidBotDashboardElapsedTime -StartedAt $watchdogStartedAt))
New-VoidBotDashboardFactRow -Label "Completed" -ValueHtml (Escape-VoidBotDashboardHtml (Get-VoidBotDashboardProperty -InputObject $watchdog -PropertyName "completedAt"))
New-VoidBotDashboardFactRow -Label "Last report status" -ValueHtml (New-VoidBotDashboardBadge -State ([string](Get-VoidBotDashboardProperty -InputObject $watchdog -PropertyName "reportStatus")) -Label ([string](Get-VoidBotDashboardProperty -InputObject $watchdog -PropertyName "reportStatus")))
New-VoidBotDashboardFactRow -Label "Notification" -ValueHtml (Escape-VoidBotDashboardHtml (Get-VoidBotDashboardProperty -InputObject $watchdog -PropertyName "lastNotificationReason"))
New-VoidBotDashboardFactRow -Label "Last run error" -ValueHtml (Escape-VoidBotDashboardHtml (Get-VoidBotDashboardProperty -InputObject $watchdog -PropertyName "lastRunError"))
New-VoidBotDashboardFactRow -Label "Watchdog log" -ValueHtml $(if (Get-VoidBotDashboardProperty -InputObject $watchdog -PropertyName "logPath") { New-VoidBotDashboardFileLink -Path ([string](Get-VoidBotDashboardProperty -InputObject $watchdog -PropertyName "logPath")) } else { "<span class='muted'>missing</span>" })
)
$offsiteRows = @(
New-VoidBotDashboardFactRow -Label "Status file" -ValueHtml (New-VoidBotDashboardFileLink -Path $offsitePath)
New-VoidBotDashboardFactRow -Label "Status" -ValueHtml (New-VoidBotDashboardBadge -State $offsiteState -Label $offsiteState)
New-VoidBotDashboardFactRow -Label "Started" -ValueHtml (Escape-VoidBotDashboardHtml (Get-VoidBotDashboardProperty -InputObject $offsite -PropertyName "startedAt"))
New-VoidBotDashboardFactRow -Label "Completed" -ValueHtml (Escape-VoidBotDashboardHtml (Get-VoidBotDashboardProperty -InputObject $offsite -PropertyName "completedAt"))
New-VoidBotDashboardFactRow -Label "Backup directory" -ValueHtml (Escape-VoidBotDashboardHtml (Get-VoidBotDashboardProperty -InputObject $offsite -PropertyName "backupDirectory"))
New-VoidBotDashboardFactRow -Label "Archive path" -ValueHtml (Escape-VoidBotDashboardHtml (Get-VoidBotDashboardProperty -InputObject $offsite -PropertyName "archivePath"))
New-VoidBotDashboardFactRow -Label "Remote file" -ValueHtml (Escape-VoidBotDashboardHtml (Get-VoidBotDashboardProperty -InputObject (Get-VoidBotDashboardProperty -InputObject $offsite -PropertyName "remoteFile") -PropertyName "path"))
New-VoidBotDashboardFactRow -Label "Error" -ValueHtml (Escape-VoidBotDashboardHtml (Get-VoidBotDashboardProperty -InputObject $offsite -PropertyName "error"))
New-VoidBotDashboardFactRow -Label "Offsite log" -ValueHtml $(if (Get-VoidBotDashboardProperty -InputObject $offsite -PropertyName "logPath") { New-VoidBotDashboardFileLink -Path ([string](Get-VoidBotDashboardProperty -InputObject $offsite -PropertyName "logPath")) } else { New-VoidBotDashboardFileLink -Path (Join-Path $logsDir "offsite-backup.log") })
)
$fileRows = @(
New-VoidBotDashboardFactRow -Label "Dashboard HTML" -ValueHtml (New-VoidBotDashboardFileLink -Path $dashboardPath)
New-VoidBotDashboardFactRow -Label "Dashboard snapshot" -ValueHtml (New-VoidBotDashboardFileLink -Path $snapshotPath)
New-VoidBotDashboardFactRow -Label "Operations report" -ValueHtml (New-VoidBotDashboardFileLink -Path $operationsPath)
New-VoidBotDashboardFactRow -Label "Operations log" -ValueHtml (New-VoidBotDashboardFileLink -Path (Join-Path $logsDir "operations-watchdog.log"))
New-VoidBotDashboardFactRow -Label "Repo root" -ValueHtml ("<span class='mono'>" + (Escape-VoidBotDashboardHtml $RepoRoot) + "</span>")
New-VoidBotDashboardFactRow -Label "Storage root" -ValueHtml ("<span class='mono'>" + (Escape-VoidBotDashboardHtml $StorageRoot) + "</span>")
)
$checkRows = @()
foreach ($check in @((Get-VoidBotDashboardProperty -InputObject $operations -PropertyName "checks"))) {
$checkRows += "<tr><td>" + (New-VoidBotDashboardBadge -State ([string](Get-VoidBotDashboardProperty -InputObject $check -PropertyName "status")) -Label ([string](Get-VoidBotDashboardProperty -InputObject $check -PropertyName "status"))) + "</td><td class='mono'>" + (Escape-VoidBotDashboardHtml (Get-VoidBotDashboardProperty -InputObject $check -PropertyName "name")) + "</td><td>" + (Escape-VoidBotDashboardHtml (Get-VoidBotDashboardProperty -InputObject $check -PropertyName "detail")) + "</td></tr>"
}
if ($checkRows.Count -eq 0) {
$checkRows = @("<tr><td colspan='3'><span class='muted'>No watchdog checks have been written yet.</span></td></tr>")
}
$checksSection = @"
<section class='panel'>
<h2>Watchdog Checks</h2>
<table class='checks'>
<thead>
<tr><th>Status</th><th>Check</th><th>Detail</th></tr>
</thead>
<tbody>
$($checkRows -join [Environment]::NewLine)
</tbody>
</table>
</section>
"@
$snapshot = [ordered]@{
generatedAt = (Get-Date).ToString("o")
dashboardPath = $dashboardPath
repoRoot = $RepoRoot
storageRoot = $StorageRoot
states = [ordered]@{
operations = $operationsState
runtime = $runtimeState
watchdog = $watchdogState
offsite = $offsiteState
}
files = [ordered]@{
runtime = $runtimePath
operations = $operationsPath
watchdog = $watchdogPath
offsite = $offsitePath
operationsLog = Join-Path $logsDir "operations-watchdog.log"
offsiteLog = Join-Path $logsDir "offsite-backup.log"
}
}
$snapshot | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $snapshotPath -Encoding utf8
$html = @"
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<meta http-equiv='refresh' content='10'>
<title>VoidBot Operations Dashboard</title>
<style>
:root {
color-scheme: light;
--bg: #f4efe7;
--paper: rgba(255,255,255,0.88);
--ink: #1f2430;
--muted: #5b6472;
--line: rgba(31,36,48,0.12);
--good: #1f7a4a;
--warn: #8f6200;
--bad: #a6342b;
--info: #1d5e8a;
--neutral: #596574;
--shadow: 0 18px 50px rgba(31,36,48,0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 32px;
background:
radial-gradient(circle at top left, rgba(237, 190, 123, 0.28), transparent 34%),
radial-gradient(circle at top right, rgba(130, 176, 210, 0.22), transparent 28%),
linear-gradient(180deg, #f8f4ee 0%, #efe7dc 100%);
color: var(--ink);
font: 15px/1.5 "Aptos", "Segoe UI Variable", "Trebuchet MS", sans-serif;
}
h1, h2, h3, p { margin: 0; }
a { color: inherit; }
.shell {
max-width: 1400px;
margin: 0 auto;
display: grid;
gap: 20px;
}
.hero, .panel, .card {
background: var(--paper);
border: 1px solid var(--line);
border-radius: 20px;
box-shadow: var(--shadow);
backdrop-filter: blur(8px);
}
.hero {
padding: 28px;
display: grid;
gap: 10px;
}
.eyebrow {
font-size: 12px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
}
.hero p {
color: var(--muted);
max-width: 80ch;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.card {
padding: 18px;
display: grid;
gap: 12px;
}
.card-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.card-head h2 {
font-size: 16px;
}
.card-subtitle, .card-meta, .muted {
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.panel {
padding: 20px;
display: grid;
gap: 14px;
}
.panel h2 {
font-size: 18px;
}
.facts, .checks {
width: 100%;
border-collapse: collapse;
}
.facts th,
.facts td,
.checks th,
.checks td {
padding: 10px 12px;
border-top: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.facts th,
.checks th {
color: var(--muted);
font-weight: 600;
width: 180px;
}
.checks thead th {
border-top: none;
}
.mono {
font-family: "Consolas", "Cascadia Mono", monospace;
font-size: 13px;
word-break: break-word;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: white;
white-space: nowrap;
}
.badge-good { background: var(--good); }
.badge-warn { background: var(--warn); }
.badge-bad { background: var(--bad); }
.badge-info { background: var(--info); }
.badge-neutral { background: var(--neutral); }
.file-link {
text-decoration: none;
border-bottom: 1px dashed rgba(29, 94, 138, 0.4);
}
@media (max-width: 720px) {
body { padding: 16px; }
.hero, .panel, .card { border-radius: 16px; }
.facts th, .checks th { width: 120px; }
}
</style>
</head>
<body>
<main class='shell'>
<section class='hero'>
<div class='eyebrow'>VoidBot Operations Dashboard</div>
<h1>Status files, but with fewer lies.</h1>
<p>This page is regenerated from the live JSON status files under <span class='mono'>$(Escape-VoidBotDashboardHtml $statusDir)</span> and refreshes itself every 10 seconds. If a hidden task hangs, it should stay frozen here with the last step it managed to confess.</p>
<div class='muted'>Generated $(Escape-VoidBotDashboardHtml (Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"))</div>
</section>
<section class='cards'>
$cardsHtml
</section>
<section class='grid'>
$(New-VoidBotDashboardFactSection -Title "Runtime Stack" -Rows $runtimeRows)
$(New-VoidBotDashboardFactSection -Title "Watchdog Run" -Rows $watchdogRows)
$(New-VoidBotDashboardFactSection -Title "Offsite Backup" -Rows $offsiteRows)
$(New-VoidBotDashboardFactSection -Title "Useful Files" -Rows $fileRows)
</section>
$checksSection
</main>
</body>
</html>
"@
Set-Content -LiteralPath $dashboardPath -Value $html -Encoding utf8
return [PSCustomObject]@{
DashboardPath = $dashboardPath
SnapshotPath = $snapshotPath
}
}