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)
No files matched your search
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
Data/*
|
||||
!Data/.gitkeep
|
||||
*.md.bak
|
||||
docker-compose.yml
|
||||
@@ -0,0 +1,3 @@
|
||||
# Runtime state written by the worker and server
|
||||
Data/*
|
||||
!Data/.gitkeep
|
||||
@@ -0,0 +1,121 @@
|
||||
@{
|
||||
# =====================================================================================
|
||||
# USMT Dash configuration
|
||||
#
|
||||
# Every environment-specific value lives in this file. To adopt the dashboard in your
|
||||
# own environment, copy this file, fill in the SCCM section, and run the two services:
|
||||
#
|
||||
# .\USMT-Worker.ps1 # data collector (keep running)
|
||||
# .\USMT-Server.ps1 # web server + JSON API (keep running)
|
||||
#
|
||||
# Pass -Demo to either script (or set Mode = 'Demo', or env USMT_MODE=Demo) to run
|
||||
# against the built-in simulated fleet instead of real infrastructure.
|
||||
# Pass -ConfigPath to point at a different config file.
|
||||
# =====================================================================================
|
||||
|
||||
# 'Live' = query real infrastructure through the provider below.
|
||||
# 'Demo' = simulated fleet, read-only API, visitor changes stay in the browser.
|
||||
Mode = 'Live'
|
||||
|
||||
# Data source adapter used in Live mode. Loaded from .\Providers\<Provider>.ps1
|
||||
Provider = 'SCCM'
|
||||
|
||||
Server = @{
|
||||
Port = 8080
|
||||
# HttpListener prefix. '*' binds every interface (may need a URL ACL on Windows:
|
||||
# netsh http add urlacl url=http://*:8080/ user=DOMAIN\svc-account)
|
||||
Prefix = 'http://*:{0}/'
|
||||
}
|
||||
|
||||
Worker = @{
|
||||
# Seconds between cycles during business hours
|
||||
CycleSeconds = 180
|
||||
|
||||
# Outside business hours the worker sleeps until the next morning, with a
|
||||
# midnight "pitstop" that runs the daily software/advanced-info refresh.
|
||||
BusinessHours = @{
|
||||
Enabled = $true
|
||||
Start = '07:30'
|
||||
End = '17:30'
|
||||
Weekdays = @(1, 2, 3, 4, 5) # 0 = Sunday ... 6 = Saturday
|
||||
}
|
||||
|
||||
# Flush the local DNS client cache at the start of each cycle (Windows only)
|
||||
FlushDnsEachCycle = $true
|
||||
|
||||
# Devices whose admin share hangs; disk and log checks are skipped for these
|
||||
DiskSkipList = @()
|
||||
|
||||
# How long a cached software inventory is considered fresh
|
||||
SoftwareCacheHours = 24
|
||||
|
||||
# A task-sequence log modified within this window counts as "active"
|
||||
ActiveLogHours = 24
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------------------
|
||||
# SCCM / ConfigMgr provider (.\Providers\SCCM.ps1)
|
||||
# -------------------------------------------------------------------------------------
|
||||
SCCM = @{
|
||||
SiteCode = 'ABC'
|
||||
ProviderMachineName = 'sccm01.corp.example.com' # SMS Provider for the CMSite PSDrive
|
||||
SqlServer = 'sccm01.corp.example.com' # Site database server
|
||||
Database = 'CM_ABC'
|
||||
|
||||
# Where the worker looks for USMT progress logs on each device (relative to the
|
||||
# admin share below). First folder containing a matching log wins.
|
||||
AdminShare = 'C$'
|
||||
LogFolders = @('Windows\CCM\Logs\SMSTSLog', 'Windows\CCM\Logs')
|
||||
ScanStateLog = 'scanstateprogress'
|
||||
LoadStateLog = 'loadstateprogress'
|
||||
|
||||
# Many organizations extend hardware inventory with a custom class that holds
|
||||
# asset/location details (room, department, mount type, ...). Map that class here.
|
||||
# Set Enabled = $false if you don't have one - those fields will show "N/A".
|
||||
AssetInventory = @{
|
||||
Enabled = $true
|
||||
View = 'v_GS_ASSET_INFO0' # primary inventory view
|
||||
FallbackView = '' # optional older/secondary view, queried only if the primary has no row
|
||||
# Dashboard field = column in the view
|
||||
Columns = @{
|
||||
Updated = 'Date_Updated0'
|
||||
Location = 'Location0'
|
||||
Vanity = 'VanityLocation0'
|
||||
Department = 'Department0'
|
||||
Building = 'Building0'
|
||||
Mount = 'Mount_Type0'
|
||||
PrimaryUser = 'Primary_User0'
|
||||
PhoneExt = 'Phone_Extension0'
|
||||
InfoBy = 'Info_Entered_By0'
|
||||
Notes = 'Notes0'
|
||||
}
|
||||
# Boolean (1/0) columns that describe what kind of space the device is in.
|
||||
# Any column set to 1 adds its label to the "Room Type" field.
|
||||
RoomTypes = @(
|
||||
@{ Label = 'Conference Room'; Column = 'ConferenceRoom0' }
|
||||
@{ Label = 'Hoteling Desk'; Column = 'HotelingDesk0' }
|
||||
@{ Label = 'Training Room'; Column = 'TrainingRoom0' }
|
||||
@{ Label = 'Reception'; Column = 'Reception0' }
|
||||
@{ Label = 'Shared Space'; Column = 'SharedSpace0' }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------------------
|
||||
# Demo provider (.\Providers\Demo.ps1) - simulated fleet used by the public demo
|
||||
# -------------------------------------------------------------------------------------
|
||||
Demo = @{
|
||||
DeviceCount = 200 # tracked source devices seeded at startup
|
||||
CycleSeconds = 45 # cooldown between cycles (business hours are ignored)
|
||||
DelayMsPerDevice = 35 # artificial per-device latency so the progress bar moves
|
||||
LifecycleHours = 24 # how long one simulated migration takes, start to finish
|
||||
Verbose = $true # run the worker with -v style tracing
|
||||
}
|
||||
|
||||
# Console output from both services is mirrored to Data\Logs\*.log as JSON lines so the
|
||||
# dashboard's terminal panel can stream it.
|
||||
Logging = @{
|
||||
Enabled = $true
|
||||
MaxLines = 3000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
Microsoft 365 Apps for enterprise *
|
||||
Microsoft Edge
|
||||
Microsoft Edge WebView2 Runtime
|
||||
Microsoft OneDrive
|
||||
Microsoft Teams*
|
||||
Microsoft Visual C++ *
|
||||
Microsoft Intune Management Extension
|
||||
Microsoft Policy Platform
|
||||
Microsoft .NET *
|
||||
Microsoft Windows Desktop Runtime *
|
||||
Configuration Manager Client
|
||||
Google Chrome
|
||||
Adobe Acrobat Reader*
|
||||
7-Zip *
|
||||
Endpoint Protection Agent
|
||||
Intel(R) Management Engine Components
|
||||
Realtek Audio Driver
|
||||
Windows Driver Package *
|
||||
HP Support Assistant
|
||||
Dell Command | Update
|
||||
Lenovo Vantage Service
|
||||
Cisco Secure Client*
|
||||
Adobe Acrobat Pro = Adobe Acrobat (64-bit), Adobe Acrobat DC*
|
||||
QuickBooks = QuickBooks Desktop*, QuickBooks Database Server Manager*, Intuit Data Protect*
|
||||
Salesforce = Salesforce for Outlook*, Salesforce Data Loader*
|
||||
Adobe Creative Cloud = Adobe Creative Cloud, Adobe Photoshop*, Adobe Illustrator*, Adobe InDesign*
|
||||
Microsoft SQL Server Management Studio = SQL Server Management Studio*, SSMS Post Install Tasks*
|
||||
Microsoft Power BI = Microsoft Power BI Desktop*, Power Pivot for Excel*
|
||||
Microsoft Visio = Microsoft Visio*
|
||||
Microsoft Project = Microsoft Project*
|
||||
Python = Python 3.*, Python Launcher*
|
||||
Wireshark = Wireshark*, Npcap*
|
||||
Zoom = Zoom Workplace*, Zoom*
|
||||
Slack = Slack*
|
||||
Tableau = Tableau 20*
|
||||
Notepad++ = Notepad++*
|
||||
Rule: PUB* = Kiosk Launcher*, Shared PC Cleanup Utility*
|
||||
Combo: Finance Reporting Bundle = QuickBooks Desktop* && Microsoft Power BI Desktop*
|
||||
@@ -0,0 +1,122 @@
|
||||
# Seeds Data\ with a simulated fleet for demo mode: tracking tags (Tracked-Devices.json),
|
||||
# the app blacklist, and a pre-warmed software cache so the first worker cycle is quick.
|
||||
# Run before starting the services. -Force wipes any existing state first.
|
||||
param(
|
||||
[switch]$Force,
|
||||
[string]$ConfigPath
|
||||
)
|
||||
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
. (Join-Path $root "Lib/Common.ps1")
|
||||
$config = Import-DashConfig -Path $ConfigPath -Demo
|
||||
$v = $false
|
||||
. (Join-Path $root "Providers/Demo.ps1")
|
||||
. (Join-Path $root "Lib/Common.ps1") # re-import so nothing in the provider shadows the helpers
|
||||
|
||||
$dataDir = Join-Path $root "Data"
|
||||
if ($Force -and (Test-Path $dataDir)) {
|
||||
Get-ChildItem $dataDir -Recurse -File | Remove-Item -Force
|
||||
}
|
||||
if (-not (Test-Path $dataDir)) { New-Item -ItemType Directory -Path $dataDir | Out-Null }
|
||||
|
||||
$trackedPath = Join-Path $dataDir "Tracked-Devices.json"
|
||||
if ((Test-Path $trackedPath) -and -not $Force) {
|
||||
Write-Host "Demo data already present (use -Force to reseed)." -ForegroundColor DarkGray
|
||||
return
|
||||
}
|
||||
|
||||
$count = [int]$config.Demo.DeviceCount
|
||||
Write-Host "Seeding demo fleet ($count tracked devices)..." -ForegroundColor Cyan
|
||||
|
||||
# --- 1. Generate source devices ---
|
||||
function New-FleetName([int]$i, [string]$prefix) { return "$prefix-$(New-DemoSerial "fleet$i")" }
|
||||
|
||||
$skipPublic = [System.Collections.Generic.List[string]]::new()
|
||||
$skipLaptops = [System.Collections.Generic.List[string]]::new()
|
||||
$pool = [System.Collections.Generic.List[string]]::new()
|
||||
$i = 0
|
||||
while ($skipPublic.Count -lt 14) { $skipPublic.Add((New-FleetName ($i++) "PUBDT")) }
|
||||
while ($skipLaptops.Count -lt 16) { $skipLaptops.Add((New-FleetName ($i++) (Get-DemoPick "lt$i" 'p' @("PRVLT", "PRVLT", "PRVLT", "PUBLT")))) }
|
||||
while ($pool.Count -lt ($count - $skipPublic.Count - $skipLaptops.Count - 3)) {
|
||||
$r = Get-DemoRand "mix$i" 'prefix'
|
||||
$prefix = if ($r -lt 0.55) { "PRVDT" } elseif ($r -lt 0.80) { "PUBDT" } elseif ($r -lt 0.95) { "PRVLT" } else { "PUBLT" }
|
||||
$pool.Add((New-FleetName ($i++) $prefix))
|
||||
}
|
||||
|
||||
# Manual "Source, Destination" pairs - hardware exceptions with no SCCM association
|
||||
$manual = @()
|
||||
for ($m = 0; $m -lt 3; $m++) {
|
||||
$src = New-FleetName ($i++) "PRVDT"
|
||||
$dst = New-FleetName ($i++) "PRVDT"
|
||||
$manual += "$src, $dst"
|
||||
}
|
||||
|
||||
# --- 2. Date-batch tags (recent and upcoming working days) ---
|
||||
function Get-Workday([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
|
||||
}
|
||||
$today = Get-Date
|
||||
$dateTags = @(-4, -3, -2, -1, 0, 1) | ForEach-Object { (Get-Workday $today $_).ToString("M/d") }
|
||||
|
||||
$tags = [ordered]@{}
|
||||
foreach ($t in $dateTags) { $tags[$t] = [System.Collections.Generic.List[string]]::new() }
|
||||
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)
|
||||
|
||||
# Department tags overlap the date batches, like a tech grouping their own work
|
||||
$byDept = @{}
|
||||
foreach ($d in $pool) {
|
||||
$dep = (Get-DemoProfile $d).Department.Name
|
||||
if (-not $byDept.ContainsKey($dep)) { $byDept[$dep] = [System.Collections.Generic.List[string]]::new() }
|
||||
$byDept[$dep].Add($d)
|
||||
}
|
||||
foreach ($pair in @(@("Accounting", "Accounting"), @("Sales Floor", "Sales"), @("Exec Suite", "Executive"))) {
|
||||
if ($byDept.ContainsKey($pair[1])) { $tags[$pair[0]] = [System.Collections.Generic.List[string]]::new([string[]]@($byDept[$pair[1]] | Select-Object -First 12)) }
|
||||
}
|
||||
$tags["Broken WMI"] = [System.Collections.Generic.List[string]]::new([string[]]@($pool | Where-Object { (Get-DemoRand $_ 'wmi') -lt 0.03 } | Select-Object -First 5))
|
||||
$tags["Public PCs"] = $skipPublic
|
||||
$tags["Laptops"] = $skipLaptops
|
||||
|
||||
$skipTags = @("Public PCs", "Laptops")
|
||||
$tracked = [ordered]@{}
|
||||
foreach ($t in $tags.Keys) {
|
||||
$tracked[$t] = [ordered]@{ devices = @($tags[$t]); skipUSMT = ($skipTags -contains $t) }
|
||||
}
|
||||
($tracked | ConvertTo-Json -Depth 5) | Set-Content -Path $trackedPath -Encoding UTF8
|
||||
|
||||
# --- 3. Blacklist / suite rules ---
|
||||
Copy-Item (Join-Path $PSScriptRoot "Blacklisted-Apps.txt") (Join-Path $dataDir "Blacklisted-Apps.txt") -Force
|
||||
$blacklist = @(Get-Content (Join-Path $dataDir "Blacklisted-Apps.txt") | Where-Object { $_.Trim() -ne "" } | ForEach-Object { $_.Trim() })
|
||||
|
||||
# --- 4. Pre-warmed software cache (the worker would otherwise build it one device at a time) ---
|
||||
$allDevices = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($d in ($pool + $skipPublic + $skipLaptops)) {
|
||||
$allDevices.Add($d)
|
||||
$p = Get-DemoProfile $d
|
||||
if (-not $p.NoAssoc -and -not ($skipPublic -contains $d) -and -not ($skipLaptops -contains $d)) { $allDevices.Add((Get-DemoDestName $d)) }
|
||||
}
|
||||
foreach ($pair in $manual) { $allDevices.AddRange([string[]]@($pair -split '\s*,\s*')) }
|
||||
|
||||
$stamp = (Get-Date).ToString("o")
|
||||
$cache = [ordered]@{}
|
||||
foreach ($d in $allDevices) {
|
||||
$rows = foreach ($row in (Get-InstalledSoftware -ComputerName $d)) {
|
||||
$isSpecialty = $true
|
||||
foreach ($bad in $blacklist) { if ($row.Name -like $bad) { $isSpecialty = $false; break } }
|
||||
[PSCustomObject]@{ Name = $row.Name; Version = $row.Version; Publisher = $row.Publisher; Source = $row.Source; InstallDate = $row.InstallDate; IsSpecialty = $isSpecialty }
|
||||
}
|
||||
$cache[$d] = [PSCustomObject]@{ Timestamp = $stamp; Data = @($rows) }
|
||||
}
|
||||
[System.IO.File]::WriteAllText((Join-Path $dataDir "Software-Cache.json"), ($cache | ConvertTo-Json -Depth 6 -Compress), [System.Text.Encoding]::UTF8)
|
||||
|
||||
# --- 5. Empty state for the rest ---
|
||||
Set-Content -Path (Join-Path $dataDir "Completed.json") -Value "[]" -Encoding UTF8
|
||||
Set-Content -Path (Join-Path $dataDir "Data.json") -Value "[]" -Encoding UTF8
|
||||
Set-Content -Path (Join-Path $dataDir "Advanced-Cache.json") -Value "{}" -Encoding UTF8
|
||||
Set-Content -Path (Join-Path $dataDir "Worker-State.json") -Value '{"Status":"Preparing...","Current":0,"Total":0,"LastUpdated":"Never","NextCycle":"Calculating...","LastDuration":"N/A"}' -Encoding UTF8
|
||||
Set-Content -Path (Join-Path $dataDir "Whitelisted-IPs.txt") -Value "" -Encoding UTF8
|
||||
|
||||
$unique = @($tracked.Values | ForEach-Object { $_.devices } | ForEach-Object { $_ }) | Select-Object -Unique
|
||||
Write-Host "Seeded $($tracked.Count) tags, $(@($unique).Count) roster entries, $($cache.Count) software inventories." -ForegroundColor Green
|
||||
@@ -0,0 +1,34 @@
|
||||
# USMT Dash - demo container
|
||||
#
|
||||
# Runs the real USMT-Server.ps1 and USMT-Worker.ps1 under PowerShell 7 with -Demo, so the
|
||||
# worker sweeps a simulated fleet instead of SCCM/AD/SMB. The web API is read-only in demo
|
||||
# mode; visitor changes are simulated in each visitor's browser.
|
||||
#
|
||||
# docker build -t usmt-dash .
|
||||
# docker run -p 8080:8080 usmt-dash
|
||||
FROM mcr.microsoft.com/powershell:lts-ubuntu-22.04
|
||||
|
||||
ENV USMT_MODE=Demo \
|
||||
POWERSHELL_TELEMETRY_OPTOUT=1 \
|
||||
POWERSHELL_UPDATECHECK=Off \
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT=1 \
|
||||
TZ=UTC
|
||||
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --home /app --shell /usr/sbin/nologin usmt \
|
||||
&& mkdir -p /app/Data \
|
||||
&& chown -R usmt:usmt /app/Data \
|
||||
&& chmod +x /app/docker/start.sh
|
||||
|
||||
USER usmt
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD pwsh -NoProfile -Command "try { if ((Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8080/get-config -TimeoutSec 5).StatusCode -eq 200) { exit 0 } } catch {}; exit 1"
|
||||
|
||||
ENTRYPOINT ["/app/docker/start.sh"]
|
||||
@@ -0,0 +1,26 @@
|
||||
[[Blue:Licensed]] [[Gray:Named User]]
|
||||
|
||||
# Adobe Acrobat Pro
|
||||
|
||||
Acrobat Pro is licensed per user through the Adobe Admin Console. The installer is the same unified package as Acrobat Reader; signing in with a licensed account unlocks Pro features.
|
||||
|
||||
## Install
|
||||
|
||||
1. Confirm the user is assigned an Acrobat Pro license in the Adobe Admin Console (ask the license owner if not).
|
||||
2. On the new device open **Software Center** and install **Adobe Acrobat (64-bit)**.
|
||||
- Manual installer if Software Center is unavailable: [[Copy:\\fileserver\software\AcrobatPro\Setup.exe]]
|
||||
3. Launch Acrobat and have the user sign in with their company account.
|
||||
4. Verify **Help > About Adobe Acrobat** shows *Acrobat Pro*.
|
||||
|
||||
## Common Issues
|
||||
|
||||
**Still shows "Acrobat Reader" after sign-in**
|
||||
1. Sign out (**Help > Sign Out**), close Acrobat, sign back in.
|
||||
2. If it persists, the license assignment hasn't synced yet. Wait 15 minutes and repeat.
|
||||
|
||||
**Default PDF handler reverted to Edge**
|
||||
- Settings > Apps > Default apps > search `.pdf` > choose **Adobe Acrobat**.
|
||||
|
||||
## Migration Notes
|
||||
- USMT carries over Acrobat preferences, but **digital signature certificates** stored outside the user profile must be re-imported.
|
||||
- Custom stamps live in `%AppData%\Adobe\Acrobat\DC\Stamps`. Confirm they came across.
|
||||
@@ -0,0 +1,21 @@
|
||||
[[Blue:Licensed]] [[Yellow:Large Install]]
|
||||
|
||||
# Adobe Creative Cloud
|
||||
|
||||
Creative Cloud apps (Photoshop, Illustrator, InDesign, ...) are installed through the **Creative Cloud Desktop** app after the user signs in.
|
||||
|
||||
## Install
|
||||
|
||||
1. Install **Adobe Creative Cloud** from Software Center.
|
||||
2. Have the user sign in with their company account.
|
||||
3. From the **Apps** tab install only what was on the old device. The **All Devices** page in USMT Dash lists them under *Missing Software*.
|
||||
|
||||
> Expect 5-20 GB per app. Check free space on the destination before starting.
|
||||
|
||||
## Fonts and Presets
|
||||
- Synced fonts return automatically after sign-in.
|
||||
- Local presets, brushes and swatches are in `%AppData%\Adobe\`. USMT migrates these, but verify on first launch.
|
||||
|
||||
## Troubleshooting
|
||||
- **"You don't have access to manage apps"**: the user's license isn't assigned. Contact the license owner.
|
||||
- **Install stuck at 0%**: sign out of Creative Cloud, delete `%LocalAppData%\Adobe\OOBE`, reboot and retry.
|
||||
@@ -0,0 +1,7 @@
|
||||
[[Gray:Archived]]
|
||||
|
||||
# Legacy VPN Client
|
||||
|
||||
*Archived: replaced by Cisco Secure Client on all laptops.*
|
||||
|
||||
The old VPN client must be **uninstalled** before installing Cisco Secure Client, or the two network filter drivers will conflict.
|
||||
@@ -0,0 +1,22 @@
|
||||
[[Gray:Reference]]
|
||||
|
||||
# Create a Bootable Imaging USB
|
||||
|
||||
Use this when PXE isn't available (remote site, isolated network, or a device that won't PXE boot).
|
||||
|
||||
## You'll Need
|
||||
- A USB drive of **32 GB or more** (it will be erased)
|
||||
- Access to the ConfigMgr console
|
||||
|
||||
## Steps
|
||||
1. In the ConfigMgr console go to **Software Library > Operating Systems > Task Sequences**.
|
||||
2. Right-click the deployment task sequence > **Create Task Sequence Media**.
|
||||
3. Choose **Bootable media** > **Dynamic media**.
|
||||
4. Select **Removable USB drive** and pick the drive.
|
||||
5. Leave the media unprotected or set a password per team policy.
|
||||
6. Select the current production **boot image** and distribution point.
|
||||
7. Finish the wizard. Writing the media takes about 5-10 minutes.
|
||||
|
||||
## Booting From It
|
||||
- Press the vendor boot-menu key (**F9** HP, **F12** Dell/Lenovo) and choose the USB device (UEFI).
|
||||
- If Secure Boot blocks the USB, confirm it was built from a signed boot image.
|
||||
@@ -0,0 +1,31 @@
|
||||
[[Yellow:Remediation]]
|
||||
|
||||
# Device Storage Remediation
|
||||
|
||||
USMT needs free space on the source device for its working files. Devices under about **15 GB free** often fail the backup. USMT Dash shows free space in the **USMT Status** table.
|
||||
|
||||
## 1. Remove stale profiles
|
||||
1. Press **Win**, type *About your PC*, open **Advanced system settings**.
|
||||
2. Under **User Profiles** click **Settings**.
|
||||
3. Delete profiles named *Account Unknown* and profiles of users who no longer use the device. **Never** delete the current user's profile.
|
||||
|
||||
## 2. Clear temp folders
|
||||
```powershell
|
||||
Remove-Item "$env:WinDir\Temp\*" -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item "C:\Temp\*" -Recurse -Force -ErrorAction SilentlyContinue
|
||||
```
|
||||
|
||||
## 3. Clear the ConfigMgr cache
|
||||
Control Panel > **Configuration Manager** > *Cache* tab > **Configure Settings** > **Delete Files** > *Delete persisted content*.
|
||||
|
||||
## 4. Rebuild the search index (if Windows.edb is huge)
|
||||
```powershell
|
||||
Stop-Service WSearch -Force
|
||||
Remove-Item "C:\ProgramData\Microsoft\Search\Data\Applications\Windows\Windows.edb" -Force
|
||||
Start-Service WSearch
|
||||
```
|
||||
|
||||
## 5. Disk Cleanup
|
||||
Run `cleanmgr /sageset:1` once to tick every category, then `cleanmgr /sagerun:1`.
|
||||
|
||||
After cleanup, use **Force Update** in USMT Dash (click the status button in the top bar) to refresh the free-space reading.
|
||||
@@ -0,0 +1,26 @@
|
||||
[[Red:Destructive]]
|
||||
|
||||
# Diskpart: Wipe and Re-partition a Disk (UEFI/GPT)
|
||||
|
||||
> **This erases the disk.** Only use it on a device whose backup is complete, or on new hardware.
|
||||
|
||||
1. Boot into WinPE or the task sequence wizard and press **F8** for a command prompt.
|
||||
2. Type `diskpart`.
|
||||
3. Enter each line and press Enter after each:
|
||||
|
||||
```
|
||||
list disk
|
||||
select disk 0
|
||||
clean
|
||||
convert gpt
|
||||
create partition efi size=300
|
||||
format quick fs=fat32 label="System"
|
||||
assign letter=S
|
||||
create partition msr size=128
|
||||
create partition primary
|
||||
format quick fs=ntfs label="Windows"
|
||||
assign letter=C
|
||||
exit
|
||||
```
|
||||
|
||||
4. Close the prompt and restart the task sequence.
|
||||
@@ -0,0 +1,16 @@
|
||||
[[Yellow:Remediation]]
|
||||
|
||||
# Manually Start a USMT Backup
|
||||
|
||||
Use this when a device in USMT Dash sits at **Not Started** while its replacement is ready.
|
||||
|
||||
1. Find the device (the **Tracked Devices** or **USMT Status** page shows its last ping state).
|
||||
2. Restart it. The USMT backup won't start while another user is signed in. Then sign in.
|
||||
3. Open **Software Center**.
|
||||
- Blank page or "failed to load"? Run [[Copy:C:\Windows\CCM\ccmrepair.exe]], wait for it to finish, then reopen Software Center.
|
||||
- Still broken? Tag the device in USMT Dash (e.g. `Broken WMI`) and move on. See [WMI Namespace Fix](?doc=WMI%20Namespace%20Fix).
|
||||
4. Run **USMT Backup** from the *Operating Systems* tab.
|
||||
- Fails immediately? Run `gpupdate /force`, then Software Center > Options > **Sync Policy**. Wait a few minutes and retry.
|
||||
5. When the button turns grey and shows *Installing...* the backup is running. You can sign out and move to the next device.
|
||||
|
||||
In USMT Dash the row changes to **Backup In Progress** within one worker cycle, and the scanstate line shows progress.
|
||||
@@ -0,0 +1,25 @@
|
||||
[[Green:Baseline]]
|
||||
|
||||
# Microsoft Office (Microsoft 365 Apps)
|
||||
|
||||
Microsoft 365 Apps for enterprise is part of the standard image and is on every device. It's in the app blacklist, so it never shows up as missing software.
|
||||
|
||||
## After a Migration
|
||||
1. Open **Outlook** and confirm the mailbox loads. A new profile is created on first launch.
|
||||
2. Open **Word** > **File > Account** and confirm *Product Activated* with the user's account.
|
||||
3. Check **OneDrive** is signed in and syncing (blue cloud in the system tray).
|
||||
|
||||
## Common Fixes
|
||||
|
||||
**Activation errors / "Unlicensed Product"**
|
||||
1. Close all Office apps.
|
||||
2. Run [[Copy:Microsoft Support and Recovery Assistant|https://aka.ms/SaRA-officeUninstallFromPC]] or sign out of every account under **File > Account** and sign back in.
|
||||
|
||||
**Outlook signatures missing**
|
||||
- Signatures are stored in `%AppData%\Microsoft\Signatures`. USMT migrates the folder; if they're missing, copy it from the migration store.
|
||||
|
||||
**Quick repair**
|
||||
- Settings > Apps > Installed apps > Microsoft 365 Apps > Modify > **Quick Repair**.
|
||||
|
||||
## Visio and Project
|
||||
Visio and Project are licensed separately. See [Microsoft Visio](?doc=Microsoft%20Visio) and [Microsoft Project](?doc=Microsoft%20Project).
|
||||
@@ -0,0 +1,12 @@
|
||||
[[Gray:Optional]]
|
||||
|
||||
# Microsoft Power BI
|
||||
|
||||
## Install
|
||||
- Software Center > **Microsoft Power BI Desktop (x64)**.
|
||||
- Sign in with the user's company account to publish to the Power BI service.
|
||||
|
||||
## Notes
|
||||
- `.pbix` reports are usually in OneDrive or SharePoint, so nothing to migrate locally.
|
||||
- **Power Pivot for Excel** is grouped with Power BI in the software view. It's enabled from Excel > File > Options > Add-ins > COM Add-ins.
|
||||
- Gateways (on-premises data gateway) are installed on servers, **never** on user workstations.
|
||||
@@ -0,0 +1,12 @@
|
||||
[[Blue:Licensed]]
|
||||
|
||||
# Microsoft Project
|
||||
|
||||
## Install
|
||||
1. Confirm the user has a Project license (Project Plan 3 or 5).
|
||||
2. Software Center > **Microsoft Project - en-us**.
|
||||
3. Launch and verify activation under **File > Account**.
|
||||
|
||||
## Notes
|
||||
- Like Visio, Project must match the installed Microsoft 365 Apps channel.
|
||||
- Global template customizations live in `%AppData%\Microsoft\MS Project\` (Global.MPT). Confirm it came across if the user relies on custom views.
|
||||
@@ -0,0 +1,21 @@
|
||||
[[Green:Baseline]]
|
||||
|
||||
# Microsoft Teams
|
||||
|
||||
The new Teams client is provisioned per user. It installs automatically the first time a user signs in to a device built from the standard image.
|
||||
|
||||
## Checks After Migration
|
||||
1. Teams launches and signs in without prompting for a password (SSO).
|
||||
2. **Settings > Devices**: the correct headset/camera is selected.
|
||||
3. The **Teams Meeting Add-in** appears in Outlook's ribbon.
|
||||
|
||||
## Meeting Add-in Missing in Outlook
|
||||
1. Close Outlook and Teams.
|
||||
2. Start Teams, wait for it to fully load, then start Outlook.
|
||||
3. If still missing: Outlook > File > Options > Add-ins > Manage **COM Add-ins** > Go > enable *Microsoft Teams Meeting Add-in*.
|
||||
|
||||
## Clear the Teams Cache
|
||||
```powershell
|
||||
Get-Process ms-teams -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
Remove-Item "$env:LocalAppData\Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams" -Recurse -Force
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
[[Blue:Licensed]]
|
||||
|
||||
# Microsoft Visio
|
||||
|
||||
Visio Plan 2 is licensed per user in Microsoft 365.
|
||||
|
||||
## Install
|
||||
1. Confirm the user has a Visio license assigned (Microsoft 365 admin center).
|
||||
2. Software Center > **Microsoft Visio - en-us**.
|
||||
3. Open Visio > **File > Account** and confirm the product is activated.
|
||||
|
||||
## Notes
|
||||
- Visio installs alongside Microsoft 365 Apps and must use the same channel (Current/Monthly Enterprise) or the install will fail.
|
||||
- Custom stencils: `Documents\My Shapes`. USMT migrates these with the Documents folder.
|
||||
@@ -0,0 +1,9 @@
|
||||
[[Gray:Optional]]
|
||||
|
||||
# Notepad++
|
||||
|
||||
Free text and code editor, mostly requested by IT and Engineering.
|
||||
|
||||
- Software Center > **Notepad++ (64-bit x64)**
|
||||
- Plugins live in `C:\Program Files\Notepad++\plugins` and are **not** migrated by USMT. Reinstall them from **Plugins > Plugins Admin**.
|
||||
- Settings and sessions are in `%AppData%\Notepad++`, which USMT migrates.
|
||||
@@ -0,0 +1,23 @@
|
||||
[[Blue:Licensed]] [[Red:Finance Approval]]
|
||||
|
||||
# QuickBooks
|
||||
|
||||
QuickBooks Desktop is installed for Accounting and Finance users only. Each install needs a **license and product number** from the Finance team. Never reuse one from another device without approval.
|
||||
|
||||
The software scan shows three entries that USMT Dash groups as one *QuickBooks* suite:
|
||||
- QuickBooks Desktop Pro
|
||||
- QuickBooks Database Server Manager
|
||||
- Intuit Data Protect
|
||||
|
||||
## Install
|
||||
1. Get the license and product number from the Finance team's request ticket.
|
||||
2. Run the installer: [[Copy:\\fileserver\software\QuickBooks\Setup_QuickBooks.exe]]
|
||||
3. Choose **Custom and Network Options** > *I'll be using QuickBooks on this computer*.
|
||||
4. Launch, register, and apply all updates (**Help > Update QuickBooks Desktop**).
|
||||
|
||||
## Company Files
|
||||
- Company files (`.QBW`) live on the finance share, **not** on the local device: [[Copy:\\fileserver\finance\QuickBooks]]
|
||||
- If the user had a local company file, move it to the share. Don't open it from a USB drive.
|
||||
|
||||
## Multi-user Mode
|
||||
The file server runs **Database Server Manager**. Workstations don't need it unless the user hosts files locally.
|
||||
@@ -0,0 +1,23 @@
|
||||
[[Blue:Licensed]]
|
||||
|
||||
# Salesforce
|
||||
|
||||
Salesforce itself is web based. Desktop components are only needed by some Sales and Support users.
|
||||
|
||||
| Component | Who needs it |
|
||||
|---|---|
|
||||
| Salesforce for Outlook / Outlook integration | Sales reps who log email to Salesforce |
|
||||
| Salesforce Data Loader | Sales Ops / admins doing bulk imports |
|
||||
|
||||
## Install
|
||||
1. Install the components that were on the old device (check *Missing Software* on the **All Devices** page).
|
||||
2. Installer share: [[Copy:\\fileserver\software\Salesforce\]]
|
||||
3. **Data Loader** needs a Java runtime. The installer bundles one; accept the defaults.
|
||||
|
||||
## Outlook Integration
|
||||
The modern integration is an Outlook add-in deployed from the Microsoft 365 admin center. After migration:
|
||||
1. Open Outlook and look for the **Salesforce** button on the ribbon.
|
||||
2. If it's missing, wait for add-in sync (up to 24 hours) or ask the M365 admins to confirm the assignment.
|
||||
|
||||
## Single Sign-On
|
||||
Users sign in with **Use Custom Domain** > `company` > SSO. They shouldn't need a separate password.
|
||||
@@ -0,0 +1,11 @@
|
||||
[[Gray:Optional]]
|
||||
|
||||
# Slack
|
||||
|
||||
## Install
|
||||
1. Software Center > **Slack (Machine - MSI)**. The machine-wide installer keeps Slack updated for all users on shared devices.
|
||||
2. Sign in to the company workspace with SSO.
|
||||
|
||||
## Notes
|
||||
- Slack data is cloud based. Nothing needs to be migrated.
|
||||
- If the user had the **per-user** Slack installed (from slack.com), uninstall it first so two copies don't fight over the protocol handler.
|
||||
@@ -0,0 +1,13 @@
|
||||
[[Blue:Licensed]]
|
||||
|
||||
# Tableau Desktop
|
||||
|
||||
## Install
|
||||
1. Installer: [[Copy:\\fileserver\software\Tableau\]]
|
||||
2. Activate with the user's license key (from the Analytics team) or sign in to Tableau Cloud if using login-based licensing.
|
||||
|
||||
## Before Wiping the Old Device
|
||||
Licenses activated with a product key must be **deactivated** first (Help > Manage Product Keys > Deactivate), or the key stays consumed.
|
||||
|
||||
## Drivers
|
||||
Database drivers (SQL Server, Oracle, ...) aren't included. Reinstall the ones the user had. Check *Missing Software* on the **All Devices** page.
|
||||
@@ -0,0 +1,36 @@
|
||||
[[Blue:Start Here]]
|
||||
|
||||
# Using USMT Dash
|
||||
|
||||
USMT Dash tracks a Windows device-refresh project: which old (**source**) device is being replaced by which new (**destination**) device, and how far each migration has got.
|
||||
|
||||

|
||||
|
||||
## The Pages
|
||||
| Page | What it's for |
|
||||
|---|---|
|
||||
| **Tracked Devices** | Register devices under a *tag* (your name, a date batch, a department...). The worker only watches tagged devices. |
|
||||
| **My Workbench** | Personal stacks: paste the devices on your cart and see just those. Saved in your browser. |
|
||||
| **USMT Status** | Every tracked migration, grouped by status, with ping, free space and USMT log lines. |
|
||||
| **All Devices** | Compares source and destination software and lists *specialty* apps missing from the new device. |
|
||||
| **Custom View** | Build your own table: add, remove, resize, drag and filter any column. |
|
||||
| **Documentation** | This knowledge base. Articles named after an app are linked from that app in the software view. |
|
||||
|
||||
## Migration Status
|
||||

|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| Not Started | Association exists, no backup yet |
|
||||
| Backup In Progress | Scanstate log on the source is actively being written |
|
||||
| Backup Completed | Backup finished, waiting for the new device |
|
||||
| Restore In Progress | Loadstate log on the destination is active |
|
||||
| Completed | Restore finished successfully |
|
||||
| Not Associated | No computer association in ConfigMgr |
|
||||
| Skipped | The device's tag has **Skip USMT** ticked (inventory only) |
|
||||
|
||||
## Tips
|
||||
- Pair devices manually with `SOURCE, DESTINATION` on one line when there's no ConfigMgr association.
|
||||
- Click any device name for full details: location, users, hardware, AD groups.
|
||||
- **Copy Location Info** builds a ready-to-paste table for facilities or asset tickets.
|
||||
- In articles, `[[Copy:text]]` creates a click-to-copy link, and `[[Yellow:Label]]` adds a badge.
|
||||
@@ -0,0 +1,31 @@
|
||||
[[Yellow:Remediation]] [[Red:Admin Required]]
|
||||
|
||||
# WMI Namespace Fix
|
||||
|
||||
Symptoms: Software Center is blank, the ConfigMgr client shows *Inactive*, or the backup task sequence fails with WMI errors (`0x80041003`, `0x8004100e`).
|
||||
|
||||
## 1. Verify the repository
|
||||
Open an **elevated** PowerShell:
|
||||
```powershell
|
||||
winmgmt /verifyrepository
|
||||
```
|
||||
- `WMI repository is consistent`: skip to step 3.
|
||||
- `inconsistent`: continue.
|
||||
|
||||
## 2. Salvage the repository
|
||||
```powershell
|
||||
winmgmt /salvagerepository
|
||||
```
|
||||
Run `/verifyrepository` again. If it's still inconsistent, as a **last resort**:
|
||||
```powershell
|
||||
winmgmt /resetrepository
|
||||
```
|
||||
|
||||
## 3. Repair the ConfigMgr client
|
||||
```powershell
|
||||
& "$env:WinDir\CCM\ccmrepair.exe"
|
||||
```
|
||||
Wait 10-15 minutes, then confirm **Configuration Manager** in Control Panel lists its actions.
|
||||
|
||||
## 4. Re-check in USMT Dash
|
||||
The client status in the device details should return to *Installed / Active* after the next hardware inventory cycle.
|
||||
@@ -0,0 +1,12 @@
|
||||
[[Gray:Optional]]
|
||||
|
||||
# Zoom
|
||||
|
||||
## Install
|
||||
- Software Center > **Zoom Workplace (64-bit)**.
|
||||
- Users sign in with **SSO** using the company domain `company`.
|
||||
|
||||
## Common Issues
|
||||
- **Camera or microphone not detected**: Windows Settings > Privacy & security > Camera / Microphone > allow desktop apps.
|
||||
- **Outlook plugin**: the Zoom add-in for Outlook is deployed centrally. No local install needed.
|
||||
- **Old version prompts to update**: Software Center installs are updated automatically. Have the user dismiss the in-app prompt.
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,140 @@
|
||||
param(
|
||||
[string]$ComputerName,
|
||||
[string]$ConfigPath,
|
||||
[switch]$Demo
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ComputerName)) { return "[]" }
|
||||
|
||||
$scriptDir = $PSScriptRoot
|
||||
$dataDir = Join-Path $scriptDir "Data"
|
||||
|
||||
if (-not (Test-Path $dataDir)) { New-Item -ItemType Directory -Path $dataDir | Out-Null }
|
||||
$cacheFile = Join-Path $dataDir "Software-Cache.json"
|
||||
$blacklistFile = Join-Path $dataDir "Blacklisted-Apps.txt"
|
||||
|
||||
if (-not (Test-Path $cacheFile)) { Set-Content -Path $cacheFile -Value "{}" -Encoding UTF8 }
|
||||
if (-not (Test-Path $blacklistFile)) { Set-Content -Path $blacklistFile -Value "" -Encoding UTF8 }
|
||||
|
||||
. (Join-Path $scriptDir "Lib/Common.ps1")
|
||||
$config = Import-DashConfig -Path $ConfigPath -Demo:$Demo
|
||||
$cacheHours = $config.Worker.SoftwareCacheHours
|
||||
|
||||
# --- 1. CHECK THE CACHE (24-Hour Timer Only) ---
|
||||
$needsUpdate = $true
|
||||
if (Test-Path $cacheFile) {
|
||||
try {
|
||||
$rawCache = Read-SharedText $cacheFile
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($rawCache)) {
|
||||
$cacheData = $rawCache | ConvertFrom-Json
|
||||
|
||||
$propExists = $false
|
||||
foreach ($p in $cacheData.psobject.properties) {
|
||||
if ($p.Name -eq $ComputerName) { $propExists = $true; break }
|
||||
}
|
||||
|
||||
if ($propExists) {
|
||||
$deviceCache = $cacheData.$ComputerName
|
||||
if ($null -ne $deviceCache.Timestamp -and $null -ne $deviceCache.Data) {
|
||||
$cacheTime = [datetime]$deviceCache.Timestamp
|
||||
|
||||
# Logic Check: Is cache still fresh?
|
||||
if ($cacheTime -gt (Get-Date).AddHours(-1 * $cacheHours)) {
|
||||
$needsUpdate = $false
|
||||
$outData = @($deviceCache.Data)
|
||||
return (ConvertTo-Json -InputObject $outData -Depth 5 -Compress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# If cache is corrupt, proceed to the provider
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $needsUpdate) { return }
|
||||
|
||||
# --- 2. LOAD BLACKLIST ---
|
||||
$blacklist = @()
|
||||
if (Test-Path $blacklistFile) {
|
||||
$blacklist = Get-Content $blacklistFile | Where-Object { $_.Trim() -ne "" } | ForEach-Object { $_.Trim() }
|
||||
}
|
||||
|
||||
# --- 3. PROVIDER QUERY (SCCM SQL in Live mode, simulated fleet in Demo mode) ---
|
||||
$providerName = if ($config.IsDemo) { "Demo" } else { $config.Provider }
|
||||
$v = $false
|
||||
. (Join-Path $scriptDir "Providers/$providerName.ps1")
|
||||
|
||||
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
try {
|
||||
foreach ($row in (Get-InstalledSoftware -ComputerName $ComputerName)) {
|
||||
$appName = "$($row.Name)".Trim()
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($appName) -or $appName -eq "--") { continue }
|
||||
|
||||
$isSpecialty = $true
|
||||
foreach ($badApp in $blacklist) {
|
||||
if ($appName -like $badApp) {
|
||||
$isSpecialty = $false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
$results.Add([PSCustomObject]@{
|
||||
Name = $appName
|
||||
Version = $row.Version
|
||||
Publisher = $row.Publisher
|
||||
Source = $row.Source
|
||||
InstallDate = $row.InstallDate
|
||||
IsSpecialty = $isSpecialty
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
|
||||
# --- 4. ATOMIC CACHE SAVE ---
|
||||
try {
|
||||
$cacheEntry = [PSCustomObject]@{
|
||||
Timestamp = (Get-Date).ToString("o")
|
||||
Data = @($results)
|
||||
}
|
||||
|
||||
for ($i = 0; $i -lt 10; $i++) {
|
||||
try {
|
||||
$fs = [System.IO.File]::Open($cacheFile, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
|
||||
$sr = New-Object System.IO.StreamReader($fs)
|
||||
$rawCache = $sr.ReadToEnd()
|
||||
|
||||
$latestCache = [PSCustomObject]@{}
|
||||
if (-not [string]::IsNullOrWhiteSpace($rawCache)) {
|
||||
try { $latestCache = $rawCache | ConvertFrom-Json } catch {}
|
||||
}
|
||||
|
||||
$propExists = $false
|
||||
foreach ($p in $latestCache.psobject.properties) {
|
||||
if ($p.Name -eq $ComputerName) { $propExists = $true; break }
|
||||
}
|
||||
|
||||
if ($propExists) {
|
||||
$latestCache.$ComputerName = $cacheEntry
|
||||
} else {
|
||||
$latestCache | Add-Member -MemberType NoteProperty -Name $ComputerName -Value $cacheEntry -Force
|
||||
}
|
||||
|
||||
$fs.SetLength(0)
|
||||
$sw = New-Object System.IO.StreamWriter($fs, [System.Text.Encoding]::UTF8)
|
||||
$sw.Write(($latestCache | ConvertTo-Json -Depth 6 -Compress))
|
||||
$sw.Flush()
|
||||
$sw.Close()
|
||||
$sr.Close()
|
||||
$fs.Close()
|
||||
break
|
||||
} catch {
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
$outData = @($results)
|
||||
return (ConvertTo-Json -InputObject $outData -Depth 5 -Compress)
|
||||
@@ -0,0 +1,129 @@
|
||||
# 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() }
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
# =========================================================================================
|
||||
# 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
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
# =========================================================================================
|
||||
# SCCM / ConfigMgr provider
|
||||
#
|
||||
# Talks to real infrastructure: the ConfigMgr PowerShell module (computer associations),
|
||||
# the site database over SQL (inventory + installed software), Active Directory (OU and
|
||||
# group membership), DNS/ICMP (online checks) and the devices' admin shares over SMB
|
||||
# (free disk space and USMT progress logs).
|
||||
#
|
||||
# Requirements: Windows PowerShell 5.1, the ConfigMgr console (ConfigurationManager
|
||||
# module), read access to the site database, and admin-share access to the devices.
|
||||
#
|
||||
# Every provider implements the same functions:
|
||||
# Initialize-Provider, Open-ProviderCycle, Close-ProviderCycle, pingDevice,
|
||||
# Resolve-DeviceIP, Get-DiskSpace, Get-LogInfo, Get-DeviceAssociations,
|
||||
# Get-DeviceInventory, Get-InstalledSoftware
|
||||
# =========================================================================================
|
||||
|
||||
$script:SccmCfg = $config.SCCM
|
||||
|
||||
function Initialize-Provider {
|
||||
$ModulePath = Join-Path (Split-Path $env:SMS_ADMIN_UI_PATH) "ConfigurationManager.psd1"
|
||||
Import-Module $ModulePath -ErrorAction Stop
|
||||
}
|
||||
|
||||
function Open-ProviderCycle {
|
||||
# Flush DNS cache at the start of every cycle so stale A records and cached
|
||||
# negative (NXDOMAIN) results don't cause false timeouts -- especially for
|
||||
# devices freshly plugged into the network that were looked up before they registered.
|
||||
if ($config.Worker.FlushDnsEachCycle) {
|
||||
try {
|
||||
$null = ipconfig /flushdns
|
||||
if ($v) { Write-Host "DNS cache flushed." -ForegroundColor DarkGray }
|
||||
} catch { Write-Host "DNS flush failed: $($_.Exception.Message)" -ForegroundColor DarkYellow }
|
||||
}
|
||||
|
||||
$SiteCode = $script:SccmCfg.SiteCode
|
||||
$session = @{ Sql = $null; PreviousLocation = Get-Location }
|
||||
|
||||
$session.Sql = New-Object System.Data.SqlClient.SqlConnection("Server=$($script:SccmCfg.SqlServer);Database=$($script:SccmCfg.Database);Integrated Security=True;")
|
||||
try { $session.Sql.Open() } catch { Write-Host "Warning: Could not open Master SQL Connection" -ForegroundColor Red }
|
||||
|
||||
if (-not (Get-PSDrive -Name $SiteCode -PSProvider CMSite -ErrorAction SilentlyContinue)) {
|
||||
if ($v) { Write-Host "Connecting to SCCM Drive ($SiteCode)..." -ForegroundColor DarkGray }
|
||||
New-PSDrive -Name $SiteCode -PSProvider CMSite -Root $script:SccmCfg.ProviderMachineName -Scope Global | Out-Null
|
||||
}
|
||||
Set-Location "$($SiteCode):"
|
||||
return $session
|
||||
}
|
||||
|
||||
function Close-ProviderCycle {
|
||||
param($Session)
|
||||
if ($Session.Sql) {
|
||||
if ($Session.Sql.State -eq 'Open') { $Session.Sql.Close() }
|
||||
$Session.Sql.Dispose()
|
||||
}
|
||||
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 }
|
||||
|
||||
try {
|
||||
# Use Resolve-DnsName instead of GetHostAddressesAsync: the latter reads the
|
||||
# Windows DNS client cache, which can hold STALE A records (wrong IP) long after
|
||||
# DNS itself has the current record. Resolve-DnsName queries more directly and
|
||||
# returns the live record. -DnsOnly skips the local cache; we still take all A records.
|
||||
$allIps = @()
|
||||
try {
|
||||
$dnsRecs = Resolve-DnsName -Name $computer -Type A -DnsOnly -ErrorAction Stop | Where-Object { $_.Type -eq 'A' }
|
||||
$allIps = @($dnsRecs | ForEach-Object { $_.IPAddress } | Select-Object -Unique)
|
||||
} catch {
|
||||
# Resolve-DnsName failed (no record / DNS error) -- fall back to .NET resolver.
|
||||
try {
|
||||
$task = [System.Net.Dns]::GetHostAddressesAsync($computer)
|
||||
if ($task.Wait(1000)) {
|
||||
$allIps = @($task.Result | Where-Object { $_.AddressFamily -eq 'InterNetwork' } | ForEach-Object { $_.IPAddressToString } | Select-Object -Unique)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if ($allIps.Count -eq 0) {
|
||||
if ($v) { Write-Host "Not Found (No IP)." -ForegroundColor Red }
|
||||
return "Not Found"
|
||||
}
|
||||
|
||||
# --- Ping every resolved IP; online if ANY responds ---
|
||||
# Handles duplicate/stale DNS records by testing all candidates.
|
||||
$p = $null
|
||||
$liveIp = $null
|
||||
try {
|
||||
$p = [System.Net.NetworkInformation.Ping]::new()
|
||||
foreach ($ip in $allIps) {
|
||||
foreach ($attempt in 1..2) {
|
||||
$res = $p.Send($ip, 1000)
|
||||
if ($res.Status -eq 'Success') { $liveIp = $ip; break }
|
||||
}
|
||||
if ($liveIp) { break }
|
||||
}
|
||||
} finally {
|
||||
if ($null -ne $p) { $p.Dispose() }
|
||||
}
|
||||
|
||||
if (-not $liveIp) {
|
||||
if ($v) { Write-Host "Timed Out." -ForegroundColor DarkYellow }
|
||||
return "Timed Out"
|
||||
}
|
||||
|
||||
# --- Reverse DNS validation on the IP that actually responded ---
|
||||
# Only flag DNS Issue if reverse DNS SUCCEEDS but names a DIFFERENT machine.
|
||||
# A missing PTR record is common and is NOT an issue -- treat as online.
|
||||
try {
|
||||
$reverseHost = [System.Net.Dns]::GetHostEntry($liveIp).HostName
|
||||
$reverseShort = ($reverseHost -split '\.')[0].ToUpper()
|
||||
if ($reverseShort -ne $computer.ToUpper()) {
|
||||
if ($v) { Write-Host "DNS Issue ($liveIp resolves back to $reverseShort)." -ForegroundColor Red }
|
||||
return "DNS Issue"
|
||||
}
|
||||
} catch {
|
||||
# No PTR record -- not a conflict.
|
||||
}
|
||||
# --------------------------------
|
||||
|
||||
if ($v) { Write-Host "Online" -ForegroundColor Green }
|
||||
return "Online"
|
||||
} catch {
|
||||
if ($v) { Write-Host "Not Found (DNS)." -ForegroundColor Red }
|
||||
return "Not Found"
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-DeviceIP {
|
||||
param([string]$computerName)
|
||||
try {
|
||||
$ipRecord = [System.Net.Dns]::GetHostAddresses($computerName) | Where-Object { $_.AddressFamily -eq 'InterNetwork' } | Select-Object -First 1
|
||||
if ($ipRecord) { return $ipRecord.IPAddressToString }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-DiskSpace {
|
||||
param([string]$computer)
|
||||
if ($v) { Write-Host " > Checking C: Drive Storage (SMB Method)... " -ForegroundColor DarkGray -NoNewline }
|
||||
|
||||
try {
|
||||
# Using SMB (Port 445) completely bypasses WMI/DCOM hangs
|
||||
$fso = New-Object -ComObject Scripting.FileSystemObject
|
||||
$uncPath = "\\$computer\$($script:SccmCfg.AdminShare)"
|
||||
|
||||
# Check if the path exists to avoid a long COM hang
|
||||
if ([System.IO.Directory]::Exists($uncPath)) {
|
||||
$drive = $fso.GetDrive($uncPath)
|
||||
$total = [math]::Round($drive.TotalSize / 1GB, 1)
|
||||
$free = [math]::Round($drive.FreeSpace / 1GB, 1)
|
||||
|
||||
if ($v) { Write-Host "${free}GB Free out of ${total}GB" -ForegroundColor Green }
|
||||
return @{ Total = "$total GB"; Free = "$free GB" }
|
||||
} else {
|
||||
if ($v) { Write-Host "$($script:SccmCfg.AdminShare) Share Unreachable" -ForegroundColor Red }
|
||||
}
|
||||
} catch {
|
||||
if ($v) { Write-Host "SMB Access Error" -ForegroundColor Red }
|
||||
}
|
||||
|
||||
return @{ Total = "N/A"; Free = "N/A" }
|
||||
}
|
||||
|
||||
function Get-LogInfo {
|
||||
param ([string]$computer, [string]$logName)
|
||||
|
||||
$basePaths = @($script:SccmCfg.LogFolders | ForEach-Object { "\\$computer\$($script:SccmCfg.AdminShare)\$_" })
|
||||
$currentDrive = Get-Location
|
||||
Set-Location "C:\"
|
||||
$result = New-LogInfoResult
|
||||
|
||||
if ($v) { Write-Host " > Scraping logs for '$logName'..." -ForegroundColor DarkGray }
|
||||
|
||||
foreach ($folder in $basePaths) {
|
||||
if ($v) { Write-Host " Checking $folder... " -ForegroundColor DarkGray -NoNewline }
|
||||
try {
|
||||
if ([System.IO.Directory]::Exists($folder)) {
|
||||
$files = [System.IO.Directory]::GetFiles($folder, "$logName*")
|
||||
if ($files.Count -gt 0) {
|
||||
$realPath = $files[0]
|
||||
if ($v) { Write-Host "Found!" -ForegroundColor Green }
|
||||
|
||||
$result.LastModified = (Get-Item $realPath).LastWriteTime
|
||||
$content = Get-Content -Path $realPath -Tail 1 -ErrorAction SilentlyContinue
|
||||
if (-not $content) { $content = Get-Content -Path $realPath -Tail 1 -Encoding Unicode -ErrorAction SilentlyContinue }
|
||||
|
||||
if ($content) {
|
||||
Set-LogInfoLine $result $content.Trim()
|
||||
$result.Found = $true; $result.Path = $realPath
|
||||
}
|
||||
break
|
||||
} else { if ($v) { Write-Host "Empty/Not there." -ForegroundColor DarkGray } }
|
||||
} else { if ($v) { Write-Host "Path doesn't exist." -ForegroundColor DarkGray } }
|
||||
} catch {
|
||||
if ($v) { Write-Host "Access Denied / Error." -ForegroundColor Red }
|
||||
$result.Line = "Access Denied"
|
||||
}
|
||||
}
|
||||
Set-Location $currentDrive
|
||||
return $result
|
||||
}
|
||||
|
||||
function Get-DeviceAssociations {
|
||||
param([string]$SourceComputer)
|
||||
return Get-CMComputerAssociation -SourceComputer $SourceComputer -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------------------
|
||||
# Inventory: AD (OU + groups) and the site database (hardware, users, asset-info class)
|
||||
# Returns only the fields it could find; the worker fills the rest with "N/A".
|
||||
# -----------------------------------------------------------------------------------------
|
||||
function Test-SqlName([string]$name) { return ($name -match '^[A-Za-z0-9_]+$') }
|
||||
|
||||
function Read-AssetInventoryRow {
|
||||
# Applies the configured column map + room-type flags from a data reader to $res
|
||||
param($reader, $res)
|
||||
$ai = $script:SccmCfg.AssetInventory
|
||||
foreach ($field in $ai.Columns.Keys) {
|
||||
$val = $reader["AI_$field"]
|
||||
if ($val -isnot [DBNull] -and "$val") { $res[$field] = "$val" }
|
||||
}
|
||||
$rTypes = @()
|
||||
for ($i = 0; $i -lt $ai.RoomTypes.Count; $i++) {
|
||||
if ("$($reader["RT_$i"])" -eq "1") { $rTypes += $ai.RoomTypes[$i].Label }
|
||||
}
|
||||
return $rTypes
|
||||
}
|
||||
|
||||
function Get-AssetInventorySelect {
|
||||
param([string]$alias)
|
||||
$ai = $script:SccmCfg.AssetInventory
|
||||
$parts = @()
|
||||
foreach ($field in $ai.Columns.Keys) {
|
||||
$col = $ai.Columns[$field]
|
||||
if ((Test-SqlName $col) -and (Test-SqlName $field)) { $parts += "$alias.[$col] AS [AI_$field]" }
|
||||
}
|
||||
for ($i = 0; $i -lt $ai.RoomTypes.Count; $i++) {
|
||||
$col = $ai.RoomTypes[$i].Column
|
||||
if (Test-SqlName $col) { $parts += "$alias.[$col] AS [RT_$i]" }
|
||||
}
|
||||
return ($parts -join ", ")
|
||||
}
|
||||
|
||||
function Get-DeviceInventory {
|
||||
param([string]$computerName, $Session)
|
||||
$res = @{}
|
||||
$SqlConn = $Session.Sql
|
||||
|
||||
$searcher = $null
|
||||
try {
|
||||
$searcher = [adsisearcher]"(&(objectCategory=computer)(name=$computerName))"
|
||||
$searcher.PropertiesToLoad.Add("distinguishedname") | Out-Null
|
||||
$searcher.PropertiesToLoad.Add("memberof") | Out-Null
|
||||
$adResult = $searcher.FindOne()
|
||||
|
||||
if ($adResult) {
|
||||
$dn = $adResult.Properties["distinguishedname"][0]
|
||||
if ($dn) {
|
||||
$ouParts = @($dn -split ',' | Where-Object { $_ -match '^OU=' } | ForEach-Object { $_.Substring(3) })
|
||||
[array]::Reverse($ouParts)
|
||||
$res.OU = if ($ouParts.Count -gt 0) { $ouParts -join '/' } else { $dn }
|
||||
}
|
||||
|
||||
$groupDNs = $adResult.Properties["memberof"]
|
||||
if ($groupDNs) {
|
||||
$groups = $groupDNs | ForEach-Object { ($_ -split ',')[0].Substring(3) }
|
||||
$res.Groups = $groups -join "<br>"
|
||||
}
|
||||
}
|
||||
} catch {} finally {
|
||||
if ($null -ne $searcher) { $searcher.Dispose() }
|
||||
}
|
||||
|
||||
$ai = $script:SccmCfg.AssetInventory
|
||||
$useAsset = $ai.Enabled -and (Test-SqlName $ai.View)
|
||||
|
||||
try {
|
||||
if ($SqlConn -and $SqlConn.State -eq 'Open') {
|
||||
$cmd = $SqlConn.CreateCommand()
|
||||
|
||||
$assetSelect = ""
|
||||
$assetJoin = ""
|
||||
if ($useAsset) {
|
||||
$assetSelect = ", ai.ResourceID AS AI_ResourceID, " + (Get-AssetInventorySelect "ai")
|
||||
$assetJoin = "LEFT JOIN $($ai.View) ai ON sys.ResourceID = ai.ResourceID"
|
||||
}
|
||||
|
||||
$cmd.CommandText = @"
|
||||
SELECT TOP 1
|
||||
sys.User_Name0, sys.Client0, sys.Active0, cs.Model0, mem.TotalPhysicalMemory0, mac.MAC_Addresses0, ip.IP_Addresses0
|
||||
$assetSelect
|
||||
FROM v_R_System sys
|
||||
$assetJoin
|
||||
LEFT JOIN v_GS_COMPUTER_SYSTEM cs ON sys.ResourceID = cs.ResourceID
|
||||
LEFT JOIN v_GS_X86_PC_MEMORY mem ON sys.ResourceID = mem.ResourceID
|
||||
LEFT JOIN (SELECT ResourceID, MAX(MAC_Addresses0) as MAC_Addresses0 FROM v_RA_System_MACAddresses GROUP BY ResourceID) mac ON sys.ResourceID = mac.ResourceID
|
||||
LEFT JOIN (SELECT ResourceID, MAX(IP_Addresses0) as IP_Addresses0 FROM v_RA_System_IPAddresses GROUP BY ResourceID) ip ON sys.ResourceID = ip.ResourceID
|
||||
WHERE sys.Name0 = @CompName
|
||||
"@
|
||||
|
||||
$cmd.Parameters.AddWithValue("@CompName", $computerName) | Out-Null
|
||||
$reader = $cmd.ExecuteReader()
|
||||
$assetExists = $false
|
||||
|
||||
if ($reader.Read()) {
|
||||
if ($reader["MAC_Addresses0"] -isnot [DBNull] -and $reader["MAC_Addresses0"]) { $res.MAC = $reader["MAC_Addresses0"] }
|
||||
if ($reader["Model0"] -isnot [DBNull] -and $reader["Model0"]) { $res.Model = $reader["Model0"] }
|
||||
if ($reader["TotalPhysicalMemory0"] -isnot [DBNull] -and $reader["TotalPhysicalMemory0"]) {
|
||||
$res.RAM = "$([math]::Round([long]$reader["TotalPhysicalMemory0"] / 1048576, 1)) GB"
|
||||
}
|
||||
if ($reader["IP_Addresses0"] -isnot [DBNull] -and $reader["IP_Addresses0"]) {
|
||||
$res.InventoryIP = $reader["IP_Addresses0"]
|
||||
}
|
||||
if ($reader["User_Name0"] -isnot [DBNull] -and $reader["User_Name0"]) { $res.LastUser = $reader["User_Name0"] }
|
||||
|
||||
$cStat = if ("$($reader["Client0"])" -eq "1") { "Installed" } else { "Not Installed" }
|
||||
$aStat = if ("$($reader["Active0"])" -eq "1") { "Active" } else { "Inactive" }
|
||||
$res.ClientStatus = "$cStat / $aStat"
|
||||
|
||||
if ($useAsset) {
|
||||
# Check if the asset-info record actually exists
|
||||
if ($reader["AI_ResourceID"] -isnot [DBNull]) { $assetExists = $true }
|
||||
$rTypes = Read-AssetInventoryRow $reader $res
|
||||
$res.RoomType = if ($rTypes.Count -gt 0) { $rTypes -join ", " } else { "Standard" }
|
||||
}
|
||||
}
|
||||
$reader.Dispose()
|
||||
|
||||
# --- Fallback asset view (Only if the primary row was completely missing) ---
|
||||
if ($useAsset -and -not $assetExists -and (Test-SqlName $ai.FallbackView)) {
|
||||
if ($v) { Write-Host " -> Not in $($ai.View), trying $($ai.FallbackView) fallback..." -ForegroundColor DarkYellow }
|
||||
|
||||
$fallbackCmd = $SqlConn.CreateCommand()
|
||||
$fallbackCmd.CommandText = @"
|
||||
SELECT TOP 1 $(Get-AssetInventorySelect "fb")
|
||||
FROM v_R_System sys
|
||||
INNER JOIN $($ai.FallbackView) fb ON sys.ResourceID = fb.ResourceID
|
||||
WHERE sys.Name0 = @CompName
|
||||
"@
|
||||
$fallbackCmd.Parameters.AddWithValue("@CompName", $computerName) | Out-Null
|
||||
$fbReader = $fallbackCmd.ExecuteReader()
|
||||
|
||||
if ($fbReader.Read()) {
|
||||
$rTypes = Read-AssetInventoryRow $fbReader $res
|
||||
if ($rTypes.Count -gt 0) { $res.RoomType = $rTypes -join ", " }
|
||||
}
|
||||
$fbReader.Dispose()
|
||||
$fallbackCmd.Dispose()
|
||||
}
|
||||
# ------------------------------------------
|
||||
|
||||
$cmd.CommandText = "SELECT UniqueUserName FROM v_UserMachineRelationship WHERE MachineResourceName = @CompName2"
|
||||
$cmd.Parameters.AddWithValue("@CompName2", $computerName) | Out-Null
|
||||
$reader2 = $cmd.ExecuteReader()
|
||||
$uda = @()
|
||||
while ($reader2.Read()) {
|
||||
$uName = $reader2["UniqueUserName"].ToString()
|
||||
if ($uName -match '^[^\\]+\\(.*)') { $uName = $Matches[1] } # strip DOMAIN\
|
||||
$uda += $uName
|
||||
}
|
||||
if ($uda.Count -gt 0) { $res.SCCMPrimaryUsers = $uda -join "<br>" }
|
||||
$reader2.Dispose()
|
||||
$cmd.Dispose()
|
||||
}
|
||||
} catch { }
|
||||
|
||||
# --- Physical Disk Query ---
|
||||
try {
|
||||
if ($SqlConn -and $SqlConn.State -eq 'Open') {
|
||||
$diskCmd = $SqlConn.CreateCommand()
|
||||
$diskCmd.CommandText = @"
|
||||
SELECT TOP 1 pd.Model0, pd.SerialNumber0, pd.Size0
|
||||
FROM v_R_System sys
|
||||
LEFT JOIN v_GS_PHYSICAL_DISK pd ON sys.ResourceID = pd.ResourceID
|
||||
WHERE sys.Name0 = @DiskCompName AND pd.MediaType0 = 4
|
||||
ORDER BY pd.Size0 DESC
|
||||
"@
|
||||
$diskCmd.Parameters.AddWithValue("@DiskCompName", $computerName) | Out-Null
|
||||
$diskReader = $diskCmd.ExecuteReader()
|
||||
if ($diskReader.Read()) {
|
||||
$diskModel = if ($diskReader["Model0"] -isnot [DBNull]) { $diskReader["Model0"] } else { "N/A" }
|
||||
$diskSerial = if ($diskReader["SerialNumber0"] -isnot [DBNull]) { $diskReader["SerialNumber0"] } else { "N/A" }
|
||||
$diskSizeGB = if ($diskReader["Size0"] -isnot [DBNull] -and $diskReader["Size0"]) { "$([math]::Round([long]$diskReader["Size0"] / 1GB)) GB" } else { "N/A" }
|
||||
$res.Disk = "Size: $diskSizeGB<br>Model: $diskModel<br>S/N: $diskSerial"
|
||||
}
|
||||
$diskReader.Dispose()
|
||||
$diskCmd.Dispose()
|
||||
}
|
||||
} catch {}
|
||||
# ---------------------------
|
||||
|
||||
return $res
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------------------
|
||||
# Installed software (used by Get-Software.ps1)
|
||||
# -----------------------------------------------------------------------------------------
|
||||
function Get-InstalledSoftware {
|
||||
param([string]$ComputerName)
|
||||
|
||||
$sql = @"
|
||||
SELECT DISTINCT
|
||||
ARPDisplayName0 AS Name,
|
||||
ProductVersion0 AS Version,
|
||||
Publisher0 AS Publisher,
|
||||
InstallSource0 AS Source,
|
||||
InstallDate0 AS InstallDate
|
||||
FROM v_GS_INSTALLED_SOFTWARE SW
|
||||
INNER JOIN v_R_System SYS ON SW.ResourceID = SYS.ResourceID
|
||||
WHERE SYS.Name0 = @CompName
|
||||
AND ARPDisplayName0 IS NOT NULL
|
||||
ORDER BY ARPDisplayName0
|
||||
"@
|
||||
|
||||
$rows = @()
|
||||
$conn = New-Object System.Data.SqlClient.SqlConnection
|
||||
$conn.ConnectionString = "Server=$($script:SccmCfg.SqlServer);Database=$($script:SccmCfg.Database);Integrated Security=True;"
|
||||
$conn.Open()
|
||||
try {
|
||||
$cmd = $conn.CreateCommand()
|
||||
$cmd.CommandText = $sql
|
||||
$cmd.CommandTimeout = 30
|
||||
$cmd.Parameters.AddWithValue("@CompName", $ComputerName) | Out-Null
|
||||
|
||||
$adapter = New-Object System.Data.SqlClient.SqlDataAdapter $cmd
|
||||
$dataset = New-Object System.Data.DataSet
|
||||
$null = $adapter.Fill($dataset)
|
||||
|
||||
if ($dataset.Tables.Count -gt 0) {
|
||||
foreach ($row in $dataset.Tables[0].Rows) {
|
||||
$rows += [PSCustomObject]@{
|
||||
Name = "$($row.Name)"
|
||||
Version = $row.Version
|
||||
Publisher = $row.Publisher
|
||||
Source = $row.Source
|
||||
InstallDate = if ($row.InstallDate -isnot [DBNull]) { $row.InstallDate } else { "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally { $conn.Close() }
|
||||
return $rows
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
# USMT Dash
|
||||
|
||||
A real-time migration dashboard for Windows device-refresh projects. It tracks every old-to-new
|
||||
PC migration driven by ConfigMgr (SCCM) and USMT: who's online, how much disk is free, where
|
||||
each backup and restore is, and which specialty apps still need to go onto the new machine.
|
||||
|
||||
It's written in **PowerShell** end to end: an `HttpListener` web server with a JSON API, a
|
||||
polling worker that queries SCCM's SQL database, Active Directory and the devices themselves,
|
||||
and a single-file HTML/JS front end. It needs no web framework, database server or runtime
|
||||
beyond PowerShell.
|
||||
|
||||
> **Live demo:** the hosted demo runs the real server and worker in a container against a
|
||||
> simulated 200-device fleet. Open the **Terminals** panel at the bottom of the page to watch
|
||||
> them work.
|
||||
|
||||

|
||||
|
||||
## Why it exists
|
||||
|
||||
On a large hardware refresh, a technician checking one device in the ConfigMgr console (the
|
||||
association, whether the backup started, whether the box is even on the network) took about a
|
||||
minute. USMT Dash brings that down to under a second for every device at once, and gives a
|
||||
team a shared view of the whole refresh.
|
||||
|
||||
Built and run during a real enterprise Windows 11 refresh, where the team's daily build rate
|
||||
went from about 10 to 25 devices a day and the project closed out about 1,900 machines against
|
||||
a 1,300-device scope.
|
||||
|
||||
## Features
|
||||
|
||||
- **Tracked Devices**: technicians register devices under tags (a name, a date batch, a
|
||||
department). The worker only sweeps tagged devices. Tags can be flagged *Skip USMT* for
|
||||
inventory-only tracking, and `SOURCE, DEST` lines create manual pairs for hardware exceptions.
|
||||
- **USMT Status**: every migration grouped by state (*Not Started, Backup In Progress, Backup
|
||||
Completed, Restore In Progress, Completed, Not Associated*), with ping, free space and the
|
||||
latest scanstate/loadstate log line. Each column can be sorted, filtered and copied.
|
||||
- **Smart status engine**: combines the ConfigMgr `MigrationStatus` with whether the USMT
|
||||
progress log in `SMSTSLog` is actively being written. Completed pairs are cached and skip
|
||||
network checks on later cycles. A reverted association resets the cache.
|
||||
- **Network checks that tell the truth**: resolves *all* A records, pings each one, and
|
||||
validates reverse DNS. This separates *Online*, *Timed Out*, *Not Found* and *DNS Issue*
|
||||
(a stale record pointing at a different machine).
|
||||
- **Missing software**: compares installed software on source and destination, filters out the
|
||||
baseline image using a pattern list, groups multi-part products into suites, and lists the
|
||||
specialty apps not yet on the new device.
|
||||
- **Device details**: AD OU and groups, hardware, disk, RAM, client health, location,
|
||||
department, users. Shown for both sides of a migration.
|
||||
- **Custom View**: build your own table from any field, with drag-to-reorder, resizable and
|
||||
filterable columns. Saved per browser.
|
||||
- **Workbench**: personal "stacks" of devices for the cart in front of you.
|
||||
- **Knowledge base**: Markdown articles with an editor, image manager, PDF export, badges and
|
||||
click-to-copy links. Renaming an image rewrites every article that links to it. Articles
|
||||
named after an app are linked from that app in the software view.
|
||||
- **Business-hours scheduling**: the worker cycles every few minutes during working hours,
|
||||
sleeps overnight, and makes a midnight "pitstop" for the daily software refresh.
|
||||
*Force Update* and *Force Full Refresh* skip the wait.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||
|
||||
The server and worker never talk to each other directly. `Data\` is the entire interface:
|
||||
the worker writes JSON state files, the server serves them, and the dashboard's "force
|
||||
refresh" buttons drop flag files that the worker watches for.
|
||||
|
||||
| Component | What it does |
|
||||
|---|---|
|
||||
| `USMT-Server.ps1` | `System.Net.HttpListener` server: static files, a JSON API (17 routes), IP allowlist, gzip |
|
||||
| `USMT-Worker.ps1` | Polling loop: builds the working set from the tags, sweeps each device, works out its status, writes `Data\*.json` |
|
||||
| `Get-Software.ps1` | Per-device installed-software lookup with a 24-hour cache (used by the worker and the API) |
|
||||
| `Providers\SCCM.ps1` | Real data source: ConfigMgr module, site SQL database, ADSI, DNS/ICMP, admin shares |
|
||||
| `Providers\Demo.ps1` | Simulated data source used by the demo (see below) |
|
||||
| `Config\usmt-dash.psd1` | Every environment-specific setting |
|
||||
| `web\index.html` | The dashboard (single-file HTML/JS, about 4,500 lines) |
|
||||
| `web\demo\demo.js` | Terminal panel, plus the demo-mode browser sandbox |
|
||||
|
||||
### API
|
||||
|
||||
| Method & path | Purpose |
|
||||
|---|---|
|
||||
| `GET /` and static files | Dashboard, assets, `Data\` state files, `Documentation\` |
|
||||
| `GET /get-config` | Mode flags for the front end |
|
||||
| `GET /get-logs?source=server\|worker&after=<id>` | Tail of the service console output (terminal panel) |
|
||||
| `POST /force-update` · `POST /force-full` | Wake the worker now (full = also refresh software/inventory caches) |
|
||||
| `GET /get-blacklist` | Baseline-app pattern list |
|
||||
| `GET /get-all-tags` · `GET /get-tracked?tech=` · `GET /get-tag-meta?tech=` | Read tracking tags |
|
||||
| `POST /update-tracked` | Set a tag's devices and *Skip USMT* flag |
|
||||
| `GET /get-software?name=` | Installed software for one device |
|
||||
| `GET /get-docs-list` · `POST /save-doc` · `POST /delete-doc` | Knowledge base (delete archives to `Documentation\Archive`) |
|
||||
| `GET /list-images` · `POST /upload-image` · `POST /rename-image` · `POST /delete-image` | Knowledge-base images |
|
||||
|
||||
## Demo mode
|
||||
|
||||
The public demo has to show the whole dashboard working without real infrastructure, and
|
||||
without letting strangers change what other visitors see.
|
||||
|
||||
- **It's the real code.** The container runs the unmodified `USMT-Server.ps1` and
|
||||
`USMT-Worker.ps1` under PowerShell 7 with `-Demo`. The worker loads `Providers\Demo.ps1`
|
||||
instead of `Providers\SCCM.ps1`, and that's the only difference.
|
||||
- **A believable fleet.** The demo provider derives each device's hardware, users, location,
|
||||
department and software from a hash of its name. Each migration moves through every stage
|
||||
on a 24-hour clock, so the dashboard keeps changing. Devices go offline, have stale DNS,
|
||||
run low on disk, and fail backups at realistic rates.
|
||||
- **Read-only API.** In demo mode the server refuses every route that writes (and
|
||||
`get-software`, which takes free-text input) with `403`. The terminal output is the real
|
||||
console of both processes, mirrored to `Data\Logs` by a `Write-Host` proxy.
|
||||
- **Visitor changes stay in their browser.** `web\demo\demo.js` intercepts the dashboard's
|
||||
write requests and keeps the visitor's tags, articles and images in IndexedDB, merged over
|
||||
the server's data. Their actions print in the terminal panel exactly as the services would,
|
||||
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.
|
||||
|
||||
Without `-Demo`, none of this applies: `demo.js` passes every request straight through and
|
||||
the dashboard works against the real backend.
|
||||
|
||||
## Running it
|
||||
|
||||
### Demo (Docker / Coolify)
|
||||
|
||||
```bash
|
||||
docker compose up -d --build # http://localhost:8080
|
||||
```
|
||||
|
||||
On **Coolify**, create a resource from this repository using the *Dockerfile* (or *Docker
|
||||
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. Set `TZ` to show
|
||||
times in your timezone.
|
||||
|
||||
### Demo (local PowerShell 7)
|
||||
|
||||
```powershell
|
||||
pwsh ./Demo/New-DemoData.ps1 -Force
|
||||
pwsh ./USMT-Worker.ps1 -Demo # terminal 1
|
||||
pwsh ./USMT-Server.ps1 -Demo # terminal 2 -> http://localhost:8080
|
||||
```
|
||||
|
||||
### Live, against your environment
|
||||
|
||||
Requirements: Windows PowerShell 5.1, the ConfigMgr console (for the `ConfigurationManager`
|
||||
module), read access to the site database, AD read access, and admin-share access to devices.
|
||||
|
||||
1. Edit `Config\usmt-dash.psd1`: site code, SMS Provider, SQL server and database, and (if
|
||||
you extend hardware inventory with an asset/location class) the view and column map
|
||||
under `AssetInventory`.
|
||||
2. Start the worker, then the server:
|
||||
```powershell
|
||||
.\USMT-Worker.ps1 # add -v for per-device tracing
|
||||
.\USMT-Server.ps1 # binding http://*:8080/ may need: netsh http add urlacl url=http://*:8080/ user=DOMAIN\account
|
||||
```
|
||||
3. Optionally restrict access by listing allowed IPs or hostnames in `Data\Whitelisted-IPs.txt`.
|
||||
4. Put baseline-image apps in `Data\Blacklisted-Apps.txt` so only specialty software shows as
|
||||
missing. See `Demo\Blacklisted-Apps.txt` for the syntax (plain patterns, `Suite = a*, b*`,
|
||||
`Rule: DEVICE* = app*`, `Combo: Label = a* && b*`).
|
||||
|
||||
### Adapting it to a different toolset
|
||||
|
||||
All infrastructure access goes through a provider. To support another platform (Intune, a
|
||||
different CMDB, PDQ, ...), add `Providers\<Name>.ps1`, implement these functions, and set
|
||||
`Provider = '<Name>'` in the config:
|
||||
|
||||
| Function | Returns |
|
||||
|---|---|
|
||||
| `Initialize-Provider` | One-time setup (import modules, ...) |
|
||||
| `Open-ProviderCycle` / `Close-ProviderCycle` | Per-cycle session (connections) |
|
||||
| `pingDevice <name>` | `Online`, `Timed Out`, `Not Found` or `DNS Issue` |
|
||||
| `Resolve-DeviceIP <name>` | Current IP, or `$null` |
|
||||
| `Get-DiskSpace <name>` | `@{ Total = '238 GB'; Free = '80 GB' }` |
|
||||
| `Get-LogInfo <name> <logName>` | Latest USMT progress log line (via the shared parser) |
|
||||
| `Get-DeviceAssociations <name>` | Objects with `SourceComputer`, `DestinationComputer`, `MigrationStatus` (0/1/2) |
|
||||
| `Get-DeviceInventory <name> <session>` | Hashtable of detail fields (OU, Groups, Model, RAM, Location, ...) |
|
||||
| `Get-InstalledSoftware <name>` | Rows of `Name`, `Version`, `Publisher`, `Source`, `InstallDate` |
|
||||
|
||||
`Providers\Demo.ps1` is a complete working example that needs no infrastructure.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
USMT-Server.ps1 web server + JSON API
|
||||
USMT-Worker.ps1 polling worker
|
||||
Get-Software.ps1 per-device software lookup + cache
|
||||
Config/usmt-dash.psd1 configuration
|
||||
Lib/Common.ps1 config loader, console-log mirror, shared I/O
|
||||
Providers/SCCM.ps1 ConfigMgr / SQL / AD / SMB data source
|
||||
Providers/Demo.ps1 simulated fleet
|
||||
Demo/ demo seed script + sample app blacklist
|
||||
Documentation/ knowledge-base articles and images
|
||||
web/ dashboard, terminal panel / demo sandbox, vendored libraries
|
||||
Dockerfile, docker/ container for the hosted demo
|
||||
Data/ runtime state (created on first run, not committed)
|
||||
```
|
||||
|
||||
## Third-party code
|
||||
|
||||
Vendored in `web/vendor/` under their own licenses: [marked](https://github.com/markedjs/marked)
|
||||
(MIT), [highlight.js](https://github.com/highlightjs/highlight.js) (BSD-3-Clause),
|
||||
[html2pdf.js](https://github.com/eKoopmans/html2pdf.js) (MIT).
|
||||
@@ -0,0 +1,517 @@
|
||||
param(
|
||||
[switch]$Demo, # Read-only demo: write routes are refused (the demo front end sandboxes them in the browser)
|
||||
[string]$ConfigPath
|
||||
)
|
||||
|
||||
$basePath = $PSScriptRoot
|
||||
. (Join-Path $basePath "Lib/Common.ps1")
|
||||
$config = Import-DashConfig -Path $ConfigPath -Demo:$Demo
|
||||
|
||||
$port = $config.Server.Port
|
||||
$dataPath = Join-Path $basePath "Data"
|
||||
$docsPath = Join-Path $basePath "Documentation"
|
||||
$webPath = Join-Path $basePath "web"
|
||||
$logsPath = Join-Path $dataPath "Logs"
|
||||
|
||||
if (-not (Test-Path $dataPath)) { New-Item -ItemType Directory -Path $dataPath | Out-Null }
|
||||
if (-not (Test-Path $docsPath)) { New-Item -ItemType Directory -Path $docsPath | Out-Null }
|
||||
$initFiles = @{ "Blacklisted-Apps.txt"=""; "Whitelisted-IPs.txt"=""; "Tracked-Devices.json"="{}"; "Data.json"="[]"; "Worker-State.json"="{}"; "Software-Cache.json"="{}"; "Advanced-Cache.json"="{}"; "Completed.json"="[]" }
|
||||
foreach ($file in $initFiles.Keys) {
|
||||
$fp = Join-Path $dataPath $file
|
||||
if (-not (Test-Path $fp)) { Set-Content -Path $fp -Value $initFiles[$file] -Encoding UTF8 }
|
||||
}
|
||||
|
||||
# State files the browser is allowed to read directly (everything else in Data/ stays private)
|
||||
$publicDataFiles = @("Data.json", "Worker-State.json", "Tracked-Devices.json", "Advanced-Cache.json", "Software-Cache.json", "Completed.json", "Blacklisted-Apps.txt")
|
||||
|
||||
$contentTypes = @{
|
||||
".html" = "text/html; charset=utf-8"; ".js" = "application/javascript; charset=utf-8"; ".css" = "text/css; charset=utf-8"
|
||||
".json" = "application/json; charset=utf-8"; ".md" = "text/markdown; charset=utf-8"; ".txt" = "text/plain; charset=utf-8"
|
||||
".png" = "image/png"; ".jpg" = "image/jpeg"; ".jpeg" = "image/jpeg"; ".gif" = "image/gif"; ".webp" = "image/webp"
|
||||
".svg" = "image/svg+xml"; ".ico" = "image/x-icon"
|
||||
}
|
||||
|
||||
if ($config.Logging.Enabled) {
|
||||
$cmdLine = "PS $basePath> ./USMT-Server.ps1" + $(if ($config.IsDemo) { " -Demo" } else { "" })
|
||||
Initialize-ConsoleLog -Path (Join-Path $logsPath "server.log") -MaxLines $config.Logging.MaxLines -CommandLine $cmdLine
|
||||
}
|
||||
|
||||
# Writes a body, gzip-compressed when the client accepts it and it's worth it (the JSON
|
||||
# caches the dashboard polls are large but compress ~10:1)
|
||||
function Write-ResponseBody {
|
||||
param($request, $response, [byte[]]$bytes, [bool]$compressible)
|
||||
$acceptsGzip = "$($request.Headers["Accept-Encoding"])" -match 'gzip'
|
||||
if ($compressible -and $acceptsGzip -and $bytes.Length -gt 2048) {
|
||||
$ms = New-Object System.IO.MemoryStream
|
||||
$gz = New-Object System.IO.Compression.GZipStream($ms, [System.IO.Compression.CompressionLevel]::Fastest)
|
||||
$gz.Write($bytes, 0, $bytes.Length)
|
||||
$gz.Close()
|
||||
$bytes = $ms.ToArray()
|
||||
$response.Headers.Add("Content-Encoding", "gzip")
|
||||
}
|
||||
$response.Headers.Add("Vary", "Accept-Encoding")
|
||||
$response.ContentLength64 = $bytes.Length
|
||||
$response.OutputStream.Write($bytes, 0, $bytes.Length)
|
||||
}
|
||||
|
||||
function Send-Json {
|
||||
param($response, [string]$json, [int]$status = 200)
|
||||
$buffer = [System.Text.Encoding]::UTF8.GetBytes($json)
|
||||
$response.StatusCode = $status
|
||||
$response.ContentType = "application/json; charset=utf-8"
|
||||
$response.Headers.Add("Cache-Control", "no-store")
|
||||
Write-ResponseBody $request $response $buffer $true
|
||||
$response.Close()
|
||||
}
|
||||
|
||||
function Read-JsonBody {
|
||||
param($request)
|
||||
$reader = New-Object System.IO.StreamReader($request.InputStream, $request.ContentEncoding)
|
||||
return ($reader.ReadToEnd() | ConvertFrom-Json)
|
||||
}
|
||||
|
||||
# Routes that change state on disk. In demo mode the server refuses them outright; the demo
|
||||
# front end never sends them (it handles them in the visitor's browser instead).
|
||||
$writeRoutes = @("force-update", "force-full", "update-tracked", "upload-image", "rename-image", "delete-image", "save-doc", "delete-doc")
|
||||
# get-software takes a visitor-supplied device name and logs it; in demo mode the browser
|
||||
# answers it from the published software cache, so the server never sees arbitrary input.
|
||||
$demoBlockedRoutes = $writeRoutes + @("get-software")
|
||||
|
||||
$listener = New-Object System.Net.HttpListener
|
||||
$listener.Prefixes.Add(($config.Server.Prefix -f $port))
|
||||
|
||||
$listener.Start()
|
||||
|
||||
$hostname = [System.Net.Dns]::GetHostName()
|
||||
Write-Host "Dashboard active locally at http://localhost:$port" -ForegroundColor Gray
|
||||
Write-Host "Network access enabled at http://$($hostname):$port" -ForegroundColor Cyan
|
||||
if ($config.IsDemo) { Write-Host "DEMO MODE: API is read-only. Visitor changes are simulated in each visitor's browser." -ForegroundColor Magenta }
|
||||
Write-Host "Press Ctrl+C to stop the server..."
|
||||
|
||||
while ($listener.IsListening) {
|
||||
$response = $null
|
||||
try {
|
||||
$context = $listener.GetContext()
|
||||
$request = $context.Request
|
||||
$response = $context.Response
|
||||
|
||||
$localPath = [uri]::UnescapeDataString($request.Url.AbsolutePath).TrimStart('/')
|
||||
|
||||
# --- IP Whitelist Check ---
|
||||
$whitelistFile = Join-Path $dataPath "Whitelisted-IPs.txt"
|
||||
$whitelist = @(Get-Content $whitelistFile | Where-Object { $_.Trim() -ne "" } | ForEach-Object { $_.Trim() })
|
||||
if ($whitelist.Count -gt 0) {
|
||||
$clientIP = $request.RemoteEndPoint.Address.ToString()
|
||||
$clientHost = try { [System.Net.Dns]::GetHostEntry($clientIP).HostName } catch { "" }
|
||||
if ($clientIP -notin $whitelist -and $clientHost -notin $whitelist -and ($clientHost -split '\.')[0] -notin ($whitelist | ForEach-Object { $_.ToLower() } )) {
|
||||
Write-Host "Blocked request from $clientIP ($clientHost)" -ForegroundColor Red
|
||||
$response.StatusCode = 403
|
||||
$response.Close()
|
||||
continue
|
||||
}
|
||||
}
|
||||
# --------------------------
|
||||
|
||||
# --- Demo Guard: no route may change server state ---
|
||||
if ($config.IsDemo -and $demoBlockedRoutes -contains $localPath) {
|
||||
Send-Json $response '{"error":"This is a read-only demo. Changes are simulated in your browser."}' 403
|
||||
continue
|
||||
}
|
||||
|
||||
# --- API: Dashboard Config (mode flags for the front end) ---
|
||||
if ($request.HttpMethod -eq "GET" -and $localPath -eq "get-config") {
|
||||
$json = [PSCustomObject]@{
|
||||
Mode = $config.Mode
|
||||
Demo = [bool]$config.IsDemo
|
||||
Terminals = [bool]$config.Logging.Enabled
|
||||
} | ConvertTo-Json -Compress
|
||||
Send-Json $response $json
|
||||
continue
|
||||
}
|
||||
|
||||
# --- API: Console Log Tail (feeds the dashboard terminal panel) ---
|
||||
if ($request.HttpMethod -eq "GET" -and $localPath -eq "get-logs") {
|
||||
$source = $request.QueryString["source"]
|
||||
if ($source -notin @("server", "worker") -or -not $config.Logging.Enabled) { Send-Json $response '{"lines":[]}' 404; continue }
|
||||
|
||||
$after = [long]0
|
||||
[void][long]::TryParse("$($request.QueryString["after"])", [ref]$after)
|
||||
$limit = 400
|
||||
|
||||
$logFile = Join-Path $logsPath "$source.log"
|
||||
$picked = [System.Collections.Generic.List[string]]::new()
|
||||
if (Test-Path $logFile) {
|
||||
$lines = (Read-SharedText $logFile) -split "`n"
|
||||
for ($i = $lines.Count - 1; $i -ge 0 -and $picked.Count -lt $limit; $i--) {
|
||||
$ln = $lines[$i]
|
||||
if ($ln -notmatch '^\{"i":(\d+),') { continue }
|
||||
if ([long]$Matches[1] -le $after) { break }
|
||||
$picked.Add($ln)
|
||||
}
|
||||
$picked.Reverse()
|
||||
}
|
||||
Send-Json $response ('{"lines":[' + ($picked -join ',') + ']}')
|
||||
continue
|
||||
}
|
||||
|
||||
# --- API: Force Worker Update ---
|
||||
if ($request.HttpMethod -eq "POST" -and $localPath -eq "force-update") {
|
||||
New-Item (Join-Path $dataPath "force-update.flag") -ItemType File -Force | Out-Null
|
||||
Write-Host "Received Force Update Command from Dashboard" -ForegroundColor Cyan
|
||||
$response.StatusCode = 200
|
||||
$response.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
if ($request.HttpMethod -eq "POST" -and $localPath -eq "force-full") {
|
||||
New-Item (Join-Path $dataPath "force-full.flag") -ItemType File -Force | Out-Null
|
||||
Write-Host "Received Force FULL Refresh Command from Dashboard" -ForegroundColor Cyan
|
||||
$response.StatusCode = 200
|
||||
$response.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
# --- API: Get Clean Blacklist ---
|
||||
if ($request.HttpMethod -eq "GET" -and $localPath -eq "get-blacklist") {
|
||||
$blacklistFile = Join-Path $dataPath "Blacklisted-Apps.txt"
|
||||
$blacklist = @()
|
||||
if (Test-Path $blacklistFile) {
|
||||
$blacklist = Get-Content $blacklistFile | Where-Object { $_.Trim() -ne "" } | ForEach-Object { $_.Trim() }
|
||||
}
|
||||
|
||||
$json = @($blacklist) | ConvertTo-Json -Compress
|
||||
if (@($blacklist).Count -eq 0) { $json = "[]" } elseif (@($blacklist).Count -eq 1) { $json = "[$json]" }
|
||||
Send-Json $response $json
|
||||
continue
|
||||
}
|
||||
|
||||
$imgDir = Join-Path $docsPath "images"
|
||||
|
||||
# --- API: List Images ---
|
||||
if ($request.HttpMethod -eq "GET" -and $localPath -eq "list-images") {
|
||||
if (-not (Test-Path $imgDir)) { New-Item -ItemType Directory -Path $imgDir | Out-Null }
|
||||
$images = @(Get-ChildItem -Path $imgDir -File | ForEach-Object {
|
||||
@{ Name = $_.Name; Date = $_.LastWriteTime.ToString("o") }
|
||||
})
|
||||
$json = $images | ConvertTo-Json -Compress
|
||||
if ($images.Count -eq 0) { $json = "[]" } elseif ($images.Count -eq 1) { $json = "[$json]" }
|
||||
Send-Json $response $json
|
||||
continue
|
||||
}
|
||||
|
||||
# --- API: Upload Image ---
|
||||
if ($request.HttpMethod -eq "POST" -and $localPath -eq "upload-image") {
|
||||
$payload = Read-JsonBody $request
|
||||
|
||||
if (-not (Test-Path $imgDir)) { New-Item -ItemType Directory -Path $imgDir | Out-Null }
|
||||
|
||||
$safeName = $payload.filename -replace '[<>:"/\\|?*]', ''
|
||||
$filePath = Join-Path $imgDir $safeName
|
||||
|
||||
try {
|
||||
$imageBytes = [Convert]::FromBase64String($payload.data)
|
||||
[System.IO.File]::WriteAllBytes($filePath, $imageBytes)
|
||||
Write-Host "Uploaded Image: $safeName" -ForegroundColor Cyan
|
||||
$response.StatusCode = 200
|
||||
} catch {
|
||||
$response.StatusCode = 500
|
||||
}
|
||||
$response.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
# --- API: Rename Image (With Markdown Crawler) ---
|
||||
if ($request.HttpMethod -eq "POST" -and $localPath -eq "rename-image") {
|
||||
$payload = Read-JsonBody $request
|
||||
|
||||
$oldNameRaw = $payload.oldName
|
||||
$newNameRaw = $payload.newName
|
||||
|
||||
$oldPath = Join-Path $imgDir ($oldNameRaw -replace '[<>:"/\\|?*]', '')
|
||||
$newName = $newNameRaw -replace '[<>:"/\\|?*]', ''
|
||||
|
||||
if (Test-Path $oldPath) {
|
||||
Rename-Item -Path $oldPath -NewName $newName -Force
|
||||
|
||||
# --- Auto-Fix Broken Markdown Links ---
|
||||
$encodedOld = [uri]::EscapeDataString($oldNameRaw)
|
||||
$encodedNew = [uri]::EscapeDataString($newName)
|
||||
|
||||
$mdFiles = Get-ChildItem -Path $docsPath -Filter "*.md" -File
|
||||
foreach ($file in $mdFiles) {
|
||||
$content = Get-Content $file.FullName -Raw
|
||||
$changed = $false
|
||||
|
||||
# Search for un-encoded spaces (e.g., images/My Photo.png)
|
||||
if ($content -match [regex]::Escape("images/" + $oldNameRaw)) {
|
||||
$content = $content -replace [regex]::Escape("images/" + $oldNameRaw), ("images/" + $newName)
|
||||
$changed = $true
|
||||
}
|
||||
# Search for encoded spaces (e.g., images/My%20Photo.png)
|
||||
if ($content -match [regex]::Escape("images/" + $encodedOld)) {
|
||||
$content = $content -replace [regex]::Escape("images/" + $encodedOld), ("images/" + $encodedNew)
|
||||
$changed = $true
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
Set-Content -Path $file.FullName -Value $content -Encoding UTF8
|
||||
Write-Host "Auto-fixed image link in $($file.Name)" -ForegroundColor Magenta
|
||||
}
|
||||
}
|
||||
# --------------------------------------
|
||||
|
||||
$response.StatusCode = 200
|
||||
} else {
|
||||
$response.StatusCode = 404
|
||||
}
|
||||
$response.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
# --- API: Delete Image ---
|
||||
if ($request.HttpMethod -eq "POST" -and $localPath -eq "delete-image") {
|
||||
$payload = Read-JsonBody $request
|
||||
|
||||
$filePath = Join-Path $imgDir ($payload.filename -replace '[<>:"/\\|?*]', '')
|
||||
|
||||
if (Test-Path $filePath) {
|
||||
Remove-Item -Path $filePath -Force
|
||||
Write-Host "Deleted Image: $($payload.filename)" -ForegroundColor Yellow
|
||||
$response.StatusCode = 200
|
||||
} else {
|
||||
$response.StatusCode = 404
|
||||
}
|
||||
$response.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
# --- API: Save Documentation (With Rename Support) ---
|
||||
if ($request.HttpMethod -eq "POST" -and $localPath -eq "save-doc") {
|
||||
$payload = Read-JsonBody $request
|
||||
|
||||
$docName = $payload.title -replace '[<>:"/\\|?*]', ''
|
||||
$originalName = $payload.originalTitle -replace '[<>:"/\\|?*]', ''
|
||||
$content = $payload.content
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($docName)) {
|
||||
# If renaming, delete the old file first
|
||||
if (-not [string]::IsNullOrWhiteSpace($originalName) -and $originalName -ne $docName) {
|
||||
$oldPath = Join-Path $docsPath "$originalName.md"
|
||||
if (Test-Path $oldPath) { Remove-Item -Path $oldPath -Force }
|
||||
}
|
||||
|
||||
$filePath = Join-Path $docsPath "$docName.md"
|
||||
Set-Content -Path $filePath -Value $content -Encoding UTF8
|
||||
Write-Host "Saved documentation: $docName.md" -ForegroundColor Green
|
||||
$response.StatusCode = 200
|
||||
} else {
|
||||
$response.StatusCode = 400
|
||||
}
|
||||
$response.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
# --- API: Archive Documentation ---
|
||||
if ($request.HttpMethod -eq "POST" -and $localPath -eq "delete-doc") {
|
||||
$payload = Read-JsonBody $request
|
||||
|
||||
$docName = $payload.title -replace '[<>:"/\\|?*]', ''
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($docName)) {
|
||||
$filePath = Join-Path $docsPath "$docName.md"
|
||||
$archivePath = Join-Path $docsPath "Archive"
|
||||
|
||||
if (-not (Test-Path $archivePath)) {
|
||||
New-Item -ItemType Directory -Path $archivePath | Out-Null
|
||||
}
|
||||
|
||||
if (Test-Path $filePath) {
|
||||
Move-Item -Path $filePath -Destination (Join-Path $archivePath "$docName.md") -Force
|
||||
Write-Host "Archived documentation: $docName.md" -ForegroundColor Yellow
|
||||
$response.StatusCode = 200
|
||||
} else {
|
||||
$response.StatusCode = 404
|
||||
}
|
||||
} else {
|
||||
$response.StatusCode = 400
|
||||
}
|
||||
$response.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
# --- API: Get Documentation List ---
|
||||
if ($request.HttpMethod -eq "GET" -and $localPath -eq "get-docs-list") {
|
||||
$docs = @()
|
||||
if (Test-Path $docsPath) {
|
||||
$docs = @(Get-ChildItem -Path $docsPath -Filter *.md | Sort-Object Name | Select-Object -ExpandProperty BaseName)
|
||||
}
|
||||
|
||||
$json = $docs | ConvertTo-Json -Compress
|
||||
if ($docs.Count -eq 1) { $json = "[$json]" } elseif ($docs.Count -eq 0) { $json = "[]" }
|
||||
Send-Json $response $json
|
||||
continue
|
||||
}
|
||||
|
||||
# --- API: Get All Available Tags ---
|
||||
if ($request.HttpMethod -eq "GET" -and $localPath -eq "get-all-tags") {
|
||||
$trackedFile = Join-Path $dataPath "Tracked-Devices.json"
|
||||
$tags = @()
|
||||
if (Test-Path $trackedFile) {
|
||||
try {
|
||||
$rawContent = Read-SharedText $trackedFile
|
||||
if (-not [string]::IsNullOrWhiteSpace($rawContent)) {
|
||||
$data = $rawContent | ConvertFrom-Json
|
||||
if ($data) { $tags = @($data.psobject.properties.name) }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
$json = $tags | ConvertTo-Json -Compress
|
||||
if ($tags.Count -eq 1) { $json = "[$json]" } elseif ($tags.Count -eq 0) { $json = "[]" }
|
||||
Send-Json $response $json
|
||||
continue
|
||||
}
|
||||
|
||||
# --- API: Get Tag Metadata (skipUSMT flag) ---
|
||||
if ($request.HttpMethod -eq "GET" -and $localPath -eq "get-tag-meta") {
|
||||
$tech = $request.QueryString["tech"]
|
||||
$trackedFile = Join-Path $dataPath "Tracked-Devices.json"
|
||||
$skipUSMT = $false
|
||||
if (Test-Path $trackedFile) {
|
||||
try {
|
||||
$rawContent = Read-SharedText $trackedFile
|
||||
if (-not [string]::IsNullOrWhiteSpace($rawContent)) {
|
||||
$data = $rawContent | ConvertFrom-Json
|
||||
if ($data -and $data.psobject.properties.name -contains $tech) {
|
||||
$tagEntry = $data.$tech
|
||||
if ($tagEntry -is [System.Management.Automation.PSCustomObject] -and $tagEntry.psobject.properties.name -contains 'skipUSMT') {
|
||||
$skipUSMT = [bool]$tagEntry.skipUSMT
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
Send-Json $response "{""skipUSMT"":$(if($skipUSMT){'true'}else{'false'})}"
|
||||
continue
|
||||
}
|
||||
|
||||
# --- API: Get Tracked Devices for a Tech ---
|
||||
if ($request.HttpMethod -eq "GET" -and $localPath -eq "get-tracked") {
|
||||
$tech = $request.QueryString["tech"]
|
||||
$trackedFile = Join-Path $dataPath "Tracked-Devices.json"
|
||||
$techList = @()
|
||||
if (Test-Path $trackedFile) {
|
||||
try {
|
||||
$rawContent = Read-SharedText $trackedFile
|
||||
if (-not [string]::IsNullOrWhiteSpace($rawContent)) {
|
||||
$data = $rawContent | ConvertFrom-Json
|
||||
if ($data -and $data.psobject.properties.name -contains $tech) {
|
||||
$tagEntry = $data.$tech
|
||||
# Support both new object format and legacy flat array
|
||||
if ($tagEntry -is [System.Management.Automation.PSCustomObject] -and $tagEntry.psobject.properties.name -contains 'devices') {
|
||||
$techList = @($tagEntry.devices)
|
||||
} else {
|
||||
$techList = @($tagEntry)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
$json = $techList | ConvertTo-Json -Compress
|
||||
if ($techList.Count -eq 1) { $json = "[$json]" } elseif ($techList.Count -eq 0) { $json = "[]" }
|
||||
Send-Json $response $json
|
||||
continue
|
||||
}
|
||||
|
||||
# --- API: Update Tracked Devices for a Tech ---
|
||||
if ($request.HttpMethod -eq "POST" -and $localPath -eq "update-tracked") {
|
||||
$payload = Read-JsonBody $request
|
||||
$tech = $payload.tech
|
||||
$devices = $payload.devices
|
||||
$skipUSMT = if ($payload.psobject.properties.name -contains 'skipUSMT') { [bool]$payload.skipUSMT } else { $false }
|
||||
$trackedFile = Join-Path $dataPath "Tracked-Devices.json"
|
||||
$data = $null
|
||||
|
||||
if (Test-Path $trackedFile) {
|
||||
try {
|
||||
$rawContent = Read-SharedText $trackedFile
|
||||
if (-not [string]::IsNullOrWhiteSpace($rawContent)) { $data = $rawContent | ConvertFrom-Json }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (-not $data) { $data = [PSCustomObject]@{} }
|
||||
|
||||
if (-not $devices -or $devices.Count -eq 0) {
|
||||
if ($data.psobject.properties.name -contains $tech) { $data.psobject.properties.Remove($tech) }
|
||||
} else {
|
||||
$tagEntry = [PSCustomObject]@{ devices = $devices; skipUSMT = $skipUSMT }
|
||||
if (-not ($data.psobject.properties.name -contains $tech)) {
|
||||
$data | Add-Member -MemberType NoteProperty -Name $tech -Value $tagEntry
|
||||
} else {
|
||||
$data.$tech = $tagEntry
|
||||
}
|
||||
}
|
||||
$data | ConvertTo-Json -Depth 5 | Out-File $trackedFile -Encoding utf8
|
||||
Write-Host "Updated tracking roster for Tech/Tag: $tech" -ForegroundColor Green
|
||||
$response.StatusCode = 200
|
||||
$response.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
# --- API ROUTE: Get Software ---
|
||||
if ($localPath -eq "get-software") {
|
||||
$targetDevice = $request.QueryString["name"]
|
||||
Write-Host "Fetching Software for: $targetDevice" -ForegroundColor Yellow
|
||||
$softwareJson = & (Join-Path $basePath "Get-Software.ps1") -ComputerName $targetDevice -ConfigPath $ConfigPath
|
||||
|
||||
if ($softwareJson -is [array]) { $softwareJson = $softwareJson[-1] }
|
||||
if (-not $softwareJson) { $softwareJson = "[]" }
|
||||
|
||||
Send-Json $response $softwareJson
|
||||
continue
|
||||
}
|
||||
|
||||
# --- STATIC FILES ---
|
||||
if ([string]::IsNullOrWhiteSpace($localPath)) { $localPath = "index.html" }
|
||||
|
||||
# Data/ state files, Documentation/ articles + images, and web/ assets for everything else
|
||||
if ($publicDataFiles -contains $localPath) {
|
||||
$root = $dataPath
|
||||
$relative = $localPath
|
||||
} elseif ($localPath -like "Documentation/*") {
|
||||
$root = $docsPath
|
||||
$relative = $localPath.Substring("Documentation/".Length)
|
||||
} else {
|
||||
$root = $webPath
|
||||
$relative = $localPath
|
||||
}
|
||||
|
||||
# Resolve and refuse anything that escapes its root (e.g. ../)
|
||||
$file = [System.IO.Path]::GetFullPath((Join-Path $root $relative))
|
||||
$rootFull = [System.IO.Path]::GetFullPath($root).TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar
|
||||
|
||||
if ($file.StartsWith($rootFull) -and (Test-Path -LiteralPath $file -PathType Leaf)) {
|
||||
$fileInfo = Get-Item -LiteralPath $file
|
||||
$fs = New-Object System.IO.FileStream($file, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
|
||||
$br = New-Object System.IO.BinaryReader($fs)
|
||||
$content = $br.ReadBytes($fs.Length)
|
||||
$br.Close()
|
||||
$fs.Close()
|
||||
|
||||
$ext = [System.IO.Path]::GetExtension($file).ToLower()
|
||||
if ($contentTypes.ContainsKey($ext)) { $response.ContentType = $contentTypes[$ext] }
|
||||
else { $response.ContentType = "application/octet-stream" }
|
||||
|
||||
$response.Headers.Add("Last-Modified", $fileInfo.LastWriteTime.ToUniversalTime().ToString("R"))
|
||||
$response.Headers.Add("X-Content-Type-Options", "nosniff")
|
||||
|
||||
Write-ResponseBody $request $response $content ($ext -in @(".html", ".js", ".css", ".json", ".md", ".txt", ".svg"))
|
||||
} else {
|
||||
$response.StatusCode = 404
|
||||
}
|
||||
} catch {
|
||||
# Request paths are never echoed: in demo mode the log is visible to every visitor
|
||||
Write-Host "Request error: $($_.Exception.GetType().Name)" -ForegroundColor DarkRed
|
||||
} finally {
|
||||
if ($response -ne $null) { try { $response.Close() } catch {} }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
param (
|
||||
[switch]$v, # Verbose per-device tracing
|
||||
[switch]$Demo, # Use the simulated fleet (Providers\Demo.ps1)
|
||||
[string]$ConfigPath
|
||||
)
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
$originalLocation = $PSScriptRoot
|
||||
. (Join-Path $originalLocation "Lib/Common.ps1")
|
||||
$config = Import-DashConfig -Path $ConfigPath -Demo:$Demo
|
||||
$providerName = if ($config.IsDemo) { "Demo" } else { $config.Provider }
|
||||
if ($config.IsDemo -and $config.Demo.Verbose) { $v = [switch]$true }
|
||||
|
||||
$dataDir = Join-Path $originalLocation "Data"
|
||||
|
||||
# Ensure Data directory exists
|
||||
if (-not (Test-Path $dataDir)) { New-Item -ItemType Directory -Path $dataDir | Out-Null }
|
||||
|
||||
$trackedPath = Join-Path $dataDir "Tracked-Devices.json"
|
||||
$jsonPath = Join-Path $dataDir "Data.json"
|
||||
$completedPath = Join-Path $dataDir "Completed.json"
|
||||
$statePath = Join-Path $dataDir "Worker-State.json"
|
||||
$softwareCachePath = Join-Path $dataDir "Software-Cache.json"
|
||||
$advancedCachePath = Join-Path $dataDir "Advanced-Cache.json"
|
||||
$getSoftwareScript = Join-Path $originalLocation "Get-Software.ps1"
|
||||
$diskSkipList = @($config.Worker.DiskSkipList) # Devices with broken SMB that hang Directory.Exists
|
||||
$scanLogName = if ($config.SCCM.ScanStateLog) { $config.SCCM.ScanStateLog } else { "scanstateprogress" }
|
||||
$loadLogName = if ($config.SCCM.LoadStateLog) { $config.SCCM.LoadStateLog } else { "loadstateprogress" }
|
||||
|
||||
if ($config.Logging.Enabled) {
|
||||
$cmdLine = "PS $originalLocation> ./USMT-Worker.ps1" + $(if ($config.IsDemo) { " -Demo" } else { "" }) + $(if ($v) { " -v" } else { "" })
|
||||
Initialize-ConsoleLog -Path (Join-Path $dataDir "Logs/worker.log") -MaxLines $config.Logging.MaxLines -CommandLine $cmdLine
|
||||
}
|
||||
|
||||
# Global State
|
||||
$lastSoftwareSweepDate = ""
|
||||
if (Test-Path $softwareCachePath) {
|
||||
$lastSoftwareSweepDate = (Get-Item $softwareCachePath).LastWriteTime.ToString("yyyy-MM-dd")
|
||||
} else {
|
||||
$lastSoftwareSweepDate = (Get-Date).ToString("yyyy-MM-dd")
|
||||
}
|
||||
$globalLastUpdated = "Never"
|
||||
$globalNextRun = "Calculating..."
|
||||
$globalLastDuration = "N/A"
|
||||
|
||||
# --- FUNCTIONS ---
|
||||
function Update-WorkerState {
|
||||
param([string]$status, [int]$current, [int]$total, [string]$duration = "N/A", [string]$nextRun = "Calculating...")
|
||||
try {
|
||||
$json = [PSCustomObject]@{
|
||||
Status = $status
|
||||
Current = $current
|
||||
Total = $total
|
||||
LastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt")
|
||||
NextCycle = $nextRun
|
||||
LastDuration = $duration
|
||||
} | ConvertTo-Json -Compress
|
||||
|
||||
[System.IO.File]::WriteAllText($statePath, $json, [System.Text.Encoding]::UTF8)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function New-LogInfoResult {
|
||||
return @{ Line = "Log Not Found"; Found = $false; IsSuccess = $false; Path = "N/A"; LastModified = $null; Elapsed = "N/A" }
|
||||
}
|
||||
|
||||
# Turns the last line of a USMT progress log into the dashboard's display line.
|
||||
# e.g. "14 Jul 2026, 10:21:34 -05:00, 00:12:44, Successful run"
|
||||
function Set-LogInfoLine {
|
||||
param($result, [string]$rawLine)
|
||||
$prefix = ""
|
||||
if ($rawLine -match '^(\d{1,2}\s+[a-zA-Z]{3}\s+\d{4}),\s+(\d{2}:\d{2}:\d{2})') {
|
||||
try { $prefix = [datetime]::ParseExact("$($Matches[1]) $($Matches[2])", "d MMM yyyy HH:mm:ss", [System.Globalization.CultureInfo]::InvariantCulture).ToString("MMM dd yyyy hh:mm tt ") }
|
||||
catch { $prefix = "$($Matches[1]) $($Matches[2]) " }
|
||||
}
|
||||
if ($rawLine -like "*Successful run*") { $result.Line = "${prefix}Successful Run"; $result.IsSuccess = $true }
|
||||
elseif ($rawLine -match 'errorText, "(.*)"') { $result.Line = "${prefix}Error: $($Matches[1])" }
|
||||
else { $parts = $rawLine -split ",", 5; $result.Line = if ($parts.Count -ge 5) { $prefix + $parts[4].Trim() } else { $prefix + $rawLine } }
|
||||
if ($rawLine -match '^\d{1,2}\s+[a-zA-Z]{3}\s+\d{4},\s+\d{2}:\d{2}:\d{2}\s+[+-]\d{2}:\d{2},\s+(\d{2}:\d{2}:\d{2})') { $result.Elapsed = $Matches[1] }
|
||||
}
|
||||
|
||||
# --- PROVIDER (data source adapter) ---
|
||||
# Supplies: Initialize-Provider, Open-ProviderCycle, Close-ProviderCycle, pingDevice,
|
||||
# Resolve-DeviceIP, Get-DiskSpace, Get-LogInfo, Get-DeviceAssociations, Get-DeviceInventory
|
||||
. (Join-Path $originalLocation "Providers/$providerName.ps1")
|
||||
|
||||
$global:AdvancedCache = @{}
|
||||
$global:AdvancedCacheUpdated = $false
|
||||
$global:ForceFullPull = $false
|
||||
|
||||
if (Test-Path $advancedCachePath) {
|
||||
try {
|
||||
$rawCache = [System.IO.File]::ReadAllText($advancedCachePath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
||||
foreach ($p in $rawCache.psobject.properties) { $global:AdvancedCache[$p.Name] = $p.Value }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function Get-AdvancedData {
|
||||
param ([string]$computerName, [switch]$IsDest, [string]$OnlineStatus, [switch]$ForceUpdate, $Session)
|
||||
|
||||
$res = @{ IP="N/A"; MAC="N/A"; OU="N/A"; Groups="N/A"; Updated="N/A"; Location="N/A"; Vanity="N/A"; Department="N/A"; Building="N/A"; Mount="N/A"; PrimaryUser="N/A"; LastUser="N/A"; SCCMPrimaryUsers="N/A"; Model="N/A"; RAM="N/A"; Disk="N/A"; ClientStatus="N/A"; PhoneExt="N/A"; RoomType="Standard"; InfoBy="N/A"; Notes="N/A" }
|
||||
if ([string]::IsNullOrWhiteSpace($computerName) -or $computerName -eq "N/A") { return $res }
|
||||
|
||||
$needsUpdate = $true
|
||||
if (-not $ForceUpdate -and $global:AdvancedCache.ContainsKey($computerName)) {
|
||||
$needsUpdate = $false
|
||||
$cachedRes = $global:AdvancedCache[$computerName]
|
||||
$cachedKeys = if ($cachedRes -is [hashtable]) { $cachedRes.Keys } else { $cachedRes.psobject.properties.Name }
|
||||
foreach ($key in @($cachedKeys)) { $res[$key] = $cachedRes.$key }
|
||||
}
|
||||
|
||||
# Live IP from DNS for devices that answered the ping (inventory IP is the fallback)
|
||||
$dnsIp = $null
|
||||
if ($OnlineStatus -eq "Online") {
|
||||
$dnsIp = Resolve-DeviceIP $computerName
|
||||
if ($dnsIp) { $res.IP = $dnsIp }
|
||||
}
|
||||
|
||||
if (-not $needsUpdate) { return $res }
|
||||
if ($v) { Write-Host " -> [CACHE MISS / UPDATE] Running AD & SQL lookup for $computerName..." -ForegroundColor DarkYellow }
|
||||
|
||||
$inventory = Get-DeviceInventory $computerName -Session $Session
|
||||
foreach ($key in @($inventory.Keys)) {
|
||||
if ($key -eq "InventoryIP") { continue }
|
||||
if ($inventory[$key]) { $res[$key] = $inventory[$key] }
|
||||
}
|
||||
if ($null -eq $dnsIp -and $inventory.InventoryIP) { $res.IP = $inventory.InventoryIP }
|
||||
|
||||
$global:AdvancedCache[$computerName] = $res
|
||||
$global:AdvancedCacheUpdated = $true
|
||||
return $res
|
||||
}
|
||||
|
||||
function Update-SoftwareCacheEntry {
|
||||
param([string]$ComputerName)
|
||||
& $getSoftwareScript -ComputerName $ComputerName -ConfigPath $ConfigPath -Demo:$config.IsDemo | Out-Null
|
||||
}
|
||||
|
||||
# --- BUSINESS HOURS COOLDOWN & MIDNIGHT PITSTOP ---
|
||||
function Get-NextRunTime {
|
||||
param([datetime]$now)
|
||||
$cycleSeconds = if ($config.IsDemo) { $config.Demo.CycleSeconds } else { $config.Worker.CycleSeconds }
|
||||
$nextRun = $now.AddSeconds($cycleSeconds)
|
||||
|
||||
$bh = $config.Worker.BusinessHours
|
||||
if ($config.IsDemo -or -not $bh.Enabled) { return $nextRun }
|
||||
|
||||
$startTime = [timespan]::Parse($bh.Start)
|
||||
$endTime = [timespan]::Parse($bh.End)
|
||||
$workDays = @($bh.Weekdays | ForEach-Object { [int]$_ })
|
||||
|
||||
$isWorkday = $workDays -contains [int]$nextRun.DayOfWeek
|
||||
if (-not $isWorkday -or $nextRun.TimeOfDay -gt $endTime -or $nextRun.TimeOfDay -lt $startTime) {
|
||||
# Next working morning (today if we're before opening on a workday)
|
||||
$daysToAdd = if (($workDays -contains [int]$now.DayOfWeek) -and $now.TimeOfDay -lt $startTime) { 0 } else { 1 }
|
||||
while (-not ($workDays -contains [int]$now.Date.AddDays($daysToAdd).DayOfWeek) -and $daysToAdd -lt 8) { $daysToAdd++ }
|
||||
|
||||
$morningRun = ($now.Date).AddDays($daysToAdd).Add($startTime)
|
||||
$midnightTonight = ($now.Date).AddDays(1)
|
||||
|
||||
if ($now -lt $midnightTonight -and $midnightTonight -lt $morningRun) {
|
||||
$nextRun = $midnightTonight
|
||||
} else {
|
||||
$nextRun = $morningRun
|
||||
}
|
||||
}
|
||||
return $nextRun
|
||||
}
|
||||
|
||||
# --- INITIAL SETUP ---
|
||||
if ($v) { Write-Host "Verbose Mode Enabled. Detailed logging active." -ForegroundColor Cyan }
|
||||
Initialize-Provider
|
||||
|
||||
Write-Host "Starting USMT Full Monitor (Smart Cache Enabled). Press Ctrl+C to stop." -ForegroundColor Magenta
|
||||
|
||||
if (Test-Path (Join-Path $dataDir "force-update.flag")) { Remove-Item (Join-Path $dataDir "force-update.flag") -Force }
|
||||
if (Test-Path (Join-Path $dataDir "force-full.flag")) { Remove-Item (Join-Path $dataDir "force-full.flag") -Force }
|
||||
|
||||
while ($true) {
|
||||
$cycleStart = Get-Date
|
||||
Write-Host "`n--- Cycle Start: $($cycleStart.ToString('hh:mm:ss tt')) ---" -ForegroundColor White
|
||||
|
||||
Update-WorkerState "Preparing..." 0 0 "N/A" "Calculating..."
|
||||
|
||||
$session = Open-ProviderCycle
|
||||
|
||||
$completedList = @()
|
||||
if (Test-Path $completedPath) {
|
||||
try {
|
||||
$completedList = [System.IO.File]::ReadAllText($completedPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
||||
if ($completedList -isnot [array]) { $completedList = @($completedList) }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
$deviceTags = @{}
|
||||
$manualPairs = @{}
|
||||
|
||||
$skipUSMTTags = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
|
||||
if (Test-Path $trackedPath) {
|
||||
try {
|
||||
$trackingData = [System.IO.File]::ReadAllText($trackedPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
||||
foreach ($techProp in $trackingData.psobject.properties) {
|
||||
$techName = $techProp.Name
|
||||
$tagEntry = $techProp.Value
|
||||
|
||||
# Support both new { devices: [], skipUSMT: bool } and legacy flat array
|
||||
$devList = if ($tagEntry -is [System.Management.Automation.PSCustomObject] -and $tagEntry.psobject.properties.name -contains 'devices') {
|
||||
$tagEntry.devices
|
||||
} else {
|
||||
$tagEntry
|
||||
}
|
||||
|
||||
# Track which tags are marked for USMT skip
|
||||
if ($tagEntry -is [System.Management.Automation.PSCustomObject] -and $tagEntry.psobject.properties.name -contains 'skipUSMT' -and [bool]$tagEntry.skipUSMT) {
|
||||
$null = $skipUSMTTags.Add($techName)
|
||||
}
|
||||
|
||||
foreach ($dev in $devList) {
|
||||
if ([string]::IsNullOrWhiteSpace($dev)) { continue }
|
||||
$upperDev = $dev.Trim().ToUpper()
|
||||
|
||||
# --- NEW: Catch "Source, Dest" Manual Associations ---
|
||||
if ($upperDev -match "^([^,]+)\s*,\s*([^,]+)$") {
|
||||
$srcDev = $matches[1].Trim()
|
||||
$dstDev = $matches[2].Trim()
|
||||
$manualPairs[$srcDev] = $dstDev
|
||||
|
||||
# Ensure BOTH devices get registered for background Software & Advanced Info caching
|
||||
if (-not $deviceTags.ContainsKey($srcDev)) { $deviceTags[$srcDev] = [System.Collections.Generic.List[string]]::new() }
|
||||
if ($deviceTags[$srcDev] -notcontains $techName) { $deviceTags[$srcDev].Add($techName) }
|
||||
|
||||
if (-not $deviceTags.ContainsKey($dstDev)) { $deviceTags[$dstDev] = [System.Collections.Generic.List[string]]::new() }
|
||||
if ($deviceTags[$dstDev] -notcontains $techName) { $deviceTags[$dstDev].Add($techName) }
|
||||
continue
|
||||
}
|
||||
# -----------------------------------------------------
|
||||
|
||||
if (-not $deviceTags.ContainsKey($upperDev)) { $deviceTags[$upperDev] = [System.Collections.Generic.List[string]]::new() }
|
||||
if ($deviceTags[$upperDev] -notcontains $techName) { $deviceTags[$upperDev].Add($techName) }
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
$uniqueDevices = $deviceTags.Keys | Sort-Object
|
||||
$totalDevices = @($uniqueDevices).Count
|
||||
|
||||
Update-WorkerState "Software Check..." 0 $totalDevices "N/A" "Calculating..."
|
||||
Write-Host "`n[Checking Software Cache Engine...]" -ForegroundColor Cyan
|
||||
$runFullSoftwareSweep = $false
|
||||
$currentDate = (Get-Date).ToString("yyyy-MM-dd")
|
||||
|
||||
if ($lastSoftwareSweepDate -ne $currentDate -or $global:ForceFullPull) {
|
||||
if ($global:ForceFullPull) { Write-Host "Dashboard Force FULL Refresh. Triggering global software/advanced update." -ForegroundColor Magenta }
|
||||
else { Write-Host "Midnight passed (or first run). Triggering global software update." -ForegroundColor Magenta }
|
||||
$runFullSoftwareSweep = $true
|
||||
$global:ForceFullPull = $false
|
||||
$lastSoftwareSweepDate = $currentDate
|
||||
}
|
||||
|
||||
$cacheObj = $null
|
||||
if (Test-Path $softwareCachePath) {
|
||||
try { $cacheObj = [System.IO.File]::ReadAllText($softwareCachePath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json } catch {}
|
||||
}
|
||||
$cachedNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
if ($cacheObj) { foreach ($p in $cacheObj.psobject.properties) { $null = $cachedNames.Add($p.Name) } }
|
||||
|
||||
foreach ($dev in $uniqueDevices) {
|
||||
if ($runFullSoftwareSweep -or -not $cachedNames.Contains($dev)) {
|
||||
if ($v) { Write-Host "Fetching background software for $dev..." -ForegroundColor DarkCyan }
|
||||
Update-SoftwareCacheEntry $dev
|
||||
$null = $cachedNames.Add($dev)
|
||||
}
|
||||
}
|
||||
Write-Host "[Software Cache Synchronized]`n" -ForegroundColor Green
|
||||
|
||||
$deviceTable = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$currentDeviceCount = 0
|
||||
|
||||
Write-Host "--- Commencing Data Sweep ($totalDevices Devices) ---" -ForegroundColor White
|
||||
|
||||
foreach ($deviceName in $uniqueDevices) {
|
||||
$currentDeviceCount++
|
||||
Update-WorkerState "Updating Devices..." $currentDeviceCount $totalDevices "N/A" "Calculating..."
|
||||
Write-Host "[$currentDeviceCount/$totalDevices] Processing: $deviceName" -ForegroundColor Yellow
|
||||
|
||||
$tags = $deviceTags[$deviceName] -join ", "
|
||||
if ($v) { Write-Host " -> Tags: [$tags]" -ForegroundColor DarkCyan }
|
||||
|
||||
# --- SKIP USMT: if any of this device's tags are marked skipUSMT ---
|
||||
$deviceTagList = @($deviceTags[$deviceName])
|
||||
$allTagsSkipped = ($deviceTagList.Count -gt 0) -and @($deviceTagList | Where-Object { $skipUSMTTags.Contains($_) }).Count -gt 0
|
||||
if ($allTagsSkipped) {
|
||||
if ($v) { Write-Host " -> [SKIP USMT] Tag marked Skip USMT. Caching Advanced/Software only." -ForegroundColor DarkMagenta }
|
||||
$null = Get-AdvancedData $deviceName -OnlineStatus "Skipped" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
$deviceTable.Add([PSCustomObject]@{
|
||||
"Source Device"=$deviceName; "Destination Device"="N/A";
|
||||
"Tags"=$tags; "Source Total Space"="N/A"; "Source Free Space"="N/A";
|
||||
"USMT Status"="Skipped"; "Backup Status"="Skipped";
|
||||
"Source Online"="Skipped"; "Destination Online"="-";
|
||||
"Source Scanstate Line"="N/A"; "Destination Loadstate Line"="N/A";
|
||||
"Source Scanstate File"="N/A"; "Destination Loadstate File"="N/A"
|
||||
"Source Scanstate Elapsed"="N/A"; "Destination Loadstate Elapsed"="N/A";
|
||||
})
|
||||
continue
|
||||
}
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
if ($manualPairs.ContainsKey($deviceName)) {
|
||||
$dName = $manualPairs[$deviceName]
|
||||
$sName = $deviceName
|
||||
if ($v) { Write-Host " -> [MANUAL MATCH] Hardware exception detected. Bypassing SCCM..." -ForegroundColor DarkYellow }
|
||||
|
||||
$pingSrc = pingDevice $sName
|
||||
$pingDst = pingDevice $dName
|
||||
|
||||
$null = Get-AdvancedData $sName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
$null = Get-AdvancedData $dName -IsDest -OnlineStatus $pingDst -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
|
||||
$deviceTable.Add([PSCustomObject]@{
|
||||
"Source Device"=$sName; "Destination Device"=$dName;
|
||||
"Tags"=$tags; "Source Total Space"="N/A"; "Source Free Space"="N/A";
|
||||
"USMT Status"="N/A"; "Backup Status"="N/A";
|
||||
"Source Online"=$pingSrc; "Destination Online"=$pingDst;
|
||||
"Source Scanstate Line"="N/A"; "Destination Loadstate Line"="N/A";
|
||||
"Source Scanstate File"="N/A"; "Destination Loadstate File"="N/A"
|
||||
"Source Scanstate Elapsed"="N/A"; "Destination Loadstate Elapsed"="N/A";
|
||||
})
|
||||
continue # Move on to the next device
|
||||
}
|
||||
|
||||
# Prevent the destination machine from being swept a second time as a standalone source
|
||||
if ($manualPairs.Values -contains $deviceName -and -not $manualPairs.ContainsKey($deviceName)) {
|
||||
if ($v) { Write-Host " -> [MANUAL DEST] Device handled by pair logic. Skipping independent sweep." -ForegroundColor DarkGray }
|
||||
continue
|
||||
}
|
||||
|
||||
if ($v) { Write-Host " -> Querying SCCM for associations..." -ForegroundColor DarkGray }
|
||||
$associations = Get-DeviceAssociations -SourceComputer $deviceName
|
||||
|
||||
if (-not $associations) {
|
||||
$cached = $completedList | Where-Object { $_."Source Device".ToUpper() -eq $deviceName } | Select-Object -First 1
|
||||
if ($cached) {
|
||||
if ($v) { Write-Host " -> [CACHE HIT] But association was removed from SCCM." -ForegroundColor DarkYellow }
|
||||
|
||||
# Backfill dest software - association is gone so the assoc loop will never fetch it
|
||||
$cachedDest = "$($cached."Destination Device")"
|
||||
if (-not [string]::IsNullOrWhiteSpace($cachedDest) -and $cachedDest -ne "N/A") {
|
||||
if ($runFullSoftwareSweep -or -not $cachedNames.Contains($cachedDest)) {
|
||||
if ($v) { Write-Host " -> [CACHE MISS] Fetching software for Cached Dest $cachedDest..." -ForegroundColor DarkCyan }
|
||||
Update-SoftwareCacheEntry $cachedDest
|
||||
$null = $cachedNames.Add($cachedDest)
|
||||
}
|
||||
}
|
||||
|
||||
$null = Get-AdvancedData $cached."Source Device" -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
$null = Get-AdvancedData $cached."Destination Device" -IsDest -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
|
||||
$deviceTable.Add([PSCustomObject]@{
|
||||
"Source Device"=$cached."Source Device"; "Destination Device"=$cached."Destination Device";
|
||||
"Tags"=$tags; "Source Total Space"="Cached"; "Source Free Space"="Cached";
|
||||
"USMT Status"="Not Associated"; "Backup Status"="Completed";
|
||||
"Source Online"="Cached"; "Destination Online"="Cached";
|
||||
"Source Scanstate Line"="Cached"; "Destination Loadstate Line"="Cached";
|
||||
"Source Scanstate File"="Cached"; "Destination Loadstate File"="Cached"
|
||||
"Source Scanstate Elapsed"="Cached"; "Destination Loadstate Elapsed"="Cached";
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if ($v) { Write-Host " -> No SCCM association found. Sweeping source device anyway..." -ForegroundColor DarkGray }
|
||||
|
||||
$pingSrc = pingDevice $deviceName
|
||||
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null }
|
||||
$srcTotal = "N/A"
|
||||
$srcFree = "N/A"
|
||||
|
||||
if ($pingSrc -eq "Online") {
|
||||
# --- DISK SKIP LIST CHECK (No-Association path) ---
|
||||
if ($diskSkipList -notcontains $deviceName) {
|
||||
$diskInfo = Get-DiskSpace $deviceName
|
||||
$srcTotal = $diskInfo.Total
|
||||
$srcFree = $diskInfo.Free
|
||||
} else {
|
||||
if ($v) { Write-Host " > Disk check skipped for $deviceName (on diskSkipList)" -ForegroundColor DarkYellow }
|
||||
}
|
||||
if ($diskSkipList -notcontains $deviceName -and $srcFree -ne "N/A") { $logSrc = Get-LogInfo $deviceName $scanLogName }
|
||||
}
|
||||
|
||||
$null = Get-AdvancedData $deviceName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
|
||||
$deviceTable.Add([PSCustomObject]@{
|
||||
"Source Device"=$deviceName; "Destination Device"="N/A";
|
||||
"Tags"=$tags; "Source Total Space"=$srcTotal; "Source Free Space"=$srcFree;
|
||||
"USMT Status"="Not Associated"; "Backup Status"="Not Associated";
|
||||
"Source Online"=$pingSrc; "Destination Online"="-";
|
||||
"Source Scanstate Line"=$logSrc.Line; "Destination Loadstate Line"="N/A";
|
||||
"Source Scanstate File"=$logSrc.Path; "Destination Loadstate File"="N/A"
|
||||
"Source Scanstate Elapsed"=$logSrc.Elapsed; "Destination Loadstate Elapsed"="N/A";
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
foreach ($assoc in $associations) {
|
||||
$assoc.Get()
|
||||
$dName = if ($assoc.DestinationComputer) { $assoc.DestinationComputer } else { $assoc.RestoreName }
|
||||
$sName = if ($assoc.SourceComputer) { $assoc.SourceComputer } else { $deviceName }
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($dName) -and $dName -ne "N/A") {
|
||||
if ($runFullSoftwareSweep -or -not $cachedNames.Contains($dName)) {
|
||||
if ($v) { Write-Host " -> [CACHE MISS] Fetching software for Dest Device $dName..." -ForegroundColor DarkCyan }
|
||||
Update-SoftwareCacheEntry $dName
|
||||
$null = $cachedNames.Add($dName)
|
||||
}
|
||||
}
|
||||
|
||||
$usmtStatusRaw = $assoc.MigrationStatus
|
||||
$usmtStatusStr = switch ($usmtStatusRaw) { 0 {"Not Started"} 1 {"In Progress"} 2 {"Completed"} default {"Unknown"} }
|
||||
|
||||
if ($v) { Write-Host " -> SCCM DB Status: $usmtStatusStr (Raw: $usmtStatusRaw)" -ForegroundColor DarkGray }
|
||||
|
||||
$cached = $completedList | Where-Object { $_."Source Device".ToUpper() -eq $sName.ToUpper() -and $_."Destination Device".ToUpper() -eq $dName.ToUpper() }
|
||||
|
||||
if ($cached -and $usmtStatusRaw -eq 0) {
|
||||
if ($v) { Write-Host " -> [CACHE RESET] SCCM Status reverted to Not Started. Resetting..." -ForegroundColor DarkYellow }
|
||||
$completedList = @($completedList | Where-Object { $_."Source Device".ToUpper() -ne $sName.ToUpper() -or $_."Destination Device".ToUpper() -ne $dName.ToUpper() })
|
||||
$jsonCompleted = ConvertTo-Json -InputObject @($completedList) -Depth 5
|
||||
[System.IO.File]::WriteAllText($completedPath, $jsonCompleted, [System.Text.Encoding]::UTF8)
|
||||
$cached = $null
|
||||
}
|
||||
|
||||
if ($cached) {
|
||||
if ($v) { Write-Host " -> [CACHE HIT] Fast-tracking row and bypassing network sweeps." -ForegroundColor Green }
|
||||
$null = Get-AdvancedData $sName -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
$null = Get-AdvancedData $dName -IsDest -OnlineStatus "Cached" -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
|
||||
$deviceTable.Add([PSCustomObject]@{
|
||||
"Source Device"=$sName; "Destination Device"=$dName;
|
||||
"Tags"=$tags; "Source Total Space"="Cached"; "Source Free Space"="Cached";
|
||||
"USMT Status"=$usmtStatusStr; "Backup Status"="Completed";
|
||||
"Source Online"="Cached"; "Destination Online"="Cached";
|
||||
"Source Scanstate Line"="Cached"; "Destination Loadstate Line"="Cached";
|
||||
"Source Scanstate File"="Cached"; "Destination Loadstate File"="Cached"
|
||||
"Source Scanstate Elapsed"="Cached"; "Destination Loadstate Elapsed"="Cached";
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
$row = [ordered]@{
|
||||
"Source Device"=$sName; "Destination Device"=$dName; "Tags"=$tags;
|
||||
"Source Total Space"="N/A"; "Source Free Space"="N/A";
|
||||
"USMT Status"=$usmtStatusStr; "Backup Status"="Checking...";
|
||||
"Source Online"="N/A"; "Destination Online"="N/A";
|
||||
"Source Scanstate Line"="N/A"; "Destination Loadstate Line"="N/A";
|
||||
"Source Scanstate File"="N/A"; "Destination Loadstate File"="N/A"
|
||||
"Source Scanstate Elapsed"="N/A"; "Destination Loadstate Elapsed"="N/A";
|
||||
}
|
||||
|
||||
if ($v) { Write-Host " -> [SWEEP SOURCE] $sName" -ForegroundColor DarkCyan }
|
||||
$pingSrc = pingDevice $sName
|
||||
$row."Source Online" = $pingSrc
|
||||
$logSrc = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingSrc)"; Path = "Unreachable"; LastModified = $null }
|
||||
|
||||
if ($pingSrc -eq "Online") {
|
||||
# --- DISK SKIP LIST CHECK (Association path) ---
|
||||
if ($diskSkipList -notcontains $sName) {
|
||||
$diskInfo = Get-DiskSpace $sName
|
||||
$row."Source Total Space" = $diskInfo.Total
|
||||
$row."Source Free Space" = $diskInfo.Free
|
||||
} else {
|
||||
if ($v) { Write-Host " > Disk check skipped for $sName (on diskSkipList)" -ForegroundColor DarkYellow }
|
||||
}
|
||||
if ($diskSkipList -notcontains $sName -and $row."Source Free Space" -ne "N/A") { $logSrc = Get-LogInfo $sName $scanLogName }
|
||||
}
|
||||
$row."Source Scanstate Line" = $logSrc.Line
|
||||
$row."Source Scanstate File" = $logSrc.Path
|
||||
$row."Source Scanstate Elapsed" = $logSrc.Elapsed
|
||||
|
||||
if ($v) { Write-Host " -> [SWEEP DESTINATION] $dName" -ForegroundColor DarkCyan }
|
||||
$pingDst = pingDevice $dName
|
||||
$row."Destination Online" = $pingDst
|
||||
$logDst = @{ IsSuccess = $false; Found = $false; Line = "Unreachable ($pingDst)"; Path = "Unreachable"; LastModified = $null }
|
||||
|
||||
if ($pingDst -eq "Online") { $logDst = Get-LogInfo $dName $loadLogName }
|
||||
$row."Destination Loadstate Line" = $logDst.Line
|
||||
$row."Destination Loadstate File" = $logDst.Path
|
||||
$row."Destination Loadstate Elapsed" = $logDst.Elapsed
|
||||
|
||||
if ($v) { Write-Host " -> Calculating Master Status..." -ForegroundColor DarkGray }
|
||||
$timeThreshold = (Get-Date).AddHours(-1 * $config.Worker.ActiveLogHours)
|
||||
|
||||
$isSrcLogActive = ($logSrc.Found -and $logSrc.Path -match "SMSTSLog" -and $logSrc.LastModified -gt $timeThreshold)
|
||||
$isDstLogActive = ($logDst.Found -and $logDst.Path -match "SMSTSLog" -and $logDst.LastModified -gt $timeThreshold)
|
||||
|
||||
if ($logDst.IsSuccess -or $usmtStatusRaw -eq 2) { $row."Backup Status" = "Completed" }
|
||||
elseif ($usmtStatusRaw -eq 1 -and $isDstLogActive) { $row."Backup Status" = "Restore In Progress" }
|
||||
elseif ($usmtStatusRaw -eq 1) { $row."Backup Status" = "Backup Completed" }
|
||||
elseif ($usmtStatusRaw -eq 0 -and $isSrcLogActive) { $row."Backup Status" = "Backup In Progress" }
|
||||
else { $row."Backup Status" = "Not Started" }
|
||||
|
||||
if ($v) { Write-Host " -> Fetching Advanced SQL/AD Data..." -ForegroundColor DarkGray }
|
||||
$null = Get-AdvancedData $sName -OnlineStatus $pingSrc -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
$null = Get-AdvancedData $dName -IsDest -OnlineStatus $pingDst -ForceUpdate:$runFullSoftwareSweep -Session $session
|
||||
|
||||
$finalRow = [PSCustomObject]$row
|
||||
Write-Host " - Status: $($finalRow."Backup Status")" -ForegroundColor Cyan
|
||||
$deviceTable.Add($finalRow)
|
||||
|
||||
if ($finalRow."Backup Status" -eq "Completed") {
|
||||
$completedList += [PSCustomObject]@{ "Source Device"=$sName; "Destination Device"=$dName }
|
||||
$jsonCompleted = ConvertTo-Json -InputObject @($completedList) -Depth 5
|
||||
[System.IO.File]::WriteAllText($completedPath, $jsonCompleted, [System.Text.Encoding]::UTF8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Close-ProviderCycle $session
|
||||
|
||||
if ($v) { Write-Host " -> Pruning and Compressing Software Cache..." -ForegroundColor DarkGray }
|
||||
if (Test-Path $softwareCachePath) {
|
||||
try {
|
||||
$rawCache = [System.IO.File]::ReadAllText($softwareCachePath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
||||
$activeDevs = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($row in $deviceTable) {
|
||||
if ($row."Source Device" -and $row."Source Device" -ne "N/A") { $null = $activeDevs.Add($row."Source Device") }
|
||||
if ($row."Destination Device" -and $row."Destination Device" -ne "N/A") { $null = $activeDevs.Add($row."Destination Device") }
|
||||
}
|
||||
|
||||
$pruned = $false
|
||||
foreach ($p in @($rawCache.psobject.properties.Name)) {
|
||||
if (-not $activeDevs.Contains($p)) {
|
||||
$rawCache.psobject.properties.Remove($p)
|
||||
$pruned = $true
|
||||
}
|
||||
}
|
||||
|
||||
$jsonSoftCache = $rawCache | ConvertTo-Json -Depth 6 -Compress
|
||||
[System.IO.File]::WriteAllText($softwareCachePath, $jsonSoftCache, [System.Text.Encoding]::UTF8)
|
||||
if ($pruned -and $v) { Write-Host " -> Removed untracked devices from cache." -ForegroundColor Green }
|
||||
|
||||
if ($global:AdvancedCacheUpdated -or $pruned -or $runFullSoftwareSweep) {
|
||||
if ($v) { Write-Host " -> Pruning and Saving Advanced Info Cache..." -ForegroundColor DarkGray }
|
||||
$keysToRemove = @()
|
||||
foreach ($key in $global:AdvancedCache.Keys) {
|
||||
if (-not $activeDevs.Contains($key)) { $keysToRemove += $key }
|
||||
}
|
||||
foreach ($key in $keysToRemove) { $global:AdvancedCache.Remove($key) }
|
||||
|
||||
$jsonAdvCache = $global:AdvancedCache | ConvertTo-Json -Depth 5 -Compress
|
||||
[System.IO.File]::WriteAllText($advancedCachePath, $jsonAdvCache, [System.Text.Encoding]::UTF8)
|
||||
$global:AdvancedCacheUpdated = $false
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if ($v) { Write-Host " -> Writing payload to Data.json..." -ForegroundColor DarkGray }
|
||||
$globalLastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt")
|
||||
$exportPayload = [PSCustomObject]@{ LastUpdated = $globalLastUpdated; Devices = $deviceTable }
|
||||
|
||||
$jsonFinal = $exportPayload | ConvertTo-Json -Depth 6
|
||||
[System.IO.File]::WriteAllText($jsonPath, $jsonFinal, [System.Text.Encoding]::UTF8)
|
||||
|
||||
$cycleEnd = Get-Date
|
||||
$cycleTime = $cycleEnd - $cycleStart
|
||||
$globalLastDuration = if ($cycleTime.TotalMinutes -ge 1) { "$([math]::Round($cycleTime.TotalMinutes, 1))m" } else { "$([math]::Round($cycleTime.TotalSeconds, 1))s" }
|
||||
|
||||
Write-Host "`nCycle Complete in $globalLastDuration." -ForegroundColor Gray
|
||||
|
||||
$nextRun = Get-NextRunTime (Get-Date)
|
||||
$nextRunStr = $nextRun.ToString("MM/dd/yy hh:mm tt")
|
||||
|
||||
Update-WorkerState "Cooldown" $totalDevices $totalDevices $globalLastDuration $nextRunStr
|
||||
Write-Host "Next Cycle Scheduled For: $nextRunStr" -ForegroundColor DarkGray
|
||||
|
||||
while ((Get-Date) -lt $nextRun) {
|
||||
if (Test-Path (Join-Path $dataDir "force-full.flag")) {
|
||||
Remove-Item (Join-Path $dataDir "force-full.flag") -Force
|
||||
$global:ForceFullPull = $true
|
||||
Write-Host "`n[!] Force FULL Refresh triggered by Dashboard!" -ForegroundColor Cyan
|
||||
break
|
||||
}
|
||||
if (Test-Path (Join-Path $dataDir "force-update.flag")) {
|
||||
Remove-Item (Join-Path $dataDir "force-update.flag") -Force
|
||||
Write-Host "`n[!] Force Update triggered by Dashboard!" -ForegroundColor Cyan
|
||||
break
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
Write-Host "`n"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Coolify: create a "Docker Compose" resource pointing at this repository (or a
|
||||
# "Dockerfile" resource), and set the domain to port 8080.
|
||||
services:
|
||||
usmt-dash:
|
||||
build: .
|
||||
image: usmt-dash:latest
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- USMT_MODE=Demo
|
||||
- TZ=UTC # set your timezone, e.g. America/New_York
|
||||
ports:
|
||||
- "8080:8080"
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
# Container entrypoint: seed the demo fleet, keep the worker running, run the server.
|
||||
set -e
|
||||
cd /app
|
||||
|
||||
MODE_FLAG=""
|
||||
if [ "${USMT_MODE:-Demo}" = "Demo" ]; then
|
||||
MODE_FLAG="-Demo"
|
||||
# Fresh simulated fleet on every container start
|
||||
pwsh -NoProfile -File Demo/New-DemoData.ps1 -Force
|
||||
fi
|
||||
|
||||
# Worker: restart it if it ever exits
|
||||
(
|
||||
while true; do
|
||||
pwsh -NoProfile -File USMT-Worker.ps1 $MODE_FLAG || true
|
||||
echo "USMT-Worker.ps1 exited; restarting in 5s..." >&2
|
||||
sleep 5
|
||||
done
|
||||
) &
|
||||
|
||||
# Server in the foreground (container stops if it stops)
|
||||
exec pwsh -NoProfile -File USMT-Server.ps1 $MODE_FLAG
|
||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,108 @@
|
||||
/* ---------- Terminal dock (docked to the bottom of the page) ---------- */
|
||||
.usmt-term {
|
||||
--usmt-term-h: 340px;
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 1900;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
box-shadow: 0 -4px 16px rgba(0,0,0,0.35);
|
||||
}
|
||||
body { padding-bottom: 40px; }
|
||||
body.usmt-term-open { padding-bottom: calc(var(--usmt-term-h, 340px) + 72px); }
|
||||
|
||||
.usmt-term-resize { height: 5px; cursor: ns-resize; background: transparent; }
|
||||
.usmt-term.collapsed .usmt-term-resize { display: none; }
|
||||
|
||||
.usmt-term-bar {
|
||||
display: flex; align-items: center; gap: 6px; height: 38px; padding: 0 10px;
|
||||
background: #1f2937; color: #d1d5db; border-top: 1px solid #374151; overflow: hidden;
|
||||
}
|
||||
.usmt-term-toggle, .usmt-term-tab, .usmt-term-btn {
|
||||
background: transparent; border: 1px solid transparent; color: #d1d5db; cursor: pointer;
|
||||
font: inherit; font-size: 0.85em; padding: 4px 10px; border-radius: 4px; white-space: nowrap;
|
||||
}
|
||||
.usmt-term-toggle { font-weight: 600; color: #fff; }
|
||||
.usmt-term-toggle:hover, .usmt-term-tab:hover, .usmt-term-btn:hover { background: rgba(255,255,255,0.08); }
|
||||
.usmt-term-chevron { display: inline-block; font-size: 0.75em; transition: transform 0.2s; }
|
||||
.usmt-term:not(.collapsed) .usmt-term-chevron { transform: rotate(180deg); }
|
||||
.usmt-term-tabs { display: flex; gap: 2px; min-width: 0; }
|
||||
.usmt-term-tab { font-family: Consolas, 'Cascadia Mono', 'Courier New', monospace; position: relative; }
|
||||
.usmt-term-tab.active { background: #012456; border-color: #2b4a7a; color: #fff; }
|
||||
.usmt-term-tab.unread::after {
|
||||
content: ''; position: absolute; top: 4px; right: 3px; width: 6px; height: 6px; border-radius: 50%; background: #16c60c;
|
||||
}
|
||||
.usmt-term-live {
|
||||
margin-left: auto; display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: 0.72em; font-weight: 700; letter-spacing: 1px; color: #86efac; white-space: nowrap;
|
||||
}
|
||||
.usmt-term-live i { width: 7px; height: 7px; border-radius: 50%; background: #16c60c; animation: usmt-pulse 1.6s infinite; }
|
||||
@keyframes usmt-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
|
||||
.usmt-term-legend { font-size: 0.75em; color: #9ca3af; white-space: nowrap; cursor: help; }
|
||||
.usmt-term-follow.active { border-color: #4b5563; color: #fff; }
|
||||
|
||||
.usmt-term-window { height: var(--usmt-term-h); display: flex; flex-direction: column; background: #012456; }
|
||||
.usmt-term.collapsed .usmt-term-window { display: none; }
|
||||
.usmt-term-titlebar {
|
||||
height: 26px; flex: 0 0 26px; display: flex; align-items: center; padding: 0 12px;
|
||||
background: #0c0c0c; color: #cccccc; font-size: 0.78em; border-bottom: 1px solid #1b1b1b;
|
||||
}
|
||||
.usmt-term-screens { flex: 1; min-height: 0; position: relative; }
|
||||
.usmt-term-body {
|
||||
display: none; position: absolute; inset: 0; overflow: auto; padding: 8px 12px 14px 18px;
|
||||
font-family: Consolas, 'Cascadia Mono', 'Courier New', monospace; font-size: 13px; line-height: 1.35;
|
||||
color: #cccccc; background: #012456; white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
.usmt-term-body.active { display: block; }
|
||||
.usmt-term-line { position: relative; min-height: 1.35em; }
|
||||
.usmt-term-line.sim::before {
|
||||
content: '\25CF'; position: absolute; left: -13px; top: 0; font-size: 9px; line-height: 1.8em; color: #f9f1a5; opacity: 0.6;
|
||||
}
|
||||
|
||||
/* PowerShell ConsoleColor -> Windows Terminal "Campbell Powershell" palette */
|
||||
.c-Default, .c-Gray { color: #cccccc; }
|
||||
.c-Prompt { color: #cccccc; }
|
||||
.c-Black { color: #0c0c0c; }
|
||||
.c-DarkBlue { color: #0037da; }
|
||||
.c-DarkGreen { color: #13a10e; }
|
||||
.c-DarkCyan { color: #3a96dd; }
|
||||
.c-DarkRed { color: #c50f1f; }
|
||||
.c-DarkMagenta { color: #b25fc7; }
|
||||
.c-DarkYellow { color: #c19c00; }
|
||||
.c-DarkGray { color: #8a8a8a; }
|
||||
.c-Blue { color: #3b78ff; }
|
||||
.c-Green { color: #16c60c; }
|
||||
.c-Cyan { color: #61d6d6; }
|
||||
.c-Red { color: #e74856; }
|
||||
.c-Magenta { color: #d65fd0; }
|
||||
.c-Yellow { color: #f9f1a5; }
|
||||
.c-White { color: #f2f2f2; }
|
||||
|
||||
/* ---------- Demo pill + about dialog ---------- */
|
||||
.usmt-demo-pill {
|
||||
background: #f59e0b; color: #1f2937; border: none; border-radius: 999px; cursor: pointer;
|
||||
font-weight: 800; font-size: 0.72em; letter-spacing: 1.5px; padding: 5px 10px; flex-shrink: 0;
|
||||
}
|
||||
.usmt-demo-pill:hover { background: #fbbf24; }
|
||||
|
||||
.usmt-demo-modal {
|
||||
display: none; position: fixed; inset: 0; z-index: 3000; background: rgba(0,0,0,0.6);
|
||||
align-items: center; justify-content: center; padding: 16px;
|
||||
}
|
||||
.usmt-demo-modal.show { display: flex; }
|
||||
.usmt-demo-card {
|
||||
background: var(--bg-card, #fff); color: var(--text-main, #333); max-width: 640px; width: 100%;
|
||||
max-height: calc(100vh - 32px); overflow: auto; border-radius: 10px; padding: 26px 28px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.45); border-top: 5px solid #f59e0b; line-height: 1.55;
|
||||
}
|
||||
.usmt-demo-card h2 { margin: 0 0 10px; }
|
||||
.usmt-demo-card p { margin: 0 0 12px; font-size: 0.95em; }
|
||||
.usmt-demo-card code { background: rgba(127,127,127,0.18); padding: 1px 5px; border-radius: 3px; font-size: 0.9em; }
|
||||
.usmt-demo-try { color: var(--text-muted, #666); }
|
||||
.usmt-demo-actions { display: flex; gap: 10px; justify-content: flex-end; flex-wrap: wrap; margin-top: 18px; }
|
||||
.usmt-demo-actions button { border: 1px solid var(--border-color, #ddd); background: transparent; color: inherit; padding: 8px 14px; border-radius: 6px; cursor: pointer; font: inherit; font-size: 0.9em; }
|
||||
.usmt-demo-actions .usmt-demo-close { background: #3498db; border-color: #3498db; color: #fff; font-weight: 600; }
|
||||
.usmt-demo-actions .usmt-demo-reset { color: #e74c3c; border-color: #e74c3c; margin-right: auto; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.usmt-term-legend, .usmt-term-follow { display: none; }
|
||||
.usmt-term-tab { padding: 4px 6px; font-size: 0.75em; }
|
||||
.usmt-term { --usmt-term-h: 45vh; }
|
||||
}
|
||||
@@ -0,0 +1,971 @@
|
||||
/*
|
||||
* USMT Dash - terminal panel + demo sandbox
|
||||
*
|
||||
* Loaded before the dashboard's own script. It does two things:
|
||||
*
|
||||
* 1. Terminal panel (Live and Demo mode). Streams the real console output of
|
||||
* USMT-Server.ps1 and USMT-Worker.ps1 from the server's /get-logs route.
|
||||
*
|
||||
* 2. Demo sandbox (Demo mode only). The public demo's API is read-only, so every request
|
||||
* that would change something (tracking tags, docs, images, force refresh) is handled
|
||||
* here instead, in the visitor's own browser (IndexedDB). Reads are answered from the
|
||||
* real server merged with the visitor's changes, and the terminals show what the real
|
||||
* services would have printed. Nothing a visitor does is ever sent to the server or
|
||||
* seen by anyone else.
|
||||
*
|
||||
* In Live mode window.fetch is passed straight through untouched.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const realFetch = window.fetch.bind(window);
|
||||
const configReady = realFetch('/get-config', { cache: 'no-store' })
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.catch(() => null)
|
||||
.then(c => c || { Mode: 'Live', Demo: false, Terminals: false });
|
||||
|
||||
// =====================================================================================
|
||||
// Helpers
|
||||
// =====================================================================================
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
const clone = o => JSON.parse(JSON.stringify(o));
|
||||
const jsonResponse = (obj, status = 200) => new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
const textResponse = (txt, status = 200, headers = {}) => new Response(txt, { status, headers });
|
||||
const pad2 = n => String(n).padStart(2, '0');
|
||||
|
||||
function psTime(d = new Date()) { // 03:01:43 PM
|
||||
let h = d.getHours(); const ampm = h >= 12 ? 'PM' : 'AM'; h = h % 12 || 12;
|
||||
return `${pad2(h)}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())} ${ampm}`;
|
||||
}
|
||||
function psStamp(d = new Date()) { // 10/06/26 03:01 PM
|
||||
let h = d.getHours(); const ampm = h >= 12 ? 'PM' : 'AM'; h = h % 12 || 12;
|
||||
return `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}/${String(d.getFullYear()).slice(-2)} ${pad2(h)}:${pad2(d.getMinutes())} ${ampm}`;
|
||||
}
|
||||
|
||||
// Small deterministic hash (FNV-1a + murmur finalizer) for visitor-added devices
|
||||
function hash(str) {
|
||||
let h = 0x811c9dc5;
|
||||
for (const ch of String(str).toUpperCase()) { h ^= ch.charCodeAt(0); h = Math.imul(h, 0x01000193); }
|
||||
h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); h ^= h >>> 16;
|
||||
return h >>> 0;
|
||||
}
|
||||
const rand = (name, salt) => hash(name + '|' + salt) / 4294967296;
|
||||
const ALPHA = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
const serial = seed => Array.from({ length: 8 }, (_, i) => ALPHA[Math.floor(rand(seed, 's' + i) * ALPHA.length)]).join('');
|
||||
const hex = (seed, n) => Array.from({ length: n }, (_, i) => Math.floor(rand(seed, 'h' + i) * 256).toString(16).toUpperCase().padStart(2, '0'));
|
||||
|
||||
// =====================================================================================
|
||||
// Terminal panel
|
||||
// =====================================================================================
|
||||
const TERMINALS = [
|
||||
{ id: 'server', title: 'USMT-Server.ps1' },
|
||||
{ id: 'worker', title: 'USMT-Worker.ps1' }
|
||||
];
|
||||
const MAX_LINES = 2500;
|
||||
|
||||
const Term = {
|
||||
el: null, open: false, active: 'worker', demo: false,
|
||||
state: {}, // id -> { lastId, body, hold, held[], unread }
|
||||
timer: null,
|
||||
|
||||
init(demo) {
|
||||
this.demo = demo;
|
||||
TERMINALS.forEach(t => { this.state[t.id] = { lastId: 0, body: null, hold: false, held: [], loaded: false }; });
|
||||
this.build();
|
||||
try { if (localStorage.getItem('usmt_term_open') === '1') this.toggle(true); } catch (e) {}
|
||||
try { const tab = localStorage.getItem('usmt_term_tab'); if (tab && this.state[tab]) this.select(tab); } catch (e) {}
|
||||
this.loop();
|
||||
},
|
||||
|
||||
build() {
|
||||
const dock = document.createElement('div');
|
||||
dock.id = 'usmt-term';
|
||||
dock.className = 'usmt-term collapsed';
|
||||
dock.innerHTML = `
|
||||
<div class="usmt-term-resize" title="Drag to resize"></div>
|
||||
<div class="usmt-term-bar">
|
||||
<button class="usmt-term-toggle" type="button" title="Show / hide terminals">
|
||||
<span class="usmt-term-chevron">▲</span> Terminals
|
||||
</button>
|
||||
<div class="usmt-term-tabs"></div>
|
||||
<span class="usmt-term-live" title="Streaming the real PowerShell console output from the server"><i></i>LIVE</span>
|
||||
${this.demo ? '<span class="usmt-term-legend" title="Lines marked with a dot were produced by your own actions. They are simulated in your browser and never reach the real services.">● your actions (simulated)</span>' : ''}
|
||||
<button class="usmt-term-btn usmt-term-follow active" type="button" title="Auto-scroll">Follow</button>
|
||||
</div>
|
||||
<div class="usmt-term-window">
|
||||
<div class="usmt-term-titlebar"><span class="usmt-term-wtitle"></span></div>
|
||||
<div class="usmt-term-screens"></div>
|
||||
</div>`;
|
||||
document.body.appendChild(dock);
|
||||
this.el = dock;
|
||||
|
||||
const tabs = dock.querySelector('.usmt-term-tabs');
|
||||
const screens = dock.querySelector('.usmt-term-screens');
|
||||
TERMINALS.forEach(t => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'usmt-term-tab'; b.dataset.id = t.id; b.textContent = t.title;
|
||||
b.onclick = () => { this.select(t.id); if (!this.open) this.toggle(true); };
|
||||
tabs.appendChild(b);
|
||||
const body = document.createElement('div');
|
||||
body.className = 'usmt-term-body'; body.dataset.id = t.id;
|
||||
screens.appendChild(body);
|
||||
this.state[t.id].body = body;
|
||||
});
|
||||
dock.querySelector('.usmt-term-toggle').onclick = () => this.toggle();
|
||||
const follow = dock.querySelector('.usmt-term-follow');
|
||||
follow.onclick = () => { follow.classList.toggle('active'); this.scroll(true); };
|
||||
this.select(this.active);
|
||||
|
||||
// drag-to-resize
|
||||
const handle = dock.querySelector('.usmt-term-resize');
|
||||
handle.addEventListener('mousedown', e => {
|
||||
e.preventDefault();
|
||||
const startY = e.clientY, startH = dock.querySelector('.usmt-term-window').offsetHeight;
|
||||
const move = ev => {
|
||||
const h = Math.max(140, Math.min(window.innerHeight - 140, startH + (startY - ev.clientY)));
|
||||
dock.style.setProperty('--usmt-term-h', h + 'px');
|
||||
};
|
||||
const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); };
|
||||
document.addEventListener('mousemove', move); document.addEventListener('mouseup', up);
|
||||
});
|
||||
},
|
||||
|
||||
toggle(force) {
|
||||
this.open = force === undefined ? !this.open : force;
|
||||
this.el.classList.toggle('collapsed', !this.open);
|
||||
document.body.classList.toggle('usmt-term-open', this.open);
|
||||
try { localStorage.setItem('usmt_term_open', this.open ? '1' : '0'); } catch (e) {}
|
||||
if (this.open) { this.poll(this.active); this.scroll(true); }
|
||||
},
|
||||
|
||||
select(id) {
|
||||
this.active = id;
|
||||
this.el.querySelectorAll('.usmt-term-tab').forEach(b => b.classList.toggle('active', b.dataset.id === id));
|
||||
this.el.querySelectorAll('.usmt-term-body').forEach(b => b.classList.toggle('active', b.dataset.id === id));
|
||||
const t = TERMINALS.find(x => x.id === id);
|
||||
this.el.querySelector('.usmt-term-wtitle').textContent = (this.demo ? 'Administrator: PowerShell (demo container) - ' : 'Administrator: PowerShell - ') + t.title;
|
||||
this.el.querySelector(`.usmt-term-tab[data-id="${id}"]`).classList.remove('unread');
|
||||
try { localStorage.setItem('usmt_term_tab', id); } catch (e) {}
|
||||
this.poll(id);
|
||||
this.scroll(true);
|
||||
},
|
||||
|
||||
following() { return this.el.querySelector('.usmt-term-follow').classList.contains('active'); },
|
||||
|
||||
scroll(force) {
|
||||
const body = this.state[this.active].body;
|
||||
if (force || this.following()) body.scrollTop = body.scrollHeight;
|
||||
},
|
||||
|
||||
// segs: [[text, ConsoleColor], ...]
|
||||
render(id, lines, simulated) {
|
||||
const st = this.state[id];
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const ln of lines) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'usmt-term-line' + (simulated ? ' sim' : '');
|
||||
if (ln.t) row.title = ln.t;
|
||||
const segs = ln.s || [];
|
||||
if (segs.length === 0) row.innerHTML = ' ';
|
||||
for (const [text, color] of segs) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'c-' + (color || 'Default');
|
||||
span.textContent = text;
|
||||
row.appendChild(span);
|
||||
}
|
||||
frag.appendChild(row);
|
||||
}
|
||||
st.body.appendChild(frag);
|
||||
while (st.body.childNodes.length > MAX_LINES) st.body.removeChild(st.body.firstChild);
|
||||
if (id === this.active && this.open) this.scroll();
|
||||
else if (lines.length) { const tab = this.el.querySelector(`.usmt-term-tab[data-id="${id}"]`); if (tab) tab.classList.add('unread'); }
|
||||
},
|
||||
|
||||
// Simulated output from the visitor's own actions (demo mode)
|
||||
write(id, segs) {
|
||||
if (!this.el) return;
|
||||
this.render(id, [{ s: segs, t: new Date().toLocaleTimeString() }], true);
|
||||
},
|
||||
|
||||
// While a simulated worker cycle is printing, real worker lines are held back so the
|
||||
// two don't interleave mid-cycle; they're flushed when the simulated cycle ends.
|
||||
hold(id, on) {
|
||||
const st = this.state[id];
|
||||
if (!st) return;
|
||||
st.hold = on;
|
||||
if (!on && st.held.length) { this.render(id, st.held, false); st.held = []; }
|
||||
},
|
||||
|
||||
async poll(id) {
|
||||
const st = this.state[id];
|
||||
if (st.busy) return;
|
||||
st.busy = true;
|
||||
try {
|
||||
const r = await realFetch(`/get-logs?source=${id}&after=${st.lastId}&t=${Date.now()}`, { cache: 'no-store' });
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
const lines = (data.lines || []).filter(l => l.i > st.lastId);
|
||||
if (!lines.length) return;
|
||||
st.lastId = lines[lines.length - 1].i;
|
||||
if (!st.loaded) {
|
||||
st.loaded = true;
|
||||
if (lines[0].s && lines[0].s[0] && lines[0].s[0][1] !== 'Prompt') {
|
||||
lines.unshift({ s: [['... (earlier output trimmed) ...', 'DarkGray']] });
|
||||
}
|
||||
}
|
||||
if (st.hold) st.held.push(...lines); else this.render(id, lines, false);
|
||||
} catch (e) {
|
||||
} finally { st.busy = false; }
|
||||
},
|
||||
|
||||
async loop() {
|
||||
let tick = 0;
|
||||
for (;;) {
|
||||
if (this.open) {
|
||||
await this.poll(this.active);
|
||||
if (tick % 3 === 0) for (const t of TERMINALS) if (t.id !== this.active) await this.poll(t.id);
|
||||
}
|
||||
tick++;
|
||||
await sleep(this.open ? 1500 : 4000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// =====================================================================================
|
||||
// Demo sandbox: per-visitor state in IndexedDB
|
||||
// =====================================================================================
|
||||
const Store = {
|
||||
db: null,
|
||||
state: null,
|
||||
empty() {
|
||||
return {
|
||||
version: 1,
|
||||
tracked: null, // visitor's copy of Tracked-Devices.json (null = untouched)
|
||||
appliedTracked: null, // roster the (simulated) worker last swept
|
||||
rev: 0, // bumps whenever simulated data changes
|
||||
docs: {}, // title -> { content, modified }
|
||||
deletedDocs: [], // server docs the visitor archived
|
||||
images: {}, // name -> { dataUrl, date } (visitor uploads)
|
||||
renamedImages: {}, // new name -> original server name
|
||||
deletedImages: [] // server image names the visitor deleted
|
||||
};
|
||||
},
|
||||
loading: null,
|
||||
load() {
|
||||
if (!this.loading) this.loading = this.open();
|
||||
return this.loading;
|
||||
},
|
||||
async open() {
|
||||
this.state = this.empty();
|
||||
try {
|
||||
this.db = await new Promise((res, rej) => {
|
||||
const req = indexedDB.open('usmt-dash-demo', 1);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore('kv');
|
||||
req.onsuccess = () => res(req.result);
|
||||
req.onerror = () => rej(req.error);
|
||||
});
|
||||
const saved = await new Promise(res => {
|
||||
const tx = this.db.transaction('kv', 'readonly').objectStore('kv').get('state');
|
||||
tx.onsuccess = () => res(tx.result); tx.onerror = () => res(null);
|
||||
});
|
||||
if (saved && saved.version === 1) this.state = Object.assign(this.empty(), saved);
|
||||
} catch (e) { this.db = null; } // private browsing etc. - keep changes in memory only
|
||||
|
||||
// The demo container reseeds its fleet on restart; a roster copied from an older
|
||||
// seed would reference tags that no longer exist, so drop it (docs/images are kept).
|
||||
if (this.state.tracked) {
|
||||
const base = await serverGet('/Tracked-Devices.json', true);
|
||||
if (base && hash(base) !== this.state.baseHash) { this.state.tracked = null; this.state.appliedTracked = null; this.save(); }
|
||||
}
|
||||
return this.state;
|
||||
},
|
||||
save() {
|
||||
if (!this.db) return;
|
||||
try { this.db.transaction('kv', 'readwrite').objectStore('kv').put(this.state, 'state'); } catch (e) {}
|
||||
},
|
||||
async reset() {
|
||||
this.state = this.empty();
|
||||
if (this.db) await new Promise(res => { const tx = this.db.transaction('kv', 'readwrite'); tx.objectStore('kv').delete('state'); tx.oncomplete = res; tx.onerror = res; });
|
||||
},
|
||||
hasChanges() {
|
||||
const s = this.state;
|
||||
return !!(s && (s.tracked || Object.keys(s.docs).length || s.deletedDocs.length || Object.keys(s.images).length || Object.keys(s.renamedImages).length || s.deletedImages.length));
|
||||
}
|
||||
};
|
||||
|
||||
// Short-lived memo of server reads so one dashboard poll doesn't fetch the same file twice
|
||||
const serverMemo = {};
|
||||
async function serverGet(path, asText) {
|
||||
const key = path + (asText ? '#t' : '');
|
||||
const hit = serverMemo[key];
|
||||
if (hit && Date.now() - hit.at < 2500) return hit.promise;
|
||||
const promise = realFetch(path + (path.includes('?') ? '&' : '?') + 't=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => (r.ok ? (asText ? r.text() : r.json()) : null))
|
||||
.catch(() => null);
|
||||
serverMemo[key] = { at: Date.now(), promise };
|
||||
return promise;
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// Demo sandbox: simulated data for the visitor's roster
|
||||
// =====================================================================================
|
||||
const SCHEME = /^(PRV|PUB)(DT|LT)-[0-9A-Z]{8}$/;
|
||||
|
||||
function parseRoster(tracked) {
|
||||
const tagMap = {}, manualPairs = {}, skipTags = new Set();
|
||||
for (const [tag, entry] of Object.entries(tracked || {})) {
|
||||
const list = Array.isArray(entry) ? entry : (entry.devices || []);
|
||||
if (!Array.isArray(entry) && entry.skipUSMT) skipTags.add(tag);
|
||||
for (const raw of list) {
|
||||
if (!raw || !String(raw).trim()) continue;
|
||||
const d = String(raw).trim().toUpperCase();
|
||||
const m = d.match(/^([^,]+)\s*,\s*([^,]+)$/);
|
||||
const add = n => { (tagMap[n] = tagMap[n] || []).includes(tag) || tagMap[n].push(tag); };
|
||||
if (m) { const s = m[1].trim(), t = m[2].trim(); manualPairs[s] = t; add(s); add(t); }
|
||||
else add(d);
|
||||
}
|
||||
}
|
||||
return { tagMap, manualPairs, skipTags };
|
||||
}
|
||||
|
||||
function blankRow(src, dst, tags) {
|
||||
return {
|
||||
"Source Device": src, "Destination Device": dst, "Tags": tags,
|
||||
"Source Total Space": "N/A", "Source Free Space": "N/A", "USMT Status": "N/A", "Backup Status": "N/A",
|
||||
"Source Online": "N/A", "Destination Online": "N/A",
|
||||
"Source Scanstate Line": "N/A", "Destination Loadstate Line": "N/A",
|
||||
"Source Scanstate File": "N/A", "Destination Loadstate File": "N/A",
|
||||
"Source Scanstate Elapsed": "N/A", "Destination Loadstate Elapsed": "N/A"
|
||||
};
|
||||
}
|
||||
|
||||
// A device the visitor typed that the demo fleet doesn't know: borrow the live status of
|
||||
// a fleet device with the same prefix, so it keeps moving through the stages like the rest.
|
||||
function pickTemplate(name, rows) {
|
||||
const prefix = name.split('-')[0];
|
||||
const pool = rows.filter(r => r["Source Device"].startsWith(prefix + '-') && r["Destination Device"] !== "N/A"
|
||||
&& !["Skipped", "N/A"].includes(r["Backup Status"]) && r["USMT Status"] !== "N/A")
|
||||
.sort((a, b) => a["Source Device"].localeCompare(b["Source Device"]));
|
||||
return pool.length ? pool[hash(name) % pool.length] : null;
|
||||
}
|
||||
const synthDest = name => name.split('-')[0] + '-' + serial(name + '|dest');
|
||||
|
||||
function renameIn(value, from, to) {
|
||||
return typeof value === 'string' ? value.split(from).join(to) : value;
|
||||
}
|
||||
|
||||
function synthAdv(name, templateName, adv) {
|
||||
const base = adv[templateName];
|
||||
if (!base) return null;
|
||||
const a = clone(base);
|
||||
a.MAC = hex(name + 'mac', 6).join(':');
|
||||
if (a.IP && a.IP !== 'N/A') { const p = a.IP.split('.'); a.IP = `${p[0]}.${p[1]}.${10 + Math.floor(rand(name, 'ip3') * 240)}.${10 + Math.floor(rand(name, 'ip4') * 240)}`; }
|
||||
if (a.Disk) a.Disk = a.Disk.replace(/S\/N: [^<]*$/, 'S/N: ' + hex(name + 'sn', 8).join('').replace(/(.{4})/g, '$1_').replace(/_$/, '.'));
|
||||
for (const k of ['LastUser', 'SCCMPrimaryUsers']) if (typeof a[k] === 'string') a[k] = renameIn(a[k], templateName, name).replace(templateName.toLowerCase(), name.toLowerCase());
|
||||
return a;
|
||||
}
|
||||
|
||||
// Builds the Data.json rows the worker would produce for a roster, plus synthetic
|
||||
// advanced/software entries for devices the fleet doesn't know.
|
||||
function buildRows(tracked, serverRows, serverAdv) {
|
||||
const { tagMap, manualPairs, skipTags } = parseRoster(tracked);
|
||||
const bySrc = {};
|
||||
for (const r of serverRows) bySrc[r["Source Device"].toUpperCase()] = r;
|
||||
const manualDests = new Set(Object.values(manualPairs));
|
||||
const out = [], adv = {}, software = {}; // software: name -> template name to copy
|
||||
|
||||
for (const dev of Object.keys(tagMap).sort()) {
|
||||
const tags = tagMap[dev].join(', ');
|
||||
const srow = bySrc[dev];
|
||||
|
||||
if (tagMap[dev].some(t => skipTags.has(t))) {
|
||||
const r = blankRow(dev, "N/A", tags);
|
||||
Object.assign(r, { "USMT Status": "Skipped", "Backup Status": "Skipped", "Source Online": "Skipped", "Destination Online": "-" });
|
||||
out.push(r);
|
||||
if (!serverAdv[dev]) { const t = pickTemplate(dev, serverRows); if (t && SCHEME.test(dev)) { adv[dev] = synthAdv(dev, t["Source Device"], serverAdv); software[dev] = t["Source Device"]; } }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (manualPairs[dev]) {
|
||||
const dst = manualPairs[dev];
|
||||
if (srow && srow["Destination Device"] === dst && srow["USMT Status"] === "N/A") { out.push(Object.assign(clone(srow), { Tags: tags })); continue; }
|
||||
const r = blankRow(dev, dst, tags);
|
||||
for (const [n, col] of [[dev, "Source Online"], [dst, "Destination Online"]]) {
|
||||
const t = SCHEME.test(n) ? pickTemplate(n, serverRows) : null;
|
||||
r[col] = t ? t["Source Online"] : "Not Found";
|
||||
if (t && !serverAdv[n]) { adv[n] = synthAdv(n, t["Source Device"], serverAdv); software[n] = t["Source Device"]; }
|
||||
else if (!t) software[n] = null;
|
||||
}
|
||||
out.push(r);
|
||||
continue;
|
||||
}
|
||||
if (manualDests.has(dev)) 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;
|
||||
if (!t) {
|
||||
// Not in the fleet and not a valid name: what the worker reports for a typo
|
||||
const r = blankRow(dev, "N/A", tags);
|
||||
Object.assign(r, { "USMT Status": "Not Associated", "Backup Status": "Not Associated", "Source Online": "Not Found", "Destination Online": "-",
|
||||
"Source Scanstate Line": "Unreachable (Not Found)", "Source Scanstate File": "Unreachable" });
|
||||
out.push(r);
|
||||
software[dev] = null; // SCCM has no inventory for it: an empty list, same as Get-Software.ps1
|
||||
continue;
|
||||
}
|
||||
const tSrc = t["Source Device"], tDst = t["Destination Device"], dst = synthDest(dev);
|
||||
const r = clone(t);
|
||||
for (const k of Object.keys(r)) r[k] = renameIn(renameIn(r[k], tSrc, dev), tDst, dst);
|
||||
r.Tags = tags;
|
||||
out.push(r);
|
||||
if (!serverAdv[dev]) adv[dev] = synthAdv(dev, tSrc, serverAdv);
|
||||
if (!serverAdv[dst]) adv[dst] = synthAdv(dst, tDst, serverAdv);
|
||||
software[dev] = tSrc; software[dst] = tDst;
|
||||
}
|
||||
return { rows: out, adv, software };
|
||||
}
|
||||
|
||||
async function serverSnapshot() {
|
||||
const [data, adv] = await Promise.all([serverGet('/Data.json'), serverGet('/Advanced-Cache.json')]);
|
||||
const rows = data && Array.isArray(data.Devices) ? data.Devices : (Array.isArray(data) ? data : []);
|
||||
return { data: data || {}, rows, adv: adv || {} };
|
||||
}
|
||||
|
||||
async function visitorTracked() {
|
||||
const s = Store.state;
|
||||
if (s.tracked) return s.tracked;
|
||||
return (await serverGet('/Tracked-Devices.json')) || {};
|
||||
}
|
||||
|
||||
async function ensureOwnRoster() {
|
||||
const s = Store.state;
|
||||
if (!s.tracked) {
|
||||
const text = (await serverGet('/Tracked-Devices.json', true)) || '{}';
|
||||
const base = JSON.parse(text);
|
||||
s.baseHash = hash(text);
|
||||
s.tracked = clone(base);
|
||||
s.appliedTracked = clone(base);
|
||||
}
|
||||
return s.tracked;
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// Demo sandbox: simulated worker cycle (prints like USMT-Worker.ps1 -v)
|
||||
// =====================================================================================
|
||||
const Sim = { running: false, again: null, worker: null, timer: null };
|
||||
|
||||
function scheduleCycle(kind, delay) {
|
||||
clearTimeout(Sim.timer);
|
||||
Sim.timer = setTimeout(() => runCycle(kind), delay);
|
||||
}
|
||||
|
||||
function pingLine(name, status) {
|
||||
const color = { "Online": "Green", "Timed Out": "DarkYellow" }[status] || "Red";
|
||||
const text = { "Online": "Online", "Timed Out": "Timed Out.", "DNS Issue": "DNS Issue (reverse lookup mismatch).", "Not Found": "Not Found (No IP)." }[status] || "Not Found (No IP).";
|
||||
return [[` > Fast Ping: ${name}... `, 'DarkGray'], [text, color]];
|
||||
}
|
||||
|
||||
function logLines(name, logName, file) {
|
||||
const out = [[[` > Scraping logs for '${logName}'...`, 'DarkGray']]];
|
||||
const folders = ['Windows\\CCM\\Logs\\SMSTSLog', 'Windows\\CCM\\Logs'];
|
||||
for (const f of folders) {
|
||||
const path = `\\\\${name}\\C$\\${f}`;
|
||||
const hit = typeof file === 'string' && file.startsWith(path + '\\');
|
||||
out.push([[` Checking ${path}... `, 'DarkGray'], hit ? ['Found!', 'Green'] : ['Empty/Not there.', 'DarkGray']]);
|
||||
if (hit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function deviceLines(r, isNew, full) {
|
||||
const W = [];
|
||||
const add = (...segs) => W.push(segs);
|
||||
const src = r["Source Device"], dst = r["Destination Device"];
|
||||
if (r["Backup Status"] === "Skipped") {
|
||||
add([' -> [SKIP USMT] Tag marked Skip USMT. Caching Advanced/Software only.', 'DarkMagenta']);
|
||||
if (isNew || full) add([` -> [CACHE MISS / UPDATE] Running AD & SQL lookup for ${src}...`, 'DarkYellow']);
|
||||
return W;
|
||||
}
|
||||
if (r["USMT Status"] === "N/A" && dst !== "N/A") {
|
||||
add([' -> [MANUAL MATCH] Hardware exception detected. Bypassing SCCM...', 'DarkYellow']);
|
||||
W.push(pingLine(src, r["Source Online"]), pingLine(dst, r["Destination Online"]));
|
||||
return W;
|
||||
}
|
||||
add([' -> Querying SCCM for associations...', 'DarkGray']);
|
||||
if (r["Source Online"] === "Cached") {
|
||||
add([' -> SCCM DB Status: Completed (Raw: 2)', 'DarkGray']);
|
||||
add([' -> [CACHE HIT] Fast-tracking row and bypassing network sweeps.', 'Green']);
|
||||
return W;
|
||||
}
|
||||
if (r["USMT Status"] === "Not Associated") {
|
||||
add([' -> No SCCM association found. Sweeping source device anyway...', 'DarkGray']);
|
||||
W.push(pingLine(src, r["Source Online"]));
|
||||
if (r["Source Online"] === "Online") {
|
||||
add([' > Checking C: Drive Storage (SMB Method)... ', 'DarkGray'], [`${(r["Source Free Space"] || '').replace(' GB', 'GB')} Free out of ${(r["Source Total Space"] || '').replace(' GB', 'GB')}`, 'Green']);
|
||||
W.push(...logLines(src, 'scanstateprogress', r["Source Scanstate File"]));
|
||||
}
|
||||
if (isNew || full) add([` -> [CACHE MISS / UPDATE] Running AD & SQL lookup for ${src}...`, 'DarkYellow']);
|
||||
add([` - Status: ${r["Backup Status"]}`, 'Cyan']);
|
||||
return W;
|
||||
}
|
||||
const raw = { "Not Started": 0, "In Progress": 1, "Completed": 2 }[r["USMT Status"]];
|
||||
if (isNew) add([` -> [CACHE MISS] Fetching software for Dest Device ${dst}...`, 'DarkCyan']);
|
||||
add([` -> SCCM DB Status: ${r["USMT Status"]} (Raw: ${raw === undefined ? '?' : raw})`, 'DarkGray']);
|
||||
add([` -> [SWEEP SOURCE] ${src}`, 'DarkCyan']);
|
||||
W.push(pingLine(src, r["Source Online"]));
|
||||
if (r["Source Online"] === "Online") {
|
||||
add([' > Checking C: Drive Storage (SMB Method)... ', 'DarkGray'], [`${(r["Source Free Space"] || '').replace(' GB', 'GB')} Free out of ${(r["Source Total Space"] || '').replace(' GB', 'GB')}`, 'Green']);
|
||||
W.push(...logLines(src, 'scanstateprogress', r["Source Scanstate File"]));
|
||||
}
|
||||
add([` -> [SWEEP DESTINATION] ${dst}`, 'DarkCyan']);
|
||||
W.push(pingLine(dst, r["Destination Online"]));
|
||||
if (r["Destination Online"] === "Online") W.push(...logLines(dst, 'loadstateprogress', r["Destination Loadstate File"]));
|
||||
add([' -> Calculating Master Status...', 'DarkGray']);
|
||||
add([' -> Fetching Advanced SQL/AD Data...', 'DarkGray']);
|
||||
if (isNew || full) {
|
||||
add([` -> [CACHE MISS / UPDATE] Running AD & SQL lookup for ${src}...`, 'DarkYellow']);
|
||||
add([` -> [CACHE MISS / UPDATE] Running AD & SQL lookup for ${dst}...`, 'DarkYellow']);
|
||||
}
|
||||
add([` - Status: ${r["Backup Status"]}`, 'Cyan']);
|
||||
return W;
|
||||
}
|
||||
|
||||
async function runCycle(kind) {
|
||||
if (Sim.running) { Sim.again = kind; return; }
|
||||
Sim.running = true;
|
||||
const full = kind === 'full';
|
||||
const w = segs => Term.write('worker', segs);
|
||||
Term.hold('worker', true);
|
||||
try {
|
||||
const s = Store.state;
|
||||
const tracked = await visitorTracked();
|
||||
const applied = s.appliedTracked || tracked;
|
||||
const snap = await serverSnapshot();
|
||||
const before = new Set(Object.keys(parseRoster(applied).tagMap));
|
||||
const { rows } = buildRows(tracked, snap.rows, snap.adv);
|
||||
const { tagMap } = parseRoster(tracked);
|
||||
const total = Object.keys(tagMap).length;
|
||||
const started = new Date();
|
||||
const state = (status, cur) => { Sim.worker = { Status: status, Current: cur, Total: total }; };
|
||||
|
||||
if (kind === 'force') w([['[!] Force Update triggered by Dashboard!', 'Cyan']]);
|
||||
if (full) w([['[!] Force FULL Refresh triggered by Dashboard!', 'Cyan']]);
|
||||
w([]); w([]);
|
||||
w([[`--- Cycle Start: ${psTime(started)} ---`, 'White']]);
|
||||
state('Preparing...', 0); await sleep(400);
|
||||
w([]);
|
||||
w([['[Checking Software Cache Engine...]', 'Cyan']]);
|
||||
state('Software Check...', 0);
|
||||
if (full) w([['Dashboard Force FULL Refresh. Triggering global software/advanced update.', 'Magenta']]);
|
||||
for (const dev of Object.keys(tagMap).sort()) {
|
||||
if (full || !before.has(dev)) { w([[`Fetching background software for ${dev}...`, 'DarkCyan']]); await sleep(full ? 8 : 60); }
|
||||
}
|
||||
w([['[Software Cache Synchronized]', 'Green']]);
|
||||
w([]);
|
||||
w([[`--- Commencing Data Sweep (${total} Devices) ---`, 'White']]);
|
||||
|
||||
const delay = Math.max(15, Math.min(90, 9000 / Math.max(1, rows.length)));
|
||||
let i = 0;
|
||||
for (const r of rows) {
|
||||
i++;
|
||||
state('Updating Devices...', i);
|
||||
const src = r["Source Device"];
|
||||
w([[`[${i}/${total}] Processing: ${src}`, 'Yellow']]);
|
||||
w([[` -> Tags: [${r.Tags}]`, 'DarkCyan']]);
|
||||
for (const segs of deviceLines(r, !before.has(src), full)) w(segs);
|
||||
await sleep(delay);
|
||||
}
|
||||
|
||||
w([[' -> Pruning and Compressing Software Cache...', 'DarkGray']]);
|
||||
w([[' -> Pruning and Saving Advanced Info Cache...', 'DarkGray']]);
|
||||
w([[' -> Writing payload to Data.json...', 'DarkGray']]);
|
||||
const secs = (new Date() - started) / 1000;
|
||||
w([]);
|
||||
w([[`Cycle Complete in ${secs.toFixed(1)}s.`, 'Gray']]);
|
||||
const next = new Date(Date.now() + 45000);
|
||||
w([[`Next Cycle Scheduled For: ${psStamp(next)}`, 'DarkGray']]);
|
||||
w([]); w([]);
|
||||
|
||||
if (!s.tracked) await ensureOwnRoster();
|
||||
s.appliedTracked = clone(tracked);
|
||||
s.rev++;
|
||||
Store.save();
|
||||
} finally {
|
||||
Sim.worker = null;
|
||||
Sim.running = false;
|
||||
Term.hold('worker', false);
|
||||
}
|
||||
if (Sim.again) { const k = Sim.again; Sim.again = null; scheduleCycle(k, 1500); }
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// Demo sandbox: route handlers
|
||||
// =====================================================================================
|
||||
const srv = (text, color) => Term.write('server', [[text, color]]);
|
||||
const DOC_NAME = /[<>:"/\\|?*]/g;
|
||||
const IMG_TYPES = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp' };
|
||||
|
||||
async function readBody(init) {
|
||||
try { return JSON.parse((init && init.body) || '{}'); } catch (e) { return {}; }
|
||||
}
|
||||
|
||||
async function serverDocNames() {
|
||||
const list = await serverGet('/get-docs-list');
|
||||
return Array.isArray(list) ? list : (list ? [list] : []);
|
||||
}
|
||||
async function visibleDocNames() {
|
||||
const s = Store.state;
|
||||
const names = new Set((await serverDocNames()).filter(d => !s.deletedDocs.includes(d)));
|
||||
Object.keys(s.docs).forEach(d => names.add(d));
|
||||
return [...names].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
async function docContent(title) {
|
||||
const s = Store.state;
|
||||
if (s.docs[title]) return s.docs[title].content;
|
||||
if (s.deletedDocs.includes(title)) return null;
|
||||
const r = await realFetch(`/Documentation/${encodeURIComponent(title)}.md?t=${Date.now()}`);
|
||||
return r.ok ? r.text() : null;
|
||||
}
|
||||
|
||||
async function serverImages() {
|
||||
const list = await serverGet('/list-images');
|
||||
return Array.isArray(list) ? list : (list ? [list] : []);
|
||||
}
|
||||
// name the visitor sees -> what backs it ({ dataUrl } or { server: originalName })
|
||||
async function imageMap() {
|
||||
const s = Store.state;
|
||||
const map = {};
|
||||
const renamedFrom = new Set(Object.values(s.renamedImages));
|
||||
for (const img of await serverImages()) {
|
||||
if (s.deletedImages.includes(img.Name) || renamedFrom.has(img.Name)) continue;
|
||||
map[img.Name] = { server: img.Name, Date: img.Date };
|
||||
}
|
||||
for (const [name, orig] of Object.entries(s.renamedImages)) map[name] = { server: orig, Date: new Date().toISOString() };
|
||||
for (const [name, img] of Object.entries(s.images)) map[name] = { dataUrl: img.dataUrl, Date: img.date };
|
||||
return map;
|
||||
}
|
||||
|
||||
const routes = {
|
||||
// ---- tracking roster ----
|
||||
'GET Tracked-Devices.json': async () => {
|
||||
const s = Store.state;
|
||||
const text = s.tracked ? JSON.stringify(s.tracked, null, 2) : ((await serverGet('/Tracked-Devices.json', true)) || '{}');
|
||||
// Trailing whitespace makes the text differ after a simulated cycle, which is how
|
||||
// the dashboard notices it should reload Data.json.
|
||||
return textResponse(text + ' '.repeat(s.rev), 200, { 'Content-Type': 'application/json' });
|
||||
},
|
||||
'GET get-all-tags': async () => jsonResponse(Object.keys(await visitorTracked())),
|
||||
'GET get-tracked': async url => {
|
||||
const entry = (await visitorTracked())[url.searchParams.get('tech')];
|
||||
if (!entry) return jsonResponse([]);
|
||||
return jsonResponse(Array.isArray(entry) ? entry : (entry.devices || []));
|
||||
},
|
||||
'GET get-tag-meta': async url => {
|
||||
const entry = (await visitorTracked())[url.searchParams.get('tech')];
|
||||
return jsonResponse({ skipUSMT: !!(entry && !Array.isArray(entry) && entry.skipUSMT) });
|
||||
},
|
||||
'POST update-tracked': async (url, init) => {
|
||||
const body = await readBody(init);
|
||||
const tracked = await ensureOwnRoster();
|
||||
const tech = body.tech;
|
||||
const skip = Object.prototype.hasOwnProperty.call(body, 'skipUSMT') ? !!body.skipUSMT : false;
|
||||
if (!body.devices || body.devices.length === 0) delete tracked[tech];
|
||||
else tracked[tech] = { devices: body.devices, skipUSMT: skip };
|
||||
Store.save();
|
||||
srv(`Updated tracking roster for Tech/Tag: ${tech}`, 'Green');
|
||||
scheduleCycle('roster', 2500); // the worker picks the change up on its next sweep
|
||||
return textResponse('', 200);
|
||||
},
|
||||
|
||||
// ---- worker data ----
|
||||
'GET Worker-State.json': async () => {
|
||||
const state = (await serverGet('/Worker-State.json')) || {};
|
||||
if (Sim.worker) return jsonResponse(Object.assign({}, state, Sim.worker));
|
||||
return jsonResponse(state);
|
||||
},
|
||||
'GET Data.json': async () => {
|
||||
const s = Store.state;
|
||||
const snap = await serverSnapshot();
|
||||
if (!s.appliedTracked) return jsonResponse(snap.data);
|
||||
const { rows } = buildRows(s.appliedTracked, snap.rows, snap.adv);
|
||||
return jsonResponse({ LastUpdated: snap.data.LastUpdated || psStamp(), Devices: rows });
|
||||
},
|
||||
'GET Advanced-Cache.json': async () => {
|
||||
const s = Store.state;
|
||||
const snap = await serverSnapshot();
|
||||
if (!s.appliedTracked) return jsonResponse(snap.adv);
|
||||
const { adv } = buildRows(s.appliedTracked, snap.rows, snap.adv);
|
||||
for (const [k, val] of Object.entries(adv)) if (val) snap.adv[k] = val;
|
||||
return jsonResponse(snap.adv);
|
||||
},
|
||||
'GET Software-Cache.json': async () => {
|
||||
const s = Store.state;
|
||||
const cache = (await serverGet('/Software-Cache.json')) || {};
|
||||
if (s.appliedTracked) {
|
||||
const snap = await serverSnapshot();
|
||||
const { software } = buildRows(s.appliedTracked, snap.rows, snap.adv);
|
||||
for (const [name, template] of Object.entries(software)) {
|
||||
if (cache[name]) continue;
|
||||
cache[name] = (template && cache[template]) || { Timestamp: new Date().toISOString(), Data: [] };
|
||||
}
|
||||
}
|
||||
return jsonResponse(cache);
|
||||
},
|
||||
'GET get-software': async url => {
|
||||
const name = (url.searchParams.get('name') || '').toUpperCase();
|
||||
const cache = await (await routes['GET Software-Cache.json']()).json();
|
||||
srv(`Fetching Software for: ${name}`, 'Yellow');
|
||||
const entry = cache[name];
|
||||
return jsonResponse(entry ? (Array.isArray(entry) ? entry : [].concat(entry.Data || [])) : []);
|
||||
},
|
||||
'POST force-update': async () => {
|
||||
srv('Received Force Update Command from Dashboard', 'Cyan');
|
||||
scheduleCycle('force', 800);
|
||||
return textResponse('', 200);
|
||||
},
|
||||
'POST force-full': async () => {
|
||||
srv('Received Force FULL Refresh Command from Dashboard', 'Cyan');
|
||||
scheduleCycle('full', 800);
|
||||
return textResponse('', 200);
|
||||
},
|
||||
|
||||
// ---- knowledge base ----
|
||||
'GET get-docs-list': async () => jsonResponse(await visibleDocNames()),
|
||||
'GET Documentation': async url => {
|
||||
const rel = decodeURIComponent(url.pathname.replace(/^\/Documentation\//, ''));
|
||||
if (rel.startsWith('images/')) return null; // passthrough (handled by the <img> rewriter)
|
||||
if (!rel.endsWith('.md') || rel.includes('/')) return null;
|
||||
const s = Store.state;
|
||||
const title = rel.slice(0, -3);
|
||||
if (s.docs[title]) return textResponse(s.docs[title].content, 200, { 'Content-Type': 'text/markdown', 'Last-Modified': new Date(s.docs[title].modified).toUTCString() });
|
||||
if (s.deletedDocs.includes(title)) return textResponse('', 404);
|
||||
return null;
|
||||
},
|
||||
'POST save-doc': async (url, init) => {
|
||||
const body = await readBody(init);
|
||||
const s = Store.state;
|
||||
const title = String(body.title || '').replace(DOC_NAME, '');
|
||||
const original = String(body.originalTitle || '').replace(DOC_NAME, '');
|
||||
if (!title.trim()) return textResponse('', 400);
|
||||
if (original && original !== title) {
|
||||
delete s.docs[original];
|
||||
if ((await serverDocNames()).includes(original) && !s.deletedDocs.includes(original)) s.deletedDocs.push(original);
|
||||
}
|
||||
s.docs[title] = { content: String(body.content || ''), modified: Date.now() };
|
||||
s.deletedDocs = s.deletedDocs.filter(d => d !== title);
|
||||
Store.save();
|
||||
srv(`Saved documentation: ${title}.md`, 'Green');
|
||||
return textResponse('', 200);
|
||||
},
|
||||
'POST delete-doc': async (url, init) => {
|
||||
const body = await readBody(init);
|
||||
const s = Store.state;
|
||||
const title = String(body.title || '').replace(DOC_NAME, '');
|
||||
if (!title.trim()) return textResponse('', 400);
|
||||
const onServer = (await serverDocNames()).includes(title) && !s.deletedDocs.includes(title);
|
||||
if (!s.docs[title] && !onServer) return textResponse('', 404);
|
||||
delete s.docs[title];
|
||||
if (onServer) s.deletedDocs.push(title);
|
||||
Store.save();
|
||||
srv(`Archived documentation: ${title}.md`, 'Yellow');
|
||||
return textResponse('', 200);
|
||||
},
|
||||
|
||||
// ---- images ----
|
||||
'GET list-images': async () => {
|
||||
const map = await imageMap();
|
||||
return jsonResponse(Object.entries(map).map(([Name, v]) => ({ Name, Date: v.Date })).sort((a, b) => a.Name.localeCompare(b.Name)));
|
||||
},
|
||||
'POST upload-image': async (url, init) => {
|
||||
const body = await readBody(init);
|
||||
const name = String(body.filename || '').replace(DOC_NAME, '');
|
||||
if (!name || !body.data) return textResponse('', 500);
|
||||
const ext = (name.split('.').pop() || '').toLowerCase();
|
||||
Store.state.images[name] = { dataUrl: `data:${IMG_TYPES[ext] || 'application/octet-stream'};base64,${body.data}`, date: new Date().toISOString() };
|
||||
delete Store.state.renamedImages[name];
|
||||
Store.save();
|
||||
ImageRewriter.refresh();
|
||||
srv(`Uploaded Image: ${name}`, 'Cyan');
|
||||
return textResponse('', 200);
|
||||
},
|
||||
'POST rename-image': async (url, init) => {
|
||||
const body = await readBody(init);
|
||||
const s = Store.state;
|
||||
const oldRaw = String(body.oldName || ''), oldName = oldRaw.replace(DOC_NAME, '');
|
||||
const newName = String(body.newName || '').replace(DOC_NAME, '');
|
||||
const map = await imageMap();
|
||||
if (!map[oldName] || !newName) return textResponse('', 404);
|
||||
if (s.images[oldName]) { s.images[newName] = s.images[oldName]; delete s.images[oldName]; }
|
||||
else { const orig = map[oldName].server; delete s.renamedImages[oldName]; s.renamedImages[newName] = orig; }
|
||||
|
||||
// Same markdown crawler as the server: fix links in every article
|
||||
const encOld = encodeURIComponent(oldRaw), encNew = encodeURIComponent(newName);
|
||||
for (const title of await visibleDocNames()) {
|
||||
const content = await docContent(title);
|
||||
if (content == null) continue;
|
||||
let next = content.split('images/' + oldRaw).join('images/' + newName);
|
||||
next = next.split('images/' + encOld).join('images/' + encNew);
|
||||
if (next !== content) {
|
||||
s.docs[title] = { content: next, modified: Date.now() };
|
||||
srv(`Auto-fixed image link in ${title}.md`, 'Magenta');
|
||||
}
|
||||
}
|
||||
Store.save();
|
||||
ImageRewriter.refresh();
|
||||
return textResponse('', 200);
|
||||
},
|
||||
'POST delete-image': async (url, init) => {
|
||||
const body = await readBody(init);
|
||||
const s = Store.state;
|
||||
const name = String(body.filename || '').replace(DOC_NAME, '');
|
||||
const map = await imageMap();
|
||||
if (!map[name]) return textResponse('', 404);
|
||||
if (s.images[name]) delete s.images[name];
|
||||
else if (s.renamedImages[name]) { s.deletedImages.push(s.renamedImages[name]); delete s.renamedImages[name]; }
|
||||
else s.deletedImages.push(name);
|
||||
Store.save();
|
||||
ImageRewriter.refresh();
|
||||
srv(`Deleted Image: ${body.filename}`, 'Yellow');
|
||||
return textResponse('', 200);
|
||||
}
|
||||
};
|
||||
|
||||
function findRoute(url, method) {
|
||||
const path = decodeURIComponent(url.pathname.replace(/^\//, ''));
|
||||
if (routes[`${method} ${path}`]) return routes[`${method} ${path}`];
|
||||
if (method === 'GET' && path.startsWith('Documentation/')) return routes['GET Documentation'];
|
||||
return null;
|
||||
}
|
||||
|
||||
// <img> requests can't be intercepted with fetch, so images the visitor uploaded or
|
||||
// renamed are swapped in as their elements appear.
|
||||
const ImageRewriter = {
|
||||
map: {},
|
||||
async refresh() {
|
||||
this.map = await imageMap();
|
||||
document.querySelectorAll('img').forEach(img => this.fix(img));
|
||||
},
|
||||
fix(img) {
|
||||
const src = img.getAttribute('src') || '';
|
||||
if (img.dataset.usmtFor === src) return; // already rewritten by us
|
||||
const m = src.match(/^\/?Documentation\/images\/([^?#]+)/);
|
||||
if (!m) return;
|
||||
let name; try { name = decodeURIComponent(m[1]); } catch (e) { return; }
|
||||
const entry = this.map[name];
|
||||
let want;
|
||||
if (!entry) want = 'data:,'; // deleted in this sandbox
|
||||
else if (entry.dataUrl) want = entry.dataUrl; // uploaded by this visitor
|
||||
else if (entry.server !== name) want = '/Documentation/images/' + encodeURIComponent(entry.server);
|
||||
if (want) { img.dataset.usmtFor = want; img.setAttribute('src', want); }
|
||||
},
|
||||
start() {
|
||||
this.refresh();
|
||||
new MutationObserver(muts => {
|
||||
for (const m of muts) {
|
||||
if (m.type === 'attributes') this.fix(m.target);
|
||||
else m.addedNodes.forEach(n => {
|
||||
if (n.nodeType !== 1) return;
|
||||
if (n.tagName === 'IMG') this.fix(n); else n.querySelectorAll && n.querySelectorAll('img').forEach(i => this.fix(i));
|
||||
});
|
||||
}
|
||||
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
|
||||
}
|
||||
};
|
||||
|
||||
// =====================================================================================
|
||||
// Demo banner + about dialog
|
||||
// =====================================================================================
|
||||
const Demo = {
|
||||
build() {
|
||||
const controls = document.querySelector('.nav-right-controls');
|
||||
if (controls) {
|
||||
const pill = document.createElement('button');
|
||||
pill.type = 'button'; pill.className = 'usmt-demo-pill'; pill.textContent = 'DEMO';
|
||||
pill.title = 'About this demo';
|
||||
pill.onclick = () => this.about(true);
|
||||
controls.insertBefore(pill, controls.firstChild);
|
||||
}
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'usmt-demo-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="usmt-demo-card" role="dialog" aria-labelledby="usmt-demo-title">
|
||||
<h2 id="usmt-demo-title">You're looking at a live demo</h2>
|
||||
<p>This is the real dashboard, served by the real <code>USMT-Server.ps1</code> HttpListener and fed by the real
|
||||
<code>USMT-Worker.ps1</code> polling loop, running in PowerShell 7 in a container. The only difference is the data
|
||||
source: instead of SCCM, Active Directory and SMB, the worker talks to a simulated fleet of 200 devices whose
|
||||
migrations progress in real time.</p>
|
||||
<p><strong>Your changes stay in your browser.</strong> The demo API is read-only. Adding devices, editing articles,
|
||||
uploading images and forcing refreshes are simulated in this browser only, and the terminals show what the
|
||||
services would print. No one else sees your changes, and they're never sent to the server.
|
||||
Run it yourself without <code>-Demo</code> and every action works against the real backend.</p>
|
||||
<p class="usmt-demo-try"><strong>Things to try:</strong> open <em>Terminals</em> at the bottom to watch the worker sweep the
|
||||
fleet · add a device such as <code>PRVDT-7QK4M2XA</code> under <em>Tracked Devices</em> ·
|
||||
click a device for its details · <em>All Devices</em> shows apps missing from replacement machines ·
|
||||
edit an article under <em>Documentation</em>.</p>
|
||||
<div class="usmt-demo-actions">
|
||||
<button type="button" class="usmt-demo-reset">Reset my changes</button>
|
||||
<button type="button" class="usmt-demo-terms">Open terminals</button>
|
||||
<button type="button" class="usmt-demo-close">Explore the dashboard</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
modal.addEventListener('click', e => { if (e.target === modal) this.about(false); });
|
||||
modal.querySelector('.usmt-demo-close').onclick = () => this.about(false);
|
||||
modal.querySelector('.usmt-demo-terms').onclick = () => { this.about(false); Term.toggle(true); };
|
||||
modal.querySelector('.usmt-demo-reset').onclick = async () => {
|
||||
await Store.reset();
|
||||
try { ['usmt_workbench_stacks', 'usmt_custom_columns', 'usmt_custom_filters', 'usmt_custom_widths', 'usmt_custom_widths_locked', 'usmt_tech_id'].forEach(k => localStorage.removeItem(k)); } catch (e) {}
|
||||
location.reload();
|
||||
};
|
||||
this.modal = modal;
|
||||
|
||||
let seen = false;
|
||||
try { seen = localStorage.getItem('usmt_demo_seen') === '1'; } catch (e) {}
|
||||
if (!seen) this.about(true);
|
||||
},
|
||||
about(show) {
|
||||
this.modal.classList.toggle('show', show);
|
||||
if (!show) { try { localStorage.setItem('usmt_demo_seen', '1'); } catch (e) {} }
|
||||
}
|
||||
};
|
||||
|
||||
// =====================================================================================
|
||||
// Wire-up
|
||||
// =====================================================================================
|
||||
window.fetch = async function (input, init) {
|
||||
const cfg = await configReady;
|
||||
if (!cfg.Demo) return realFetch(input, init);
|
||||
|
||||
let url;
|
||||
try { url = new URL(typeof input === 'string' ? input : input.url, location.href); } catch (e) { return realFetch(input, init); }
|
||||
if (url.origin !== location.origin) return realFetch(input, init);
|
||||
|
||||
await Store.load();
|
||||
const method = ((init && init.method) || (input && input.method) || 'GET').toUpperCase();
|
||||
const handler = findRoute(url, method);
|
||||
if (handler) {
|
||||
try {
|
||||
const res = await handler(url, init);
|
||||
if (res) return res;
|
||||
} catch (e) {
|
||||
console.error('[demo sandbox]', e);
|
||||
return textResponse('', 500);
|
||||
}
|
||||
}
|
||||
return realFetch(input, init);
|
||||
};
|
||||
|
||||
function onReady(fn) { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn); else fn(); }
|
||||
|
||||
onReady(async () => {
|
||||
const cfg = await configReady;
|
||||
if (cfg.Terminals) Term.init(!!cfg.Demo);
|
||||
if (cfg.Demo) {
|
||||
document.documentElement.classList.add('usmt-demo');
|
||||
await Store.load();
|
||||
Demo.build();
|
||||
ImageRewriter.start();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 158 KiB |
@@ -0,0 +1,29 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2006, Ivan Sagalaev.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,39 @@
|
||||
/*! `powershell` grammar compiled for Highlight.js 11.9.0 */
|
||||
(()=>{var e=(()=>{"use strict";return e=>{const n={$pattern:/-?[A-z\.\-]+\b/,
|
||||
keyword:"if else foreach return do while until elseif begin for trap data dynamicparam end break throw param continue finally in switch exit filter try process catch hidden static parameter",
|
||||
built_in:"ac asnp cat cd CFS chdir clc clear clhy cli clp cls clv cnsn compare copy cp cpi cpp curl cvpa dbp del diff dir dnsn ebp echo|0 epal epcsv epsn erase etsn exsn fc fhx fl ft fw gal gbp gc gcb gci gcm gcs gdr gerr ghy gi gin gjb gl gm gmo gp gps gpv group gsn gsnp gsv gtz gu gv gwmi h history icm iex ihy ii ipal ipcsv ipmo ipsn irm ise iwmi iwr kill lp ls man md measure mi mount move mp mv nal ndr ni nmo npssc nsn nv ogv oh popd ps pushd pwd r rbp rcjb rcsn rd rdr ren ri rjb rm rmdir rmo rni rnp rp rsn rsnp rujb rv rvpa rwmi sajb sal saps sasv sbp sc scb select set shcm si sl sleep sls sort sp spjb spps spsv start stz sujb sv swmi tee trcm type wget where wjb write"
|
||||
},s={begin:"`[\\s\\S]",relevance:0},i={className:"variable",variants:[{
|
||||
begin:/\$\B/},{className:"keyword",begin:/\$this/},{begin:/\$[\w\d][\w\d_:]*/}]
|
||||
},a={className:"string",variants:[{begin:/"/,end:/"/},{begin:/@"/,end:/^"@/}],
|
||||
contains:[s,i,{className:"variable",begin:/\$[A-z]/,end:/[^A-z]/}]},t={
|
||||
className:"string",variants:[{begin:/'/,end:/'/},{begin:/@'/,end:/^'@/}]
|
||||
},r=e.inherit(e.COMMENT(null,null),{variants:[{begin:/#/,end:/$/},{begin:/<#/,
|
||||
end:/#>/}],contains:[{className:"doctag",variants:[{
|
||||
begin:/\.(synopsis|description|example|inputs|outputs|notes|link|component|role|functionality)/
|
||||
},{
|
||||
begin:/\.(parameter|forwardhelptargetname|forwardhelpcategory|remotehelprunspace|externalhelp)\s+\S+/
|
||||
}]}]}),c={className:"class",beginKeywords:"class enum",end:/\s*[{]/,
|
||||
excludeEnd:!0,relevance:0,contains:[e.TITLE_MODE]},l={className:"function",
|
||||
begin:/function\s+/,end:/\s*\{|$/,excludeEnd:!0,returnBegin:!0,relevance:0,
|
||||
contains:[{begin:"function",relevance:0,className:"keyword"},{className:"title",
|
||||
begin:/\w[\w\d]*((-)[\w\d]+)*/,relevance:0},{begin:/\(/,end:/\)/,
|
||||
className:"params",relevance:0,contains:[i]}]},o={begin:/using\s/,end:/$/,
|
||||
returnBegin:!0,contains:[a,t,{className:"keyword",
|
||||
begin:/(using|assembly|command|module|namespace|type)/}]},p={
|
||||
className:"function",begin:/\[.*\]\s*[\w]+[ ]??\(/,end:/$/,returnBegin:!0,
|
||||
relevance:0,contains:[{className:"keyword",
|
||||
begin:"(".concat(n.keyword.toString().replace(/\s/g,"|"),")\\b"),endsParent:!0,
|
||||
relevance:0},e.inherit(e.TITLE_MODE,{endsParent:!0})]
|
||||
},g=[p,r,s,e.NUMBER_MODE,a,t,{className:"built_in",variants:[{
|
||||
begin:"(Add|Clear|Close|Copy|Enter|Exit|Find|Format|Get|Hide|Join|Lock|Move|New|Open|Optimize|Pop|Push|Redo|Remove|Rename|Reset|Resize|Search|Select|Set|Show|Skip|Split|Step|Switch|Undo|Unlock|Watch|Backup|Checkpoint|Compare|Compress|Convert|ConvertFrom|ConvertTo|Dismount|Edit|Expand|Export|Group|Import|Initialize|Limit|Merge|Mount|Out|Publish|Restore|Save|Sync|Unpublish|Update|Approve|Assert|Build|Complete|Confirm|Deny|Deploy|Disable|Enable|Install|Invoke|Register|Request|Restart|Resume|Start|Stop|Submit|Suspend|Uninstall|Unregister|Wait|Debug|Measure|Ping|Repair|Resolve|Test|Trace|Connect|Disconnect|Read|Receive|Send|Write|Block|Grant|Protect|Revoke|Unblock|Unprotect|Use|ForEach|Sort|Tee|Where)+(-)[\\w\\d]+"
|
||||
}]},i,{className:"literal",begin:/\$(null|true|false)\b/},{
|
||||
className:"selector-tag",begin:/@\B/,relevance:0}],m={begin:/\[/,end:/\]/,
|
||||
excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[].concat("self",g,{
|
||||
begin:"(string|char|byte|int|long|bool|decimal|single|double|DateTime|xml|array|hashtable|void)",
|
||||
className:"built_in",relevance:0},{className:"type",begin:/[\.\w\d]+/,
|
||||
relevance:0})};return p.contains.unshift(m),{name:"PowerShell",
|
||||
aliases:["pwsh","ps","ps1"],case_insensitive:!0,keywords:n,
|
||||
contains:g.concat(c,l,o,{variants:[{className:"operator",
|
||||
begin:"(-and|-as|-band|-bnot|-bor|-bxor|-casesensitive|-ccontains|-ceq|-cge|-cgt|-cle|-clike|-clt|-cmatch|-cne|-cnotcontains|-cnotlike|-cnotmatch|-contains|-creplace|-csplit|-eq|-exact|-f|-file|-ge|-gt|-icontains|-ieq|-ige|-igt|-ile|-ilike|-ilt|-imatch|-in|-ine|-inotcontains|-inotlike|-inotmatch|-ireplace|-is|-isnot|-isplit|-join|-le|-like|-lt|-match|-ne|-not|-notcontains|-notin|-notlike|-notmatch|-or|-regex|-replace|-shl|-shr|-split|-wildcard|-xor)\\b"
|
||||
},{className:"literal",begin:/(-){1,2}[\w\d-]+/,relevance:0}]},m)}}})()
|
||||
;hljs.registerLanguage("powershell",e)})();
|
||||
@@ -0,0 +1 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34}.hljs-comment,.hljs-quote{color:#5c6370;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#c678dd}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e06c75}.hljs-literal{color:#56b6c2}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#98c379}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#d19a66}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#61aeee}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#e6c07b}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License
|
||||
|
||||
Copyright (c) 2017 Erik Koopmans
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,3 @@
|
||||
/*! For license information please see html2pdf.bundle.min.js.LICENSE.txt */
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define("html2pdf",[],t):"object"==typeof exports?exports.html2pdf=t():e.html2pdf=t()}(self,(function(){return function(){var e,t,r={"./node_modules/@babel/runtime-corejs3/core-js-stable/array/from.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/array/from.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/array/is-array.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/array/is-array.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/date/now.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/date/now.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/bind.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/instance/bind.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/concat.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/instance/concat.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/every.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/instance/every.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/fill.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/instance/fill.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/filter.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/instance/filter.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/for-each.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/instance/for-each.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/includes.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/instance/includes.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/index-of.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/instance/index-of.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/map.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/instance/map.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/reduce.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/instance/reduce.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/reverse.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/instance/reverse.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/slice.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/instance/slice.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/some.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/instance/some.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/starts-with.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/instance/starts-with.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/trim.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/instance/trim.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/values.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/instance/values.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/map.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/map/index.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/object/define-properties.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/object/define-properties.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/object/define-property.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/object/define-property.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/object/get-own-property-descriptor.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/object/get-own-property-descriptor.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/object/get-own-property-descriptors.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/object/get-own-property-descriptors.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/object/get-own-property-symbols.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/object/get-own-property-symbols.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/object/keys.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/object/keys.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/parse-float.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/parse-float.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/parse-int.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/parse-int.js")},"./node_modules/@babel/runtime-corejs3/core-js-stable/promise.js":function(e,t,r){e.exports=r("./node_modules/core-js-pure/stable/promise/index.js")},"./node_modules/@babel/runtime-corejs3/core-js-Line truncated
|
||||
//# sourceMappingURL=html2pdf.bundle.min.js.map
|
||||
@@ -0,0 +1,44 @@
|
||||
# License information
|
||||
|
||||
## Contribution License Agreement
|
||||
|
||||
If you contribute code to this project, you are implicitly allowing your code
|
||||
to be distributed under the MIT license. You are also implicitly verifying that
|
||||
all code is your original work. `</legalese>`
|
||||
|
||||
## Marked
|
||||
|
||||
Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/)
|
||||
Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
## Markdown
|
||||
|
||||
Copyright © 2004, John Gruber
|
||||
http://daringfireball.net/
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
* Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage.
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* marked v15.0.12 - a markdown parser
|
||||
* Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed)
|
||||
* https://github.com/markedjs/marked
|
||||
*/
|
||||
|
||||
/**
|
||||
* DO NOT EDIT THIS FILE
|
||||
* The code in this file is generated from files in ./src/
|
||||
*/
|
||||
(function(g,f){if(typeof exports=="object"&&typeof module<"u"){module.exports=f()}else if("function"==typeof define && define.amd){define("marked",f)}else {g["marked"]=f()}}(typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : this,function(){var exports={};var __exports=exports;var module={exports};
|
||||
"use strict";var H=Object.defineProperty;var be=Object.getOwnPropertyDescriptor;var Te=Object.getOwnPropertyNames;var we=Object.prototype.hasOwnProperty;var ye=(l,e)=>{for(var t in e)H(l,t,{get:e[t],enumerable:!0})},Re=(l,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of Te(e))!we.call(l,s)&&s!==t&&H(l,s,{get:()=>e[s],enumerable:!(n=be(e,s))||n.enumerable});return l};var Se=l=>Re(H({},"__esModule",{value:!0}),l);var kt={};ye(kt,{Hooks:()=>L,Lexer:()=>x,Marked:()=>E,Parser:()=>b,Renderer:()=>$,TextRenderer:()=>_,Tokenizer:()=>S,defaults:()=>w,getDefaults:()=>z,lexer:()=>ht,marked:()=>k,options:()=>it,parse:()=>pt,parseInline:()=>ct,parser:()=>ut,setOptions:()=>ot,use:()=>lt,walkTokens:()=>at});module.exports=Se(kt);function z(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var w=z();function N(l){w=l}var I={exec:()=>null};function h(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(s,i)=>{let r=typeof i=="string"?i:i.source;return r=r.replace(m.caret,"$1"),t=t.replace(s,r),n},getRegex:()=>new RegExp(t,e)};return n}var m={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^<a /i,endATag:/^<\/a>/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^</,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}#`),htmlBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}<(?:[a-z].*>|!--)`,"i")},$e=/^(?:[ \t]*(?:\n|$))+/,_e=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Le=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,O=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,ze=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,F=/(?:[*+-]|\d{1,9}[.)])/,ie=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,oe=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Me=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Q=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Pe=/^[^\n]+/,U=/(?!\s*\])(?:\\.|[^\[\]\\])+/,Ae=h(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",U).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Ee=h(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,F).getRegex(),v="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",K=/<!--(?:-?>|[\s\S]*?(?:-->|$))/,Ce=h("^ {Line truncated
|
||||
]`).replace("lheading",oe).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},qe=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,ve=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ae=/^( {2,}|\\)\n(?!\s*$)/,De=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\<!\[`*_]|\b_|$)|[^ ](?= {2,}\n)))/,D=/[\p{P}\p{S}]/u,W=/[\s\p{P}\p{S}]/u,ce=/[^\s\p{P}\p{S}]/u,Ze=h(/^((?![*_])punctSpace)/,"u").replace(/punctSpace/g,W).getRegex(),pe=/(?!~)[\p{P}\p{S}]/u,Ge=/(?!~)[\s\p{P}\p{S}]/u,He=/(?:[^\s\p{P}\p{S}]|~)/u,Ne=/\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g,ue=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,je=h(ue,"u").replace(/punct/g,D).getRegex(),Fe=h(ue,"u").replace(/punct/g,pe).getRegex(),he="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Qe=h(he,"gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Ue=h(he,"gu").replace(/notPunctSpace/g,He).replace(/punctSpace/g,Ge).replace(/punct/g,pe).getRegex(),Ke=h("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Xe=h(/\\(punct)/,"gu").replace(/punct/g,D).getRegex(),We=h(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),Je=h(K).replace("(?:-->|$)","-->").getRegex(),Ve=h("^comment|^</[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^<![a-zA-Z]+\\s[\\s\\S]*?>|^<!\\[CDATA\\[[\\s\\S]*?\\]\\]>").replace("comment",Je).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),q=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,Ye=h(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",q).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),ke=h(/^!?\[(label)\]\[(ref)\]/).replace("label",q).replace("ref",U).getRegex(),ge=h(/^!?\[(ref)\](?:\[\])?/).replace("ref",U).getRegex(),et=h("reflink|nolink(?!\\()","g").replace("reflink",ke).replace("nolink",ge).getRegex(),J={_backpedal:I,anyPunctuation:Xe,autolink:We,blockSkip:Ne,br:ae,code:ve,del:I,emStrongLDelim:je,emStrongRDelimAst:Qe,emStrongRDelimUnd:Ke,escape:qe,link:Ye,nolink:ge,punctuation:Ze,reflink:ke,reflinkSearch:et,tag:Ve,text:De,url:I},tt={...J,link:h(/^!?\[(label)\]\((.*?)\)/).replace("label",q).getRegex(),reflink:h(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",q).getRegex()},j={...J,emStrongRDelimAst:Ue,emStrongLDelim:Fe,url:h(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\<!\[`*~_]|\b_|https?:\/\/|ftp:\/\/|www\.|$)|[^ ](?= {2,}\n)|[^a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-](?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)))/},nt={...j,br:h(ae).replace("{2,}","*").getRegex(),text:h(j.text).replace("\\b_","\\b_| {2,}\\n").replace(/\{2,\}/g,"*").getRegex()},B={normal:X,gfm:Oe,pedantic:Be},P={normal:J,gfm:j,breaks:nt,pedantic:tt};var st={"&":"&","<":"<",">":">",'"':""","'":"'"},fe=l=>st[l];function R(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,fe)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,fe);return l}function V(l){try{l=encodeURI(l).replace(m.percentDecode,"%")}catch{return null}return l}function Y(l,e){let t=l.replace(m.findPipe,(i,r,o)=>{let a=!1,c=r;for(;--c>=0&&o[c]==="\\";)a=!a;return a?"|":" |"}),n=t.split(m.splitPipe),s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length<e;)n.push("");for(;s<n.length;s++)n[s]=n[s].trim().replace(m.slashPipe,"|");return n}function A(l,e,t){let n=l.length;if(n===0)return"";let s=0;for(;s<n;){let i=l.charAt(n-s-1);if(i===e&&!t)s++;else if(i!==e&&t)s++;else break}return l.slice(0,n-s)}function de(l,e){if(l.indexOf(e[1])===-1)return-1;let t=0;for(let n=0;n<l.length;n++)if(l[n]==="\\")n++;else if(l[n]===e[0])t++;else if(l[n]===e[1]&&(t--,t<0))return n;return t>0?-2:-1}fLine truncated
|
||||
`).map(i=>{let r=i.match(t.other.beginningSpace);if(r===null)return i;let[o]=r;return o.length>=s.length?i.slice(s.length):i}).join(`
|
||||
`)}var S=class{options;rules;lexer;constructor(e){this.options=e||w}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:A(n,`
|
||||
`)}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],s=rt(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:s}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let s=A(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:A(t[0],`
|
||||
`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=A(t[0],`
|
||||
`).split(`
|
||||
`),s="",i="",r=[];for(;n.length>0;){let o=!1,a=[],c;for(c=0;c<n.length;c++)if(this.rules.other.blockquoteStart.test(n[c]))a.push(n[c]),o=!0;else if(!o)a.push(n[c]);else break;n=n.slice(c);let p=a.join(`
|
||||
`),u=p.replace(this.rules.other.blockquoteSetextReplace,`
|
||||
$1`).replace(this.rules.other.blockquoteSetextReplace2,"");s=s?`${s}
|
||||
${p}`:p,i=i?`${i}
|
||||
${u}`:u;let d=this.lexer.state.top;if(this.lexer.state.top=!0,this.lexer.blockTokens(u,r,!0),this.lexer.state.top=d,n.length===0)break;let g=r.at(-1);if(g?.type==="code")break;if(g?.type==="blockquote"){let T=g,f=T.raw+`
|
||||
`+n.join(`
|
||||
`),y=this.blockquote(f);r[r.length-1]=y,s=s.substring(0,s.length-T.raw.length)+y.raw,i=i.substring(0,i.length-T.text.length)+y.text;break}else if(g?.type==="list"){let T=g,f=T.raw+`
|
||||
`+n.join(`
|
||||
`),y=this.list(f);r[r.length-1]=y,s=s.substring(0,s.length-g.raw.length)+y.raw,i=i.substring(0,i.length-T.raw.length)+y.raw,n=f.substring(r.at(-1).raw.length).split(`
|
||||
`);continue}}return{type:"blockquote",raw:s,tokens:r,text:i}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim(),s=n.length>1,i={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");let r=this.rules.other.listItemRegex(n),o=!1;for(;e;){let c=!1,p="",u="";if(!(t=r.exec(e))||this.rules.block.hr.test(e))break;p=t[0],e=e.substring(p.length);let d=t[2].split(`
|
||||
`,1)[0].replace(this.rules.other.listReplaceTabs,Z=>" ".repeat(3*Z.length)),g=e.split(`
|
||||
`,1)[0],T=!d.trim(),f=0;if(this.options.pedantic?(f=2,u=d.trimStart()):T?f=t[1].length+1:(f=t[2].search(this.rules.other.nonSpaceChar),f=f>4?1:f,u=d.slice(f),f+=t[1].length),T&&this.rules.other.blankLine.test(g)&&(p+=g+`
|
||||
`,e=e.substring(g.length+1),c=!0),!c){let Z=this.rules.other.nextBulletRegex(f),te=this.rules.other.hrRegex(f),ne=this.rules.other.fencesBeginRegex(f),se=this.rules.other.headingBeginRegex(f),xe=this.rules.other.htmlBeginRegex(f);for(;e;){let G=e.split(`
|
||||
`,1)[0],C;if(g=G,this.options.pedantic?(g=g.replace(this.rules.other.listReplaceNesting," "),C=g):C=g.replace(this.rules.other.tabCharGlobal," "),ne.test(g)||se.test(g)||xe.test(g)||Z.test(g)||te.test(g))break;if(C.search(this.rules.other.nonSpaceChar)>=f||!g.trim())u+=`
|
||||
`+C.slice(f);else{if(T||d.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||ne.test(d)||se.test(d)||te.test(d))break;u+=`
|
||||
`+g}!T&&!g.trim()&&(T=!0),p+=G+`
|
||||
`,e=e.substring(G.length+1),d=C.slice(f)}}i.loose||(o?i.loose=!0:this.rules.other.doubleBlankLine.test(p)&&(o=!0));let y=null,ee;this.options.gfm&&(y=this.rules.other.listIsTask.exec(u),y&&(ee=y[0]!=="[ ] ",u=u.replace(this.rules.other.listReplaceTask,""))),i.items.push({type:"list_item",raw:p,task:!!y,checked:ee,loose:!1,text:u,tokens:[]}),i.raw+=p}let a=i.items.at(-1);if(a)a.raw=a.raw.trimEnd(),a.text=a.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let c=0;c<i.items.length;c++)if(this.lexer.state.top=!1,i.items[c].tokens=this.lexer.blockTokens(i.items[c].text,[]),!i.loose){let p=i.items[c].tokens.filter(d=>d.type==="space"),u=p.length>0&&p.some(d=>this.rules.other.anyLine.test(d.raw));i.loose=u}if(i.loose)for(let c=0;c<i.items.length;c++)i.items[c].loose=!0;return i}}html(e){let t=this.rules.block.html.exec(e);if(t)return{type:"html",block:!0,raw:t[0],pre:t[1]==="pre"||t[1]==="script"||t[1]==="style",text:t[0]}}def(e){let t=this.rules.block.def.exec(e);if(t){let n=t[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal," "),s=t[2]?t[2].replace(this.rules.other.hrefBrackets,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",i=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:n,raw:t[0],href:s,title:i}}}table(e){let t=this.rules.block.table.exec(e);if(!t||!this.rules.other.tableDelimiter.test(t[2]))return;let n=Y(t[1]),s=t[2].replace(this.rules.other.tableAlignChars,"").split("|"),i=t[3]?.trim()?t[3].replace(this.rules.other.tableRowBlankLine,"").split(`
|
||||
`):[],r={type:"table",raw:t[0],header:[],align:[],rows:[]};if(n.length===s.length){for(let o of s)this.rules.other.tableAlignRight.test(o)?r.align.push("right"):this.rules.other.tableAlignCenter.test(o)?r.align.push("center"):this.rules.other.tableAlignLeft.test(o)?r.align.push("left"):r.align.push(null);for(let o=0;o<n.length;o++)r.header.push({text:n[o],tokens:this.lexer.inline(n[o]),header:!0,align:r.align[o]});for(let o of i)r.rows.push(Y(o,r.header.length).map((a,c)=>({text:a,tokens:this.lexer.inline(a),header:!1,align:r.align[c]})));return r}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===`
|
||||
`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let r=A(n.slice(0,-1),"\\");if((n.length-r.length)%2===0)return}else{let r=de(t[2],"()");if(r===-2)return;if(r>-1){let a=(t[0].indexOf("!")===0?5:4)+t[1].length+r;t[2]=t[2].substring(0,r),t[0]=t[0].substring(0,a).trim(),t[3]=""}}let s=t[2],i="";if(this.options.pedantic){let r=this.rules.other.pedanticHrefTitle.exec(s);r&&(s=r[1],i=r[3])}else i=t[3]?t[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),me(t,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=t[s.toLowerCase()];if(!i){let r=n[0].charAt(0);return{type:"text",raw:r,text:r}}return me(n,i,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s||s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){let r=[...s[0]].length-1,o,a,c=r,p=0,u=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(u.lastIndex=0,t=t.slice(-1*e.length+r);(s=u.exec(t))!=null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o)continue;if(a=[...o].length,s[3]||s[4]){c+=a;continue}else if((s[5]||s[6])&&r%3&&!((r+a)%3)){p+=a;continue}if(c-=a,c>0)continue;a=Math.min(a,a+c+p);let d=[...s[0]][0].length,g=e.slice(0,r+s.index+d+a);if(Math.min(r,a)%2){let f=g.slice(1,-1);return{type:"em",raw:g,text:f,tokens:this.lexer.inlineTokens(f)}}let T=g.slice(2,-2);return{type:"strong",raw:g,text:T,tokens:this.lexer.inlineTokens(T)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),i=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&i&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){let t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,s;return t[2]==="@"?(n=t[1],s="mailto:"+n):(n=t[1],s=n),{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,s;if(t[2]==="@")n=t[0],s="mailto:"+n;else{let i;do i=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(i!==t[0]);n=t[0],t[1]==="www."?s="http://"+t[0]:s=t[0]}return{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}};var x=class l{tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||w,this.options.tokenizer=this.options.tokenizer||new S,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:B.normal,inline:P.normal};this.options.pedantic?(t.block=B.pedantic,t.inline=P.pedantic):this.options.gfm&&(t.block=B.gfm,this.options.breaks?t.inline=P.breaks:t.inline=P.gfm),this.tokenizer.rules=t}static get rules(){return{block:B,inline:P}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,`
|
||||
`),this.blockTokens(e,this.tokens);for(let t=0;t<this.inlineQueue.length;t++){let n=this.inlineQueue[t];this.inlineTokens(n.src,n.tokens)}return this.inlineQueue=[],this.tokens}blockTokens(e,t=[],n=!1){for(this.options.pedantic&&(e=e.replace(m.tabCharGlobal," ").replace(m.spaceLine,""));e;){let s;if(this.options.extensions?.block?.some(r=>(s=r.call({lexer:this},e,t))?(e=e.substring(s.raw.length),t.push(s),!0):!1))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);let r=t.at(-1);s.raw.length===1&&r!==void 0?r.raw+=`
|
||||
`:t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=`
|
||||
`+s.raw,r.text+=`
|
||||
`+s.text,this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=`
|
||||
`+s.raw,r.text+=`
|
||||
`+s.raw,this.inlineQueue.at(-1).src=r.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let i=e;if(this.options.extensions?.startBlock){let r=1/0,o=e.slice(1),a;this.options.extensions.startBlock.forEach(c=>{a=c.call({lexer:this},o),typeof a=="number"&&a>=0&&(r=Math.min(r,a))}),r<1/0&&r>=0&&(i=e.substring(0,r+1))}if(this.state.top&&(s=this.tokenizer.paragraph(i))){let r=t.at(-1);n&&r?.type==="paragraph"?(r.raw+=`
|
||||
`+s.raw,r.text+=`
|
||||
`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s),n=i.length!==e.length,e=e.substring(s.raw.length);continue}if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="text"?(r.raw+=`
|
||||
`+s.raw,r.text+=`
|
||||
`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(e){let r="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(r);break}else throw new Error(r)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){let o=Object.keys(this.tokens.links);if(o.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)o.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(s=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,r="";for(;e;){i||(r=""),i=!1;let o;if(this.options.extensions?.inline?.some(c=>(o=c.call({lexer:this},e,t))?(e=e.substring(o.raw.length),t.push(o),!0):!1))continue;if(o=this.tokenizer.escape(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.tag(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.link(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(o.raw.length);let c=t.at(-1);o.type==="text"&&c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(o=this.tokenizer.emStrong(e,n,r)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.codespan(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.br(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.del(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.autolink(e)){e=e.substring(o.raw.length),t.push(o);continue}if(!this.state.inLink&&(o=this.tokenizer.url(e))){e=e.substring(o.raw.length),t.push(o);continue}let a=e;if(this.options.extensions?.startInline){let c=1/0,p=e.slice(1),u;this.options.extensions.startInline.forEach(d=>{u=d.call({lexer:this},p),typeof u=="number"&&u>=0&&(c=Math.min(c,u))}),c<1/0&&c>=0&&(a=e.substring(0,c+1))}if(o=this.tokenizer.inlineText(a)){e=e.substring(o.raw.length),o.raw.slice(-1)!=="_"&&(r=o.raw.slice(-1)),i=!0;let c=t.at(-1);c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(e){let c="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(c);break}else throw new Error(c)}}return t}};var $=class{options;parser;constructor(e){this.options=e||w}space(e){return""}code({text:e,lang:t,escaped:n}){let s=(t||"").match(m.notSpaceStart)?.[0],i=e.replace(m.endingNewline,"")+`
|
||||
`;return s?'<pre><code class="language-'+R(s)+'">'+(n?i:R(i,!0))+`</code></pre>
|
||||
`:"<pre><code>"+(n?i:R(i,!0))+`</code></pre>
|
||||
`}blockquote({tokens:e}){return`<blockquote>
|
||||
${this.parser.parse(e)}</blockquote>
|
||||
`}html({text:e}){return e}heading({tokens:e,depth:t}){return`<h${t}>${this.parser.parseInline(e)}</h${t}>
|
||||
`}hr(e){return`<hr>
|
||||
`}list(e){let t=e.ordered,n=e.start,s="";for(let o=0;o<e.items.length;o++){let a=e.items[o];s+=this.listitem(a)}let i=t?"ol":"ul",r=t&&n!==1?' start="'+n+'"':"";return"<"+i+r+`>
|
||||
`+s+"</"+i+`>
|
||||
`}listitem(e){let t="";if(e.task){let n=this.checkbox({checked:!!e.checked});e.loose?e.tokens[0]?.type==="paragraph"?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&e.tokens[0].tokens[0].type==="text"&&(e.tokens[0].tokens[0].text=n+" "+R(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`<li>${t}</li>
|
||||
`}checkbox({checked:e}){return"<input "+(e?'checked="" ':"")+'disabled="" type="checkbox">'}paragraph({tokens:e}){return`<p>${this.parser.parseInline(e)}</p>
|
||||
`}table(e){let t="",n="";for(let i=0;i<e.header.length;i++)n+=this.tablecell(e.header[i]);t+=this.tablerow({text:n});let s="";for(let i=0;i<e.rows.length;i++){let r=e.rows[i];n="";for(let o=0;o<r.length;o++)n+=this.tablecell(r[o]);s+=this.tablerow({text:n})}return s&&(s=`<tbody>${s}</tbody>`),`<table>
|
||||
<thead>
|
||||
`+t+`</thead>
|
||||
`+s+`</table>
|
||||
`}tablerow({text:e}){return`<tr>
|
||||
${e}</tr>
|
||||
`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`</${n}>
|
||||
`}strong({tokens:e}){return`<strong>${this.parser.parseInline(e)}</strong>`}em({tokens:e}){return`<em>${this.parser.parseInline(e)}</em>`}codespan({text:e}){return`<code>${R(e,!0)}</code>`}br(e){return"<br>"}del({tokens:e}){return`<del>${this.parser.parseInline(e)}</del>`}link({href:e,title:t,tokens:n}){let s=this.parser.parseInline(n),i=V(e);if(i===null)return s;e=i;let r='<a href="'+e+'"';return t&&(r+=' title="'+R(t)+'"'),r+=">"+s+"</a>",r}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let i=V(e);if(i===null)return R(n);e=i;let r=`<img src="${e}" alt="${n}"`;return t&&(r+=` title="${R(t)}"`),r+=">",r}text(e){return"tokens"in e&&e.tokens?this.parser.parseInline(e.tokens):"escaped"in e&&e.escaped?e.text:R(e.text)}};var _=class{strong({text:e}){return e}em({text:e}){return e}codespan({text:e}){return e}del({text:e}){return e}html({text:e}){return e}text({text:e}){return e}link({text:e}){return""+e}image({text:e}){return""+e}br(){return""}};var b=class l{options;renderer;textRenderer;constructor(e){this.options=e||w,this.options.renderer=this.options.renderer||new $,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new _}static parse(e,t){return new l(t).parse(e)}static parseInline(e,t){return new l(t).parseInline(e)}parse(e,t=!0){let n="";for(let s=0;s<e.length;s++){let i=e[s];if(this.options.extensions?.renderers?.[i.type]){let o=i,a=this.options.extensions.renderers[o.type].call({parser:this},o);if(a!==!1||!["space","hr","heading","code","table","blockquote","list","html","paragraph","text"].includes(o.type)){n+=a||"";continue}}let r=i;switch(r.type){case"space":{n+=this.renderer.space(r);continue}case"hr":{n+=this.renderer.hr(r);continue}case"heading":{n+=this.renderer.heading(r);continue}case"code":{n+=this.renderer.code(r);continue}case"table":{n+=this.renderer.table(r);continue}case"blockquote":{n+=this.renderer.blockquote(r);continue}case"list":{n+=this.renderer.list(r);continue}case"html":{n+=this.renderer.html(r);continue}case"paragraph":{n+=this.renderer.paragraph(r);continue}case"text":{let o=r,a=this.renderer.text(o);for(;s+1<e.length&&e[s+1].type==="text";)o=e[++s],a+=`
|
||||
`+this.renderer.text(o);t?n+=this.renderer.paragraph({type:"paragraph",raw:a,text:a,tokens:[{type:"text",raw:a,text:a,escaped:!0}]}):n+=a;continue}default:{let o='Token with "'+r.type+'" type was not found.';if(this.options.silent)return console.error(o),"";throw new Error(o)}}}return n}parseInline(e,t=this.renderer){let n="";for(let s=0;s<e.length;s++){let i=e[s];if(this.options.extensions?.renderers?.[i.type]){let o=this.options.extensions.renderers[i.type].call({parser:this},i);if(o!==!1||!["escape","html","link","image","strong","em","codespan","br","del","text"].includes(i.type)){n+=o||"";continue}}let r=i;switch(r.type){case"escape":{n+=t.text(r);break}case"html":{n+=t.html(r);break}case"link":{n+=t.link(r);break}case"image":{n+=t.image(r);break}case"strong":{n+=t.strong(r);break}case"em":{n+=t.em(r);break}case"codespan":{n+=t.codespan(r);break}case"br":{n+=t.br(r);break}case"del":{n+=t.del(r);break}case"text":{n+=t.text(r);break}default:{let o='Token with "'+r.type+'" type was not found.';if(this.options.silent)return console.error(o),"";throw new Error(o)}}}return n}};var L=class{options;block;constructor(e){this.options=e||w}static passThroughHooks=new Set(["preprocess","postprocess","processAllTokens"]);preprocess(e){return e}postprocess(e){return e}processAllTokens(e){return e}provideLexer(){return this.block?x.lex:x.lexInline}provideParser(){return this.block?b.parse:b.parseInline}};var E=class{defaults=z();options=this.setOptions;parse=this.parseMarkdown(!0);parseInline=this.parseMarkdown(!1);Parser=b;Renderer=$;TextRenderer=_;Lexer=x;Tokenizer=S;Hooks=L;constructor(...e){this.use(...e)}walkTokens(e,t){let n=[];for(let s of e)switch(n=n.concat(t.call(this,s)),s.type){case"table":{let i=s;for(let r of i.header)n=n.concat(this.walkTokens(r.tokens,t));for(let r of i.rows)for(let o of r)n=n.concat(this.walkTokens(o.tokens,t));break}case"list":{let i=s;n=n.concat(this.walkTokens(i.items,t));break}default:{let i=s;this.defaults.extensions?.childTokens?.[i.type]?this.defaults.extensions.childTokens[i.type].forEach(r=>{let o=i[r].flat(1/0);n=n.concat(this.walkTokens(o,t))}):i.tokens&&(n=n.concat(this.walkTokens(i.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let r=t.renderers[i.name];r?t.renderers[i.name]=function(...o){let a=i.renderer.apply(this,o);return a===!1&&(a=r.apply(this,o)),a}:t.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let r=t[i.level];r?r.unshift(i.tokenizer):t[i.level]=[i.tokenizer],i.start&&(i.level==="block"?t.startBlock?t.startBlock.push(i.start):t.startBlock=[i.start]:i.level==="inline"&&(t.startInline?t.startInline.push(i.start):t.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(t.childTokens[i.name]=i.childTokens)}),s.extensions=t),n.renderer){let i=this.defaults.renderer||new $(this.defaults);for(let r in n.renderer){if(!(r in i))throw new Error(`renderer '${r}' does not exist`);if(["options","parser"].includes(r))continue;let o=r,a=n.renderer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u||""}}s.renderer=i}if(n.tokenizer){let i=this.defaults.tokenizer||new S(this.defaults);for(let r in n.tokenizer){if(!(r in i))throw new Error(`tokenizer '${r}' does not exist`);if(["options","rules","lexer"].includes(r))continue;let o=r,a=n.tokenizer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.tokenizer=i}if(n.hooks){let i=this.defaults.hooks||new L;for(let r in n.hooks){if(!(r in i))throw new Error(`hook '${r}' does not exist`);if(["options","block"].includes(r))continue;let o=r,a=n.hooks[o],c=i[o];L.passThroughHooks.has(r)?i[o]=p=>{if(this.defaults.async)return Promise.resolve(a.call(i,p)).then(d=>c.call(i,d));let u=a.call(i,p);return c.call(i,u)}:i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.hooks=i}if(n.walkTokens){let i=this.defaults.walkTokens,r=n.walkTokens;s.walkTokens=function(o){let a=[];return a.push(r.call(this,o)),i&&(a=a.concat(i.call(this,o))),a}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return x.lex(e,t??this.defaults)}parser(e,t){return b.parse(e,t??this.defaults)}parseMarkdown(e){return(n,s)=>{let i={...s},r={...this.defaults,...i},o=this.onError(!!r.silent,!!r.async);if(this.defaults.async===!0&&i.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.
Line truncated
|
||||
Please report this to https://github.com/markedjs/marked.`,e){let s="<p>An error occurred:</p><pre>"+R(n.message+"",!0)+"</pre>";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new E;function k(l,e){return M.parse(l,e)}k.options=k.setOptions=function(l){return M.setOptions(l),k.defaults=M.defaults,N(k.defaults),k};k.getDefaults=z;k.defaults=w;k.use=function(...l){return M.use(...l),k.defaults=M.defaults,N(k.defaults),k};k.walkTokens=function(l,e){return M.walkTokens(l,e)};k.parseInline=M.parseInline;k.Parser=b;k.parser=b.parse;k.Renderer=$;k.TextRenderer=_;k.Lexer=x;k.lexer=x.lex;k.Tokenizer=S;k.Hooks=L;k.parse=k;var it=k.options,ot=k.setOptions,lt=k.use,at=k.walkTokens,ct=k.parseInline,pt=k,ut=b.parse,ht=x.lex;
|
||||
|
||||
if(__exports != exports)module.exports = exports;return module.exports}));
|
||||