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.
This commit is contained in:
Johan Sandoval committed 2026-10-06 19:09:49 +00:00
1 parent 50c2a18a68
commit 7868948bfc
10 files changed
+1089 -215

No files matched your search

+18
View File
@@ -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
}
+42 -3
View File
@@ -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 "<br>" } else { "None" }
OU = "Company Devices/$vis/$form"
Groups = $groups -join "<br>"
MAC = $p.Mac
@@ -455,6 +476,24 @@ function Get-DeviceInventory {
SCCMPrimaryUsers = $uda
Disk = "Size: $($p.DiskSize) GB<br>Model: $($p.DiskModel)<br>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 {
+60
View File
@@ -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 "<br>" } 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)
# -----------------------------------------------------------------------------------------
+381 -114
View File
@@ -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,56 +513,38 @@ 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)
}
# 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
}
else {
if ($v) { Write-Host " -> Querying SCCM for associations..." -ForegroundColor DarkGray }
$associations = Get-DeviceAssociations -SourceComputer $deviceName
if (-not $associations) {
$category = "NoAssoc"
$cached = $completedList | Where-Object { $_."Source Device".ToUpper() -eq $deviceName } | Select-Object -First 1
if ($cached) {
$category = "Cached"
if ($v) { Write-Host " -> [CACHE HIT] But association was removed from SCCM." -ForegroundColor DarkYellow }
# Backfill dest software - association is gone so the assoc loop will never fetch it
@@ -356,51 +560,48 @@ 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
$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 }
$pingSrc = pingDevice $deviceName
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null }
$srcTotal = "N/A"
$srcFree = "N/A"
$row = New-DeviceRow $deviceName "N/A" $tags "None"
$pingSrc = Test-Device $deviceName -Session $session
$row."Source Online" = $pingSrc; $row."Destination Online" = "-"
$row."Source Checked" = Get-IsoNow
$row."USMT Status" = "Not Associated"; $row."Backup Status" = "Not Associated"
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null; Elapsed = "N/A" }
if ($pingSrc -eq "Online") {
# --- DISK SKIP LIST CHECK (No-Association path) ---
if ($diskSkipList -notcontains $deviceName) {
$diskInfo = Get-DiskSpace $deviceName
$srcTotal = $diskInfo.Total
$srcFree = $diskInfo.Free
$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 $srcFree -ne "N/A") { $logSrc = Get-LogInfo $deviceName $scanLogName }
if ($diskSkipList -notcontains $deviceName -and $row."Source Free Space" -ne "N/A") { $logSrc = Get-LogInfo $deviceName $scanLogName }
}
$row."Source Scanstate Line" = $logSrc.Line
$row."Source Scanstate File" = $logSrc.Path
$row."Source Scanstate Elapsed" = $logSrc.Elapsed
$srcLive = ($pingSrc -eq "Online" -and $row."Source Free Space" -ne "N/A")
if ($srcLive) { Update-Snapshot $deviceName "N/A" $row $true $false }
else { Use-Snapshot $deviceName "N/A" $row $true $false }
$null = Get-AdvancedData $deviceName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
$deviceTable.Add([PSCustomObject]@{
"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
$deviceTable.Add([PSCustomObject]$row)
}
}
else {
foreach ($assoc in $associations) {
$assoc.Get()
$dName = if ($assoc.DestinationComputer) { $assoc.DestinationComputer } else { $assoc.RestoreName }
@@ -430,36 +631,30 @@ while ($true) {
}
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
$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";
})
$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 = [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";
}
$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 = pingDevice $sName
$pingSrc = Test-Device $sName -Session $session
$row."Source Online" = $pingSrc
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null }
$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) ---
@@ -467,6 +662,7 @@ while ($true) {
$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 }
}
@@ -477,9 +673,10 @@ while ($true) {
$row."Source Scanstate Elapsed" = $logSrc.Elapsed
if ($v) { Write-Host " -> [SWEEP DESTINATION] $dName" -ForegroundColor DarkCyan }
$pingDst = pingDevice $dName
$pingDst = Test-Device $dName -Session $session
$row."Destination Online" = $pingDst
$logDst = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingDst)"; Path = "Unreachable"; LastModified = $null }
$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
@@ -498,6 +695,12 @@ while ($true) {
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
@@ -513,19 +716,64 @@ while ($true) {
}
}
}
}
$null = $processedSources.Add($deviceName)
$timing[$category].Count++
$timing[$category].Ms += $deviceWatch.Elapsed.TotalMilliseconds
# 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
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") }
}
if ($v) { Write-Host " -> Pruning and Compressing Software Cache..." -ForegroundColor DarkGray }
if (Test-Path $softwareCachePath) {
try {
$rawCache = [System.IO.File]::ReadAllText($softwareCachePath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
$pruned = $false
foreach ($p in @($rawCache.psobject.properties.Name)) {
if (-not $activeDevs.Contains($p)) {
@@ -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")
+34 -13
View File
@@ -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']]);
+242
View File
@@ -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 `<span class="di-waiting">${f.text}</span>`;
if (f.state === 'missing') return `<span class="di-missing">${f.text}</span>`;
return f.text;
}
function presenceHtml(name) {
if (!isName(name)) return 'N/A';
const p = presence(name);
if (!p) return `<span class="di-waiting">${WAITING}</span>`;
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)' : ''}<br>`;
sub += `Last checked ${fmtWhen(p.checked)} (${ago(p.checked)})`;
return `<b class="${cls}">${txt}</b><br><span class="di-sub">${sub}</span>`;
}
// ---------------------------------------------------------------------------------
// Snapshot / staleness markers
// ---------------------------------------------------------------------------------
const CLOCK = '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M8 4.5V8l2.5 1.6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
// 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 ` <span class="snap-mark" title="Last captured ${fmtWhen(at)} (${why})">${CLOCK}</span>`;
}
// 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 ` <span class="snap-mark" title="Checked ${fmtWhen(at)}">${CLOCK}</span>`;
}
function snapshotLine(row, side) {
const at = row && row[side + ' Snapshot'];
if (!at) return '';
return `<br><span class="di-sub">${CLOCK.replace('<svg', '<svg class="di-inline-clock"')} Snapshot from ${fmtWhen(at)} (${ago(at)})</span>`;
}
// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 => `<tr title="${esc(HINT[x.Key] || '')}"><td>${esc(x.Label)}</td><td>${x.Count}</td><td>${secs(x.Seconds)}</td><td>${per(x.AvgMs)}</td></tr>`).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 `
<div class="sync-bd-title">Last cycle by type <span>${[c.NewFirst ? `${c.NewFirst} new device(s) swept first` : '', c.FullRefresh ? 'full refresh' : ''].filter(Boolean).join(' &middot; ')}</span></div>
<table class="sync-bd"><thead><tr><th>Type</th><th>Devices</th><th>Time</th><th>Avg</th></tr></thead><tbody>${rows}</tbody></table>
<div class="sync-bd-phases">Software ${secs(ph.Software || 0)} &middot; Sweep ${secs(ph.Sweep || 0)} &middot; Presence ${secs(ph.Presence || 0)} &middot; Save ${secs(ph.Save || 0)}</div>
${presence ? `<div class="sync-bd-phases">${presence}</div>` : ''}`;
};
})();
+9 -5
View File
@@ -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);
}
+160 -62
View File
@@ -353,6 +353,7 @@
</style>
<link rel="stylesheet" href="/layout.css">
<script src="/layout.js"></script>
<script src="/device-info.js"></script>
<script src="/excel-filter.js" defer></script>
</head>
<body>
@@ -662,6 +663,7 @@
<div class="sync-details" id="syncDetailsText">Waiting for server response...</div>
<div class="sync-details" id="syncDurationText" style="color: var(--text-main); font-style: italic; margin-top: 5px;">Last Cycle Duration: N/A</div>
<div id="syncBreakdown" class="sync-breakdown"></div>
<button class="force-btn" id="forceSyncBtn" onclick="forceUpdate()">Force Update Now</button>
<button class="force-btn" id="forceFullBtn" onclick="forceFullRefresh()" style="margin-top: 10px;">Full Software/Advanced Refresh</button>
@@ -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")}<br>${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 `<b>${c.text}</b>${list.state === "value" && list.text !== "None" ? "<br>" + 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") ? '<br>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 = `
<div ${window.isAdvView ? scrollBoxStyle : ''}>
<table class="details-table" style="width:100%; border-collapse: collapse; table-layout: fixed;">
${window.isAdvView ? `<tr><th colspan="2" style="text-align: center; background: var(--table-header-bg); color: var(--table-header-text);">Migration Overview</th></tr>` : ''}
<tr><th width="40%">Source Device</th><td ${tdStyle}><b>${match["Source Device"]}</b> ${inlineCopyBtn(match["Source Device"])} ${srcAppBtn}</td></tr>
<tr><th>Source Ping</th><td style="color: ${getPingColor(match["Source Online"])}; font-weight: bold; padding: 10px;">${match["Source Online"]}</td></tr>
<tr><th>Space Available</th><td ${tdStyle}>${match["Source Free Space"] !== "N/A" ? match["Source Free Space"] + ' / ' + match["Source Total Space"] : "N/A"}</td></tr>
<tr><th>Scanstate Line</th><td ${tdStyle}>${match["Source Scanstate Line"]}${(match["Source Scanstate Elapsed"] && match["Source Scanstate Elapsed"] !== "N/A" && match["Source Scanstate Elapsed"] !== "Cached") ? '<br>Elapsed Time: ' + match["Source Scanstate Elapsed"] : ''}</td></tr>
<tr><th>Dest Device</th><td ${tdStyle}><b>${match["Destination Device"]}</b> ${inlineCopyBtn(match["Destination Device"])} ${dstAppBtn}</td></tr>
<tr><th>Dest Ping</th><td style="color: ${getPingColor(match["Destination Online"])}; font-weight: bold; padding: 10px;">${match["Destination Online"]}</td></tr>
<tr><th>Loadstate Line</th><td ${tdStyle}>${match["Destination Loadstate Line"]}${(match["Destination Loadstate Elapsed"] && match["Destination Loadstate Elapsed"] !== "N/A" && match["Destination Loadstate Elapsed"] !== "Cached") ? '<br>Elapsed Time: ' + match["Destination Loadstate Elapsed"] : ''}</td></tr>
<tr><th width="40%">Source Device</th><td ${tdStyle}><b>${srcName}</b> ${inlineCopyBtn(srcName)} ${srcAppBtn}</td></tr>
<tr><th>Source Ping</th><td style="color: ${getPingColor(match["Source Online"])}; font-weight: bold; padding: 10px;">${match["Source Online"]}${DeviceInfo.pingMark(match, "Source")}</td></tr>
<tr><th>Source Presence</th><td ${tdStyle}>${DeviceInfo.presenceHtml(srcName)}</td></tr>
<tr><th>Space Available</th><td ${tdStyle}>${space}${match["Source Space Checked"] && !match["Source Snapshot"] ? `<br><span class="di-sub">Checked ${DeviceInfo.fmtWhen(match["Source Space Checked"])}</span>` : ''}${DeviceInfo.snapshotLine(match, "Source")}</td></tr>
<tr><th>Scanstate Line</th><td ${tdStyle}>${match["Source Scanstate Line"]}${elapsed(match["Source Scanstate Elapsed"])}${DeviceInfo.snapshotLine(match, "Source")}</td></tr>
<tr><th>Dest Device</th><td ${tdStyle}><b>${dstName}</b> ${inlineCopyBtn(dstName)} ${dstAppBtn}</td></tr>
<tr><th>Dest Ping</th><td style="color: ${getPingColor(match["Destination Online"])}; font-weight: bold; padding: 10px;">${match["Destination Online"]}${DeviceInfo.pingMark(match, "Destination")}</td></tr>
${hasDst ? `<tr><th>Dest Presence</th><td ${tdStyle}>${DeviceInfo.presenceHtml(dstName)}</td></tr>` : ''}
<tr><th>Loadstate Line</th><td ${tdStyle}>${match["Destination Loadstate Line"]}${elapsed(match["Destination Loadstate Elapsed"])}${DeviceInfo.snapshotLine(match, "Destination")}</td></tr>
<tr><th>Backup Status</th><td style="background-color: ${statusBg}; color: ${statusText}; padding: 10px;"><b>${match["Backup Status"]}</b></td></tr>
<tr><th>Specialty Software</th><td ${tdStyle}>${specialtySoftware}</td></tr>
<tr><th>USMT Status</th><td ${tdStyle}>${match["USMT Status"]}</td></tr>
<tr><th>USMT Status</th><td ${tdStyle}>${match["USMT Status"]}${pairing ? `<br><span class="di-sub">${pairing}</span>` : ''}</td></tr>
${window.isAdvView ? '' : `<tr><th>Monitors</th><td ${tdStyle}>${monitorsCell(srcName)}</td></tr>`}
<tr><th>Tags</th><td ${tdStyle}>${match["Tags"] || 'None'}</td></tr>
</table>
</div>
@@ -2898,21 +2925,25 @@
<div ${scrollBoxStyle}>
<table class="details-table" style="width:100%; border-collapse: collapse; table-layout: fixed;">
<tr><th colspan="2" style="text-align: center; background: #34495e; color: white;">Source Info</th></tr>
<tr><th width="40%">Device Name</th><td ${tdStyle}><b>${match["Source Device"]}</b> ${inlineCopyBtn(match["Source Device"])}</td></tr>
<tr><th>Source IP / MAC</th><td ${tdStyle}>${getAdv(advSrc, "IP")}<br>${getAdv(advSrc, "MAC")}</td></tr>
<tr><th>Source OU</th><td ${tdStyle}>${getAdv(advSrc, "OU")}</td></tr>
<tr><th>Source Groups</th><td ${tdStyle}>${getAdv(advSrc, "Groups")}</td></tr>
<tr><th>Hardware Specs</th><td ${tdStyle}>${getAdv(advSrc, "Model")}<br>${(getAdv(advSrc, "RAM") !== 'N/A') ? getAdv(advSrc, "RAM") + ' RAM' : 'N/A'}</td></tr>
<tr><th>Physical Disk</th><td ${tdStyle}>${getAdv(advSrc, "Disk")}</td></tr>
<tr><th>SCCM Client</th><td ${tdStyle}>${getAdv(advSrc, "ClientStatus")}</td></tr>
<tr><th>Dept / Bldg</th><td ${tdStyle}>${getAdv(advSrc, "Department")} / ${getAdv(advSrc, "Building")}</td></tr>
<tr><th>Location / Vanity</th><td ${tdStyle}>${getAdv(advSrc, "Location")} / ${getAdv(advSrc, "Vanity")}</td></tr>
<tr><th>Phone / Mount</th><td ${tdStyle}>Ext: ${getAdv(advSrc, "PhoneExt")}<br>${getAdv(advSrc, "Mount")}</td></tr>
<tr><th>Room Type</th><td ${tdStyle}>${getAdv(advSrc, "RoomType")}</td></tr>
<tr><th>Users (Pri / Last)</th><td ${tdStyle}>${getAdv(advSrc, "PrimaryUser")} / ${getAdv(advSrc, "LastUser")}</td></tr>
<tr><th>Primary Users</th><td ${tdStyle}>${getAdv(advSrc, "SCCMPrimaryUsers")}</td></tr>
<tr><th>Asset Info Entered</th><td ${tdStyle}>${getAdv(advSrc, "Updated")}<br>(By: ${getAdv(advSrc, "InfoBy")})</td></tr>
<tr><th>Notes</th><td ${tdStyle}>${getAdv(advSrc, "Notes")}</td></tr>
<tr><th width="40%">Device Name</th><td ${tdStyle}><b>${srcName}</b> ${inlineCopyBtn(srcName)}</td></tr>
<tr><th>Found In</th><td ${tdStyle}>${getAdv(srcName, "FoundIn")}</td></tr>
<tr><th>Presence</th><td ${tdStyle}>${DeviceInfo.presenceHtml(srcName)}</td></tr>
${DeviceInfo.unknownEverywhere(srcName) ? `<tr><td colspan="2" ${tdStyle}><span class="di-missing">Not in AD, SCCM or DNS.</span><br><span class="di-sub">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.</span></td></tr>` : `
<tr><th>Source IP / MAC</th><td ${tdStyle}>${getAdv(srcName, "IP")}<br>${getAdv(srcName, "MAC")}</td></tr>
<tr><th>Source OU</th><td ${tdStyle}>${getAdv(srcName, "OU")}</td></tr>
<tr><th>Source Groups</th><td ${tdStyle}>${getAdv(srcName, "Groups")}</td></tr>
<tr><th>Hardware Specs</th><td ${tdStyle}>${hwSpecs(srcName)}</td></tr>
<tr><th>Physical Disk</th><td ${tdStyle}>${getAdv(srcName, "Disk")}</td></tr>
<tr><th>Monitors</th><td ${tdStyle}>${monitorsCell(srcName)}</td></tr>
<tr><th>SCCM Client</th><td ${tdStyle}>${getAdv(srcName, "ClientStatus")}</td></tr>
<tr><th>Dept / Bldg</th><td ${tdStyle}>${getAdv(srcName, "Department")} / ${getAdv(srcName, "Building")}</td></tr>
<tr><th>Location / Vanity</th><td ${tdStyle}>${getAdv(srcName, "Location")} / ${getAdv(srcName, "Vanity")}</td></tr>
<tr><th>Phone / Mount</th><td ${tdStyle}>Ext: ${getAdv(srcName, "PhoneExt")}<br>${getAdv(srcName, "Mount")}</td></tr>
<tr><th>Room Type</th><td ${tdStyle}>${getAdv(srcName, "RoomType")}</td></tr>
<tr><th>Users (Pri / Last)</th><td ${tdStyle}>${getAdv(srcName, "PrimaryUser")} / ${getAdv(srcName, "LastUser")}</td></tr>
<tr><th>Primary Users</th><td ${tdStyle}>${getAdv(srcName, "SCCMPrimaryUsers")}</td></tr>
<tr><th>Asset Info Entered</th><td ${tdStyle}>${getAdv(srcName, "Updated")}<br>(By: ${getAdv(srcName, "InfoBy")})</td></tr>
<tr><th>Notes</th><td ${tdStyle}>${getAdv(srcName, "Notes")}</td></tr>`}
</table>
</div>
`;
@@ -2921,21 +2952,25 @@
<div ${scrollBoxStyle}>
<table class="details-table" style="width:100%; border-collapse: collapse; table-layout: fixed;">
<tr><th colspan="2" style="text-align: center; background: #34495e; color: white;">Destination Info</th></tr>
<tr><th width="40%">Device Name</th><td ${tdStyle}><b>${match["Destination Device"]}</b> ${inlineCopyBtn(match["Destination Device"])}</td></tr>
<tr><th>Dest IP / MAC</th><td ${tdStyle}>${getAdv(advDst, "IP")}<br>${getAdv(advDst, "MAC")}</td></tr>
<tr><th>Dest OU</th><td ${tdStyle}>${getAdv(advDst, "OU")}</td></tr>
<tr><th>Dest Groups</th><td ${tdStyle}>${getAdv(advDst, "Groups")}</td></tr>
<tr><th>Hardware Specs</th><td ${tdStyle}>${getAdv(advDst, "Model")}<br>${(getAdv(advDst, "RAM") !== 'N/A') ? getAdv(advDst, "RAM") + ' RAM' : 'N/A'}</td></tr>
<tr><th>Physical Disk</th><td ${tdStyle}>${getAdv(advDst, "Disk")}</td></tr>
<tr><th>SCCM Client</th><td ${tdStyle}>${getAdv(advDst, "ClientStatus")}</td></tr>
<tr><th>Dept / Bldg</th><td ${tdStyle}>${getAdv(advDst, "Department")} / ${getAdv(advDst, "Building")}</td></tr>
<tr><th>Location / Vanity</th><td ${tdStyle}>${getAdv(advDst, "Location")} / ${getAdv(advDst, "Vanity")}</td></tr>
<tr><th>Phone / Mount</th><td ${tdStyle}>Ext: ${getAdv(advDst, "PhoneExt")}<br>${getAdv(advDst, "Mount")}</td></tr>
<tr><th>Room Type</th><td ${tdStyle}>${getAdv(advDst, "RoomType")}</td></tr>
<tr><th>Users (Pri / Last)</th><td ${tdStyle}>${getAdv(advDst, "PrimaryUser")} / ${getAdv(advDst, "LastUser")}</td></tr>
<tr><th>Primary Users</th><td ${tdStyle}>${getAdv(advDst, "SCCMPrimaryUsers")}</td></tr>
<tr><th>Asset Info Entered</th><td ${tdStyle}>${getAdv(advDst, "Updated")}<br>(By: ${getAdv(advDst, "InfoBy")})</td></tr>
<tr><th>Notes</th><td ${tdStyle}>${getAdv(advDst, "Notes")}</td></tr>
<tr><th width="40%">Device Name</th><td ${tdStyle}><b>${dstName}</b> ${inlineCopyBtn(dstName)}</td></tr>
<tr><th>Found In</th><td ${tdStyle}>${getAdv(dstName, "FoundIn")}</td></tr>
<tr><th>Presence</th><td ${tdStyle}>${DeviceInfo.presenceHtml(dstName)}</td></tr>
${DeviceInfo.unknownEverywhere(dstName) ? `<tr><td colspan="2" ${tdStyle}><span class="di-missing">Not in AD, SCCM or DNS.</span><br><span class="di-sub">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.</span></td></tr>` : `
<tr><th>Dest IP / MAC</th><td ${tdStyle}>${getAdv(dstName, "IP")}<br>${getAdv(dstName, "MAC")}</td></tr>
<tr><th>Dest OU</th><td ${tdStyle}>${getAdv(dstName, "OU")}</td></tr>
<tr><th>Dest Groups</th><td ${tdStyle}>${getAdv(dstName, "Groups")}</td></tr>
<tr><th>Hardware Specs</th><td ${tdStyle}>${hwSpecs(dstName)}</td></tr>
<tr><th>Physical Disk</th><td ${tdStyle}>${getAdv(dstName, "Disk")}</td></tr>
<tr><th>Monitors</th><td ${tdStyle}>${monitorsCell(dstName)}</td></tr>
<tr><th>SCCM Client</th><td ${tdStyle}>${getAdv(dstName, "ClientStatus")}</td></tr>
<tr><th>Dept / Bldg</th><td ${tdStyle}>${getAdv(dstName, "Department")} / ${getAdv(dstName, "Building")}</td></tr>
<tr><th>Location / Vanity</th><td ${tdStyle}>${getAdv(dstName, "Location")} / ${getAdv(dstName, "Vanity")}</td></tr>
<tr><th>Phone / Mount</th><td ${tdStyle}>Ext: ${getAdv(dstName, "PhoneExt")}<br>${getAdv(dstName, "Mount")}</td></tr>
<tr><th>Room Type</th><td ${tdStyle}>${getAdv(dstName, "RoomType")}</td></tr>
<tr><th>Users (Pri / Last)</th><td ${tdStyle}>${getAdv(dstName, "PrimaryUser")} / ${getAdv(dstName, "LastUser")}</td></tr>
<tr><th>Primary Users</th><td ${tdStyle}>${getAdv(dstName, "SCCMPrimaryUsers")}</td></tr>
<tr><th>Asset Info Entered</th><td ${tdStyle}>${getAdv(dstName, "Updated")}<br>(By: ${getAdv(dstName, "InfoBy")})</td></tr>
<tr><th>Notes</th><td ${tdStyle}>${getAdv(dstName, "Notes")}</td></tr>`}
</table>
</div>
`;
@@ -3225,9 +3260,11 @@
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
@@ -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";
@@ -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") ?
`<button class="soft-btn" onclick="event.stopPropagation(); fetchSoftware('${row["Destination Device"]}')">Apps</button>` : `<span style="color:var(--text-muted);">---</span>`;
rowsHtml += `<tr class="clickable-row" style='background-color:${themes[currentTheme].groupColors[g]}; cursor: pointer;' onclick="showDeviceDetails('${row["Source Device"]}')">
<td title="${row["Source Device"]}"><b>${row["Source Device"]}</b>${inlineCopyBtn(row["Source Device"])}</td>
<td style="color: ${getPingColor(srcPingStatus)}; font-weight: bold;">${srcPingStatus}</td>
<td style="color: ${getPingColor(srcPingStatus)}; font-weight: bold;">${srcPingStatus}${DeviceInfo.pingMark(row, "Source")}</td>
<td class="no-copy"><button class="soft-btn" onclick="event.stopPropagation(); fetchSoftware('${row["Source Device"]}')">Apps</button></td>
<td title="${row["Source Scanstate Line"]}">${(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"]}</td>
<td title="${spaceStr}">${spaceStr}</td>
<td title="${row["Source Scanstate Line"]}">${(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")}</td>
<td title="${spaceStr}">${spaceStr}${DeviceInfo.snapshotMark(row, "Source")}</td>
<td title="${row["Destination Device"]}"><b>${row["Destination Device"]}</b>${inlineCopyBtn(row["Destination Device"])}</td>
<td style="color: ${getPingColor(dstPingStatus)}; font-weight: bold;">${dstPingStatus}</td>
<td style="color: ${getPingColor(dstPingStatus)}; font-weight: bold;">${dstPingStatus}${DeviceInfo.pingMark(row, "Destination")}</td>
<td class="no-copy">${destAppsBtn}</td>
<td title="${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"]}</td>
<td title="${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")}</td>
<td title="${row["USMT Status"] || "N/A"}">${row["USMT Status"] || "N/A"}</td>
<td title="${row.Tags || 'None'}">${row.Tags || 'None'}</td>
<td style="text-align:center;" class="no-copy"><span class="copy-row-btn" title="Copy Row Data" onclick="copyRowData(this, event)">${COPY_SVG}</span></td>
@@ -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();
+56 -4
View File
@@ -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; }
+87 -14
View File
@@ -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 =
'<button type="button" class="chs-btn chs-left" aria-label="Scroll columns left">&#9664;</button>' +
'<div class="chs-track" role="scrollbar" aria-orientation="horizontal" aria-controls="table-custom" tabindex="0"><div class="chs-thumb"><span></span></div></div>' +
'<button type="button" class="chs-btn chs-right" aria-label="Scroll columns right">&#9654;</button>' +
'<span class="chs-info"></span>';
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