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
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
}
+19 -7
View File
@@ -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
+453 -90
View File
@@ -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 }
}
$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 }
}
+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`
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
+1 -1
View File
@@ -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) {
+57 -44
View File
@@ -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 || {} };
}
// 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() {
const s = Store.state;
if (s.tracked) return s.tracked;
return (await serverGet('/Tracked-Devices.json')) || {};
return mergeRoster((await serverGet('/Tracked-Devices.json')) || {}, Store.state.overrides);
}
async function ensureOwnRoster() {
async function appliedTracked() {
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);
}
return s.tracked;
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 <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
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;
edit an article under <em>Documentation</em>.</p>
</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-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-today { box-shadow: inset 0 0 0 2px #3498db; }
.ov-today .ov-day-num { color: #3498db; font-weight: 700; }
/* Today: the date number in a filled badge (like a calendar app), so it can't be mistaken for
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 */