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)
591 lines
31 KiB
PowerShell
Executable File
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"
|
|
}
|