diff --git a/Config/usmt-dash.psd1 b/Config/usmt-dash.psd1
index e0ac67c..ba66bec 100644
--- a/Config/usmt-dash.psd1
+++ b/Config/usmt-dash.psd1
@@ -130,7 +130,6 @@
DeviceCount = 200 # tracked source devices seeded at startup
CycleSeconds = 45 # cooldown between cycles (business hours are ignored)
DelayMsPerDevice = 35 # artificial per-device latency so the progress bar moves
- LifecycleHours = 24 # how long one simulated migration takes, start to finish
PresenceSweepMinutes = 60 # completed / Skip USMT devices: hourly, like Live (the demo runs all day)
LivePublishSeconds = 4 # publish swept rows this often during a cycle
@@ -142,6 +141,18 @@
End = '21:00'
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
}
diff --git a/Demo/New-DemoData.ps1 b/Demo/New-DemoData.ps1
index f5f8c2a..d4c2d43 100644
--- a/Demo/New-DemoData.ps1
+++ b/Demo/New-DemoData.ps1
@@ -51,12 +51,13 @@ for ($m = 0; $m -lt 3; $m++) {
$manual += "$src, $dst"
}
-# --- 2. Date-batch tags (recent and upcoming working days; the worker keeps them current) ---
-$dateTags = Get-DemoDateTags
-[System.IO.File]::WriteAllText((Join-Path $dataDir "Demo-Dates.json"), (@{ Tags = $dateTags } | ConvertTo-Json), [System.Text.Encoding]::UTF8)
-
+# --- 2. Schedule: the last few working days (mostly finished) and the next two weeks ---
+$team = $config.Demo.Team
+$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]@{}
-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]) }
$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"))) {
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["Laptops"] = $skipLaptops
@@ -81,6 +90,9 @@ foreach ($t in $tags.Keys) {
$tracked[$t] = [ordered]@{ devices = @($tags[$t]); skipUSMT = ($skipTags -contains $t) }
}
($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 ---
Copy-Item (Join-Path $PSScriptRoot "Blacklisted-Apps.txt") (Join-Path $dataDir "Blacklisted-Apps.txt") -Force
diff --git a/Providers/Demo.ps1 b/Providers/Demo.ps1
index c49d216..b1c45d0 100644
--- a/Providers/Demo.ps1
+++ b/Providers/Demo.ps1
@@ -3,9 +3,12 @@
#
# 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,
-# location and software are derived from a hash of its name, and its migration moves
-# through each stage on a clock, so the unmodified worker loop sees devices go from
-# Not Started -> Backup In Progress -> Backup Completed -> Restore In Progress -> Completed.
+# location and software are derived from a hash of its name. Its migration happens on the
+# day of its date tag (10/14 and so on), so the unmodified worker loop sees that day's devices
+# 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)
# followed by an 8 character serial. Destination (replacement) devices get a serial that
@@ -13,20 +16,14 @@
# =========================================================================================
$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:DemoAlphabet = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ"
$script:DemoAdminShare = 'C$'
$script:DemoLogFolders = @('Windows\CCM\Logs\SMSTSLog', 'Windows\CCM\Logs')
-# --- Stage boundaries (fraction of the lifecycle) ---
-$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 }
-)
+$script:DemoStageOrder = @('NotStarted', 'BackupInProgress', 'BackupCompleted', 'RestoreInProgress', 'Completed')
# --- 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")
@@ -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-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
# 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
$s = $p.Source
- $p.Offset = (Get-DemoRand $s 'offset') * $script:DemoLifecycle
$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.Building = Get-DemoPick $s 'bldg' $script:DemoBuildings
$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.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.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 })
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 }
+ # A few old devices have been unplugged / powered off for days
+ $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
$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 })
@@ -256,30 +219,149 @@ function Get-DemoProfile([string]$name) {
$p.DiskModel = Get-DemoPick $n 'ssd' $script:DemoSsdModels
$p.DiskSerial = (1..8 | ForEach-Object { "{0:X4}" -f (Get-DemoInt $n "dsn$_" 0 65535) }) -join '_'
$p.Inactive = (Get-DemoRand $n 'inactive') -lt 0.04
- $p.NoSccm = (-not $p.IsDest) -and (Get-DemoRand $n 'nosccm') -lt 0.025 # broken client, never reported
+ $p.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
}
$script:DemoProfiles[$key] = $p
return $p
}
-# Where a migration currently is. Both halves of a pair share the source's clock.
-function Get-DemoStage([hashtable]$p) {
- $now = Get-DemoNow
- $L = $script:DemoLifecycle
- $phase = (($now + $p.Offset) % $L) / $L
- $idx = 0
- for ($i = 0; $i -lt $script:DemoStages.Count; $i++) { if ($phase -ge $script:DemoStages[$i].Start) { $idx = $i } }
- $start = $script:DemoStages[$idx].Start
- $end = if ($idx -lt $script:DemoStages.Count - 1) { $script:DemoStages[$idx + 1].Start } else { 1.0 }
- $cycleStart = $now - ($phase * $L)
- return @{
- Name = $script:DemoStages[$idx].Name
- Phase = $phase
- Progress = ($phase - $start) / ($end - $start)
- StageStarted = [DateTimeOffset]::FromUnixTimeSeconds([long]($cycleStart + $start * $L)).LocalDateTime
- StageEnded = [DateTimeOffset]::FromUnixTimeSeconds([long]($cycleStart + $end * $L)).LocalDateTime
+# -----------------------------------------------------------------------------------------
+# Schedule: each source device migrates on the day of its date tag
+# -----------------------------------------------------------------------------------------
+function Test-DemoWorkday([datetime]$d) { return @($script:DemoTeamCfg.Weekdays) -contains [int]$d.DayOfWeek }
+function Get-DemoWorkday([datetime]$from, [int]$offset) {
+ # $offset working days from $from (0 = $from itself if it's a working day, else the next one)
+ $d = $from.Date
+ if ($offset -ge 0) {
+ while (-not (Test-DemoWorkday $d)) { $d = $d.AddDays(1) }
+ for ($i = 0; $i -lt $offset; $i++) { do { $d = $d.AddDays(1) } until (Test-DemoWorkday $d) }
+ } else {
+ for ($i = 0; $i -lt -$offset; $i++) { do { $d = $d.AddDays(-1) } until (Test-DemoWorkday $d) }
}
+ 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) {
@@ -291,7 +373,7 @@ function Get-DemoNetworkState([string]$computer) {
if ($fault -lt 0.03) { return "DNS Issue" }
$stage = (Get-DemoStage $p).Name
- if (-not $p.IsDest -and $p.OfflineDays -gt 0 -and $stage -in @('NotStarted', 'Completed')) { return "Timed Out" }
+ if (-not $p.IsDest -and (Test-DemoOffline $p)) { return "Timed Out" }
if ($p.IsDest) {
if (Test-DemoNotImaged $p) { return "Not Found" } # not imaged yet: no DNS record
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
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) {
@@ -367,7 +454,9 @@ function Get-DiskSpace {
# A small drift so free space isn't frozen between cycles
$drift = 2 * (Get-DemoRand $p.Name "drift$([math]::Floor((Get-DemoNow) / 3600))") - 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 }
return @{ Total = "$total GB"; Free = "$free GB" }
}
@@ -394,31 +483,36 @@ function Get-LogInfo {
if ($p.Valid) {
$stage = Get-DemoStage $p
+ $plan = $stage.Plan
$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
if ($stage.Name -eq $active) {
- # Task sequence still running: log lives in SMSTSLog and is being written to
- $msg = if ($isScan -and $p.Failing -and $stage.Progress -gt 0.5) {
- 'errorText, "Insufficient free space on the migration store"'
+ # Task sequence still running (or stopped on an error): log lives in SMSTSLog
+ if ($stage.Failed) {
+ $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 {
$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 35) { "detectedUser, CORP\$($p.User), includedInMigration, Yes" }
elseif ($pct -lt 50) { if ($isScan) { "PHASE, Collecting" } else { "PHASE, Applying" } }
else { "totalPercentageCompleted, $pct" }
+ $modified = $now.AddSeconds(-1 * (Get-DemoInt $p.Name "lm$([math]::Floor((Get-DemoNow) / 60))" 20 240))
+ if ($modified -lt $stage.StageStarted) { $modified = $stage.StageStarted }
}
- $modified = $now.AddSeconds(-1 * (Get-DemoInt $p.Name "lm$([math]::Floor((Get-DemoNow) / 60))" 20 240))
$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
- $L = $script:DemoLifecycle
- $cycleStart = (Get-DemoNow) - ($stage.Phase * $L)
- $startFrac = if ($isScan) { 0.25 } else { 0.55 }
- $began = [DateTimeOffset]::FromUnixTimeSeconds([long]($cycleStart + $startFrac * $L)).LocalDateTime
- $ended = [DateTimeOffset]::FromUnixTimeSeconds([long]($cycleStart + $doneFrom * $L)).LocalDateTime
+ $began = if ($isScan) { $plan.BackupStart } else { $plan.RestoreStart }
+ $ended = if ($isScan) { $plan.BackupEnd } else { $plan.RestoreEnd }
$found = @{ Folder = $script:DemoLogFolders[1]; Raw = (Format-DemoLogLine $ended $began "Successful run"); Modified = $ended }
}
}
@@ -444,7 +538,12 @@ function Get-DeviceAssociations {
param([string]$SourceComputer)
Start-DemoLatency 2
$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) {
'NotStarted' { 0 }
@@ -464,7 +563,8 @@ function Get-DeviceInventory {
$p = Get-DemoProfile $computerName
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 }
+ $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" }
$form = if ($p.Laptop) { "Laptops" } else { "Desktops" }
@@ -497,7 +597,7 @@ function Get-DeviceInventory {
RAM = $p.RAM
InventoryIP = $p.IP
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")
Location = $p.Location
Vanity = $p.Vanity
@@ -525,8 +625,8 @@ function Get-DeviceInventory {
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) {
+ 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 (Test-DemoOffline $p)) {
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))
@@ -572,3 +672,266 @@ function Get-InstalledSoftware {
}
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 }
+}
diff --git a/README.md b/README.md
index 24eaf5a..2d9f2b7 100644
--- a/README.md
+++ b/README.md
@@ -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`
instead of `Providers\SCCM.ps1`, and that's the only difference.
- **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
- on a 24-hour clock, so the dashboard keeps changing. Devices go offline, have stale DNS,
- run low on disk, and fail backups at realistic rates.
+ department and software from a hash of its name. Each device migrates on the day of its date
+ tag: earlier days are finished, today's devices move through backup and restore during
+ 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
`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.
- **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
- the server's data. Their actions print in the terminal panel exactly as the services would,
+ write requests and keeps the visitor's tags, articles and images in IndexedDB, laid over
+ 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
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
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
-6 AM to 9 PM every day (`Demo.BusinessHours` in the config) and sleeps overnight.
-The date tags (such as `10/7`) move along each day, so a container that runs for weeks still
-shows this week's deployments.
+6 AM to 9 PM every day (`Demo.BusinessHours` in the config) and sleeps overnight; the
+simulated team works 7:30 AM to 5 PM on weekdays (`Demo.Team`), so migrations run then.
### Demo (local PowerShell 7)
@@ -215,6 +221,7 @@ most likely to change:
| `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.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
diff --git a/USMT-Worker.ps1 b/USMT-Worker.ps1
index c374f9d..c07f212 100755
--- a/USMT-Worker.ps1
+++ b/USMT-Worker.ps1
@@ -404,8 +404,8 @@ while ($true) {
Update-WorkerState "Preparing..." 0 0 $globalLastDuration "Calculating..."
+ if ($config.IsDemo) { Invoke-DemoTeam $dataDir } # the simulated team edits the roster
$session = Open-ProviderCycle
- if ($config.IsDemo) { Update-DemoDateTags $dataDir }
$completedList = @()
if (Test-Path $completedPath) {
diff --git a/web/demo/demo.js b/web/demo/demo.js
index 936379c..fa3c5f0 100644
--- a/web/demo/demo.js
+++ b/web/demo/demo.js
@@ -253,9 +253,11 @@
state: null,
empty() {
return {
- version: 1,
- tracked: null, // visitor's copy of Tracked-Devices.json (null = untouched)
- appliedTracked: null, // roster the (simulated) worker last swept
+ version: 2,
+ // The visitor's roster edits, per tag, laid over the live roster (which the demo's
+ // 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
docs: {}, // title -> { content, modified }
deletedDocs: [], // server docs the visitor archived
@@ -282,15 +284,12 @@
const tx = this.db.transaction('kv', 'readonly').objectStore('kv').get('state');
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
- // 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;
},
save() {
@@ -303,7 +302,7 @@
},
hasChanges() {
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 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 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")
.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;
}
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; }
- const t = SCHEME.test(dev) ? pickTemplate(dev, serverRows) : null;
+ const t = SCHEME.test(dev) ? pickTemplate(dev, serverRows, tagMap[dev]) : null;
if (!t) {
// Not in the fleet and not a valid name: what the worker reports for a typo
const r = blankRow(dev, "N/A", tags, "None");
@@ -456,22 +462,26 @@
return { data: data || {}, rows, adv: adv || {} };
}
- async function visitorTracked() {
- const s = Store.state;
- if (s.tracked) return s.tracked;
- return (await serverGet('/Tracked-Devices.json')) || {};
- }
-
- async function ensureOwnRoster() {
- const s = Store.state;
- if (!s.tracked) {
- 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);
+ // 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 };
}
- return s.tracked;
+ 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() {
+ return mergeRoster((await serverGet('/Tracked-Devices.json')) || {}, Store.state.overrides);
+ }
+ async function appliedTracked() {
+ const s = Store.state;
+ return s.applied ? mergeRoster((await serverGet('/Tracked-Devices.json')) || {}, s.applied) : null;
}
// =====================================================================================
@@ -564,7 +574,7 @@
try {
const s = Store.state;
const tracked = await visitorTracked();
- const applied = s.appliedTracked || tracked;
+ const applied = (await appliedTracked()) || tracked;
const snap = await serverSnapshot();
if (!snap.rows.length) { // container just started: nothing to sweep against yet
w([['[Waiting for the first worker sweep to finish...]', 'DarkGray']]);
@@ -610,8 +620,7 @@
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.applied = clone(s.overrides);
s.rev++;
Store.save();
}
@@ -629,8 +638,7 @@
w([[`Next Cycle Scheduled For: ${nextText}`, 'DarkGray']]);
w([]); w([]);
- if (!s.tracked) await ensureOwnRoster();
- s.appliedTracked = clone(tracked);
+ s.applied = clone(s.overrides);
s.rev++;
Store.save();
} finally {
@@ -692,7 +700,7 @@
// ---- tracking roster ----
'GET Tracked-Devices.json': async () => {
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
// the dashboard notices it should reload Data.json.
return textResponse(text + ' '.repeat(s.rev), 200, { 'Content-Type': 'application/json' });
@@ -709,11 +717,16 @@
},
'POST update-tracked': async (url, 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 skip = Object.prototype.hasOwnProperty.call(body, 'skipUSMT') ? !!body.skipUSMT : false;
- if (!body.devices || body.devices.length === 0) delete tracked[tech];
- else tracked[tech] = { devices: body.devices, skipUSMT: skip };
+ const prev = s.overrides[tech];
+ 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();
srv(`Updated tracking roster for Tech/Tag: ${tech}`, 'Green');
scheduleCycle('roster', 2500); // the worker picks the change up on its next sweep
@@ -724,7 +737,7 @@
'GET Worker-State.json': async () => {
const state = (await serverGet('/Worker-State.json')) || {};
// 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));
return jsonResponse(state);
},
@@ -733,24 +746,24 @@
const snap = await serverSnapshot();
// Until the worker's first sweep there are no rows to merge with: pass through so
// the dashboard shows its "Waiting for Worker..." placeholders
- if (!s.appliedTracked || !snap.rows.length) return jsonResponse(snap.data);
- const { rows } = buildRows(s.appliedTracked, snap.rows, snap.adv);
+ if (!s.applied || !snap.rows.length) return jsonResponse(snap.data);
+ const { rows } = buildRows(await appliedTracked(), snap.rows, snap.adv);
return jsonResponse({ LastUpdated: snap.data.LastUpdated || psStamp(), DataVersion: (snap.data.DataVersion || '') + '#' + s.rev, Devices: rows });
},
'GET Advanced-Cache.json': async () => {
const s = Store.state;
const snap = await serverSnapshot();
- if (!s.appliedTracked || !snap.rows.length) return jsonResponse(snap.adv);
- const { adv } = buildRows(s.appliedTracked, snap.rows, snap.adv);
+ if (!s.applied || !snap.rows.length) return jsonResponse(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;
return jsonResponse(snap.adv);
},
'GET Software-Cache.json': async () => {
const s = Store.state;
const cache = (await serverGet('/Software-Cache.json')) || {};
- if (s.appliedTracked) {
+ if (s.applied) {
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)) {
if (cache[name]) continue;
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.
Run it yourself without -Demo and every action works against the real backend.
Things to try: open Terminals at the bottom to watch the worker sweep the
- fleet · add a device such as PRVDT-7QK4M2XA under Tracked Devices ·
+ fleet and a simulated team work the schedule · add a device such as PRVDT-7QK4M2XA under Tracked Devices ·
click a device for its details · All Devices shows apps missing from replacement machines ·
edit an article under Documentation.