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)
This commit is contained in:
commit
1c531bc2ab
56 files changed
+10232
No files matched your search
Executable
+590
@@ -0,0 +1,590 @@
|
||||
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"
|
||||
}
|
||||
Reference in new issue
Block a user