Files
usmt-dash/USMT-Worker.ps1
T
Johan Sandoval 1c531bc2ab Generalize USMT Dash and add a containerized demo mode
Remove all environment-specific data and make the dashboard adoptable
anywhere, then add a demo that runs the real PowerShell services.

- Strip cached device data, internal knowledge-base articles and
  screenshots, server/site names and the old project notes; replace the
  knowledge base with generic sample articles and diagrams
- Move every environment value into Config/usmt-dash.psd1
- Route all infrastructure access in the worker and Get-Software.ps1
  through a provider (Providers/SCCM.ps1), with a config-driven asset
  inventory view instead of a hard-coded custom class
- Add Providers/Demo.ps1: a deterministic simulated fleet whose
  migrations progress in real time, and Demo/New-DemoData.ps1 to seed it
- Server: -Demo makes the API read-only; add get-config and get-logs;
  serve static files only from web/, Documentation/ and an allowlist of
  Data/ files with a path-traversal guard; gzip large responses
- Mirror both services' console output to Data/Logs via a Write-Host
  proxy, and add a terminal panel to the dashboard that streams it
- Demo sandbox (web/demo/demo.js): visitor changes to tags, articles and
  images are kept in the visitor's browser and echoed in the terminals;
  nothing a visitor does reaches the server
- Vendor marked, highlight.js and html2pdf.js instead of loading CDNs
- Dockerfile and compose file for hosting the demo (Coolify-ready)
2026-10-06 15:20:43 +00:00

591 lines
31 KiB
PowerShell
Executable File

