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

Things to try: open Terminals at the bottom to watch the worker sweep the - fleet · add a device such as PRVDT-7QK4M2XA under Tracked Devices · + fleet and a simulated team work the schedule · add a device such as PRVDT-7QK4M2XA under Tracked Devices · click a device for its details · All Devices shows apps missing from replacement machines · edit an article under Documentation.

diff --git a/web/overview.css b/web/overview.css index c7833d5..6d8bc64 100644 --- a/web/overview.css +++ b/web/overview.css @@ -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 */