First load: - Draw the Overview as soon as the data arrives instead of on its next 2-second timer tick (about 2.5 s to 0.6 s on a typical connection). - Start on DOMContentLoaded instead of window.onload. - Load the ~900 KB PDF library only when someone exports a PDF. - Fetch the startup files side by side, and build the docs search index after the dashboard has drawn (unless the visit starts on /docs). - Show a loading placeholder on the Overview until the data arrives. Demo: the date-batch tags sit on fixed working-day offsets from today. The seed records them in Demo-Dates.json and the worker relabels them when the day changes, so a long-running container never looks stale.
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..."
|
|
|
|
$session = Open-ProviderCycle
|
|
if ($config.IsDemo) { Update-DemoDateTags $dataDir }
|
|
|
|
$completedList = @()
|
|
if (Test-Path $completedPath) {
|
|
try {
|
|
$completedList = [System.IO.File]::ReadAllText($completedPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
|
if ($completedList -isnot [array]) { $completedList = @($completedList) }
|
|
} catch {}
|
|
}
|
|
|
|
$deviceTags = @{}
|
|
$manualPairs = @{}
|
|
|
|
$skipUSMTTags = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
|
|
|
if (Test-Path $trackedPath) {
|
|
try {
|
|
$trackingData = [System.IO.File]::ReadAllText($trackedPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
|
foreach ($techProp in $trackingData.psobject.properties) {
|
|
$techName = $techProp.Name
|
|
$tagEntry = $techProp.Value
|
|
|
|
# Support both new { devices: [], skipUSMT: bool } and legacy flat array
|
|
$devList = if ($tagEntry -is [System.Management.Automation.PSCustomObject] -and $tagEntry.psobject.properties.name -contains 'devices') {
|
|
$tagEntry.devices
|
|
} else {
|
|
$tagEntry
|
|
}
|
|
|
|
# Track which tags are marked for USMT skip
|
|
if ($tagEntry -is [System.Management.Automation.PSCustomObject] -and $tagEntry.psobject.properties.name -contains 'skipUSMT' -and [bool]$tagEntry.skipUSMT) {
|
|
$null = $skipUSMTTags.Add($techName)
|
|
}
|
|
|
|
foreach ($dev in $devList) {
|
|
if ([string]::IsNullOrWhiteSpace($dev)) { continue }
|
|
$upperDev = $dev.Trim().ToUpper()
|
|
|
|
# --- NEW: Catch "Source, Dest" Manual Associations ---
|
|
if ($upperDev -match "^([^,]+)\s*,\s*([^,]+)$") {
|
|
$srcDev = $matches[1].Trim()
|
|
$dstDev = $matches[2].Trim()
|
|
$manualPairs[$srcDev] = $dstDev
|
|
|
|
# Ensure BOTH devices get registered for background Software & Advanced Info caching
|
|
if (-not $deviceTags.ContainsKey($srcDev)) { $deviceTags[$srcDev] = [System.Collections.Generic.List[string]]::new() }
|
|
if ($deviceTags[$srcDev] -notcontains $techName) { $deviceTags[$srcDev].Add($techName) }
|
|
|
|
if (-not $deviceTags.ContainsKey($dstDev)) { $deviceTags[$dstDev] = [System.Collections.Generic.List[string]]::new() }
|
|
if ($deviceTags[$dstDev] -notcontains $techName) { $deviceTags[$dstDev].Add($techName) }
|
|
continue
|
|
}
|
|
# -----------------------------------------------------
|
|
|
|
if (-not $deviceTags.ContainsKey($upperDev)) { $deviceTags[$upperDev] = [System.Collections.Generic.List[string]]::new() }
|
|
if ($deviceTags[$upperDev] -notcontains $techName) { $deviceTags[$upperDev].Add($techName) }
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
$uniqueDevices = @($deviceTags.Keys | Sort-Object)
|
|
|
|
# Totals count SOURCE devices only: a manual pair's destination is part of its source's row
|
|
$manualDestSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
|
foreach ($d in $manualPairs.Values) { if (-not $manualPairs.ContainsKey($d)) { $null = $manualDestSet.Add($d) } }
|
|
|
|
# Last cycle's rows: devices with no data yet (newly tracked) are swept first, and the
|
|
# dashboard gets them as soon as they're done instead of at the end of the cycle
|
|
$previousRows = @{}
|
|
if (Test-Path $jsonPath) {
|
|
try {
|
|
$prev = [System.IO.File]::ReadAllText($jsonPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
|
foreach ($r in @($prev.Devices)) { if ($r."Source Device") { $previousRows[$r."Source Device".ToUpper()] = $r } }
|
|
} catch {}
|
|
}
|
|
$newSources = [System.Collections.Generic.List[string]]::new()
|
|
$knownSources = [System.Collections.Generic.List[string]]::new()
|
|
foreach ($dev in $uniqueDevices) {
|
|
if ($manualDestSet.Contains($dev)) { continue }
|
|
$prevRow = $previousRows[$dev]
|
|
$isNew = (-not $prevRow) -or (-not $global:AdvancedCache.ContainsKey($dev)) -or ($manualPairs.ContainsKey($dev) -and "$($prevRow."Destination Device")" -ne $manualPairs[$dev])
|
|
if ($isNew) { $newSources.Add($dev) } else { $knownSources.Add($dev) }
|
|
}
|
|
$sourceDevices = @($newSources) + @($knownSources)
|
|
$totalDevices = $sourceDevices.Count
|
|
$global:PingedThisCycle.Clear()
|
|
|
|
$timing = [ordered]@{}
|
|
foreach ($k in @("SCCM", "Cached", "NoAssoc", "Manual", "Skipped")) { $timing[$k] = @{ Count = 0; Ms = 0 } }
|
|
$phaseWatch = [System.Diagnostics.Stopwatch]::StartNew()
|
|
|
|
Update-WorkerState "Software Check..." 0 $totalDevices $globalLastDuration "Calculating..."
|
|
Write-Host "`n[Checking Software Cache Engine...]" -ForegroundColor Cyan
|
|
$runFullSoftwareSweep = $false
|
|
$currentDate = (Get-Date).ToString("yyyy-MM-dd")
|
|
|
|
if ($lastSoftwareSweepDate -ne $currentDate -or $global:ForceFullPull) {
|
|
if ($global:ForceFullPull) { Write-Host "Dashboard Force FULL Refresh. Triggering global software/advanced update." -ForegroundColor Magenta }
|
|
else { Write-Host "Midnight passed (or first run). Triggering global software update." -ForegroundColor Magenta }
|
|
$runFullSoftwareSweep = $true
|
|
$global:ForceFullPull = $false
|
|
$lastSoftwareSweepDate = $currentDate
|
|
}
|
|
|
|
$cacheObj = $null
|
|
if (Test-Path $softwareCachePath) {
|
|
try { $cacheObj = [System.IO.File]::ReadAllText($softwareCachePath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json } catch {}
|
|
}
|
|
$cachedNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
|
if ($cacheObj) { foreach ($p in $cacheObj.psobject.properties) { $null = $cachedNames.Add($p.Name) } }
|
|
|
|
# New devices first here too, so their software is ready when their row is
|
|
$softwareOrder = @($newSources) + @($uniqueDevices | Where-Object { -not $newSources.Contains($_) })
|
|
foreach ($dev in $softwareOrder) {
|
|
if ($runFullSoftwareSweep -or -not $cachedNames.Contains($dev)) {
|
|
if ($v) { Write-Host "Fetching background software for $dev..." -ForegroundColor DarkCyan }
|
|
Update-SoftwareCacheEntry $dev
|
|
$null = $cachedNames.Add($dev)
|
|
}
|
|
}
|
|
Write-Host "[Software Cache Synchronized]`n" -ForegroundColor Green
|
|
$softwareSeconds = $phaseWatch.Elapsed.TotalSeconds
|
|
$phaseWatch.Restart()
|
|
|
|
$deviceTable = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
$processedSources = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
|
$currentDeviceCount = 0
|
|
|
|
if ($newSources.Count -gt 0) { Write-Host "--- Commencing Data Sweep ($totalDevices Devices, $($newSources.Count) new first) ---" -ForegroundColor White }
|
|
else { Write-Host "--- Commencing Data Sweep ($totalDevices Devices) ---" -ForegroundColor White }
|
|
|
|
$publishWatch = [System.Diagnostics.Stopwatch]::StartNew()
|
|
foreach ($deviceName in $sourceDevices) {
|
|
$currentDeviceCount++
|
|
$deviceWatch = [System.Diagnostics.Stopwatch]::StartNew()
|
|
$category = "SCCM"
|
|
Update-WorkerState "Updating Devices..." $currentDeviceCount $totalDevices $globalLastDuration "Calculating..."
|
|
Write-Host "[$currentDeviceCount/$totalDevices] Processing: $deviceName" -ForegroundColor Yellow
|
|
|
|
$tags = $deviceTags[$deviceName] -join ", "
|
|
if ($v) { Write-Host " -> Tags: [$tags]" -ForegroundColor DarkCyan }
|
|
|
|
# --- SKIP USMT: if any of this device's tags are marked skipUSMT ---
|
|
$deviceTagList = @($deviceTags[$deviceName])
|
|
$allTagsSkipped = ($deviceTagList.Count -gt 0) -and @($deviceTagList | Where-Object { $skipUSMTTags.Contains($_) }).Count -gt 0
|
|
if ($allTagsSkipped) {
|
|
$category = "Skipped"
|
|
if ($v) { Write-Host " -> [SKIP USMT] Tag marked Skip USMT. Caching Advanced/Software only." -ForegroundColor DarkMagenta }
|
|
$null = Get-AdvancedData $deviceName -OnlineStatus "Skipped" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
|
$row = New-DeviceRow $deviceName "N/A" $tags "Skipped"
|
|
$row."USMT Status" = "Skipped"; $row."Backup Status" = "Skipped"
|
|
$row."Source Online" = "Skipped"; $row."Destination Online" = "-"
|
|
$deviceTable.Add([PSCustomObject]$row)
|
|
}
|
|
elseif ($manualPairs.ContainsKey($deviceName)) {
|
|
$category = "Manual"
|
|
$dName = $manualPairs[$deviceName]
|
|
$sName = $deviceName
|
|
if ($v) { Write-Host " -> [MANUAL MATCH] Hardware exception detected. Bypassing SCCM..." -ForegroundColor DarkYellow }
|
|
|
|
$row = New-DeviceRow $sName $dName $tags "Manual"
|
|
$row."Source Online" = Test-Device $sName -Session $session
|
|
$row."Destination Online" = Test-Device $dName -Session $session
|
|
$row."Source Checked" = Get-IsoNow; $row."Destination Checked" = $row."Source Checked"
|
|
|
|
$null = Get-AdvancedData $sName -OnlineStatus $row."Source Online" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
|
$null = Get-AdvancedData $dName -IsDest -OnlineStatus $row."Destination Online" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
|
$deviceTable.Add([PSCustomObject]$row)
|
|
}
|
|
else {
|
|
if ($v) { Write-Host " -> Querying SCCM for associations..." -ForegroundColor DarkGray }
|
|
$associations = Get-DeviceAssociations -SourceComputer $deviceName
|
|
|
|
if (-not $associations) {
|
|
$category = "NoAssoc"
|
|
$cached = $completedList | Where-Object { $_."Source Device".ToUpper() -eq $deviceName } | Select-Object -First 1
|
|
if ($cached) {
|
|
$category = "Cached"
|
|
if ($v) { Write-Host " -> [CACHE HIT] But association was removed from SCCM." -ForegroundColor DarkYellow }
|
|
|
|
# Backfill dest software - association is gone so the assoc loop will never fetch it
|
|
$cachedDest = "$($cached."Destination Device")"
|
|
if (-not [string]::IsNullOrWhiteSpace($cachedDest) -and $cachedDest -ne "N/A") {
|
|
if ($runFullSoftwareSweep -or -not $cachedNames.Contains($cachedDest)) {
|
|
if ($v) { Write-Host " -> [CACHE MISS] Fetching software for Cached Dest $cachedDest..." -ForegroundColor DarkCyan }
|
|
Update-SoftwareCacheEntry $cachedDest
|
|
$null = $cachedNames.Add($cachedDest)
|
|
}
|
|
}
|
|
|
|
$null = Get-AdvancedData $cached."Source Device" -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
|
$null = Get-AdvancedData $cached."Destination Device" -IsDest -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
|
|
|
$row = New-DeviceRow $cached."Source Device" $cached."Destination Device" $tags "Cached"
|
|
foreach ($k in @("Source Total Space", "Source Free Space", "Source Online", "Destination Online", "Source Scanstate Line", "Destination Loadstate Line", "Source Scanstate File", "Destination Loadstate File", "Source Scanstate Elapsed", "Destination Loadstate Elapsed")) { $row[$k] = "Cached" }
|
|
$row."USMT Status" = "Not Associated"; $row."Backup Status" = "Completed"
|
|
Use-Snapshot $row."Source Device" $row."Destination Device" $row $true $true
|
|
Set-RowPresence $row -Source -Destination
|
|
$deviceTable.Add([PSCustomObject]$row)
|
|
}
|
|
else {
|
|
if ($v) { Write-Host " -> No SCCM association found. Sweeping source device anyway..." -ForegroundColor DarkGray }
|
|
|
|
$row = New-DeviceRow $deviceName "N/A" $tags "None"
|
|
$pingSrc = Test-Device $deviceName -Session $session
|
|
$row."Source Online" = $pingSrc; $row."Destination Online" = "-"
|
|
$row."Source Checked" = Get-IsoNow
|
|
$row."USMT Status" = "Not Associated"; $row."Backup Status" = "Not Associated"
|
|
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null; Elapsed = "N/A" }
|
|
|
|
if ($pingSrc -eq "Online") {
|
|
# --- DISK SKIP LIST CHECK (No-Association path) ---
|
|
if ($diskSkipList -notcontains $deviceName) {
|
|
$diskInfo = Get-DiskSpace $deviceName
|
|
$row."Source Total Space" = $diskInfo.Total
|
|
$row."Source Free Space" = $diskInfo.Free
|
|
if ($diskInfo.Free -ne "N/A") { $row."Source Space Checked" = Get-IsoNow }
|
|
} else {
|
|
if ($v) { Write-Host " > Disk check skipped for $deviceName (on diskSkipList)" -ForegroundColor DarkYellow }
|
|
}
|
|
if ($diskSkipList -notcontains $deviceName -and $row."Source Free Space" -ne "N/A") { $logSrc = Get-LogInfo $deviceName $scanLogName }
|
|
}
|
|
$row."Source Scanstate Line" = $logSrc.Line
|
|
$row."Source Scanstate File" = $logSrc.Path
|
|
$row."Source Scanstate Elapsed" = $logSrc.Elapsed
|
|
|
|
$srcLive = ($pingSrc -eq "Online" -and $row."Source Free Space" -ne "N/A")
|
|
if ($srcLive) { Update-Snapshot $deviceName "N/A" $row $true $false }
|
|
else { Use-Snapshot $deviceName "N/A" $row $true $false }
|
|
|
|
$null = Get-AdvancedData $deviceName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
|
|
$deviceTable.Add([PSCustomObject]$row)
|
|
}
|
|
}
|
|
else {
|
|
foreach ($assoc in $associations) {
|
|
$assoc.Get()
|
|
$dName = if ($assoc.DestinationComputer) { $assoc.DestinationComputer } else { $assoc.RestoreName }
|
|
$sName = if ($assoc.SourceComputer) { $assoc.SourceComputer } else { $deviceName }
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($dName) -and $dName -ne "N/A") {
|
|
if ($runFullSoftwareSweep -or -not $cachedNames.Contains($dName)) {
|
|
if ($v) { Write-Host " -> [CACHE MISS] Fetching software for Dest Device $dName..." -ForegroundColor DarkCyan }
|
|
Update-SoftwareCacheEntry $dName
|
|
$null = $cachedNames.Add($dName)
|
|
}
|
|
}
|
|
|
|
$usmtStatusRaw = $assoc.MigrationStatus
|
|
$usmtStatusStr = switch ($usmtStatusRaw) { 0 {"Not Started"} 1 {"In Progress"} 2 {"Completed"} default {"Unknown"} }
|
|
|
|
if ($v) { Write-Host " -> SCCM DB Status: $usmtStatusStr (Raw: $usmtStatusRaw)" -ForegroundColor DarkGray }
|
|
|
|
$cached = $completedList | Where-Object { $_."Source Device".ToUpper() -eq $sName.ToUpper() -and $_."Destination Device".ToUpper() -eq $dName.ToUpper() }
|
|
|
|
if ($cached -and $usmtStatusRaw -eq 0) {
|
|
if ($v) { Write-Host " -> [CACHE RESET] SCCM Status reverted to Not Started. Resetting..." -ForegroundColor DarkYellow }
|
|
$completedList = @($completedList | Where-Object { $_."Source Device".ToUpper() -ne $sName.ToUpper() -or $_."Destination Device".ToUpper() -ne $dName.ToUpper() })
|
|
$jsonCompleted = ConvertTo-Json -InputObject @($completedList) -Depth 5
|
|
[System.IO.File]::WriteAllText($completedPath, $jsonCompleted, [System.Text.Encoding]::UTF8)
|
|
$cached = $null
|
|
}
|
|
|
|
if ($cached) {
|
|
$category = "Cached"
|
|
if ($v) { Write-Host " -> [CACHE HIT] Fast-tracking row and bypassing network sweeps." -ForegroundColor Green }
|
|
$null = Get-AdvancedData $sName -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
|
$null = Get-AdvancedData $dName -IsDest -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
|
|
|
$row = New-DeviceRow $sName $dName $tags "Cached"
|
|
foreach ($k in @("Source Total Space", "Source Free Space", "Source Online", "Destination Online", "Source Scanstate Line", "Destination Loadstate Line", "Source Scanstate File", "Destination Loadstate File", "Source Scanstate Elapsed", "Destination Loadstate Elapsed")) { $row[$k] = "Cached" }
|
|
$row."USMT Status" = $usmtStatusStr; $row."Backup Status" = "Completed"
|
|
# Last state seen before it completed (with its timestamp) instead of "Cached"
|
|
Use-Snapshot $sName $dName $row $true $true
|
|
Set-RowPresence $row -Source -Destination
|
|
if ($v -and $row."Source Snapshot") { Write-Host " > Showing snapshot from $(([datetime]$row."Source Snapshot").ToString('MM/dd hh:mm tt'))" -ForegroundColor DarkGray }
|
|
$deviceTable.Add([PSCustomObject]$row)
|
|
continue
|
|
}
|
|
|
|
$row = New-DeviceRow $sName $dName $tags "SCCM"
|
|
$row."USMT Status" = $usmtStatusStr; $row."Backup Status" = "Checking..."
|
|
|
|
if ($v) { Write-Host " -> [SWEEP SOURCE] $sName" -ForegroundColor DarkCyan }
|
|
$pingSrc = Test-Device $sName -Session $session
|
|
$row."Source Online" = $pingSrc
|
|
$row."Source Checked" = Get-IsoNow
|
|
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null; Elapsed = "N/A" }
|
|
|
|
if ($pingSrc -eq "Online") {
|
|
# --- DISK SKIP LIST CHECK (Association path) ---
|
|
if ($diskSkipList -notcontains $sName) {
|
|
$diskInfo = Get-DiskSpace $sName
|
|
$row."Source Total Space" = $diskInfo.Total
|
|
$row."Source Free Space" = $diskInfo.Free
|
|
if ($diskInfo.Free -ne "N/A") { $row."Source Space Checked" = Get-IsoNow }
|
|
} else {
|
|
if ($v) { Write-Host " > Disk check skipped for $sName (on diskSkipList)" -ForegroundColor DarkYellow }
|
|
}
|
|
if ($diskSkipList -notcontains $sName -and $row."Source Free Space" -ne "N/A") { $logSrc = Get-LogInfo $sName $scanLogName }
|
|
}
|
|
$row."Source Scanstate Line" = $logSrc.Line
|
|
$row."Source Scanstate File" = $logSrc.Path
|
|
$row."Source Scanstate Elapsed" = $logSrc.Elapsed
|
|
|
|
if ($v) { Write-Host " -> [SWEEP DESTINATION] $dName" -ForegroundColor DarkCyan }
|
|
$pingDst = Test-Device $dName -Session $session
|
|
$row."Destination Online" = $pingDst
|
|
$row."Destination Checked" = Get-IsoNow
|
|
$logDst = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingDst)"; Path = "Unreachable"; LastModified = $null; Elapsed = "N/A" }
|
|
|
|
if ($pingDst -eq "Online") { $logDst = Get-LogInfo $dName $loadLogName }
|
|
$row."Destination Loadstate Line" = $logDst.Line
|
|
$row."Destination Loadstate File" = $logDst.Path
|
|
$row."Destination Loadstate Elapsed" = $logDst.Elapsed
|
|
|
|
if ($v) { Write-Host " -> Calculating Master Status..." -ForegroundColor DarkGray }
|
|
$timeThreshold = (Get-Date).AddHours(-1 * $config.Worker.ActiveLogHours)
|
|
|
|
$isSrcLogActive = ($logSrc.Found -and $logSrc.Path -match "SMSTSLog" -and $logSrc.LastModified -gt $timeThreshold)
|
|
$isDstLogActive = ($logDst.Found -and $logDst.Path -match "SMSTSLog" -and $logDst.LastModified -gt $timeThreshold)
|
|
|
|
if ($logDst.IsSuccess -or $usmtStatusRaw -eq 2) { $row."Backup Status" = "Completed" }
|
|
elseif ($usmtStatusRaw -eq 1 -and $isDstLogActive) { $row."Backup Status" = "Restore In Progress" }
|
|
elseif ($usmtStatusRaw -eq 1) { $row."Backup Status" = "Backup Completed" }
|
|
elseif ($usmtStatusRaw -eq 0 -and $isSrcLogActive) { $row."Backup Status" = "Backup In Progress" }
|
|
else { $row."Backup Status" = "Not Started" }
|
|
|
|
# Remember what we could read; fill what we couldn't from the last snapshot
|
|
$srcLive = ($pingSrc -eq "Online" -and $row."Source Free Space" -ne "N/A")
|
|
$dstLive = ($pingDst -eq "Online" -and $logDst.Found)
|
|
Update-Snapshot $sName $dName $row $srcLive $dstLive
|
|
Use-Snapshot $sName $dName $row (-not $srcLive) (-not $dstLive -and $pingDst -ne "Online")
|
|
|
|
if ($v) { Write-Host " -> Fetching Advanced SQL/AD Data..." -ForegroundColor DarkGray }
|
|
$null = Get-AdvancedData $sName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
|
|
$null = Get-AdvancedData $dName -IsDest -OnlineStatus $pingDst -ForceUpdate:$runFullSoftwareSweep -Session $session
|
|
|
|
$finalRow = [PSCustomObject]$row
|
|
Write-Host " - Status: $($finalRow."Backup Status")" -ForegroundColor Cyan
|
|
$deviceTable.Add($finalRow)
|
|
|
|
if ($finalRow."Backup Status" -eq "Completed") {
|
|
$completedList += [PSCustomObject]@{ "Source Device"=$sName; "Destination Device"=$dName }
|
|
$jsonCompleted = ConvertTo-Json -InputObject @($completedList) -Depth 5
|
|
[System.IO.File]::WriteAllText($completedPath, $jsonCompleted, [System.Text.Encoding]::UTF8)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$null = $processedSources.Add($deviceName)
|
|
$timing[$category].Count++
|
|
$timing[$category].Ms += $deviceWatch.Elapsed.TotalMilliseconds
|
|
|
|
# Live updates: newly tracked devices are published as soon as they're done, then the
|
|
# rows swept so far every few seconds (devices not reached yet keep last cycle's row)
|
|
$newDone = ($newSources.Count -gt 0 -and $currentDeviceCount -eq $newSources.Count)
|
|
$remaining = $totalDevices - $currentDeviceCount
|
|
if ($remaining -gt 0 -and ($newDone -or $publishWatch.Elapsed.TotalSeconds -ge $livePublishSeconds)) {
|
|
if ($newDone) { Write-Host " -> [PRIORITY] $($newSources.Count) new device(s) swept. Publishing early..." -ForegroundColor Green }
|
|
elseif ($v) { Write-Host " -> [LIVE] Publishing $currentDeviceCount of $totalDevices swept device(s)..." -ForegroundColor DarkGray }
|
|
$interim = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($r in $deviceTable) { $interim.Add($r) }
|
|
foreach ($dev in $sourceDevices) { if (-not $processedSources.Contains($dev) -and $previousRows.ContainsKey($dev)) { $interim.Add($previousRows[$dev]) } }
|
|
Save-AdvancedCache
|
|
Publish-Data $interim
|
|
$publishWatch.Restart()
|
|
}
|
|
}
|
|
$sweepSeconds = $phaseWatch.Elapsed.TotalSeconds
|
|
$phaseWatch.Restart()
|
|
|
|
# --- PRESENCE SWEEP: completed pairs and Skip USMT devices ---
|
|
# Every other device is pinged by the sweep itself on every cycle. Completed pairs and
|
|
# devices under a Skip USMT tag skip network checks, so they're pinged here: hourly during
|
|
# business hours (cycles only run then) and on the midnight pitstop.
|
|
$presenceCount = 0
|
|
$presenceDue = ((Get-Date) - $global:LastPresenceSweep).TotalMinutes -ge ($presenceMinutes - 0.5) -or $runFullSoftwareSweep
|
|
if ($presenceDue) {
|
|
$completedDevs = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
|
foreach ($r in $deviceTable) {
|
|
if ($r.Pairing -ne "Cached" -and $r.Pairing -ne "Skipped") { continue }
|
|
foreach ($n in @($r."Source Device", $r."Destination Device")) { if ($n -and $n -ne "N/A") { $null = $completedDevs.Add($n) } }
|
|
}
|
|
$toPing = @($completedDevs | Where-Object { -not $global:PingedThisCycle.Contains($_) } | Sort-Object)
|
|
Write-Host "`n[Presence Sweep: pinging $($toPing.Count) completed / Skip USMT device(s)]" -ForegroundColor Cyan
|
|
$i = 0
|
|
foreach ($n in $toPing) {
|
|
$i++
|
|
Update-WorkerState "Presence Sweep..." $i $toPing.Count $globalLastDuration "Calculating..."
|
|
$null = Test-Device $n -Session $session
|
|
}
|
|
# Completed rows pick up the fresh result (Skip USMT rows keep "Skipped" in the ping
|
|
# column; their presence shows in the device details and Custom View)
|
|
foreach ($r in $deviceTable) {
|
|
if ($r.Pairing -eq "Cached") { Set-RowPresence $r -Source -Destination }
|
|
}
|
|
$presenceCount = $toPing.Count
|
|
$global:LastPresenceSweep = Get-Date
|
|
Write-Host "[Presence Updated. Next presence sweep after $($global:LastPresenceSweep.AddMinutes($presenceMinutes).ToString('hh:mm tt')), or at midnight]" -ForegroundColor Green
|
|
}
|
|
$presenceSeconds = $phaseWatch.Elapsed.TotalSeconds
|
|
$phaseWatch.Restart()
|
|
|
|
Close-ProviderCycle $session
|
|
|
|
$activeDevs = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
|
foreach ($row in $deviceTable) {
|
|
if ($row."Source Device" -and $row."Source Device" -ne "N/A") { $null = $activeDevs.Add($row."Source Device") }
|
|
if ($row."Destination Device" -and $row."Destination Device" -ne "N/A") { $null = $activeDevs.Add($row."Destination Device") }
|
|
}
|
|
|
|
if ($v) { Write-Host " -> Pruning and Compressing Software Cache..." -ForegroundColor DarkGray }
|
|
if (Test-Path $softwareCachePath) {
|
|
try {
|
|
$rawCache = [System.IO.File]::ReadAllText($softwareCachePath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
|
$pruned = $false
|
|
foreach ($p in @($rawCache.psobject.properties.Name)) {
|
|
if (-not $activeDevs.Contains($p)) {
|
|
$rawCache.psobject.properties.Remove($p)
|
|
$pruned = $true
|
|
}
|
|
}
|
|
|
|
$jsonSoftCache = $rawCache | ConvertTo-Json -Depth 6 -Compress
|
|
[System.IO.File]::WriteAllText($softwareCachePath, $jsonSoftCache, [System.Text.Encoding]::UTF8)
|
|
if ($pruned -and $v) { Write-Host " -> Removed untracked devices from cache." -ForegroundColor Green }
|
|
} catch {}
|
|
}
|
|
|
|
# Presence changes every cycle, so the advanced cache (which carries it) is saved every cycle
|
|
try {
|
|
if ($v) { Write-Host " -> Pruning and Saving Advanced Info Cache..." -ForegroundColor DarkGray }
|
|
foreach ($key in @($global:AdvancedCache.Keys)) { if (-not $activeDevs.Contains($key)) { $global:AdvancedCache.Remove($key) } }
|
|
foreach ($key in @($global:Presence.Keys)) { if (-not $activeDevs.Contains($key)) { $global:Presence.Remove($key) } }
|
|
foreach ($key in @($global:Snapshots.Keys)) { if (-not $activeDevs.Contains($key)) { $global:Snapshots.Remove($key) } }
|
|
Save-AdvancedCache
|
|
Write-JsonFile $snapshotPath $global:Snapshots 4
|
|
$global:AdvancedCacheUpdated = $false
|
|
} catch { Write-Host "Could not save caches: $($_.Exception.GetType().Name)" -ForegroundColor Red }
|
|
|
|
if ($v) { Write-Host " -> Writing payload to Data.json..." -ForegroundColor DarkGray }
|
|
$globalLastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt")
|
|
Publish-Data $deviceTable
|
|
$saveSeconds = $phaseWatch.Elapsed.TotalSeconds
|
|
|
|
$cycleEnd = Get-Date
|
|
$cycleTime = $cycleEnd - $cycleStart
|
|
$globalLastDuration = if ($cycleTime.TotalMinutes -ge 1) { "$([math]::Round($cycleTime.TotalMinutes, 1))m" } else { "$([math]::Round($cycleTime.TotalSeconds, 1))s" }
|
|
|
|
Write-Host "`nCycle Complete in $globalLastDuration." -ForegroundColor Gray
|
|
|
|
# How the time was spent, per kind of row (shown in the dashboard's sync dialog)
|
|
$labels = [ordered]@{ SCCM = "SCCM associations"; Manual = "Manual pairs"; Cached = "Completed (snapshot)"; NoAssoc = "No association"; Skipped = "Skip USMT tags" }
|
|
$cats = @()
|
|
foreach ($k in $labels.Keys) {
|
|
$t = $timing[$k]
|
|
if ($t.Count -eq 0) { continue }
|
|
$cats += [PSCustomObject]@{ Key = $k; Label = $labels[$k]; Count = $t.Count; Seconds = [math]::Round($t.Ms / 1000, 1); AvgMs = [int]($t.Ms / $t.Count) }
|
|
if ($v) { Write-Host (" {0,-22} {1,4} device(s) {2,7:N1}s (avg {3} ms)" -f $labels[$k], $t.Count, ($t.Ms / 1000), [int]($t.Ms / $t.Count)) -ForegroundColor DarkGray }
|
|
}
|
|
$global:LastBreakdown = [PSCustomObject]@{
|
|
Finished = Get-IsoNow
|
|
Seconds = [math]::Round($cycleTime.TotalSeconds, 1)
|
|
Sources = $totalDevices
|
|
NewFirst = $newSources.Count
|
|
FullRefresh = $runFullSoftwareSweep
|
|
Categories = $cats
|
|
Phases = [PSCustomObject]@{ Software = [math]::Round($softwareSeconds, 1); Sweep = [math]::Round($sweepSeconds, 1); Presence = [math]::Round($presenceSeconds, 1); Save = [math]::Round($saveSeconds, 1) }
|
|
PresenceSweep = [PSCustomObject]@{ Ran = $presenceDue; Pinged = $presenceCount; Last = $(if ($global:LastPresenceSweep -gt [datetime]::MinValue) { $global:LastPresenceSweep.ToString("o") } else { $null }); EveryMinutes = $presenceMinutes }
|
|
}
|
|
|
|
$nextRun = Get-NextRunTime (Get-Date)
|
|
$nextRunStr = $nextRun.ToString("MM/dd/yy hh:mm tt")
|
|
|
|
Update-WorkerState "Cooldown" $totalDevices $totalDevices $globalLastDuration $nextRunStr
|
|
Write-Host "Next Cycle Scheduled For: $nextRunStr" -ForegroundColor DarkGray
|
|
|
|
while ((Get-Date) -lt $nextRun) {
|
|
if (Test-Path (Join-Path $dataDir "force-full.flag")) {
|
|
Remove-Item (Join-Path $dataDir "force-full.flag") -Force
|
|
$global:ForceFullPull = $true
|
|
Write-Host "`n[!] Force FULL Refresh triggered by Dashboard!" -ForegroundColor Cyan
|
|
break
|
|
}
|
|
if (Test-Path (Join-Path $dataDir "force-update.flag")) {
|
|
Remove-Item (Join-Path $dataDir "force-update.flag") -Force
|
|
Write-Host "`n[!] Force Update triggered by Dashboard!" -ForegroundColor Cyan
|
|
break
|
|
}
|
|
Start-Sleep -Seconds 1
|
|
}
|
|
Write-Host "`n"
|
|
}
|