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:
1 parent
50c2a18a68
commit
7868948bfc
10 files changed
+1230
-356
No files matched your search
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
# -----------------------------------------------------------------------------------------
|
||||
|
||||
+484
-217
@@ -22,10 +22,17 @@ $completedPath = Join-Path $dataDir "Completed.json"
|
||||
$statePath = Join-Path $dataDir "Worker-State.json"
|
||||
$softwareCachePath = Join-Path $dataDir "Software-Cache.json"
|
||||
$advancedCachePath = Join-Path $dataDir "Advanced-Cache.json"
|
||||
$presencePath = Join-Path $dataDir "Presence.json"
|
||||
$snapshotPath = Join-Path $dataDir "Snapshots.json"
|
||||
$getSoftwareScript = Join-Path $originalLocation "Get-Software.ps1"
|
||||
$diskSkipList = @($config.Worker.DiskSkipList) # Devices with broken SMB that hang Directory.Exists
|
||||
$scanLogName = if ($config.SCCM.ScanStateLog) { $config.SCCM.ScanStateLog } else { "scanstateprogress" }
|
||||
$loadLogName = if ($config.SCCM.LoadStateLog) { $config.SCCM.LoadStateLog } else { "loadstateprogress" }
|
||||
# How often every tracked device (cached and destination devices included) is pinged to keep
|
||||
# "last online" current. Cycles only run in business hours plus the midnight pitstop, so this
|
||||
# works out to an hourly presence sweep during the day and one at midnight.
|
||||
$presenceMinutes = if ($config.IsDemo) { $config.Demo.PresenceSweepMinutes } else { $config.Worker.PresenceSweepMinutes }
|
||||
if (-not $presenceMinutes) { $presenceMinutes = 60 }
|
||||
|
||||
if ($config.Logging.Enabled) {
|
||||
$cmdLine = "PS $originalLocation> ./USMT-Worker.ps1" + $(if ($config.IsDemo) { " -Demo" } else { "" }) + $(if ($v) { " -v" } else { "" })
|
||||
@@ -42,6 +49,8 @@ if (Test-Path $softwareCachePath) {
|
||||
$globalLastUpdated = "Never"
|
||||
$globalNextRun = "Calculating..."
|
||||
$globalLastDuration = "N/A"
|
||||
$global:LastBreakdown = $null
|
||||
$global:LastPresenceSweep = [datetime]::MinValue
|
||||
|
||||
# --- FUNCTIONS ---
|
||||
function Update-WorkerState {
|
||||
@@ -54,7 +63,8 @@ function Update-WorkerState {
|
||||
LastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt")
|
||||
NextCycle = $nextRun
|
||||
LastDuration = $duration
|
||||
} | ConvertTo-Json -Compress
|
||||
LastCycle = $global:LastBreakdown
|
||||
} | ConvertTo-Json -Compress -Depth 5
|
||||
|
||||
[System.IO.File]::WriteAllText($statePath, $json, [System.Text.Encoding]::UTF8)
|
||||
} catch {}
|
||||
@@ -79,10 +89,176 @@ function Set-LogInfoLine {
|
||||
if ($rawLine -match '^\d{1,2}\s+[a-zA-Z]{3}\s+\d{4},\s+\d{2}:\d{2}:\d{2}\s+[+-]\d{2}:\d{2},\s+(\d{2}:\d{2}:\d{2})') { $result.Elapsed = $Matches[1] }
|
||||
}
|
||||
|
||||
function Get-IsoNow { return (Get-Date).ToString("o") }
|
||||
|
||||
function ConvertTo-Hashtable($obj) {
|
||||
$h = @{}
|
||||
if ($obj -is [hashtable]) { foreach ($k in $obj.Keys) { $h[$k] = $obj[$k] } }
|
||||
elseif ($obj) { foreach ($p in $obj.psobject.properties) { $h[$p.Name] = $p.Value } }
|
||||
return $h
|
||||
}
|
||||
|
||||
# Reads a JSON object of objects into a hashtable of hashtables
|
||||
function Read-JsonTable([string]$path) {
|
||||
$table = @{}
|
||||
if (Test-Path $path) {
|
||||
try {
|
||||
$raw = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
||||
foreach ($p in $raw.psobject.properties) { $table[$p.Name] = ConvertTo-Hashtable $p.Value }
|
||||
} catch {}
|
||||
}
|
||||
return $table
|
||||
}
|
||||
|
||||
function Write-JsonFile([string]$path, $obj, [int]$depth = 6) {
|
||||
$json = ConvertTo-Json -InputObject $obj -Depth $depth -Compress
|
||||
[System.IO.File]::WriteAllText($path, $json, [System.Text.Encoding]::UTF8)
|
||||
}
|
||||
|
||||
# --- PROVIDER (data source adapter) ---
|
||||
# Supplies: Initialize-Provider, Open-ProviderCycle, Close-ProviderCycle, pingDevice,
|
||||
# Resolve-DeviceIP, Get-DiskSpace, Get-LogInfo, Get-DeviceAssociations, Get-DeviceInventory
|
||||
# and optionally Get-DeviceLastSeen (backfills "last online" for devices tracked for the first time)
|
||||
. (Join-Path $originalLocation "Providers/$providerName.ps1")
|
||||
$hasLastSeen = [bool](Get-Command Get-DeviceLastSeen -ErrorAction SilentlyContinue)
|
||||
|
||||
# --- PRESENCE (last online) ---
|
||||
# Every ping the worker makes is recorded here: when the device was last checked, what the
|
||||
# ping said, and when it last answered. Kept across restarts in Presence.json.
|
||||
$global:Presence = Read-JsonTable $presencePath
|
||||
$global:PingedThisCycle = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
|
||||
function Set-Presence {
|
||||
param([string]$name, [string]$status, $Session)
|
||||
if ([string]::IsNullOrWhiteSpace($name) -or $name -eq "N/A") { return }
|
||||
$key = $name.Trim().ToUpper()
|
||||
$now = Get-IsoNow
|
||||
$entry = $global:Presence[$key]
|
||||
if (-not $entry) {
|
||||
$entry = @{ FirstChecked = $now; LastOnline = $null }
|
||||
# First time we see this device: ask the data source when it was last active
|
||||
if ($status -ne "Online" -and $hasLastSeen) {
|
||||
try {
|
||||
$seen = Get-DeviceLastSeen $key -Session $Session
|
||||
if ($seen) { $entry.LastOnline = ([datetime]$seen).ToString("o"); $entry.LastOnlineSource = "Inventory" }
|
||||
} catch {}
|
||||
}
|
||||
$global:Presence[$key] = $entry
|
||||
}
|
||||
$entry.LastChecked = $now
|
||||
$entry.LastPing = $status
|
||||
$entry.InDNS = ($status -ne "Not Found")
|
||||
if ($status -eq "Online") { $entry.LastOnline = $now; $entry.LastOnlineSource = "Ping" }
|
||||
$null = $global:PingedThisCycle.Add($key)
|
||||
}
|
||||
|
||||
# pingDevice + presence bookkeeping
|
||||
function Test-Device {
|
||||
param([string]$name, $Session)
|
||||
$status = pingDevice $name
|
||||
Set-Presence $name $status -Session $Session
|
||||
return $status
|
||||
}
|
||||
|
||||
function Get-PresenceValue([string]$name, [string]$field) {
|
||||
if ([string]::IsNullOrWhiteSpace($name) -or $name -eq "N/A") { return $null }
|
||||
$e = $global:Presence[$name.ToUpper()]
|
||||
if ($e) { return $e[$field] }
|
||||
return $null
|
||||
}
|
||||
|
||||
# --- SNAPSHOTS ---
|
||||
# The last state seen while a device was reachable: free space, the last scanstate /
|
||||
# loadstate line, and when each was read. Shown (with its timestamp) instead of "Cached" for
|
||||
# completed migrations, and instead of blanks while a device is offline.
|
||||
$global:Snapshots = Read-JsonTable $snapshotPath
|
||||
|
||||
function Update-Snapshot {
|
||||
param([string]$sName, [string]$dName, $row, [bool]$srcLive, [bool]$dstLive)
|
||||
$key = $sName.ToUpper()
|
||||
$snap = $global:Snapshots[$key]
|
||||
if (-not $snap) { $snap = @{}; $global:Snapshots[$key] = $snap }
|
||||
$now = Get-IsoNow
|
||||
if ($srcLive) {
|
||||
$snap.Src = @{
|
||||
At = $now; Total = $row."Source Total Space"; Free = $row."Source Free Space"
|
||||
Line = $row."Source Scanstate Line"; File = $row."Source Scanstate File"; Elapsed = $row."Source Scanstate Elapsed"
|
||||
}
|
||||
}
|
||||
if ($dstLive -and $dName -and $dName -ne "N/A") {
|
||||
$snap.Dst = @{
|
||||
At = $now; Device = $dName.ToUpper()
|
||||
Line = $row."Destination Loadstate Line"; File = $row."Destination Loadstate File"; Elapsed = $row."Destination Loadstate Elapsed"
|
||||
}
|
||||
}
|
||||
if ($srcLive -or $dstLive) { $snap.Status = $row."Backup Status"; $snap.At = $now }
|
||||
}
|
||||
|
||||
# Fills the source / destination parts of a row from the snapshot. Returns what was used.
|
||||
function Use-Snapshot {
|
||||
param([string]$sName, [string]$dName, $row, [bool]$src, [bool]$dst)
|
||||
$snap = $global:Snapshots[$sName.ToUpper()]
|
||||
if (-not $snap) { return }
|
||||
$s = $snap.Src; $d = $snap.Dst
|
||||
if ($src -and $s) {
|
||||
$s = ConvertTo-Hashtable $s
|
||||
$row."Source Total Space" = $s.Total; $row."Source Free Space" = $s.Free
|
||||
$row."Source Scanstate Line" = $s.Line; $row."Source Scanstate File" = $s.File; $row."Source Scanstate Elapsed" = $s.Elapsed
|
||||
$row."Source Snapshot" = $s.At
|
||||
$row."Source Space Checked" = $s.At
|
||||
}
|
||||
if ($dst -and $d) {
|
||||
$d = ConvertTo-Hashtable $d
|
||||
if (-not $dName -or $dName -eq "N/A" -or $d.Device -eq $dName.ToUpper()) {
|
||||
$row."Destination Loadstate Line" = $d.Line; $row."Destination Loadstate File" = $d.File; $row."Destination Loadstate Elapsed" = $d.Elapsed
|
||||
$row."Destination Snapshot" = $d.At
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Every row has the same columns; these are filled in as the device is processed
|
||||
function New-DeviceRow {
|
||||
param([string]$sName, [string]$dName, [string]$tags, [string]$pairing)
|
||||
return [ordered]@{
|
||||
"Source Device"=$sName; "Destination Device"=$dName; "Tags"=$tags; "Pairing"=$pairing;
|
||||
"Source Total Space"="N/A"; "Source Free Space"="N/A";
|
||||
"USMT Status"="N/A"; "Backup Status"="N/A";
|
||||
"Source Online"="N/A"; "Destination Online"="N/A";
|
||||
"Source Scanstate Line"="N/A"; "Destination Loadstate Line"="N/A";
|
||||
"Source Scanstate File"="N/A"; "Destination Loadstate File"="N/A";
|
||||
"Source Scanstate Elapsed"="N/A"; "Destination Loadstate Elapsed"="N/A";
|
||||
"Source Checked"=$null; "Destination Checked"=$null; "Source Space Checked"=$null;
|
||||
"Source Snapshot"=$null; "Destination Snapshot"=$null
|
||||
}
|
||||
}
|
||||
|
||||
# Advanced-Cache.json = inventory per device plus its presence (last online / last check)
|
||||
function Save-AdvancedCache {
|
||||
$out = @{}
|
||||
foreach ($key in @($global:AdvancedCache.Keys)) {
|
||||
$entry = ConvertTo-Hashtable $global:AdvancedCache[$key]
|
||||
$pr = $global:Presence[$key.ToUpper()]
|
||||
if ($pr) {
|
||||
foreach ($f in @("LastOnline", "LastOnlineSource", "LastChecked", "LastPing", "FirstChecked", "InDNS")) { $entry[$f] = $pr[$f] }
|
||||
}
|
||||
$out[$key] = $entry
|
||||
}
|
||||
Write-JsonFile $advancedCachePath $out 5
|
||||
Write-JsonFile $presencePath $global:Presence 4
|
||||
}
|
||||
|
||||
# Cached / skipped rows show the latest presence-sweep result instead of a placeholder
|
||||
function Set-RowPresence {
|
||||
param($row, [switch]$Source, [switch]$Destination)
|
||||
if ($Source) {
|
||||
$ping = Get-PresenceValue $row."Source Device" "LastPing"
|
||||
if ($ping) { $row."Source Online" = $ping; $row."Source Checked" = Get-PresenceValue $row."Source Device" "LastChecked" }
|
||||
}
|
||||
if ($Destination) {
|
||||
$ping = Get-PresenceValue $row."Destination Device" "LastPing"
|
||||
if ($ping) { $row."Destination Online" = $ping; $row."Destination Checked" = Get-PresenceValue $row."Destination Device" "LastChecked" }
|
||||
}
|
||||
}
|
||||
|
||||
$global:AdvancedCache = @{}
|
||||
$global:AdvancedCacheUpdated = $false
|
||||
@@ -98,7 +274,7 @@ if (Test-Path $advancedCachePath) {
|
||||
function Get-AdvancedData {
|
||||
param ([string]$computerName, [switch]$IsDest, [string]$OnlineStatus, [switch]$ForceUpdate, $Session)
|
||||
|
||||
$res = @{ IP="N/A"; MAC="N/A"; OU="N/A"; Groups="N/A"; Updated="N/A"; Location="N/A"; Vanity="N/A"; Department="N/A"; Building="N/A"; Mount="N/A"; PrimaryUser="N/A"; LastUser="N/A"; SCCMPrimaryUsers="N/A"; Model="N/A"; RAM="N/A"; Disk="N/A"; ClientStatus="N/A"; PhoneExt="N/A"; RoomType="Standard"; InfoBy="N/A"; Notes="N/A" }
|
||||
$res = @{ IP="N/A"; MAC="N/A"; OU="N/A"; Groups="N/A"; Updated="N/A"; Location="N/A"; Vanity="N/A"; Department="N/A"; Building="N/A"; Mount="N/A"; PrimaryUser="N/A"; LastUser="N/A"; SCCMPrimaryUsers="N/A"; Model="N/A"; RAM="N/A"; Disk="N/A"; ClientStatus="N/A"; PhoneExt="N/A"; RoomType="Standard"; InfoBy="N/A"; Notes="N/A"; MonitorCount="N/A"; Monitors="N/A"; InAD=$null; InSCCM=$null; Fetched=$null }
|
||||
if ([string]::IsNullOrWhiteSpace($computerName) -or $computerName -eq "N/A") { return $res }
|
||||
|
||||
$needsUpdate = $true
|
||||
@@ -107,6 +283,9 @@ function Get-AdvancedData {
|
||||
$cachedRes = $global:AdvancedCache[$computerName]
|
||||
$cachedKeys = if ($cachedRes -is [hashtable]) { $cachedRes.Keys } else { $cachedRes.psobject.properties.Name }
|
||||
foreach ($key in @($cachedKeys)) { $res[$key] = $cachedRes.$key }
|
||||
# A device that wasn't in AD or SCCM yet (e.g. a replacement not imaged yet) is looked
|
||||
# up again every cycle until it shows up, instead of waiting for the nightly refresh
|
||||
if ($res.InAD -eq $false -or $res.InSCCM -eq $false -or -not $res.Fetched) { $needsUpdate = $true }
|
||||
}
|
||||
|
||||
# Live IP from DNS for devices that answered the ping (inventory IP is the fallback)
|
||||
@@ -125,6 +304,13 @@ function Get-AdvancedData {
|
||||
if ($inventory[$key]) { $res[$key] = $inventory[$key] }
|
||||
}
|
||||
if ($null -eq $dnsIp -and $inventory.InventoryIP) { $res.IP = $inventory.InventoryIP }
|
||||
$res.InAD = $inventory.InAD
|
||||
$res.InSCCM = $inventory.InSCCM
|
||||
$res.Fetched = Get-IsoNow
|
||||
if ($v -and ($res.InAD -eq $false -or $res.InSCCM -eq $false)) {
|
||||
$missing = @(); if ($res.InAD -eq $false) { $missing += "AD" }; if ($res.InSCCM -eq $false) { $missing += "SCCM" }
|
||||
Write-Host " -> $computerName not found in: $($missing -join ', ')" -ForegroundColor DarkYellow
|
||||
}
|
||||
|
||||
$global:AdvancedCache[$computerName] = $res
|
||||
$global:AdvancedCacheUpdated = $true
|
||||
@@ -180,7 +366,7 @@ while ($true) {
|
||||
$cycleStart = Get-Date
|
||||
Write-Host "`n--- Cycle Start: $($cycleStart.ToString('hh:mm:ss tt')) ---" -ForegroundColor White
|
||||
|
||||
Update-WorkerState "Preparing..." 0 0 "N/A" "Calculating..."
|
||||
Update-WorkerState "Preparing..." 0 0 $globalLastDuration "Calculating..."
|
||||
|
||||
$session = Open-ProviderCycle
|
||||
|
||||
@@ -242,10 +428,38 @@ while ($true) {
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
$uniqueDevices = $deviceTags.Keys | Sort-Object
|
||||
$totalDevices = @($uniqueDevices).Count
|
||||
$uniqueDevices = @($deviceTags.Keys | Sort-Object)
|
||||
|
||||
Update-WorkerState "Software Check..." 0 $totalDevices "N/A" "Calculating..."
|
||||
# Totals count SOURCE devices only: a manual pair's destination is part of its source's row
|
||||
$manualDestSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($d in $manualPairs.Values) { if (-not $manualPairs.ContainsKey($d)) { $null = $manualDestSet.Add($d) } }
|
||||
|
||||
# Last cycle's rows: devices with no data yet (newly tracked) are swept first, and the
|
||||
# dashboard gets them as soon as they're done instead of at the end of the cycle
|
||||
$previousRows = @{}
|
||||
if (Test-Path $jsonPath) {
|
||||
try {
|
||||
$prev = [System.IO.File]::ReadAllText($jsonPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
||||
foreach ($r in @($prev.Devices)) { if ($r."Source Device") { $previousRows[$r."Source Device".ToUpper()] = $r } }
|
||||
} catch {}
|
||||
}
|
||||
$newSources = [System.Collections.Generic.List[string]]::new()
|
||||
$knownSources = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($dev in $uniqueDevices) {
|
||||
if ($manualDestSet.Contains($dev)) { continue }
|
||||
$prevRow = $previousRows[$dev]
|
||||
$isNew = (-not $prevRow) -or (-not $global:AdvancedCache.ContainsKey($dev)) -or ($manualPairs.ContainsKey($dev) -and "$($prevRow."Destination Device")" -ne $manualPairs[$dev])
|
||||
if ($isNew) { $newSources.Add($dev) } else { $knownSources.Add($dev) }
|
||||
}
|
||||
$sourceDevices = @($newSources) + @($knownSources)
|
||||
$totalDevices = $sourceDevices.Count
|
||||
$global:PingedThisCycle.Clear()
|
||||
|
||||
$timing = [ordered]@{}
|
||||
foreach ($k in @("SCCM", "Cached", "NoAssoc", "Manual", "Skipped")) { $timing[$k] = @{ Count = 0; Ms = 0 } }
|
||||
$phaseWatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
Update-WorkerState "Software Check..." 0 $totalDevices $globalLastDuration "Calculating..."
|
||||
Write-Host "`n[Checking Software Cache Engine...]" -ForegroundColor Cyan
|
||||
$runFullSoftwareSweep = $false
|
||||
$currentDate = (Get-Date).ToString("yyyy-MM-dd")
|
||||
@@ -265,7 +479,9 @@ while ($true) {
|
||||
$cachedNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
if ($cacheObj) { foreach ($p in $cacheObj.psobject.properties) { $null = $cachedNames.Add($p.Name) } }
|
||||
|
||||
foreach ($dev in $uniqueDevices) {
|
||||
# New devices first here too, so their software is ready when their row is
|
||||
$softwareOrder = @($newSources) + @($uniqueDevices | Where-Object { -not $newSources.Contains($_) })
|
||||
foreach ($dev in $softwareOrder) {
|
||||
if ($runFullSoftwareSweep -or -not $cachedNames.Contains($dev)) {
|
||||
if ($v) { Write-Host "Fetching background software for $dev..." -ForegroundColor DarkCyan }
|
||||
Update-SoftwareCacheEntry $dev
|
||||
@@ -273,15 +489,21 @@ while ($true) {
|
||||
}
|
||||
}
|
||||
Write-Host "[Software Cache Synchronized]`n" -ForegroundColor Green
|
||||
$softwareSeconds = $phaseWatch.Elapsed.TotalSeconds
|
||||
$phaseWatch.Restart()
|
||||
|
||||
$deviceTable = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$processedSources = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
$currentDeviceCount = 0
|
||||
|
||||
Write-Host "--- Commencing Data Sweep ($totalDevices Devices) ---" -ForegroundColor White
|
||||
if ($newSources.Count -gt 0) { Write-Host "--- Commencing Data Sweep ($totalDevices Devices, $($newSources.Count) new first) ---" -ForegroundColor White }
|
||||
else { Write-Host "--- Commencing Data Sweep ($totalDevices Devices) ---" -ForegroundColor White }
|
||||
|
||||
foreach ($deviceName in $uniqueDevices) {
|
||||
foreach ($deviceName in $sourceDevices) {
|
||||
$currentDeviceCount++
|
||||
Update-WorkerState "Updating Devices..." $currentDeviceCount $totalDevices "N/A" "Calculating..."
|
||||
$deviceWatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$category = "SCCM"
|
||||
Update-WorkerState "Updating Devices..." $currentDeviceCount $totalDevices $globalLastDuration "Calculating..."
|
||||
Write-Host "[$currentDeviceCount/$totalDevices] Processing: $deviceName" -ForegroundColor Yellow
|
||||
|
||||
$tags = $deviceTags[$deviceName] -join ", "
|
||||
@@ -291,57 +513,39 @@ while ($true) {
|
||||
$deviceTagList = @($deviceTags[$deviceName])
|
||||
$allTagsSkipped = ($deviceTagList.Count -gt 0) -and @($deviceTagList | Where-Object { $skipUSMTTags.Contains($_) }).Count -gt 0
|
||||
if ($allTagsSkipped) {
|
||||
$category = "Skipped"
|
||||
if ($v) { Write-Host " -> [SKIP USMT] Tag marked Skip USMT. Caching Advanced/Software only." -ForegroundColor DarkMagenta }
|
||||
$null = Get-AdvancedData $deviceName -OnlineStatus "Skipped" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
$deviceTable.Add([PSCustomObject]@{
|
||||
"Source Device"=$deviceName; "Destination Device"="N/A";
|
||||
"Tags"=$tags; "Source Total Space"="N/A"; "Source Free Space"="N/A";
|
||||
"USMT Status"="Skipped"; "Backup Status"="Skipped";
|
||||
"Source Online"="Skipped"; "Destination Online"="-";
|
||||
"Source Scanstate Line"="N/A"; "Destination Loadstate Line"="N/A";
|
||||
"Source Scanstate File"="N/A"; "Destination Loadstate File"="N/A"
|
||||
"Source Scanstate Elapsed"="N/A"; "Destination Loadstate Elapsed"="N/A";
|
||||
})
|
||||
continue
|
||||
$row = New-DeviceRow $deviceName "N/A" $tags "Skipped"
|
||||
$row."USMT Status" = "Skipped"; $row."Backup Status" = "Skipped"
|
||||
$row."Source Online" = "Skipped"; $row."Destination Online" = "-"
|
||||
$deviceTable.Add([PSCustomObject]$row)
|
||||
}
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
if ($manualPairs.ContainsKey($deviceName)) {
|
||||
elseif ($manualPairs.ContainsKey($deviceName)) {
|
||||
$category = "Manual"
|
||||
$dName = $manualPairs[$deviceName]
|
||||
$sName = $deviceName
|
||||
if ($v) { Write-Host " -> [MANUAL MATCH] Hardware exception detected. Bypassing SCCM..." -ForegroundColor DarkYellow }
|
||||
|
||||
$pingSrc = pingDevice $sName
|
||||
$pingDst = pingDevice $dName
|
||||
$row = New-DeviceRow $sName $dName $tags "Manual"
|
||||
$row."Source Online" = Test-Device $sName -Session $session
|
||||
$row."Destination Online" = Test-Device $dName -Session $session
|
||||
$row."Source Checked" = Get-IsoNow; $row."Destination Checked" = $row."Source Checked"
|
||||
|
||||
$null = Get-AdvancedData $sName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
$null = Get-AdvancedData $dName -IsDest -OnlineStatus $pingDst -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
|
||||
$deviceTable.Add([PSCustomObject]@{
|
||||
"Source Device"=$sName; "Destination Device"=$dName;
|
||||
"Tags"=$tags; "Source Total Space"="N/A"; "Source Free Space"="N/A";
|
||||
"USMT Status"="N/A"; "Backup Status"="N/A";
|
||||
"Source Online"=$pingSrc; "Destination Online"=$pingDst;
|
||||
"Source Scanstate Line"="N/A"; "Destination Loadstate Line"="N/A";
|
||||
"Source Scanstate File"="N/A"; "Destination Loadstate File"="N/A"
|
||||
"Source Scanstate Elapsed"="N/A"; "Destination Loadstate Elapsed"="N/A";
|
||||
})
|
||||
continue # Move on to the next device
|
||||
$null = Get-AdvancedData $sName -OnlineStatus $row."Source Online" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
$null = Get-AdvancedData $dName -IsDest -OnlineStatus $row."Destination Online" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
$deviceTable.Add([PSCustomObject]$row)
|
||||
}
|
||||
else {
|
||||
if ($v) { Write-Host " -> Querying SCCM for associations..." -ForegroundColor DarkGray }
|
||||
$associations = Get-DeviceAssociations -SourceComputer $deviceName
|
||||
|
||||
# Prevent the destination machine from being swept a second time as a standalone source
|
||||
if ($manualPairs.Values -contains $deviceName -and -not $manualPairs.ContainsKey($deviceName)) {
|
||||
if ($v) { Write-Host " -> [MANUAL DEST] Device handled by pair logic. Skipping independent sweep." -ForegroundColor DarkGray }
|
||||
continue
|
||||
}
|
||||
|
||||
if ($v) { Write-Host " -> Querying SCCM for associations..." -ForegroundColor DarkGray }
|
||||
$associations = Get-DeviceAssociations -SourceComputer $deviceName
|
||||
|
||||
if (-not $associations) {
|
||||
$cached = $completedList | Where-Object { $_."Source Device".ToUpper() -eq $deviceName } | Select-Object -First 1
|
||||
if ($cached) {
|
||||
if ($v) { Write-Host " -> [CACHE HIT] But association was removed from SCCM." -ForegroundColor DarkYellow }
|
||||
if (-not $associations) {
|
||||
$category = "NoAssoc"
|
||||
$cached = $completedList | Where-Object { $_."Source Device".ToUpper() -eq $deviceName } | Select-Object -First 1
|
||||
if ($cached) {
|
||||
$category = "Cached"
|
||||
if ($v) { Write-Host " -> [CACHE HIT] But association was removed from SCCM." -ForegroundColor DarkYellow }
|
||||
|
||||
# Backfill dest software - association is gone so the assoc loop will never fetch it
|
||||
$cachedDest = "$($cached."Destination Device")"
|
||||
@@ -353,179 +557,223 @@ while ($true) {
|
||||
}
|
||||
}
|
||||
|
||||
$null = Get-AdvancedData $cached."Source Device" -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
$null = Get-AdvancedData $cached."Destination Device" -IsDest -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
$null = Get-AdvancedData $cached."Source Device" -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
$null = Get-AdvancedData $cached."Destination Device" -IsDest -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
|
||||
$deviceTable.Add([PSCustomObject]@{
|
||||
"Source Device"=$cached."Source Device"; "Destination Device"=$cached."Destination Device";
|
||||
"Tags"=$tags; "Source Total Space"="Cached"; "Source Free Space"="Cached";
|
||||
"USMT Status"="Not Associated"; "Backup Status"="Completed";
|
||||
"Source Online"="Cached"; "Destination Online"="Cached";
|
||||
"Source Scanstate Line"="Cached"; "Destination Loadstate Line"="Cached";
|
||||
"Source Scanstate File"="Cached"; "Destination Loadstate File"="Cached"
|
||||
"Source Scanstate Elapsed"="Cached"; "Destination Loadstate Elapsed"="Cached";
|
||||
})
|
||||
continue
|
||||
}
|
||||
$row = New-DeviceRow $cached."Source Device" $cached."Destination Device" $tags "Cached"
|
||||
foreach ($k in @("Source Total Space", "Source Free Space", "Source Online", "Destination Online", "Source Scanstate Line", "Destination Loadstate Line", "Source Scanstate File", "Destination Loadstate File", "Source Scanstate Elapsed", "Destination Loadstate Elapsed")) { $row[$k] = "Cached" }
|
||||
$row."USMT Status" = "Not Associated"; $row."Backup Status" = "Completed"
|
||||
Use-Snapshot $row."Source Device" $row."Destination Device" $row $true $true
|
||||
Set-RowPresence $row -Source -Destination
|
||||
$deviceTable.Add([PSCustomObject]$row)
|
||||
}
|
||||
else {
|
||||
if ($v) { Write-Host " -> No SCCM association found. Sweeping source device anyway..." -ForegroundColor DarkGray }
|
||||
|
||||
if ($v) { Write-Host " -> No SCCM association found. Sweeping source device anyway..." -ForegroundColor DarkGray }
|
||||
$row = New-DeviceRow $deviceName "N/A" $tags "None"
|
||||
$pingSrc = Test-Device $deviceName -Session $session
|
||||
$row."Source Online" = $pingSrc; $row."Destination Online" = "-"
|
||||
$row."Source Checked" = Get-IsoNow
|
||||
$row."USMT Status" = "Not Associated"; $row."Backup Status" = "Not Associated"
|
||||
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null; Elapsed = "N/A" }
|
||||
|
||||
$pingSrc = pingDevice $deviceName
|
||||
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null }
|
||||
$srcTotal = "N/A"
|
||||
$srcFree = "N/A"
|
||||
if ($pingSrc -eq "Online") {
|
||||
# --- DISK SKIP LIST CHECK (No-Association path) ---
|
||||
if ($diskSkipList -notcontains $deviceName) {
|
||||
$diskInfo = Get-DiskSpace $deviceName
|
||||
$row."Source Total Space" = $diskInfo.Total
|
||||
$row."Source Free Space" = $diskInfo.Free
|
||||
if ($diskInfo.Free -ne "N/A") { $row."Source Space Checked" = Get-IsoNow }
|
||||
} else {
|
||||
if ($v) { Write-Host " > Disk check skipped for $deviceName (on diskSkipList)" -ForegroundColor DarkYellow }
|
||||
}
|
||||
if ($diskSkipList -notcontains $deviceName -and $row."Source Free Space" -ne "N/A") { $logSrc = Get-LogInfo $deviceName $scanLogName }
|
||||
}
|
||||
$row."Source Scanstate Line" = $logSrc.Line
|
||||
$row."Source Scanstate File" = $logSrc.Path
|
||||
$row."Source Scanstate Elapsed" = $logSrc.Elapsed
|
||||
|
||||
if ($pingSrc -eq "Online") {
|
||||
# --- DISK SKIP LIST CHECK (No-Association path) ---
|
||||
if ($diskSkipList -notcontains $deviceName) {
|
||||
$diskInfo = Get-DiskSpace $deviceName
|
||||
$srcTotal = $diskInfo.Total
|
||||
$srcFree = $diskInfo.Free
|
||||
} else {
|
||||
if ($v) { Write-Host " > Disk check skipped for $deviceName (on diskSkipList)" -ForegroundColor DarkYellow }
|
||||
$srcLive = ($pingSrc -eq "Online" -and $row."Source Free Space" -ne "N/A")
|
||||
if ($srcLive) { Update-Snapshot $deviceName "N/A" $row $true $false }
|
||||
else { Use-Snapshot $deviceName "N/A" $row $true $false }
|
||||
|
||||
$null = Get-AdvancedData $deviceName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
$deviceTable.Add([PSCustomObject]$row)
|
||||
}
|
||||
if ($diskSkipList -notcontains $deviceName -and $srcFree -ne "N/A") { $logSrc = Get-LogInfo $deviceName $scanLogName }
|
||||
}
|
||||
else {
|
||||
foreach ($assoc in $associations) {
|
||||
$assoc.Get()
|
||||
$dName = if ($assoc.DestinationComputer) { $assoc.DestinationComputer } else { $assoc.RestoreName }
|
||||
$sName = if ($assoc.SourceComputer) { $assoc.SourceComputer } else { $deviceName }
|
||||
|
||||
$null = Get-AdvancedData $deviceName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
if (-not [string]::IsNullOrWhiteSpace($dName) -and $dName -ne "N/A") {
|
||||
if ($runFullSoftwareSweep -or -not $cachedNames.Contains($dName)) {
|
||||
if ($v) { Write-Host " -> [CACHE MISS] Fetching software for Dest Device $dName..." -ForegroundColor DarkCyan }
|
||||
Update-SoftwareCacheEntry $dName
|
||||
$null = $cachedNames.Add($dName)
|
||||
}
|
||||
}
|
||||
|
||||
$deviceTable.Add([PSCustomObject]@{
|
||||
"Source Device"=$deviceName; "Destination Device"="N/A";
|
||||
"Tags"=$tags; "Source Total Space"=$srcTotal; "Source Free Space"=$srcFree;
|
||||
"USMT Status"="Not Associated"; "Backup Status"="Not Associated";
|
||||
"Source Online"=$pingSrc; "Destination Online"="-";
|
||||
"Source Scanstate Line"=$logSrc.Line; "Destination Loadstate Line"="N/A";
|
||||
"Source Scanstate File"=$logSrc.Path; "Destination Loadstate File"="N/A"
|
||||
"Source Scanstate Elapsed"=$logSrc.Elapsed; "Destination Loadstate Elapsed"="N/A";
|
||||
})
|
||||
continue
|
||||
$usmtStatusRaw = $assoc.MigrationStatus
|
||||
$usmtStatusStr = switch ($usmtStatusRaw) { 0 {"Not Started"} 1 {"In Progress"} 2 {"Completed"} default {"Unknown"} }
|
||||
|
||||
if ($v) { Write-Host " -> SCCM DB Status: $usmtStatusStr (Raw: $usmtStatusRaw)" -ForegroundColor DarkGray }
|
||||
|
||||
$cached = $completedList | Where-Object { $_."Source Device".ToUpper() -eq $sName.ToUpper() -and $_."Destination Device".ToUpper() -eq $dName.ToUpper() }
|
||||
|
||||
if ($cached -and $usmtStatusRaw -eq 0) {
|
||||
if ($v) { Write-Host " -> [CACHE RESET] SCCM Status reverted to Not Started. Resetting..." -ForegroundColor DarkYellow }
|
||||
$completedList = @($completedList | Where-Object { $_."Source Device".ToUpper() -ne $sName.ToUpper() -or $_."Destination Device".ToUpper() -ne $dName.ToUpper() })
|
||||
$jsonCompleted = ConvertTo-Json -InputObject @($completedList) -Depth 5
|
||||
[System.IO.File]::WriteAllText($completedPath, $jsonCompleted, [System.Text.Encoding]::UTF8)
|
||||
$cached = $null
|
||||
}
|
||||
|
||||
if ($cached) {
|
||||
$category = "Cached"
|
||||
if ($v) { Write-Host " -> [CACHE HIT] Fast-tracking row and bypassing network sweeps." -ForegroundColor Green }
|
||||
$null = Get-AdvancedData $sName -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
$null = Get-AdvancedData $dName -IsDest -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
|
||||
$row = New-DeviceRow $sName $dName $tags "Cached"
|
||||
foreach ($k in @("Source Total Space", "Source Free Space", "Source Online", "Destination Online", "Source Scanstate Line", "Destination Loadstate Line", "Source Scanstate File", "Destination Loadstate File", "Source Scanstate Elapsed", "Destination Loadstate Elapsed")) { $row[$k] = "Cached" }
|
||||
$row."USMT Status" = $usmtStatusStr; $row."Backup Status" = "Completed"
|
||||
# Last state seen before it completed (with its timestamp) instead of "Cached"
|
||||
Use-Snapshot $sName $dName $row $true $true
|
||||
Set-RowPresence $row -Source -Destination
|
||||
if ($v -and $row."Source Snapshot") { Write-Host " > Showing snapshot from $(([datetime]$row."Source Snapshot").ToString('MM/dd hh:mm tt'))" -ForegroundColor DarkGray }
|
||||
$deviceTable.Add([PSCustomObject]$row)
|
||||
continue
|
||||
}
|
||||
|
||||
$row = New-DeviceRow $sName $dName $tags "SCCM"
|
||||
$row."USMT Status" = $usmtStatusStr; $row."Backup Status" = "Checking..."
|
||||
|
||||
if ($v) { Write-Host " -> [SWEEP SOURCE] $sName" -ForegroundColor DarkCyan }
|
||||
$pingSrc = Test-Device $sName -Session $session
|
||||
$row."Source Online" = $pingSrc
|
||||
$row."Source Checked" = Get-IsoNow
|
||||
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null; Elapsed = "N/A" }
|
||||
|
||||
if ($pingSrc -eq "Online") {
|
||||
# --- DISK SKIP LIST CHECK (Association path) ---
|
||||
if ($diskSkipList -notcontains $sName) {
|
||||
$diskInfo = Get-DiskSpace $sName
|
||||
$row."Source Total Space" = $diskInfo.Total
|
||||
$row."Source Free Space" = $diskInfo.Free
|
||||
if ($diskInfo.Free -ne "N/A") { $row."Source Space Checked" = Get-IsoNow }
|
||||
} else {
|
||||
if ($v) { Write-Host " > Disk check skipped for $sName (on diskSkipList)" -ForegroundColor DarkYellow }
|
||||
}
|
||||
if ($diskSkipList -notcontains $sName -and $row."Source Free Space" -ne "N/A") { $logSrc = Get-LogInfo $sName $scanLogName }
|
||||
}
|
||||
$row."Source Scanstate Line" = $logSrc.Line
|
||||
$row."Source Scanstate File" = $logSrc.Path
|
||||
$row."Source Scanstate Elapsed" = $logSrc.Elapsed
|
||||
|
||||
if ($v) { Write-Host " -> [SWEEP DESTINATION] $dName" -ForegroundColor DarkCyan }
|
||||
$pingDst = Test-Device $dName -Session $session
|
||||
$row."Destination Online" = $pingDst
|
||||
$row."Destination Checked" = Get-IsoNow
|
||||
$logDst = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingDst)"; Path = "Unreachable"; LastModified = $null; Elapsed = "N/A" }
|
||||
|
||||
if ($pingDst -eq "Online") { $logDst = Get-LogInfo $dName $loadLogName }
|
||||
$row."Destination Loadstate Line" = $logDst.Line
|
||||
$row."Destination Loadstate File" = $logDst.Path
|
||||
$row."Destination Loadstate Elapsed" = $logDst.Elapsed
|
||||
|
||||
if ($v) { Write-Host " -> Calculating Master Status..." -ForegroundColor DarkGray }
|
||||
$timeThreshold = (Get-Date).AddHours(-1 * $config.Worker.ActiveLogHours)
|
||||
|
||||
$isSrcLogActive = ($logSrc.Found -and $logSrc.Path -match "SMSTSLog" -and $logSrc.LastModified -gt $timeThreshold)
|
||||
$isDstLogActive = ($logDst.Found -and $logDst.Path -match "SMSTSLog" -and $logDst.LastModified -gt $timeThreshold)
|
||||
|
||||
if ($logDst.IsSuccess -or $usmtStatusRaw -eq 2) { $row."Backup Status" = "Completed" }
|
||||
elseif ($usmtStatusRaw -eq 1 -and $isDstLogActive) { $row."Backup Status" = "Restore In Progress" }
|
||||
elseif ($usmtStatusRaw -eq 1) { $row."Backup Status" = "Backup Completed" }
|
||||
elseif ($usmtStatusRaw -eq 0 -and $isSrcLogActive) { $row."Backup Status" = "Backup In Progress" }
|
||||
else { $row."Backup Status" = "Not Started" }
|
||||
|
||||
# Remember what we could read; fill what we couldn't from the last snapshot
|
||||
$srcLive = ($pingSrc -eq "Online" -and $row."Source Free Space" -ne "N/A")
|
||||
$dstLive = ($pingDst -eq "Online" -and $logDst.Found)
|
||||
Update-Snapshot $sName $dName $row $srcLive $dstLive
|
||||
Use-Snapshot $sName $dName $row (-not $srcLive) (-not $dstLive -and $pingDst -ne "Online")
|
||||
|
||||
if ($v) { Write-Host " -> Fetching Advanced SQL/AD Data..." -ForegroundColor DarkGray }
|
||||
$null = Get-AdvancedData $sName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
$null = Get-AdvancedData $dName -IsDest -OnlineStatus $pingDst -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
|
||||
$finalRow = [PSCustomObject]$row
|
||||
Write-Host " - Status: $($finalRow."Backup Status")" -ForegroundColor Cyan
|
||||
$deviceTable.Add($finalRow)
|
||||
|
||||
if ($finalRow."Backup Status" -eq "Completed") {
|
||||
$completedList += [PSCustomObject]@{ "Source Device"=$sName; "Destination Device"=$dName }
|
||||
$jsonCompleted = ConvertTo-Json -InputObject @($completedList) -Depth 5
|
||||
[System.IO.File]::WriteAllText($completedPath, $jsonCompleted, [System.Text.Encoding]::UTF8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($assoc in $associations) {
|
||||
$assoc.Get()
|
||||
$dName = if ($assoc.DestinationComputer) { $assoc.DestinationComputer } else { $assoc.RestoreName }
|
||||
$sName = if ($assoc.SourceComputer) { $assoc.SourceComputer } else { $deviceName }
|
||||
$null = $processedSources.Add($deviceName)
|
||||
$timing[$category].Count++
|
||||
$timing[$category].Ms += $deviceWatch.Elapsed.TotalMilliseconds
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($dName) -and $dName -ne "N/A") {
|
||||
if ($runFullSoftwareSweep -or -not $cachedNames.Contains($dName)) {
|
||||
if ($v) { Write-Host " -> [CACHE MISS] Fetching software for Dest Device $dName..." -ForegroundColor DarkCyan }
|
||||
Update-SoftwareCacheEntry $dName
|
||||
$null = $cachedNames.Add($dName)
|
||||
}
|
||||
}
|
||||
|
||||
$usmtStatusRaw = $assoc.MigrationStatus
|
||||
$usmtStatusStr = switch ($usmtStatusRaw) { 0 {"Not Started"} 1 {"In Progress"} 2 {"Completed"} default {"Unknown"} }
|
||||
|
||||
if ($v) { Write-Host " -> SCCM DB Status: $usmtStatusStr (Raw: $usmtStatusRaw)" -ForegroundColor DarkGray }
|
||||
|
||||
$cached = $completedList | Where-Object { $_."Source Device".ToUpper() -eq $sName.ToUpper() -and $_."Destination Device".ToUpper() -eq $dName.ToUpper() }
|
||||
|
||||
if ($cached -and $usmtStatusRaw -eq 0) {
|
||||
if ($v) { Write-Host " -> [CACHE RESET] SCCM Status reverted to Not Started. Resetting..." -ForegroundColor DarkYellow }
|
||||
$completedList = @($completedList | Where-Object { $_."Source Device".ToUpper() -ne $sName.ToUpper() -or $_."Destination Device".ToUpper() -ne $dName.ToUpper() })
|
||||
$jsonCompleted = ConvertTo-Json -InputObject @($completedList) -Depth 5
|
||||
[System.IO.File]::WriteAllText($completedPath, $jsonCompleted, [System.Text.Encoding]::UTF8)
|
||||
$cached = $null
|
||||
}
|
||||
|
||||
if ($cached) {
|
||||
if ($v) { Write-Host " -> [CACHE HIT] Fast-tracking row and bypassing network sweeps." -ForegroundColor Green }
|
||||
$null = Get-AdvancedData $sName -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
$null = Get-AdvancedData $dName -IsDest -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
|
||||
$deviceTable.Add([PSCustomObject]@{
|
||||
"Source Device"=$sName; "Destination Device"=$dName;
|
||||
"Tags"=$tags; "Source Total Space"="Cached"; "Source Free Space"="Cached";
|
||||
"USMT Status"=$usmtStatusStr; "Backup Status"="Completed";
|
||||
"Source Online"="Cached"; "Destination Online"="Cached";
|
||||
"Source Scanstate Line"="Cached"; "Destination Loadstate Line"="Cached";
|
||||
"Source Scanstate File"="Cached"; "Destination Loadstate File"="Cached"
|
||||
"Source Scanstate Elapsed"="Cached"; "Destination Loadstate Elapsed"="Cached";
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
$row = [ordered]@{
|
||||
"Source Device"=$sName; "Destination Device"=$dName; "Tags"=$tags;
|
||||
"Source Total Space"="N/A"; "Source Free Space"="N/A";
|
||||
"USMT Status"=$usmtStatusStr; "Backup Status"="Checking...";
|
||||
"Source Online"="N/A"; "Destination Online"="N/A";
|
||||
"Source Scanstate Line"="N/A"; "Destination Loadstate Line"="N/A";
|
||||
"Source Scanstate File"="N/A"; "Destination Loadstate File"="N/A"
|
||||
"Source Scanstate Elapsed"="N/A"; "Destination Loadstate Elapsed"="N/A";
|
||||
}
|
||||
|
||||
if ($v) { Write-Host " -> [SWEEP SOURCE] $sName" -ForegroundColor DarkCyan }
|
||||
$pingSrc = pingDevice $sName
|
||||
$row."Source Online" = $pingSrc
|
||||
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null }
|
||||
|
||||
if ($pingSrc -eq "Online") {
|
||||
# --- DISK SKIP LIST CHECK (Association path) ---
|
||||
if ($diskSkipList -notcontains $sName) {
|
||||
$diskInfo = Get-DiskSpace $sName
|
||||
$row."Source Total Space" = $diskInfo.Total
|
||||
$row."Source Free Space" = $diskInfo.Free
|
||||
} else {
|
||||
if ($v) { Write-Host " > Disk check skipped for $sName (on diskSkipList)" -ForegroundColor DarkYellow }
|
||||
}
|
||||
if ($diskSkipList -notcontains $sName -and $row."Source Free Space" -ne "N/A") { $logSrc = Get-LogInfo $sName $scanLogName }
|
||||
}
|
||||
$row."Source Scanstate Line" = $logSrc.Line
|
||||
$row."Source Scanstate File" = $logSrc.Path
|
||||
$row."Source Scanstate Elapsed" = $logSrc.Elapsed
|
||||
|
||||
if ($v) { Write-Host " -> [SWEEP DESTINATION] $dName" -ForegroundColor DarkCyan }
|
||||
$pingDst = pingDevice $dName
|
||||
$row."Destination Online" = $pingDst
|
||||
$logDst = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingDst)"; Path = "Unreachable"; LastModified = $null }
|
||||
|
||||
if ($pingDst -eq "Online") { $logDst = Get-LogInfo $dName $loadLogName }
|
||||
$row."Destination Loadstate Line" = $logDst.Line
|
||||
$row."Destination Loadstate File" = $logDst.Path
|
||||
$row."Destination Loadstate Elapsed" = $logDst.Elapsed
|
||||
|
||||
if ($v) { Write-Host " -> Calculating Master Status..." -ForegroundColor DarkGray }
|
||||
$timeThreshold = (Get-Date).AddHours(-1 * $config.Worker.ActiveLogHours)
|
||||
|
||||
$isSrcLogActive = ($logSrc.Found -and $logSrc.Path -match "SMSTSLog" -and $logSrc.LastModified -gt $timeThreshold)
|
||||
$isDstLogActive = ($logDst.Found -and $logDst.Path -match "SMSTSLog" -and $logDst.LastModified -gt $timeThreshold)
|
||||
|
||||
if ($logDst.IsSuccess -or $usmtStatusRaw -eq 2) { $row."Backup Status" = "Completed" }
|
||||
elseif ($usmtStatusRaw -eq 1 -and $isDstLogActive) { $row."Backup Status" = "Restore In Progress" }
|
||||
elseif ($usmtStatusRaw -eq 1) { $row."Backup Status" = "Backup Completed" }
|
||||
elseif ($usmtStatusRaw -eq 0 -and $isSrcLogActive) { $row."Backup Status" = "Backup In Progress" }
|
||||
else { $row."Backup Status" = "Not Started" }
|
||||
|
||||
if ($v) { Write-Host " -> Fetching Advanced SQL/AD Data..." -ForegroundColor DarkGray }
|
||||
$null = Get-AdvancedData $sName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
$null = Get-AdvancedData $dName -IsDest -OnlineStatus $pingDst -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
|
||||
$finalRow = [PSCustomObject]$row
|
||||
Write-Host " - Status: $($finalRow."Backup Status")" -ForegroundColor Cyan
|
||||
$deviceTable.Add($finalRow)
|
||||
|
||||
if ($finalRow."Backup Status" -eq "Completed") {
|
||||
$completedList += [PSCustomObject]@{ "Source Device"=$sName; "Destination Device"=$dName }
|
||||
$jsonCompleted = ConvertTo-Json -InputObject @($completedList) -Depth 5
|
||||
[System.IO.File]::WriteAllText($completedPath, $jsonCompleted, [System.Text.Encoding]::UTF8)
|
||||
}
|
||||
# Newly tracked devices are done: publish them now rather than at the end of the cycle
|
||||
if ($newSources.Count -gt 0 -and $knownSources.Count -gt 0 -and $currentDeviceCount -eq $newSources.Count) {
|
||||
Write-Host " -> [PRIORITY] $($newSources.Count) new device(s) swept. Publishing early..." -ForegroundColor Green
|
||||
$interim = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($r in $deviceTable) { $interim.Add($r) }
|
||||
foreach ($dev in $knownSources) { if ($previousRows.ContainsKey($dev)) { $interim.Add($previousRows[$dev]) } }
|
||||
Save-AdvancedCache
|
||||
Write-JsonFile $jsonPath ([PSCustomObject]@{ LastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt"); Devices = $interim })
|
||||
}
|
||||
}
|
||||
$sweepSeconds = $phaseWatch.Elapsed.TotalSeconds
|
||||
$phaseWatch.Restart()
|
||||
|
||||
# --- PRESENCE SWEEP: ping everything the sweep didn't (cached pairs, skipped tags...) ---
|
||||
$presenceCount = 0
|
||||
$presenceDue = ((Get-Date) - $global:LastPresenceSweep).TotalMinutes -ge ($presenceMinutes - 0.5) -or $runFullSoftwareSweep
|
||||
if ($presenceDue) {
|
||||
$everyone = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($r in $deviceTable) {
|
||||
foreach ($n in @($r."Source Device", $r."Destination Device")) { if ($n -and $n -ne "N/A") { $null = $everyone.Add($n) } }
|
||||
}
|
||||
$toPing = @($everyone | Where-Object { -not $global:PingedThisCycle.Contains($_) } | Sort-Object)
|
||||
Write-Host "`n[Presence Sweep: $($toPing.Count) device(s) not pinged this cycle]" -ForegroundColor Cyan
|
||||
$i = 0
|
||||
foreach ($n in $toPing) {
|
||||
$i++
|
||||
Update-WorkerState "Presence Sweep..." $i $toPing.Count $globalLastDuration "Calculating..."
|
||||
$null = Test-Device $n -Session $session
|
||||
}
|
||||
# Cached / skipped rows pick up the fresh result
|
||||
foreach ($r in $deviceTable) {
|
||||
if ($r.Pairing -eq "Cached") { Set-RowPresence $r -Source -Destination }
|
||||
}
|
||||
$presenceCount = $toPing.Count
|
||||
$global:LastPresenceSweep = Get-Date
|
||||
Write-Host "[Presence Updated]" -ForegroundColor Green
|
||||
}
|
||||
$presenceSeconds = $phaseWatch.Elapsed.TotalSeconds
|
||||
$phaseWatch.Restart()
|
||||
|
||||
Close-ProviderCycle $session
|
||||
|
||||
$activeDevs = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($row in $deviceTable) {
|
||||
if ($row."Source Device" -and $row."Source Device" -ne "N/A") { $null = $activeDevs.Add($row."Source Device") }
|
||||
if ($row."Destination Device" -and $row."Destination Device" -ne "N/A") { $null = $activeDevs.Add($row."Destination Device") }
|
||||
}
|
||||
|
||||
if ($v) { Write-Host " -> Pruning and Compressing Software Cache..." -ForegroundColor DarkGray }
|
||||
if (Test-Path $softwareCachePath) {
|
||||
try {
|
||||
$rawCache = [System.IO.File]::ReadAllText($softwareCachePath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
||||
$activeDevs = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($row in $deviceTable) {
|
||||
if ($row."Source Device" -and $row."Source Device" -ne "N/A") { $null = $activeDevs.Add($row."Source Device") }
|
||||
if ($row."Destination Device" -and $row."Destination Device" -ne "N/A") { $null = $activeDevs.Add($row."Destination Device") }
|
||||
}
|
||||
|
||||
$pruned = $false
|
||||
foreach ($p in @($rawCache.psobject.properties.Name)) {
|
||||
if (-not $activeDevs.Contains($p)) {
|
||||
@@ -537,28 +785,27 @@ while ($true) {
|
||||
$jsonSoftCache = $rawCache | ConvertTo-Json -Depth 6 -Compress
|
||||
[System.IO.File]::WriteAllText($softwareCachePath, $jsonSoftCache, [System.Text.Encoding]::UTF8)
|
||||
if ($pruned -and $v) { Write-Host " -> Removed untracked devices from cache." -ForegroundColor Green }
|
||||
|
||||
if ($global:AdvancedCacheUpdated -or $pruned -or $runFullSoftwareSweep) {
|
||||
if ($v) { Write-Host " -> Pruning and Saving Advanced Info Cache..." -ForegroundColor DarkGray }
|
||||
$keysToRemove = @()
|
||||
foreach ($key in $global:AdvancedCache.Keys) {
|
||||
if (-not $activeDevs.Contains($key)) { $keysToRemove += $key }
|
||||
}
|
||||
foreach ($key in $keysToRemove) { $global:AdvancedCache.Remove($key) }
|
||||
|
||||
$jsonAdvCache = $global:AdvancedCache | ConvertTo-Json -Depth 5 -Compress
|
||||
[System.IO.File]::WriteAllText($advancedCachePath, $jsonAdvCache, [System.Text.Encoding]::UTF8)
|
||||
$global:AdvancedCacheUpdated = $false
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# Presence changes every cycle, so the advanced cache (which carries it) is saved every cycle
|
||||
try {
|
||||
if ($v) { Write-Host " -> Pruning and Saving Advanced Info Cache..." -ForegroundColor DarkGray }
|
||||
foreach ($key in @($global:AdvancedCache.Keys)) { if (-not $activeDevs.Contains($key)) { $global:AdvancedCache.Remove($key) } }
|
||||
foreach ($key in @($global:Presence.Keys)) { if (-not $activeDevs.Contains($key)) { $global:Presence.Remove($key) } }
|
||||
foreach ($key in @($global:Snapshots.Keys)) { if (-not $activeDevs.Contains($key)) { $global:Snapshots.Remove($key) } }
|
||||
Save-AdvancedCache
|
||||
Write-JsonFile $snapshotPath $global:Snapshots 4
|
||||
$global:AdvancedCacheUpdated = $false
|
||||
} catch { Write-Host "Could not save caches: $($_.Exception.GetType().Name)" -ForegroundColor Red }
|
||||
|
||||
if ($v) { Write-Host " -> Writing payload to Data.json..." -ForegroundColor DarkGray }
|
||||
$globalLastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt")
|
||||
$exportPayload = [PSCustomObject]@{ LastUpdated = $globalLastUpdated; Devices = $deviceTable }
|
||||
|
||||
$jsonFinal = $exportPayload | ConvertTo-Json -Depth 6
|
||||
[System.IO.File]::WriteAllText($jsonPath, $jsonFinal, [System.Text.Encoding]::UTF8)
|
||||
$saveSeconds = $phaseWatch.Elapsed.TotalSeconds
|
||||
|
||||
$cycleEnd = Get-Date
|
||||
$cycleTime = $cycleEnd - $cycleStart
|
||||
@@ -566,6 +813,26 @@ while ($true) {
|
||||
|
||||
Write-Host "`nCycle Complete in $globalLastDuration." -ForegroundColor Gray
|
||||
|
||||
# How the time was spent, per kind of row (shown in the dashboard's sync dialog)
|
||||
$labels = [ordered]@{ SCCM = "SCCM associations"; Manual = "Manual pairs"; Cached = "Completed (snapshot)"; NoAssoc = "No association"; Skipped = "Skip USMT tags" }
|
||||
$cats = @()
|
||||
foreach ($k in $labels.Keys) {
|
||||
$t = $timing[$k]
|
||||
if ($t.Count -eq 0) { continue }
|
||||
$cats += [PSCustomObject]@{ Key = $k; Label = $labels[$k]; Count = $t.Count; Seconds = [math]::Round($t.Ms / 1000, 1); AvgMs = [int]($t.Ms / $t.Count) }
|
||||
if ($v) { Write-Host (" {0,-22} {1,4} device(s) {2,7:N1}s (avg {3} ms)" -f $labels[$k], $t.Count, ($t.Ms / 1000), [int]($t.Ms / $t.Count)) -ForegroundColor DarkGray }
|
||||
}
|
||||
$global:LastBreakdown = [PSCustomObject]@{
|
||||
Finished = Get-IsoNow
|
||||
Seconds = [math]::Round($cycleTime.TotalSeconds, 1)
|
||||
Sources = $totalDevices
|
||||
NewFirst = $newSources.Count
|
||||
FullRefresh = $runFullSoftwareSweep
|
||||
Categories = $cats
|
||||
Phases = [PSCustomObject]@{ Software = [math]::Round($softwareSeconds, 1); Sweep = [math]::Round($sweepSeconds, 1); Presence = [math]::Round($presenceSeconds, 1); Save = [math]::Round($saveSeconds, 1) }
|
||||
PresenceSweep = [PSCustomObject]@{ Ran = $presenceDue; Pinged = $presenceCount; Last = $(if ($global:LastPresenceSweep -gt [datetime]::MinValue) { $global:LastPresenceSweep.ToString("o") } else { $null }); EveryMinutes = $presenceMinutes }
|
||||
}
|
||||
|
||||
$nextRun = Get-NextRunTime (Get-Date)
|
||||
$nextRunStr = $nextRun.ToString("MM/dd/yy hh:mm tt")
|
||||
|
||||
|
||||
+34
-13
@@ -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']]);
|
||||
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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(' · ')}</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)} · Sweep ${secs(ph.Sweep || 0)} · Presence ${secs(ph.Presence || 0)} · Save ${secs(ph.Save || 0)}</div>
|
||||
${presence ? `<div class="sync-bd-phases">${presence}</div>` : ''}`;
|
||||
};
|
||||
})();
|
||||
+9
-5
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+198
-100
@@ -196,11 +196,11 @@
|
||||
.sort-icon.active { color: white; font-weight: bold; }
|
||||
|
||||
/* ===== Custom View responsive table ===== */
|
||||
.custom-scroll-area { overflow: auto; }
|
||||
#custom { min-width: 0; max-width: 100%; width: 100%; overflow-x: hidden; box-sizing: border-box; }
|
||||
#custom * { min-width: 0; }
|
||||
#wrapper-custom { min-width: 0; max-width: 100%; width: 100%; overflow: hidden; box-sizing: border-box; }
|
||||
#scroll-custom { max-width: 100%; overflow: auto; max-height: calc(100vh - 60px - 50px - 210px); }
|
||||
.custom-scroll-area { overflow: auto; }
|
||||
#custom { min-width: 0; max-width: 100%; width: 100%; overflow-x: hidden; box-sizing: border-box; }
|
||||
#custom * { min-width: 0; }
|
||||
#wrapper-custom { min-width: 0; max-width: 100%; width: 100%; overflow: hidden; box-sizing: border-box; }
|
||||
#scroll-custom { max-width: 100%; overflow: auto; max-height: calc(100vh - 60px - 50px - 210px); }
|
||||
#custom-table-host { max-width: none; }
|
||||
#wrapper-custom.has-extra .custom-scroll-area thead th { position: sticky; top: 0; z-index: 20; box-shadow: inset 0 -2px 0 var(--border-light); }
|
||||
/* Header: bordered drag-grip strip on top; label left; icons grouped far right */
|
||||
@@ -226,10 +226,10 @@
|
||||
#table-custom .custom-th .col-remove { color: var(--danger, #e74c3c); font-weight: bold; cursor: pointer; opacity: 0; transition: opacity 0.15s; font-size: 1em; }
|
||||
#table-custom .custom-th:hover .col-remove { opacity: 1; }
|
||||
/* Full-height resize handles (overlay), active anywhere along the column border */
|
||||
#custom-resize-overlay { z-index: 40; }
|
||||
#custom-resize-overlay { z-index: 40; }
|
||||
#custom-resize-overlay .rz-handle { position: absolute; top: 0; width: 9px; height: 100%; cursor: col-resize; pointer-events: auto; transform: translateX(-50%); z-index: 40; }
|
||||
#custom-resize-overlay .rz-handle::after { content: ""; position: absolute; left: 50%; top: 0; width: 1px; height: 100%; background: transparent; transform: translateX(-50%); }
|
||||
#table-custom .custom-spacer-th, #table-custom .custom-spacer-td { border-right: none !important; border-left: none !important; background: var(--bg-card); }
|
||||
#table-custom .custom-spacer-th, #table-custom .custom-spacer-td { border-right: none !important; border-left: none !important; background: var(--bg-card); }
|
||||
#table-custom thead th.no-copy:not(.custom-th) { border-left: none; }
|
||||
#wrapper-custom.has-extra #table-custom tbody tr:hover { filter: brightness(1.08); }
|
||||
#wrapper-custom.has-extra #table-custom td, #wrapper-custom.has-extra #table-custom th { border-right: 1px solid var(--border-light); }
|
||||
@@ -353,6 +353,7 @@
|
||||
</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,13 +3260,15 @@
|
||||
Department: "Department", Building: "Building", Location: "Location", Vanity: "Vanity",
|
||||
PhoneExt: "Phone Ext", Mount: "Mount", RoomType: "Room Type",
|
||||
PrimaryUser: "Primary User", LastUser: "Last User", SCCMPrimaryUsers: "SCCM Primary Users",
|
||||
Updated: "Asset Info Updated", InfoBy: "Info Entered By", Notes: "Notes"
|
||||
Updated: "Asset Info Updated", InfoBy: "Info Entered By", Notes: "Notes",
|
||||
MonitorCount: "Monitor Count", Monitors: "Monitors",
|
||||
DaysOffline: "Days Offline", LastOnline: "Last Online", LastChecked: "Last Ping Check", FoundIn: "Found In (AD/SCCM/DNS)"
|
||||
};
|
||||
const ADV_FIELD_ORDER = ["IP","MAC","OU","Groups","Model","RAM","Disk","ClientStatus","Department","Building","Location","Vanity","PhoneExt","Mount","RoomType","PrimaryUser","LastUser","SCCMPrimaryUsers","Updated","InfoBy","Notes"];
|
||||
const ADV_FIELD_ORDER = ["DaysOffline","LastOnline","LastChecked","FoundIn","IP","MAC","OU","Groups","Model","RAM","Disk","MonitorCount","Monitors","ClientStatus","Department","Building","Location","Vanity","PhoneExt","Mount","RoomType","PrimaryUser","LastUser","SCCMPrimaryUsers","Updated","InfoBy","Notes"];
|
||||
|
||||
let customColumns = JSON.parse(localStorage.getItem('usmt_custom_columns')) || [...CUSTOM_DEFAULT_COLS]; // ordered list of column ids
|
||||
let customColWidths = JSON.parse(localStorage.getItem('usmt_custom_widths')) || {}; // id -> px width
|
||||
let customWidthsLocked = localStorage.getItem('usmt_custom_widths_locked') === '1'; // true once user manually resizes
|
||||
let customColWidths = JSON.parse(localStorage.getItem('usmt_custom_widths')) || {}; // id -> px width
|
||||
let customWidthsLocked = localStorage.getItem('usmt_custom_widths_locked') === '1'; // true once user manually resizes
|
||||
const MIN_COL_W = 110; // minimum column width (fits the header icon row)
|
||||
// Per-column filter: { text:"", excluded:[values] }. Excluded stored as arrays in localStorage.
|
||||
let customFilters = JSON.parse(localStorage.getItem('usmt_custom_filters')) || {}; // id -> {text, excluded:[]}
|
||||
@@ -3270,7 +3307,7 @@
|
||||
function getAdvKeys() {
|
||||
let keys = new Set(ADV_FIELD_ORDER);
|
||||
Object.values(advancedDataCache || {}).forEach(obj => {
|
||||
if (obj && typeof obj === 'object') Object.keys(obj).forEach(k => keys.add(k));
|
||||
if (obj && typeof obj === 'object') Object.keys(obj).forEach(k => { if (!DeviceInfo.HIDDEN.has(k)) keys.add(k); });
|
||||
});
|
||||
return Array.from(keys).sort((a, b) => {
|
||||
let ia = ADV_FIELD_ORDER.indexOf(a), ib = ADV_FIELD_ORDER.indexOf(b);
|
||||
@@ -3393,8 +3430,8 @@
|
||||
function getCustomCellValue(row, col) {
|
||||
const blank = v => (v === undefined || v === null || v === "") ? "N/A" : v;
|
||||
if (col.kind === "apiKey") return blank(row[col.key]);
|
||||
if (col.kind === "adv") return blank((advancedDataCache[row["Source Device"]] || {})[col.key]);
|
||||
if (col.kind === "advDst") return blank((advancedDataCache[row["Destination Device"]] || {})[col.key]);
|
||||
if (col.kind === "adv") return DeviceInfo.field(row["Source Device"], col.key).text;
|
||||
if (col.kind === "advDst") return DeviceInfo.field(row["Destination Device"], col.key).text;
|
||||
if (col.kind === "special") return getCustomSpecialty(row["Source Device"]);
|
||||
if (col.kind === "migration") {
|
||||
if (col.fn === "space") return (row["Source Free Space"] && row["Source Free Space"] !== "N/A") ? row["Source Free Space"] + " / " + row["Source Total Space"] : "N/A";
|
||||
@@ -3404,35 +3441,35 @@
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
// Returns {td-inner-html, cellStyle} for a column, replicating All Devices styling.
|
||||
function renderCustomCell(row, col, val) {
|
||||
const safe = String(val).replace(/"/g, '"');
|
||||
// Ping columns: colored + bold
|
||||
if (col.id === "__srcping" || col.id === "__destping") {
|
||||
return { html: `<b>${val}</b>`, style: `color:${getPingColor(val)}; font-weight:bold;` };
|
||||
}
|
||||
// Device names: bold + inline copy
|
||||
if (col.kind === "apiKey" && (col.key === "Source Device" || col.key === "Destination Device")) {
|
||||
return { html: `<b>${val}</b>${inlineCopyBtn(String(val))}`, style: "" };
|
||||
}
|
||||
// Backup / USMT status: bold
|
||||
if (col.key === "Backup Status" || col.id === "__usmt") {
|
||||
return { html: `<b>${val}</b>`, style: "" };
|
||||
}
|
||||
// Specialty Software: hyperlink any app that has documentation
|
||||
if (col.kind === "special") {
|
||||
if (!val || val === "None" || val === "Loading...") return { html: val, style: "" };
|
||||
const linked = val.split(", ").map(app => {
|
||||
const m = availableDocs.find(d => d.toLowerCase() === app.toLowerCase());
|
||||
return m
|
||||
? `<a href="?doc=${encodeURIComponent(m)}" target="_blank" onclick="event.stopPropagation();" style="color:#3498db; font-weight:bold;">${app}</a>`
|
||||
: app;
|
||||
});
|
||||
return { html: linked.join(", "), style: "" };
|
||||
}
|
||||
return { html: safe, style: "" };
|
||||
}
|
||||
|
||||
// Returns {td-inner-html, cellStyle} for a column, replicating All Devices styling.
|
||||
function renderCustomCell(row, col, val) {
|
||||
const safe = String(val).replace(/"/g, '"');
|
||||
// Ping columns: colored + bold
|
||||
if (col.id === "__srcping" || col.id === "__destping") {
|
||||
return { html: `<b>${val}</b>`, style: `color:${getPingColor(val)}; font-weight:bold;` };
|
||||
}
|
||||
// Device names: bold + inline copy
|
||||
if (col.kind === "apiKey" && (col.key === "Source Device" || col.key === "Destination Device")) {
|
||||
return { html: `<b>${val}</b>${inlineCopyBtn(String(val))}`, style: "" };
|
||||
}
|
||||
// Backup / USMT status: bold
|
||||
if (col.key === "Backup Status" || col.id === "__usmt") {
|
||||
return { html: `<b>${val}</b>`, style: "" };
|
||||
}
|
||||
// Specialty Software: hyperlink any app that has documentation
|
||||
if (col.kind === "special") {
|
||||
if (!val || val === "None" || val === "Loading...") return { html: val, style: "" };
|
||||
const linked = val.split(", ").map(app => {
|
||||
const m = availableDocs.find(d => d.toLowerCase() === app.toLowerCase());
|
||||
return m
|
||||
? `<a href="?doc=${encodeURIComponent(m)}" target="_blank" onclick="event.stopPropagation();" style="color:#3498db; font-weight:bold;">${app}</a>`
|
||||
: app;
|
||||
});
|
||||
return { html: linked.join(", "), style: "" };
|
||||
}
|
||||
return { html: safe, style: "" };
|
||||
}
|
||||
|
||||
function renderCustomTable() {
|
||||
const headerRow = document.getElementById('custom-header-row');
|
||||
const tbody = document.getElementById('tbody-custom');
|
||||
@@ -4095,22 +4132,22 @@
|
||||
|
||||
const dstPingStatus = row["Destination Online"] || "N/A";
|
||||
|
||||
const spaceStr = (row["Source Free Space"] !== "N/A" && row["Source Free Space"]) ?
|
||||
`${row["Source Free Space"]} / ${row["Source Total Space"]}` : "N/A";
|
||||
const spaceStr = (row["Source Free Space"] !== "N/A" && row["Source Free Space"] !== "Cached" && row["Source Free Space"]) ?
|
||||
`${row["Source Free Space"]} / ${row["Source Total Space"]}` : (row["Source Free Space"] || "N/A");
|
||||
|
||||
const destAppsBtn = (row["Destination Device"] && row["Destination Device"] !== "N/A") ?
|
||||
`<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
@@ -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
@@ -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">◀</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">▶</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
|
||||
|
||||
Reference in new issue
Block a user