Files
usmt-dash/USMT-Worker.ps1
T
Johan Sandoval 41ff9e2b49 Demo: migrations follow the schedule, worked by a simulated team
Each device now migrates on the day of its date tag instead of on a
looping 24-hour clock: earlier days are finished, today's devices go
through backup and restore during working hours, later days haven't
started. The few that don't finish have a cause the dashboard shows
(broken client, broken WMI, low disk, a USMT error, unreachable PC).

A simulated team keeps two weeks of working days tagged. During working
hours it adds devices, reschedules or drops one, tags stuck PCs with
their problem, then fixes them and moves them to a new day; each morning
finished days are closed out and a new day is scheduled. Actions print
in the worker terminal as [Team] lines. Settings live in Demo.Team.

The visitor sandbox now keeps per-tag edits laid over the live roster,
so the team's changes keep showing around a visitor's own edits.

Overview calendar: today is a filled badge on the date number, so it no
longer looks like the selected day (outline plus tint).
2026-10-07 20:41:07 +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..."
if ($config.IsDemo) { Invoke-DemoTeam $dataDir } # the simulated team edits the roster
$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)
# 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"
}