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).
903 lines
49 KiB
PowerShell
Executable File
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"
|
|
}
|