Files
usmt-dash/USMT-Worker.ps1
T
Johan Sandoval 9731368687 Faster first load; demo date tags stay current
First load:
- Draw the Overview as soon as the data arrives instead of on its next
  2-second timer tick (about 2.5 s to 0.6 s on a typical connection).
- Start on DOMContentLoaded instead of window.onload.
- Load the ~900 KB PDF library only when someone exports a PDF.
- Fetch the startup files side by side, and build the docs search index
  after the dashboard has drawn (unless the visit starts on /docs).
- Show a loading placeholder on the Overview until the data arrives.

Demo: the date-batch tags sit on fixed working-day offsets from today.
The seed records them in Demo-Dates.json and the worker relabels them when
the day changes, so a long-running container never looks stale.
2026-10-07 20:22:09 +00:00

903 lines
49 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"
$presencePath = Join-Path $dataDir "Presence.json"
$snapshotPath = Join-Path $dataDir "Snapshots.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" }
# How often every tracked device (cached and destination devices included) is pinged to keep
# "last online" current. Cycles only run in business hours plus the midnight pitstop, so this
# works out to an hourly presence sweep during the day and one at midnight.
# While a cycle runs, rows are published this often (merged with last cycle's rows for devices
# not swept yet), so the dashboard updates live instead of only when the cycle ends
$livePublishSeconds = if ($config.IsDemo) { $config.Demo.LivePublishSeconds } else { $config.Worker.LivePublishSeconds }
if (-not $livePublishSeconds) { $livePublishSeconds = 10 }
$presenceMinutes = if ($config.IsDemo) { $config.Demo.PresenceSweepMinutes } else { $config.Worker.PresenceSweepMinutes }
if (-not $presenceMinutes) { $presenceMinutes = 60 }
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"
$global:LastBreakdown = $null
$global:DataVersion = ""
$global:LastPresenceSweep = [datetime]::MinValue
# --- 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
LastCycle = $global:LastBreakdown
DataVersion = $global:DataVersion
} | ConvertTo-Json -Compress -Depth 5
Write-SharedText $statePath $json
} 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] }
}
function Get-IsoNow { return (Get-Date).ToString("o") }
# Files the dashboard reads while the worker writes them: write a temp file, then swap it in,
# so a reader never sees a half-written file. Falls back to a direct write if the swap fails
# (e.g. Windows refusing to replace a file another process has open).
function Write-SharedText([string]$path, [string]$text) {
$tmp = "$path.tmp"
try {
[System.IO.File]::WriteAllText($tmp, $text, [System.Text.Encoding]::UTF8)
if (Test-Path -LiteralPath $path) { [System.IO.File]::Replace($tmp, $path, $null) }
else { [System.IO.File]::Move($tmp, $path) }
} catch {
[System.IO.File]::WriteAllText($path, $text, [System.Text.Encoding]::UTF8)
if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }
}
}
function ConvertTo-Hashtable($obj) {
$h = @{}
if ($obj -is [hashtable]) { foreach ($k in $obj.Keys) { $h[$k] = $obj[$k] } }
elseif ($obj) { foreach ($p in $obj.psobject.properties) { $h[$p.Name] = $p.Value } }
return $h
}
# Reads a JSON object of objects into a hashtable of hashtables
function Read-JsonTable([string]$path) {
$table = @{}
if (Test-Path $path) {
try {
$raw = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
foreach ($p in $raw.psobject.properties) { $table[$p.Name] = ConvertTo-Hashtable $p.Value }
} catch {}
}
return $table
}
function Write-JsonFile([string]$path, $obj, [int]$depth = 6) {
Write-SharedText $path (ConvertTo-Json -InputObject $obj -Depth $depth -Compress)
}
# --- PROVIDER (data source adapter) ---
# Supplies: Initialize-Provider, Open-ProviderCycle, Close-ProviderCycle, pingDevice,
# Resolve-DeviceIP, Get-DiskSpace, Get-LogInfo, Get-DeviceAssociations, Get-DeviceInventory
# and optionally Get-DeviceLastSeen (backfills "last online" for devices tracked for the first time)
. (Join-Path $originalLocation "Providers/$providerName.ps1")
$hasLastSeen = [bool](Get-Command Get-DeviceLastSeen -ErrorAction SilentlyContinue)
# --- PRESENCE (last online) ---
# Every ping the worker makes is recorded here: when the device was last checked, what the
# ping said, and when it last answered. Kept across restarts in Presence.json.
$global:Presence = Read-JsonTable $presencePath
# When the last presence sweep ran survives a worker restart, so a restart doesn't trigger
# an extra sweep (it's kept in Presence.json under a reserved key)
if ($global:Presence.ContainsKey('__sweep')) {
try { $global:LastPresenceSweep = [datetime]$global:Presence['__sweep'].Last } catch {}
$global:Presence.Remove('__sweep')
}
$global:PingedThisCycle = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
function Set-Presence {
param([string]$name, [string]$status, $Session)
if ([string]::IsNullOrWhiteSpace($name) -or $name -eq "N/A") { return }
$key = $name.Trim().ToUpper()
$now = Get-IsoNow
$entry = $global:Presence[$key]
if (-not $entry) {
$entry = @{ FirstChecked = $now; LastOnline = $null }
# First time we see this device: ask the data source when it was last active
if ($status -ne "Online" -and $hasLastSeen) {
try {
$seen = Get-DeviceLastSeen $key -Session $Session
if ($seen) { $entry.LastOnline = ([datetime]$seen).ToString("o"); $entry.LastOnlineSource = "Inventory" }
} catch {}
}
$global:Presence[$key] = $entry
}
$entry.LastChecked = $now
$entry.LastPing = $status
$entry.InDNS = ($status -ne "Not Found")
if ($status -eq "Online") { $entry.LastOnline = $now; $entry.LastOnlineSource = "Ping" }
$null = $global:PingedThisCycle.Add($key)
}
# pingDevice + presence bookkeeping
function Test-Device {
param([string]$name, $Session)
$status = pingDevice $name
Set-Presence $name $status -Session $Session
return $status
}
function Get-PresenceValue([string]$name, [string]$field) {
if ([string]::IsNullOrWhiteSpace($name) -or $name -eq "N/A") { return $null }
$e = $global:Presence[$name.ToUpper()]
if ($e) { return $e[$field] }
return $null
}
# --- SNAPSHOTS ---
# The last state seen while a device was reachable: free space, the last scanstate /
# loadstate line, and when each was read. Shown (with its timestamp) instead of "Cached" for
# completed migrations, and instead of blanks while a device is offline.
$global:Snapshots = Read-JsonTable $snapshotPath
function Update-Snapshot {
param([string]$sName, [string]$dName, $row, [bool]$srcLive, [bool]$dstLive)
$key = $sName.ToUpper()
$snap = $global:Snapshots[$key]
if (-not $snap) { $snap = @{}; $global:Snapshots[$key] = $snap }
$now = Get-IsoNow
if ($srcLive) {
$snap.Src = @{
At = $now; Total = $row."Source Total Space"; Free = $row."Source Free Space"
Line = $row."Source Scanstate Line"; File = $row."Source Scanstate File"; Elapsed = $row."Source Scanstate Elapsed"
}
}
if ($dstLive -and $dName -and $dName -ne "N/A") {
$snap.Dst = @{
At = $now; Device = $dName.ToUpper()
Line = $row."Destination Loadstate Line"; File = $row."Destination Loadstate File"; Elapsed = $row."Destination Loadstate Elapsed"
}
}
if ($srcLive -or $dstLive) { $snap.Status = $row."Backup Status"; $snap.At = $now }
}
# Fills the source / destination parts of a row from the snapshot. Returns what was used.
function Use-Snapshot {
param([string]$sName, [string]$dName, $row, [bool]$src, [bool]$dst)
$snap = $global:Snapshots[$sName.ToUpper()]
if (-not $snap) { return }
$s = $snap.Src; $d = $snap.Dst
if ($src -and $s) {
$s = ConvertTo-Hashtable $s
$row."Source Total Space" = $s.Total; $row."Source Free Space" = $s.Free
$row."Source Scanstate Line" = $s.Line; $row."Source Scanstate File" = $s.File; $row."Source Scanstate Elapsed" = $s.Elapsed
$row."Source Snapshot" = $s.At
$row."Source Space Checked" = $s.At
}
if ($dst -and $d) {
$d = ConvertTo-Hashtable $d
if (-not $dName -or $dName -eq "N/A" -or $d.Device -eq $dName.ToUpper()) {
$row."Destination Loadstate Line" = $d.Line; $row."Destination Loadstate File" = $d.File; $row."Destination Loadstate Elapsed" = $d.Elapsed
$row."Destination Snapshot" = $d.At
}
}
}
# Data.json + a version stamp the dashboard compares to know there's something new to load
function Publish-Data {
param($rows)
$global:DataVersion = (Get-Date).ToString("o")
$payload = [PSCustomObject]@{ LastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt"); DataVersion = $global:DataVersion; Devices = $rows }
Write-SharedText $jsonPath ($payload | ConvertTo-Json -Depth 6)
}
# Every row has the same columns; these are filled in as the device is processed
function New-DeviceRow {
param([string]$sName, [string]$dName, [string]$tags, [string]$pairing)
return [ordered]@{
"Source Device"=$sName; "Destination Device"=$dName; "Tags"=$tags; "Pairing"=$pairing;
"Source Total Space"="N/A"; "Source Free Space"="N/A";
"USMT Status"="N/A"; "Backup Status"="N/A";
"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";
"Source Checked"=$null; "Destination Checked"=$null; "Source Space Checked"=$null;
"Source Snapshot"=$null; "Destination Snapshot"=$null
}
}
# Advanced-Cache.json = inventory per device plus its presence (last online / last check)
function Save-AdvancedCache {
$out = @{}
foreach ($key in @($global:AdvancedCache.Keys)) {
$entry = ConvertTo-Hashtable $global:AdvancedCache[$key]
$pr = $global:Presence[$key.ToUpper()]
if ($pr) {
foreach ($f in @("LastOnline", "LastOnlineSource", "LastChecked", "LastPing", "FirstChecked", "InDNS")) { $entry[$f] = $pr[$f] }
}
$out[$key] = $entry
}
Write-JsonFile $advancedCachePath $out 5
$presenceOut = @{} + $global:Presence
if ($global:LastPresenceSweep -gt [datetime]::MinValue) { $presenceOut['__sweep'] = @{ Last = $global:LastPresenceSweep.ToString("o") } }
Write-JsonFile $presencePath $presenceOut 4
}
# Cached / skipped rows show the latest presence-sweep result instead of a placeholder
function Set-RowPresence {
param($row, [switch]$Source, [switch]$Destination)
if ($Source) {
$ping = Get-PresenceValue $row."Source Device" "LastPing"
if ($ping) { $row."Source Online" = $ping; $row."Source Checked" = Get-PresenceValue $row."Source Device" "LastChecked" }
}
if ($Destination) {
$ping = Get-PresenceValue $row."Destination Device" "LastPing"
if ($ping) { $row."Destination Online" = $ping; $row."Destination Checked" = Get-PresenceValue $row."Destination Device" "LastChecked" }
}
}
$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"; MonitorCount="N/A"; Monitors="N/A"; InAD=$null; InSCCM=$null; Fetched=$null }
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 }
# A device that wasn't in AD or SCCM yet (e.g. a replacement not imaged yet) is looked
# up again every cycle until it shows up, instead of waiting for the nightly refresh
if ($res.InAD -eq $false -or $res.InSCCM -eq $false -or -not $res.Fetched) { $needsUpdate = $true }
}
# 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 }
$res.InAD = $inventory.InAD
$res.InSCCM = $inventory.InSCCM
$res.Fetched = Get-IsoNow
if ($v -and ($res.InAD -eq $false -or $res.InSCCM -eq $false)) {
$missing = @(); if ($res.InAD -eq $false) { $missing += "AD" }; if ($res.InSCCM -eq $false) { $missing += "SCCM" }
Write-Host " -> $computerName not found in: $($missing -join ', ')" -ForegroundColor DarkYellow
}
$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 = if ($config.IsDemo) { $config.Demo.BusinessHours } else { $config.Worker.BusinessHours }
if (-not $bh -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 $globalLastDuration "Calculating..."
$session = Open-ProviderCycle
if ($config.IsDemo) { Update-DemoDateTags $dataDir }
$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)
# Totals count SOURCE devices only: a manual pair's destination is part of its source's row
$manualDestSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($d in $manualPairs.Values) { if (-not $manualPairs.ContainsKey($d)) { $null = $manualDestSet.Add($d) } }
# Last cycle's rows: devices with no data yet (newly tracked) are swept first, and the
# dashboard gets them as soon as they're done instead of at the end of the cycle
$previousRows = @{}
if (Test-Path $jsonPath) {
try {
$prev = [System.IO.File]::ReadAllText($jsonPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
foreach ($r in @($prev.Devices)) { if ($r."Source Device") { $previousRows[$r."Source Device".ToUpper()] = $r } }
} catch {}
}
$newSources = [System.Collections.Generic.List[string]]::new()
$knownSources = [System.Collections.Generic.List[string]]::new()
foreach ($dev in $uniqueDevices) {
if ($manualDestSet.Contains($dev)) { continue }
$prevRow = $previousRows[$dev]
$isNew = (-not $prevRow) -or (-not $global:AdvancedCache.ContainsKey($dev)) -or ($manualPairs.ContainsKey($dev) -and "$($prevRow."Destination Device")" -ne $manualPairs[$dev])
if ($isNew) { $newSources.Add($dev) } else { $knownSources.Add($dev) }
}
$sourceDevices = @($newSources) + @($knownSources)
$totalDevices = $sourceDevices.Count
$global:PingedThisCycle.Clear()
$timing = [ordered]@{}
foreach ($k in @("SCCM", "Cached", "NoAssoc", "Manual", "Skipped")) { $timing[$k] = @{ Count = 0; Ms = 0 } }
$phaseWatch = [System.Diagnostics.Stopwatch]::StartNew()
Update-WorkerState "Software Check..." 0 $totalDevices $globalLastDuration "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) } }
# New devices first here too, so their software is ready when their row is
$softwareOrder = @($newSources) + @($uniqueDevices | Where-Object { -not $newSources.Contains($_) })
foreach ($dev in $softwareOrder) {
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
$softwareSeconds = $phaseWatch.Elapsed.TotalSeconds
$phaseWatch.Restart()
$deviceTable = [System.Collections.Generic.List[PSCustomObject]]::new()
$processedSources = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$currentDeviceCount = 0
if ($newSources.Count -gt 0) { Write-Host "--- Commencing Data Sweep ($totalDevices Devices, $($newSources.Count) new first) ---" -ForegroundColor White }
else { Write-Host "--- Commencing Data Sweep ($totalDevices Devices) ---" -ForegroundColor White }
$publishWatch = [System.Diagnostics.Stopwatch]::StartNew()
foreach ($deviceName in $sourceDevices) {
$currentDeviceCount++
$deviceWatch = [System.Diagnostics.Stopwatch]::StartNew()
$category = "SCCM"
Update-WorkerState "Updating Devices..." $currentDeviceCount $totalDevices $globalLastDuration "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) {
$category = "Skipped"
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
$row = New-DeviceRow $deviceName "N/A" $tags "Skipped"
$row."USMT Status" = "Skipped"; $row."Backup Status" = "Skipped"
$row."Source Online" = "Skipped"; $row."Destination Online" = "-"
$deviceTable.Add([PSCustomObject]$row)
}
elseif ($manualPairs.ContainsKey($deviceName)) {
$category = "Manual"
$dName = $manualPairs[$deviceName]
$sName = $deviceName
if ($v) { Write-Host " -> [MANUAL MATCH] Hardware exception detected. Bypassing SCCM..." -ForegroundColor DarkYellow }
$row = New-DeviceRow $sName $dName $tags "Manual"
$row."Source Online" = Test-Device $sName -Session $session
$row."Destination Online" = Test-Device $dName -Session $session
$row."Source Checked" = Get-IsoNow; $row."Destination Checked" = $row."Source Checked"
$null = Get-AdvancedData $sName -OnlineStatus $row."Source Online" -ForceUpdate:$runFullSoftwareSweep -Session $session
$null = Get-AdvancedData $dName -IsDest -OnlineStatus $row."Destination Online" -ForceUpdate:$runFullSoftwareSweep -Session $session
$deviceTable.Add([PSCustomObject]$row)
}
else {
if ($v) { Write-Host " -> Querying SCCM for associations..." -ForegroundColor DarkGray }
$associations = Get-DeviceAssociations -SourceComputer $deviceName
if (-not $associations) {
$category = "NoAssoc"
$cached = $completedList | Where-Object { $_."Source Device".ToUpper() -eq $deviceName } | Select-Object -First 1
if ($cached) {
$category = "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
$row = New-DeviceRow $cached."Source Device" $cached."Destination Device" $tags "Cached"
foreach ($k in @("Source Total Space", "Source Free Space", "Source Online", "Destination Online", "Source Scanstate Line", "Destination Loadstate Line", "Source Scanstate File", "Destination Loadstate File", "Source Scanstate Elapsed", "Destination Loadstate Elapsed")) { $row[$k] = "Cached" }
$row."USMT Status" = "Not Associated"; $row."Backup Status" = "Completed"
Use-Snapshot $row."Source Device" $row."Destination Device" $row $true $true
Set-RowPresence $row -Source -Destination
$deviceTable.Add([PSCustomObject]$row)
}
else {
if ($v) { Write-Host " -> No SCCM association found. Sweeping source device anyway..." -ForegroundColor DarkGray }
$row = New-DeviceRow $deviceName "N/A" $tags "None"
$pingSrc = Test-Device $deviceName -Session $session
$row."Source Online" = $pingSrc; $row."Destination Online" = "-"
$row."Source Checked" = Get-IsoNow
$row."USMT Status" = "Not Associated"; $row."Backup Status" = "Not Associated"
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null; Elapsed = "N/A" }
if ($pingSrc -eq "Online") {
# --- DISK SKIP LIST CHECK (No-Association path) ---
if ($diskSkipList -notcontains $deviceName) {
$diskInfo = Get-DiskSpace $deviceName
$row."Source Total Space" = $diskInfo.Total
$row."Source Free Space" = $diskInfo.Free
if ($diskInfo.Free -ne "N/A") { $row."Source Space Checked" = Get-IsoNow }
} else {
if ($v) { Write-Host " > Disk check skipped for $deviceName (on diskSkipList)" -ForegroundColor DarkYellow }
}
if ($diskSkipList -notcontains $deviceName -and $row."Source Free Space" -ne "N/A") { $logSrc = Get-LogInfo $deviceName $scanLogName }
}
$row."Source Scanstate Line" = $logSrc.Line
$row."Source Scanstate File" = $logSrc.Path
$row."Source Scanstate Elapsed" = $logSrc.Elapsed
$srcLive = ($pingSrc -eq "Online" -and $row."Source Free Space" -ne "N/A")
if ($srcLive) { Update-Snapshot $deviceName "N/A" $row $true $false }
else { Use-Snapshot $deviceName "N/A" $row $true $false }
$null = Get-AdvancedData $deviceName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
$deviceTable.Add([PSCustomObject]$row)
}
}
else {
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) {
$category = "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
$row = New-DeviceRow $sName $dName $tags "Cached"
foreach ($k in @("Source Total Space", "Source Free Space", "Source Online", "Destination Online", "Source Scanstate Line", "Destination Loadstate Line", "Source Scanstate File", "Destination Loadstate File", "Source Scanstate Elapsed", "Destination Loadstate Elapsed")) { $row[$k] = "Cached" }
$row."USMT Status" = $usmtStatusStr; $row."Backup Status" = "Completed"
# Last state seen before it completed (with its timestamp) instead of "Cached"
Use-Snapshot $sName $dName $row $true $true
Set-RowPresence $row -Source -Destination
if ($v -and $row."Source Snapshot") { Write-Host " > Showing snapshot from $(([datetime]$row."Source Snapshot").ToString('MM/dd hh:mm tt'))" -ForegroundColor DarkGray }
$deviceTable.Add([PSCustomObject]$row)
continue
}
$row = New-DeviceRow $sName $dName $tags "SCCM"
$row."USMT Status" = $usmtStatusStr; $row."Backup Status" = "Checking..."
if ($v) { Write-Host " -> [SWEEP SOURCE] $sName" -ForegroundColor DarkCyan }
$pingSrc = Test-Device $sName -Session $session
$row."Source Online" = $pingSrc
$row."Source Checked" = Get-IsoNow
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null; Elapsed = "N/A" }
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
if ($diskInfo.Free -ne "N/A") { $row."Source Space Checked" = Get-IsoNow }
} 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 = Test-Device $dName -Session $session
$row."Destination Online" = $pingDst
$row."Destination Checked" = Get-IsoNow
$logDst = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingDst)"; Path = "Unreachable"; LastModified = $null; Elapsed = "N/A" }
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" }
# Remember what we could read; fill what we couldn't from the last snapshot
$srcLive = ($pingSrc -eq "Online" -and $row."Source Free Space" -ne "N/A")
$dstLive = ($pingDst -eq "Online" -and $logDst.Found)
Update-Snapshot $sName $dName $row $srcLive $dstLive
Use-Snapshot $sName $dName $row (-not $srcLive) (-not $dstLive -and $pingDst -ne "Online")
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)
}
}
}
}
$null = $processedSources.Add($deviceName)
$timing[$category].Count++
$timing[$category].Ms += $deviceWatch.Elapsed.TotalMilliseconds
# Live updates: newly tracked devices are published as soon as they're done, then the
# rows swept so far every few seconds (devices not reached yet keep last cycle's row)
$newDone = ($newSources.Count -gt 0 -and $currentDeviceCount -eq $newSources.Count)
$remaining = $totalDevices - $currentDeviceCount
if ($remaining -gt 0 -and ($newDone -or $publishWatch.Elapsed.TotalSeconds -ge $livePublishSeconds)) {
if ($newDone) { Write-Host " -> [PRIORITY] $($newSources.Count) new device(s) swept. Publishing early..." -ForegroundColor Green }
elseif ($v) { Write-Host " -> [LIVE] Publishing $currentDeviceCount of $totalDevices swept device(s)..." -ForegroundColor DarkGray }
$interim = [System.Collections.Generic.List[object]]::new()
foreach ($r in $deviceTable) { $interim.Add($r) }
foreach ($dev in $sourceDevices) { if (-not $processedSources.Contains($dev) -and $previousRows.ContainsKey($dev)) { $interim.Add($previousRows[$dev]) } }
Save-AdvancedCache
Publish-Data $interim
$publishWatch.Restart()
}
}
$sweepSeconds = $phaseWatch.Elapsed.TotalSeconds
$phaseWatch.Restart()
# --- PRESENCE SWEEP: completed pairs and Skip USMT devices ---
# Every other device is pinged by the sweep itself on every cycle. Completed pairs and
# devices under a Skip USMT tag skip network checks, so they're pinged here: hourly during
# business hours (cycles only run then) and on the midnight pitstop.
$presenceCount = 0
$presenceDue = ((Get-Date) - $global:LastPresenceSweep).TotalMinutes -ge ($presenceMinutes - 0.5) -or $runFullSoftwareSweep
if ($presenceDue) {
$completedDevs = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($r in $deviceTable) {
if ($r.Pairing -ne "Cached" -and $r.Pairing -ne "Skipped") { continue }
foreach ($n in @($r."Source Device", $r."Destination Device")) { if ($n -and $n -ne "N/A") { $null = $completedDevs.Add($n) } }
}
$toPing = @($completedDevs | Where-Object { -not $global:PingedThisCycle.Contains($_) } | Sort-Object)
Write-Host "`n[Presence Sweep: pinging $($toPing.Count) completed / Skip USMT device(s)]" -ForegroundColor Cyan
$i = 0
foreach ($n in $toPing) {
$i++
Update-WorkerState "Presence Sweep..." $i $toPing.Count $globalLastDuration "Calculating..."
$null = Test-Device $n -Session $session
}
# Completed rows pick up the fresh result (Skip USMT rows keep "Skipped" in the ping
# column; their presence shows in the device details and Custom View)
foreach ($r in $deviceTable) {
if ($r.Pairing -eq "Cached") { Set-RowPresence $r -Source -Destination }
}
$presenceCount = $toPing.Count
$global:LastPresenceSweep = Get-Date
Write-Host "[Presence Updated. Next presence sweep after $($global:LastPresenceSweep.AddMinutes($presenceMinutes).ToString('hh:mm tt')), or at midnight]" -ForegroundColor Green
}
$presenceSeconds = $phaseWatch.Elapsed.TotalSeconds
$phaseWatch.Restart()
Close-ProviderCycle $session
$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") }
}
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
$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 }
} catch {}
}
# Presence changes every cycle, so the advanced cache (which carries it) is saved every cycle
try {
if ($v) { Write-Host " -> Pruning and Saving Advanced Info Cache..." -ForegroundColor DarkGray }
foreach ($key in @($global:AdvancedCache.Keys)) { if (-not $activeDevs.Contains($key)) { $global:AdvancedCache.Remove($key) } }
foreach ($key in @($global:Presence.Keys)) { if (-not $activeDevs.Contains($key)) { $global:Presence.Remove($key) } }
foreach ($key in @($global:Snapshots.Keys)) { if (-not $activeDevs.Contains($key)) { $global:Snapshots.Remove($key) } }
Save-AdvancedCache
Write-JsonFile $snapshotPath $global:Snapshots 4
$global:AdvancedCacheUpdated = $false
} catch { Write-Host "Could not save caches: $($_.Exception.GetType().Name)" -ForegroundColor Red }
if ($v) { Write-Host " -> Writing payload to Data.json..." -ForegroundColor DarkGray }
$globalLastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt")
Publish-Data $deviceTable
$saveSeconds = $phaseWatch.Elapsed.TotalSeconds
$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
# How the time was spent, per kind of row (shown in the dashboard's sync dialog)
$labels = [ordered]@{ SCCM = "SCCM associations"; Manual = "Manual pairs"; Cached = "Completed (snapshot)"; NoAssoc = "No association"; Skipped = "Skip USMT tags" }
$cats = @()
foreach ($k in $labels.Keys) {
$t = $timing[$k]
if ($t.Count -eq 0) { continue }
$cats += [PSCustomObject]@{ Key = $k; Label = $labels[$k]; Count = $t.Count; Seconds = [math]::Round($t.Ms / 1000, 1); AvgMs = [int]($t.Ms / $t.Count) }
if ($v) { Write-Host (" {0,-22} {1,4} device(s) {2,7:N1}s (avg {3} ms)" -f $labels[$k], $t.Count, ($t.Ms / 1000), [int]($t.Ms / $t.Count)) -ForegroundColor DarkGray }
}
$global:LastBreakdown = [PSCustomObject]@{
Finished = Get-IsoNow
Seconds = [math]::Round($cycleTime.TotalSeconds, 1)
Sources = $totalDevices
NewFirst = $newSources.Count
FullRefresh = $runFullSoftwareSweep
Categories = $cats
Phases = [PSCustomObject]@{ Software = [math]::Round($softwareSeconds, 1); Sweep = [math]::Round($sweepSeconds, 1); Presence = [math]::Round($presenceSeconds, 1); Save = [math]::Round($saveSeconds, 1) }
PresenceSweep = [PSCustomObject]@{ Ran = $presenceDue; Pinged = $presenceCount; Last = $(if ($global:LastPresenceSweep -gt [datetime]::MinValue) { $global:LastPresenceSweep.ToString("o") } else { $null }); EveryMinutes = $presenceMinutes }
}
$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"
}