From 7868948bfc82987e0181d8f0e05a29a946e6080c Mon Sep 17 00:00:00 2001 From: Johan Sandoval Date: Tue, 6 Oct 2026 19:09:49 +0000 Subject: [PATCH] Presence, AD/SCCM/DNS reasons, monitors, snapshots, new-device priority Worker: pings every tracked device on a presence sweep (hourly in business hours plus midnight) and records last online; inventory reports whether a device was found in AD and SCCM; monitors from WmiMonitorID; snapshots of the last reachable state replace "Cached"; newly tracked devices are swept and published first; totals count source devices only; per-type timing in Worker-State. Front end: details dialog shows presence, reasons instead of N/A, "Waiting for worker...", monitors and snapshot times, and refreshes in place; Custom View gains Days Offline, Last Online, Found In and monitor columns; manual pair changes show immediately; sync dialog shows timing by type. Custom View gets a large scrollbar; "Exactly ticked" filter. --- Config/usmt-dash.psd1 | 18 ++ Providers/Demo.ps1 | 45 ++- Providers/SCCM.ps1 | 60 ++++ USMT-Worker.ps1 | 701 +++++++++++++++++++++++++++++------------- web/demo/demo.js | 47 ++- web/device-info.js | 242 +++++++++++++++ web/excel-filter.js | 14 +- web/index.html | 298 ++++++++++++------ web/layout.css | 60 +++- web/layout.js | 101 +++++- 10 files changed, 1230 insertions(+), 356 deletions(-) create mode 100644 web/device-info.js diff --git a/Config/usmt-dash.psd1 b/Config/usmt-dash.psd1 index a86a4d5..5ad570a 100644 --- a/Config/usmt-dash.psd1 +++ b/Config/usmt-dash.psd1 @@ -51,6 +51,11 @@ # A task-sequence log modified within this window counts as "active" ActiveLogHours = 24 + + # Every tracked device (including completed pairs and destinations the sweep skips) + # is pinged this often to keep "last online" / "days offline" current. Cycles only run + # in business hours plus the midnight pitstop, so 60 = hourly by day, plus midnight. + PresenceSweepMinutes = 60 } # ------------------------------------------------------------------------------------- @@ -99,6 +104,18 @@ @{ Label = 'Shared Space'; Column = 'SharedSpace0' } ) } + + # Attached monitors (count + model names). Needs the WmiMonitorID class enabled in + # hardware inventory (root\wmi). SCCM stores its string properties as comma-separated + # character codes, which the provider decodes. Set Enabled = $false if not collected. + Monitors = @{ + Enabled = $true + View = 'v_GS_WMIMONITORID' + NameColumn = 'UserFriendlyName0' + ManufacturerColumn = 'ManufacturerName0' + # Skip the built-in panel of laptops (instance names containing these) + InternalPattern = 'LGD|SDC|BOE|AUO|CMN|SHP' + } } # ------------------------------------------------------------------------------------- @@ -109,6 +126,7 @@ CycleSeconds = 45 # cooldown between cycles (business hours are ignored) DelayMsPerDevice = 35 # artificial per-device latency so the progress bar moves LifecycleHours = 24 # how long one simulated migration takes, start to finish + PresenceSweepMinutes = 5 # ping every device this often (Live default is hourly) Verbose = $true # run the worker with -v style tracing } diff --git a/Providers/Demo.ps1 b/Providers/Demo.ps1 index bace67c..0593e23 100644 --- a/Providers/Demo.ps1 +++ b/Providers/Demo.ps1 @@ -58,6 +58,7 @@ $script:DemoModels = @{ } $script:DemoSsdModels = @("KXG50ZNV256G TOSHIBA", "SAMSUNG MZVLB256HBHQ-000H1", "SK hynix PC611 HFS256GD9TNG", "Micron 2450 NVMe 512GB", "SAMSUNG MZVL2512HCJQ-00BH1", "KBG50ZNS512G KIOXIA") $script:DemoMounts = @("Desktop Bundle", "Monitor Arm", "Under Desk", "Wall Mount", "Desktop Bundle", "Monitor Arm") +$script:DemoMonitors = @("HP E24 G5", "HP E27 G5", "HP P24h G5", "DELL P2422H", "DELL P2723DE", "LEN T24i-30", "LEN T27h-30", "LG 27UK850") $script:DemoNotes = @("N/A", "N/A", "N/A", "N/A", "Front counter", "Dual monitors", "Desk 3", "Shared with night shift", "Badge printer attached", "Label printer attached") # Specialty apps: one or more ARP entries each (multi-entry apps exercise the "suite" rules) @@ -202,6 +203,11 @@ function Get-DemoProfile([string]$name) { $p.SrcFree = if ((Get-DemoRand $s 'lowdisk') -lt 0.08) { 4.0 + 12 * (Get-DemoRand $s 'freeL') } else { 30.0 + 150 * (Get-DemoRand $s 'free') } $p.Apps = @($p.Department.Apps | Where-Object { (Get-DemoRand $s "app$_") -lt 0.65 }) if ($p.Apps.Count -eq 0) { $p.Apps = @($p.Department.Apps[0]) } + # A few old devices have been unplugged / powered off for days (not while they're mid-migration) + $p.OfflineDays = if ((Get-DemoRand $s 'longoff') -lt 0.07) { Get-DemoInt $s 'offdays' 1 21 } else { 0 } + # Monitors at the desk move to the replacement, so both share the source's set + $monCount = if ($p.Laptop) { Get-DemoPick $s 'mons' @(0, 1, 1, 2) } else { Get-DemoPick $s 'mons' @(1, 2, 2, 2, 3) } + $p.Monitors = @(1..3 | Select-Object -First $monCount | ForEach-Object { Get-DemoPick $s "mon$(if ((Get-DemoRand $s 'monmatch') -lt 0.7) { 1 } else { $_ })" $script:DemoMonitors }) # Facts specific to this physical device $n = $key @@ -214,6 +220,8 @@ function Get-DemoProfile([string]$name) { $p.DiskModel = Get-DemoPick $n 'ssd' $script:DemoSsdModels $p.DiskSerial = (1..8 | ForEach-Object { "{0:X4}" -f (Get-DemoInt $n "dsn$_" 0 65535) }) -join '_' $p.Inactive = (Get-DemoRand $n 'inactive') -lt 0.04 + $p.NoSccm = (-not $p.IsDest) -and (Get-DemoRand $n 'nosccm') -lt 0.025 # broken client, never reported + $p.NoAd = (-not $p.IsDest) -and (Get-DemoRand $n 'noad') -lt 0.012 # computer object deleted } $script:DemoProfiles[$key] = $p return $p @@ -247,8 +255,9 @@ function Get-DemoNetworkState([string]$computer) { if ($fault -lt 0.03) { return "DNS Issue" } $stage = (Get-DemoStage $p).Name + if (-not $p.IsDest -and $p.OfflineDays -gt 0 -and $stage -in @('NotStarted', 'Completed')) { return "Timed Out" } if ($p.IsDest) { - if ($stage -eq 'NotStarted' -and (Get-DemoRand $p.Name 'staged') -lt 0.5) { return "Not Found" } # not imaged yet + if (Test-DemoNotImaged $p) { return "Not Found" } # not imaged yet: no DNS record if ($stage -eq 'RestoreInProgress') { return "Online" } } else { if ($stage -eq 'BackupInProgress') { return "Online" } @@ -261,6 +270,11 @@ function Get-DemoNetworkState([string]$computer) { return "Timed Out" } +# A replacement that hasn't been imaged yet isn't in DNS, AD or SCCM +function Test-DemoNotImaged([hashtable]$p) { + return $p.IsDest -and (Get-DemoStage $p).Name -eq 'NotStarted' -and (Get-DemoRand $p.Name 'staged') -lt 0.5 +} + function Start-DemoLatency([int]$scale = 1) { $ms = [int]$script:DemoCfg.DelayMsPerDevice * $scale if ($ms -gt 0) { Start-Sleep -Milliseconds $ms } @@ -412,7 +426,9 @@ function Get-DeviceAssociations { function Get-DeviceInventory { param([string]$computerName, $Session) $p = Get-DemoProfile $computerName - if (-not $p.Valid) { return @{} } + if (-not $p.Valid) { return @{ InAD = $false; InSCCM = $false } } # a typo: nothing knows it + if (Test-DemoNotImaged $p) { return @{ InAD = $false; InSCCM = $false } } + $flags = @{ InAD = -not $p.NoAd; InSCCM = -not $p.NoSccm } $vis = if ($p.Public) { "Public" } else { "Private" } $form = if ($p.Laptop) { "Laptops" } else { "Desktops" } @@ -432,7 +448,12 @@ function Get-DeviceInventory { $updated = (Get-Date).AddDays(-1 * (Get-DemoInt $p.Source 'upd' 20 1400)) if ($p.IsDest) { $updated = (Get-Date).AddDays(-1 * (Get-DemoInt $p.Name 'upd' 1 30)) } - return @{ + $monitors = @($p.Monitors) + $inv = @{ + InAD = $true + InSCCM = $true + MonitorCount = "$($monitors.Count)" + Monitors = if ($monitors.Count) { $monitors -join "
" } else { "None" } OU = "Company Devices/$vis/$form" Groups = $groups -join "
" MAC = $p.Mac @@ -455,6 +476,24 @@ function Get-DeviceInventory { SCCMPrimaryUsers = $uda Disk = "Size: $($p.DiskSize) GB
Model: $($p.DiskModel)
S/N: $($p.DiskSerial)." } + # What a missing AD object / SCCM record would take away + if (-not $flags.InAD) { $inv.InAD = $false; foreach ($k in @("OU", "Groups")) { $inv.Remove($k) } } + if (-not $flags.InSCCM) { + $inv.InSCCM = $false + foreach ($k in @("MAC", "Model", "RAM", "InventoryIP", "LastUser", "ClientStatus", "Updated", "Location", "Vanity", "Department", "Building", "Mount", "PrimaryUser", "PhoneExt", "InfoBy", "Notes", "RoomType", "SCCMPrimaryUsers", "Disk", "MonitorCount", "Monitors")) { $inv.Remove($k) } + } + return $inv +} + +# Client's last activity, used the first time a device is tracked and doesn't answer +function Get-DeviceLastSeen { + param([string]$computerName, $Session) + $p = Get-DemoProfile $computerName + if (-not $p.Valid -or (Test-DemoNotImaged $p) -or $p.NoSccm) { return $null } + if (-not $p.IsDest -and $p.OfflineDays -gt 0) { + return (Get-Date).AddDays(-$p.OfflineDays).AddHours(-(Get-DemoInt $p.Name 'offhrs' 0 9)) + } + return (Get-Date).AddMinutes(-(Get-DemoInt $p.Name "seen$([math]::Floor((Get-DemoNow) / 600))" 5 55)) } function Get-InstalledSoftware { diff --git a/Providers/SCCM.ps1 b/Providers/SCCM.ps1 index c40c2e2..5a26712 100644 --- a/Providers/SCCM.ps1 +++ b/Providers/SCCM.ps1 @@ -13,6 +13,7 @@ # Initialize-Provider, Open-ProviderCycle, Close-ProviderCycle, pingDevice, # Resolve-DeviceIP, Get-DiskSpace, Get-LogInfo, Get-DeviceAssociations, # Get-DeviceInventory, Get-InstalledSoftware +# and optionally Get-DeviceLastSeen. # ========================================================================================= $script:SccmCfg = $config.SCCM @@ -254,10 +255,13 @@ function Get-DeviceInventory { $searcher = $null try { + # Only letters, digits and - _ . can reach the LDAP filter + if ($computerName -notmatch '^[A-Za-z0-9._-]+$') { throw "invalid name" } $searcher = [adsisearcher]"(&(objectCategory=computer)(name=$computerName))" $searcher.PropertiesToLoad.Add("distinguishedname") | Out-Null $searcher.PropertiesToLoad.Add("memberof") | Out-Null $adResult = $searcher.FindOne() + $res.InAD = [bool]$adResult # the lookup worked, so "no result" really means "not in AD" if ($adResult) { $dn = $adResult.Properties["distinguishedname"][0] @@ -308,7 +312,9 @@ function Get-DeviceInventory { $reader = $cmd.ExecuteReader() $assetExists = $false + $res.InSCCM = $false if ($reader.Read()) { + $res.InSCCM = $true if ($reader["MAC_Addresses0"] -isnot [DBNull] -and $reader["MAC_Addresses0"]) { $res.MAC = $reader["MAC_Addresses0"] } if ($reader["Model0"] -isnot [DBNull] -and $reader["Model0"]) { $res.Model = $reader["Model0"] } if ($reader["TotalPhysicalMemory0"] -isnot [DBNull] -and $reader["TotalPhysicalMemory0"]) { @@ -395,9 +401,63 @@ function Get-DeviceInventory { } catch {} # --------------------------- + # --- Monitors (WmiMonitorID hardware inventory) --- + $mon = $script:SccmCfg.Monitors + if ($mon -and $mon.Enabled -and (Test-SqlName $mon.View) -and (Test-SqlName $mon.NameColumn) -and (Test-SqlName $mon.ManufacturerColumn)) { + try { + if ($SqlConn -and $SqlConn.State -eq 'Open') { + $monCmd = $SqlConn.CreateCommand() + $monCmd.CommandText = @" + SELECT m.[$($mon.NameColumn)] AS MonName, m.[$($mon.ManufacturerColumn)] AS MonMfr + FROM v_R_System sys + INNER JOIN $($mon.View) m ON sys.ResourceID = m.ResourceID + WHERE sys.Name0 = @MonCompName +"@ + $monCmd.Parameters.AddWithValue("@MonCompName", $computerName) | Out-Null + $monReader = $monCmd.ExecuteReader() + $names = @() + while ($monReader.Read()) { + $name = ConvertFrom-WmiCharCodes $monReader["MonName"] + $mfr = ConvertFrom-WmiCharCodes $monReader["MonMfr"] + if ($mon.InternalPattern -and $mfr -match $mon.InternalPattern -and -not $name) { continue } # laptop panel + $names += $(if ($name) { $name } elseif ($mfr) { "$mfr (unnamed)" } else { "Unknown monitor" }) + } + $monReader.Dispose(); $monCmd.Dispose() + $res.MonitorCount = "$($names.Count)" + $res.Monitors = if ($names.Count -gt 0) { $names -join "
" } else { "None" } + } + } catch {} + } + return $res } +# WmiMonitorID strings are uint16 arrays; SCCM inventories them as "72,80,32,69,50,52,0,0" +function ConvertFrom-WmiCharCodes($value) { + if ($null -eq $value -or $value -is [DBNull]) { return "" } + $text = "$value".Trim() + if ($text -match '^\d+(\s*,\s*\d+)*$') { + $text = -join ($text -split '\s*,\s*' | Where-Object { [int]$_ -gt 0 } | ForEach-Object { [char][int]$_ }) + } + return $text.Trim() +} + +# When a device is tracked for the first time and doesn't answer a ping, "last online" is +# backfilled from the client's last activity (heartbeat / policy / status messages). +function Get-DeviceLastSeen { + param([string]$computerName, $Session) + $SqlConn = $Session.Sql + if (-not $SqlConn -or $SqlConn.State -ne 'Open') { return $null } + $cmd = $SqlConn.CreateCommand() + try { + $cmd.CommandText = "SELECT TOP 1 ch.LastActiveTime FROM v_R_System sys INNER JOIN v_CH_ClientSummary ch ON sys.ResourceID = ch.ResourceID WHERE sys.Name0 = @SeenName" + $cmd.Parameters.AddWithValue("@SeenName", $computerName) | Out-Null + $val = $cmd.ExecuteScalar() + if ($val -and $val -isnot [DBNull]) { return ([datetime]$val).ToLocalTime() } + } catch {} finally { $cmd.Dispose() } + return $null +} + # ----------------------------------------------------------------------------------------- # Installed software (used by Get-Software.ps1) # ----------------------------------------------------------------------------------------- diff --git a/USMT-Worker.ps1 b/USMT-Worker.ps1 index d3de305..a3e5047 100755 --- a/USMT-Worker.ps1 +++ b/USMT-Worker.ps1 @@ -22,10 +22,17 @@ $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. +$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 { "" }) @@ -42,6 +49,8 @@ if (Test-Path $softwareCachePath) { $globalLastUpdated = "Never" $globalNextRun = "Calculating..." $globalLastDuration = "N/A" +$global:LastBreakdown = $null +$global:LastPresenceSweep = [datetime]::MinValue # --- FUNCTIONS --- function Update-WorkerState { @@ -54,7 +63,8 @@ function Update-WorkerState { LastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt") NextCycle = $nextRun LastDuration = $duration - } | ConvertTo-Json -Compress + LastCycle = $global:LastBreakdown + } | ConvertTo-Json -Compress -Depth 5 [System.IO.File]::WriteAllText($statePath, $json, [System.Text.Encoding]::UTF8) } catch {} @@ -79,10 +89,176 @@ function Set-LogInfoLine { 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") } + +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) { + $json = ConvertTo-Json -InputObject $obj -Depth $depth -Compress + [System.IO.File]::WriteAllText($path, $json, [System.Text.Encoding]::UTF8) +} + # --- 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 +$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 + } + } +} + +# 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 + Write-JsonFile $presencePath $global:Presence 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 @@ -98,7 +274,7 @@ if (Test-Path $advancedCachePath) { 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" } + $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 @@ -107,6 +283,9 @@ function Get-AdvancedData { $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) @@ -125,6 +304,13 @@ function Get-AdvancedData { 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 @@ -180,7 +366,7 @@ while ($true) { $cycleStart = Get-Date Write-Host "`n--- Cycle Start: $($cycleStart.ToString('hh:mm:ss tt')) ---" -ForegroundColor White - Update-WorkerState "Preparing..." 0 0 "N/A" "Calculating..." + Update-WorkerState "Preparing..." 0 0 $globalLastDuration "Calculating..." $session = Open-ProviderCycle @@ -242,10 +428,38 @@ while ($true) { } } catch {} } - $uniqueDevices = $deviceTags.Keys | Sort-Object - $totalDevices = @($uniqueDevices).Count + $uniqueDevices = @($deviceTags.Keys | Sort-Object) - Update-WorkerState "Software Check..." 0 $totalDevices "N/A" "Calculating..." + # 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") @@ -265,7 +479,9 @@ while ($true) { $cachedNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) if ($cacheObj) { foreach ($p in $cacheObj.psobject.properties) { $null = $cachedNames.Add($p.Name) } } - foreach ($dev in $uniqueDevices) { + # 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 @@ -273,15 +489,21 @@ while ($true) { } } 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 - Write-Host "--- Commencing Data Sweep ($totalDevices Devices) ---" -ForegroundColor White + 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 } - foreach ($deviceName in $uniqueDevices) { + foreach ($deviceName in $sourceDevices) { $currentDeviceCount++ - Update-WorkerState "Updating Devices..." $currentDeviceCount $totalDevices "N/A" "Calculating..." + $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 ", " @@ -291,57 +513,39 @@ while ($true) { $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 - $deviceTable.Add([PSCustomObject]@{ - "Source Device"=$deviceName; "Destination Device"="N/A"; - "Tags"=$tags; "Source Total Space"="N/A"; "Source Free Space"="N/A"; - "USMT Status"="Skipped"; "Backup Status"="Skipped"; - "Source Online"="Skipped"; "Destination Online"="-"; - "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"; - }) - continue + $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) } - # ----------------------------------------------------------------- - - if ($manualPairs.ContainsKey($deviceName)) { + elseif ($manualPairs.ContainsKey($deviceName)) { + $category = "Manual" $dName = $manualPairs[$deviceName] $sName = $deviceName if ($v) { Write-Host " -> [MANUAL MATCH] Hardware exception detected. Bypassing SCCM..." -ForegroundColor DarkYellow } - $pingSrc = pingDevice $sName - $pingDst = pingDevice $dName + $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 $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session - $null = Get-AdvancedData $dName -IsDest -OnlineStatus $pingDst -ForceUpdate:$runFullSoftwareSweep -Session $session - - $deviceTable.Add([PSCustomObject]@{ - "Source Device"=$sName; "Destination Device"=$dName; - "Tags"=$tags; "Source Total Space"="N/A"; "Source Free Space"="N/A"; - "USMT Status"="N/A"; "Backup Status"="N/A"; - "Source Online"=$pingSrc; "Destination Online"=$pingDst; - "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"; - }) - continue # Move on to the next device + $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 - # Prevent the destination machine from being swept a second time as a standalone source - if ($manualPairs.Values -contains $deviceName -and -not $manualPairs.ContainsKey($deviceName)) { - if ($v) { Write-Host " -> [MANUAL DEST] Device handled by pair logic. Skipping independent sweep." -ForegroundColor DarkGray } - continue - } - - if ($v) { Write-Host " -> Querying SCCM for associations..." -ForegroundColor DarkGray } - $associations = Get-DeviceAssociations -SourceComputer $deviceName - - if (-not $associations) { - $cached = $completedList | Where-Object { $_."Source Device".ToUpper() -eq $deviceName } | Select-Object -First 1 - if ($cached) { - if ($v) { Write-Host " -> [CACHE HIT] But association was removed from SCCM." -ForegroundColor DarkYellow } + 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")" @@ -353,179 +557,223 @@ while ($true) { } } - $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 + $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 - $deviceTable.Add([PSCustomObject]@{ - "Source Device"=$cached."Source Device"; "Destination Device"=$cached."Destination Device"; - "Tags"=$tags; "Source Total Space"="Cached"; "Source Free Space"="Cached"; - "USMT Status"="Not Associated"; "Backup Status"="Completed"; - "Source Online"="Cached"; "Destination Online"="Cached"; - "Source Scanstate Line"="Cached"; "Destination Loadstate Line"="Cached"; - "Source Scanstate File"="Cached"; "Destination Loadstate File"="Cached" - "Source Scanstate Elapsed"="Cached"; "Destination Loadstate Elapsed"="Cached"; - }) - continue - } + $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 } - 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" } - $pingSrc = pingDevice $deviceName - $logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null } - $srcTotal = "N/A" - $srcFree = "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 - if ($pingSrc -eq "Online") { - # --- DISK SKIP LIST CHECK (No-Association path) --- - if ($diskSkipList -notcontains $deviceName) { - $diskInfo = Get-DiskSpace $deviceName - $srcTotal = $diskInfo.Total - $srcFree = $diskInfo.Free - } else { - if ($v) { Write-Host " > Disk check skipped for $deviceName (on diskSkipList)" -ForegroundColor DarkYellow } + $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) } - if ($diskSkipList -notcontains $deviceName -and $srcFree -ne "N/A") { $logSrc = Get-LogInfo $deviceName $scanLogName } } + else { + foreach ($assoc in $associations) { + $assoc.Get() + $dName = if ($assoc.DestinationComputer) { $assoc.DestinationComputer } else { $assoc.RestoreName } + $sName = if ($assoc.SourceComputer) { $assoc.SourceComputer } else { $deviceName } - $null = Get-AdvancedData $deviceName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session + 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) + } + } - $deviceTable.Add([PSCustomObject]@{ - "Source Device"=$deviceName; "Destination Device"="N/A"; - "Tags"=$tags; "Source Total Space"=$srcTotal; "Source Free Space"=$srcFree; - "USMT Status"="Not Associated"; "Backup Status"="Not Associated"; - "Source Online"=$pingSrc; "Destination Online"="-"; - "Source Scanstate Line"=$logSrc.Line; "Destination Loadstate Line"="N/A"; - "Source Scanstate File"=$logSrc.Path; "Destination Loadstate File"="N/A" - "Source Scanstate Elapsed"=$logSrc.Elapsed; "Destination Loadstate Elapsed"="N/A"; - }) - continue + $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) + } + } + } } - foreach ($assoc in $associations) { - $assoc.Get() - $dName = if ($assoc.DestinationComputer) { $assoc.DestinationComputer } else { $assoc.RestoreName } - $sName = if ($assoc.SourceComputer) { $assoc.SourceComputer } else { $deviceName } + $null = $processedSources.Add($deviceName) + $timing[$category].Count++ + $timing[$category].Ms += $deviceWatch.Elapsed.TotalMilliseconds - 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) { - 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 - - $deviceTable.Add([PSCustomObject]@{ - "Source Device"=$sName; "Destination Device"=$dName; - "Tags"=$tags; "Source Total Space"="Cached"; "Source Free Space"="Cached"; - "USMT Status"=$usmtStatusStr; "Backup Status"="Completed"; - "Source Online"="Cached"; "Destination Online"="Cached"; - "Source Scanstate Line"="Cached"; "Destination Loadstate Line"="Cached"; - "Source Scanstate File"="Cached"; "Destination Loadstate File"="Cached" - "Source Scanstate Elapsed"="Cached"; "Destination Loadstate Elapsed"="Cached"; - }) - continue - } - - $row = [ordered]@{ - "Source Device"=$sName; "Destination Device"=$dName; "Tags"=$tags; - "Source Total Space"="N/A"; "Source Free Space"="N/A"; - "USMT Status"=$usmtStatusStr; "Backup Status"="Checking..."; - "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"; - } - - if ($v) { Write-Host " -> [SWEEP SOURCE] $sName" -ForegroundColor DarkCyan } - $pingSrc = pingDevice $sName - $row."Source Online" = $pingSrc - $logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null } - - 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 - } 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 = pingDevice $dName - $row."Destination Online" = $pingDst - $logDst = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingDst)"; Path = "Unreachable"; LastModified = $null } - - 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" } - - 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) - } + # Newly tracked devices are done: publish them now rather than at the end of the cycle + if ($newSources.Count -gt 0 -and $knownSources.Count -gt 0 -and $currentDeviceCount -eq $newSources.Count) { + Write-Host " -> [PRIORITY] $($newSources.Count) new device(s) swept. Publishing early..." -ForegroundColor Green + $interim = [System.Collections.Generic.List[object]]::new() + foreach ($r in $deviceTable) { $interim.Add($r) } + foreach ($dev in $knownSources) { if ($previousRows.ContainsKey($dev)) { $interim.Add($previousRows[$dev]) } } + Save-AdvancedCache + Write-JsonFile $jsonPath ([PSCustomObject]@{ LastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt"); Devices = $interim }) } } + $sweepSeconds = $phaseWatch.Elapsed.TotalSeconds + $phaseWatch.Restart() + + # --- PRESENCE SWEEP: ping everything the sweep didn't (cached pairs, skipped tags...) --- + $presenceCount = 0 + $presenceDue = ((Get-Date) - $global:LastPresenceSweep).TotalMinutes -ge ($presenceMinutes - 0.5) -or $runFullSoftwareSweep + if ($presenceDue) { + $everyone = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($r in $deviceTable) { + foreach ($n in @($r."Source Device", $r."Destination Device")) { if ($n -and $n -ne "N/A") { $null = $everyone.Add($n) } } + } + $toPing = @($everyone | Where-Object { -not $global:PingedThisCycle.Contains($_) } | Sort-Object) + Write-Host "`n[Presence Sweep: $($toPing.Count) device(s) not pinged this cycle]" -ForegroundColor Cyan + $i = 0 + foreach ($n in $toPing) { + $i++ + Update-WorkerState "Presence Sweep..." $i $toPing.Count $globalLastDuration "Calculating..." + $null = Test-Device $n -Session $session + } + # Cached / skipped rows pick up the fresh result + 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]" -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 - $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") } - } - $pruned = $false foreach ($p in @($rawCache.psobject.properties.Name)) { if (-not $activeDevs.Contains($p)) { @@ -537,28 +785,27 @@ while ($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 } - - if ($global:AdvancedCacheUpdated -or $pruned -or $runFullSoftwareSweep) { - if ($v) { Write-Host " -> Pruning and Saving Advanced Info Cache..." -ForegroundColor DarkGray } - $keysToRemove = @() - foreach ($key in $global:AdvancedCache.Keys) { - if (-not $activeDevs.Contains($key)) { $keysToRemove += $key } - } - foreach ($key in $keysToRemove) { $global:AdvancedCache.Remove($key) } - - $jsonAdvCache = $global:AdvancedCache | ConvertTo-Json -Depth 5 -Compress - [System.IO.File]::WriteAllText($advancedCachePath, $jsonAdvCache, [System.Text.Encoding]::UTF8) - $global:AdvancedCacheUpdated = $false - } } 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") $exportPayload = [PSCustomObject]@{ LastUpdated = $globalLastUpdated; Devices = $deviceTable } $jsonFinal = $exportPayload | ConvertTo-Json -Depth 6 [System.IO.File]::WriteAllText($jsonPath, $jsonFinal, [System.Text.Encoding]::UTF8) + $saveSeconds = $phaseWatch.Elapsed.TotalSeconds $cycleEnd = Get-Date $cycleTime = $cycleEnd - $cycleStart @@ -566,6 +813,26 @@ while ($true) { 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") diff --git a/web/demo/demo.js b/web/demo/demo.js index 27a9b6d..783291b 100644 --- a/web/demo/demo.js +++ b/web/demo/demo.js @@ -342,9 +342,9 @@ return { tagMap, manualPairs, skipTags }; } - function blankRow(src, dst, tags) { + function blankRow(src, dst, tags, pairing) { return { - "Source Device": src, "Destination Device": dst, "Tags": tags, + "Source Device": src, "Destination Device": dst, "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", @@ -376,9 +376,17 @@ if (a.IP && a.IP !== 'N/A') { const p = a.IP.split('.'); a.IP = `${p[0]}.${p[1]}.${10 + Math.floor(rand(name, 'ip3') * 240)}.${10 + Math.floor(rand(name, 'ip4') * 240)}`; } if (a.Disk) a.Disk = a.Disk.replace(/S\/N: [^<]*$/, 'S/N: ' + hex(name + 'sn', 8).join('').replace(/(.{4})/g, '$1_').replace(/_$/, '.')); for (const k of ['LastUser', 'SCCMPrimaryUsers']) if (typeof a[k] === 'string') a[k] = renameIn(a[k], templateName, name).replace(templateName.toLowerCase(), name.toLowerCase()); + a.FirstChecked = new Date().toISOString(); return a; } + // What the worker records for a name nothing knows (a typo): not in AD, SCCM or DNS + function unknownAdv() { + const now = new Date().toISOString(); + return { IP: "N/A", MAC: "N/A", OU: "N/A", Groups: "N/A", Model: "N/A", RAM: "N/A", Disk: "N/A", ClientStatus: "N/A", MonitorCount: "N/A", Monitors: "N/A", + RoomType: "N/A", InAD: false, InSCCM: false, InDNS: false, Fetched: now, LastChecked: now, LastPing: "Not Found", FirstChecked: now, LastOnline: null }; + } + // Builds the Data.json rows the worker would produce for a roster, plus synthetic // advanced/software entries for devices the fleet doesn't know. function buildRows(tracked, serverRows, serverAdv) { @@ -393,7 +401,7 @@ const srow = bySrc[dev]; if (tagMap[dev].some(t => skipTags.has(t))) { - const r = blankRow(dev, "N/A", tags); + const r = blankRow(dev, "N/A", tags, "Skipped"); Object.assign(r, { "USMT Status": "Skipped", "Backup Status": "Skipped", "Source Online": "Skipped", "Destination Online": "-" }); out.push(r); if (!serverAdv[dev]) { const t = pickTemplate(dev, serverRows); if (t && SCHEME.test(dev)) { adv[dev] = synthAdv(dev, t["Source Device"], serverAdv); software[dev] = t["Source Device"]; } } @@ -403,12 +411,14 @@ if (manualPairs[dev]) { const dst = manualPairs[dev]; if (srow && srow["Destination Device"] === dst && srow["USMT Status"] === "N/A") { out.push(Object.assign(clone(srow), { Tags: tags })); continue; } - const r = blankRow(dev, dst, tags); - for (const [n, col] of [[dev, "Source Online"], [dst, "Destination Online"]]) { + const r = blankRow(dev, dst, tags, "Manual"); + const now = new Date().toISOString(); + for (const [n, col] of [[dev, "Source"], [dst, "Destination"]]) { const t = SCHEME.test(n) ? pickTemplate(n, serverRows) : null; - r[col] = t ? t["Source Online"] : "Not Found"; + r[col + " Online"] = t ? t["Source Online"] : "Not Found"; + r[col + " Checked"] = now; if (t && !serverAdv[n]) { adv[n] = synthAdv(n, t["Source Device"], serverAdv); software[n] = t["Source Device"]; } - else if (!t) software[n] = null; + else if (!t) { software[n] = null; if (!serverAdv[n]) adv[n] = unknownAdv(); } } out.push(r); continue; @@ -420,11 +430,12 @@ const t = SCHEME.test(dev) ? pickTemplate(dev, serverRows) : null; if (!t) { // Not in the fleet and not a valid name: what the worker reports for a typo - const r = blankRow(dev, "N/A", tags); + const r = blankRow(dev, "N/A", tags, "None"); Object.assign(r, { "USMT Status": "Not Associated", "Backup Status": "Not Associated", "Source Online": "Not Found", "Destination Online": "-", - "Source Scanstate Line": "Unreachable (Not Found)", "Source Scanstate File": "Unreachable" }); + "Source Scanstate Line": "Unreachable (Not Found)", "Source Scanstate File": "Unreachable", "Source Checked": new Date().toISOString() }); out.push(r); software[dev] = null; // SCCM has no inventory for it: an empty list, same as Get-Software.ps1 + if (!serverAdv[dev]) adv[dev] = unknownAdv(); continue; } const tSrc = t["Source Device"], tDst = t["Destination Device"], dst = synthDest(dev); @@ -506,7 +517,7 @@ return W; } add([' -> Querying SCCM for associations...', 'DarkGray']); - if (r["Source Online"] === "Cached") { + if (r["Pairing"] === "Cached" || r["Source Online"] === "Cached") { add([' -> SCCM DB Status: Completed (Raw: 2)', 'DarkGray']); add([' -> [CACHE HIT] Fast-tracking row and bypassing network sweeps.', 'Green']); return W; @@ -564,7 +575,7 @@ const before = new Set(Object.keys(parseRoster(applied).tagMap)); const { rows } = buildRows(tracked, snap.rows, snap.adv); const { tagMap } = parseRoster(tracked); - const total = Object.keys(tagMap).length; + const total = rows.length; // source devices only, like the worker const started = new Date(); const state = (status, cur) => { Sim.worker = { Status: status, Current: cur, Total: total }; }; @@ -582,11 +593,14 @@ } w([['[Software Cache Synchronized]', 'Green']]); w([]); - w([[`--- Commencing Data Sweep (${total} Devices) ---`, 'White']]); + // Devices the visitor just added are swept first, like the worker does for new devices + const fresh = rows.filter(r => !before.has(r["Source Device"])); + const ordered = fresh.concat(rows.filter(r => before.has(r["Source Device"]))); + w([[`--- Commencing Data Sweep (${total} Devices${fresh.length ? `, ${fresh.length} new first` : ''}) ---`, 'White']]); const delay = Math.max(15, Math.min(90, 9000 / Math.max(1, rows.length))); let i = 0; - for (const r of rows) { + for (const r of ordered) { i++; state('Updating Devices...', i); const src = r["Source Device"]; @@ -594,6 +608,13 @@ w([[` -> Tags: [${r.Tags}]`, 'DarkCyan']]); for (const segs of deviceLines(r, !before.has(src), full)) w(segs); await sleep(delay); + if (fresh.length && i === fresh.length && i < ordered.length) { + w([[` -> [PRIORITY] ${fresh.length} new device(s) swept. Publishing early...`, 'Green']]); + if (!s.tracked) await ensureOwnRoster(); + s.appliedTracked = clone(tracked); + s.rev++; + Store.save(); + } } w([[' -> Pruning and Compressing Software Cache...', 'DarkGray']]); diff --git a/web/device-info.js b/web/device-info.js new file mode 100644 index 0000000..ce1337d --- /dev/null +++ b/web/device-info.js @@ -0,0 +1,242 @@ +/* + * USMT Dash - device info helpers + * + * Shared by the details dialog, Custom View, the tables and the Overview page: + * - Advanced fields say WHY a value is missing ("Not in AD", "Not in SCCM", "No DNS record") + * instead of a bare N/A, and "Waiting for worker..." when the worker hasn't looked yet. + * - Presence: last time a device answered a ping and how long it has been offline. + * - Snapshot markers: values the worker saved while a device was reachable are shown with + * a clock icon and the time they were captured. + */ +(function () { + 'use strict'; + + const WAITING = 'Waiting for worker...'; + const AD_FIELDS = new Set(['OU', 'Groups']); + // Fields computed here rather than stored by the worker (also offered as Custom View columns) + const COMPUTED = { + DaysOffline: 'Days Offline', + LastOnline: 'Last Online', + LastChecked: 'Last Ping Check', + FoundIn: 'Found In (AD / SCCM / DNS)' + }; + // Internal bookkeeping fields that aren't useful as columns + const HIDDEN = new Set(['InAD', 'InSCCM', 'InDNS', 'Fetched', 'LastPing', 'FirstChecked', 'LastOnlineSource']); + + const cache = () => (typeof advancedDataCache !== 'undefined' && advancedDataCache) ? advancedDataCache : {}; + const isName = n => !!n && n !== 'N/A' && n !== '---'; + const empty = v => v === undefined || v === null || v === '' || v === 'N/A'; + + function entry(name) { + if (!isName(name)) return null; + const c = cache(); + return c[name] || c[String(name).toUpperCase()] || null; + } + + function toDate(iso) { + if (!iso) return null; + const d = new Date(iso); + return isNaN(d) ? null : d; + } + + function fmtWhen(iso) { + const d = toDate(iso); + if (!d) return ''; + const sameYear = d.getFullYear() === new Date().getFullYear(); + return d.toLocaleString(undefined, { month: 'short', day: 'numeric', year: sameYear ? undefined : 'numeric', hour: 'numeric', minute: '2-digit' }); + } + + function ago(iso) { + const d = toDate(iso); + if (!d) return ''; + const s = Math.max(0, (Date.now() - d.getTime()) / 1000); + if (s < 60) return 'just now'; + if (s < 3600) return Math.floor(s / 60) + ' min ago'; + if (s < 86400) return Math.floor(s / 3600) + ' h ago'; + const days = Math.floor(s / 86400); + return days + (days === 1 ? ' day ago' : ' days ago'); + } + + // Why a field is empty, from the worker's lookup flags (null = no particular reason) + function missingReason(a, key) { + const noAD = a.InAD === false, noSCCM = a.InSCCM === false, noDNS = a.InDNS === false; + if (noAD && noSCCM && noDNS) return 'Not in AD, SCCM or DNS'; + if (AD_FIELDS.has(key)) return noAD ? 'Not in AD' : null; + if (key === 'IP') { + if (noDNS && noSCCM) return 'No DNS record, not in SCCM'; + if (noDNS) return 'No DNS record'; + return null; + } + return noSCCM ? 'Not in SCCM' : null; + } + + // { text, state } where state is value | waiting | missing | na + function field(name, key) { + if (!isName(name)) return { text: 'N/A', state: 'na' }; + if (COMPUTED[key]) return computed(name, key); + const a = entry(name); + if (!a) return { text: WAITING, state: 'waiting' }; + const v = a[key]; + // the worker's default room type isn't real data when SCCM has no record + if (key === 'RoomType' && a.InSCCM === false && v === 'Standard') return { text: missingReason(a, key), state: 'missing' }; + if (!empty(v)) return { text: String(v), state: 'value' }; + const why = missingReason(a, key); + return why ? { text: why, state: 'missing' } : { text: 'N/A', state: 'na' }; + } + + // --------------------------------------------------------------------------------- + // Presence + // --------------------------------------------------------------------------------- + function presence(name) { + const a = entry(name); + if (!a || !a.LastChecked) return null; + const last = toDate(a.LastOnline); + const online = a.LastPing === 'Online'; + const days = online ? 0 : (last ? (Date.now() - last.getTime()) / 86400000 : null); + return { online, last, lastIso: a.LastOnline, checked: a.LastChecked, ping: a.LastPing, first: a.FirstChecked, days, source: a.LastOnlineSource }; + } + + // Whole days offline (0 when online or offline less than a day), null if never seen + function daysOffline(name) { + const p = presence(name); + if (!p) return undefined; + if (p.online) return 0; + if (p.days === null) return null; + return Math.floor(p.days); + } + + function offlineText(name) { + if (!isName(name)) return 'N/A'; + const p = presence(name); + if (!p) return WAITING; + if (p.online) return 'Online now'; + if (!p.last) return 'Never seen online (tracked since ' + fmtWhen(p.first) + ')'; + const hours = (Date.now() - p.last.getTime()) / 3600000; + if (hours < 1) return 'Offline less than an hour'; + if (hours < 24) return 'Offline ' + Math.floor(hours) + ' h'; + const d = Math.floor(hours / 24); + return d + (d === 1 ? ' day' : ' days') + ' offline'; + } + + function computed(name, key) { + const a = entry(name); + if (!a) return { text: WAITING, state: 'waiting' }; + if (key === 'DaysOffline') { + const d = daysOffline(name); + if (d === undefined) return { text: WAITING, state: 'waiting' }; + if (d === null) return { text: 'Never seen', state: 'value' }; + if (d === 0) return { text: presence(name).online ? 'Online' : '0 days', state: 'value' }; + return { text: d + (d === 1 ? ' day' : ' days'), state: 'value' }; + } + if (key === 'LastOnline') { + const p = presence(name); + if (!p) return { text: WAITING, state: 'waiting' }; + if (p.online) return { text: 'Online now', state: 'value' }; + return p.last ? { text: fmtWhen(p.lastIso), state: 'value' } : { text: 'Never seen', state: 'value' }; + } + if (key === 'LastChecked') { + return a.LastChecked ? { text: fmtWhen(a.LastChecked) + ' (' + (a.LastPing || '?') + ')', state: 'value' } : { text: WAITING, state: 'waiting' }; + } + if (key === 'FoundIn') { + if (a.InAD === undefined && a.InSCCM === undefined && a.InDNS === undefined) return { text: WAITING, state: 'waiting' }; + const yes = [], no = []; + [['AD', a.InAD], ['SCCM', a.InSCCM], ['DNS', a.InDNS]].forEach(([n, v]) => { if (v === true) yes.push(n); else if (v === false) no.push(n); }); + if (!no.length) return { text: 'AD, SCCM, DNS', state: 'value' }; + return { text: 'Missing from ' + no.join(', '), state: 'missing' }; + } + return { text: 'N/A', state: 'na' }; + } + + // True when AD, SCCM and DNS all said "never heard of it" (not imaged yet, or a typo) + function unknownEverywhere(name) { + const a = entry(name); + return !!a && a.InAD === false && a.InSCCM === false && a.InDNS === false; + } + + // HTML for a details-dialog cell + function html(name, key) { + const f = field(name, key); + if (f.state === 'waiting') return `${f.text}`; + if (f.state === 'missing') return `${f.text}`; + return f.text; + } + + function presenceHtml(name) { + if (!isName(name)) return 'N/A'; + const p = presence(name); + if (!p) return `${WAITING}`; + const txt = offlineText(name); + const cls = p.online ? 'di-online' : (p.days !== null && p.days >= 3 ? 'di-offline-long' : 'di-offline'); + let sub = ''; + if (!p.online && p.last) sub = `Last online ${fmtWhen(p.lastIso)}${p.source === 'Inventory' ? ' (from client activity)' : ''}
`; + sub += `Last checked ${fmtWhen(p.checked)} (${ago(p.checked)})`; + return `${txt}
${sub}`; + } + + // --------------------------------------------------------------------------------- + // Snapshot / staleness markers + // --------------------------------------------------------------------------------- + const CLOCK = ''; + + // side: 'Source' | 'Destination' + function snapshotMark(row, side) { + const at = row && row[side + ' Snapshot']; + if (!at) return ''; + const why = row['Backup Status'] === 'Completed' ? 'migration completed' : 'device unreachable'; + return ` ${CLOCK}`; + } + + // Ping results older than 15 minutes (completed pairs are only re-pinged by the presence sweep) + function pingMark(row, side) { + const at = row && row[side + ' Checked']; + const d = toDate(at); + if (!d || Date.now() - d.getTime() < 15 * 60000) return ''; + return ` ${CLOCK}`; + } + + function snapshotLine(row, side) { + const at = row && row[side + ' Snapshot']; + if (!at) return ''; + return `
${CLOCK.replace('`; + } + + // Software inventory timestamp for a device (Software-Cache.json entries carry one) + window.DeviceInfo = { + WAITING, COMPUTED, HIDDEN, + entry, field, html, unknownEverywhere, presence, presenceHtml, daysOffline, offlineText, + snapshotMark, snapshotLine, pingMark, fmtWhen, ago, isName + }; +})(); + +// Data Synchronization dialog: per-type timing of the last cycle (SCCM associations vs +// manual pairs vs completed snapshots...), the phases, and the presence sweep schedule. +(function () { + 'use strict'; + const esc = v => String(v).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const secs = s => (s >= 60 ? (s / 60).toFixed(1) + ' min' : (+s).toFixed(1) + ' s'); + const per = ms => (ms >= 1000 ? (ms / 1000).toFixed(1) + ' s' : ms + ' ms'); + const HINT = { + SCCM: 'Association lookup, ping + disk + log on both devices', + Manual: 'No SCCM lookup: ping both devices only', + Cached: 'Completed: shown from its snapshot, no network checks', + NoAssoc: 'No association: source checked on its own', + Skipped: 'Inventory only, no network checks' + }; + window.DeviceInfo.syncBreakdownHtml = function (state) { + const c = state && state.LastCycle; + if (!c || !Array.isArray(c.Categories)) return ''; + const rows = c.Categories.map(x => `${esc(x.Label)}${x.Count}${secs(x.Seconds)}${per(x.AvgMs)}`).join(''); + const ph = c.Phases || {}; + const ps = c.PresenceSweep || {}; + let presence = ''; + if (ps.Last) { + const next = new Date(new Date(ps.Last).getTime() + (ps.EveryMinutes || 60) * 60000); + presence = `Presence sweep: last ${DeviceInfo.fmtWhen(ps.Last)}${ps.Ran ? ` (pinged ${ps.Pinged} more)` : ''}, next after ${next.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}`; + } + return ` +
Last cycle by type ${[c.NewFirst ? `${c.NewFirst} new device(s) swept first` : '', c.FullRefresh ? 'full refresh' : ''].filter(Boolean).join(' · ')}
+ ${rows}
TypeDevicesTimeAvg
+
Software ${secs(ph.Software || 0)} · Sweep ${secs(ph.Sweep || 0)} · Presence ${secs(ph.Presence || 0)} · Save ${secs(ph.Save || 0)}
+ ${presence ? `
${presence}
` : ''}`; + }; +})(); diff --git a/web/excel-filter.js b/web/excel-filter.js index 4ab3b02..f5b5e69 100644 --- a/web/excel-filter.js +++ b/web/excel-filter.js @@ -7,8 +7,8 @@ * The value list only shows values from rows that pass the OTHER columns' filters, filters * on several columns combine (AND), and a filtered column's button shows a funnel. * List-type columns (Tags, Missing/Specialty Software) are split so each tag or app is - * its own checkbox; a row matches if it has ANY of the ticked items, or with "Has only - * ticked" if it has nothing BUT ticked items. Free-space columns ("78 GB / 221 GB") filter + * its own checkbox; a row matches if it has ANY of the ticked items, or with "Exactly + * ticked" if its items are exactly the ticked set (no more, no fewer). Free-space columns ("78 GB / 221 GB") filter * by disk size plus a free-space threshold, and sort by disk size or by free space. * * It plugs into the dashboard's existing filter passes (applyFilters, filterSoftwareTab, @@ -166,7 +166,11 @@ if (allowed.free.op === 'ge' ? sp.free < allowed.free.gb : sp.free >= allowed.free.gb) return false; } const vals = cellValues(td, col); - if (col.list && allowed.only) { if (!vals.every(v => allowed.has(v))) return false; } + if (col.list && allowed.only) { + // exact set: the row's items are precisely the ticked ones, no more, no fewer + const own = new Set(vals); + if (own.size !== allowed.size || !vals.every(v => allowed.has(v))) return false; + } else if (!vals.some(v => allowed.has(v))) return false; } return true; @@ -472,7 +476,7 @@ let onlyToggle = null; if (col.list) { const seg = el('div', 'xf-seg'); - seg.title = 'Any: devices with at least one ticked item. Only: devices with nothing except ticked items.'; + seg.title = 'Any: devices with at least one ticked item. Exactly: devices whose list is exactly the ticked items, nothing more and nothing less.'; const mk = (text, only) => { const b = el('button', 'xf-seg-btn', text); b.type = 'button'; b.onclick = () => { seg.querySelectorAll('.xf-seg-btn').forEach(x => x.classList.toggle('on', x === b)); }; @@ -481,7 +485,7 @@ return b; }; mk('Has any ticked', false); - onlyToggle = mk('Has only ticked', true); + onlyToggle = mk('Exactly ticked', true); menu.appendChild(seg); } diff --git a/web/index.html b/web/index.html index 28921e9..617a590 100755 --- a/web/index.html +++ b/web/index.html @@ -196,11 +196,11 @@ .sort-icon.active { color: white; font-weight: bold; } /* ===== Custom View responsive table ===== */ - .custom-scroll-area { overflow: auto; } - #custom { min-width: 0; max-width: 100%; width: 100%; overflow-x: hidden; box-sizing: border-box; } - #custom * { min-width: 0; } - #wrapper-custom { min-width: 0; max-width: 100%; width: 100%; overflow: hidden; box-sizing: border-box; } - #scroll-custom { max-width: 100%; overflow: auto; max-height: calc(100vh - 60px - 50px - 210px); } + .custom-scroll-area { overflow: auto; } + #custom { min-width: 0; max-width: 100%; width: 100%; overflow-x: hidden; box-sizing: border-box; } + #custom * { min-width: 0; } + #wrapper-custom { min-width: 0; max-width: 100%; width: 100%; overflow: hidden; box-sizing: border-box; } + #scroll-custom { max-width: 100%; overflow: auto; max-height: calc(100vh - 60px - 50px - 210px); } #custom-table-host { max-width: none; } #wrapper-custom.has-extra .custom-scroll-area thead th { position: sticky; top: 0; z-index: 20; box-shadow: inset 0 -2px 0 var(--border-light); } /* Header: bordered drag-grip strip on top; label left; icons grouped far right */ @@ -226,10 +226,10 @@ #table-custom .custom-th .col-remove { color: var(--danger, #e74c3c); font-weight: bold; cursor: pointer; opacity: 0; transition: opacity 0.15s; font-size: 1em; } #table-custom .custom-th:hover .col-remove { opacity: 1; } /* Full-height resize handles (overlay), active anywhere along the column border */ - #custom-resize-overlay { z-index: 40; } + #custom-resize-overlay { z-index: 40; } #custom-resize-overlay .rz-handle { position: absolute; top: 0; width: 9px; height: 100%; cursor: col-resize; pointer-events: auto; transform: translateX(-50%); z-index: 40; } #custom-resize-overlay .rz-handle::after { content: ""; position: absolute; left: 50%; top: 0; width: 1px; height: 100%; background: transparent; transform: translateX(-50%); } - #table-custom .custom-spacer-th, #table-custom .custom-spacer-td { border-right: none !important; border-left: none !important; background: var(--bg-card); } + #table-custom .custom-spacer-th, #table-custom .custom-spacer-td { border-right: none !important; border-left: none !important; background: var(--bg-card); } #table-custom thead th.no-copy:not(.custom-th) { border-left: none; } #wrapper-custom.has-extra #table-custom tbody tr:hover { filter: brightness(1.08); } #wrapper-custom.has-extra #table-custom td, #wrapper-custom.has-extra #table-custom th { border-right: 1px solid var(--border-light); } @@ -353,6 +353,7 @@ + @@ -662,6 +663,7 @@
Waiting for server response...
Last Cycle Duration: N/A
+
@@ -2607,6 +2609,7 @@ } async function fetchSoftware(name) { + window.currentSoftwareDevice = name; document.getElementById('modal').style.display = 'block'; document.getElementById('modalTitle').innerHTML = `Software: ${name} ${inlineCopyBtn(name)}`; @@ -2641,7 +2644,7 @@ const newJsonString = JSON.stringify(data); if (newJsonString !== cachedJsonString) { - drawModalTables(data, false, name); + if (window.currentSoftwareDevice === name) drawModalTables(data, false, name); softwareCache[name] = data; pullData(); } @@ -2789,6 +2792,7 @@ if (!match) return; window.currentDetailsMatch = match; + window.currentDetailsKey = match["Source Device"]; document.getElementById('detailsModal').style.display = 'block'; renderDetailsHTML(); } @@ -2867,25 +2871,48 @@ const tdStyle = `style="white-space: normal; word-wrap: break-word; overflow-wrap: break-word; padding: 10px;"`; const scrollBoxStyle = `style="flex: 1; overflow-y: auto; padding: 5px; border: 1px solid var(--border-light); border-radius: 4px;"`; - let advSrc = advancedDataCache[match["Source Device"]] || {}; - let advDst = advancedDataCache[match["Destination Device"]] || {}; - - const getAdv = (obj, key) => obj[key] ? obj[key] : 'N/A'; + const srcName = match["Source Device"], dstName = match["Destination Device"]; + const hasDst = DeviceInfo.isName(dstName); + // Advanced fields explain a blank: "Not in AD", "Not in SCCM", "No DNS record", or + // "Waiting for worker..." when the worker hasn't looked the device up yet + const getAdv = (name, key) => DeviceInfo.html(name, key); + const hwSpecs = name => { + const m = DeviceInfo.field(name, "Model"), r = DeviceInfo.field(name, "RAM"); + if (m.state !== "value" && r.state !== "value") return getAdv(name, "Model"); + return `${getAdv(name, "Model")}
${r.state === "value" ? r.text + " RAM" : getAdv(name, "RAM")}`; + }; + const monitorsCell = name => { + const c = DeviceInfo.field(name, "MonitorCount"); + if (c.state !== "value") return getAdv(name, "MonitorCount"); + const list = DeviceInfo.field(name, "Monitors"); + return `${c.text}${list.state === "value" && list.text !== "None" ? "
" + list.text : ""}`; + }; + const pairing = match["Pairing"] === "Manual" ? "Manual pair (no SCCM association)" + : match["Pairing"] === "Skipped" ? "Skip USMT tag (inventory only)" + : match["Pairing"] === "None" ? "No SCCM association" + : match["Pairing"] === "Cached" ? "SCCM association (completed, network checks skipped)" + : match["Pairing"] === "SCCM" ? "SCCM association" : null; + const elapsed = e => (e && e !== "N/A" && e !== "Cached") ? '
Elapsed Time: ' + e : ''; + const space = match["Source Free Space"] && match["Source Free Space"] !== "N/A" && match["Source Free Space"] !== "Cached" + ? match["Source Free Space"] + ' / ' + match["Source Total Space"] : (match["Source Free Space"] || "N/A"); let mainRows = `
${window.isAdvView ? `` : ''} - - - - - - - + + + + + + + + ${hasDst ? `` : ''} + - + + ${window.isAdvView ? '' : ``}
Migration Overview
Source Device${match["Source Device"]} ${inlineCopyBtn(match["Source Device"])} ${srcAppBtn}
Source Ping${match["Source Online"]}
Space Available${match["Source Free Space"] !== "N/A" ? match["Source Free Space"] + ' / ' + match["Source Total Space"] : "N/A"}
Scanstate Line${match["Source Scanstate Line"]}${(match["Source Scanstate Elapsed"] && match["Source Scanstate Elapsed"] !== "N/A" && match["Source Scanstate Elapsed"] !== "Cached") ? '
Elapsed Time: ' + match["Source Scanstate Elapsed"] : ''}
Dest Device${match["Destination Device"]} ${inlineCopyBtn(match["Destination Device"])} ${dstAppBtn}
Dest Ping${match["Destination Online"]}
Loadstate Line${match["Destination Loadstate Line"]}${(match["Destination Loadstate Elapsed"] && match["Destination Loadstate Elapsed"] !== "N/A" && match["Destination Loadstate Elapsed"] !== "Cached") ? '
Elapsed Time: ' + match["Destination Loadstate Elapsed"] : ''}
Source Device${srcName} ${inlineCopyBtn(srcName)} ${srcAppBtn}
Source Ping${match["Source Online"]}${DeviceInfo.pingMark(match, "Source")}
Source Presence${DeviceInfo.presenceHtml(srcName)}
Space Available${space}${match["Source Space Checked"] && !match["Source Snapshot"] ? `
Checked ${DeviceInfo.fmtWhen(match["Source Space Checked"])}` : ''}${DeviceInfo.snapshotLine(match, "Source")}
Scanstate Line${match["Source Scanstate Line"]}${elapsed(match["Source Scanstate Elapsed"])}${DeviceInfo.snapshotLine(match, "Source")}
Dest Device${dstName} ${inlineCopyBtn(dstName)} ${dstAppBtn}
Dest Ping${match["Destination Online"]}${DeviceInfo.pingMark(match, "Destination")}
Dest Presence${DeviceInfo.presenceHtml(dstName)}
Loadstate Line${match["Destination Loadstate Line"]}${elapsed(match["Destination Loadstate Elapsed"])}${DeviceInfo.snapshotLine(match, "Destination")}
Backup Status${match["Backup Status"]}
Specialty Software${specialtySoftware}
USMT Status${match["USMT Status"]}
USMT Status${match["USMT Status"]}${pairing ? `
${pairing}` : ''}
Monitors${monitorsCell(srcName)}
Tags${match["Tags"] || 'None'}
@@ -2898,21 +2925,25 @@
- - - - - - - - - - - - - - - + + + + ${DeviceInfo.unknownEverywhere(srcName) ? `` : ` + + + + + + + + + + + + + + + `}
Source Info
Device Name${match["Source Device"]} ${inlineCopyBtn(match["Source Device"])}
Source IP / MAC${getAdv(advSrc, "IP")}
${getAdv(advSrc, "MAC")}
Source OU${getAdv(advSrc, "OU")}
Source Groups${getAdv(advSrc, "Groups")}
Hardware Specs${getAdv(advSrc, "Model")}
${(getAdv(advSrc, "RAM") !== 'N/A') ? getAdv(advSrc, "RAM") + ' RAM' : 'N/A'}
Physical Disk${getAdv(advSrc, "Disk")}
SCCM Client${getAdv(advSrc, "ClientStatus")}
Dept / Bldg${getAdv(advSrc, "Department")} / ${getAdv(advSrc, "Building")}
Location / Vanity${getAdv(advSrc, "Location")} / ${getAdv(advSrc, "Vanity")}
Phone / MountExt: ${getAdv(advSrc, "PhoneExt")}
${getAdv(advSrc, "Mount")}
Room Type${getAdv(advSrc, "RoomType")}
Users (Pri / Last)${getAdv(advSrc, "PrimaryUser")} / ${getAdv(advSrc, "LastUser")}
Primary Users${getAdv(advSrc, "SCCMPrimaryUsers")}
Asset Info Entered${getAdv(advSrc, "Updated")}
(By: ${getAdv(advSrc, "InfoBy")})
Notes${getAdv(advSrc, "Notes")}
Device Name${srcName} ${inlineCopyBtn(srcName)}
Found In${getAdv(srcName, "FoundIn")}
Presence${DeviceInfo.presenceHtml(srcName)}
Not in AD, SCCM or DNS.
Nothing in the directory, the site database or DNS knows this name yet: a replacement that hasn't been imaged, or a typo in the tag.
Source IP / MAC${getAdv(srcName, "IP")}
${getAdv(srcName, "MAC")}
Source OU${getAdv(srcName, "OU")}
Source Groups${getAdv(srcName, "Groups")}
Hardware Specs${hwSpecs(srcName)}
Physical Disk${getAdv(srcName, "Disk")}
Monitors${monitorsCell(srcName)}
SCCM Client${getAdv(srcName, "ClientStatus")}
Dept / Bldg${getAdv(srcName, "Department")} / ${getAdv(srcName, "Building")}
Location / Vanity${getAdv(srcName, "Location")} / ${getAdv(srcName, "Vanity")}
Phone / MountExt: ${getAdv(srcName, "PhoneExt")}
${getAdv(srcName, "Mount")}
Room Type${getAdv(srcName, "RoomType")}
Users (Pri / Last)${getAdv(srcName, "PrimaryUser")} / ${getAdv(srcName, "LastUser")}
Primary Users${getAdv(srcName, "SCCMPrimaryUsers")}
Asset Info Entered${getAdv(srcName, "Updated")}
(By: ${getAdv(srcName, "InfoBy")})
Notes${getAdv(srcName, "Notes")}
`; @@ -2921,21 +2952,25 @@
- - - - - - - - - - - - - - - + + + + ${DeviceInfo.unknownEverywhere(dstName) ? `` : ` + + + + + + + + + + + + + + + `}
Destination Info
Device Name${match["Destination Device"]} ${inlineCopyBtn(match["Destination Device"])}
Dest IP / MAC${getAdv(advDst, "IP")}
${getAdv(advDst, "MAC")}
Dest OU${getAdv(advDst, "OU")}
Dest Groups${getAdv(advDst, "Groups")}
Hardware Specs${getAdv(advDst, "Model")}
${(getAdv(advDst, "RAM") !== 'N/A') ? getAdv(advDst, "RAM") + ' RAM' : 'N/A'}
Physical Disk${getAdv(advDst, "Disk")}
SCCM Client${getAdv(advDst, "ClientStatus")}
Dept / Bldg${getAdv(advDst, "Department")} / ${getAdv(advDst, "Building")}
Location / Vanity${getAdv(advDst, "Location")} / ${getAdv(advDst, "Vanity")}
Phone / MountExt: ${getAdv(advDst, "PhoneExt")}
${getAdv(advDst, "Mount")}
Room Type${getAdv(advDst, "RoomType")}
Users (Pri / Last)${getAdv(advDst, "PrimaryUser")} / ${getAdv(advDst, "LastUser")}
Primary Users${getAdv(advDst, "SCCMPrimaryUsers")}
Asset Info Entered${getAdv(advDst, "Updated")}
(By: ${getAdv(advDst, "InfoBy")})
Notes${getAdv(advDst, "Notes")}
Device Name${dstName} ${inlineCopyBtn(dstName)}
Found In${getAdv(dstName, "FoundIn")}
Presence${DeviceInfo.presenceHtml(dstName)}
Not in AD, SCCM or DNS.
Nothing in the directory, the site database or DNS knows this name yet: a replacement that hasn't been imaged, or a typo in the tag.
Dest IP / MAC${getAdv(dstName, "IP")}
${getAdv(dstName, "MAC")}
Dest OU${getAdv(dstName, "OU")}
Dest Groups${getAdv(dstName, "Groups")}
Hardware Specs${hwSpecs(dstName)}
Physical Disk${getAdv(dstName, "Disk")}
Monitors${monitorsCell(dstName)}
SCCM Client${getAdv(dstName, "ClientStatus")}
Dept / Bldg${getAdv(dstName, "Department")} / ${getAdv(dstName, "Building")}
Location / Vanity${getAdv(dstName, "Location")} / ${getAdv(dstName, "Vanity")}
Phone / MountExt: ${getAdv(dstName, "PhoneExt")}
${getAdv(dstName, "Mount")}
Room Type${getAdv(dstName, "RoomType")}
Users (Pri / Last)${getAdv(dstName, "PrimaryUser")} / ${getAdv(dstName, "LastUser")}
Primary Users${getAdv(dstName, "SCCMPrimaryUsers")}
Asset Info Entered${getAdv(dstName, "Updated")}
(By: ${getAdv(dstName, "InfoBy")})
Notes${getAdv(dstName, "Notes")}
`; @@ -3225,13 +3260,15 @@ Department: "Department", Building: "Building", Location: "Location", Vanity: "Vanity", PhoneExt: "Phone Ext", Mount: "Mount", RoomType: "Room Type", PrimaryUser: "Primary User", LastUser: "Last User", SCCMPrimaryUsers: "SCCM Primary Users", - Updated: "Asset Info Updated", InfoBy: "Info Entered By", Notes: "Notes" + Updated: "Asset Info Updated", InfoBy: "Info Entered By", Notes: "Notes", + MonitorCount: "Monitor Count", Monitors: "Monitors", + DaysOffline: "Days Offline", LastOnline: "Last Online", LastChecked: "Last Ping Check", FoundIn: "Found In (AD/SCCM/DNS)" }; - const ADV_FIELD_ORDER = ["IP","MAC","OU","Groups","Model","RAM","Disk","ClientStatus","Department","Building","Location","Vanity","PhoneExt","Mount","RoomType","PrimaryUser","LastUser","SCCMPrimaryUsers","Updated","InfoBy","Notes"]; + const ADV_FIELD_ORDER = ["DaysOffline","LastOnline","LastChecked","FoundIn","IP","MAC","OU","Groups","Model","RAM","Disk","MonitorCount","Monitors","ClientStatus","Department","Building","Location","Vanity","PhoneExt","Mount","RoomType","PrimaryUser","LastUser","SCCMPrimaryUsers","Updated","InfoBy","Notes"]; let customColumns = JSON.parse(localStorage.getItem('usmt_custom_columns')) || [...CUSTOM_DEFAULT_COLS]; // ordered list of column ids - let customColWidths = JSON.parse(localStorage.getItem('usmt_custom_widths')) || {}; // id -> px width - let customWidthsLocked = localStorage.getItem('usmt_custom_widths_locked') === '1'; // true once user manually resizes + let customColWidths = JSON.parse(localStorage.getItem('usmt_custom_widths')) || {}; // id -> px width + let customWidthsLocked = localStorage.getItem('usmt_custom_widths_locked') === '1'; // true once user manually resizes const MIN_COL_W = 110; // minimum column width (fits the header icon row) // Per-column filter: { text:"", excluded:[values] }. Excluded stored as arrays in localStorage. let customFilters = JSON.parse(localStorage.getItem('usmt_custom_filters')) || {}; // id -> {text, excluded:[]} @@ -3270,7 +3307,7 @@ function getAdvKeys() { let keys = new Set(ADV_FIELD_ORDER); Object.values(advancedDataCache || {}).forEach(obj => { - if (obj && typeof obj === 'object') Object.keys(obj).forEach(k => keys.add(k)); + if (obj && typeof obj === 'object') Object.keys(obj).forEach(k => { if (!DeviceInfo.HIDDEN.has(k)) keys.add(k); }); }); return Array.from(keys).sort((a, b) => { let ia = ADV_FIELD_ORDER.indexOf(a), ib = ADV_FIELD_ORDER.indexOf(b); @@ -3393,8 +3430,8 @@ function getCustomCellValue(row, col) { const blank = v => (v === undefined || v === null || v === "") ? "N/A" : v; if (col.kind === "apiKey") return blank(row[col.key]); - if (col.kind === "adv") return blank((advancedDataCache[row["Source Device"]] || {})[col.key]); - if (col.kind === "advDst") return blank((advancedDataCache[row["Destination Device"]] || {})[col.key]); + if (col.kind === "adv") return DeviceInfo.field(row["Source Device"], col.key).text; + if (col.kind === "advDst") return DeviceInfo.field(row["Destination Device"], col.key).text; if (col.kind === "special") return getCustomSpecialty(row["Source Device"]); if (col.kind === "migration") { if (col.fn === "space") return (row["Source Free Space"] && row["Source Free Space"] !== "N/A") ? row["Source Free Space"] + " / " + row["Source Total Space"] : "N/A"; @@ -3404,35 +3441,35 @@ return "N/A"; } - // Returns {td-inner-html, cellStyle} for a column, replicating All Devices styling. - function renderCustomCell(row, col, val) { - const safe = String(val).replace(/"/g, '"'); - // Ping columns: colored + bold - if (col.id === "__srcping" || col.id === "__destping") { - return { html: `${val}`, style: `color:${getPingColor(val)}; font-weight:bold;` }; - } - // Device names: bold + inline copy - if (col.kind === "apiKey" && (col.key === "Source Device" || col.key === "Destination Device")) { - return { html: `${val}${inlineCopyBtn(String(val))}`, style: "" }; - } - // Backup / USMT status: bold - if (col.key === "Backup Status" || col.id === "__usmt") { - return { html: `${val}`, style: "" }; - } - // Specialty Software: hyperlink any app that has documentation - if (col.kind === "special") { - if (!val || val === "None" || val === "Loading...") return { html: val, style: "" }; - const linked = val.split(", ").map(app => { - const m = availableDocs.find(d => d.toLowerCase() === app.toLowerCase()); - return m - ? `${app}` - : app; - }); - return { html: linked.join(", "), style: "" }; - } - return { html: safe, style: "" }; - } - + // Returns {td-inner-html, cellStyle} for a column, replicating All Devices styling. + function renderCustomCell(row, col, val) { + const safe = String(val).replace(/"/g, '"'); + // Ping columns: colored + bold + if (col.id === "__srcping" || col.id === "__destping") { + return { html: `${val}`, style: `color:${getPingColor(val)}; font-weight:bold;` }; + } + // Device names: bold + inline copy + if (col.kind === "apiKey" && (col.key === "Source Device" || col.key === "Destination Device")) { + return { html: `${val}${inlineCopyBtn(String(val))}`, style: "" }; + } + // Backup / USMT status: bold + if (col.key === "Backup Status" || col.id === "__usmt") { + return { html: `${val}`, style: "" }; + } + // Specialty Software: hyperlink any app that has documentation + if (col.kind === "special") { + if (!val || val === "None" || val === "Loading...") return { html: val, style: "" }; + const linked = val.split(", ").map(app => { + const m = availableDocs.find(d => d.toLowerCase() === app.toLowerCase()); + return m + ? `${app}` + : app; + }); + return { html: linked.join(", "), style: "" }; + } + return { html: safe, style: "" }; + } + function renderCustomTable() { const headerRow = document.getElementById('custom-header-row'); const tbody = document.getElementById('tbody-custom'); @@ -4095,22 +4132,22 @@ const dstPingStatus = row["Destination Online"] || "N/A"; - const spaceStr = (row["Source Free Space"] !== "N/A" && row["Source Free Space"]) ? - `${row["Source Free Space"]} / ${row["Source Total Space"]}` : "N/A"; + const spaceStr = (row["Source Free Space"] !== "N/A" && row["Source Free Space"] !== "Cached" && row["Source Free Space"]) ? + `${row["Source Free Space"]} / ${row["Source Total Space"]}` : (row["Source Free Space"] || "N/A"); const destAppsBtn = (row["Destination Device"] && row["Destination Device"] !== "N/A") ? `` : `---`; rowsHtml += ` ${row["Source Device"]}${inlineCopyBtn(row["Source Device"])} - ${srcPingStatus} + ${srcPingStatus}${DeviceInfo.pingMark(row, "Source")} - ${(g === "Backup In Progress" && row["Source Scanstate Elapsed"] && row["Source Scanstate Elapsed"] !== "N/A" && row["Source Scanstate Elapsed"] !== "Cached") ? row["Source Scanstate Elapsed"] + " " + String(row["Source Scanstate Line"]).trim().split(/\s+/).pop() : row["Source Scanstate Line"]} - ${spaceStr} + ${(g === "Backup In Progress" && row["Source Scanstate Elapsed"] && row["Source Scanstate Elapsed"] !== "N/A" && row["Source Scanstate Elapsed"] !== "Cached") ? row["Source Scanstate Elapsed"] + " " + String(row["Source Scanstate Line"]).trim().split(/\s+/).pop() : row["Source Scanstate Line"]}${DeviceInfo.snapshotMark(row, "Source")} + ${spaceStr}${DeviceInfo.snapshotMark(row, "Source")} ${row["Destination Device"]}${inlineCopyBtn(row["Destination Device"])} - ${dstPingStatus} + ${dstPingStatus}${DeviceInfo.pingMark(row, "Destination")} ${destAppsBtn} - ${(g === "Restore In Progress" && row["Destination Loadstate Elapsed"] && row["Destination Loadstate Elapsed"] !== "N/A" && row["Destination Loadstate Elapsed"] !== "Cached") ? row["Destination Loadstate Elapsed"] + " " + String(row["Destination Loadstate Line"]).trim().split(/\s+/).pop() : row["Destination Loadstate Line"]} + ${(g === "Restore In Progress" && row["Destination Loadstate Elapsed"] && row["Destination Loadstate Elapsed"] !== "N/A" && row["Destination Loadstate Elapsed"] !== "Cached") ? row["Destination Loadstate Elapsed"] + " " + String(row["Destination Loadstate Line"]).trim().split(/\s+/).pop() : row["Destination Loadstate Line"]}${DeviceInfo.snapshotMark(row, "Destination")} ${row["USMT Status"] || "N/A"} ${row.Tags || 'None'} ${COPY_SVG} @@ -4267,7 +4304,7 @@ document.getElementById('syncProgressFill').style.backgroundColor = "#27ae60"; if (state.LastDuration && state.LastDuration !== "N/A") { - document.getElementById('syncDurationText').innerText = `Last cycle finished in ${state.LastDuration} with ${state.Total} devices`; + document.getElementById('syncDurationText').innerText = `Last cycle finished in ${state.LastDuration} with ${state.Total} source devices`; } forceBtn.disabled = false; @@ -4276,19 +4313,23 @@ let percentage = 0; if (state.Total > 0) percentage = Math.round((state.Current / state.Total) * 100); document.getElementById('syncStatusText').innerText = "Live Sync in Progress..."; - document.getElementById('syncDetailsText').innerText = `${state.Status}: Device ${state.Current} of ${state.Total}`; + document.getElementById('syncDetailsText').innerText = state.Status === "Presence Sweep..." + ? `Presence sweep: pinging device ${state.Current} of ${state.Total}` + : `${state.Status}: Device ${state.Current} of ${state.Total}`; document.getElementById('syncProgressFill').style.width = `${percentage}%`; document.getElementById('syncProgressFill').innerText = `${percentage}%`; document.getElementById('syncProgressFill').style.backgroundColor = "#3498db"; if (state.LastDuration && state.LastDuration !== "N/A") { - document.getElementById('syncDurationText').innerText = `Previous cycle took ${state.LastDuration} with ${state.Total} devices`; + document.getElementById('syncDurationText').innerText = `Previous cycle took ${state.LastDuration}${state.LastCycle ? ` with ${state.LastCycle.Sources} source devices` : ''}`; } forceBtn.disabled = true; forceFullBtn.disabled = true; } + document.getElementById('syncBreakdown').innerHTML = DeviceInfo.syncBreakdownHtml(state); + // Check if Tracked-Devices was updated by you or a teammate const trackRes = await fetch('Tracked-Devices.json?t=' + Date.now()).catch(() => null); let currentTrackedHash = ""; @@ -4359,6 +4400,18 @@ }); } + // A manual pair whose destination changed (or a device that became / stopped being a + // manual pair) drops its stale row right away; a "Waiting for Worker..." row for + // the new pairing is added below until the worker sweeps it + apiData = apiData.filter(row => { + let src = (row["Source Device"] || "").toUpperCase(); + let dest = (row["Destination Device"] || "").toUpperCase(); + let isManualRow = row["Pairing"] ? row["Pairing"] === "Manual" : (row["USMT Status"] === "N/A" && row["Backup Status"] === "N/A" && dest !== "N/A"); + if (manualPairs[src]) return isManualRow && dest === manualPairs[src]; + if (isManualRow && !manualPairs[src]) return false; + return true; + }); + // Surgically strip old devices from apiData before the backend worker finishes purging them apiData = apiData.filter(row => { let src = (row["Source Device"] || "").toUpperCase(); @@ -4397,13 +4450,14 @@ apiData.push({ "Source Device": dev, "Destination Device": destDev, + "Pairing": isManual ? "Manual" : undefined, "Tags": tags.join(", "), "Source Total Space": placeholder, "Source Free Space": placeholder, "USMT Status": placeholder, "Backup Status": status, - "Source Online": placeholder, - "Destination Online": "Waiting for Worker...", + "Source Online": "Waiting for Worker...", + "Destination Online": isManual ? "Waiting for Worker..." : "-", "Source Scanstate Line": placeholder, "Destination Loadstate Line": placeholder }); @@ -4415,6 +4469,7 @@ renderMasterList(); renderSoftwareTable(); // <-- Forces the Software tab to update instantly! renderCustomTable(); + refreshOpenDialogs(); // 3. LAZY LOAD SOFTWARE LAST fetch('Software-Cache.json?t=' + Date.now()) @@ -4433,8 +4488,11 @@ } else if (entry && entry.Data) { rawData = Array.isArray(entry.Data) ? entry.Data : [entry.Data]; } - if (rawData && !softwareCache[device]) { + // Re-read an entry whenever the worker refreshed it (nightly sweep, new device) + const stamp = (entry && entry.Timestamp) ? String(entry.Timestamp) : (rawData ? String(rawData.length) : ''); + if (rawData && (!softwareCache[device] || softwareStamps[device] !== stamp)) { softwareCache[device] = rawData.filter(s => s && s.Name && s.Name.trim() !== '' && s.Name.trim() !== '--'); + softwareStamps[device] = stamp; cacheUpdated = true; } } @@ -4442,7 +4500,8 @@ myStacks.forEach(s => refreshStackTable(s.id)); renderMasterList(); renderSoftwareTable(); - renderDetailsHTML(); + renderCustomTable(); + refreshOpenDialogs(true); } } }).catch(() => null); @@ -4450,6 +4509,45 @@ } catch (e) {} } + // Open dialogs follow the data: when the worker finishes a cycle (or the software cache + // changes) the device details and software dialogs re-render in place, keeping scroll + // position, unless the user is selecting text in them. + let softwareStamps = {}; + let lastDetailsSig = "", lastSoftwareSig = ""; + function refreshOpenDialogs(force) { + const selecting = () => { const sel = window.getSelection(); return sel && sel.toString().length > 0; }; + const dm = document.getElementById('detailsModal'); + if (dm && dm.style.display === 'block' && window.currentDetailsKey && !selecting()) { + const key = window.currentDetailsKey; + const m = apiData.find(x => x["Source Device"] === key) || apiData.find(x => x["Destination Device"] === key); + if (m) { + const sig = JSON.stringify([m, advancedDataCache[m["Source Device"]], advancedDataCache[m["Destination Device"]], + (softwareCache[m["Source Device"]] || []).length, (softwareCache[m["Destination Device"]] || []).length]); + if (force || sig !== lastDetailsSig) { + lastDetailsSig = sig; + window.currentDetailsMatch = m; + const mc = dm.querySelector('.modal-content'); + const boxes = Array.from(dm.querySelectorAll('#detailsBody div')).map(d => d.scrollTop); + const top = mc.scrollTop; + renderDetailsHTML(); + mc.scrollTop = top; + Array.from(dm.querySelectorAll('#detailsBody div')).forEach((d, i) => { if (boxes[i]) d.scrollTop = boxes[i]; }); + } + } + } + const sm = document.getElementById('modal'); + const dev = window.currentSoftwareDevice; + if (sm && sm.style.display === 'block' && dev && Array.isArray(softwareCache[dev]) && !selecting()) { + const sig = dev + JSON.stringify(softwareCache[dev]); + if (sig !== lastSoftwareSig) { + lastSoftwareSig = sig; + drawModalTables(softwareCache[dev], true, dev); + const search = document.getElementById('softSearch'); + if (search && search.value) filterSoftwareModal(); + } + } + } + async function startDashboard() { renderStacksOnly(); await pullData(); diff --git a/web/layout.css b/web/layout.css index ee852ba..f48b772 100644 --- a/web/layout.css +++ b/web/layout.css @@ -41,11 +41,37 @@ table.xf-table thead tr.filter-row { display: none !important; } #scroll-custom { max-height: none !important; overflow-x: clip !important; overflow-y: visible !important; } #table-custom thead th { position: sticky !important; top: var(--sticky-top, 112px) !important; z-index: 20; } .custom-hscroll { - position: sticky; bottom: var(--dock-h, 40px); z-index: 15; height: 16px; - overflow-x: auto; overflow-y: hidden; - background: var(--bg-card); border-top: 1px solid var(--border-light); border-radius: 0 0 8px 8px; + position: sticky; bottom: var(--dock-h, 40px); z-index: 15; + display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 8px; + background: var(--bg-card); border-top: 2px solid #3498db; border-radius: 0 0 8px 8px; + box-shadow: 0 -3px 10px rgba(0,0,0,0.18); user-select: none; touch-action: none; } - .custom-hscroll > div { height: 1px; } + .custom-hscroll[hidden] { display: none; } + .chs-btn { + flex: 0 0 30px; height: 22px; border: 1px solid var(--border-color, #ccc); border-radius: 5px; + background: var(--bg-body, #f0f2f5); color: var(--text-main, #333); cursor: pointer; + font-size: 11px; line-height: 1; padding: 0; + } + .chs-btn:hover:not(:disabled) { background: #3498db; border-color: #3498db; color: #fff; } + .chs-btn:disabled { opacity: 0.35; cursor: default; } + .chs-track { + position: relative; flex: 1 1 auto; height: 14px; border-radius: 7px; cursor: pointer; + background: rgba(127,127,127,0.22); box-shadow: inset 0 1px 2px rgba(0,0,0,0.25); outline: none; + } + .chs-track:focus-visible { box-shadow: 0 0 0 2px #3498db; } + .chs-thumb { + position: absolute; top: -3px; left: 0; height: 20px; min-width: 48px; border-radius: 10px; + background: #3498db; cursor: grab; box-shadow: 0 1px 4px rgba(0,0,0,0.35); + display: flex; align-items: center; justify-content: center; transition: background 0.15s; + } + .chs-thumb span { width: 14px; height: 8px; border-left: 2px solid rgba(255,255,255,0.8); border-right: 2px solid rgba(255,255,255,0.8); } + .chs-thumb:hover, .custom-hscroll.dragging .chs-thumb { background: #1d7fc4; } + .custom-hscroll.dragging .chs-thumb { cursor: grabbing; } + .chs-info { flex: 0 0 auto; min-width: 34px; text-align: right; font-size: 0.75em; color: var(--text-muted, #666); font-variant-numeric: tabular-nums; } + [data-theme="dark"] .chs-track { background: rgba(255,255,255,0.16); } + [data-theme="dark"] .chs-thumb { background: #5dade2; } + [data-theme="dark"] .chs-thumb:hover, [data-theme="dark"] .custom-hscroll.dragging .chs-thumb { background: #85c1e9; } + [data-theme="dark"] .chs-thumb span { border-color: rgba(0,0,0,0.55); } /* Custom View header on one line like the other tables: drag grip, name, then the Excel-style button (sort + filter) and copy icon; remove (x) appears on hover */ @@ -397,3 +423,29 @@ body.col-dragging, body.col-dragging * { cursor: grabbing !important; user-selec .xf-seg-btn.on { background: #3498db; color: #fff; font-weight: 600; } /* Only the value list shrinks when the menu is height-limited; controls keep their size */ .xf-menu > :not(.xf-list):not(.xf-add):not(.xf-fields) { flex-shrink: 0; } + +/* ---------- Device info: reasons, presence, snapshot markers ---------- */ +.di-waiting { color: var(--text-muted); font-style: italic; } +.di-missing { color: #c0392b; font-weight: 600; } +[data-theme="dark"] .di-missing { color: #f1948a; } +.di-online { color: #27ae60; } +.di-offline { color: #d68910; } +.di-offline-long { color: #c0392b; } +[data-theme="dark"] .di-offline-long { color: #f1948a; } +.di-sub { color: var(--text-muted); font-size: 0.85em; } +.di-inline-clock { width: 11px; height: 11px; vertical-align: -1px; } +.snap-mark { display: inline-flex; vertical-align: -2px; margin-left: 3px; color: var(--text-muted); opacity: 0.8; cursor: help; } +.snap-mark svg { width: 12px; height: 12px; } + +/* ---------- Data Synchronization: per-type timing ---------- */ +.sync-breakdown:empty { display: none; } +.sync-breakdown { margin-top: 14px; font-size: 0.85em; } +.sync-bd-title { font-weight: 700; margin-bottom: 6px; color: var(--text-main); } +.sync-bd-title span { font-weight: 400; color: var(--text-muted); margin-left: 4px; } +table.sync-bd { width: 100%; border-collapse: collapse; } +table.sync-bd th, table.sync-bd td { text-align: right; padding: 4px 6px; border-bottom: 1px solid var(--border-color); white-space: nowrap; } +table.sync-bd th:first-child, table.sync-bd td:first-child { text-align: left; white-space: normal; } +table.sync-bd th { color: var(--text-muted); font-weight: 600; } +table.sync-bd tbody tr { cursor: help; } +.sync-bd-phases { margin-top: 6px; color: var(--text-muted); line-height: 1.4; } +#syncModal .modal-content { max-width: 460px !important; } diff --git a/web/layout.js b/web/layout.js index fdd0dca..cd961f6 100644 --- a/web/layout.js +++ b/web/layout.js @@ -81,8 +81,12 @@ // Custom View can be wider than the page. A sideways-scrolling box would stop its header // from sticking, so the box clips instead (overflow-x: clip) and the table is shifted - // sideways by a scrollbar pinned to the bottom of the window. Wheel, shift+wheel and - // touch swipes over the table drive the same scrollbar. + // sideways by a scrollbar of our own, pinned to the bottom of the window: a wide track + // with a draggable thumb (click the track to page), arrow buttons that repeat while held, + // and a position readout. Wheel, shift+wheel, trackpad and touch swipes over the table + // drive the same position. customHScroll.by(dx) is also used by the column drag. + const customHScroll = { left: 0, max: 0, bar: null, by(dx) { this.set(this.left + dx); }, set() {} }; + window.customHScroll = customHScroll; function setupCustomHScroll() { const box = document.getElementById('scroll-custom'); const host = document.getElementById('custom-table-host'); @@ -93,27 +97,96 @@ bar = document.createElement('div'); bar.id = 'custom-hscroll'; bar.className = 'custom-hscroll no-copy'; - bar.appendChild(document.createElement('div')); + bar.innerHTML = + '' + + '
' + + '' + + ''; box.insertAdjacentElement('afterend', bar); - const applyX = () => { host.style.transform = bar.scrollLeft ? `translateX(${-bar.scrollLeft}px)` : ''; }; - bar.addEventListener('scroll', applyX, { passive: true }); + customHScroll.bar = bar; + const track = bar.querySelector('.chs-track'), thumb = bar.querySelector('.chs-thumb'); + const info = bar.querySelector('.chs-info'); + const geom = () => { + const tw = track.clientWidth, view = box.clientWidth, full = view + customHScroll.max; + const w = Math.max(48, Math.round(tw * view / Math.max(full, 1))); + return { tw, w, room: Math.max(tw - w, 1) }; + }; + customHScroll.draw = () => { + const g = geom(); + thumb.style.width = g.w + 'px'; + thumb.style.transform = `translateX(${Math.round(g.room * (customHScroll.max ? customHScroll.left / customHScroll.max : 0))}px)`; + const pct = customHScroll.max ? Math.round(100 * customHScroll.left / customHScroll.max) : 0; + info.textContent = pct + '%'; + track.setAttribute('aria-valuenow', pct); + bar.querySelector('.chs-left').disabled = customHScroll.left <= 0; + bar.querySelector('.chs-right').disabled = customHScroll.left >= customHScroll.max; + }; + customHScroll.set = x => { + const v = Math.max(0, Math.min(customHScroll.max, Math.round(x))); + customHScroll.left = v; + host.style.transform = v ? `translateX(${-v}px)` : ''; + customHScroll.draw(); + }; + // Thumb drag + thumb.addEventListener('pointerdown', e => { + e.preventDefault(); e.stopPropagation(); + thumb.setPointerCapture(e.pointerId); + bar.classList.add('dragging'); + const x0 = e.clientX, l0 = customHScroll.left, g = geom(); + const move = ev => customHScroll.set(l0 + (ev.clientX - x0) * customHScroll.max / g.room); + const up = () => { bar.classList.remove('dragging'); thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); thumb.removeEventListener('pointercancel', up); }; + thumb.addEventListener('pointermove', move); + thumb.addEventListener('pointerup', up); + thumb.addEventListener('pointercancel', up); + }); + // Click on the track: jump so the thumb centres on the pointer + track.addEventListener('pointerdown', e => { + if (e.target !== track) return; + const g = geom(), r = track.getBoundingClientRect(); + customHScroll.set(((e.clientX - r.left - g.w / 2) / g.room) * customHScroll.max); + }); + track.addEventListener('keydown', e => { + const page = box.clientWidth * 0.8; + const k = { ArrowLeft: -60, ArrowRight: 60, PageUp: -page, PageDown: page }[e.key]; + if (k) { e.preventDefault(); customHScroll.by(k); } + else if (e.key === 'Home') { e.preventDefault(); customHScroll.set(0); } + else if (e.key === 'End') { e.preventDefault(); customHScroll.set(customHScroll.max); } + }); + // Arrow buttons: one step per click, keep going while held + bar.querySelectorAll('.chs-btn').forEach(btn => { + const dir = btn.classList.contains('chs-left') ? -1 : 1; + let timer = null; + const stop = () => { clearInterval(timer); clearTimeout(timer); timer = null; }; + btn.addEventListener('pointerdown', e => { + e.preventDefault(); + customHScroll.by(dir * 160); + timer = setTimeout(() => { timer = setInterval(() => customHScroll.by(dir * 40), 30); }, 300); + }); + ['pointerup', 'pointerleave', 'pointercancel'].forEach(t => btn.addEventListener(t, stop)); + }); box.addEventListener('wheel', e => { if (bar.hidden) return; const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : (e.shiftKey ? e.deltaY : 0); if (!dx) return; e.preventDefault(); - bar.scrollLeft += dx; + customHScroll.by(dx); + }, { passive: false }); + bar.addEventListener('wheel', e => { + const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + if (!d) return; + e.preventDefault(); + customHScroll.by(d); }, { passive: false }); let start = null; box.addEventListener('touchstart', e => { if (bar.hidden || e.touches.length !== 1) { start = null; return; } - start = { x: e.touches[0].clientX, y: e.touches[0].clientY, left: bar.scrollLeft, lock: null }; + start = { x: e.touches[0].clientX, y: e.touches[0].clientY, left: customHScroll.left, lock: null }; }, { passive: true }); box.addEventListener('touchmove', e => { if (!start) return; const dx = e.touches[0].clientX - start.x, dy = e.touches[0].clientY - start.y; if (!start.lock && Math.hypot(dx, dy) > 8) start.lock = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y'; - if (start.lock === 'x') { e.preventDefault(); bar.scrollLeft = start.left - dx; } + if (start.lock === 'x') { e.preventDefault(); customHScroll.set(start.left - dx); } }, { passive: false }); box.addEventListener('touchend', () => { start = null; }, { passive: true }); } @@ -124,11 +197,11 @@ } const full = Math.ceil(table.getBoundingClientRect().width); const view = box.clientWidth; - const inner = bar.firstChild; - if (inner.style.width !== full + 'px') inner.style.width = full + 'px'; - const hide = full <= view + 1; + const max = Math.max(0, full - view); + const hide = max <= 1; if (bar.hidden !== hide) bar.hidden = hide; - if (hide && bar.scrollLeft) { bar.scrollLeft = 0; host.style.transform = ''; } + customHScroll.max = hide ? 0 : max; + customHScroll.set(customHScroll.left); } // Auto-sizing text boxes (Workbench stacks) only grow while typing in the original; @@ -206,8 +279,8 @@ const box = document.getElementById('scroll-custom'); if (bar && !bar.hidden && box) { const r = box.getBoundingClientRect(); - if (e.clientX < r.left + 40) bar.scrollLeft -= 18; - else if (e.clientX > r.right - 40) bar.scrollLeft += 18; + if (e.clientX < r.left + 40) customHScroll.by(-18); + else if (e.clientX > r.right - 40) customHScroll.by(18); } // Target slot = number of other columns whose middle is left of the pointer