Demo: migrations follow the schedule, worked by a simulated team

Each device now migrates on the day of its date tag instead of on a
looping 24-hour clock: earlier days are finished, today's devices go
through backup and restore during working hours, later days haven't
started. The few that don't finish have a cause the dashboard shows
(broken client, broken WMI, low disk, a USMT error, unreachable PC).

A simulated team keeps two weeks of working days tagged. During working
hours it adds devices, reschedules or drops one, tags stuck PCs with
their problem, then fixes them and moves them to a new day; each morning
finished days are closed out and a new day is scheduled. Actions print
in the worker terminal as [Team] lines. Settings live in Demo.Team.

The visitor sandbox now keeps per-tag edits laid over the live roster,
so the team's changes keep showing around a visitor's own edits.

Overview calendar: today is a filled badge on the date number, so it no
longer looks like the selected day (outline plus tint).
This commit is contained in:
Johan Sandoval committed 2026-10-07 20:41:07 +00:00
1 parent 9731368687
commit 41ff9e2b49
7 files changed
+561 -153

No files matched your search

+12 -1
View File
@@ -130,7 +130,6 @@
DeviceCount = 200 # tracked source devices seeded at startup DeviceCount = 200 # tracked source devices seeded at startup
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
PresenceSweepMinutes = 60 # completed / Skip USMT devices: hourly, like Live (the demo runs all day) PresenceSweepMinutes = 60 # completed / Skip USMT devices: hourly, like Live (the demo runs all day)
LivePublishSeconds = 4 # publish swept rows this often during a cycle LivePublishSeconds = 4 # publish swept rows this often during a cycle
@@ -142,6 +141,18 @@
End = '21:00' End = '21:00'
Weekdays = @(0, 1, 2, 3, 4, 5, 6) # every day Weekdays = @(0, 1, 2, 3, 4, 5, 6) # every day
} }
# The simulated team: date tags cover the next DaysAhead working days (each day's devices
# migrate during WorkStart-WorkEnd) and the last DaysKept days until they're closed out.
# Every ActionEveryMinutes (a random pick in the range) someone changes the roster.
Team = @{
WorkStart = '07:30'
WorkEnd = '17:00'
Weekdays = @(1, 2, 3, 4, 5) # Monday to Friday
DaysAhead = 10 # two weeks of working days, today included
DaysKept = 3
DevicesPerDay = 12
ActionEveryMinutes = @(4, 12)
}
Verbose = $true # run the worker with -v style tracing Verbose = $true # run the worker with -v style tracing
} }
+19 -7
View File
@@ -51,12 +51,13 @@ for ($m = 0; $m -lt 3; $m++) {
$manual += "$src, $dst" $manual += "$src, $dst"
} }
# --- 2. Date-batch tags (recent and upcoming working days; the worker keeps them current) --- # --- 2. Schedule: the last few working days (mostly finished) and the next two weeks ---
$dateTags = Get-DemoDateTags $team = $config.Demo.Team
[System.IO.File]::WriteAllText((Join-Path $dataDir "Demo-Dates.json"), (@{ Tags = $dateTags } | ConvertTo-Json), [System.Text.Encoding]::UTF8) $today = (Get-Date).Date
$days = @(@(1..[int]$team.DaysKept | ForEach-Object { Get-DemoWorkday $today (-$_) }) | Sort-Object) + @(0..([int]$team.DaysAhead - 1) | ForEach-Object { Get-DemoWorkday $today $_ })
$tags = [ordered]@{} $tags = [ordered]@{}
foreach ($t in $dateTags) { $tags[$t] = [System.Collections.Generic.List[string]]::new() } foreach ($d in $days) { $tags[(ConvertTo-DemoDateTag $d)] = [System.Collections.Generic.List[string]]::new() }
$dateTags = @($tags.Keys)
for ($n = 0; $n -lt $pool.Count; $n++) { $tags[$dateTags[$n % $dateTags.Count]].Add($pool[$n]) } for ($n = 0; $n -lt $pool.Count; $n++) { $tags[$dateTags[$n % $dateTags.Count]].Add($pool[$n]) }
$tags["Manual Pairs"] = [System.Collections.Generic.List[string]]::new([string[]]$manual) $tags["Manual Pairs"] = [System.Collections.Generic.List[string]]::new([string[]]$manual)
@@ -70,8 +71,16 @@ foreach ($d in $pool) {
foreach ($pair in @(@("Accounting", "Accounting"), @("Sales Floor", "Sales"), @("Exec Suite", "Executive"))) { foreach ($pair in @(@("Accounting", "Accounting"), @("Sales Floor", "Sales"), @("Exec Suite", "Executive"))) {
if ($byDept.ContainsKey($pair[1])) { $tags[$pair[0]] = [System.Collections.Generic.List[string]]::new([string[]]@($byDept[$pair[1]] | Select-Object -First 12)) } if ($byDept.ContainsKey($pair[1])) { $tags[$pair[0]] = [System.Collections.Generic.List[string]]::new([string[]]@($byDept[$pair[1]] | Select-Object -First 12)) }
} }
$tags["Broken WMI"] = [System.Collections.Generic.List[string]]::new([string[]]@($pool | Where-Object { (Get-DemoRand $_ 'wmi') -lt 0.03 } | Select-Object -First 5))
$tags["Bad Client"] = [System.Collections.Generic.List[string]]::new([string[]]@($pool | Where-Object { (Get-DemoRand $_ 'badclient') -lt 0.03 } | Select-Object -First 4)) # Problems that have already shown up on earlier days are tagged, as the team would have
Set-DemoSchedule $tags @() -Pin
foreach ($d in $pool) {
$kind = Get-DemoStuck $d
if (-not $kind) { continue }
$t = $script:DemoIssueTags[$kind]
if (-not $tags.Contains($t)) { $tags[$t] = [System.Collections.Generic.List[string]]::new() }
$tags[$t].Add($d)
}
$tags["Public PCs"] = $skipPublic $tags["Public PCs"] = $skipPublic
$tags["Laptops"] = $skipLaptops $tags["Laptops"] = $skipLaptops
@@ -81,6 +90,9 @@ foreach ($t in $tags.Keys) {
$tracked[$t] = [ordered]@{ devices = @($tags[$t]); skipUSMT = ($skipTags -contains $t) } $tracked[$t] = [ordered]@{ devices = @($tags[$t]); skipUSMT = ($skipTags -contains $t) }
} }
($tracked | ConvertTo-Json -Depth 5) | Set-Content -Path $trackedPath -Encoding UTF8 ($tracked | ConvertTo-Json -Depth 5) | Set-Content -Path $trackedPath -Encoding UTF8
# The simulated team picks up from here (see Invoke-DemoTeam in Providers/Demo.ps1)
$teamState = [ordered]@{ Day = $today.ToString("yyyy-MM-dd"); NextAction = (Get-Date).AddMinutes(2).ToString("o"); Serial = 0; Fixed = @(); Moves = @{} }
[System.IO.File]::WriteAllText((Join-Path $dataDir "Demo-Team.json"), ($teamState | ConvertTo-Json), [System.Text.Encoding]::UTF8)
# --- 3. Blacklist / suite rules --- # --- 3. Blacklist / suite rules ---
Copy-Item (Join-Path $PSScriptRoot "Blacklisted-Apps.txt") (Join-Path $dataDir "Blacklisted-Apps.txt") -Force Copy-Item (Join-Path $PSScriptRoot "Blacklisted-Apps.txt") (Join-Path $dataDir "Blacklisted-Apps.txt") -Force
+453 -90
View File
@@ -3,9 +3,12 @@
# #
# Implements the same functions as Providers\SCCM.ps1, but every answer comes from a # Implements the same functions as Providers\SCCM.ps1, but every answer comes from a
# deterministic model instead of SCCM / AD / DNS / SMB. A device's hardware, users, # deterministic model instead of SCCM / AD / DNS / SMB. A device's hardware, users,
# location and software are derived from a hash of its name, and its migration moves # location and software are derived from a hash of its name. Its migration happens on the
# through each stage on a clock, so the unmodified worker loop sees devices go from # day of its date tag (10/14 and so on), so the unmodified worker loop sees that day's devices
# Not Started -> Backup In Progress -> Backup Completed -> Restore In Progress -> Completed. # go Not Started -> Backup In Progress -> Backup Completed -> Restore In Progress -> Completed,
# earlier days finished (apart from a few with a real reason, such as a broken client or low
# disk) and later days not started yet. A simulated team keeps the date tags moving: see
# Invoke-DemoTeam at the end of this file.
# #
# Naming scheme: PrvDT- / PubDT- (private/public desktops) PrvLT- / PubLT- (laptops) # Naming scheme: PrvDT- / PubDT- (private/public desktops) PrvLT- / PubLT- (laptops)
# followed by an 8 character serial. Destination (replacement) devices get a serial that # followed by an 8 character serial. Destination (replacement) devices get a serial that
@@ -13,20 +16,14 @@
# ========================================================================================= # =========================================================================================
$script:DemoCfg = $config.Demo $script:DemoCfg = $config.Demo
$script:DemoLifecycle = [double]$script:DemoCfg.LifecycleHours * 3600 $script:DemoTeamCfg = $script:DemoCfg.Team
$script:DemoDataDir = Join-Path (Split-Path $PSScriptRoot -Parent) "Data"
$script:DemoProfiles = @{} $script:DemoProfiles = @{}
$script:DemoAlphabet = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ" $script:DemoAlphabet = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ"
$script:DemoAdminShare = 'C$' $script:DemoAdminShare = 'C$'
$script:DemoLogFolders = @('Windows\CCM\Logs\SMSTSLog', 'Windows\CCM\Logs') $script:DemoLogFolders = @('Windows\CCM\Logs\SMSTSLog', 'Windows\CCM\Logs')
# --- Stage boundaries (fraction of the lifecycle) --- $script:DemoStageOrder = @('NotStarted', 'BackupInProgress', 'BackupCompleted', 'RestoreInProgress', 'Completed')
$script:DemoStages = @(
@{ Name = 'NotStarted'; Start = 0.00 }
@{ Name = 'BackupInProgress'; Start = 0.25 }
@{ Name = 'BackupCompleted'; Start = 0.40 }
@{ Name = 'RestoreInProgress'; Start = 0.55 }
@{ Name = 'Completed'; Start = 0.70 }
)
# --- Catalogs --- # --- Catalogs ---
$script:DemoSurnames = @("Smith","Johnson","Williams","Brown","Jones","Garcia","Miller","Davis","Rodriguez","Martinez","Hernandez","Lopez","Gonzalez","Wilson","Anderson","Thomas","Taylor","Moore","Jackson","Martin","Lee","Perez","Thompson","White","Harris","Sanchez","Clark","Ramirez","Lewis","Robinson","Walker","Young","Allen","King","Wright","Scott","Torres","Nguyen","Hill","Flores","Green","Adams","Nelson","Baker","Hall","Rivera","Campbell","Mitchell","Carter","Roberts","Patel","Kim","Chen","Shah","Murphy","Cook","Rogers","Morgan","Cooper","Reed") $script:DemoSurnames = @("Smith","Johnson","Williams","Brown","Jones","Garcia","Miller","Davis","Rodriguez","Martinez","Hernandez","Lopez","Gonzalez","Wilson","Anderson","Thomas","Taylor","Moore","Jackson","Martin","Lee","Perez","Thompson","White","Harris","Sanchez","Clark","Ramirez","Lewis","Robinson","Walker","Young","Allen","King","Wright","Scott","Torres","Nguyen","Hill","Flores","Green","Adams","Nelson","Baker","Hall","Rivera","Campbell","Mitchell","Carter","Roberts","Patel","Kim","Chen","Shah","Murphy","Cook","Rogers","Morgan","Cooper","Reed")
@@ -135,42 +132,6 @@ function Get-DemoPick([string]$name, [string]$salt, [array]$list) { return $list
function Get-DemoInt([string]$name, [string]$salt, [int]$min, [int]$max) { return $min + [int][math]::Floor((Get-DemoRand $name $salt) * ($max - $min + 1)) } function Get-DemoInt([string]$name, [string]$salt, [int]$min, [int]$max) { return $min + [int][math]::Floor((Get-DemoRand $name $salt) * ($max - $min + 1)) }
function Get-DemoNow { return [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } function Get-DemoNow { return [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() }
# -----------------------------------------------------------------------------------------
# Date-batch tags ("M/d") sit on fixed working-day offsets from today. The seed records the
# labels it used in Demo-Dates.json and the worker moves them along when the day changes, so
# a demo container that runs for weeks still shows this week's deployments.
# -----------------------------------------------------------------------------------------
$script:DemoDateOffsets = @(-4, -3, -2, -1, 0, 1)
function Get-DemoWorkday([datetime]$from, [int]$offset) {
$d = $from.Date; $step = [math]::Sign($offset); $left = [math]::Abs($offset)
while ($left -gt 0) { $d = $d.AddDays($step); if ($d.DayOfWeek -ne 'Saturday' -and $d.DayOfWeek -ne 'Sunday') { $left-- } }
return $d
}
function Get-DemoDateTags { $today = Get-Date; return @($script:DemoDateOffsets | ForEach-Object { (Get-DemoWorkday $today $_).ToString("M/d") }) }
function Update-DemoDateTags([string]$DataDir) {
$metaPath = Join-Path $DataDir "Demo-Dates.json"
$trackedPath = Join-Path $DataDir "Tracked-Devices.json"
if (-not (Test-Path $metaPath) -or -not (Test-Path $trackedPath)) { return }
try { $old = @(([System.IO.File]::ReadAllText($metaPath) | ConvertFrom-Json).Tags) } catch { return }
$new = Get-DemoDateTags
if ($old.Count -ne $new.Count -or ($old -join '|') -eq ($new -join '|')) { return }
$rename = @{}
for ($i = 0; $i -lt $old.Count; $i++) { $rename[$old[$i]] = $new[$i] }
try { $tracked = [System.IO.File]::ReadAllText($trackedPath) | ConvertFrom-Json } catch { return }
$out = [ordered]@{}
foreach ($prop in $tracked.PSObject.Properties) {
$name = if ($rename.ContainsKey($prop.Name)) { $rename[$prop.Name] } else { $prop.Name }
$out[$name] = $prop.Value
}
$tmp = "$trackedPath.tmp"
[System.IO.File]::WriteAllText($tmp, ($out | ConvertTo-Json -Depth 5), [System.Text.Encoding]::UTF8)
Move-Item -LiteralPath $tmp -Destination $trackedPath -Force
[System.IO.File]::WriteAllText($metaPath, (@{ Tags = $new } | ConvertTo-Json), [System.Text.Encoding]::UTF8)
Write-Host "Demo: date tags moved to today ($($old -join ', ') -> $($new -join ', '))" -ForegroundColor DarkCyan
}
# ----------------------------------------------------------------------------------------- # -----------------------------------------------------------------------------------------
# Serial scheme: 7 chars + check char. Sources and destinations use different check offsets # Serial scheme: 7 chars + check char. Sources and destinations use different check offsets
# and a destination's first 7 chars are a reversible scramble of its source's. # and a destination's first 7 chars are a reversible scramble of its source's.
@@ -219,9 +180,10 @@ function Get-DemoProfile([string]$name) {
# Facts shared by a source and its replacement come from the SOURCE name # Facts shared by a source and its replacement come from the SOURCE name
$s = $p.Source $s = $p.Source
$p.Offset = (Get-DemoRand $s 'offset') * $script:DemoLifecycle
$p.NoAssoc = (Get-DemoRand $s 'assoc') -lt 0.08 $p.NoAssoc = (Get-DemoRand $s 'assoc') -lt 0.08
$p.Failing = (Get-DemoRand $s 'fail') -lt 0.05 # Problems that stop a migration on its day (until the team fixes them)
$p.LowDisk = (Get-DemoRand $s 'lowdisk') -lt 0.04
$p.BrokenWmi = (Get-DemoRand $s 'wmi') -lt 0.02
$p.Vendor = Get-DemoPick $s 'vendor' @("HP", "HP", "Dell", "Lenovo") $p.Vendor = Get-DemoPick $s 'vendor' @("HP", "HP", "Dell", "Lenovo")
$p.Building = Get-DemoPick $s 'bldg' $script:DemoBuildings $p.Building = Get-DemoPick $s 'bldg' $script:DemoBuildings
$p.Department = Get-DemoPick $s 'dept' $script:DemoDepartments $p.Department = Get-DemoPick $s 'dept' $script:DemoDepartments
@@ -236,11 +198,12 @@ function Get-DemoProfile([string]$name) {
$p.User = ("abcdefghijklmnoprstw"[(Get-DemoInt $s 'fi' 0 19)]).ToString() + (Get-DemoPick $s 'sn' $script:DemoSurnames).ToLower() $p.User = ("abcdefghijklmnoprstw"[(Get-DemoInt $s 'fi' 0 19)]).ToString() + (Get-DemoPick $s 'sn' $script:DemoSurnames).ToLower()
$p.InfoBy = (Get-DemoPick $s 'ib1' @("A","B","C","D","J","K","M","R","S","T")) + (Get-DemoPick $s 'ib2' @("B","C","G","H","L","M","P","R","S","W")) $p.InfoBy = (Get-DemoPick $s 'ib1' @("A","B","C","D","J","K","M","R","S","T")) + (Get-DemoPick $s 'ib2' @("B","C","G","H","L","M","P","R","S","W"))
$p.Notes = Get-DemoPick $s 'notes' $script:DemoNotes $p.Notes = Get-DemoPick $s 'notes' $script:DemoNotes
$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 = 30.0 + 150 * (Get-DemoRand $s 'free')
$p.LowFree = 3.0 + 9 * (Get-DemoRand $s 'freeL')
$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) # A few old devices have been unplugged / powered off for days
$p.OfflineDays = if ((Get-DemoRand $s 'longoff') -lt 0.07) { Get-DemoInt $s 'offdays' 1 21 } else { 0 } $p.OfflineDays = if ((Get-DemoRand $s 'longoff') -lt 0.03) { Get-DemoInt $s 'offdays' 1 21 } else { 0 }
# Monitors at the desk move to the replacement, so both share the source's set # 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) } $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 }) $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 })
@@ -256,30 +219,149 @@ 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.NoSccm = (-not $p.IsDest) -and (Get-DemoRand $n 'nosccm') -lt 0.02 # broken client, never reported
$p.NoAd = (-not $p.IsDest) -and (Get-DemoRand $n 'noad') -lt 0.012 # computer object deleted $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
} }
# Where a migration currently is. Both halves of a pair share the source's clock. # -----------------------------------------------------------------------------------------
function Get-DemoStage([hashtable]$p) { # Schedule: each source device migrates on the day of its date tag
$now = Get-DemoNow # -----------------------------------------------------------------------------------------
$L = $script:DemoLifecycle function Test-DemoWorkday([datetime]$d) { return @($script:DemoTeamCfg.Weekdays) -contains [int]$d.DayOfWeek }
$phase = (($now + $p.Offset) % $L) / $L function Get-DemoWorkday([datetime]$from, [int]$offset) {
$idx = 0 # $offset working days from $from (0 = $from itself if it's a working day, else the next one)
for ($i = 0; $i -lt $script:DemoStages.Count; $i++) { if ($phase -ge $script:DemoStages[$i].Start) { $idx = $i } } $d = $from.Date
$start = $script:DemoStages[$idx].Start if ($offset -ge 0) {
$end = if ($idx -lt $script:DemoStages.Count - 1) { $script:DemoStages[$idx + 1].Start } else { 1.0 } while (-not (Test-DemoWorkday $d)) { $d = $d.AddDays(1) }
$cycleStart = $now - ($phase * $L) for ($i = 0; $i -lt $offset; $i++) { do { $d = $d.AddDays(1) } until (Test-DemoWorkday $d) }
return @{ } else {
Name = $script:DemoStages[$idx].Name for ($i = 0; $i -lt -$offset; $i++) { do { $d = $d.AddDays(-1) } until (Test-DemoWorkday $d) }
Phase = $phase
Progress = ($phase - $start) / ($end - $start)
StageStarted = [DateTimeOffset]::FromUnixTimeSeconds([long]($cycleStart + $start * $L)).LocalDateTime
StageEnded = [DateTimeOffset]::FromUnixTimeSeconds([long]($cycleStart + $end * $L)).LocalDateTime
} }
return $d
}
function ConvertFrom-DemoDateTag([string]$tag) {
if ($tag -notmatch '^(\d{1,2})/(\d{1,2})$') { return $null }
$today = (Get-Date).Date
try { $d = [datetime]::new($today.Year, [int]$Matches[1], [int]$Matches[2]) } catch { return $null }
if (($d - $today).TotalDays -gt 183) { $d = $d.AddYears(-1) } elseif (($today - $d).TotalDays -gt 183) { $d = $d.AddYears(1) }
return $d
}
function ConvertTo-DemoDateTag([datetime]$d) { return $d.ToString("M/d", [System.Globalization.CultureInfo]::InvariantCulture) }
# Tags as a list of @{ Name; Devices; Skip } from a parsed Tracked-Devices.json (object or hashtable)
function Get-DemoTagList($tracked) {
$out = [System.Collections.Generic.List[object]]::new()
if (-not $tracked) { return $out }
$pairs = if ($tracked -is [System.Collections.IDictionary]) { $tracked.GetEnumerator() | ForEach-Object { @{ Name = $_.Key; Value = $_.Value } } }
else { $tracked.PSObject.Properties | ForEach-Object { @{ Name = $_.Name; Value = $_.Value } } }
foreach ($pair in $pairs) {
$v = $pair.Value
$devs = if ($v -is [System.Collections.IDictionary]) { $v.devices } elseif ($v.PSObject.Properties.Name -contains 'devices') { $v.devices } else { $v }
$skip = if ($v -is [System.Collections.IDictionary]) { [bool]$v.skipUSMT } elseif ($v.PSObject.Properties.Name -contains 'skipUSMT') { [bool]$v.skipUSMT } else { $false }
$out.Add(@{ Name = $pair.Name; Devices = @($devs | Where-Object { $_ } | ForEach-Object { "$_".Trim().ToUpper() }); Skip = $skip })
}
return $out
}
$script:DemoSchedule = $null # source -> scheduled day
$script:DemoFixed = [System.Collections.Generic.HashSet[string]]::new() # problems the team has fixed
$script:DemoPlans = @{}
$script:DemoScheduleStamp = ''
$script:DemoScheduleChecked = [datetime]::MinValue
# -Pin: use this roster as is (the seed and the team work on one in memory), don't re-read the file
function Set-DemoSchedule($tracked, $fixed, [switch]$Pin) {
$sched = @{}
foreach ($t in (Get-DemoTagList $tracked)) {
$day = ConvertFrom-DemoDateTag $t.Name
if (-not $day) { continue }
foreach ($d in $t.Devices) { if ($d -notmatch ',' -and (-not $sched.ContainsKey($d) -or $sched[$d] -lt $day)) { $sched[$d] = $day } }
}
$script:DemoSchedule = $sched
$script:DemoFixed = [System.Collections.Generic.HashSet[string]]::new([string[]]@($fixed | Where-Object { $_ }))
$script:DemoPlans = @{}
$script:DemoSchedulePinned = [bool]$Pin
}
# Re-reads the roster (and the team's fixes) when they change on disk; checked at most every few seconds
$script:DemoSchedulePinned = $false
function Import-DemoSchedule {
if ($script:DemoSchedulePinned) { return }
if ($script:DemoSchedule -and ((Get-Date) - $script:DemoScheduleChecked).TotalSeconds -lt 3) { return }
$script:DemoScheduleChecked = Get-Date
$tp = Join-Path $script:DemoDataDir "Tracked-Devices.json"
$fp = Join-Path $script:DemoDataDir "Demo-Team.json"
$stamp = (@($tp, $fp) | ForEach-Object { if (Test-Path $_) { (Get-Item $_).LastWriteTimeUtc.Ticks } else { 0 } }) -join '|'
if ($script:DemoSchedule -and $stamp -eq $script:DemoScheduleStamp) { return }
$tracked = $null; $fixed = @()
try { $tracked = [System.IO.File]::ReadAllText($tp) | ConvertFrom-Json } catch {}
try { $fixed = @(([System.IO.File]::ReadAllText($fp) | ConvertFrom-Json).Fixed) } catch {}
Set-DemoSchedule $tracked $fixed
$script:DemoScheduleStamp = $stamp
}
# What the problems that block a device are, before the team fixes them
function Test-DemoOffline([hashtable]$sp) { return $sp.OfflineDays -gt 0 -and -not $script:DemoFixed.Contains($sp.Name) }
# The day's timeline for a source: when backup and restore run and whether something stops it
function Get-DemoPlan([string]$source) {
Import-DemoSchedule
$day = $script:DemoSchedule[$source]
if (-not $day) { return $null }
$d = $day.ToString('yyyyMMdd')
$key = "$source|$d"
if ($script:DemoPlans.ContainsKey($key)) { return $script:DemoPlans[$key] }
$sp = Get-DemoProfile $source
$ws = [timespan]$script:DemoTeamCfg.WorkStart; $we = [timespan]$script:DemoTeamCfg.WorkEnd
$room = [math]::Max(30, ($we - $ws).TotalMinutes - 150) # last ones still finish around closing time
$bStart = $day.Add($ws).AddMinutes([math]::Floor((Get-DemoRand $source "start$d") * $room))
$bEnd = $bStart.AddMinutes((Get-DemoInt $source "bdur$d" 30 70))
$rStart = $bEnd.AddMinutes((Get-DemoInt $source "swap$d" 15 60)) # hardware swap at the desk
$rEnd = $rStart.AddMinutes((Get-DemoInt $source "rdur$d" 20 45))
$fail = $null
if (-not $script:DemoFixed.Contains($source)) {
$r = Get-DemoRand $source "err$d"
if ($sp.NoSccm) { $fail = 'BadClient' }
elseif (Test-DemoOffline $sp) { $fail = 'Offline' }
elseif ($sp.BrokenWmi) { $fail = 'WMI' }
elseif ($sp.LowDisk) { $fail = 'LowDisk' }
elseif ($r -lt 0.025) { $fail = 'BackupError' }
elseif ($r -lt 0.035) { $fail = 'RestoreError' }
}
$failAt = switch ($fail) {
{ $_ -in 'LowDisk', 'BackupError' } { $bStart.AddMinutes(0.55 * ($bEnd - $bStart).TotalMinutes); break }
'RestoreError' { $rStart.AddMinutes(0.6 * ($rEnd - $rStart).TotalMinutes); break }
$null { $null; break }
default { $bStart } # can't even start: the task sequence never runs
}
$plan = @{ Day = $day; BackupStart = $bStart; BackupEnd = $bEnd; RestoreStart = $rStart; RestoreEnd = $rEnd; Fail = $fail; FailAt = $failAt }
$script:DemoPlans[$key] = $plan
return $plan
}
# Where a migration is right now. Both halves of a pair share the source's plan.
function Get-DemoStage([hashtable]$p) {
$plan = Get-DemoPlan $p.Source
$now = Get-Date
$st = { param($name, $progress, $from, $to, $failed) @{ Name = $name; Progress = $progress; StageStarted = $from; StageEnded = $to; Plan = $plan; Failed = [bool]$failed } }
if (-not $plan) { return & $st 'NotStarted' 0 $now.Date $now.Date $false } # not scheduled
if ($plan.Fail -in 'BadClient', 'Offline', 'WMI') { return & $st 'NotStarted' 0 $plan.Day $plan.Day $false }
if ($now -lt $plan.BackupStart) { return & $st 'NotStarted' 0 $plan.Day $plan.BackupStart $false }
$backupFails = $plan.Fail -in 'LowDisk', 'BackupError'
if ($now -lt $plan.BackupEnd -or $backupFails) {
if ($backupFails -and $now -ge $plan.FailAt) { return & $st 'BackupInProgress' 0.55 $plan.BackupStart $plan.BackupEnd $true }
return & $st 'BackupInProgress' (($now - $plan.BackupStart).TotalSeconds / ($plan.BackupEnd - $plan.BackupStart).TotalSeconds) $plan.BackupStart $plan.BackupEnd $false
}
if ($now -lt $plan.RestoreStart) { return & $st 'BackupCompleted' 0 $plan.BackupEnd $plan.RestoreStart $false }
if ($now -lt $plan.RestoreEnd -or $plan.Fail -eq 'RestoreError') {
if ($plan.Fail -eq 'RestoreError' -and $now -ge $plan.FailAt) { return & $st 'RestoreInProgress' 0.6 $plan.RestoreStart $plan.RestoreEnd $true }
return & $st 'RestoreInProgress' (($now - $plan.RestoreStart).TotalSeconds / ($plan.RestoreEnd - $plan.RestoreStart).TotalSeconds) $plan.RestoreStart $plan.RestoreEnd $false
}
return & $st 'Completed' 1 $plan.RestoreEnd $plan.RestoreEnd $false
} }
function Get-DemoNetworkState([string]$computer) { function Get-DemoNetworkState([string]$computer) {
@@ -291,7 +373,7 @@ 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 (-not $p.IsDest -and (Test-DemoOffline $p)) { return "Timed Out" }
if ($p.IsDest) { if ($p.IsDest) {
if (Test-DemoNotImaged $p) { return "Not Found" } # not imaged yet: no DNS record 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" }
@@ -308,7 +390,12 @@ function Get-DemoNetworkState([string]$computer) {
# A replacement that hasn't been imaged yet isn't in DNS, AD or SCCM # A replacement that hasn't been imaged yet isn't in DNS, AD or SCCM
function Test-DemoNotImaged([hashtable]$p) { function Test-DemoNotImaged([hashtable]$p) {
return $p.IsDest -and (Get-DemoStage $p).Name -eq 'NotStarted' -and (Get-DemoRand $p.Name 'staged') -lt 0.5 if (-not $p.IsDest) { return $false }
$stage = Get-DemoStage $p
if ($stage.Name -ne 'NotStarted') { return $false }
# Replacements are imaged a few days ahead of their date
if (-not $stage.Plan -or ($stage.Plan.Day - (Get-Date).Date).TotalDays -gt 3) { return $true }
return (Get-DemoRand $p.Name 'staged') -lt 0.2
} }
function Start-DemoLatency([int]$scale = 1) { function Start-DemoLatency([int]$scale = 1) {
@@ -367,7 +454,9 @@ function Get-DiskSpace {
# A small drift so free space isn't frozen between cycles # A small drift so free space isn't frozen between cycles
$drift = 2 * (Get-DemoRand $p.Name "drift$([math]::Floor((Get-DemoNow) / 3600))") - 1 $drift = 2 * (Get-DemoRand $p.Name "drift$([math]::Floor((Get-DemoNow) / 3600))") - 1
$total = [math]::Round($p.DiskSize * 0.93, 1) $total = [math]::Round($p.DiskSize * 0.93, 1)
$free = [math]::Round([math]::Max(1.2, $p.SrcFree + $drift), 1) $sp = Get-DemoProfile $p.Source
$base = if ($sp.LowDisk -and -not $script:DemoFixed.Contains($sp.Name)) { $sp.LowFree } else { $p.SrcFree }
$free = [math]::Round([math]::Max(1.2, $base + $drift), 1)
if ($v) { Write-Host "${free}GB Free out of ${total}GB" -ForegroundColor Green } if ($v) { Write-Host "${free}GB Free out of ${total}GB" -ForegroundColor Green }
return @{ Total = "$total GB"; Free = "$free GB" } return @{ Total = "$total GB"; Free = "$free GB" }
} }
@@ -394,31 +483,36 @@ function Get-LogInfo {
if ($p.Valid) { if ($p.Valid) {
$stage = Get-DemoStage $p $stage = Get-DemoStage $p
$plan = $stage.Plan
$active = if ($isScan) { 'BackupInProgress' } else { 'RestoreInProgress' } $active = if ($isScan) { 'BackupInProgress' } else { 'RestoreInProgress' }
$doneFrom = if ($isScan) { 0.40 } else { 0.70 } $doneFrom = if ($isScan) { 2 } else { 4 } # index in DemoStageOrder
$now = Get-Date $now = Get-Date
if ($stage.Name -eq $active) { if ($stage.Name -eq $active) {
# Task sequence still running: log lives in SMSTSLog and is being written to # Task sequence still running (or stopped on an error): log lives in SMSTSLog
$msg = if ($isScan -and $p.Failing -and $stage.Progress -gt 0.5) { if ($stage.Failed) {
'errorText, "Insufficient free space on the migration store"' $err = switch ($plan.Fail) {
'LowDisk' { 'Insufficient free space on the migration store' }
'BackupError' { Get-DemoPick $p.Source 'berr' @('Failed to gather data: Access is denied. (0x80070005)', 'Return code 61: Processing stopped due to an I/O error', 'Return code 27: Invalid store path') }
default { Get-DemoPick $p.Source 'rerr' @('Failed to apply settings: the profile is in use (0x80070020)', 'Return code 71: Unable to start. Run USMT with elevated privileges') }
}
$msg = "errorText, `"$err`""
$modified = $plan.FailAt
} else { } else {
$pct = [int][math]::Floor($stage.Progress * 100) $pct = [int][math]::Floor($stage.Progress * 100)
if ($pct -lt 8) { "PHASE, Initializing" } $msg = if ($pct -lt 8) { "PHASE, Initializing" }
elseif ($pct -lt 20) { if ($isScan) { "PHASE, Scanning" } else { "PHASE, Gathering" } } elseif ($pct -lt 20) { if ($isScan) { "PHASE, Scanning" } else { "PHASE, Gathering" } }
elseif ($pct -lt 35) { "detectedUser, CORP\$($p.User), includedInMigration, Yes" } elseif ($pct -lt 35) { "detectedUser, CORP\$($p.User), includedInMigration, Yes" }
elseif ($pct -lt 50) { if ($isScan) { "PHASE, Collecting" } else { "PHASE, Applying" } } elseif ($pct -lt 50) { if ($isScan) { "PHASE, Collecting" } else { "PHASE, Applying" } }
else { "totalPercentageCompleted, $pct" } else { "totalPercentageCompleted, $pct" }
}
$modified = $now.AddSeconds(-1 * (Get-DemoInt $p.Name "lm$([math]::Floor((Get-DemoNow) / 60))" 20 240)) $modified = $now.AddSeconds(-1 * (Get-DemoInt $p.Name "lm$([math]::Floor((Get-DemoNow) / 60))" 20 240))
if ($modified -lt $stage.StageStarted) { $modified = $stage.StageStarted }
}
$found = @{ Folder = $script:DemoLogFolders[0]; Raw = (Format-DemoLogLine $modified $stage.StageStarted $msg); Modified = $modified } $found = @{ Folder = $script:DemoLogFolders[0]; Raw = (Format-DemoLogLine $modified $stage.StageStarted $msg); Modified = $modified }
} elseif ($stage.Phase -ge $doneFrom) { } elseif ($plan -and $script:DemoStageOrder.IndexOf($stage.Name) -ge $doneFrom) {
# Finished: the task sequence has ended and the log was moved to CCM\Logs # Finished: the task sequence has ended and the log was moved to CCM\Logs
$L = $script:DemoLifecycle $began = if ($isScan) { $plan.BackupStart } else { $plan.RestoreStart }
$cycleStart = (Get-DemoNow) - ($stage.Phase * $L) $ended = if ($isScan) { $plan.BackupEnd } else { $plan.RestoreEnd }
$startFrac = if ($isScan) { 0.25 } else { 0.55 }
$began = [DateTimeOffset]::FromUnixTimeSeconds([long]($cycleStart + $startFrac * $L)).LocalDateTime
$ended = [DateTimeOffset]::FromUnixTimeSeconds([long]($cycleStart + $doneFrom * $L)).LocalDateTime
$found = @{ Folder = $script:DemoLogFolders[1]; Raw = (Format-DemoLogLine $ended $began "Successful run"); Modified = $ended } $found = @{ Folder = $script:DemoLogFolders[1]; Raw = (Format-DemoLogLine $ended $began "Successful run"); Modified = $ended }
} }
} }
@@ -444,7 +538,12 @@ function Get-DeviceAssociations {
param([string]$SourceComputer) param([string]$SourceComputer)
Start-DemoLatency 2 Start-DemoLatency 2
$p = Get-DemoProfile $SourceComputer $p = Get-DemoProfile $SourceComputer
if (-not $p.Valid -or $p.IsDest -or $p.NoAssoc) { return $null } if (-not $p.Valid -or $p.IsDest) { return $null }
# Some associations are only created in SCCM a couple of days before the device's date
if ($p.NoAssoc) {
$plan = Get-DemoPlan $p.Name
if (-not $plan -or ($plan.Day - (Get-Date).Date).TotalDays -gt 2) { return $null }
}
$status = switch ((Get-DemoStage $p).Name) { $status = switch ((Get-DemoStage $p).Name) {
'NotStarted' { 0 } 'NotStarted' { 0 }
@@ -464,7 +563,8 @@ function Get-DeviceInventory {
$p = Get-DemoProfile $computerName $p = Get-DemoProfile $computerName
if (-not $p.Valid) { return @{ InAD = $false; InSCCM = $false } } # a typo: nothing knows it if (-not $p.Valid) { return @{ InAD = $false; InSCCM = $false } } # a typo: nothing knows it
if (Test-DemoNotImaged $p) { return @{ InAD = $false; InSCCM = $false } } if (Test-DemoNotImaged $p) { return @{ InAD = $false; InSCCM = $false } }
$flags = @{ InAD = -not $p.NoAd; InSCCM = -not $p.NoSccm } $fixed = $script:DemoFixed.Contains($p.Name)
$flags = @{ InAD = -not $p.NoAd; InSCCM = -not ($p.NoSccm -and -not $fixed) }
$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" }
@@ -497,7 +597,7 @@ function Get-DeviceInventory {
RAM = $p.RAM RAM = $p.RAM
InventoryIP = $p.IP InventoryIP = $p.IP
LastUser = $lastUser LastUser = $lastUser
ClientStatus = if ($p.Inactive) { "Installed / Inactive" } else { "Installed / Active" } ClientStatus = if ($p.Inactive -or ($p.BrokenWmi -and -not $fixed)) { "Installed / Inactive" } else { "Installed / Active" }
Updated = $updated.ToString("M/d/yyyy") Updated = $updated.ToString("M/d/yyyy")
Location = $p.Location Location = $p.Location
Vanity = $p.Vanity Vanity = $p.Vanity
@@ -525,8 +625,8 @@ function Get-DeviceInventory {
function Get-DeviceLastSeen { function Get-DeviceLastSeen {
param([string]$computerName, $Session) param([string]$computerName, $Session)
$p = Get-DemoProfile $computerName $p = Get-DemoProfile $computerName
if (-not $p.Valid -or (Test-DemoNotImaged $p) -or $p.NoSccm) { return $null } if (-not $p.Valid -or (Test-DemoNotImaged $p) -or ($p.NoSccm -and -not $script:DemoFixed.Contains($p.Name))) { return $null }
if (-not $p.IsDest -and $p.OfflineDays -gt 0) { if (-not $p.IsDest -and (Test-DemoOffline $p)) {
return (Get-Date).AddDays(-$p.OfflineDays).AddHours(-(Get-DemoInt $p.Name 'offhrs' 0 9)) 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)) return (Get-Date).AddMinutes(-(Get-DemoInt $p.Name "seen$([math]::Floor((Get-DemoNow) / 600))" 5 55))
@@ -572,3 +672,266 @@ function Get-InstalledSoftware {
} }
return $rows return $rows
} }
# =========================================================================================
# Simulated team (demo worker only)
#
# Plays the IT team that owns the roster: the next two weeks of working days are tagged,
# and during working hours someone adds devices, reschedules or drops one, tags a stuck PC
# with its problem and later fixes it and moves it to a new day. Each morning finished days
# are closed out (completed devices untracked, stragglers rescheduled) and a new day is
# scheduled at the end. Every action is printed to the worker log.
# =========================================================================================
$script:DemoTechs = @("MR", "SK", "TB", "JC", "AL")
$script:DemoIssueTags = @{ BadClient = "Bad Client"; WMI = "Broken WMI"; LowDisk = "Low Disk Space"; BackupError = "USMT Errors"; RestoreError = "USMT Errors"; Offline = "Can't Reach" }
$script:DemoDeptTags = @{ Accounting = "Accounting"; Sales = "Sales Floor"; Executive = "Exec Suite" }
# Roster as an ordered table of tag -> @{ devices = List[string]; skipUSMT = bool }
function ConvertTo-DemoRoster($tracked) {
$r = [ordered]@{}
foreach ($t in (Get-DemoTagList $tracked)) { $r[$t.Name] = @{ devices = [System.Collections.Generic.List[string]]::new([string[]]$t.Devices); skipUSMT = $t.Skip } }
return $r
}
function Save-DemoRoster([string]$path, $roster) {
# Date tags first, in date order, then the rest as they were
$dated = @($roster.Keys | Where-Object { ConvertFrom-DemoDateTag $_ } | Sort-Object { ConvertFrom-DemoDateTag $_ })
$out = [ordered]@{}
foreach ($k in $dated + @($roster.Keys | Where-Object { $dated -notcontains $_ })) {
if ($roster[$k].devices.Count -eq 0) { continue } # an empty tag is deleted
$out[$k] = [ordered]@{ devices = @($roster[$k].devices); skipUSMT = [bool]$roster[$k].skipUSMT }
}
$tmp = "$path.tmp"
[System.IO.File]::WriteAllText($tmp, ($out | ConvertTo-Json -Depth 5), [System.Text.Encoding]::UTF8)
Move-Item -LiteralPath $tmp -Destination $path -Force
}
function Get-DemoNewDevice($roster, $state) {
$all = [System.Collections.Generic.HashSet[string]]::new()
foreach ($t in $roster.Values) { foreach ($d in $t.devices) { foreach ($n in ($d -split '\s*,\s*')) { $null = $all.Add($n) } } }
do {
$state.Serial = [int]$state.Serial + 1
$seed = "team$($state.Serial)"
$r = Get-DemoRand $seed 'prefix'
$prefix = if ($r -lt 0.55) { "PRVDT" } elseif ($r -lt 0.80) { "PUBDT" } elseif ($r -lt 0.95) { "PRVLT" } else { "PUBLT" }
$name = "$prefix-$(New-DemoSerial $seed)"
} while ($all.Contains($name))
return $name
}
function Remove-DemoFromRoster($roster, [string]$dev, [string[]]$only) {
foreach ($k in @($roster.Keys)) { if (-not $only -or $only -contains $k) { $null = $roster[$k].devices.Remove($dev) } }
}
function Add-DemoToTag($roster, [string]$tag, [string]$dev) {
if (-not $roster.Contains($tag)) { $roster[$tag] = @{ devices = [System.Collections.Generic.List[string]]::new(); skipUSMT = $false } }
if (-not $roster[$tag].devices.Contains($dev)) { $roster[$tag].devices.Add($dev) }
}
function Get-DemoDateTagsOf($roster, [string]$dev) { return @($roster.Keys | Where-Object { (ConvertFrom-DemoDateTag $_) -and $roster[$_].devices.Contains($dev) }) }
# A stuck device: its problem has shown up by now
function Get-DemoStuck([string]$dev) {
$plan = Get-DemoPlan $dev
if (-not $plan -or -not $plan.Fail -or (Get-Date) -lt $plan.FailAt) { return $null }
return $plan.Fail
}
function Move-DemoDevice($roster, $state, [string]$dev, [datetime]$to) {
$from = Get-DemoDateTagsOf $roster $dev
Remove-DemoFromRoster $roster $dev $from
Add-DemoToTag $roster (ConvertTo-DemoDateTag $to) $dev
return ($from -join ", ")
}
# The days the team keeps tagged: the last few working days (still being cleaned up) and the
# next two weeks of working days, today included
function Get-DemoWindow {
$today = (Get-Date).Date
$kept = @(1..[int]$script:DemoTeamCfg.DaysKept | ForEach-Object { Get-DemoWorkday $today (-$_) })
$ahead = @(0..([int]$script:DemoTeamCfg.DaysAhead - 1) | ForEach-Object { Get-DemoWorkday $today $_ })
return @{ Oldest = ($kept | Sort-Object | Select-Object -First 1); Ahead = $ahead }
}
function Invoke-DemoRollover($roster, $state, $log) {
$w = Get-DemoWindow
$tech = Get-Random -InputObject $script:DemoTechs
foreach ($tag in @($roster.Keys)) {
$day = ConvertFrom-DemoDateTag $tag
if (-not $day -or $day -ge $w.Oldest) { continue }
$done = 0; $moved = [System.Collections.Generic.List[string]]::new(); $retired = 0
foreach ($dev in @($roster[$tag].devices)) {
if ((Get-DemoStage (Get-DemoProfile $dev)).Name -eq 'Completed' -or -not (Get-DemoPlan $dev)) {
Remove-DemoFromRoster $roster $dev; $done++; continue
}
$moves = [int]$state.Moves[$dev]
if ($moves -ge 2) { Remove-DemoFromRoster $roster $dev; $retired++; continue }
$state.Moves[$dev] = $moves + 1
$to = $w.Ahead[[math]::Min($w.Ahead.Count - 1, (Get-Random -Minimum 1 -Maximum 4))]
$null = Move-DemoDevice $roster $state $dev $to
$moved.Add("$dev -> $(ConvertTo-DemoDateTag $to)")
}
$roster.Remove($tag)
if ($done + $moved.Count + $retired -eq 0) { continue }
$msg = "[Team] $tech closed out $tag`: $done completed device(s) untracked"
if ($moved.Count) { $msg += ", rescheduled $($moved -join ', ')" }
if ($retired) { $msg += ", $retired dropped after two reschedules" }
$log.Add($msg)
}
# Fill any working day in the next two weeks that has nothing scheduled
$per = [int]$script:DemoTeamCfg.DevicesPerDay
foreach ($day in $w.Ahead) {
$tag = ConvertTo-DemoDateTag $day
if ($roster.Contains($tag) -and $roster[$tag].devices.Count) { continue }
$n = [math]::Max(3, $per + (Get-Random -Minimum -3 -Maximum 4))
$new = @(1..$n | ForEach-Object { Get-DemoNewDevice $roster $state })
foreach ($dev in $new) {
Add-DemoToTag $roster $tag $dev
$dept = (Get-DemoProfile $dev).Department.Name
if ($script:DemoDeptTags.ContainsKey($dept) -and (Get-Random -Maximum 1.0) -lt 0.6) { Add-DemoToTag $roster $script:DemoDeptTags[$dept] $dev }
}
$log.Add("[Team] $tech scheduled $n devices for $tag")
}
}
function Invoke-DemoTeamAction($roster, $state, $log) {
$w = Get-DemoWindow
$today = (Get-Date).Date
$tech = Get-Random -InputObject $script:DemoTechs
$upcoming = @($w.Ahead | Where-Object { $_ -gt $today })
$allDated = @($roster.Keys | Where-Object { ConvertFrom-DemoDateTag $_ })
$devsOn = { param($days) @($allDated | Where-Object { (ConvertFrom-DemoDateTag $_) -in $days } | ForEach-Object { $roster[$_].devices } | Select-Object -Unique) }
$issueNames = @($script:DemoIssueTags.Values | Select-Object -Unique)
$actions = @('label', 'label', 'fix', 'fix', 'add', 'add', 'move', 'move', 'drop', 'dept') | Sort-Object { Get-Random }
foreach ($a in $actions) {
switch ($a) {
'label' {
# Someone notices a stuck PC and tags it with its problem
$cands = @(& $devsOn @($allDated | ForEach-Object { ConvertFrom-DemoDateTag $_ } | Where-Object { $_ -le $today }) | Where-Object {
$k = Get-DemoStuck $_; $k -and -not ($roster.Contains($script:DemoIssueTags[$k]) -and $roster[$script:DemoIssueTags[$k]].devices.Contains($_)) })
if (-not $cands.Count) { continue }
$dev = Get-Random -InputObject $cands
$tag = $script:DemoIssueTags[(Get-DemoStuck $dev)]
Add-DemoToTag $roster $tag $dev
$log.Add("[Team] $tech tagged $dev as '$tag'"); return
}
'fix' {
# A problem gets fixed: the PC goes back on the schedule
$cands = @($issueNames | Where-Object { $roster.Contains($_) } | ForEach-Object { $roster[$_].devices } | Select-Object -Unique)
if (-not $cands.Count) { continue }
$dev = Get-Random -InputObject $cands
$kind = (Get-DemoPlan $dev).Fail
$what = switch ($kind) {
'BadClient' { "repaired the ConfigMgr client on" } 'WMI' { "rebuilt the WMI repository on" } 'LowDisk' { "cleared space on C: of" }
'Offline' { "got the user to power on" } default { "cleared the USMT error on" }
}
$null = $state.Fixed.Add($dev)
$script:DemoFixed.Add($dev) | Out-Null
Remove-DemoFromRoster $roster $dev $issueNames
$to = $w.Ahead[[math]::Min($w.Ahead.Count - 1, (Get-Random -Minimum 1 -Maximum 4))]
$from = Move-DemoDevice $roster $state $dev $to
$log.Add("[Team] $tech $what $dev; moved it from $from to $(ConvertTo-DemoDateTag $to)"); return
}
'add' {
if (-not $upcoming.Count) { continue }
$day = Get-Random -InputObject $upcoming
$tag = ConvertTo-DemoDateTag $day
$new = @(1..(Get-Random -Minimum 1 -Maximum 4) | ForEach-Object { Get-DemoNewDevice $roster $state })
foreach ($dev in $new) { Add-DemoToTag $roster $tag $dev }
$log.Add("[Team] $tech added $($new -join ', ') to $tag"); return
}
'move' {
$cands = @(& $devsOn $upcoming | Where-Object { (Get-DemoStage (Get-DemoProfile $_)).Name -eq 'NotStarted' })
if (-not $cands.Count -or $upcoming.Count -lt 2) { continue }
$dev = Get-Random -InputObject $cands
$cur = Get-DemoScheduleDate $dev
$to = Get-Random -InputObject @($upcoming | Where-Object { $_ -ne $cur })
$why = if ($to -lt $cur) { Get-Random -InputObject @("user asked to go earlier", "a slot opened up", "swapped with another desk") }
else { Get-Random -InputObject @("user is out that day", "department asked for a later date", "waiting on a docking station", "swapped with another desk") }
$from = Move-DemoDevice $roster $state $dev $to
$log.Add("[Team] $tech moved $dev from $from to $(ConvertTo-DemoDateTag $to) ($why)"); return
}
'drop' {
$cands = @(& $devsOn $upcoming | Where-Object { (Get-DemoStage (Get-DemoProfile $_)).Name -eq 'NotStarted' })
if (-not $cands.Count) { continue }
$dev = Get-Random -InputObject $cands
$why = Get-Random -InputObject @("device is being retired", "already replaced last month", "user left the company", "duplicate entry")
Remove-DemoFromRoster $roster $dev
$log.Add("[Team] $tech removed $dev ($why)"); return
}
'dept' {
$cands = @(& $devsOn $upcoming | Where-Object {
$dep = (Get-DemoProfile $_).Department.Name
$script:DemoDeptTags.ContainsKey($dep) -and -not ($roster.Contains($script:DemoDeptTags[$dep]) -and $roster[$script:DemoDeptTags[$dep]].devices.Contains($_)) })
if (-not $cands.Count) { continue }
$dev = Get-Random -InputObject $cands
$tag = $script:DemoDeptTags[(Get-DemoProfile $dev).Department.Name]
Add-DemoToTag $roster $tag $dev
$log.Add("[Team] $tech added $dev to '$tag'"); return
}
}
}
}
function Get-DemoScheduleDate([string]$dev) { Import-DemoSchedule; return $script:DemoSchedule[$dev] }
function Read-DemoTeamState([string]$path) {
$state = @{ Day = ''; NextAction = (Get-Date).ToString("o"); Serial = 0; Fixed = [System.Collections.Generic.HashSet[string]]::new(); Moves = @{} }
if (Test-Path $path) {
try {
$j = [System.IO.File]::ReadAllText($path) | ConvertFrom-Json
$state.Day = "$($j.Day)"; $state.NextAction = "$($j.NextAction)"; $state.Serial = [int]$j.Serial
foreach ($f in @($j.Fixed)) { if ($f) { $null = $state.Fixed.Add($f) } }
if ($j.Moves) { foreach ($m in $j.Moves.PSObject.Properties) { $state.Moves[$m.Name] = [int]$m.Value } }
} catch {}
}
return $state
}
function Save-DemoTeamState([string]$path, $state, $roster) {
# Forget fixes and move counts for devices that are no longer tracked
$live = [System.Collections.Generic.HashSet[string]]::new()
foreach ($t in $roster.Values) { foreach ($d in $t.devices) { $null = $live.Add($d) } }
$moves = [ordered]@{}
foreach ($k in $state.Moves.Keys) { if ($live.Contains($k)) { $moves[$k] = $state.Moves[$k] } }
$out = [ordered]@{
Day = $state.Day; NextAction = $state.NextAction; Serial = $state.Serial
Fixed = @($state.Fixed | Where-Object { $live.Contains($_) })
Moves = $moves
}
[System.IO.File]::WriteAllText($path, ($out | ConvertTo-Json -Depth 4), [System.Text.Encoding]::UTF8)
}
# Called by the worker at the start of every demo cycle
function Invoke-DemoTeam([string]$DataDir) {
$trackedPath = Join-Path $DataDir "Tracked-Devices.json"
$statePath = Join-Path $DataDir "Demo-Team.json"
if (-not (Test-Path $trackedPath)) { return }
$state = Read-DemoTeamState $statePath
try { $roster = ConvertTo-DemoRoster ([System.IO.File]::ReadAllText($trackedPath) | ConvertFrom-Json) } catch { return }
Set-DemoSchedule $roster $state.Fixed -Pin
$log = [System.Collections.Generic.List[string]]::new()
$now = Get-Date
$todayKey = $now.ToString("yyyy-MM-dd")
$dirty = -not (Test-Path $statePath)
if ($state.Day -ne $todayKey) {
Invoke-DemoRollover $roster $state $log
$state.Day = $todayKey
$dirty = $true
}
$ws = [timespan]$script:DemoTeamCfg.WorkStart; $we = [timespan]$script:DemoTeamCfg.WorkEnd
$working = (Test-DemoWorkday $now) -and $now.TimeOfDay -ge $ws -and $now.TimeOfDay -lt $we
$next = [datetime]::MinValue
if (-not [datetime]::TryParse($state.NextAction, [ref]$next)) { $next = $now }
if ($working -and $now -ge $next) {
Invoke-DemoTeamAction $roster $state $log
$range = @($script:DemoTeamCfg.ActionEveryMinutes)
$state.NextAction = $now.AddMinutes((Get-Random -Minimum ([int]$range[0]) -Maximum ([int]$range[1] + 1))).ToString("o")
$dirty = $true
}
$script:DemoSchedulePinned = $false
$script:DemoScheduleChecked = [datetime]::MinValue # re-read the roster on the next lookup
if (-not $dirty -and $log.Count -eq 0) { return }
if ($log.Count) { Save-DemoRoster $trackedPath $roster }
Save-DemoTeamState $statePath $state $roster
foreach ($line in $log) { Write-Host $line -ForegroundColor Magenta }
}
+15 -8
View File
@@ -141,15 +141,22 @@ without letting strangers change what other visitors see.
`USMT-Worker.ps1` under PowerShell 7 with `-Demo`. The worker loads `Providers\Demo.ps1` `USMT-Worker.ps1` under PowerShell 7 with `-Demo`. The worker loads `Providers\Demo.ps1`
instead of `Providers\SCCM.ps1`, and that's the only difference. instead of `Providers\SCCM.ps1`, and that's the only difference.
- **A believable fleet.** The demo provider derives each device's hardware, users, location, - **A believable fleet.** The demo provider derives each device's hardware, users, location,
department and software from a hash of its name. Each migration moves through every stage department and software from a hash of its name. Each device migrates on the day of its date
on a 24-hour clock, so the dashboard keeps changing. Devices go offline, have stale DNS, tag: earlier days are finished, today's devices move through backup and restore during
run low on disk, and fail backups at realistic rates. working hours, and later days haven't started. The few that don't finish have a reason the
dashboard shows: a broken ConfigMgr client, broken WMI, low disk space, a USMT error, or a
PC nobody can reach.
- **A simulated team.** The next two weeks of working days are tagged, like a team working a
two-week schedule. During working hours someone adds devices, reschedules or drops one,
tags a stuck PC with its problem (*Bad Client*, *Low Disk Space*...) and later fixes it and
moves it to a new day. Each morning finished days are closed out and a new day is scheduled
at the end. Every action prints in the worker terminal as `[Team] ...`.
- **Read-only API.** In demo mode the server refuses every route that writes (and - **Read-only API.** In demo mode the server refuses every route that writes (and
`get-software`, which takes free-text input) with `403`. The terminal output is the real `get-software`, which takes free-text input) with `403`. The terminal output is the real
console of both processes, mirrored to `Data\Logs` by a `Write-Host` proxy. console of both processes, mirrored to `Data\Logs` by a `Write-Host` proxy.
- **Visitor changes stay in their browser.** `web\demo\demo.js` intercepts the dashboard's - **Visitor changes stay in their browser.** `web\demo\demo.js` intercepts the dashboard's
write requests and keeps the visitor's tags, articles and images in IndexedDB, merged over write requests and keeps the visitor's tags, articles and images in IndexedDB, laid over
the server's data. Their actions print in the terminal panel exactly as the services would, the server's data (so the team's changes keep showing up around them). Their actions print in the terminal panel exactly as the services would,
marked with a small dot. Nobody else sees them, and nothing a visitor types reaches the marked with a small dot. Nobody else sees them, and nothing a visitor types reaches the
server, so there's nothing to moderate or inject. **DEMO > Reset my changes** clears it. server, so there's nothing to moderate or inject. **DEMO > Reset my changes** clears it.
@@ -168,9 +175,8 @@ On **Coolify**, create a resource from this repository using the *Dockerfile* (o
Compose*) build pack, set the exposed port to **8080**, and attach a domain. The container Compose*) build pack, set the exposed port to **8080**, and attach a domain. The container
reseeds the simulated fleet on every start and needs no volumes or secrets. Times are shown in reseeds the simulated fleet on every start and needs no volumes or secrets. Times are shown in
`TZ` (default `America/Chicago`); set it to your own timezone. The demo worker runs `TZ` (default `America/Chicago`); set it to your own timezone. The demo worker runs
6 AM to 9 PM every day (`Demo.BusinessHours` in the config) and sleeps overnight. 6 AM to 9 PM every day (`Demo.BusinessHours` in the config) and sleeps overnight; the
The date tags (such as `10/7`) move along each day, so a container that runs for weeks still simulated team works 7:30 AM to 5 PM on weekdays (`Demo.Team`), so migrations run then.
shows this week's deployments.
### Demo (local PowerShell 7) ### Demo (local PowerShell 7)
@@ -215,6 +221,7 @@ most likely to change:
| `SCCM.Monitors` | `v_GS_WMIMONITORID` | Attached monitors (needs WmiMonitorID in hardware inventory) | | `SCCM.Monitors` | `v_GS_WMIMONITORID` | Attached monitors (needs WmiMonitorID in hardware inventory) |
| `Demo.BusinessHours` | 06:00-21:00, every day | When the demo worker runs | | `Demo.BusinessHours` | 06:00-21:00, every day | When the demo worker runs |
| `Demo.DeviceCount` / `CycleSeconds` | `200` / `45` | Size of the simulated fleet and its cycle pause | | `Demo.DeviceCount` / `CycleSeconds` | `200` / `45` | Size of the simulated fleet and its cycle pause |
| `Demo.Team` | 07:30-17:00, Mon-Fri; 10 days ahead, 3 kept; 12 per day; an action every 4-12 min | The simulated team's hours, schedule window and pace |
### Adapting it to a different toolset ### Adapting it to a different toolset
+1 -1
View File
@@ -404,8 +404,8 @@ while ($true) {
Update-WorkerState "Preparing..." 0 0 $globalLastDuration "Calculating..." Update-WorkerState "Preparing..." 0 0 $globalLastDuration "Calculating..."
if ($config.IsDemo) { Invoke-DemoTeam $dataDir } # the simulated team edits the roster
$session = Open-ProviderCycle $session = Open-ProviderCycle
if ($config.IsDemo) { Update-DemoDateTags $dataDir }
$completedList = @() $completedList = @()
if (Test-Path $completedPath) { if (Test-Path $completedPath) {
+57 -44
View File
@@ -253,9 +253,11 @@
state: null, state: null,
empty() { empty() {
return { return {
version: 1, version: 2,
tracked: null, // visitor's copy of Tracked-Devices.json (null = untouched) // The visitor's roster edits, per tag, laid over the live roster (which the demo's
appliedTracked: null, // roster the (simulated) worker last swept // simulated team keeps changing): tag -> { devices, skipUSMT, created } or { deleted: true }
overrides: {},
applied: null, // the overrides the (simulated) worker last swept (null = none yet)
rev: 0, // bumps whenever simulated data changes rev: 0, // bumps whenever simulated data changes
docs: {}, // title -> { content, modified } docs: {}, // title -> { content, modified }
deletedDocs: [], // server docs the visitor archived deletedDocs: [], // server docs the visitor archived
@@ -282,15 +284,12 @@
const tx = this.db.transaction('kv', 'readonly').objectStore('kv').get('state'); const tx = this.db.transaction('kv', 'readonly').objectStore('kv').get('state');
tx.onsuccess = () => res(tx.result); tx.onerror = () => res(null); tx.onsuccess = () => res(tx.result); tx.onerror = () => res(null);
}); });
if (saved && saved.version === 1) this.state = Object.assign(this.empty(), saved); if (saved && saved.version === 2) this.state = Object.assign(this.empty(), saved);
// version 1 kept a full copy of the roster: keep its docs and images, drop the copy
else if (saved && saved.version === 1) this.state = Object.assign(this.empty(), { docs: saved.docs || {}, deletedDocs: saved.deletedDocs || [],
images: saved.images || {}, renamedImages: saved.renamedImages || {}, deletedImages: saved.deletedImages || [] });
} catch (e) { this.db = null; } // private browsing etc. - keep changes in memory only } catch (e) { this.db = null; } // private browsing etc. - keep changes in memory only
// The demo container reseeds its fleet on restart; a roster copied from an older
// seed would reference tags that no longer exist, so drop it (docs/images are kept).
if (this.state.tracked) {
const base = await serverGet('/Tracked-Devices.json', true);
if (base && hash(base) !== this.state.baseHash) { this.state.tracked = null; this.state.appliedTracked = null; this.save(); }
}
return this.state; return this.state;
}, },
save() { save() {
@@ -303,7 +302,7 @@
}, },
hasChanges() { hasChanges() {
const s = this.state; const s = this.state;
return !!(s && (s.tracked || Object.keys(s.docs).length || s.deletedDocs.length || Object.keys(s.images).length || Object.keys(s.renamedImages).length || s.deletedImages.length)); return !!(s && (Object.keys(s.overrides).length || Object.keys(s.docs).length || s.deletedDocs.length || Object.keys(s.images).length || Object.keys(s.renamedImages).length || s.deletedImages.length));
} }
}; };
@@ -355,11 +354,18 @@
// A device the visitor typed that the demo fleet doesn't know: borrow the live status of // A device the visitor typed that the demo fleet doesn't know: borrow the live status of
// a fleet device with the same prefix, so it keeps moving through the stages like the rest. // a fleet device with the same prefix, so it keeps moving through the stages like the rest.
function pickTemplate(name, rows) { // Prefers a device scheduled on the same day (a date tag such as 10/14), so a PC added to a
// future day looks not started and one added to a past day looks done.
const DATE_TAG = /^\d{1,2}\/\d{1,2}$/;
function pickTemplate(name, rows, tags) {
const prefix = name.split('-')[0]; const prefix = name.split('-')[0];
const pool = rows.filter(r => r["Source Device"].startsWith(prefix + '-') && r["Destination Device"] !== "N/A" const usable = rows.filter(r => r["Destination Device"] !== "N/A"
&& !["Skipped", "N/A"].includes(r["Backup Status"]) && r["USMT Status"] !== "N/A") && !["Skipped", "N/A"].includes(r["Backup Status"]) && r["USMT Status"] !== "N/A")
.sort((a, b) => a["Source Device"].localeCompare(b["Source Device"])); .sort((a, b) => a["Source Device"].localeCompare(b["Source Device"]));
const days = (tags || []).filter(t => DATE_TAG.test(t));
const sameDay = days.length ? usable.filter(r => String(r.Tags || '').split(', ').some(t => days.includes(t))) : [];
const healthy = sameDay.filter(r => !/error/i.test((r["Source Scanstate Line"] || '') + (r["Destination Loadstate Line"] || '')));
const pool = healthy.length ? healthy : sameDay.length ? sameDay : usable.filter(r => r["Source Device"].startsWith(prefix + '-'));
return pool.length ? pool[hash(name) % pool.length] : null; return pool.length ? pool[hash(name) % pool.length] : null;
} }
const synthDest = name => name.split('-')[0] + '-' + serial(name + '|dest'); const synthDest = name => name.split('-')[0] + '-' + serial(name + '|dest');
@@ -427,7 +433,7 @@
if (srow && !["Skipped", "N/A"].includes(srow["Backup Status"])) { out.push(Object.assign(clone(srow), { Tags: tags })); continue; } if (srow && !["Skipped", "N/A"].includes(srow["Backup Status"])) { out.push(Object.assign(clone(srow), { Tags: tags })); continue; }
const t = SCHEME.test(dev) ? pickTemplate(dev, serverRows) : null; const t = SCHEME.test(dev) ? pickTemplate(dev, serverRows, tagMap[dev]) : 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, "None"); const r = blankRow(dev, "N/A", tags, "None");
@@ -456,22 +462,26 @@
return { data: data || {}, rows, adv: adv || {} }; return { data: data || {}, rows, adv: adv || {} };
} }
// The live roster with the visitor's per-tag edits on top. An edit to a tag the team has
// since closed out is dropped; a tag the visitor created stays until they delete it.
function mergeRoster(base, overrides) {
const out = {};
for (const [tag, entry] of Object.entries(base || {})) {
const o = overrides[tag];
if (!o) out[tag] = entry;
else if (!o.deleted) out[tag] = { devices: o.devices, skipUSMT: o.skipUSMT };
}
for (const [tag, o] of Object.entries(overrides)) {
if (!(tag in (base || {})) && o.created && !o.deleted) out[tag] = { devices: o.devices, skipUSMT: o.skipUSMT };
}
return out;
}
async function visitorTracked() { async function visitorTracked() {
const s = Store.state; return mergeRoster((await serverGet('/Tracked-Devices.json')) || {}, Store.state.overrides);
if (s.tracked) return s.tracked;
return (await serverGet('/Tracked-Devices.json')) || {};
} }
async function appliedTracked() {
async function ensureOwnRoster() {
const s = Store.state; const s = Store.state;
if (!s.tracked) { return s.applied ? mergeRoster((await serverGet('/Tracked-Devices.json')) || {}, s.applied) : null;
const text = (await serverGet('/Tracked-Devices.json', true)) || '{}';
const base = JSON.parse(text);
s.baseHash = hash(text);
s.tracked = clone(base);
s.appliedTracked = clone(base);
}
return s.tracked;
} }
// ===================================================================================== // =====================================================================================
@@ -564,7 +574,7 @@
try { try {
const s = Store.state; const s = Store.state;
const tracked = await visitorTracked(); const tracked = await visitorTracked();
const applied = s.appliedTracked || tracked; const applied = (await appliedTracked()) || tracked;
const snap = await serverSnapshot(); const snap = await serverSnapshot();
if (!snap.rows.length) { // container just started: nothing to sweep against yet if (!snap.rows.length) { // container just started: nothing to sweep against yet
w([['[Waiting for the first worker sweep to finish...]', 'DarkGray']]); w([['[Waiting for the first worker sweep to finish...]', 'DarkGray']]);
@@ -610,8 +620,7 @@
await sleep(delay); await sleep(delay);
if (fresh.length && i === fresh.length && i < ordered.length) { if (fresh.length && i === fresh.length && i < ordered.length) {
w([[` -> [PRIORITY] ${fresh.length} new device(s) swept. Publishing early...`, 'Green']]); w([[` -> [PRIORITY] ${fresh.length} new device(s) swept. Publishing early...`, 'Green']]);
if (!s.tracked) await ensureOwnRoster(); s.applied = clone(s.overrides);
s.appliedTracked = clone(tracked);
s.rev++; s.rev++;
Store.save(); Store.save();
} }
@@ -629,8 +638,7 @@
w([[`Next Cycle Scheduled For: ${nextText}`, 'DarkGray']]); w([[`Next Cycle Scheduled For: ${nextText}`, 'DarkGray']]);
w([]); w([]); w([]); w([]);
if (!s.tracked) await ensureOwnRoster(); s.applied = clone(s.overrides);
s.appliedTracked = clone(tracked);
s.rev++; s.rev++;
Store.save(); Store.save();
} finally { } finally {
@@ -692,7 +700,7 @@
// ---- tracking roster ---- // ---- tracking roster ----
'GET Tracked-Devices.json': async () => { 'GET Tracked-Devices.json': async () => {
const s = Store.state; const s = Store.state;
const text = s.tracked ? JSON.stringify(s.tracked, null, 2) : ((await serverGet('/Tracked-Devices.json', true)) || '{}'); const text = Object.keys(s.overrides).length ? JSON.stringify(await visitorTracked(), null, 2) : ((await serverGet('/Tracked-Devices.json', true)) || '{}');
// Trailing whitespace makes the text differ after a simulated cycle, which is how // Trailing whitespace makes the text differ after a simulated cycle, which is how
// the dashboard notices it should reload Data.json. // the dashboard notices it should reload Data.json.
return textResponse(text + ' '.repeat(s.rev), 200, { 'Content-Type': 'application/json' }); return textResponse(text + ' '.repeat(s.rev), 200, { 'Content-Type': 'application/json' });
@@ -709,11 +717,16 @@
}, },
'POST update-tracked': async (url, init) => { 'POST update-tracked': async (url, init) => {
const body = await readBody(init); const body = await readBody(init);
const tracked = await ensureOwnRoster(); const s = Store.state;
const base = (await serverGet('/Tracked-Devices.json')) || {};
const tech = body.tech; const tech = body.tech;
const skip = Object.prototype.hasOwnProperty.call(body, 'skipUSMT') ? !!body.skipUSMT : false; const skip = Object.prototype.hasOwnProperty.call(body, 'skipUSMT') ? !!body.skipUSMT : false;
if (!body.devices || body.devices.length === 0) delete tracked[tech]; const prev = s.overrides[tech];
else tracked[tech] = { devices: body.devices, skipUSMT: skip }; const created = prev && !prev.deleted ? !!prev.created : !(tech in base); // a tag the visitor made
if (!body.devices || body.devices.length === 0) {
if (tech in base) s.overrides[tech] = { deleted: true };
else delete s.overrides[tech];
} else s.overrides[tech] = { devices: body.devices, skipUSMT: skip, created };
Store.save(); Store.save();
srv(`Updated tracking roster for Tech/Tag: ${tech}`, 'Green'); srv(`Updated tracking roster for Tech/Tag: ${tech}`, 'Green');
scheduleCycle('roster', 2500); // the worker picks the change up on its next sweep scheduleCycle('roster', 2500); // the worker picks the change up on its next sweep
@@ -724,7 +737,7 @@
'GET Worker-State.json': async () => { 'GET Worker-State.json': async () => {
const state = (await serverGet('/Worker-State.json')) || {}; const state = (await serverGet('/Worker-State.json')) || {};
// the visitor's own (simulated) changes count as new data too // the visitor's own (simulated) changes count as new data too
if (state.DataVersion && Store.state.appliedTracked) state.DataVersion += '#' + Store.state.rev; if (state.DataVersion && Store.state.applied) state.DataVersion += '#' + Store.state.rev;
if (Sim.worker) return jsonResponse(Object.assign({}, state, Sim.worker)); if (Sim.worker) return jsonResponse(Object.assign({}, state, Sim.worker));
return jsonResponse(state); return jsonResponse(state);
}, },
@@ -733,24 +746,24 @@
const snap = await serverSnapshot(); const snap = await serverSnapshot();
// Until the worker's first sweep there are no rows to merge with: pass through so // Until the worker's first sweep there are no rows to merge with: pass through so
// the dashboard shows its "Waiting for Worker..." placeholders // the dashboard shows its "Waiting for Worker..." placeholders
if (!s.appliedTracked || !snap.rows.length) return jsonResponse(snap.data); if (!s.applied || !snap.rows.length) return jsonResponse(snap.data);
const { rows } = buildRows(s.appliedTracked, snap.rows, snap.adv); const { rows } = buildRows(await appliedTracked(), snap.rows, snap.adv);
return jsonResponse({ LastUpdated: snap.data.LastUpdated || psStamp(), DataVersion: (snap.data.DataVersion || '') + '#' + s.rev, Devices: rows }); return jsonResponse({ LastUpdated: snap.data.LastUpdated || psStamp(), DataVersion: (snap.data.DataVersion || '') + '#' + s.rev, Devices: rows });
}, },
'GET Advanced-Cache.json': async () => { 'GET Advanced-Cache.json': async () => {
const s = Store.state; const s = Store.state;
const snap = await serverSnapshot(); const snap = await serverSnapshot();
if (!s.appliedTracked || !snap.rows.length) return jsonResponse(snap.adv); if (!s.applied || !snap.rows.length) return jsonResponse(snap.adv);
const { adv } = buildRows(s.appliedTracked, snap.rows, snap.adv); const { adv } = buildRows(await appliedTracked(), snap.rows, snap.adv);
for (const [k, val] of Object.entries(adv)) if (val) snap.adv[k] = val; for (const [k, val] of Object.entries(adv)) if (val) snap.adv[k] = val;
return jsonResponse(snap.adv); return jsonResponse(snap.adv);
}, },
'GET Software-Cache.json': async () => { 'GET Software-Cache.json': async () => {
const s = Store.state; const s = Store.state;
const cache = (await serverGet('/Software-Cache.json')) || {}; const cache = (await serverGet('/Software-Cache.json')) || {};
if (s.appliedTracked) { if (s.applied) {
const snap = await serverSnapshot(); const snap = await serverSnapshot();
const { software } = buildRows(s.appliedTracked, snap.rows, snap.adv); const { software } = buildRows(await appliedTracked(), snap.rows, snap.adv);
for (const [name, template] of Object.entries(software)) { for (const [name, template] of Object.entries(software)) {
if (cache[name]) continue; if (cache[name]) continue;
cache[name] = (template && cache[template]) || { Timestamp: new Date().toISOString(), Data: [] }; cache[name] = (template && cache[template]) || { Timestamp: new Date().toISOString(), Data: [] };
@@ -949,7 +962,7 @@
services would print. No one else sees your changes, and they're never sent to the server. services would print. No one else sees your changes, and they're never sent to the server.
Run it yourself without <code>-Demo</code> and every action works against the real backend.</p> Run it yourself without <code>-Demo</code> and every action works against the real backend.</p>
<p class="usmt-demo-try"><strong>Things to try:</strong> open <em>Terminals</em> at the bottom to watch the worker sweep the <p class="usmt-demo-try"><strong>Things to try:</strong> open <em>Terminals</em> at the bottom to watch the worker sweep the
fleet &middot; add a device such as <code>PRVDT-7QK4M2XA</code> under <em>Tracked Devices</em> &middot; fleet and a simulated team work the schedule &middot; add a device such as <code>PRVDT-7QK4M2XA</code> under <em>Tracked Devices</em> &middot;
click a device for its details &middot; <em>All Devices</em> shows apps missing from replacement machines &middot; click a device for its details &middot; <em>All Devices</em> shows apps missing from replacement machines &middot;
edit an article under <em>Documentation</em>.</p> edit an article under <em>Documentation</em>.</p>
</div> </div>
+4 -2
View File
@@ -123,8 +123,10 @@ table.ov-heat td { padding: 0; text-align: center; border: 0; background: none;
.ov-day-bar { height: 6px; margin-top: 3px; } .ov-day-bar { height: 6px; margin-top: 3px; }
.ov-has-day { cursor: pointer; background: var(--bg-card); border: 1px solid var(--border-color); } .ov-has-day { cursor: pointer; background: var(--bg-card); border: 1px solid var(--border-color); }
.ov-has-day:hover, .ov-has-day:focus-visible, .ov-day.ov-selected { border-color: #3498db; box-shadow: 0 0 0 1px #3498db; } .ov-has-day:hover, .ov-has-day:focus-visible, .ov-day.ov-selected { border-color: #3498db; box-shadow: 0 0 0 1px #3498db; }
.ov-today { box-shadow: inset 0 0 0 2px #3498db; } /* Today: the date number in a filled badge (like a calendar app), so it can't be mistaken for
.ov-today .ov-day-num { color: #3498db; font-weight: 700; } the selected day, which gets the outline and a tint */
.ov-today .ov-day-num { align-self: flex-start; background: #3498db; color: #fff; font-weight: 700; border-radius: 999px; min-width: 1.6em; padding: 0 0.4em; box-sizing: border-box; text-align: center; line-height: 1.55; }
.ov-day.ov-selected { background: rgba(52,152,219,0.16); }
/* tooltip */ /* tooltip */