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