Files
usmt-dash/Providers/Demo.ps1
T
Johan Sandoval 1c531bc2ab Generalize USMT Dash and add a containerized demo mode
Remove all environment-specific data and make the dashboard adoptable
anywhere, then add a demo that runs the real PowerShell services.

- Strip cached device data, internal knowledge-base articles and
  screenshots, server/site names and the old project notes; replace the
  knowledge base with generic sample articles and diagrams
- Move every environment value into Config/usmt-dash.psd1
- Route all infrastructure access in the worker and Get-Software.ps1
  through a provider (Providers/SCCM.ps1), with a config-driven asset
  inventory view instead of a hard-coded custom class
- Add Providers/Demo.ps1: a deterministic simulated fleet whose
  migrations progress in real time, and Demo/New-DemoData.ps1 to seed it
- Server: -Demo makes the API read-only; add get-config and get-logs;
  serve static files only from web/, Documentation/ and an allowlist of
  Data/ files with a path-traversal guard; gzip large responses
- Mirror both services' console output to Data/Logs via a Write-Host
  proxy, and add a terminal panel to the dashboard that streams it
- Demo sandbox (web/demo/demo.js): visitor changes to tags, articles and
  images are kept in the visitor's browser and echoed in the terminals;
  nothing a visitor does reaches the server
- Vendor marked, highlight.js and html2pdf.js instead of loading CDNs
- Dockerfile and compose file for hosting the demo (Coolify-ready)
2026-10-06 15:20:43 +00:00

500 lines
28 KiB
PowerShell

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