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 $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" }