Files
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

130 lines
4.8 KiB
PowerShell

# Shared helpers for USMT-Server.ps1, USMT-Worker.ps1 and Get-Software.ps1.
# Dot-source this file; it must stay compatible with Windows PowerShell 5.1 and PowerShell 7.
function Import-DashConfig {
param([string]$Path, [switch]$Demo)
if (-not $Path) { $Path = Join-Path (Split-Path $PSScriptRoot -Parent) "Config\usmt-dash.psd1" }
$Path = $Path -replace '[\\/]', [System.IO.Path]::DirectorySeparatorChar
if (-not (Test-Path $Path)) { throw "Config file not found: $Path" }
$cfg = Import-PowerShellDataFile -Path $Path
if ($Demo -or $env:USMT_MODE -eq 'Demo') { $cfg.Mode = 'Demo' }
$cfg.IsDemo = ($cfg.Mode -eq 'Demo')
return $cfg
}
function Join-DashPath {
# Join-Path that tolerates the backslash-separated relative paths used in config on Linux
param([string]$Base, [string]$Child)
return Join-Path $Base ($Child -replace '[\\/]', [System.IO.Path]::DirectorySeparatorChar)
}
# -----------------------------------------------------------------------------------------
# Console log mirror
#
# Write-Host is proxied so every line printed to the console is also appended to a JSON
# lines file: {"i":<id>,"t":"<time>","s":[["text","Color"],...]}. The server's /get-logs
# route tails that file for the dashboard terminal panel. Call sites are unchanged.
# -----------------------------------------------------------------------------------------
$script:ConsoleLog = $null
function Initialize-ConsoleLog {
param([string]$Path, [int]$MaxLines = 3000, [string]$CommandLine)
$dir = Split-Path $Path -Parent
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
$script:ConsoleLog = @{
Path = $Path
MaxLines = $MaxLines
NextId = [long]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()) * 1000
Pending = [System.Collections.Generic.List[object]]::new()
Written = 0
}
if ($CommandLine) { Add-ConsoleLogLine -Segments @(, @($CommandLine, 'Prompt')) }
}
function ConvertTo-JsonString([string]$s) {
$sb = [System.Text.StringBuilder]::new('"')
foreach ($ch in $s.ToCharArray()) {
switch ($ch) {
'"' { [void]$sb.Append('\"') }
'\' { [void]$sb.Append('\\') }
"`r" { }
"`t" { [void]$sb.Append('\t') }
default {
if ([int]$ch -lt 32) { [void]$sb.AppendFormat('\u{0:x4}', [int]$ch) } else { [void]$sb.Append($ch) }
}
}
}
[void]$sb.Append('"')
return $sb.ToString()
}
function Add-ConsoleLogLine {
param([object[]]$Segments)
$log = $script:ConsoleLog
if (-not $log) { return }
$log.NextId++
$segJson = ($Segments | ForEach-Object { "[" + (ConvertTo-JsonString $_[0]) + "," + (ConvertTo-JsonString $_[1]) + "]" }) -join ','
$line = '{"i":' + $log.NextId + ',"t":"' + (Get-Date).ToString('HH:mm:ss') + '","s":[' + $segJson + ']}'
for ($try = 0; $try -lt 5; $try++) {
try { [System.IO.File]::AppendAllText($log.Path, $line + "`n"); break }
catch { Start-Sleep -Milliseconds 20 }
}
$log.Written++
if ($log.Written % 250 -eq 0) { Limit-ConsoleLog }
}
function Limit-ConsoleLog {
$log = $script:ConsoleLog
try {
$lines = [System.IO.File]::ReadAllLines($log.Path)
if ($lines.Count -gt $log.MaxLines) {
$keep = $lines[($lines.Count - $log.MaxLines)..($lines.Count - 1)]
$tmp = "$($log.Path).tmp"
[System.IO.File]::WriteAllLines($tmp, $keep)
Move-Item -Path $tmp -Destination $log.Path -Force
}
} catch {}
}
function Write-Host {
param(
[Parameter(Position = 0)][object]$Object = '',
[switch]$NoNewline,
[object]$Separator = ' ',
[System.ConsoleColor]$ForegroundColor,
[System.ConsoleColor]$BackgroundColor
)
Microsoft.PowerShell.Utility\Write-Host @PSBoundParameters
$log = $script:ConsoleLog
if (-not $log) { return }
$color = if ($PSBoundParameters.ContainsKey('ForegroundColor')) { "$ForegroundColor" } else { 'Default' }
$parts = "$Object" -split "`n"
for ($p = 0; $p -lt $parts.Count; $p++) {
if ($parts[$p] -ne '') { $log.Pending.Add(@($parts[$p], $color)) }
$isLast = ($p -eq $parts.Count - 1)
if (-not $isLast -or -not $NoNewline) {
Add-ConsoleLogLine -Segments $log.Pending.ToArray()
$log.Pending.Clear()
}
}
}
# Reads a file without locking it against the other process (worker writes while server reads)
function Read-SharedText {
param([string]$Path)
$fs = New-Object System.IO.FileStream($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
try {
$sr = New-Object System.IO.StreamReader($fs)
return $sr.ReadToEnd()
} finally { $fs.Close() }
}