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
|
DeviceCount = 200 # tracked source devices seeded at startup
|
||||||
CycleSeconds = 45 # cooldown between cycles (business hours are ignored)
|
CycleSeconds = 45 # cooldown between cycles (business hours are ignored)
|
||||||
DelayMsPerDevice = 35 # artificial per-device latency so the progress bar moves
|
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)
|
PresenceSweepMinutes = 60 # completed / Skip USMT devices: hourly, like Live (the demo runs all day)
|
||||||
LivePublishSeconds = 4 # publish swept rows this often during a cycle
|
LivePublishSeconds = 4 # publish swept rows this often during a cycle
|
||||||
|
|
||||||
@@ -142,6 +141,18 @@
|
|||||||
End = '21:00'
|
End = '21:00'
|
||||||
Weekdays = @(0, 1, 2, 3, 4, 5, 6) # every day
|
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
|
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"
|
$manual += "$src, $dst"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- 2. Date-batch tags (recent and upcoming working days; the worker keeps them current) ---
|
# --- 2. Schedule: the last few working days (mostly finished) and the next two weeks ---
|
||||||
$dateTags = Get-DemoDateTags
|
$team = $config.Demo.Team
|
||||||
[System.IO.File]::WriteAllText((Join-Path $dataDir "Demo-Dates.json"), (@{ Tags = $dateTags } | ConvertTo-Json), [System.Text.Encoding]::UTF8)
|
$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]@{}
|
$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]) }
|
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)
|
$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"))) {
|
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)) }
|
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["Public PCs"] = $skipPublic
|
||||||
$tags["Laptops"] = $skipLaptops
|
$tags["Laptops"] = $skipLaptops
|
||||||
|
|
||||||
@@ -81,6 +90,9 @@ foreach ($t in $tags.Keys) {
|
|||||||
$tracked[$t] = [ordered]@{ devices = @($tags[$t]); skipUSMT = ($skipTags -contains $t) }
|
$tracked[$t] = [ordered]@{ devices = @($tags[$t]); skipUSMT = ($skipTags -contains $t) }
|
||||||
}
|
}
|
||||||
($tracked | ConvertTo-Json -Depth 5) | Set-Content -Path $trackedPath -Encoding UTF8
|
($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 ---
|
# --- 3. Blacklist / suite rules ---
|
||||||
Copy-Item (Join-Path $PSScriptRoot "Blacklisted-Apps.txt") (Join-Path $dataDir "Blacklisted-Apps.txt") -Force
|
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
|
# 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,
|
# 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
|
# location and software are derived from a hash of its name. Its migration happens on the
|
||||||
# through each stage on a clock, so the unmodified worker loop sees devices go from
|
# day of its date tag (10/14 and so on), so the unmodified worker loop sees that day's devices
|
||||||
# Not Started -> Backup In Progress -> Backup Completed -> Restore In Progress -> Completed.
|
# 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)
|
# Naming scheme: PrvDT- / PubDT- (private/public desktops) PrvLT- / PubLT- (laptops)
|
||||||
# followed by an 8 character serial. Destination (replacement) devices get a serial that
|
# followed by an 8 character serial. Destination (replacement) devices get a serial that
|
||||||
@@ -13,20 +16,14 @@
|
|||||||
# =========================================================================================
|
# =========================================================================================
|
||||||
|
|
||||||
$script:DemoCfg = $config.Demo
|
$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:DemoProfiles = @{}
|
||||||
$script:DemoAlphabet = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
$script:DemoAlphabet = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||||
$script:DemoAdminShare = 'C$'
|
$script:DemoAdminShare = 'C$'
|
||||||
$script:DemoLogFolders = @('Windows\CCM\Logs\SMSTSLog', 'Windows\CCM\Logs')
|
$script:DemoLogFolders = @('Windows\CCM\Logs\SMSTSLog', 'Windows\CCM\Logs')
|
||||||
|
|
||||||
# --- Stage boundaries (fraction of the lifecycle) ---
|
$script:DemoStageOrder = @('NotStarted', 'BackupInProgress', 'BackupCompleted', 'RestoreInProgress', 'Completed')
|
||||||
$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 }
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Catalogs ---
|
# --- Catalogs ---
|
||||||
$script:DemoSurnames = @("Smith","Johnson","Williams","Brown","Jones","Garcia","Miller","Davis","Rodriguez","Martinez","Hernandez","Lopez","Gonzalez","Wilson","Anderson","Thomas","Taylor","Moore","Jackson","Martin","Lee","Perez","Thompson","White","Harris","Sanchez","Clark","Ramirez","Lewis","Robinson","Walker","Young","Allen","King","Wright","Scott","Torres","Nguyen","Hill","Flores","Green","Adams","Nelson","Baker","Hall","Rivera","Campbell","Mitchell","Carter","Roberts","Patel","Kim","Chen","Shah","Murphy","Cook","Rogers","Morgan","Cooper","Reed")
|
$script: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-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() }
|
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
|
# 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.
|
# 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
|
# Facts shared by a source and its replacement come from the SOURCE name
|
||||||
$s = $p.Source
|
$s = $p.Source
|
||||||
$p.Offset = (Get-DemoRand $s 'offset') * $script:DemoLifecycle
|
|
||||||
$p.NoAssoc = (Get-DemoRand $s 'assoc') -lt 0.08
|
$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.Vendor = Get-DemoPick $s 'vendor' @("HP", "HP", "Dell", "Lenovo")
|
||||||
$p.Building = Get-DemoPick $s 'bldg' $script:DemoBuildings
|
$p.Building = Get-DemoPick $s 'bldg' $script:DemoBuildings
|
||||||
$p.Department = Get-DemoPick $s 'dept' $script:DemoDepartments
|
$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.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.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.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 })
|
$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]) }
|
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)
|
# A few old devices have been unplugged / powered off for days
|
||||||
$p.OfflineDays = if ((Get-DemoRand $s 'longoff') -lt 0.07) { Get-DemoInt $s 'offdays' 1 21 } else { 0 }
|
$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
|
# 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) }
|
$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 })
|
$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.DiskModel = Get-DemoPick $n 'ssd' $script:DemoSsdModels
|
||||||
$p.DiskSerial = (1..8 | ForEach-Object { "{0:X4}" -f (Get-DemoInt $n "dsn$_" 0 65535) }) -join '_'
|
$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.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
|
$p.NoAd = (-not $p.IsDest) -and (Get-DemoRand $n 'noad') -lt 0.012 # computer object deleted
|
||||||
}
|
}
|
||||||
$script:DemoProfiles[$key] = $p
|
$script:DemoProfiles[$key] = $p
|
||||||
return $p
|
return $p
|
||||||
}
|
}
|
||||||
|
|
||||||
# Where a migration currently is. Both halves of a pair share the source's clock.
|
# -----------------------------------------------------------------------------------------
|
||||||
function Get-DemoStage([hashtable]$p) {
|
# Schedule: each source device migrates on the day of its date tag
|
||||||
$now = Get-DemoNow
|
# -----------------------------------------------------------------------------------------
|
||||||
$L = $script:DemoLifecycle
|
function Test-DemoWorkday([datetime]$d) { return @($script:DemoTeamCfg.Weekdays) -contains [int]$d.DayOfWeek }
|
||||||
$phase = (($now + $p.Offset) % $L) / $L
|
function Get-DemoWorkday([datetime]$from, [int]$offset) {
|
||||||
$idx = 0
|
# $offset working days from $from (0 = $from itself if it's a working day, else the next one)
|
||||||
for ($i = 0; $i -lt $script:DemoStages.Count; $i++) { if ($phase -ge $script:DemoStages[$i].Start) { $idx = $i } }
|
$d = $from.Date
|
||||||
$start = $script:DemoStages[$idx].Start
|
if ($offset -ge 0) {
|
||||||
$end = if ($idx -lt $script:DemoStages.Count - 1) { $script:DemoStages[$idx + 1].Start } else { 1.0 }
|
while (-not (Test-DemoWorkday $d)) { $d = $d.AddDays(1) }
|
||||||
$cycleStart = $now - ($phase * $L)
|
for ($i = 0; $i -lt $offset; $i++) { do { $d = $d.AddDays(1) } until (Test-DemoWorkday $d) }
|
||||||
return @{
|
} else {
|
||||||
Name = $script:DemoStages[$idx].Name
|
for ($i = 0; $i -lt -$offset; $i++) { do { $d = $d.AddDays(-1) } until (Test-DemoWorkday $d) }
|
||||||
Phase = $phase
|
|
||||||
Progress = ($phase - $start) / ($end - $start)
|
|
||||||
StageStarted = [DateTimeOffset]::FromUnixTimeSeconds([long]($cycleStart + $start * $L)).LocalDateTime
|
|
||||||
StageEnded = [DateTimeOffset]::FromUnixTimeSeconds([long]($cycleStart + $end * $L)).LocalDateTime
|
|
||||||
}
|
}
|
||||||
|
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) {
|
function Get-DemoNetworkState([string]$computer) {
|
||||||
@@ -291,7 +373,7 @@ function Get-DemoNetworkState([string]$computer) {
|
|||||||
if ($fault -lt 0.03) { return "DNS Issue" }
|
if ($fault -lt 0.03) { return "DNS Issue" }
|
||||||
|
|
||||||
$stage = (Get-DemoStage $p).Name
|
$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 ($p.IsDest) {
|
||||||
if (Test-DemoNotImaged $p) { return "Not Found" } # not imaged yet: no DNS record
|
if (Test-DemoNotImaged $p) { return "Not Found" } # not imaged yet: no DNS record
|
||||||
if ($stage -eq 'RestoreInProgress') { return "Online" }
|
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
|
# A replacement that hasn't been imaged yet isn't in DNS, AD or SCCM
|
||||||
function Test-DemoNotImaged([hashtable]$p) {
|
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) {
|
function Start-DemoLatency([int]$scale = 1) {
|
||||||
@@ -367,7 +454,9 @@ function Get-DiskSpace {
|
|||||||
# A small drift so free space isn't frozen between cycles
|
# A small drift so free space isn't frozen between cycles
|
||||||
$drift = 2 * (Get-DemoRand $p.Name "drift$([math]::Floor((Get-DemoNow) / 3600))") - 1
|
$drift = 2 * (Get-DemoRand $p.Name "drift$([math]::Floor((Get-DemoNow) / 3600))") - 1
|
||||||
$total = [math]::Round($p.DiskSize * 0.93, 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 }
|
if ($v) { Write-Host "${free}GB Free out of ${total}GB" -ForegroundColor Green }
|
||||||
return @{ Total = "$total GB"; Free = "$free GB" }
|
return @{ Total = "$total GB"; Free = "$free GB" }
|
||||||
}
|
}
|
||||||
@@ -394,31 +483,36 @@ function Get-LogInfo {
|
|||||||
|
|
||||||
if ($p.Valid) {
|
if ($p.Valid) {
|
||||||
$stage = Get-DemoStage $p
|
$stage = Get-DemoStage $p
|
||||||
|
$plan = $stage.Plan
|
||||||
$active = if ($isScan) { 'BackupInProgress' } else { 'RestoreInProgress' }
|
$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
|
$now = Get-Date
|
||||||
|
|
||||||
if ($stage.Name -eq $active) {
|
if ($stage.Name -eq $active) {
|
||||||
# Task sequence still running: log lives in SMSTSLog and is being written to
|
# Task sequence still running (or stopped on an error): log lives in SMSTSLog
|
||||||
$msg = if ($isScan -and $p.Failing -and $stage.Progress -gt 0.5) {
|
if ($stage.Failed) {
|
||||||
'errorText, "Insufficient free space on the migration store"'
|
$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 {
|
} else {
|
||||||
$pct = [int][math]::Floor($stage.Progress * 100)
|
$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 20) { if ($isScan) { "PHASE, Scanning" } else { "PHASE, Gathering" } }
|
||||||
elseif ($pct -lt 35) { "detectedUser, CORP\$($p.User), includedInMigration, Yes" }
|
elseif ($pct -lt 35) { "detectedUser, CORP\$($p.User), includedInMigration, Yes" }
|
||||||
elseif ($pct -lt 50) { if ($isScan) { "PHASE, Collecting" } else { "PHASE, Applying" } }
|
elseif ($pct -lt 50) { if ($isScan) { "PHASE, Collecting" } else { "PHASE, Applying" } }
|
||||||
else { "totalPercentageCompleted, $pct" }
|
else { "totalPercentageCompleted, $pct" }
|
||||||
}
|
|
||||||
$modified = $now.AddSeconds(-1 * (Get-DemoInt $p.Name "lm$([math]::Floor((Get-DemoNow) / 60))" 20 240))
|
$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 }
|
$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
|
# Finished: the task sequence has ended and the log was moved to CCM\Logs
|
||||||
$L = $script:DemoLifecycle
|
$began = if ($isScan) { $plan.BackupStart } else { $plan.RestoreStart }
|
||||||
$cycleStart = (Get-DemoNow) - ($stage.Phase * $L)
|
$ended = if ($isScan) { $plan.BackupEnd } else { $plan.RestoreEnd }
|
||||||
$startFrac = if ($isScan) { 0.25 } else { 0.55 }
|
|
||||||
$began = [DateTimeOffset]::FromUnixTimeSeconds([long]($cycleStart + $startFrac * $L)).LocalDateTime
|
|
||||||
$ended = [DateTimeOffset]::FromUnixTimeSeconds([long]($cycleStart + $doneFrom * $L)).LocalDateTime
|
|
||||||
$found = @{ Folder = $script:DemoLogFolders[1]; Raw = (Format-DemoLogLine $ended $began "Successful run"); Modified = $ended }
|
$found = @{ Folder = $script:DemoLogFolders[1]; Raw = (Format-DemoLogLine $ended $began "Successful run"); Modified = $ended }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,7 +538,12 @@ function Get-DeviceAssociations {
|
|||||||
param([string]$SourceComputer)
|
param([string]$SourceComputer)
|
||||||
Start-DemoLatency 2
|
Start-DemoLatency 2
|
||||||
$p = Get-DemoProfile $SourceComputer
|
$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) {
|
$status = switch ((Get-DemoStage $p).Name) {
|
||||||
'NotStarted' { 0 }
|
'NotStarted' { 0 }
|
||||||
@@ -464,7 +563,8 @@ function Get-DeviceInventory {
|
|||||||
$p = Get-DemoProfile $computerName
|
$p = Get-DemoProfile $computerName
|
||||||
if (-not $p.Valid) { return @{ InAD = $false; InSCCM = $false } } # a typo: nothing knows it
|
if (-not $p.Valid) { return @{ InAD = $false; InSCCM = $false } } # a typo: nothing knows it
|
||||||
if (Test-DemoNotImaged $p) { return @{ InAD = $false; InSCCM = $false } }
|
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" }
|
$vis = if ($p.Public) { "Public" } else { "Private" }
|
||||||
$form = if ($p.Laptop) { "Laptops" } else { "Desktops" }
|
$form = if ($p.Laptop) { "Laptops" } else { "Desktops" }
|
||||||
@@ -497,7 +597,7 @@ function Get-DeviceInventory {
|
|||||||
RAM = $p.RAM
|
RAM = $p.RAM
|
||||||
InventoryIP = $p.IP
|
InventoryIP = $p.IP
|
||||||
LastUser = $lastUser
|
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")
|
Updated = $updated.ToString("M/d/yyyy")
|
||||||
Location = $p.Location
|
Location = $p.Location
|
||||||
Vanity = $p.Vanity
|
Vanity = $p.Vanity
|
||||||
@@ -525,8 +625,8 @@ function Get-DeviceInventory {
|
|||||||
function Get-DeviceLastSeen {
|
function Get-DeviceLastSeen {
|
||||||
param([string]$computerName, $Session)
|
param([string]$computerName, $Session)
|
||||||
$p = Get-DemoProfile $computerName
|
$p = Get-DemoProfile $computerName
|
||||||
if (-not $p.Valid -or (Test-DemoNotImaged $p) -or $p.NoSccm) { return $null }
|
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 $p.OfflineDays -gt 0) {
|
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).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))
|
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
|
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`
|
`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.
|
instead of `Providers\SCCM.ps1`, and that's the only difference.
|
||||||
- **A believable fleet.** The demo provider derives each device's hardware, users, location,
|
- **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
|
department and software from a hash of its name. Each device migrates on the day of its date
|
||||||
on a 24-hour clock, so the dashboard keeps changing. Devices go offline, have stale DNS,
|
tag: earlier days are finished, today's devices move through backup and restore during
|
||||||
run low on disk, and fail backups at realistic rates.
|
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
|
- **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
|
`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.
|
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
|
- **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
|
write requests and keeps the visitor's tags, articles and images in IndexedDB, laid over
|
||||||
the server's data. Their actions print in the terminal panel exactly as the services would,
|
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
|
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.
|
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
|
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
|
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
|
`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.
|
6 AM to 9 PM every day (`Demo.BusinessHours` in the config) and sleeps overnight; the
|
||||||
The date tags (such as `10/7`) move along each day, so a container that runs for weeks still
|
simulated team works 7:30 AM to 5 PM on weekdays (`Demo.Team`), so migrations run then.
|
||||||
shows this week's deployments.
|
|
||||||
|
|
||||||
### Demo (local PowerShell 7)
|
### Demo (local PowerShell 7)
|
||||||
|
|
||||||
@@ -215,6 +221,7 @@ most likely to change:
|
|||||||
| `SCCM.Monitors` | `v_GS_WMIMONITORID` | Attached monitors (needs WmiMonitorID in hardware inventory) |
|
| `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.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.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
|
### Adapting it to a different toolset
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -404,8 +404,8 @@ while ($true) {
|
|||||||
|
|
||||||
Update-WorkerState "Preparing..." 0 0 $globalLastDuration "Calculating..."
|
Update-WorkerState "Preparing..." 0 0 $globalLastDuration "Calculating..."
|
||||||
|
|
||||||
|
if ($config.IsDemo) { Invoke-DemoTeam $dataDir } # the simulated team edits the roster
|
||||||
$session = Open-ProviderCycle
|
$session = Open-ProviderCycle
|
||||||
if ($config.IsDemo) { Update-DemoDateTags $dataDir }
|
|
||||||
|
|
||||||
$completedList = @()
|
$completedList = @()
|
||||||
if (Test-Path $completedPath) {
|
if (Test-Path $completedPath) {
|
||||||
|
|||||||
+57
-44
@@ -253,9 +253,11 @@
|
|||||||
state: null,
|
state: null,
|
||||||
empty() {
|
empty() {
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 2,
|
||||||
tracked: null, // visitor's copy of Tracked-Devices.json (null = untouched)
|
// The visitor's roster edits, per tag, laid over the live roster (which the demo's
|
||||||
appliedTracked: null, // roster the (simulated) worker last swept
|
// 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
|
rev: 0, // bumps whenever simulated data changes
|
||||||
docs: {}, // title -> { content, modified }
|
docs: {}, // title -> { content, modified }
|
||||||
deletedDocs: [], // server docs the visitor archived
|
deletedDocs: [], // server docs the visitor archived
|
||||||
@@ -282,15 +284,12 @@
|
|||||||
const tx = this.db.transaction('kv', 'readonly').objectStore('kv').get('state');
|
const tx = this.db.transaction('kv', 'readonly').objectStore('kv').get('state');
|
||||||
tx.onsuccess = () => res(tx.result); tx.onerror = () => res(null);
|
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
|
} 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;
|
return this.state;
|
||||||
},
|
},
|
||||||
save() {
|
save() {
|
||||||
@@ -303,7 +302,7 @@
|
|||||||
},
|
},
|
||||||
hasChanges() {
|
hasChanges() {
|
||||||
const s = this.state;
|
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 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.
|
// 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 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")
|
&& !["Skipped", "N/A"].includes(r["Backup Status"]) && r["USMT Status"] !== "N/A")
|
||||||
.sort((a, b) => a["Source Device"].localeCompare(b["Source Device"]));
|
.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;
|
return pool.length ? pool[hash(name) % pool.length] : null;
|
||||||
}
|
}
|
||||||
const synthDest = name => name.split('-')[0] + '-' + serial(name + '|dest');
|
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; }
|
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) {
|
if (!t) {
|
||||||
// Not in the fleet and not a valid name: what the worker reports for a typo
|
// Not in the fleet and not a valid name: what the worker reports for a typo
|
||||||
const r = blankRow(dev, "N/A", tags, "None");
|
const r = blankRow(dev, "N/A", tags, "None");
|
||||||
@@ -456,22 +462,26 @@
|
|||||||
return { data: data || {}, rows, adv: adv || {} };
|
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() {
|
async function visitorTracked() {
|
||||||
const s = Store.state;
|
return mergeRoster((await serverGet('/Tracked-Devices.json')) || {}, Store.state.overrides);
|
||||||
if (s.tracked) return s.tracked;
|
|
||||||
return (await serverGet('/Tracked-Devices.json')) || {};
|
|
||||||
}
|
}
|
||||||
|
async function appliedTracked() {
|
||||||
async function ensureOwnRoster() {
|
|
||||||
const s = Store.state;
|
const s = Store.state;
|
||||||
if (!s.tracked) {
|
return s.applied ? mergeRoster((await serverGet('/Tracked-Devices.json')) || {}, s.applied) : null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================================================
|
// =====================================================================================
|
||||||
@@ -564,7 +574,7 @@
|
|||||||
try {
|
try {
|
||||||
const s = Store.state;
|
const s = Store.state;
|
||||||
const tracked = await visitorTracked();
|
const tracked = await visitorTracked();
|
||||||
const applied = s.appliedTracked || tracked;
|
const applied = (await appliedTracked()) || tracked;
|
||||||
const snap = await serverSnapshot();
|
const snap = await serverSnapshot();
|
||||||
if (!snap.rows.length) { // container just started: nothing to sweep against yet
|
if (!snap.rows.length) { // container just started: nothing to sweep against yet
|
||||||
w([['[Waiting for the first worker sweep to finish...]', 'DarkGray']]);
|
w([['[Waiting for the first worker sweep to finish...]', 'DarkGray']]);
|
||||||
@@ -610,8 +620,7 @@
|
|||||||
await sleep(delay);
|
await sleep(delay);
|
||||||
if (fresh.length && i === fresh.length && i < ordered.length) {
|
if (fresh.length && i === fresh.length && i < ordered.length) {
|
||||||
w([[` -> [PRIORITY] ${fresh.length} new device(s) swept. Publishing early...`, 'Green']]);
|
w([[` -> [PRIORITY] ${fresh.length} new device(s) swept. Publishing early...`, 'Green']]);
|
||||||
if (!s.tracked) await ensureOwnRoster();
|
s.applied = clone(s.overrides);
|
||||||
s.appliedTracked = clone(tracked);
|
|
||||||
s.rev++;
|
s.rev++;
|
||||||
Store.save();
|
Store.save();
|
||||||
}
|
}
|
||||||
@@ -629,8 +638,7 @@
|
|||||||
w([[`Next Cycle Scheduled For: ${nextText}`, 'DarkGray']]);
|
w([[`Next Cycle Scheduled For: ${nextText}`, 'DarkGray']]);
|
||||||
w([]); w([]);
|
w([]); w([]);
|
||||||
|
|
||||||
if (!s.tracked) await ensureOwnRoster();
|
s.applied = clone(s.overrides);
|
||||||
s.appliedTracked = clone(tracked);
|
|
||||||
s.rev++;
|
s.rev++;
|
||||||
Store.save();
|
Store.save();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -692,7 +700,7 @@
|
|||||||
// ---- tracking roster ----
|
// ---- tracking roster ----
|
||||||
'GET Tracked-Devices.json': async () => {
|
'GET Tracked-Devices.json': async () => {
|
||||||
const s = Store.state;
|
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
|
// Trailing whitespace makes the text differ after a simulated cycle, which is how
|
||||||
// the dashboard notices it should reload Data.json.
|
// the dashboard notices it should reload Data.json.
|
||||||
return textResponse(text + ' '.repeat(s.rev), 200, { 'Content-Type': 'application/json' });
|
return textResponse(text + ' '.repeat(s.rev), 200, { 'Content-Type': 'application/json' });
|
||||||
@@ -709,11 +717,16 @@
|
|||||||
},
|
},
|
||||||
'POST update-tracked': async (url, init) => {
|
'POST update-tracked': async (url, init) => {
|
||||||
const body = await readBody(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 tech = body.tech;
|
||||||
const skip = Object.prototype.hasOwnProperty.call(body, 'skipUSMT') ? !!body.skipUSMT : false;
|
const skip = Object.prototype.hasOwnProperty.call(body, 'skipUSMT') ? !!body.skipUSMT : false;
|
||||||
if (!body.devices || body.devices.length === 0) delete tracked[tech];
|
const prev = s.overrides[tech];
|
||||||
else tracked[tech] = { devices: body.devices, skipUSMT: skip };
|
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();
|
Store.save();
|
||||||
srv(`Updated tracking roster for Tech/Tag: ${tech}`, 'Green');
|
srv(`Updated tracking roster for Tech/Tag: ${tech}`, 'Green');
|
||||||
scheduleCycle('roster', 2500); // the worker picks the change up on its next sweep
|
scheduleCycle('roster', 2500); // the worker picks the change up on its next sweep
|
||||||
@@ -724,7 +737,7 @@
|
|||||||
'GET Worker-State.json': async () => {
|
'GET Worker-State.json': async () => {
|
||||||
const state = (await serverGet('/Worker-State.json')) || {};
|
const state = (await serverGet('/Worker-State.json')) || {};
|
||||||
// the visitor's own (simulated) changes count as new data too
|
// 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));
|
if (Sim.worker) return jsonResponse(Object.assign({}, state, Sim.worker));
|
||||||
return jsonResponse(state);
|
return jsonResponse(state);
|
||||||
},
|
},
|
||||||
@@ -733,24 +746,24 @@
|
|||||||
const snap = await serverSnapshot();
|
const snap = await serverSnapshot();
|
||||||
// Until the worker's first sweep there are no rows to merge with: pass through so
|
// Until the worker's first sweep there are no rows to merge with: pass through so
|
||||||
// the dashboard shows its "Waiting for Worker..." placeholders
|
// the dashboard shows its "Waiting for Worker..." placeholders
|
||||||
if (!s.appliedTracked || !snap.rows.length) return jsonResponse(snap.data);
|
if (!s.applied || !snap.rows.length) return jsonResponse(snap.data);
|
||||||
const { rows } = buildRows(s.appliedTracked, snap.rows, snap.adv);
|
const { rows } = buildRows(await appliedTracked(), snap.rows, snap.adv);
|
||||||
return jsonResponse({ LastUpdated: snap.data.LastUpdated || psStamp(), DataVersion: (snap.data.DataVersion || '') + '#' + s.rev, Devices: rows });
|
return jsonResponse({ LastUpdated: snap.data.LastUpdated || psStamp(), DataVersion: (snap.data.DataVersion || '') + '#' + s.rev, Devices: rows });
|
||||||
},
|
},
|
||||||
'GET Advanced-Cache.json': async () => {
|
'GET Advanced-Cache.json': async () => {
|
||||||
const s = Store.state;
|
const s = Store.state;
|
||||||
const snap = await serverSnapshot();
|
const snap = await serverSnapshot();
|
||||||
if (!s.appliedTracked || !snap.rows.length) return jsonResponse(snap.adv);
|
if (!s.applied || !snap.rows.length) return jsonResponse(snap.adv);
|
||||||
const { adv } = buildRows(s.appliedTracked, snap.rows, 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;
|
for (const [k, val] of Object.entries(adv)) if (val) snap.adv[k] = val;
|
||||||
return jsonResponse(snap.adv);
|
return jsonResponse(snap.adv);
|
||||||
},
|
},
|
||||||
'GET Software-Cache.json': async () => {
|
'GET Software-Cache.json': async () => {
|
||||||
const s = Store.state;
|
const s = Store.state;
|
||||||
const cache = (await serverGet('/Software-Cache.json')) || {};
|
const cache = (await serverGet('/Software-Cache.json')) || {};
|
||||||
if (s.appliedTracked) {
|
if (s.applied) {
|
||||||
const snap = await serverSnapshot();
|
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)) {
|
for (const [name, template] of Object.entries(software)) {
|
||||||
if (cache[name]) continue;
|
if (cache[name]) continue;
|
||||||
cache[name] = (template && cache[template]) || { Timestamp: new Date().toISOString(), Data: [] };
|
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.
|
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>
|
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
|
<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 ·
|
click a device for its details · <em>All Devices</em> shows apps missing from replacement machines ·
|
||||||
edit an article under <em>Documentation</em>.</p>
|
edit an article under <em>Documentation</em>.</p>
|
||||||
</div>
|
</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-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 { 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-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; }
|
/* Today: the date number in a filled badge (like a calendar app), so it can't be mistaken for
|
||||||
.ov-today .ov-day-num { color: #3498db; font-weight: 700; }
|
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 */
|
/* tooltip */
|
||||||
|
|||||||
Reference in new issue
Block a user