Files
Johan Sandoval 41ff9e2b49 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).
2026-10-07 20:41:07 +00:00

938 lines
54 KiB
PowerShell

# =========================================================================================
# Demo provider - simulated fleet
#
# 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. 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
# is derived reversibly from their source, so any process can tell which is which.
# =========================================================================================
$script:DemoCfg = $config.Demo
$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')
$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")
$script:DemoBuildings = @(
@{ Name = "Main"; Code = "MN"; Net = 20 }
@{ Name = "Annex"; Code = "AX"; Net = 30 }
@{ Name = "North"; Code = "NO"; Net = 40 }
@{ Name = "Warehouse"; Code = "WH"; Net = 50 }
)
$script:DemoDepartments = @(
@{ Name = "Accounting"; Apps = @("QuickBooks","AcrobatPro","PowerBI","Excel Add-ins") }
@{ Name = "Finance"; Apps = @("QuickBooks","PowerBI","Tableau","AcrobatPro") }
@{ Name = "Sales"; Apps = @("Salesforce","Zoom","Slack","AcrobatPro") }
@{ Name = "Marketing"; Apps = @("CreativeCloud","Snagit","Slack","Zoom") }
@{ Name = "Human Resources"; Apps = @("AcrobatPro","Zoom","Snagit") }
@{ Name = "IT"; Apps = @("NotepadPP","PuTTY","WinSCP","Wireshark","VSCode","SSMS","Python") }
@{ Name = "Engineering"; Apps = @("AutoCAD","Bluebeam","Visio","Project","Python") }
@{ Name = "Operations"; Apps = @("Project","Visio","Tableau","PowerBI") }
@{ Name = "Legal"; Apps = @("AcrobatPro","Snagit","Zoom") }
@{ Name = "Customer Support"; Apps = @("Salesforce","Zoom","Slack") }
@{ Name = "Executive"; Apps = @("AcrobatPro","PowerBI","Zoom") }
@{ Name = "Facilities"; Apps = @("AutoCAD","Visio","Bluebeam") }
@{ Name = "Training"; Apps = @("Snagit","Camtasia","Zoom") }
)
$script:DemoModels = @{
HP = @{ OldDT = "HP EliteDesk 800 G4 DM 65W"; NewDT = "HP Elite Mini 800 G9 Desktop PC"; OldLT = "HP EliteBook 840 G5"; NewLT = "HP EliteBook 840 G10 Notebook PC"; Tool = "HP Support Assistant"; ToolPub = "HP Inc." }
Dell = @{ OldDT = "OptiPlex 7060"; NewDT = "OptiPlex Micro 7010"; OldLT = "Latitude 5490"; NewLT = "Latitude 7440"; Tool = "Dell Command | Update"; ToolPub = "Dell Inc." }
Lenovo = @{ OldDT = "ThinkCentre M720q"; NewDT = "ThinkCentre M70q Gen 4"; OldLT = "ThinkPad T480"; NewLT = "ThinkPad T14 Gen 4"; Tool = "Lenovo Vantage Service"; ToolPub = "Lenovo Group Ltd." }
}
$script:DemoSsdModels = @("KXG50ZNV256G TOSHIBA", "SAMSUNG MZVLB256HBHQ-000H1", "SK hynix PC611 HFS256GD9TNG", "Micron 2450 NVMe 512GB", "SAMSUNG MZVL2512HCJQ-00BH1", "KBG50ZNS512G KIOXIA")
$script:DemoMounts = @("Desktop Bundle", "Monitor Arm", "Under Desk", "Wall Mount", "Desktop Bundle", "Monitor Arm")
$script:DemoMonitors = @("HP E24 G5", "HP E27 G5", "HP P24h G5", "DELL P2422H", "DELL P2723DE", "LEN T24i-30", "LEN T27h-30", "LG 27UK850")
$script:DemoNotes = @("N/A", "N/A", "N/A", "N/A", "Front counter", "Dual monitors", "Desk 3", "Shared with night shift", "Badge printer attached", "Label printer attached")
# Specialty apps: one or more ARP entries each (multi-entry apps exercise the "suite" rules)
$script:DemoApps = @{
QuickBooks = @(@("QuickBooks Desktop Pro 2024", "34.0.4004.2405", "Intuit Inc."), @("QuickBooks Database Server Manager 34.0", "34.0.4004.2405", "Intuit Inc."), @("Intuit Data Protect", "34.0.2000.1", "Intuit Inc."))
AcrobatPro = @(, @("Adobe Acrobat (64-bit)", "25.001.20531", "Adobe"))
PowerBI = @(, @("Microsoft Power BI Desktop (x64)", "2.140.1205.0", "Microsoft Corporation"))
"Excel Add-ins" = @(, @("Power Pivot for Excel", "16.0.4849.1000", "Microsoft Corporation"))
Tableau = @(, @("Tableau 2024.3 (20243.24.1112.0334)", "24.3.1112", "Tableau Software"))
Salesforce = @(@("Salesforce for Outlook", "3.4.8.393", "salesforce.com"), @("Salesforce Data Loader", "62.0.0", "salesforce.com"))
Zoom = @(, @("Zoom Workplace (64-bit)", "6.2.11.50443", "Zoom Video Communications, Inc."))
Slack = @(, @("Slack (Machine - MSI)", "4.41.105", "Slack Technologies Inc."))
CreativeCloud = @(@("Adobe Creative Cloud", "6.4.0.361", "Adobe Inc."), @("Adobe Photoshop 2025", "26.1.0.121", "Adobe Inc."), @("Adobe Illustrator 2025", "29.1", "Adobe Inc."))
Snagit = @(, @("Snagit 2024", "24.3.0.4570", "TechSmith Corporation"))
Camtasia = @(, @("Camtasia 2024", "24.1.5.6082", "TechSmith Corporation"))
NotepadPP = @(, @("Notepad++ (64-bit x64)", "8.7.1", "Notepad++ Team"))
PuTTY = @(, @("PuTTY release 0.81 (64-bit)", "0.81.0.0", "Simon Tatham"))
WinSCP = @(, @("WinSCP 6.3.5", "6.3.5", "Martin Prikryl"))
Wireshark = @(@("Wireshark 4.4.1 x64", "4.4.1", "The Wireshark developer community"), @("Npcap", "1.79", "Nmap Project"))
VSCode = @(, @("Microsoft Visual Studio Code", "1.94.2", "Microsoft Corporation"))
SSMS = @(@("SQL Server Management Studio", "20.2.30.0", "Microsoft Corp."), @("SSMS Post Install Tasks", "20.2.30.0", "Microsoft Corporation"))
Python = @(@("Python 3.12.7 (64-bit)", "3.12.7150.0", "Python Software Foundation"), @("Python Launcher", "3.12.7150.0", "Python Software Foundation"))
AutoCAD = @(, @("AutoCAD LT 2025", "25.0.58.0", "Autodesk"))
Bluebeam = @(, @("Bluebeam Revu 21", "21.3.0", "Bluebeam, Inc."))
Visio = @(, @("Microsoft Visio - en-us", "16.0.18129.20116", "Microsoft Corporation"))
Project = @(, @("Microsoft Project - en-us", "16.0.18129.20116", "Microsoft Corporation"))
}
# Installed on every device - these match Blacklisted-Apps.txt so they never count as "specialty".
# Each row is: name, version on old hardware, version on new hardware, publisher. (The leading
# commas keep each row an array; without them PowerShell flattens the rows into one list.)
$script:DemoBaseline = @(
,@("Microsoft 365 Apps for enterprise - en-us", "16.0.17928.20156", "16.0.18129.20116", "Microsoft Corporation")
,@("Microsoft Edge", "129.0.2792.89", "130.0.2849.46", "Microsoft Corporation")
,@("Microsoft Edge WebView2 Runtime", "129.0.2792.89", "130.0.2849.46", "Microsoft Corporation")
,@("Microsoft OneDrive", "24.180.0905.0002", "24.201.1006.0005", "Microsoft Corporation")
,@("Microsoft Teams Meeting Add-in for Microsoft Office", "1.24.21705", "1.24.25804", "Microsoft")
,@("Microsoft Visual C++ 2015-2022 Redistributable (x64) - 14.40.33810", "14.40.33810.0", "14.40.33816.0", "Microsoft Corporation")
,@("Microsoft Visual C++ 2015-2022 Redistributable (x86) - 14.40.33810", "14.40.33810.0", "14.40.33816.0", "Microsoft Corporation")
,@("Microsoft Intune Management Extension", "1.83.107.0", "1.85.103.0", "Microsoft Corporation")
,@("Microsoft Policy Platform", "68.1.1010.0", "68.1.1010.0", "Microsoft Corporation")
,@("Configuration Manager Client", "5.00.9128.1007", "5.00.9132.1011", "Microsoft Corporation")
,@("Google Chrome", "129.0.6668.101", "130.0.6723.70", "Google LLC")
,@("Adobe Acrobat Reader DC (64-bit)", "24.003.20180", "24.004.20219", "Adobe")
,@("7-Zip 23.01 (x64)", "23.01", "24.08", "Igor Pavlov")
,@("Endpoint Protection Agent", "7.18.18603", "7.19.18710", "Security Vendor")
,@("Intel(R) Management Engine Components", "2228.5.4.0", "2412.5.69.0", "Intel Corporation")
,@("Realtek Audio Driver", "6.0.9549.1", "6.0.9696.1", "Realtek Semiconductor Corp.")
)
# -----------------------------------------------------------------------------------------
# Deterministic randomness
# -----------------------------------------------------------------------------------------
function Get-DemoMul32([uint64]$a, [uint64]$c) {
# (a * c) mod 2^32 without overflowing 64 bits
$lo = $a * ($c % 65536)
$hi = (($a * [math]::Floor($c / 65536)) % 65536) * 65536
return [uint64](($lo + $hi) % 4294967296)
}
function Get-DemoHash([string]$s) {
# FNV-1a, then the murmur3 finalizer so similar inputs land far apart
[uint64]$h = 2166136261
foreach ($b in [System.Text.Encoding]::UTF8.GetBytes($s.ToUpperInvariant())) {
$h = (($h -bxor $b) * 16777619) % 4294967296
}
$h = $h -bxor [math]::Floor($h / 65536)
$h = Get-DemoMul32 $h 2246822507
$h = $h -bxor [math]::Floor($h / 8192)
$h = Get-DemoMul32 $h 3266489909
$h = $h -bxor [math]::Floor($h / 65536)
return [uint64]$h
}
function Get-DemoRand([string]$name, [string]$salt) { return (Get-DemoHash "$name|$salt") / 4294967296.0 }
function Get-DemoPick([string]$name, [string]$salt, [array]$list) { return $list[[int][math]::Floor((Get-DemoRand $name $salt) * $list.Count)] }
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() }
# -----------------------------------------------------------------------------------------
# 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.
# -----------------------------------------------------------------------------------------
function Get-DemoCheckChar([string]$body, [int]$offset) {
$a = $script:DemoAlphabet; $sum = 0
for ($i = 0; $i -lt $body.Length; $i++) { $sum += ($a.IndexOf($body[$i]) + 1) * ($i + 1) }
return $a[($sum + $offset) % $a.Length]
}
function New-DemoSerial([string]$seed) {
$a = $script:DemoAlphabet; $body = ""
for ($i = 0; $i -lt 7; $i++) { $body += $a[(Get-DemoInt $seed "s$i" 0 ($a.Length - 1))] }
return $body + (Get-DemoCheckChar $body 0)
}
function Get-DemoDestName([string]$src) {
$a = $script:DemoAlphabet
$prefix, $serial = $src.ToUpper() -split '-', 2
$body = ""
foreach ($ch in $serial.Substring(0, 7).ToCharArray()) { $body = $a[($a.IndexOf($ch) * 7 + 11) % $a.Length] + $body }
return "$prefix-$body$(Get-DemoCheckChar $body 17)"
}
function Get-DemoSourceName([string]$dst) {
$a = $script:DemoAlphabet
$prefix, $serial = $dst.ToUpper() -split '-', 2
$body = ""
foreach ($ch in $serial.Substring(0, 7).ToCharArray()) { $body = $a[((($a.IndexOf($ch) - 11) % $a.Length + $a.Length) * 5) % $a.Length] + $body }
return "$prefix-$body$(Get-DemoCheckChar $body 0)"
}
# -----------------------------------------------------------------------------------------
# Device profile (static facts about a device)
# -----------------------------------------------------------------------------------------
function Get-DemoProfile([string]$name) {
$key = $name.ToUpper()
if ($script:DemoProfiles.ContainsKey($key)) { return $script:DemoProfiles[$key] }
$p = @{ Name = $key; Valid = $false; IsDest = $false; Source = $key }
if ($key -match '^(PRV|PUB)(DT|LT)-([0-9A-Z]{8})$') {
$p.Valid = $true
$p.Public = ($Matches[1] -eq 'PUB')
$p.Laptop = ($Matches[2] -eq 'LT')
$serial = $Matches[3]
$body = $serial.Substring(0, 7)
$p.IsDest = ($body.ToCharArray() | Where-Object { $script:DemoAlphabet.IndexOf($_) -lt 0 }).Count -eq 0 -and $serial[7] -eq (Get-DemoCheckChar $body 17)
if ($p.IsDest) { $p.Source = Get-DemoSourceName $key }
# Facts shared by a source and its replacement come from the SOURCE name
$s = $p.Source
$p.NoAssoc = (Get-DemoRand $s 'assoc') -lt 0.08
# 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
$floor = Get-DemoInt $s 'floor' 1 5
$room = Get-DemoInt $s 'room' 100 480
$p.Location = "{0:D2}-{1}" -f $floor, $room
$p.Vanity = if ((Get-DemoRand $s 'vanity') -lt 0.4) { "{0:D2}-{1}{2}" -f $floor, $room, (Get-DemoPick $s 'vsuf' @("A","B","C","D")) } else { "N/A" }
$p.PhoneExt = "{0}" -f (Get-DemoInt $s 'ext' 40000 49999)
$p.Mount = if ($p.Laptop) { "Docking Station" } else { Get-DemoPick $s 'mount' $script:DemoMounts }
$rt = Get-DemoRand $s 'roomtype'
$p.RoomType = if ($rt -lt 0.70) { "Standard" } else { Get-DemoPick $s 'rtl' @("Conference Room", "Hoteling Desk", "Training Room", "Reception", "Shared Space") }
$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 = 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
$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 })
# Facts specific to this physical device
$n = $key
$p.Mac = (1..6 | ForEach-Object { "{0:X2}" -f (Get-DemoInt $n "mac$_" 0 255) }) -join ':'
$p.IP = "10.{0}.{1}.{2}" -f $p.Building.Net, (Get-DemoInt $n 'ip3' 10 250), (Get-DemoInt $n 'ip4' 10 250)
$form = if ($p.Laptop) { "LT" } else { "DT" }
$p.Model = if ($p.IsDest) { $script:DemoModels[$p.Vendor]["New$form"] } else { $script:DemoModels[$p.Vendor]["Old$form"] }
$p.RAM = if ($p.IsDest) { Get-DemoPick $n 'ram' @("15.7 GB", "15.7 GB", "31.7 GB") } else { Get-DemoPick $n 'ram' @("7.8 GB", "7.8 GB", "15.8 GB") }
$p.DiskSize = if ($p.IsDest) { Get-DemoPick $n 'disk' @(476, 476, 953) } else { Get-DemoPick $n 'disk' @(238, 238, 476) }
$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.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
}
# -----------------------------------------------------------------------------------------
# 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) {
$p = Get-DemoProfile $computer
if (-not $p.Valid) { return "Not Found" }
$fault = Get-DemoRand $p.Name 'netfault'
if ($fault -lt 0.015) { return "Not Found" }
if ($fault -lt 0.03) { return "DNS Issue" }
$stage = (Get-DemoStage $p).Name
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" }
} else {
if ($stage -eq 'BackupInProgress') { return "Online" }
}
$odds = if ($p.Laptop) { 0.6 } else { 0.9 }
if (-not $p.IsDest -and $stage -eq 'Completed') { $odds = 0.45 } # old hardware gets unplugged
$bucket = [math]::Floor((Get-DemoNow) / 600)
if ((Get-DemoRand $p.Name "ping$bucket") -lt $odds) { return "Online" }
return "Timed Out"
}
# A replacement that hasn't been imaged yet isn't in DNS, AD or SCCM
function Test-DemoNotImaged([hashtable]$p) {
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) {
$ms = [int]$script:DemoCfg.DelayMsPerDevice * $scale
if ($ms -gt 0) { Start-Sleep -Milliseconds $ms }
}
# -----------------------------------------------------------------------------------------
# Provider interface
# -----------------------------------------------------------------------------------------
function Initialize-Provider {
Write-Host "Demo provider loaded: simulated fleet (no SCCM, AD, DNS or SMB calls are made)." -ForegroundColor DarkMagenta
}
function Open-ProviderCycle { return @{ PreviousLocation = Get-Location } }
function Close-ProviderCycle { param($Session) Set-Location $Session.PreviousLocation }
function pingDevice {
param ([string]$computer)
if ([string]::IsNullOrWhiteSpace($computer) -or $computer -eq "N/A") { return "Not Found" }
if ($v) { Write-Host " > Fast Ping: $computer... " -ForegroundColor DarkGray -NoNewline }
Start-DemoLatency
$state = Get-DemoNetworkState $computer
switch ($state) {
"Online" { if ($v) { Write-Host "Online" -ForegroundColor Green } }
"Timed Out" { if ($v) { Write-Host "Timed Out." -ForegroundColor DarkYellow } }
"DNS Issue" {
if ($v) {
$p = Get-DemoProfile $computer
$other = Get-DemoPick $p.Name 'dnsother' @("PRVDT-", "PUBDT-", "PRVLT-")
Write-Host "DNS Issue ($($p.IP) resolves back to $other$(New-DemoSerial "$($p.Name)dns"))." -ForegroundColor Red
}
}
default { if ($v) { Write-Host "Not Found (No IP)." -ForegroundColor Red } }
}
return $state
}
function Resolve-DeviceIP {
param([string]$computerName)
$p = Get-DemoProfile $computerName
if ($p.Valid) { return $p.IP }
return $null
}
function Get-DiskSpace {
param([string]$computer)
if ($v) { Write-Host " > Checking C: Drive Storage (SMB Method)... " -ForegroundColor DarkGray -NoNewline }
$p = Get-DemoProfile $computer
if (-not $p.Valid) {
if ($v) { Write-Host "$($script:DemoAdminShare) Share Unreachable" -ForegroundColor Red }
return @{ Total = "N/A"; Free = "N/A" }
}
# 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)
$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" }
}
function Format-DemoLogLine([datetime]$at, [datetime]$started, [string]$message) {
$inv = [System.Globalization.CultureInfo]::InvariantCulture
$elapsed = $at - $started
if ($elapsed.TotalSeconds -lt 0) { $elapsed = [timespan]::Zero }
$off = [System.TimeZoneInfo]::Local.GetUtcOffset($at)
$sign = if ($off.Ticks -lt 0) { "-" } else { "+" }
$offStr = "{0}{1:D2}:{2:D2}" -f $sign, [math]::Abs($off.Hours), [math]::Abs($off.Minutes)
return "{0}, {1} {2}, {3:D2}:{4:D2}:{5:D2}, {6}" -f $at.ToString("d MMM yyyy", $inv), $at.ToString("HH:mm:ss", $inv), $offStr, [int][math]::Floor($elapsed.TotalHours), $elapsed.Minutes, $elapsed.Seconds, $message
}
function Get-LogInfo {
param ([string]$computer, [string]$logName)
$result = New-LogInfoResult
if ($v) { Write-Host " > Scraping logs for '$logName'..." -ForegroundColor DarkGray }
$p = Get-DemoProfile $computer
$isScan = $logName -like "*scan*"
$found = $null # @{ Folder; Raw; Modified }
if ($p.Valid) {
$stage = Get-DemoStage $p
$plan = $stage.Plan
$active = if ($isScan) { 'BackupInProgress' } else { 'RestoreInProgress' }
$doneFrom = if ($isScan) { 2 } else { 4 } # index in DemoStageOrder
$now = Get-Date
if ($stage.Name -eq $active) {
# 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)
$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 ($plan -and $script:DemoStageOrder.IndexOf($stage.Name) -ge $doneFrom) {
# Finished: the task sequence has ended and the log was moved to CCM\Logs
$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 }
}
}
foreach ($folderRel in $script:DemoLogFolders) {
$folder = "\\$computer\$($script:DemoAdminShare)\$folderRel"
if ($v) { Write-Host " Checking $folder... " -ForegroundColor DarkGray -NoNewline }
if (-not $p.Valid) { if ($v) { Write-Host "Path doesn't exist." -ForegroundColor DarkGray }; continue }
if ($found -and $found.Folder -eq $folderRel) {
if ($v) { Write-Host "Found!" -ForegroundColor Green }
$result.LastModified = $found.Modified
Set-LogInfoLine $result $found.Raw
$result.Found = $true
$result.Path = "$folder\$logName.log"
break
}
if ($v) { Write-Host "Empty/Not there." -ForegroundColor DarkGray }
}
return $result
}
function Get-DeviceAssociations {
param([string]$SourceComputer)
Start-DemoLatency 2
$p = Get-DemoProfile $SourceComputer
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 }
'BackupInProgress' { 0 }
'BackupCompleted' { 1 }
'RestoreInProgress' { 1 }
default { 2 }
}
$dest = Get-DemoDestName $p.Name
$assoc = [PSCustomObject]@{ SourceComputer = $p.Name; DestinationComputer = $dest; RestoreName = $dest; MigrationStatus = $status }
$assoc | Add-Member -MemberType ScriptMethod -Name Get -Value { } # SMS_StateMigration lazy-property refresh; nothing to load here
return @($assoc)
}
function Get-DeviceInventory {
param([string]$computerName, $Session)
$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 } }
$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" }
$floor = $p.Location.Substring(0, 2)
$groups = [System.Collections.Generic.List[string]]::new()
$groups.Add("$vis PCs")
if ($p.Laptop) { $groups.Add("Wireless_Endpoints"); $groups.Add("VPN-Users") }
$groups.Add("PRN-$($p.Building.Code)-$floor-Copier")
if ((Get-DemoRand $p.Name 'grpbl') -lt 0.8) { $groups.Add("BitLocker-Enforced") }
if ($p.IsDest) { $groups.Add("Win11-Deployment") } else { $groups.Add("Win10-Legacy") }
if ($p.Public) { $groups.Add("Kiosk-AutoLogon") }
$primary = if ($p.Public) { "multi-user" } else { $p.User }
$lastUser = if ($p.Public) { "$($p.Name)-U" } else { $p.User }
$uda = if ($p.Public) { "$($p.Name.ToLower())-u" } else { $p.User }
$updated = (Get-Date).AddDays(-1 * (Get-DemoInt $p.Source 'upd' 20 1400))
if ($p.IsDest) { $updated = (Get-Date).AddDays(-1 * (Get-DemoInt $p.Name 'upd' 1 30)) }
$monitors = @($p.Monitors)
$inv = @{
InAD = $true
InSCCM = $true
MonitorCount = "$($monitors.Count)"
Monitors = if ($monitors.Count) { $monitors -join "<br>" } else { "None" }
OU = "Company Devices/$vis/$form"
Groups = $groups -join "<br>"
MAC = $p.Mac
Model = $p.Model
RAM = $p.RAM
InventoryIP = $p.IP
LastUser = $lastUser
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
Department = $p.Department.Name
Building = $p.Building.Name
Mount = $p.Mount
PrimaryUser = $primary
PhoneExt = $p.PhoneExt
InfoBy = $p.InfoBy
Notes = $p.Notes
RoomType = $p.RoomType
SCCMPrimaryUsers = $uda
Disk = "Size: $($p.DiskSize) GB<br>Model: $($p.DiskModel)<br>S/N: $($p.DiskSerial)."
}
# What a missing AD object / SCCM record would take away
if (-not $flags.InAD) { $inv.InAD = $false; foreach ($k in @("OU", "Groups")) { $inv.Remove($k) } }
if (-not $flags.InSCCM) {
$inv.InSCCM = $false
foreach ($k in @("MAC", "Model", "RAM", "InventoryIP", "LastUser", "ClientStatus", "Updated", "Location", "Vanity", "Department", "Building", "Mount", "PrimaryUser", "PhoneExt", "InfoBy", "Notes", "RoomType", "SCCMPrimaryUsers", "Disk", "MonitorCount", "Monitors")) { $inv.Remove($k) }
}
return $inv
}
# Client's last activity, used the first time a device is tracked and doesn't answer
function Get-DeviceLastSeen {
param([string]$computerName, $Session)
$p = Get-DemoProfile $computerName
if (-not $p.Valid -or (Test-DemoNotImaged $p) -or ($p.NoSccm -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))
}
function Get-InstalledSoftware {
param([string]$ComputerName)
$p = Get-DemoProfile $ComputerName
if (-not $p.Valid) { return @() }
$rows = [System.Collections.Generic.List[object]]::new()
$baseDays = if ($p.IsDest) { Get-DemoInt $p.Name 'img' 10 60 } else { Get-DemoInt $p.Name 'img' 900 2200 }
$imaged = (Get-Date).Date.AddDays(-$baseDays)
$add = {
param($name, $ver, $pub, $date, $src)
$rows.Add([PSCustomObject]@{ Name = $name; Version = $ver; Publisher = $pub; Source = $src; InstallDate = $date })
}
foreach ($b in $script:DemoBaseline) {
$ver = if ($p.IsDest) { $b[2] } else { $b[1] }
& $add $b[0] $ver $b[3] $imaged ""
}
$tools = $script:DemoModels[$p.Vendor]
& $add $tools.Tool "9.$(Get-DemoInt $p.Name 'toolv' 1 9).0" $tools.ToolPub $imaged ""
if ($p.Laptop) { & $add "Cisco Secure Client - AnyConnect VPN" "5.1.4.74" "Cisco Systems, Inc." $imaged "" }
if ($p.Public) {
& $add "Kiosk Launcher" "3.2.1" "Company IT" $imaged "\\fileserver\software\KioskLauncher\"
& $add "Shared PC Cleanup Utility" "1.4.0" "Company IT" $imaged "\\fileserver\software\SharedPC\"
}
# Specialty apps: the source has all of its department's picks; the replacement only gets
# the ones that have been reinstalled so far (the "Missing Software" page shows the gap).
$stage = Get-DemoStage $p
foreach ($app in $p.Apps) {
if ($p.IsDest) {
$odds = switch ($stage.Name) { 'Completed' { 0.85 } 'RestoreInProgress' { 0.5 } 'BackupCompleted' { 0.2 } default { 0.0 } }
if ((Get-DemoRand $p.Name "has$app") -ge $odds) { continue }
}
$installed = $imaged.AddDays((Get-DemoInt $p.Name "inst$app" 1 ([math]::Max(2, $baseDays - 1))))
foreach ($entry in $script:DemoApps[$app]) {
& $add $entry[0] $entry[1] $entry[2] $installed "\\fileserver\software\$($app)\"
}
}
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 }
}