param (
[switch]$v, # Verbose per-device tracing
[switch]$Demo, # Use the simulated fleet (Providers\Demo.ps1)
[string]$ConfigPath
)
# --- CONFIGURATION ---
$originalLocation = $PSScriptRoot
. (Join-Path $originalLocation "Lib/Common.ps1")
$config = Import-DashConfig -Path $ConfigPath -Demo:$Demo
$providerName = if ($config.IsDemo) { "Demo" } else { $config.Provider }
if ($config.IsDemo -and $config.Demo.Verbose) { $v = [switch]$true }
$dataDir = Join-Path $originalLocation "Data"
# Ensure Data directory exists
if (-not (Test-Path $dataDir)) { New-Item -ItemType Directory -Path $dataDir | Out-Null }
$trackedPath = Join-Path $dataDir "Tracked-Devices.json"
$jsonPath = Join-Path $dataDir "Data.json"
$completedPath = Join-Path $dataDir "Completed.json"
$statePath = Join-Path $dataDir "Worker-State.json"
$softwareCachePath = Join-Path $dataDir "Software-Cache.json"
$advancedCachePath = Join-Path $dataDir "Advanced-Cache.json"
$getSoftwareScript = Join-Path $originalLocation "Get-Software.ps1"
$diskSkipList = @($config.Worker.DiskSkipList) # Devices with broken SMB that hang Directory.Exists
$scanLogName = if ($config.SCCM.ScanStateLog) { $config.SCCM.ScanStateLog } else { "scanstateprogress" }
$loadLogName = if ($config.SCCM.LoadStateLog) { $config.SCCM.LoadStateLog } else { "loadstateprogress" }
if ($config.Logging.Enabled) {
$cmdLine = "PS $originalLocation> ./USMT-Worker.ps1" + $(if ($config.IsDemo) { " -Demo" } else { "" }) + $(if ($v) { " -v" } else { "" })
Initialize-ConsoleLog -Path (Join-Path $dataDir "Logs/worker.log") -MaxLines $config.Logging.MaxLines -CommandLine $cmdLine
}
# Global State
$lastSoftwareSweepDate = ""
if (Test-Path $softwareCachePath) {
$lastSoftwareSweepDate = (Get-Item $softwareCachePath).LastWriteTime.ToString("yyyy-MM-dd")
} else {
$lastSoftwareSweepDate = (Get-Date).ToString("yyyy-MM-dd")
}
$globalLastUpdated = "Never"
$globalNextRun = "Calculating..."
$globalLastDuration = "N/A"
# --- FUNCTIONS ---
function Update-WorkerState {
param([string]$status, [int]$current, [int]$total, [string]$duration = "N/A", [string]$nextRun = "Calculating...")
try {
$json = [PSCustomObject]@{
Status = $status
Current = $current
Total = $total
LastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt")
NextCycle = $nextRun
LastDuration = $duration
} | ConvertTo-Json -Compress
[System.IO.File]::WriteAllText($statePath, $json, [System.Text.Encoding]::UTF8)
} catch {}
}
function New-LogInfoResult {
return @{ Line = "Log Not Found"; Found = $false; IsSuccess = $false; Path = "N/A"; LastModified = $null; Elapsed = "N/A" }
}
# Turns the last line of a USMT progress log into the dashboard's display line.
# e.g. "14 Jul 2026, 10:21:34 -05:00, 00:12:44, Successful run"
function Set-LogInfoLine {
param($result, [string]$rawLine)
$prefix = ""
if ($rawLine -match '^(\d{1,2}\s+[a-zA-Z]{3}\s+\d{4}),\s+(\d{2}:\d{2}:\d{2})') {
try { $prefix = [datetime]::ParseExact("$($Matches[1]) $($Matches[2])", "d MMM yyyy HH:mm:ss", [System.Globalization.CultureInfo]::InvariantCulture).ToString("MMM dd yyyy hh:mm tt ") }
catch { $prefix = "$($Matches[1]) $($Matches[2]) " }
}
if ($rawLine -like "*Successful run*") { $result.Line = "${prefix}Successful Run"; $result.IsSuccess = $true }
elseif ($rawLine -match 'errorText, "(.*)"') { $result.Line = "${prefix}Error: $($Matches[1])" }
else { $parts = $rawLine -split ",", 5; $result.Line = if ($parts.Count -ge 5) { $prefix + $parts[4].Trim() } else { $prefix + $rawLine } }
if ($rawLine -match '^\d{1,2}\s+[a-zA-Z]{3}\s+\d{4},\s+\d{2}:\d{2}:\d{2}\s+[+-]\d{2}:\d{2},\s+(\d{2}:\d{2}:\d{2})') { $result.Elapsed = $Matches[1] }
}
# --- PROVIDER (data source adapter) ---
# Supplies: Initialize-Provider, Open-ProviderCycle, Close-ProviderCycle, pingDevice,
# Resolve-DeviceIP, Get-DiskSpace, Get-LogInfo, Get-DeviceAssociations, Get-DeviceInventory
. (Join-Path $originalLocation "Providers/$providerName.ps1")
$global:AdvancedCache = @{}
$global:AdvancedCacheUpdated = $false
$global:ForceFullPull = $false
if (Test-Path $advancedCachePath) {
try {
$rawCache = [System.IO.File]::ReadAllText($advancedCachePath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
foreach ($p in $rawCache.psobject.properties) { $global:AdvancedCache[$p.Name] = $p.Value }
} catch {}
}
function Get-AdvancedData {
param ([string]$computerName, [switch]$IsDest, [string]$OnlineStatus, [switch]$ForceUpdate, $Session)
$res = @{ IP="N/A"; MAC="N/A"; OU="N/A"; Groups="N/A"; Updated="N/A"; Location="N/A"; Vanity="N/A"; Department="N/A"; Building="N/A"; Mount="N/A"; PrimaryUser="N/A"; LastUser="N/A"; SCCMPrimaryUsers="N/A"; Model="N/A"; RAM="N/A"; Disk="N/A"; ClientStatus="N/A"; PhoneExt="N/A"; RoomType="Standard"; InfoBy="N/A"; Notes="N/A" }
if ([string]::IsNullOrWhiteSpace($computerName) -or $computerName -eq "N/A") { return $res }
$needsUpdate = $true
if (-not $ForceUpdate -and $global:AdvancedCache.ContainsKey($computerName)) {
$needsUpdate = $false
$cachedRes = $global:AdvancedCache[$computerName]
$cachedKeys = if ($cachedRes -is [hashtable]) { $cachedRes.Keys } else { $cachedRes.psobject.properties.Name }
foreach ($key in @($cachedKeys)) { $res[$key] = $cachedRes.$key }
}
# Live IP from DNS for devices that answered the ping (inventory IP is the fallback)
$dnsIp = $null
if ($OnlineStatus -eq "Online") {
$dnsIp = Resolve-DeviceIP $computerName
if ($dnsIp) { $res.IP = $dnsIp }
}
if (-not $needsUpdate) { return $res }
if ($v) { Write-Host " -> [CACHE MISS / UPDATE] Running AD & SQL lookup for $computerName..." -ForegroundColor DarkYellow }
$inventory = Get-DeviceInventory $computerName -Session $Session
foreach ($key in @($inventory.Keys)) {
if ($key -eq "InventoryIP") { continue }
if ($inventory[$key]) { $res[$key] = $inventory[$key] }
}
if ($null -eq $dnsIp -and $inventory.InventoryIP) { $res.IP = $inventory.InventoryIP }
$global:AdvancedCache[$computerName] = $res
$global:AdvancedCacheUpdated = $true
return $res
}
function Update-SoftwareCacheEntry {
param([string]$ComputerName)
& $getSoftwareScript -ComputerName $ComputerName -ConfigPath $ConfigPath -Demo:$config.IsDemo | Out-Null
}
# --- BUSINESS HOURS COOLDOWN & MIDNIGHT PITSTOP ---
function Get-NextRunTime {
param([datetime]$now)
$cycleSeconds = if ($config.IsDemo) { $config.Demo.CycleSeconds } else { $config.Worker.CycleSeconds }
$nextRun = $now.AddSeconds($cycleSeconds)
$bh = $config.Worker.BusinessHours
if ($config.IsDemo -or -not $bh.Enabled) { return $nextRun }
$startTime = [timespan]::Parse($bh.Start)
$endTime = [timespan]::Parse($bh.End)
$workDays = @($bh.Weekdays | ForEach-Object { [int]$_ })
$isWorkday = $workDays -contains [int]$nextRun.DayOfWeek
if (-not $isWorkday -or $nextRun.TimeOfDay -gt $endTime -or $nextRun.TimeOfDay -lt $startTime) {
# Next working morning (today if we're before opening on a workday)
$daysToAdd = if (($workDays -contains [int]$now.DayOfWeek) -and $now.TimeOfDay -lt $startTime) { 0 } else { 1 }
while (-not ($workDays -contains [int]$now.Date.AddDays($daysToAdd).DayOfWeek) -and $daysToAdd -lt 8) { $daysToAdd++ }
$morningRun = ($now.Date).AddDays($daysToAdd).Add($startTime)
$midnightTonight = ($now.Date).AddDays(1)
if ($now -lt $midnightTonight -and $midnightTonight -lt $morningRun) {
$nextRun = $midnightTonight
} else {
$nextRun = $morningRun
}
}
return $nextRun
}
# --- INITIAL SETUP ---
if ($v) { Write-Host "Verbose Mode Enabled. Detailed logging active." -ForegroundColor Cyan }
Initialize-Provider
Write-Host "Starting USMT Full Monitor (Smart Cache Enabled). Press Ctrl+C to stop." -ForegroundColor Magenta
if (Test-Path (Join-Path $dataDir "force-update.flag")) { Remove-Item (Join-Path $dataDir "force-update.flag") -Force }
if (Test-Path (Join-Path $dataDir "force-full.flag")) { Remove-Item (Join-Path $dataDir "force-full.flag") -Force }
while ($true) {
$cycleStart = Get-Date
Write-Host "`n--- Cycle Start: $($cycleStart.ToString('hh:mm:ss tt')) ---" -ForegroundColor White
Update-WorkerState "Preparing..." 0 0 "N/A" "Calculating..."
$session = Open-ProviderCycle
$completedList = @()
if (Test-Path $completedPath) {
try {
$completedList = [System.IO.File]::ReadAllText($completedPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
if ($completedList -isnot [array]) { $completedList = @($completedList) }
} catch {}
}
$deviceTags = @{}
$manualPairs = @{}
$skipUSMTTags = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
if (Test-Path $trackedPath) {
try {
$trackingData = [System.IO.File]::ReadAllText($trackedPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
foreach ($techProp in $trackingData.psobject.properties) {
$techName = $techProp.Name
$tagEntry = $techProp.Value
# Support both new { devices: [], skipUSMT: bool } and legacy flat array
$devList = if ($tagEntry -is [System.Management.Automation.PSCustomObject] -and $tagEntry.psobject.properties.name -contains 'devices') {
$tagEntry.devices
} else {
$tagEntry
}
# Track which tags are marked for USMT skip
if ($tagEntry -is [System.Management.Automation.PSCustomObject] -and $tagEntry.psobject.properties.name -contains 'skipUSMT' -and [bool]$tagEntry.skipUSMT) {
$null = $skipUSMTTags.Add($techName)
}
foreach ($dev in $devList) {
if ([string]::IsNullOrWhiteSpace($dev)) { continue }
$upperDev = $dev.Trim().ToUpper()
# --- NEW: Catch "Source, Dest" Manual Associations ---
if ($upperDev -match "^([^,]+)\s*,\s*([^,]+)$") {
$srcDev = $matches[1].Trim()
$dstDev = $matches[2].Trim()
$manualPairs[$srcDev] = $dstDev
# Ensure BOTH devices get registered for background Software & Advanced Info caching
if (-not $deviceTags.ContainsKey($srcDev)) { $deviceTags[$srcDev] = [System.Collections.Generic.List[string]]::new() }
if ($deviceTags[$srcDev] -notcontains $techName) { $deviceTags[$srcDev].Add($techName) }
if (-not $deviceTags.ContainsKey($dstDev)) { $deviceTags[$dstDev] = [System.Collections.Generic.List[string]]::new() }
if ($deviceTags[$dstDev] -notcontains $techName) { $deviceTags[$dstDev].Add($techName) }
continue
}
# -----------------------------------------------------
if (-not $deviceTags.ContainsKey($upperDev)) { $deviceTags[$upperDev] = [System.Collections.Generic.List[string]]::new() }
if ($deviceTags[$upperDev] -notcontains $techName) { $deviceTags[$upperDev].Add($techName) }
}
}
} catch {}
}
$uniqueDevices = $deviceTags.Keys | Sort-Object
$totalDevices = @($uniqueDevices).Count
Update-WorkerState "Software Check..." 0 $totalDevices "N/A" "Calculating..."
Write-Host "`n[Checking Software Cache Engine...]" -ForegroundColor Cyan
$runFullSoftwareSweep = $false
$currentDate = (Get-Date).ToString("yyyy-MM-dd")
if ($lastSoftwareSweepDate -ne $currentDate -or $global:ForceFullPull) {
if ($global:ForceFullPull) { Write-Host "Dashboard Force FULL Refresh. Triggering global software/advanced update." -ForegroundColor Magenta }
else { Write-Host "Midnight passed (or first run). Triggering global software update." -ForegroundColor Magenta }
$runFullSoftwareSweep = $true
$global:ForceFullPull = $false
$lastSoftwareSweepDate = $currentDate
}
$cacheObj = $null
if (Test-Path $softwareCachePath) {
try { $cacheObj = [System.IO.File]::ReadAllText($softwareCachePath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json } catch {}
}
$cachedNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
if ($cacheObj) { foreach ($p in $cacheObj.psobject.properties) { $null = $cachedNames.Add($p.Name) } }
foreach ($dev in $uniqueDevices) {
if ($runFullSoftwareSweep -or -not $cachedNames.Contains($dev)) {
if ($v) { Write-Host "Fetching background software for $dev..." -ForegroundColor DarkCyan }
Update-SoftwareCacheEntry $dev
$null = $cachedNames.Add($dev)
}
}
Write-Host "[Software Cache Synchronized]`n" -ForegroundColor Green
$deviceTable = [System.Collections.Generic.List[PSCustomObject]]::new()
$currentDeviceCount = 0
Write-Host "--- Commencing Data Sweep ($totalDevices Devices) ---" -ForegroundColor White
foreach ($deviceName in $uniqueDevices) {
$currentDeviceCount++
Update-WorkerState "Updating Devices..." $currentDeviceCount $totalDevices "N/A" "Calculating..."
Write-Host "[$currentDeviceCount/$totalDevices] Processing: $deviceName" -ForegroundColor Yellow
$tags = $deviceTags[$deviceName] -join ", "
if ($v) { Write-Host " -> Tags: [$tags]" -ForegroundColor DarkCyan }
# --- SKIP USMT: if any of this device's tags are marked skipUSMT ---
$deviceTagList = @($deviceTags[$deviceName])
$allTagsSkipped = ($deviceTagList.Count -gt 0) -and @($deviceTagList | Where-Object { $skipUSMTTags.Contains($_) }).Count -gt 0
if ($allTagsSkipped) {
if ($v) { Write-Host " -> [SKIP USMT] Tag marked Skip USMT. Caching Advanced/Software only." -ForegroundColor DarkMagenta }
$null = Get-AdvancedData $deviceName -OnlineStatus "Skipped" -ForceUpdate:$runFullSoftwareSweep -Session $session
$deviceTable.Add([PSCustomObject]@{
"Source Device"=$deviceName; "Destination Device"="N/A";
"Tags"=$tags; "Source Total Space"="N/A"; "Source Free Space"="N/A";
"USMT Status"="Skipped"; "Backup Status"="Skipped";
"Source Online"="Skipped"; "Destination Online"="-";
"Source Scanstate Line"="N/A"; "Destination Loadstate Line"="N/A";
"Source Scanstate File"="N/A"; "Destination Loadstate File"="N/A"
"Source Scanstate Elapsed"="N/A"; "Destination Loadstate Elapsed"="N/A";
})
continue
}
# -----------------------------------------------------------------
if ($manualPairs.ContainsKey($deviceName)) {
$dName = $manualPairs[$deviceName]
$sName = $deviceName
if ($v) { Write-Host " -> [MANUAL MATCH] Hardware exception detected. Bypassing SCCM..." -ForegroundColor DarkYellow }
$pingSrc = pingDevice $sName
$pingDst = pingDevice $dName
$null = Get-AdvancedData $sName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
$null = Get-AdvancedData $dName -IsDest -OnlineStatus $pingDst -ForceUpdate:$runFullSoftwareSweep -Session $session
$deviceTable.Add([PSCustomObject]@{
"Source Device"=$sName; "Destination Device"=$dName;
"Tags"=$tags; "Source Total Space"="N/A"; "Source Free Space"="N/A";
"USMT Status"="N/A"; "Backup Status"="N/A";
"Source Online"=$pingSrc; "Destination Online"=$pingDst;
"Source Scanstate Line"="N/A"; "Destination Loadstate Line"="N/A";
"Source Scanstate File"="N/A"; "Destination Loadstate File"="N/A"
"Source Scanstate Elapsed"="N/A"; "Destination Loadstate Elapsed"="N/A";
})
continue # Move on to the next device
}
# Prevent the destination machine from being swept a second time as a standalone source
if ($manualPairs.Values -contains $deviceName -and -not $manualPairs.ContainsKey($deviceName)) {
if ($v) { Write-Host " -> [MANUAL DEST] Device handled by pair logic. Skipping independent sweep." -ForegroundColor DarkGray }
continue
}
if ($v) { Write-Host " -> Querying SCCM for associations..." -ForegroundColor DarkGray }
$associations = Get-DeviceAssociations -SourceComputer $deviceName
if (-not $associations) {
$cached = $completedList | Where-Object { $_."Source Device".ToUpper() -eq $deviceName } | Select-Object -First 1
if ($cached) {
if ($v) { Write-Host " -> [CACHE HIT] But association was removed from SCCM." -ForegroundColor DarkYellow }
# Backfill dest software - association is gone so the assoc loop will never fetch it
$cachedDest = "$($cached."Destination Device")"
if (-not [string]::IsNullOrWhiteSpace($cachedDest) -and $cachedDest -ne "N/A") {
if ($runFullSoftwareSweep -or -not $cachedNames.Contains($cachedDest)) {
if ($v) { Write-Host " -> [CACHE MISS] Fetching software for Cached Dest $cachedDest..." -ForegroundColor DarkCyan }
Update-SoftwareCacheEntry $cachedDest
$null = $cachedNames.Add($cachedDest)
}
}
$null = Get-AdvancedData $cached."Source Device" -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
$null = Get-AdvancedData $cached."Destination Device" -IsDest -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
$deviceTable.Add([PSCustomObject]@{
"Source Device"=$cached."Source Device"; "Destination Device"=$cached."Destination Device";
"Tags"=$tags; "Source Total Space"="Cached"; "Source Free Space"="Cached";
"USMT Status"="Not Associated"; "Backup Status"="Completed";
"Source Online"="Cached"; "Destination Online"="Cached";
"Source Scanstate Line"="Cached"; "Destination Loadstate Line"="Cached";
"Source Scanstate File"="Cached"; "Destination Loadstate File"="Cached"
"Source Scanstate Elapsed"="Cached"; "Destination Loadstate Elapsed"="Cached";
})
continue
}
if ($v) { Write-Host " -> No SCCM association found. Sweeping source device anyway..." -ForegroundColor DarkGray }
$pingSrc = pingDevice $deviceName
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null }
$srcTotal = "N/A"
$srcFree = "N/A"
if ($pingSrc -eq "Online") {
# --- DISK SKIP LIST CHECK (No-Association path) ---
if ($diskSkipList -notcontains $deviceName) {
$diskInfo = Get-DiskSpace $deviceName
$srcTotal = $diskInfo.Total
$srcFree = $diskInfo.Free
} else {
if ($v) { Write-Host " > Disk check skipped for $deviceName (on diskSkipList)" -ForegroundColor DarkYellow }
}
if ($diskSkipList -notcontains $deviceName -and $srcFree -ne "N/A") { $logSrc = Get-LogInfo $deviceName $scanLogName }
}
$null = Get-AdvancedData $deviceName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
$deviceTable.Add([PSCustomObject]@{
"Source Device"=$deviceName; "Destination Device"="N/A";
"Tags"=$tags; "Source Total Space"=$srcTotal; "Source Free Space"=$srcFree;
"USMT Status"="Not Associated"; "Backup Status"="Not Associated";
"Source Online"=$pingSrc; "Destination Online"="-";
"Source Scanstate Line"=$logSrc.Line; "Destination Loadstate Line"="N/A";
"Source Scanstate File"=$logSrc.Path; "Destination Loadstate File"="N/A"
"Source Scanstate Elapsed"=$logSrc.Elapsed; "Destination Loadstate Elapsed"="N/A";
})
continue
}
foreach ($assoc in $associations) {
$assoc.Get()
$dName = if ($assoc.DestinationComputer) { $assoc.DestinationComputer } else { $assoc.RestoreName }
$sName = if ($assoc.SourceComputer) { $assoc.SourceComputer } else { $deviceName }
if (-not [string]::IsNullOrWhiteSpace($dName) -and $dName -ne "N/A") {
if ($runFullSoftwareSweep -or -not $cachedNames.Contains($dName)) {
if ($v) { Write-Host " -> [CACHE MISS] Fetching software for Dest Device $dName..." -ForegroundColor DarkCyan }
Update-SoftwareCacheEntry $dName
$null = $cachedNames.Add($dName)
}
}
$usmtStatusRaw = $assoc.MigrationStatus
$usmtStatusStr = switch ($usmtStatusRaw) { 0 {"Not Started"} 1 {"In Progress"} 2 {"Completed"} default {"Unknown"} }
if ($v) { Write-Host " -> SCCM DB Status: $usmtStatusStr (Raw: $usmtStatusRaw)" -ForegroundColor DarkGray }
$cached = $completedList | Where-Object { $_."Source Device".ToUpper() -eq $sName.ToUpper() -and $_."Destination Device".ToUpper() -eq $dName.ToUpper() }
if ($cached -and $usmtStatusRaw -eq 0) {
if ($v) { Write-Host " -> [CACHE RESET] SCCM Status reverted to Not Started. Resetting..." -ForegroundColor DarkYellow }
$completedList = @($completedList | Where-Object { $_."Source Device".ToUpper() -ne $sName.ToUpper() -or $_."Destination Device".ToUpper() -ne $dName.ToUpper() })
$jsonCompleted = ConvertTo-Json -InputObject @($completedList) -Depth 5
[System.IO.File]::WriteAllText($completedPath, $jsonCompleted, [System.Text.Encoding]::UTF8)
$cached = $null
}
if ($cached) {
if ($v) { Write-Host " -> [CACHE HIT] Fast-tracking row and bypassing network sweeps." -ForegroundColor Green }
$null = Get-AdvancedData $sName -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
$null = Get-AdvancedData $dName -IsDest -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
$deviceTable.Add([PSCustomObject]@{
"Source Device"=$sName; "Destination Device"=$dName;
"Tags"=$tags; "Source Total Space"="Cached"; "Source Free Space"="Cached";
"USMT Status"=$usmtStatusStr; "Backup Status"="Completed";
"Source Online"="Cached"; "Destination Online"="Cached";
"Source Scanstate Line"="Cached"; "Destination Loadstate Line"="Cached";
"Source Scanstate File"="Cached"; "Destination Loadstate File"="Cached"
"Source Scanstate Elapsed"="Cached"; "Destination Loadstate Elapsed"="Cached";
})
continue
}
$row = [ordered]@{
"Source Device"=$sName; "Destination Device"=$dName; "Tags"=$tags;
"Source Total Space"="N/A"; "Source Free Space"="N/A";
"USMT Status"=$usmtStatusStr; "Backup Status"="Checking...";
"Source Online"="N/A"; "Destination Online"="N/A";
"Source Scanstate Line"="N/A"; "Destination Loadstate Line"="N/A";
"Source Scanstate File"="N/A"; "Destination Loadstate File"="N/A"
"Source Scanstate Elapsed"="N/A"; "Destination Loadstate Elapsed"="N/A";
}
if ($v) { Write-Host " -> [SWEEP SOURCE] $sName" -ForegroundColor DarkCyan }
$pingSrc = pingDevice $sName
$row."Source Online" = $pingSrc
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null }
if ($pingSrc -eq "Online") {
# --- DISK SKIP LIST CHECK (Association path) ---
if ($diskSkipList -notcontains $sName) {
$diskInfo = Get-DiskSpace $sName
$row."Source Total Space" = $diskInfo.Total
$row."Source Free Space" = $diskInfo.Free
} else {
if ($v) { Write-Host " > Disk check skipped for $sName (on diskSkipList)" -ForegroundColor DarkYellow }
}
if ($diskSkipList -notcontains $sName -and $row."Source Free Space" -ne "N/A") { $logSrc = Get-LogInfo $sName $scanLogName }
}
$row."Source Scanstate Line" = $logSrc.Line
$row."Source Scanstate File" = $logSrc.Path
$row."Source Scanstate Elapsed" = $logSrc.Elapsed
if ($v) { Write-Host " -> [SWEEP DESTINATION] $dName" -ForegroundColor DarkCyan }
$pingDst = pingDevice $dName
$row."Destination Online" = $pingDst
$logDst = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingDst)"; Path = "Unreachable"; LastModified = $null }
if ($pingDst -eq "Online") { $logDst = Get-LogInfo $dName $loadLogName }
$row."Destination Loadstate Line" = $logDst.Line
$row."Destination Loadstate File" = $logDst.Path
$row."Destination Loadstate Elapsed" = $logDst.Elapsed
if ($v) { Write-Host " -> Calculating Master Status..." -ForegroundColor DarkGray }
$timeThreshold = (Get-Date).AddHours(-1 * $config.Worker.ActiveLogHours)
$isSrcLogActive = ($logSrc.Found -and $logSrc.Path -match "SMSTSLog" -and $logSrc.LastModified -gt $timeThreshold)
$isDstLogActive = ($logDst.Found -and $logDst.Path -match "SMSTSLog" -and $logDst.LastModified -gt $timeThreshold)
if ($logDst.IsSuccess -or $usmtStatusRaw -eq 2) { $row."Backup Status" = "Completed" }
elseif ($usmtStatusRaw -eq 1 -and $isDstLogActive) { $row."Backup Status" = "Restore In Progress" }
elseif ($usmtStatusRaw -eq 1) { $row."Backup Status" = "Backup Completed" }
elseif ($usmtStatusRaw -eq 0 -and $isSrcLogActive) { $row."Backup Status" = "Backup In Progress" }
else { $row."Backup Status" = "Not Started" }
if ($v) { Write-Host " -> Fetching Advanced SQL/AD Data..." -ForegroundColor DarkGray }
$null = Get-AdvancedData $sName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
$null = Get-AdvancedData $dName -IsDest -OnlineStatus $pingDst -ForceUpdate:$runFullSoftwareSweep -Session $session
$finalRow = [PSCustomObject]$row
Write-Host " - Status: $($finalRow."Backup Status")" -ForegroundColor Cyan
$deviceTable.Add($finalRow)
if ($finalRow."Backup Status" -eq "Completed") {
$completedList += [PSCustomObject]@{ "Source Device"=$sName; "Destination Device"=$dName }
$jsonCompleted = ConvertTo-Json -InputObject @($completedList) -Depth 5
[System.IO.File]::WriteAllText($completedPath, $jsonCompleted, [System.Text.Encoding]::UTF8)
}
}
}
Close-ProviderCycle $session
if ($v) { Write-Host " -> Pruning and Compressing Software Cache..." -ForegroundColor DarkGray }
if (Test-Path $softwareCachePath) {
try {
$rawCache = [System.IO.File]::ReadAllText($softwareCachePath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
$activeDevs = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($row in $deviceTable) {
if ($row."Source Device" -and $row."Source Device" -ne "N/A") { $null = $activeDevs.Add($row."Source Device") }
if ($row."Destination Device" -and $row."Destination Device" -ne "N/A") { $null = $activeDevs.Add($row."Destination Device") }
}
$pruned = $false
foreach ($p in @($rawCache.psobject.properties.Name)) {
if (-not $activeDevs.Contains($p)) {
$rawCache.psobject.properties.Remove($p)
$pruned = $true
}
}
$jsonSoftCache = $rawCache | ConvertTo-Json -Depth 6 -Compress
[System.IO.File]::WriteAllText($softwareCachePath, $jsonSoftCache, [System.Text.Encoding]::UTF8)
if ($pruned -and $v) { Write-Host " -> Removed untracked devices from cache." -ForegroundColor Green }
if ($global:AdvancedCacheUpdated -or $pruned -or $runFullSoftwareSweep) {
if ($v) { Write-Host " -> Pruning and Saving Advanced Info Cache..." -ForegroundColor DarkGray }
$keysToRemove = @()
foreach ($key in $global:AdvancedCache.Keys) {
if (-not $activeDevs.Contains($key)) { $keysToRemove += $key }
}
foreach ($key in $keysToRemove) { $global:AdvancedCache.Remove($key) }
$jsonAdvCache = $global:AdvancedCache | ConvertTo-Json -Depth 5 -Compress
[System.IO.File]::WriteAllText($advancedCachePath, $jsonAdvCache, [System.Text.Encoding]::UTF8)
$global:AdvancedCacheUpdated = $false
}
} catch {}
}
if ($v) { Write-Host " -> Writing payload to Data.json..." -ForegroundColor DarkGray }
$globalLastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt")
$exportPayload = [PSCustomObject]@{ LastUpdated = $globalLastUpdated; Devices = $deviceTable }
$jsonFinal = $exportPayload | ConvertTo-Json -Depth 6
[System.IO.File]::WriteAllText($jsonPath, $jsonFinal, [System.Text.Encoding]::UTF8)
$cycleEnd = Get-Date
$cycleTime = $cycleEnd - $cycleStart
$globalLastDuration = if ($cycleTime.TotalMinutes -ge 1) { "$([math]::Round($cycleTime.TotalMinutes, 1))m" } else { "$([math]::Round($cycleTime.TotalSeconds, 1))s" }
Write-Host "`nCycle Complete in $globalLastDuration." -ForegroundColor Gray
$nextRun = Get-NextRunTime (Get-Date)
$nextRunStr = $nextRun.ToString("MM/dd/yy hh:mm tt")
Update-WorkerState "Cooldown" $totalDevices $totalDevices $globalLastDuration $nextRunStr
Write-Host "Next Cycle Scheduled For: $nextRunStr" -ForegroundColor DarkGray
while ((Get-Date) -lt $nextRun) {
if (Test-Path (Join-Path $dataDir "force-full.flag")) {
Remove-Item (Join-Path $dataDir "force-full.flag") -Force
$global:ForceFullPull = $true
Write-Host "`n[!] Force FULL Refresh triggered by Dashboard!" -ForegroundColor Cyan
break
}
if (Test-Path (Join-Path $dataDir "force-update.flag")) {
Remove-Item (Join-Path $dataDir "force-update.flag") -Force
Write-Host "`n[!] Force Update triggered by Dashboard!" -ForegroundColor Cyan
break
}
Start-Sleep -Seconds 1
}
Write-Host "`n"
}