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
+1089
-215
No files matched your search
@@ -51,6 +51,11 @@
|
|||||||
|
|
||||||
# A task-sequence log modified within this window counts as "active"
|
# A task-sequence log modified within this window counts as "active"
|
||||||
ActiveLogHours = 24
|
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' }
|
@{ 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)
|
CycleSeconds = 45 # cooldown between cycles (business hours are ignored)
|
||||||
DelayMsPerDevice = 35 # artificial per-device latency so the progress bar moves
|
DelayMsPerDevice = 35 # artificial per-device latency so the progress bar moves
|
||||||
LifecycleHours = 24 # how long one simulated migration takes, start to finish
|
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
|
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: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: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")
|
$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)
|
# 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.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 })
|
$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]) }
|
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
|
# Facts specific to this physical device
|
||||||
$n = $key
|
$n = $key
|
||||||
@@ -214,6 +220,8 @@ function Get-DemoProfile([string]$name) {
|
|||||||
$p.DiskModel = Get-DemoPick $n 'ssd' $script:DemoSsdModels
|
$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.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.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
|
$script:DemoProfiles[$key] = $p
|
||||||
return $p
|
return $p
|
||||||
@@ -247,8 +255,9 @@ function Get-DemoNetworkState([string]$computer) {
|
|||||||
if ($fault -lt 0.03) { return "DNS Issue" }
|
if ($fault -lt 0.03) { return "DNS Issue" }
|
||||||
|
|
||||||
$stage = (Get-DemoStage $p).Name
|
$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 ($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" }
|
if ($stage -eq 'RestoreInProgress') { return "Online" }
|
||||||
} else {
|
} else {
|
||||||
if ($stage -eq 'BackupInProgress') { return "Online" }
|
if ($stage -eq 'BackupInProgress') { return "Online" }
|
||||||
@@ -261,6 +270,11 @@ function Get-DemoNetworkState([string]$computer) {
|
|||||||
return "Timed Out"
|
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) {
|
function Start-DemoLatency([int]$scale = 1) {
|
||||||
$ms = [int]$script:DemoCfg.DelayMsPerDevice * $scale
|
$ms = [int]$script:DemoCfg.DelayMsPerDevice * $scale
|
||||||
if ($ms -gt 0) { Start-Sleep -Milliseconds $ms }
|
if ($ms -gt 0) { Start-Sleep -Milliseconds $ms }
|
||||||
@@ -412,7 +426,9 @@ function Get-DeviceAssociations {
|
|||||||
function Get-DeviceInventory {
|
function Get-DeviceInventory {
|
||||||
param([string]$computerName, $Session)
|
param([string]$computerName, $Session)
|
||||||
$p = Get-DemoProfile $computerName
|
$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" }
|
$vis = if ($p.Public) { "Public" } else { "Private" }
|
||||||
$form = if ($p.Laptop) { "Laptops" } else { "Desktops" }
|
$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))
|
$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)) }
|
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"
|
OU = "Company Devices/$vis/$form"
|
||||||
Groups = $groups -join "<br>"
|
Groups = $groups -join "<br>"
|
||||||
MAC = $p.Mac
|
MAC = $p.Mac
|
||||||
@@ -455,6 +476,24 @@ function Get-DeviceInventory {
|
|||||||
SCCMPrimaryUsers = $uda
|
SCCMPrimaryUsers = $uda
|
||||||
Disk = "Size: $($p.DiskSize) GB<br>Model: $($p.DiskModel)<br>S/N: $($p.DiskSerial)."
|
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 {
|
function Get-InstalledSoftware {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
# Initialize-Provider, Open-ProviderCycle, Close-ProviderCycle, pingDevice,
|
# Initialize-Provider, Open-ProviderCycle, Close-ProviderCycle, pingDevice,
|
||||||
# Resolve-DeviceIP, Get-DiskSpace, Get-LogInfo, Get-DeviceAssociations,
|
# Resolve-DeviceIP, Get-DiskSpace, Get-LogInfo, Get-DeviceAssociations,
|
||||||
# Get-DeviceInventory, Get-InstalledSoftware
|
# Get-DeviceInventory, Get-InstalledSoftware
|
||||||
|
# and optionally Get-DeviceLastSeen.
|
||||||
# =========================================================================================
|
# =========================================================================================
|
||||||
|
|
||||||
$script:SccmCfg = $config.SCCM
|
$script:SccmCfg = $config.SCCM
|
||||||
@@ -254,10 +255,13 @@ function Get-DeviceInventory {
|
|||||||
|
|
||||||
$searcher = $null
|
$searcher = $null
|
||||||
try {
|
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 = [adsisearcher]"(&(objectCategory=computer)(name=$computerName))"
|
||||||
$searcher.PropertiesToLoad.Add("distinguishedname") | Out-Null
|
$searcher.PropertiesToLoad.Add("distinguishedname") | Out-Null
|
||||||
$searcher.PropertiesToLoad.Add("memberof") | Out-Null
|
$searcher.PropertiesToLoad.Add("memberof") | Out-Null
|
||||||
$adResult = $searcher.FindOne()
|
$adResult = $searcher.FindOne()
|
||||||
|
$res.InAD = [bool]$adResult # the lookup worked, so "no result" really means "not in AD"
|
||||||
|
|
||||||
if ($adResult) {
|
if ($adResult) {
|
||||||
$dn = $adResult.Properties["distinguishedname"][0]
|
$dn = $adResult.Properties["distinguishedname"][0]
|
||||||
@@ -308,7 +312,9 @@ function Get-DeviceInventory {
|
|||||||
$reader = $cmd.ExecuteReader()
|
$reader = $cmd.ExecuteReader()
|
||||||
$assetExists = $false
|
$assetExists = $false
|
||||||
|
|
||||||
|
$res.InSCCM = $false
|
||||||
if ($reader.Read()) {
|
if ($reader.Read()) {
|
||||||
|
$res.InSCCM = $true
|
||||||
if ($reader["MAC_Addresses0"] -isnot [DBNull] -and $reader["MAC_Addresses0"]) { $res.MAC = $reader["MAC_Addresses0"] }
|
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["Model0"] -isnot [DBNull] -and $reader["Model0"]) { $res.Model = $reader["Model0"] }
|
||||||
if ($reader["TotalPhysicalMemory0"] -isnot [DBNull] -and $reader["TotalPhysicalMemory0"]) {
|
if ($reader["TotalPhysicalMemory0"] -isnot [DBNull] -and $reader["TotalPhysicalMemory0"]) {
|
||||||
@@ -395,9 +401,63 @@ function Get-DeviceInventory {
|
|||||||
} catch {}
|
} 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
|
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)
|
# Installed software (used by Get-Software.ps1)
|
||||||
# -----------------------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------------------
|
||||||
|
|||||||
+381
-114
@@ -22,10 +22,17 @@ $completedPath = Join-Path $dataDir "Completed.json"
|
|||||||
$statePath = Join-Path $dataDir "Worker-State.json"
|
$statePath = Join-Path $dataDir "Worker-State.json"
|
||||||
$softwareCachePath = Join-Path $dataDir "Software-Cache.json"
|
$softwareCachePath = Join-Path $dataDir "Software-Cache.json"
|
||||||
$advancedCachePath = Join-Path $dataDir "Advanced-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"
|
$getSoftwareScript = Join-Path $originalLocation "Get-Software.ps1"
|
||||||
$diskSkipList = @($config.Worker.DiskSkipList) # Devices with broken SMB that hang Directory.Exists
|
$diskSkipList = @($config.Worker.DiskSkipList) # Devices with broken SMB that hang Directory.Exists
|
||||||
$scanLogName = if ($config.SCCM.ScanStateLog) { $config.SCCM.ScanStateLog } else { "scanstateprogress" }
|
$scanLogName = if ($config.SCCM.ScanStateLog) { $config.SCCM.ScanStateLog } else { "scanstateprogress" }
|
||||||
$loadLogName = if ($config.SCCM.LoadStateLog) { $config.SCCM.LoadStateLog } else { "loadstateprogress" }
|
$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) {
|
if ($config.Logging.Enabled) {
|
||||||
$cmdLine = "PS $originalLocation> ./USMT-Worker.ps1" + $(if ($config.IsDemo) { " -Demo" } else { "" }) + $(if ($v) { " -v" } else { "" })
|
$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"
|
$globalLastUpdated = "Never"
|
||||||
$globalNextRun = "Calculating..."
|
$globalNextRun = "Calculating..."
|
||||||
$globalLastDuration = "N/A"
|
$globalLastDuration = "N/A"
|
||||||
|
$global:LastBreakdown = $null
|
||||||
|
$global:LastPresenceSweep = [datetime]::MinValue
|
||||||
|
|
||||||
# --- FUNCTIONS ---
|
# --- FUNCTIONS ---
|
||||||
function Update-WorkerState {
|
function Update-WorkerState {
|
||||||
@@ -54,7 +63,8 @@ function Update-WorkerState {
|
|||||||
LastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt")
|
LastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt")
|
||||||
NextCycle = $nextRun
|
NextCycle = $nextRun
|
||||||
LastDuration = $duration
|
LastDuration = $duration
|
||||||
} | ConvertTo-Json -Compress
|
LastCycle = $global:LastBreakdown
|
||||||
|
} | ConvertTo-Json -Compress -Depth 5
|
||||||
|
|
||||||
[System.IO.File]::WriteAllText($statePath, $json, [System.Text.Encoding]::UTF8)
|
[System.IO.File]::WriteAllText($statePath, $json, [System.Text.Encoding]::UTF8)
|
||||||
} catch {}
|
} 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] }
|
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) ---
|
# --- PROVIDER (data source adapter) ---
|
||||||
# Supplies: Initialize-Provider, Open-ProviderCycle, Close-ProviderCycle, pingDevice,
|
# Supplies: Initialize-Provider, Open-ProviderCycle, Close-ProviderCycle, pingDevice,
|
||||||
# Resolve-DeviceIP, Get-DiskSpace, Get-LogInfo, Get-DeviceAssociations, Get-DeviceInventory
|
# 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")
|
. (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:AdvancedCache = @{}
|
||||||
$global:AdvancedCacheUpdated = $false
|
$global:AdvancedCacheUpdated = $false
|
||||||
@@ -98,7 +274,7 @@ if (Test-Path $advancedCachePath) {
|
|||||||
function Get-AdvancedData {
|
function Get-AdvancedData {
|
||||||
param ([string]$computerName, [switch]$IsDest, [string]$OnlineStatus, [switch]$ForceUpdate, $Session)
|
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 }
|
if ([string]::IsNullOrWhiteSpace($computerName) -or $computerName -eq "N/A") { return $res }
|
||||||
|
|
||||||
$needsUpdate = $true
|
$needsUpdate = $true
|
||||||
@@ -107,6 +283,9 @@ function Get-AdvancedData {
|
|||||||
$cachedRes = $global:AdvancedCache[$computerName]
|
$cachedRes = $global:AdvancedCache[$computerName]
|
||||||
$cachedKeys = if ($cachedRes -is [hashtable]) { $cachedRes.Keys } else { $cachedRes.psobject.properties.Name }
|
$cachedKeys = if ($cachedRes -is [hashtable]) { $cachedRes.Keys } else { $cachedRes.psobject.properties.Name }
|
||||||
foreach ($key in @($cachedKeys)) { $res[$key] = $cachedRes.$key }
|
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)
|
# 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 ($inventory[$key]) { $res[$key] = $inventory[$key] }
|
||||||
}
|
}
|
||||||
if ($null -eq $dnsIp -and $inventory.InventoryIP) { $res.IP = $inventory.InventoryIP }
|
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:AdvancedCache[$computerName] = $res
|
||||||
$global:AdvancedCacheUpdated = $true
|
$global:AdvancedCacheUpdated = $true
|
||||||
@@ -180,7 +366,7 @@ while ($true) {
|
|||||||
$cycleStart = Get-Date
|
$cycleStart = Get-Date
|
||||||
Write-Host "`n--- Cycle Start: $($cycleStart.ToString('hh:mm:ss tt')) ---" -ForegroundColor White
|
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
|
$session = Open-ProviderCycle
|
||||||
|
|
||||||
@@ -242,10 +428,38 @@ while ($true) {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
$uniqueDevices = $deviceTags.Keys | Sort-Object
|
$uniqueDevices = @($deviceTags.Keys | Sort-Object)
|
||||||
$totalDevices = @($uniqueDevices).Count
|
|
||||||
|
|
||||||
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
|
Write-Host "`n[Checking Software Cache Engine...]" -ForegroundColor Cyan
|
||||||
$runFullSoftwareSweep = $false
|
$runFullSoftwareSweep = $false
|
||||||
$currentDate = (Get-Date).ToString("yyyy-MM-dd")
|
$currentDate = (Get-Date).ToString("yyyy-MM-dd")
|
||||||
@@ -265,7 +479,9 @@ while ($true) {
|
|||||||
$cachedNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
$cachedNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||||
if ($cacheObj) { foreach ($p in $cacheObj.psobject.properties) { $null = $cachedNames.Add($p.Name) } }
|
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 ($runFullSoftwareSweep -or -not $cachedNames.Contains($dev)) {
|
||||||
if ($v) { Write-Host "Fetching background software for $dev..." -ForegroundColor DarkCyan }
|
if ($v) { Write-Host "Fetching background software for $dev..." -ForegroundColor DarkCyan }
|
||||||
Update-SoftwareCacheEntry $dev
|
Update-SoftwareCacheEntry $dev
|
||||||
@@ -273,15 +489,21 @@ while ($true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Write-Host "[Software Cache Synchronized]`n" -ForegroundColor Green
|
Write-Host "[Software Cache Synchronized]`n" -ForegroundColor Green
|
||||||
|
$softwareSeconds = $phaseWatch.Elapsed.TotalSeconds
|
||||||
|
$phaseWatch.Restart()
|
||||||
|
|
||||||
$deviceTable = [System.Collections.Generic.List[PSCustomObject]]::new()
|
$deviceTable = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||||
|
$processedSources = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||||
$currentDeviceCount = 0
|
$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++
|
$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
|
Write-Host "[$currentDeviceCount/$totalDevices] Processing: $deviceName" -ForegroundColor Yellow
|
||||||
|
|
||||||
$tags = $deviceTags[$deviceName] -join ", "
|
$tags = $deviceTags[$deviceName] -join ", "
|
||||||
@@ -291,56 +513,38 @@ while ($true) {
|
|||||||
$deviceTagList = @($deviceTags[$deviceName])
|
$deviceTagList = @($deviceTags[$deviceName])
|
||||||
$allTagsSkipped = ($deviceTagList.Count -gt 0) -and @($deviceTagList | Where-Object { $skipUSMTTags.Contains($_) }).Count -gt 0
|
$allTagsSkipped = ($deviceTagList.Count -gt 0) -and @($deviceTagList | Where-Object { $skipUSMTTags.Contains($_) }).Count -gt 0
|
||||||
if ($allTagsSkipped) {
|
if ($allTagsSkipped) {
|
||||||
|
$category = "Skipped"
|
||||||
if ($v) { Write-Host " -> [SKIP USMT] Tag marked Skip USMT. Caching Advanced/Software only." -ForegroundColor DarkMagenta }
|
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
|
$null = Get-AdvancedData $deviceName -OnlineStatus "Skipped" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||||
$deviceTable.Add([PSCustomObject]@{
|
$row = New-DeviceRow $deviceName "N/A" $tags "Skipped"
|
||||||
"Source Device"=$deviceName; "Destination Device"="N/A";
|
$row."USMT Status" = "Skipped"; $row."Backup Status" = "Skipped"
|
||||||
"Tags"=$tags; "Source Total Space"="N/A"; "Source Free Space"="N/A";
|
$row."Source Online" = "Skipped"; $row."Destination Online" = "-"
|
||||||
"USMT Status"="Skipped"; "Backup Status"="Skipped";
|
$deviceTable.Add([PSCustomObject]$row)
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
# -----------------------------------------------------------------
|
elseif ($manualPairs.ContainsKey($deviceName)) {
|
||||||
|
$category = "Manual"
|
||||||
if ($manualPairs.ContainsKey($deviceName)) {
|
|
||||||
$dName = $manualPairs[$deviceName]
|
$dName = $manualPairs[$deviceName]
|
||||||
$sName = $deviceName
|
$sName = $deviceName
|
||||||
if ($v) { Write-Host " -> [MANUAL MATCH] Hardware exception detected. Bypassing SCCM..." -ForegroundColor DarkYellow }
|
if ($v) { Write-Host " -> [MANUAL MATCH] Hardware exception detected. Bypassing SCCM..." -ForegroundColor DarkYellow }
|
||||||
|
|
||||||
$pingSrc = pingDevice $sName
|
$row = New-DeviceRow $sName $dName $tags "Manual"
|
||||||
$pingDst = pingDevice $dName
|
$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 $sName -OnlineStatus $row."Source Online" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||||
$null = Get-AdvancedData $dName -IsDest -OnlineStatus $pingDst -ForceUpdate:$runFullSoftwareSweep -Session $session
|
$null = Get-AdvancedData $dName -IsDest -OnlineStatus $row."Destination Online" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||||
|
$deviceTable.Add([PSCustomObject]$row)
|
||||||
$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
|
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
# 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 }
|
if ($v) { Write-Host " -> Querying SCCM for associations..." -ForegroundColor DarkGray }
|
||||||
$associations = Get-DeviceAssociations -SourceComputer $deviceName
|
$associations = Get-DeviceAssociations -SourceComputer $deviceName
|
||||||
|
|
||||||
if (-not $associations) {
|
if (-not $associations) {
|
||||||
|
$category = "NoAssoc"
|
||||||
$cached = $completedList | Where-Object { $_."Source Device".ToUpper() -eq $deviceName } | Select-Object -First 1
|
$cached = $completedList | Where-Object { $_."Source Device".ToUpper() -eq $deviceName } | Select-Object -First 1
|
||||||
if ($cached) {
|
if ($cached) {
|
||||||
|
$category = "Cached"
|
||||||
if ($v) { Write-Host " -> [CACHE HIT] But association was removed from SCCM." -ForegroundColor DarkYellow }
|
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
|
# Backfill dest software - association is gone so the assoc loop will never fetch it
|
||||||
@@ -356,51 +560,48 @@ while ($true) {
|
|||||||
$null = Get-AdvancedData $cached."Source Device" -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
$null = Get-AdvancedData $cached."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."Destination Device" -IsDest -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||||
|
|
||||||
$deviceTable.Add([PSCustomObject]@{
|
$row = New-DeviceRow $cached."Source Device" $cached."Destination Device" $tags "Cached"
|
||||||
"Source Device"=$cached."Source Device"; "Destination Device"=$cached."Destination Device";
|
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" }
|
||||||
"Tags"=$tags; "Source Total Space"="Cached"; "Source Free Space"="Cached";
|
$row."USMT Status" = "Not Associated"; $row."Backup Status" = "Completed"
|
||||||
"USMT Status"="Not Associated"; "Backup Status"="Completed";
|
Use-Snapshot $row."Source Device" $row."Destination Device" $row $true $true
|
||||||
"Source Online"="Cached"; "Destination Online"="Cached";
|
Set-RowPresence $row -Source -Destination
|
||||||
"Source Scanstate Line"="Cached"; "Destination Loadstate Line"="Cached";
|
$deviceTable.Add([PSCustomObject]$row)
|
||||||
"Source Scanstate File"="Cached"; "Destination Loadstate File"="Cached"
|
|
||||||
"Source Scanstate Elapsed"="Cached"; "Destination Loadstate Elapsed"="Cached";
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
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 }
|
||||||
|
|
||||||
$pingSrc = pingDevice $deviceName
|
$row = New-DeviceRow $deviceName "N/A" $tags "None"
|
||||||
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null }
|
$pingSrc = Test-Device $deviceName -Session $session
|
||||||
$srcTotal = "N/A"
|
$row."Source Online" = $pingSrc; $row."Destination Online" = "-"
|
||||||
$srcFree = "N/A"
|
$row."Source Checked" = Get-IsoNow
|
||||||
|
$row."USMT Status" = "Not Associated"; $row."Backup Status" = "Not Associated"
|
||||||
|
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null; Elapsed = "N/A" }
|
||||||
|
|
||||||
if ($pingSrc -eq "Online") {
|
if ($pingSrc -eq "Online") {
|
||||||
# --- DISK SKIP LIST CHECK (No-Association path) ---
|
# --- DISK SKIP LIST CHECK (No-Association path) ---
|
||||||
if ($diskSkipList -notcontains $deviceName) {
|
if ($diskSkipList -notcontains $deviceName) {
|
||||||
$diskInfo = Get-DiskSpace $deviceName
|
$diskInfo = Get-DiskSpace $deviceName
|
||||||
$srcTotal = $diskInfo.Total
|
$row."Source Total Space" = $diskInfo.Total
|
||||||
$srcFree = $diskInfo.Free
|
$row."Source Free Space" = $diskInfo.Free
|
||||||
|
if ($diskInfo.Free -ne "N/A") { $row."Source Space Checked" = Get-IsoNow }
|
||||||
} else {
|
} else {
|
||||||
if ($v) { Write-Host " > Disk check skipped for $deviceName (on diskSkipList)" -ForegroundColor DarkYellow }
|
if ($v) { Write-Host " > Disk check skipped for $deviceName (on diskSkipList)" -ForegroundColor DarkYellow }
|
||||||
}
|
}
|
||||||
if ($diskSkipList -notcontains $deviceName -and $srcFree -ne "N/A") { $logSrc = Get-LogInfo $deviceName $scanLogName }
|
if ($diskSkipList -notcontains $deviceName -and $row."Source Free Space" -ne "N/A") { $logSrc = Get-LogInfo $deviceName $scanLogName }
|
||||||
}
|
}
|
||||||
|
$row."Source Scanstate Line" = $logSrc.Line
|
||||||
|
$row."Source Scanstate File" = $logSrc.Path
|
||||||
|
$row."Source Scanstate Elapsed" = $logSrc.Elapsed
|
||||||
|
|
||||||
|
$srcLive = ($pingSrc -eq "Online" -and $row."Source Free Space" -ne "N/A")
|
||||||
|
if ($srcLive) { Update-Snapshot $deviceName "N/A" $row $true $false }
|
||||||
|
else { Use-Snapshot $deviceName "N/A" $row $true $false }
|
||||||
|
|
||||||
$null = Get-AdvancedData $deviceName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
|
$null = Get-AdvancedData $deviceName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||||
|
$deviceTable.Add([PSCustomObject]$row)
|
||||||
$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
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
foreach ($assoc in $associations) {
|
foreach ($assoc in $associations) {
|
||||||
$assoc.Get()
|
$assoc.Get()
|
||||||
$dName = if ($assoc.DestinationComputer) { $assoc.DestinationComputer } else { $assoc.RestoreName }
|
$dName = if ($assoc.DestinationComputer) { $assoc.DestinationComputer } else { $assoc.RestoreName }
|
||||||
@@ -430,36 +631,30 @@ while ($true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($cached) {
|
if ($cached) {
|
||||||
|
$category = "Cached"
|
||||||
if ($v) { Write-Host " -> [CACHE HIT] Fast-tracking row and bypassing network sweeps." -ForegroundColor Green }
|
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 $sName -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||||
$null = Get-AdvancedData $dName -IsDest -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
$null = Get-AdvancedData $dName -IsDest -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||||
|
|
||||||
$deviceTable.Add([PSCustomObject]@{
|
$row = New-DeviceRow $sName $dName $tags "Cached"
|
||||||
"Source Device"=$sName; "Destination Device"=$dName;
|
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" }
|
||||||
"Tags"=$tags; "Source Total Space"="Cached"; "Source Free Space"="Cached";
|
$row."USMT Status" = $usmtStatusStr; $row."Backup Status" = "Completed"
|
||||||
"USMT Status"=$usmtStatusStr; "Backup Status"="Completed";
|
# Last state seen before it completed (with its timestamp) instead of "Cached"
|
||||||
"Source Online"="Cached"; "Destination Online"="Cached";
|
Use-Snapshot $sName $dName $row $true $true
|
||||||
"Source Scanstate Line"="Cached"; "Destination Loadstate Line"="Cached";
|
Set-RowPresence $row -Source -Destination
|
||||||
"Source Scanstate File"="Cached"; "Destination Loadstate File"="Cached"
|
if ($v -and $row."Source Snapshot") { Write-Host " > Showing snapshot from $(([datetime]$row."Source Snapshot").ToString('MM/dd hh:mm tt'))" -ForegroundColor DarkGray }
|
||||||
"Source Scanstate Elapsed"="Cached"; "Destination Loadstate Elapsed"="Cached";
|
$deviceTable.Add([PSCustomObject]$row)
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
$row = [ordered]@{
|
$row = New-DeviceRow $sName $dName $tags "SCCM"
|
||||||
"Source Device"=$sName; "Destination Device"=$dName; "Tags"=$tags;
|
$row."USMT Status" = $usmtStatusStr; $row."Backup Status" = "Checking..."
|
||||||
"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 }
|
if ($v) { Write-Host " -> [SWEEP SOURCE] $sName" -ForegroundColor DarkCyan }
|
||||||
$pingSrc = pingDevice $sName
|
$pingSrc = Test-Device $sName -Session $session
|
||||||
$row."Source Online" = $pingSrc
|
$row."Source Online" = $pingSrc
|
||||||
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null }
|
$row."Source Checked" = Get-IsoNow
|
||||||
|
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null; Elapsed = "N/A" }
|
||||||
|
|
||||||
if ($pingSrc -eq "Online") {
|
if ($pingSrc -eq "Online") {
|
||||||
# --- DISK SKIP LIST CHECK (Association path) ---
|
# --- DISK SKIP LIST CHECK (Association path) ---
|
||||||
@@ -467,6 +662,7 @@ while ($true) {
|
|||||||
$diskInfo = Get-DiskSpace $sName
|
$diskInfo = Get-DiskSpace $sName
|
||||||
$row."Source Total Space" = $diskInfo.Total
|
$row."Source Total Space" = $diskInfo.Total
|
||||||
$row."Source Free Space" = $diskInfo.Free
|
$row."Source Free Space" = $diskInfo.Free
|
||||||
|
if ($diskInfo.Free -ne "N/A") { $row."Source Space Checked" = Get-IsoNow }
|
||||||
} else {
|
} else {
|
||||||
if ($v) { Write-Host " > Disk check skipped for $sName (on diskSkipList)" -ForegroundColor DarkYellow }
|
if ($v) { Write-Host " > Disk check skipped for $sName (on diskSkipList)" -ForegroundColor DarkYellow }
|
||||||
}
|
}
|
||||||
@@ -477,9 +673,10 @@ while ($true) {
|
|||||||
$row."Source Scanstate Elapsed" = $logSrc.Elapsed
|
$row."Source Scanstate Elapsed" = $logSrc.Elapsed
|
||||||
|
|
||||||
if ($v) { Write-Host " -> [SWEEP DESTINATION] $dName" -ForegroundColor DarkCyan }
|
if ($v) { Write-Host " -> [SWEEP DESTINATION] $dName" -ForegroundColor DarkCyan }
|
||||||
$pingDst = pingDevice $dName
|
$pingDst = Test-Device $dName -Session $session
|
||||||
$row."Destination Online" = $pingDst
|
$row."Destination Online" = $pingDst
|
||||||
$logDst = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingDst)"; Path = "Unreachable"; LastModified = $null }
|
$row."Destination Checked" = Get-IsoNow
|
||||||
|
$logDst = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingDst)"; Path = "Unreachable"; LastModified = $null; Elapsed = "N/A" }
|
||||||
|
|
||||||
if ($pingDst -eq "Online") { $logDst = Get-LogInfo $dName $loadLogName }
|
if ($pingDst -eq "Online") { $logDst = Get-LogInfo $dName $loadLogName }
|
||||||
$row."Destination Loadstate Line" = $logDst.Line
|
$row."Destination Loadstate Line" = $logDst.Line
|
||||||
@@ -498,6 +695,12 @@ while ($true) {
|
|||||||
elseif ($usmtStatusRaw -eq 0 -and $isSrcLogActive) { $row."Backup Status" = "Backup In Progress" }
|
elseif ($usmtStatusRaw -eq 0 -and $isSrcLogActive) { $row."Backup Status" = "Backup In Progress" }
|
||||||
else { $row."Backup Status" = "Not Started" }
|
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 }
|
if ($v) { Write-Host " -> Fetching Advanced SQL/AD Data..." -ForegroundColor DarkGray }
|
||||||
$null = Get-AdvancedData $sName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
|
$null = Get-AdvancedData $sName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||||
$null = Get-AdvancedData $dName -IsDest -OnlineStatus $pingDst -ForceUpdate:$runFullSoftwareSweep -Session $session
|
$null = Get-AdvancedData $dName -IsDest -OnlineStatus $pingDst -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||||
@@ -513,19 +716,64 @@ while ($true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$null = $processedSources.Add($deviceName)
|
||||||
|
$timing[$category].Count++
|
||||||
|
$timing[$category].Ms += $deviceWatch.Elapsed.TotalMilliseconds
|
||||||
|
|
||||||
|
# Newly tracked devices are done: publish them now rather than at the end of the cycle
|
||||||
|
if ($newSources.Count -gt 0 -and $knownSources.Count -gt 0 -and $currentDeviceCount -eq $newSources.Count) {
|
||||||
|
Write-Host " -> [PRIORITY] $($newSources.Count) new device(s) swept. Publishing early..." -ForegroundColor Green
|
||||||
|
$interim = [System.Collections.Generic.List[object]]::new()
|
||||||
|
foreach ($r in $deviceTable) { $interim.Add($r) }
|
||||||
|
foreach ($dev in $knownSources) { if ($previousRows.ContainsKey($dev)) { $interim.Add($previousRows[$dev]) } }
|
||||||
|
Save-AdvancedCache
|
||||||
|
Write-JsonFile $jsonPath ([PSCustomObject]@{ LastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt"); Devices = $interim })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$sweepSeconds = $phaseWatch.Elapsed.TotalSeconds
|
||||||
|
$phaseWatch.Restart()
|
||||||
|
|
||||||
|
# --- PRESENCE SWEEP: ping everything the sweep didn't (cached pairs, skipped tags...) ---
|
||||||
|
$presenceCount = 0
|
||||||
|
$presenceDue = ((Get-Date) - $global:LastPresenceSweep).TotalMinutes -ge ($presenceMinutes - 0.5) -or $runFullSoftwareSweep
|
||||||
|
if ($presenceDue) {
|
||||||
|
$everyone = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||||
|
foreach ($r in $deviceTable) {
|
||||||
|
foreach ($n in @($r."Source Device", $r."Destination Device")) { if ($n -and $n -ne "N/A") { $null = $everyone.Add($n) } }
|
||||||
|
}
|
||||||
|
$toPing = @($everyone | Where-Object { -not $global:PingedThisCycle.Contains($_) } | Sort-Object)
|
||||||
|
Write-Host "`n[Presence Sweep: $($toPing.Count) device(s) not pinged this cycle]" -ForegroundColor Cyan
|
||||||
|
$i = 0
|
||||||
|
foreach ($n in $toPing) {
|
||||||
|
$i++
|
||||||
|
Update-WorkerState "Presence Sweep..." $i $toPing.Count $globalLastDuration "Calculating..."
|
||||||
|
$null = Test-Device $n -Session $session
|
||||||
|
}
|
||||||
|
# Cached / skipped rows pick up the fresh result
|
||||||
|
foreach ($r in $deviceTable) {
|
||||||
|
if ($r.Pairing -eq "Cached") { Set-RowPresence $r -Source -Destination }
|
||||||
|
}
|
||||||
|
$presenceCount = $toPing.Count
|
||||||
|
$global:LastPresenceSweep = Get-Date
|
||||||
|
Write-Host "[Presence Updated]" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
$presenceSeconds = $phaseWatch.Elapsed.TotalSeconds
|
||||||
|
$phaseWatch.Restart()
|
||||||
|
|
||||||
Close-ProviderCycle $session
|
Close-ProviderCycle $session
|
||||||
|
|
||||||
if ($v) { Write-Host " -> Pruning and Compressing Software Cache..." -ForegroundColor DarkGray }
|
|
||||||
if (Test-Path $softwareCachePath) {
|
|
||||||
try {
|
|
||||||
$rawCache = [System.IO.File]::ReadAllText($softwareCachePath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
|
||||||
$activeDevs = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
$activeDevs = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||||
foreach ($row in $deviceTable) {
|
foreach ($row in $deviceTable) {
|
||||||
if ($row."Source Device" -and $row."Source Device" -ne "N/A") { $null = $activeDevs.Add($row."Source Device") }
|
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 ($row."Destination Device" -and $row."Destination Device" -ne "N/A") { $null = $activeDevs.Add($row."Destination Device") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($v) { Write-Host " -> Pruning and Compressing Software Cache..." -ForegroundColor DarkGray }
|
||||||
|
if (Test-Path $softwareCachePath) {
|
||||||
|
try {
|
||||||
|
$rawCache = [System.IO.File]::ReadAllText($softwareCachePath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
||||||
$pruned = $false
|
$pruned = $false
|
||||||
foreach ($p in @($rawCache.psobject.properties.Name)) {
|
foreach ($p in @($rawCache.psobject.properties.Name)) {
|
||||||
if (-not $activeDevs.Contains($p)) {
|
if (-not $activeDevs.Contains($p)) {
|
||||||
@@ -537,28 +785,27 @@ while ($true) {
|
|||||||
$jsonSoftCache = $rawCache | ConvertTo-Json -Depth 6 -Compress
|
$jsonSoftCache = $rawCache | ConvertTo-Json -Depth 6 -Compress
|
||||||
[System.IO.File]::WriteAllText($softwareCachePath, $jsonSoftCache, [System.Text.Encoding]::UTF8)
|
[System.IO.File]::WriteAllText($softwareCachePath, $jsonSoftCache, [System.Text.Encoding]::UTF8)
|
||||||
if ($pruned -and $v) { Write-Host " -> Removed untracked devices from cache." -ForegroundColor Green }
|
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 {}
|
} 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 }
|
if ($v) { Write-Host " -> Writing payload to Data.json..." -ForegroundColor DarkGray }
|
||||||
$globalLastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt")
|
$globalLastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt")
|
||||||
$exportPayload = [PSCustomObject]@{ LastUpdated = $globalLastUpdated; Devices = $deviceTable }
|
$exportPayload = [PSCustomObject]@{ LastUpdated = $globalLastUpdated; Devices = $deviceTable }
|
||||||
|
|
||||||
$jsonFinal = $exportPayload | ConvertTo-Json -Depth 6
|
$jsonFinal = $exportPayload | ConvertTo-Json -Depth 6
|
||||||
[System.IO.File]::WriteAllText($jsonPath, $jsonFinal, [System.Text.Encoding]::UTF8)
|
[System.IO.File]::WriteAllText($jsonPath, $jsonFinal, [System.Text.Encoding]::UTF8)
|
||||||
|
$saveSeconds = $phaseWatch.Elapsed.TotalSeconds
|
||||||
|
|
||||||
$cycleEnd = Get-Date
|
$cycleEnd = Get-Date
|
||||||
$cycleTime = $cycleEnd - $cycleStart
|
$cycleTime = $cycleEnd - $cycleStart
|
||||||
@@ -566,6 +813,26 @@ while ($true) {
|
|||||||
|
|
||||||
Write-Host "`nCycle Complete in $globalLastDuration." -ForegroundColor Gray
|
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)
|
$nextRun = Get-NextRunTime (Get-Date)
|
||||||
$nextRunStr = $nextRun.ToString("MM/dd/yy hh:mm tt")
|
$nextRunStr = $nextRun.ToString("MM/dd/yy hh:mm tt")
|
||||||
|
|
||||||
|
|||||||
+34
-13
@@ -342,9 +342,9 @@
|
|||||||
return { tagMap, manualPairs, skipTags };
|
return { tagMap, manualPairs, skipTags };
|
||||||
}
|
}
|
||||||
|
|
||||||
function blankRow(src, dst, tags) {
|
function blankRow(src, dst, tags, pairing) {
|
||||||
return {
|
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 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 Online": "N/A", "Destination Online": "N/A",
|
||||||
"Source Scanstate Line": "N/A", "Destination Loadstate Line": "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.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(/_$/, '.'));
|
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());
|
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;
|
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
|
// Builds the Data.json rows the worker would produce for a roster, plus synthetic
|
||||||
// advanced/software entries for devices the fleet doesn't know.
|
// advanced/software entries for devices the fleet doesn't know.
|
||||||
function buildRows(tracked, serverRows, serverAdv) {
|
function buildRows(tracked, serverRows, serverAdv) {
|
||||||
@@ -393,7 +401,7 @@
|
|||||||
const srow = bySrc[dev];
|
const srow = bySrc[dev];
|
||||||
|
|
||||||
if (tagMap[dev].some(t => skipTags.has(t))) {
|
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": "-" });
|
Object.assign(r, { "USMT Status": "Skipped", "Backup Status": "Skipped", "Source Online": "Skipped", "Destination Online": "-" });
|
||||||
out.push(r);
|
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"]; } }
|
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]) {
|
if (manualPairs[dev]) {
|
||||||
const dst = 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; }
|
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);
|
const r = blankRow(dev, dst, tags, "Manual");
|
||||||
for (const [n, col] of [[dev, "Source Online"], [dst, "Destination Online"]]) {
|
const now = new Date().toISOString();
|
||||||
|
for (const [n, col] of [[dev, "Source"], [dst, "Destination"]]) {
|
||||||
const t = SCHEME.test(n) ? pickTemplate(n, serverRows) : null;
|
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"]; }
|
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);
|
out.push(r);
|
||||||
continue;
|
continue;
|
||||||
@@ -420,11 +430,12 @@
|
|||||||
const t = SCHEME.test(dev) ? pickTemplate(dev, serverRows) : null;
|
const t = SCHEME.test(dev) ? pickTemplate(dev, serverRows) : null;
|
||||||
if (!t) {
|
if (!t) {
|
||||||
// Not in the fleet and not a valid name: what the worker reports for a typo
|
// 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": "-",
|
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);
|
out.push(r);
|
||||||
software[dev] = null; // SCCM has no inventory for it: an empty list, same as Get-Software.ps1
|
software[dev] = null; // SCCM has no inventory for it: an empty list, same as Get-Software.ps1
|
||||||
|
if (!serverAdv[dev]) adv[dev] = unknownAdv();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const tSrc = t["Source Device"], tDst = t["Destination Device"], dst = synthDest(dev);
|
const tSrc = t["Source Device"], tDst = t["Destination Device"], dst = synthDest(dev);
|
||||||
@@ -506,7 +517,7 @@
|
|||||||
return W;
|
return W;
|
||||||
}
|
}
|
||||||
add([' -> Querying SCCM for associations...', 'DarkGray']);
|
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([' -> SCCM DB Status: Completed (Raw: 2)', 'DarkGray']);
|
||||||
add([' -> [CACHE HIT] Fast-tracking row and bypassing network sweeps.', 'Green']);
|
add([' -> [CACHE HIT] Fast-tracking row and bypassing network sweeps.', 'Green']);
|
||||||
return W;
|
return W;
|
||||||
@@ -564,7 +575,7 @@
|
|||||||
const before = new Set(Object.keys(parseRoster(applied).tagMap));
|
const before = new Set(Object.keys(parseRoster(applied).tagMap));
|
||||||
const { rows } = buildRows(tracked, snap.rows, snap.adv);
|
const { rows } = buildRows(tracked, snap.rows, snap.adv);
|
||||||
const { tagMap } = parseRoster(tracked);
|
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 started = new Date();
|
||||||
const state = (status, cur) => { Sim.worker = { Status: status, Current: cur, Total: total }; };
|
const state = (status, cur) => { Sim.worker = { Status: status, Current: cur, Total: total }; };
|
||||||
|
|
||||||
@@ -582,11 +593,14 @@
|
|||||||
}
|
}
|
||||||
w([['[Software Cache Synchronized]', 'Green']]);
|
w([['[Software Cache Synchronized]', 'Green']]);
|
||||||
w([]);
|
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)));
|
const delay = Math.max(15, Math.min(90, 9000 / Math.max(1, rows.length)));
|
||||||
let i = 0;
|
let i = 0;
|
||||||
for (const r of rows) {
|
for (const r of ordered) {
|
||||||
i++;
|
i++;
|
||||||
state('Updating Devices...', i);
|
state('Updating Devices...', i);
|
||||||
const src = r["Source Device"];
|
const src = r["Source Device"];
|
||||||
@@ -594,6 +608,13 @@
|
|||||||
w([[` -> Tags: [${r.Tags}]`, 'DarkCyan']]);
|
w([[` -> Tags: [${r.Tags}]`, 'DarkCyan']]);
|
||||||
for (const segs of deviceLines(r, !before.has(src), full)) w(segs);
|
for (const segs of deviceLines(r, !before.has(src), full)) w(segs);
|
||||||
await sleep(delay);
|
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']]);
|
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
|
* 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.
|
* 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
|
* 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
|
* its own checkbox; a row matches if it has ANY of the ticked items, or with "Exactly
|
||||||
* ticked" if it has nothing BUT ticked items. Free-space columns ("78 GB / 221 GB") filter
|
* 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.
|
* 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,
|
* 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;
|
if (allowed.free.op === 'ge' ? sp.free < allowed.free.gb : sp.free >= allowed.free.gb) return false;
|
||||||
}
|
}
|
||||||
const vals = cellValues(td, col);
|
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;
|
else if (!vals.some(v => allowed.has(v))) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -472,7 +476,7 @@
|
|||||||
let onlyToggle = null;
|
let onlyToggle = null;
|
||||||
if (col.list) {
|
if (col.list) {
|
||||||
const seg = el('div', 'xf-seg');
|
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 mk = (text, only) => {
|
||||||
const b = el('button', 'xf-seg-btn', text); b.type = 'button';
|
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)); };
|
b.onclick = () => { seg.querySelectorAll('.xf-seg-btn').forEach(x => x.classList.toggle('on', x === b)); };
|
||||||
@@ -481,7 +485,7 @@
|
|||||||
return b;
|
return b;
|
||||||
};
|
};
|
||||||
mk('Has any ticked', false);
|
mk('Has any ticked', false);
|
||||||
onlyToggle = mk('Has only ticked', true);
|
onlyToggle = mk('Exactly ticked', true);
|
||||||
menu.appendChild(seg);
|
menu.appendChild(seg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+160
-62
@@ -353,6 +353,7 @@
|
|||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="/layout.css">
|
<link rel="stylesheet" href="/layout.css">
|
||||||
<script src="/layout.js"></script>
|
<script src="/layout.js"></script>
|
||||||
|
<script src="/device-info.js"></script>
|
||||||
<script src="/excel-filter.js" defer></script>
|
<script src="/excel-filter.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -662,6 +663,7 @@
|
|||||||
|
|
||||||
<div class="sync-details" id="syncDetailsText">Waiting for server response...</div>
|
<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 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="forceSyncBtn" onclick="forceUpdate()">Force Update Now</button>
|
||||||
<button class="force-btn" id="forceFullBtn" onclick="forceFullRefresh()" style="margin-top: 10px;">Full Software/Advanced Refresh</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) {
|
async function fetchSoftware(name) {
|
||||||
|
window.currentSoftwareDevice = name;
|
||||||
document.getElementById('modal').style.display = 'block';
|
document.getElementById('modal').style.display = 'block';
|
||||||
document.getElementById('modalTitle').innerHTML = `Software: ${name} ${inlineCopyBtn(name)}`;
|
document.getElementById('modalTitle').innerHTML = `Software: ${name} ${inlineCopyBtn(name)}`;
|
||||||
|
|
||||||
@@ -2641,7 +2644,7 @@
|
|||||||
const newJsonString = JSON.stringify(data);
|
const newJsonString = JSON.stringify(data);
|
||||||
|
|
||||||
if (newJsonString !== cachedJsonString) {
|
if (newJsonString !== cachedJsonString) {
|
||||||
drawModalTables(data, false, name);
|
if (window.currentSoftwareDevice === name) drawModalTables(data, false, name);
|
||||||
softwareCache[name] = data;
|
softwareCache[name] = data;
|
||||||
pullData();
|
pullData();
|
||||||
}
|
}
|
||||||
@@ -2789,6 +2792,7 @@
|
|||||||
if (!match) return;
|
if (!match) return;
|
||||||
|
|
||||||
window.currentDetailsMatch = match;
|
window.currentDetailsMatch = match;
|
||||||
|
window.currentDetailsKey = match["Source Device"];
|
||||||
document.getElementById('detailsModal').style.display = 'block';
|
document.getElementById('detailsModal').style.display = 'block';
|
||||||
renderDetailsHTML();
|
renderDetailsHTML();
|
||||||
}
|
}
|
||||||
@@ -2867,25 +2871,48 @@
|
|||||||
const tdStyle = `style="white-space: normal; word-wrap: break-word; overflow-wrap: break-word; padding: 10px;"`;
|
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;"`;
|
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"]] || {};
|
const srcName = match["Source Device"], dstName = match["Destination Device"];
|
||||||
let advDst = advancedDataCache[match["Destination Device"]] || {};
|
const hasDst = DeviceInfo.isName(dstName);
|
||||||
|
// Advanced fields explain a blank: "Not in AD", "Not in SCCM", "No DNS record", or
|
||||||
const getAdv = (obj, key) => obj[key] ? obj[key] : 'N/A';
|
// "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 = `
|
let mainRows = `
|
||||||
<div ${window.isAdvView ? scrollBoxStyle : ''}>
|
<div ${window.isAdvView ? scrollBoxStyle : ''}>
|
||||||
<table class="details-table" style="width:100%; border-collapse: collapse; table-layout: fixed;">
|
<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>` : ''}
|
${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 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"]}</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>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>Source Presence</th><td ${tdStyle}>${DeviceInfo.presenceHtml(srcName)}</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>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>Dest Device</th><td ${tdStyle}><b>${match["Destination Device"]}</b> ${inlineCopyBtn(match["Destination Device"])} ${dstAppBtn}</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 Ping</th><td style="color: ${getPingColor(match["Destination Online"])}; font-weight: bold; padding: 10px;">${match["Destination Online"]}</td></tr>
|
<tr><th>Dest Device</th><td ${tdStyle}><b>${dstName}</b> ${inlineCopyBtn(dstName)} ${dstAppBtn}</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>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>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>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>
|
<tr><th>Tags</th><td ${tdStyle}>${match["Tags"] || 'None'}</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -2898,21 +2925,25 @@
|
|||||||
<div ${scrollBoxStyle}>
|
<div ${scrollBoxStyle}>
|
||||||
<table class="details-table" style="width:100%; border-collapse: collapse; table-layout: fixed;">
|
<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 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 width="40%">Device Name</th><td ${tdStyle}><b>${srcName}</b> ${inlineCopyBtn(srcName)}</td></tr>
|
||||||
<tr><th>Source IP / MAC</th><td ${tdStyle}>${getAdv(advSrc, "IP")}<br>${getAdv(advSrc, "MAC")}</td></tr>
|
<tr><th>Found In</th><td ${tdStyle}>${getAdv(srcName, "FoundIn")}</td></tr>
|
||||||
<tr><th>Source OU</th><td ${tdStyle}>${getAdv(advSrc, "OU")}</td></tr>
|
<tr><th>Presence</th><td ${tdStyle}>${DeviceInfo.presenceHtml(srcName)}</td></tr>
|
||||||
<tr><th>Source Groups</th><td ${tdStyle}>${getAdv(advSrc, "Groups")}</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>Hardware Specs</th><td ${tdStyle}>${getAdv(advSrc, "Model")}<br>${(getAdv(advSrc, "RAM") !== 'N/A') ? getAdv(advSrc, "RAM") + ' RAM' : 'N/A'}</td></tr>
|
<tr><th>Source IP / MAC</th><td ${tdStyle}>${getAdv(srcName, "IP")}<br>${getAdv(srcName, "MAC")}</td></tr>
|
||||||
<tr><th>Physical Disk</th><td ${tdStyle}>${getAdv(advSrc, "Disk")}</td></tr>
|
<tr><th>Source OU</th><td ${tdStyle}>${getAdv(srcName, "OU")}</td></tr>
|
||||||
<tr><th>SCCM Client</th><td ${tdStyle}>${getAdv(advSrc, "ClientStatus")}</td></tr>
|
<tr><th>Source Groups</th><td ${tdStyle}>${getAdv(srcName, "Groups")}</td></tr>
|
||||||
<tr><th>Dept / Bldg</th><td ${tdStyle}>${getAdv(advSrc, "Department")} / ${getAdv(advSrc, "Building")}</td></tr>
|
<tr><th>Hardware Specs</th><td ${tdStyle}>${hwSpecs(srcName)}</td></tr>
|
||||||
<tr><th>Location / Vanity</th><td ${tdStyle}>${getAdv(advSrc, "Location")} / ${getAdv(advSrc, "Vanity")}</td></tr>
|
<tr><th>Physical Disk</th><td ${tdStyle}>${getAdv(srcName, "Disk")}</td></tr>
|
||||||
<tr><th>Phone / Mount</th><td ${tdStyle}>Ext: ${getAdv(advSrc, "PhoneExt")}<br>${getAdv(advSrc, "Mount")}</td></tr>
|
<tr><th>Monitors</th><td ${tdStyle}>${monitorsCell(srcName)}</td></tr>
|
||||||
<tr><th>Room Type</th><td ${tdStyle}>${getAdv(advSrc, "RoomType")}</td></tr>
|
<tr><th>SCCM Client</th><td ${tdStyle}>${getAdv(srcName, "ClientStatus")}</td></tr>
|
||||||
<tr><th>Users (Pri / Last)</th><td ${tdStyle}>${getAdv(advSrc, "PrimaryUser")} / ${getAdv(advSrc, "LastUser")}</td></tr>
|
<tr><th>Dept / Bldg</th><td ${tdStyle}>${getAdv(srcName, "Department")} / ${getAdv(srcName, "Building")}</td></tr>
|
||||||
<tr><th>Primary Users</th><td ${tdStyle}>${getAdv(advSrc, "SCCMPrimaryUsers")}</td></tr>
|
<tr><th>Location / Vanity</th><td ${tdStyle}>${getAdv(srcName, "Location")} / ${getAdv(srcName, "Vanity")}</td></tr>
|
||||||
<tr><th>Asset Info Entered</th><td ${tdStyle}>${getAdv(advSrc, "Updated")}<br>(By: ${getAdv(advSrc, "InfoBy")})</td></tr>
|
<tr><th>Phone / Mount</th><td ${tdStyle}>Ext: ${getAdv(srcName, "PhoneExt")}<br>${getAdv(srcName, "Mount")}</td></tr>
|
||||||
<tr><th>Notes</th><td ${tdStyle}>${getAdv(advSrc, "Notes")}</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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -2921,21 +2952,25 @@
|
|||||||
<div ${scrollBoxStyle}>
|
<div ${scrollBoxStyle}>
|
||||||
<table class="details-table" style="width:100%; border-collapse: collapse; table-layout: fixed;">
|
<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 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 width="40%">Device Name</th><td ${tdStyle}><b>${dstName}</b> ${inlineCopyBtn(dstName)}</td></tr>
|
||||||
<tr><th>Dest IP / MAC</th><td ${tdStyle}>${getAdv(advDst, "IP")}<br>${getAdv(advDst, "MAC")}</td></tr>
|
<tr><th>Found In</th><td ${tdStyle}>${getAdv(dstName, "FoundIn")}</td></tr>
|
||||||
<tr><th>Dest OU</th><td ${tdStyle}>${getAdv(advDst, "OU")}</td></tr>
|
<tr><th>Presence</th><td ${tdStyle}>${DeviceInfo.presenceHtml(dstName)}</td></tr>
|
||||||
<tr><th>Dest Groups</th><td ${tdStyle}>${getAdv(advDst, "Groups")}</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>Hardware Specs</th><td ${tdStyle}>${getAdv(advDst, "Model")}<br>${(getAdv(advDst, "RAM") !== 'N/A') ? getAdv(advDst, "RAM") + ' RAM' : 'N/A'}</td></tr>
|
<tr><th>Dest IP / MAC</th><td ${tdStyle}>${getAdv(dstName, "IP")}<br>${getAdv(dstName, "MAC")}</td></tr>
|
||||||
<tr><th>Physical Disk</th><td ${tdStyle}>${getAdv(advDst, "Disk")}</td></tr>
|
<tr><th>Dest OU</th><td ${tdStyle}>${getAdv(dstName, "OU")}</td></tr>
|
||||||
<tr><th>SCCM Client</th><td ${tdStyle}>${getAdv(advDst, "ClientStatus")}</td></tr>
|
<tr><th>Dest Groups</th><td ${tdStyle}>${getAdv(dstName, "Groups")}</td></tr>
|
||||||
<tr><th>Dept / Bldg</th><td ${tdStyle}>${getAdv(advDst, "Department")} / ${getAdv(advDst, "Building")}</td></tr>
|
<tr><th>Hardware Specs</th><td ${tdStyle}>${hwSpecs(dstName)}</td></tr>
|
||||||
<tr><th>Location / Vanity</th><td ${tdStyle}>${getAdv(advDst, "Location")} / ${getAdv(advDst, "Vanity")}</td></tr>
|
<tr><th>Physical Disk</th><td ${tdStyle}>${getAdv(dstName, "Disk")}</td></tr>
|
||||||
<tr><th>Phone / Mount</th><td ${tdStyle}>Ext: ${getAdv(advDst, "PhoneExt")}<br>${getAdv(advDst, "Mount")}</td></tr>
|
<tr><th>Monitors</th><td ${tdStyle}>${monitorsCell(dstName)}</td></tr>
|
||||||
<tr><th>Room Type</th><td ${tdStyle}>${getAdv(advDst, "RoomType")}</td></tr>
|
<tr><th>SCCM Client</th><td ${tdStyle}>${getAdv(dstName, "ClientStatus")}</td></tr>
|
||||||
<tr><th>Users (Pri / Last)</th><td ${tdStyle}>${getAdv(advDst, "PrimaryUser")} / ${getAdv(advDst, "LastUser")}</td></tr>
|
<tr><th>Dept / Bldg</th><td ${tdStyle}>${getAdv(dstName, "Department")} / ${getAdv(dstName, "Building")}</td></tr>
|
||||||
<tr><th>Primary Users</th><td ${tdStyle}>${getAdv(advDst, "SCCMPrimaryUsers")}</td></tr>
|
<tr><th>Location / Vanity</th><td ${tdStyle}>${getAdv(dstName, "Location")} / ${getAdv(dstName, "Vanity")}</td></tr>
|
||||||
<tr><th>Asset Info Entered</th><td ${tdStyle}>${getAdv(advDst, "Updated")}<br>(By: ${getAdv(advDst, "InfoBy")})</td></tr>
|
<tr><th>Phone / Mount</th><td ${tdStyle}>Ext: ${getAdv(dstName, "PhoneExt")}<br>${getAdv(dstName, "Mount")}</td></tr>
|
||||||
<tr><th>Notes</th><td ${tdStyle}>${getAdv(advDst, "Notes")}</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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -3225,9 +3260,11 @@
|
|||||||
Department: "Department", Building: "Building", Location: "Location", Vanity: "Vanity",
|
Department: "Department", Building: "Building", Location: "Location", Vanity: "Vanity",
|
||||||
PhoneExt: "Phone Ext", Mount: "Mount", RoomType: "Room Type",
|
PhoneExt: "Phone Ext", Mount: "Mount", RoomType: "Room Type",
|
||||||
PrimaryUser: "Primary User", LastUser: "Last User", SCCMPrimaryUsers: "SCCM Primary Users",
|
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 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 customColWidths = JSON.parse(localStorage.getItem('usmt_custom_widths')) || {}; // id -> px width
|
||||||
@@ -3270,7 +3307,7 @@
|
|||||||
function getAdvKeys() {
|
function getAdvKeys() {
|
||||||
let keys = new Set(ADV_FIELD_ORDER);
|
let keys = new Set(ADV_FIELD_ORDER);
|
||||||
Object.values(advancedDataCache || {}).forEach(obj => {
|
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) => {
|
return Array.from(keys).sort((a, b) => {
|
||||||
let ia = ADV_FIELD_ORDER.indexOf(a), ib = ADV_FIELD_ORDER.indexOf(b);
|
let ia = ADV_FIELD_ORDER.indexOf(a), ib = ADV_FIELD_ORDER.indexOf(b);
|
||||||
@@ -3393,8 +3430,8 @@
|
|||||||
function getCustomCellValue(row, col) {
|
function getCustomCellValue(row, col) {
|
||||||
const blank = v => (v === undefined || v === null || v === "") ? "N/A" : v;
|
const blank = v => (v === undefined || v === null || v === "") ? "N/A" : v;
|
||||||
if (col.kind === "apiKey") return blank(row[col.key]);
|
if (col.kind === "apiKey") return blank(row[col.key]);
|
||||||
if (col.kind === "adv") return blank((advancedDataCache[row["Source Device"]] || {})[col.key]);
|
if (col.kind === "adv") return DeviceInfo.field(row["Source Device"], col.key).text;
|
||||||
if (col.kind === "advDst") return blank((advancedDataCache[row["Destination Device"]] || {})[col.key]);
|
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 === "special") return getCustomSpecialty(row["Source Device"]);
|
||||||
if (col.kind === "migration") {
|
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";
|
if (col.fn === "space") return (row["Source Free Space"] && row["Source Free Space"] !== "N/A") ? row["Source Free Space"] + " / " + row["Source Total Space"] : "N/A";
|
||||||
@@ -4095,22 +4132,22 @@
|
|||||||
|
|
||||||
const dstPingStatus = row["Destination Online"] || "N/A";
|
const dstPingStatus = row["Destination Online"] || "N/A";
|
||||||
|
|
||||||
const spaceStr = (row["Source Free Space"] !== "N/A" && row["Source Free Space"]) ?
|
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"]}` : "N/A";
|
`${row["Source Free Space"]} / ${row["Source Total Space"]}` : (row["Source Free Space"] || "N/A");
|
||||||
|
|
||||||
const destAppsBtn = (row["Destination Device"] && row["Destination Device"] !== "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>`;
|
`<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"]}')">
|
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 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 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="${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}</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 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 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["USMT Status"] || "N/A"}">${row["USMT Status"] || "N/A"}</td>
|
||||||
<td title="${row.Tags || 'None'}">${row.Tags || 'None'}</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>
|
<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";
|
document.getElementById('syncProgressFill').style.backgroundColor = "#27ae60";
|
||||||
|
|
||||||
if (state.LastDuration && state.LastDuration !== "N/A") {
|
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;
|
forceBtn.disabled = false;
|
||||||
@@ -4276,19 +4313,23 @@
|
|||||||
let percentage = 0;
|
let percentage = 0;
|
||||||
if (state.Total > 0) percentage = Math.round((state.Current / state.Total) * 100);
|
if (state.Total > 0) percentage = Math.round((state.Current / state.Total) * 100);
|
||||||
document.getElementById('syncStatusText').innerText = "Live Sync in Progress...";
|
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').style.width = `${percentage}%`;
|
||||||
document.getElementById('syncProgressFill').innerText = `${percentage}%`;
|
document.getElementById('syncProgressFill').innerText = `${percentage}%`;
|
||||||
document.getElementById('syncProgressFill').style.backgroundColor = "#3498db";
|
document.getElementById('syncProgressFill').style.backgroundColor = "#3498db";
|
||||||
|
|
||||||
if (state.LastDuration && state.LastDuration !== "N/A") {
|
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;
|
forceBtn.disabled = true;
|
||||||
forceFullBtn.disabled = true;
|
forceFullBtn.disabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.getElementById('syncBreakdown').innerHTML = DeviceInfo.syncBreakdownHtml(state);
|
||||||
|
|
||||||
// Check if Tracked-Devices was updated by you or a teammate
|
// Check if Tracked-Devices was updated by you or a teammate
|
||||||
const trackRes = await fetch('Tracked-Devices.json?t=' + Date.now()).catch(() => null);
|
const trackRes = await fetch('Tracked-Devices.json?t=' + Date.now()).catch(() => null);
|
||||||
let currentTrackedHash = "";
|
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
|
// Surgically strip old devices from apiData before the backend worker finishes purging them
|
||||||
apiData = apiData.filter(row => {
|
apiData = apiData.filter(row => {
|
||||||
let src = (row["Source Device"] || "").toUpperCase();
|
let src = (row["Source Device"] || "").toUpperCase();
|
||||||
@@ -4397,13 +4450,14 @@
|
|||||||
apiData.push({
|
apiData.push({
|
||||||
"Source Device": dev,
|
"Source Device": dev,
|
||||||
"Destination Device": destDev,
|
"Destination Device": destDev,
|
||||||
|
"Pairing": isManual ? "Manual" : undefined,
|
||||||
"Tags": tags.join(", "),
|
"Tags": tags.join(", "),
|
||||||
"Source Total Space": placeholder,
|
"Source Total Space": placeholder,
|
||||||
"Source Free Space": placeholder,
|
"Source Free Space": placeholder,
|
||||||
"USMT Status": placeholder,
|
"USMT Status": placeholder,
|
||||||
"Backup Status": status,
|
"Backup Status": status,
|
||||||
"Source Online": placeholder,
|
"Source Online": "Waiting for Worker...",
|
||||||
"Destination Online": "Waiting for Worker...",
|
"Destination Online": isManual ? "Waiting for Worker..." : "-",
|
||||||
"Source Scanstate Line": placeholder,
|
"Source Scanstate Line": placeholder,
|
||||||
"Destination Loadstate Line": placeholder
|
"Destination Loadstate Line": placeholder
|
||||||
});
|
});
|
||||||
@@ -4415,6 +4469,7 @@
|
|||||||
renderMasterList();
|
renderMasterList();
|
||||||
renderSoftwareTable(); // <-- Forces the Software tab to update instantly!
|
renderSoftwareTable(); // <-- Forces the Software tab to update instantly!
|
||||||
renderCustomTable();
|
renderCustomTable();
|
||||||
|
refreshOpenDialogs();
|
||||||
|
|
||||||
// 3. LAZY LOAD SOFTWARE LAST
|
// 3. LAZY LOAD SOFTWARE LAST
|
||||||
fetch('Software-Cache.json?t=' + Date.now())
|
fetch('Software-Cache.json?t=' + Date.now())
|
||||||
@@ -4433,8 +4488,11 @@
|
|||||||
} else if (entry && entry.Data) {
|
} else if (entry && entry.Data) {
|
||||||
rawData = Array.isArray(entry.Data) ? entry.Data : [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() !== '--');
|
softwareCache[device] = rawData.filter(s => s && s.Name && s.Name.trim() !== '' && s.Name.trim() !== '--');
|
||||||
|
softwareStamps[device] = stamp;
|
||||||
cacheUpdated = true;
|
cacheUpdated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4442,7 +4500,8 @@
|
|||||||
myStacks.forEach(s => refreshStackTable(s.id));
|
myStacks.forEach(s => refreshStackTable(s.id));
|
||||||
renderMasterList();
|
renderMasterList();
|
||||||
renderSoftwareTable();
|
renderSoftwareTable();
|
||||||
renderDetailsHTML();
|
renderCustomTable();
|
||||||
|
refreshOpenDialogs(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
@@ -4450,6 +4509,45 @@
|
|||||||
} catch (e) {}
|
} 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() {
|
async function startDashboard() {
|
||||||
renderStacksOnly();
|
renderStacksOnly();
|
||||||
await pullData();
|
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; }
|
#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; }
|
#table-custom thead th { position: sticky !important; top: var(--sticky-top, 112px) !important; z-index: 20; }
|
||||||
.custom-hscroll {
|
.custom-hscroll {
|
||||||
position: sticky; bottom: var(--dock-h, 40px); z-index: 15; height: 16px;
|
position: sticky; bottom: var(--dock-h, 40px); z-index: 15;
|
||||||
overflow-x: auto; overflow-y: hidden;
|
display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 8px;
|
||||||
background: var(--bg-card); border-top: 1px solid var(--border-light); border-radius: 0 0 8px 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
|
/* 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 */
|
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; }
|
.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 */
|
/* 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; }
|
.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
|
// 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
|
// 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
|
// sideways by a scrollbar of our own, pinned to the bottom of the window: a wide track
|
||||||
// touch swipes over the table drive the same scrollbar.
|
// 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() {
|
function setupCustomHScroll() {
|
||||||
const box = document.getElementById('scroll-custom');
|
const box = document.getElementById('scroll-custom');
|
||||||
const host = document.getElementById('custom-table-host');
|
const host = document.getElementById('custom-table-host');
|
||||||
@@ -93,27 +97,96 @@
|
|||||||
bar = document.createElement('div');
|
bar = document.createElement('div');
|
||||||
bar.id = 'custom-hscroll';
|
bar.id = 'custom-hscroll';
|
||||||
bar.className = 'custom-hscroll no-copy';
|
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);
|
box.insertAdjacentElement('afterend', bar);
|
||||||
const applyX = () => { host.style.transform = bar.scrollLeft ? `translateX(${-bar.scrollLeft}px)` : ''; };
|
customHScroll.bar = bar;
|
||||||
bar.addEventListener('scroll', applyX, { passive: true });
|
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 => {
|
box.addEventListener('wheel', e => {
|
||||||
if (bar.hidden) return;
|
if (bar.hidden) return;
|
||||||
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : (e.shiftKey ? e.deltaY : 0);
|
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : (e.shiftKey ? e.deltaY : 0);
|
||||||
if (!dx) return;
|
if (!dx) return;
|
||||||
e.preventDefault();
|
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 });
|
}, { passive: false });
|
||||||
let start = null;
|
let start = null;
|
||||||
box.addEventListener('touchstart', e => {
|
box.addEventListener('touchstart', e => {
|
||||||
if (bar.hidden || e.touches.length !== 1) { start = null; return; }
|
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 });
|
}, { passive: true });
|
||||||
box.addEventListener('touchmove', e => {
|
box.addEventListener('touchmove', e => {
|
||||||
if (!start) return;
|
if (!start) return;
|
||||||
const dx = e.touches[0].clientX - start.x, dy = e.touches[0].clientY - start.y;
|
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 && 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 });
|
}, { passive: false });
|
||||||
box.addEventListener('touchend', () => { start = null; }, { passive: true });
|
box.addEventListener('touchend', () => { start = null; }, { passive: true });
|
||||||
}
|
}
|
||||||
@@ -124,11 +197,11 @@
|
|||||||
}
|
}
|
||||||
const full = Math.ceil(table.getBoundingClientRect().width);
|
const full = Math.ceil(table.getBoundingClientRect().width);
|
||||||
const view = box.clientWidth;
|
const view = box.clientWidth;
|
||||||
const inner = bar.firstChild;
|
const max = Math.max(0, full - view);
|
||||||
if (inner.style.width !== full + 'px') inner.style.width = full + 'px';
|
const hide = max <= 1;
|
||||||
const hide = full <= view + 1;
|
|
||||||
if (bar.hidden !== hide) bar.hidden = hide;
|
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;
|
// Auto-sizing text boxes (Workbench stacks) only grow while typing in the original;
|
||||||
@@ -206,8 +279,8 @@
|
|||||||
const box = document.getElementById('scroll-custom');
|
const box = document.getElementById('scroll-custom');
|
||||||
if (bar && !bar.hidden && box) {
|
if (bar && !bar.hidden && box) {
|
||||||
const r = box.getBoundingClientRect();
|
const r = box.getBoundingClientRect();
|
||||||
if (e.clientX < r.left + 40) bar.scrollLeft -= 18;
|
if (e.clientX < r.left + 40) customHScroll.by(-18);
|
||||||
else if (e.clientX > r.right - 40) bar.scrollLeft += 18;
|
else if (e.clientX > r.right - 40) customHScroll.by(18);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Target slot = number of other columns whose middle is left of the pointer
|
// Target slot = number of other columns whose middle is left of the pointer
|
||||||
|
|||||||
Reference in new issue
Block a user