<# .SYNOPSIS Aggregates Secure Boot status JSON data from multiple devices into summary reports. .DESCRIPTION Reads collected Secure Boot status JSON files and generates: - HTML Dashboard with charts and filtering - Summary by ConfidenceLevel - Unique device bucket analysis for testing strategy Supports: - Per-machine files: HOSTNAME_latest.json (recommended) - Single JSON file Automatically deduplicates by HostName, keeping latest CollectionTime. By default, only includes devices with "Action Req" or "High" confidence to focus on actionable buckets. Use -IncludeAllConfidenceLevels to override. .PARAMETER InputPath Path to JSON file(s): - Folder: Reads all *_latest.json files (or *.json if no _latest files) - File: Reads single JSON file .PARAMETER OutputPath Path for generated reports (default: .\SecureBootReports) .EXAMPLE # Aggregate from folder of per-machine files (recommended) .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" # Reads: \\contoso\SecureBootLogs$\*_latest.json .EXAMPLE # Custom output location .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -OutputPath "C:\Reports\SecureBoot" .EXAMPLE # Include only Action Req and High confidence (default behavior) .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" # Excludes: Observation, Paused, Not Supported .EXAMPLE # Include all confidence levels (override filter) .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -IncludeAllConfidenceLevels .EXAMPLE # Custom confidence level filter .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -IncludeConfidenceLevels @("Action Req", "High", "Observation") .EXAMPLE # ENTERPRISE SCALE: Incremental mode - only process changed files (fast subsequent runs) .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -IncrementalMode # First run: Full load ~2 hours for 500K devices # Subsequent runs: Seconds if no changes, minutes for deltas .EXAMPLE # Skip HTML if nothing changed (fastest for monitoring) .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -IncrementalMode -SkipReportIfUnchanged # If no files changed since last run: ~5 seconds .EXAMPLE # Summary only mode - skip large device tables (1-2 minutes vs 20+ minutes) .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -SummaryOnly # Generates CSVs but skips HTML dashboard with full device tables .NOTES Pair with Detect-SecureBootCertUpdateStatus.ps1 for enterprise deployment. See GPO-DEPLOYMENT-GUIDE.md for full deployment guide. Default behavior excludes Observation, Paused, and Not Supported devices to focus reporting on actionable device buckets only. #> param( [Parameter(Mandatory = $true)] [string]$InputPath, [Parameter(Mandatory = $false)] [string]$OutputPath = ".\SecureBootReports", [Parameter(Mandatory = $false)] [string]$ScanHistoryPath = ".\SecureBootReports\ScanHistory.json", [Parameter(Mandatory = $false)] [string]$RolloutStatePath, # Path to RolloutState.json to identify InProgress devices [Parameter(Mandatory = $false)] [string]$RolloutSummaryPath, # Path to SecureBootRolloutSummary.json from Orchestrator (contains projection data) [Parameter(Mandatory = $false)] [string[]]$IncludeConfidenceLevels = @("Action Required", "High Confidence"), # Only include these confidence levels (default: actionable buckets only) [Parameter(Mandatory = $false)] [switch]$IncludeAllConfidenceLevels, # Override filter to include all confidence levels [Parameter(Mandatory = $false)] [switch]$SkipHistoryTracking, [Parameter(Mandatory = $false)] [switch]$IncrementalMode, # Enable delta processing - only load changed files since last run [Parameter(Mandatory = $false)] [string]$CachePath, # Path to cache directory (default: OutputPath\.cache) [Parameter(Mandatory = $false)] [int]$ParallelThreads = 8, # Number of parallel threads for file loading (PS7+) [Parameter(Mandatory = $false)] [switch]$ForceFullRefresh, # Force full reload even in incremental mode [Parameter(Mandatory = $false)] [switch]$SkipReportIfUnchanged, # Skip HTML/CSV generation if no files changed (just output stats) [Parameter(Mandatory = $false)] [switch]$SummaryOnly, # Generate summary stats only (no large device tables) - much faster [Parameter(Mandatory = $false)] [switch]$StreamingMode # Memory-efficient mode: process chunks, write CSVs incrementally, keep only summaries in memory ) # Self-repair: Strip invisible Unicode characters injected by web CMS when copy-pasting from HTML articles. # The support.microsoft.com CMS injects Zero Width Spaces (U+200B), Non-Breaking Spaces (U+00A0), and other # invisible characters around tags inside here-strings, causing PowerShell parse errors. if ($MyInvocation.MyCommand.Path) { $rawScript = [System.IO.File]::ReadAllText($MyInvocation.MyCommand.Path) if ($rawScript -match '[\u200B-\u200F\uFEFF]' -or $rawScript -match '\xA0') { Write-Host "WARNING: Invisible Unicode characters detected (likely from web copy-paste) - auto-cleaning script..." -ForegroundColor Yellow $cleaned = $rawScript -replace '[\u200B-\u200F\uFEFF]', '' $cleaned = $cleaned -replace '\xA0', ' ' [System.IO.File]::WriteAllText($MyInvocation.MyCommand.Path, $cleaned, [System.Text.UTF8Encoding]::new($false)) Write-Host "Script cleaned successfully. Re-launching..." -ForegroundColor Green & $MyInvocation.MyCommand.Path @PSBoundParameters exit $LASTEXITCODE } } # Auto-elevate to PowerShell 7 if available (6x faster for large datasets) if ($PSVersionTable.PSVersion.Major -lt 7) { $pwshPath = Get-Command pwsh -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source if ($pwshPath) { Write-Host "PowerShell $($PSVersionTable.PSVersion) detected - re-launching with PowerShell 7 for faster processing..." -ForegroundColor Yellow # Rebuild argument list from bound parameters $relaunchArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $MyInvocation.MyCommand.Path) foreach ($key in $PSBoundParameters.Keys) { $val = $PSBoundParameters[$key] if ($val -is [switch]) { if ($val.IsPresent) { $relaunchArgs += "-$key" } } elseif ($val -is [array]) { $relaunchArgs += "-$key" $relaunchArgs += ($val -join ',') } else { $relaunchArgs += "-$key" $relaunchArgs += "$val" } } & $pwshPath @relaunchArgs exit $LASTEXITCODE } } $ErrorActionPreference = "Continue" $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" $scanTime = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $DownloadUrl = "https://aka.ms/getsecureboot" $DownloadSubPage = "Deployment and Monitoring Samples" # Note: This script has no dependencies on other scripts. # For the complete toolset, download from: $DownloadUrl -> $DownloadSubPage #region Setup Write-Host "=" * 60 -ForegroundColor Cyan Write-Host "Secure Boot Data Aggregation" -ForegroundColor Cyan Write-Host "=" * 60 -ForegroundColor Cyan # Create output directory if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } # Load data - supports CSV (legacy) and JSON (native) formats Write-Host "`nLoading data from: $InputPath" -ForegroundColor Yellow # Helper function to normalize device object (handle field name differences) function Normalize-DeviceRecord { param($device) # Handle Hostname vs HostName (JSON uses Hostname, CSV uses HostName) if ($device.PSObject.Properties['Hostname'] -and -not $device.PSObject.Properties['HostName']) { $device | Add-Member -NotePropertyName 'HostName' -NotePropertyValue $device.Hostname -Force } # Handle Confidence vs ConfidenceLevel (JSON uses Confidence, CSV uses ConfidenceLevel) # ConfidenceLevel is the official field name - map Confidence to it if ($device.PSObject.Properties['Confidence'] -and -not $device.PSObject.Properties['ConfidenceLevel']) { $device | Add-Member -NotePropertyName 'ConfidenceLevel' -NotePropertyValue $device.Confidence -Force } # Track update status via Event1808Count OR UEFICA2023Status="Updated" # This allows tracking how many devices in each confidence bucket have been updated $event1808 = 0 if ($device.PSObject.Properties['Event1808Count']) { $event1808 = [int]$device.Event1808Count } $uefiCaUpdated = $false if ($device.PSObject.Properties['UEFICA2023Status'] -and $device.UEFICA2023Status -eq "Updated") { $uefiCaUpdated = $true } if ($event1808 -gt 0 -or $uefiCaUpdated) { # Mark as updated for dashboard/rollout logic - but DON'T override ConfidenceLevel $device | Add-Member -NotePropertyName 'IsUpdated' -NotePropertyValue $true -Force } else { $device | Add-Member -NotePropertyName 'IsUpdated' -NotePropertyValue $false -Force # ConfidenceLevel classification: # - "High Confidence", "Under Observation...", "Temporarily Paused...", "Not Supported..." = use as-is # - Everything else (null, empty, "UpdateType:...", "Unknown", "N/A") = falls to Action Required in counters # No normalization needed — the streaming counter's else branch handles it } # Handle OEMManufacturerName vs WMI_Manufacturer (JSON uses OEM*, legacy uses WMI_*) if ($device.PSObject.Properties['OEMManufacturerName'] -and -not $device.PSObject.Properties['WMI_Manufacturer']) { $device | Add-Member -NotePropertyName 'WMI_Manufacturer' -NotePropertyValue $device.OEMManufacturerName -Force } # Handle OEMModelNumber vs WMI_Model if ($device.PSObject.Properties['OEMModelNumber'] -and -not $device.PSObject.Properties['WMI_Model']) { $device | Add-Member -NotePropertyName 'WMI_Model' -NotePropertyValue $device.OEMModelNumber -Force } # Handle FirmwareVersion vs BIOSDescription if ($device.PSObject.Properties['FirmwareVersion'] -and -not $device.PSObject.Properties['BIOSDescription']) { $device | Add-Member -NotePropertyName 'BIOSDescription' -NotePropertyValue $device.FirmwareVersion -Force } return $device } #region Incremental Processing / Cache Management # Setup cache paths if (-not $CachePath) { $CachePath = Join-Path $OutputPath ".cache" } $manifestPath = Join-Path $CachePath "FileManifest.json" $deviceCachePath = Join-Path $CachePath "DeviceCache.json" # Cache management functions function Get-FileManifest { param([string]$Path) if (Test-Path $Path) { try { $json = Get-Content $Path -Raw | ConvertFrom-Json # Convert PSObject to hashtable (PS5.1 compatible - PS7 has -AsHashtable) $ht = @{} $json.PSObject.Properties | ForEach-Object { $ht[$_.Name] = $_.Value } return $ht } catch { return @{} } } return @{} } function Save-FileManifest { param([hashtable]$Manifest, [string]$Path) $dir = Split-Path $Path -Parent if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $Manifest | ConvertTo-Json -Depth 3 -Compress | Set-Content $Path -Force } function Get-DeviceCache { param([string]$Path) if (Test-Path $Path) { try { $cacheData = Get-Content $Path -Raw | ConvertFrom-Json Write-Host " Loaded device cache: $($cacheData.Count) devices" -ForegroundColor DarkGray return $cacheData } catch { Write-Host " Cache corrupted, will rebuild" -ForegroundColor Yellow return @() } } return @() } function Save-DeviceCache { param($Devices, [string]$Path) $dir = Split-Path $Path -Parent if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } # Convert to array and save $deviceArray = @($Devices) $deviceArray | ConvertTo-Json -Depth 10 -Compress | Set-Content $Path -Force Write-Host " Saved device cache: $($deviceArray.Count) devices" -ForegroundColor DarkGray } function Get-ChangedFiles { param( [System.IO.FileInfo[]]$AllFiles, [hashtable]$Manifest ) $changed = [System.Collections.ArrayList]::new() $unchanged = [System.Collections.ArrayList]::new() $newManifest = @{} # Build case-insensitive lookup from manifest (normalize to lowercase) $manifestLookup = @{} foreach ($mk in $Manifest.Keys) { $manifestLookup[$mk.ToLowerInvariant()] = $Manifest[$mk] } foreach ($file in $AllFiles) { $key = $file.FullName.ToLowerInvariant() # Normalize path to lowercase $lwt = $file.LastWriteTimeUtc.ToString("o") $newManifest[$key] = @{ LastWriteTimeUtc = $lwt Size = $file.Length } if ($manifestLookup.ContainsKey($key)) { $cached = $manifestLookup[$key] if ($cached.LastWriteTimeUtc -eq $lwt -and $cached.Size -eq $file.Length) { [void]$unchanged.Add($file) continue } } [void]$changed.Add($file) } return @{ Changed = $changed Unchanged = $unchanged NewManifest = $newManifest } } # Ultra-fast parallel file loading using batched processing function Load-FilesParallel { param( [System.IO.FileInfo[]]$Files, [int]$Threads = 8 ) $totalFiles = $Files.Count # Use batches of ~1000 files each for better memory control $batchSize = [math]::Min(1000, [math]::Ceiling($totalFiles / [math]::Max(1, $Threads))) $batches = [System.Collections.Generic.List[object]]::new() for ($i = 0; $i -lt $totalFiles; $i += $batchSize) { $end = [math]::Min($i + $batchSize, $totalFiles) $batch = $Files[$i..($end-1)] $batches.Add($batch) } Write-Host " ($($batches.Count) batches of ~$batchSize files each)" -NoNewline -ForegroundColor DarkGray $flatResults = [System.Collections.Generic.List[object]]::new() # Check if PowerShell 7+ parallel is available $canParallel = $PSVersionTable.PSVersion.Major -ge 7 if ($canParallel -and $Threads -gt 1) { # PS7+: Process batches in parallel $results = $batches | ForEach-Object -ThrottleLimit $Threads -Parallel { $batchFiles = $_ $batchResults = [System.Collections.Generic.List[object]]::new() foreach ($file in $batchFiles) { try { $content = [System.IO.File]::ReadAllText($file.FullName) | ConvertFrom-Json $batchResults.Add($content) } catch { } } $batchResults.ToArray() } foreach ($batch in $results) { if ($batch) { foreach ($item in $batch) { $flatResults.Add($item) } } } } else { # PS5.1 fallback: Sequential processing (still fast for <10K files) foreach ($file in $Files) { try { $content = [System.IO.File]::ReadAllText($file.FullName) | ConvertFrom-Json $flatResults.Add($content) } catch { } } } return $flatResults.ToArray() } #endregion $allDevices = @() if (Test-Path $InputPath -PathType Leaf) { # Single JSON file if ($InputPath -like "*.json") { $jsonContent = Get-Content -Path $InputPath -Raw | ConvertFrom-Json $allDevices = @($jsonContent) | ForEach-Object { Normalize-DeviceRecord $_ } Write-Host "Loaded $($allDevices.Count) records from file" } else { Write-Error "Only JSON format is supported. File must have .json extension." exit 1 } } elseif (Test-Path $InputPath -PathType Container) { # Folder - JSON only $jsonFiles = @(Get-ChildItem -Path $InputPath -Filter "*.json" -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Name -notmatch "ScanHistory|RolloutState|RolloutPlan" }) # Prefer *_latest.json files if they exist (per-machine mode) $latestJson = $jsonFiles | Where-Object { $_.Name -like "*_latest.json" } if ($latestJson.Count -gt 0) { $jsonFiles = $latestJson } $totalFiles = $jsonFiles.Count if ($totalFiles -eq 0) { Write-Error "No JSON files found in: $InputPath" exit 1 } Write-Host "Found $totalFiles JSON files" -ForegroundColor Gray # Helper function to match confidence levels (handles both short and full forms) # Defined early so both StreamingMode and normal paths can use it function Test-ConfidenceLevel { param([string]$Value, [string]$Match) if ([string]::IsNullOrEmpty($Value)) { return $false } switch ($Match) { "HighConfidence" { return $Value -eq "High Confidence" } "UnderObservation" { return $Value -like "Under Observation*" } "ActionRequired" { return ($Value -like "*Action Required*" -or $Value -eq "Action Required") } "TemporarilyPaused" { return $Value -like "Temporarily Paused*" } "NotSupported" { return ($Value -like "Not Supported*" -or $Value -eq "Not Supported") } default { return $false } } } #region STREAMING MODE - Memory-efficient processing for large datasets # Always use StreamingMode for memory-efficient processing and new-style dashboard if (-not $StreamingMode) { Write-Host "Auto-enabling StreamingMode (new-style dashboard)" -ForegroundColor Yellow $StreamingMode = $true if (-not $IncrementalMode) { $IncrementalMode = $true } } # When -StreamingMode is enabled, process files in chunks keeping only counters in memory. # Device-level data is written to JSON files per-chunk for on-demand loading in the dashboard. # Memory usage: ~1.5 GB regardless of dataset size (vs 10-20 GB without streaming). if ($StreamingMode) { Write-Host "STREAMING MODE enabled - memory-efficient processing" -ForegroundColor Green $streamSw = [System.Diagnostics.Stopwatch]::StartNew() # INCREMENTAL CHECK: If no files changed since last run, skip processing entirely if ($IncrementalMode -and -not $ForceFullRefresh) { $stManifestDir = Join-Path $OutputPath ".cache" $stManifestPath = Join-Path $stManifestDir "StreamingManifest.json" if (Test-Path $stManifestPath) { Write-Host "Checking for changes since last streaming run..." -ForegroundColor Cyan $stOldManifest = Get-FileManifest -Path $stManifestPath if ($stOldManifest.Count -gt 0) { $stChanged = $false # Quick check: same file count? if ($stOldManifest.Count -eq $totalFiles) { # Check the 100 NEWEST files (sorted by LastWriteTime descending) # If any file changed, it will have the most recent timestamp and appear first $sampleSize = [math]::Min(100, $totalFiles) $sampleFiles = $jsonFiles | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First $sampleSize foreach ($sf in $sampleFiles) { $sfKey = $sf.FullName.ToLowerInvariant() if (-not $stOldManifest.ContainsKey($sfKey)) { $stChanged = $true break } # Compare timestamps - cached may be DateTime or string after JSON roundtrip $cachedLWT = $stOldManifest[$sfKey].LastWriteTimeUtc $fileDT = $sf.LastWriteTimeUtc try { # If cached is already DateTime (ConvertFrom-Json auto-converts), use directly if ($cachedLWT -is [DateTime]) { $cachedDT = $cachedLWT.ToUniversalTime() } else { $cachedDT = [DateTimeOffset]::Parse("$cachedLWT").UtcDateTime } if ([math]::Abs(($cachedDT - $fileDT).TotalSeconds) -gt 1) { $stChanged = $true break } } catch { $stChanged = $true break } } } else { $stChanged = $true } if (-not $stChanged) { # Check if output files exist $stSummaryExists = Get-ChildItem (Join-Path $OutputPath "SecureBoot_Summary_*.csv") -EA SilentlyContinue | Select-Object -First 1 $stDashExists = Get-ChildItem (Join-Path $OutputPath "SecureBoot_Dashboard_*.html") -EA SilentlyContinue | Select-Object -First 1 if ($stSummaryExists -and $stDashExists) { Write-Host " No changes detected ($totalFiles files unchanged) - skipping processing" -ForegroundColor Green Write-Host " Last dashboard: $($stDashExists.FullName)" -ForegroundColor White $cachedStats = Get-Content $stSummaryExists.FullName | ConvertFrom-Csv Write-Host " Devices: $($cachedStats.TotalDevices) | Updated: $($cachedStats.Updated) | Errors: $($cachedStats.WithErrors)" -ForegroundColor Gray Write-Host " Completed in $([math]::Round($streamSw.Elapsed.TotalSeconds, 1))s (no processing needed)" -ForegroundColor Green return $cachedStats } } else { # DELTA PATCH: Find exactly which files changed Write-Host " Changes detected - identifying changed files..." -ForegroundColor Yellow $changedFiles = [System.Collections.ArrayList]::new() $newFiles = [System.Collections.ArrayList]::new() foreach ($jf in $jsonFiles) { $jfKey = $jf.FullName.ToLowerInvariant() if (-not $stOldManifest.ContainsKey($jfKey)) { [void]$newFiles.Add($jf) } else { $cachedLWT = $stOldManifest[$jfKey].LastWriteTimeUtc $fileDT = $jf.LastWriteTimeUtc try { $cachedDT = if ($cachedLWT -is [DateTime]) { $cachedLWT.ToUniversalTime() } else { [DateTimeOffset]::Parse("$cachedLWT").UtcDateTime } if ([math]::Abs(($cachedDT - $fileDT).TotalSeconds) -gt 1) { [void]$changedFiles.Add($jf) } } catch { [void]$changedFiles.Add($jf) } } } $totalChanged = $changedFiles.Count + $newFiles.Count $changePct = [math]::Round(($totalChanged / $totalFiles) * 100, 1) Write-Host " Changed: $($changedFiles.Count) | New: $($newFiles.Count) | Total: $totalChanged ($changePct%)" -ForegroundColor Yellow if ($totalChanged -gt 0 -and $changePct -lt 10) { # DELTA PATCH MODE: <10% changed, patch existing data Write-Host " Delta patch mode ($changePct% < 10%) - patching $totalChanged files..." -ForegroundColor Green $dataDir = Join-Path $OutputPath "data" # Load changed/new device records $deltaDevices = @{} $allDeltaFiles = @($changedFiles) + @($newFiles) foreach ($df in $allDeltaFiles) { try { $devData = Get-Content $df.FullName -Raw | ConvertFrom-Json $dev = Normalize-DeviceRecord $devData if ($dev.HostName) { $deltaDevices[$dev.HostName] = $dev } } catch { } } Write-Host " Loaded $($deltaDevices.Count) changed device records" -ForegroundColor Gray # For each category JSON: remove old entries for changed hostnames, add new entries $categoryFiles = @("errors", "known_issues", "missing_kek", "not_updated", "task_disabled", "temp_failures", "perm_failures", "updated_devices", "action_required", "secureboot_off", "rollout_inprogress") $changedHostnames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($hn in $deltaDevices.Keys) { [void]$changedHostnames.Add($hn) } foreach ($cat in $categoryFiles) { $catPath = Join-Path $dataDir "$cat.json" if (Test-Path $catPath) { try { $catData = Get-Content $catPath -Raw | ConvertFrom-Json # Remove old entries for changed hostnames $catData = @($catData | Where-Object { -not $changedHostnames.Contains($_.HostName) }) # Re-classify each changed device into categories # (will be added below after classification) $catData | ConvertTo-Json -Depth 5 | Set-Content $catPath -Encoding UTF8 } catch { } } } # Classify each changed device and append to the right category files foreach ($dev in $deltaDevices.Values) { $slim = [ordered]@{ HostName = $dev.HostName WMI_Manufacturer = if ($dev.PSObject.Properties['WMI_Manufacturer']) { $dev.WMI_Manufacturer } else { "" } WMI_Model = if ($dev.PSObject.Properties['WMI_Model']) { $dev.WMI_Model } else { "" } BucketId = if ($dev.PSObject.Properties['BucketId']) { $dev.BucketId } else { "" } ConfidenceLevel = if ($dev.PSObject.Properties['ConfidenceLevel']) { $dev.ConfidenceLevel } else { "" } IsUpdated = $dev.IsUpdated UEFICA2023Error = if ($dev.PSObject.Properties['UEFICA2023Error']) { $dev.UEFICA2023Error } else { $null } SecureBootTaskStatus = if ($dev.PSObject.Properties['SecureBootTaskStatus']) { $dev.SecureBootTaskStatus } else { "" } KnownIssueId = if ($dev.PSObject.Properties['KnownIssueId']) { $dev.KnownIssueId } else { $null } SkipReasonKnownIssue = if ($dev.PSObject.Properties['SkipReasonKnownIssue']) { $dev.SkipReasonKnownIssue } else { $null } } $isUpd = $dev.IsUpdated -eq $true $conf = if ($dev.PSObject.Properties['ConfidenceLevel']) { $dev.ConfidenceLevel } else { "" } $hasErr = (-not [string]::IsNullOrEmpty($dev.UEFICA2023Error) -and $dev.UEFICA2023Error -ne "0" -and $dev.UEFICA2023Error -ne "") $tskDis = ($dev.SecureBootTaskEnabled -eq $false -or $dev.SecureBootTaskStatus -eq 'Disabled' -or $dev.SecureBootTaskStatus -eq 'NotFound') $tskNF = ($dev.SecureBootTaskStatus -eq 'NotFound') $sbOn = ($dev.SecureBootEnabled -ne $false -and "$($dev.SecureBootEnabled)" -ne "False") $e1801 = if ($dev.PSObject.Properties['Event1801Count']) { [int]$dev.Event1801Count } else { 0 } $e1808 = if ($dev.PSObject.Properties['Event1808Count']) { [int]$dev.Event1808Count } else { 0 } $e1803 = if ($dev.PSObject.Properties['Event1803Count']) { [int]$dev.Event1803Count } else { 0 } $mKEK = ($e1803 -gt 0 -or $dev.MissingKEK -eq $true) $hKI = ((-not [string]::IsNullOrEmpty($dev.SkipReasonKnownIssue)) -or (-not [string]::IsNullOrEmpty($dev.KnownIssueId))) $rStat = if ($dev.PSObject.Properties['RolloutStatus']) { $dev.RolloutStatus } else { "" } # Append to matching category files $targets = @() if ($isUpd) { $targets += "updated_devices" } if ($hasErr) { $targets += "errors" } if ($hKI) { $targets += "known_issues" } if ($mKEK) { $targets += "missing_kek" } if (-not $isUpd -and $sbOn) { $targets += "not_updated" } if ($tskDis) { $targets += "task_disabled" } if (-not $isUpd -and ($tskDis -or (Test-ConfidenceLevel $conf 'TemporarilyPaused'))) { $targets += "temp_failures" } if (-not $isUpd -and ((Test-ConfidenceLevel $conf 'NotSupported') -or ($tskNF -and $hasErr))) { $targets += "perm_failures" } if (-not $isUpd -and (Test-ConfidenceLevel $conf 'ActionRequired')) { $targets += "action_required" } if (-not $sbOn) { $targets += "secureboot_off" } if ($e1801 -gt 0 -and $e1808 -eq 0 -and -not $hasErr -and $rStat -eq "InProgress") { $targets += "rollout_inprogress" } foreach ($tgt in $targets) { $tgtPath = Join-Path $dataDir "$tgt.json" if (Test-Path $tgtPath) { $existing = Get-Content $tgtPath -Raw | ConvertFrom-Json $existing = @($existing) + @([PSCustomObject]$slim) $existing | ConvertTo-Json -Depth 5 | Set-Content $tgtPath -Encoding UTF8 } } } # Regenerate CSVs from patched JSONs Write-Host " Regenerating CSVs from patched data..." -ForegroundColor Gray $newTimestamp = Get-Date -Format "yyyyMMdd-HHmmss" foreach ($cat in $categoryFiles) { $catJsonPath = Join-Path $dataDir "$cat.json" $catCsvPath = Join-Path $OutputPath "SecureBoot_${cat}_$newTimestamp.csv" if (Test-Path $catJsonPath) { try { $catJsonData = Get-Content $catJsonPath -Raw | ConvertFrom-Json if ($catJsonData.Count -gt 0) { $catJsonData | Export-Csv -Path $catCsvPath -NoTypeInformation -Encoding UTF8 } } catch { } } } # Recount stats from the patched JSON files Write-Host " Recalculating summary from patched data..." -ForegroundColor Gray $patchedStats = [ordered]@{ ReportGeneratedAt = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") } $pTotal = 0; $pUpdated = 0; $pErrors = 0; $pKI = 0; $pKEK = 0 $pTaskDis = 0; $pTempFail = 0; $pPermFail = 0; $pActionReq = 0; $pSBOff = 0; $pRIP = 0 foreach ($cat in $categoryFiles) { $catPath = Join-Path $dataDir "$cat.json" $cnt = 0 if (Test-Path $catPath) { try { $cnt = (Get-Content $catPath -Raw | ConvertFrom-Json).Count } catch { } } switch ($cat) { "updated_devices" { $pUpdated = $cnt } "errors" { $pErrors = $cnt } "known_issues" { $pKI = $cnt } "missing_kek" { $pKEK = $cnt } "not_updated" { } # computed "task_disabled" { $pTaskDis = $cnt } "temp_failures" { $pTempFail = $cnt } "perm_failures" { $pPermFail = $cnt } "action_required" { $pActionReq = $cnt } "secureboot_off" { $pSBOff = $cnt } "rollout_inprogress" { $pRIP = $cnt } } } $pNotUpdated = (Get-Content (Join-Path $dataDir "not_updated.json") -Raw | ConvertFrom-Json).Count $pTotal = $pUpdated + $pNotUpdated + $pSBOff Write-Host " Delta patch complete: $totalChanged devices updated" -ForegroundColor Green Write-Host " Total: $pTotal | Updated: $pUpdated | NotUpdated: $pNotUpdated | Errors: $pErrors" -ForegroundColor White # Update manifest $stManifestDir = Join-Path $OutputPath ".cache" $stNewManifest = @{} foreach ($jf in $jsonFiles) { $stNewManifest[$jf.FullName.ToLowerInvariant()] = @{ LastWriteTimeUtc = $jf.LastWriteTimeUtc.ToString("o"); Size = $jf.Length } } Save-FileManifest -Manifest $stNewManifest -Path $stManifestPath Write-Host " Completed in $([math]::Round($streamSw.Elapsed.TotalSeconds, 1))s (delta patch - $totalChanged devices)" -ForegroundColor Green # Fall through to full streaming reprocess to regenerate HTML dashboard # The data files are already patched, so this ensures dashboard stays current Write-Host " Regenerating dashboard from patched data..." -ForegroundColor Yellow } else { Write-Host " $changePct% files changed (>= 10%) - full streaming reprocess required" -ForegroundColor Yellow } } } } } # Create data subdirectory for on-demand device JSON files $dataDir = Join-Path $OutputPath "data" if (-not (Test-Path $dataDir)) { New-Item -ItemType Directory -Path $dataDir -Force | Out-Null } # Deduplication via HashSet (O(1) per lookup, ~50MB for 600K hostnames) $seenHostnames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) # Lightweight summary counters (replaces $allDevices + $uniqueDevices in memory) $c = @{ Total = 0; SBEnabled = 0; SBOff = 0 Updated = 0; HighConf = 0; UnderObs = 0; ActionReq = 0; TempPaused = 0; NotSupported = 0; NoConfData = 0 TaskDisabled = 0; TaskNotFound = 0; TaskDisabledNotUpdated = 0 WithErrors = 0; InProgress = 0; NotYetInitiated = 0; RolloutInProgress = 0 WithKnownIssues = 0; WithMissingKEK = 0; TempFailures = 0; PermFailures = 0; NeedsReboot = 0 UpdatePending = 0 } # Bucket tracking for AtRisk/SafeList (lightweight sets) $stFailedBuckets = [System.Collections.Generic.HashSet[string]]::new() $stSuccessBuckets = [System.Collections.Generic.HashSet[string]]::new() $stAllBuckets = @{} $stMfrCounts = @{} $stErrorCodeCounts = @{}; $stErrorCodeSamples = @{} $stKnownIssueCounts = @{} # Batch-mode device data files: accumulate per-chunk, flush at chunk boundaries $stDeviceFiles = @("errors", "known_issues", "missing_kek", "not_updated", "task_disabled", "temp_failures", "perm_failures", "updated_devices", "action_required", "secureboot_off", "rollout_inprogress", "under_observation", "needs_reboot", "update_pending") $stDeviceFilePaths = @{}; $stDeviceFileCounts = @{} foreach ($dfName in $stDeviceFiles) { $dfPath = Join-Path $dataDir "$dfName.json" [System.IO.File]::WriteAllText($dfPath, "[`n", [System.Text.Encoding]::UTF8) $stDeviceFilePaths[$dfName] = $dfPath; $stDeviceFileCounts[$dfName] = 0 } # Slim device record for JSON output (only essential fields, ~200 bytes vs ~2KB full) function Get-SlimDevice { param($Dev) return [ordered]@{ HostName = $Dev.HostName WMI_Manufacturer = if ($Dev.PSObject.Properties['WMI_Manufacturer']) { $Dev.WMI_Manufacturer } else { "" } WMI_Model = if ($Dev.PSObject.Properties['WMI_Model']) { $Dev.WMI_Model } else { "" } BucketId = if ($Dev.PSObject.Properties['BucketId']) { $Dev.BucketId } else { "" } ConfidenceLevel = if ($Dev.PSObject.Properties['ConfidenceLevel']) { $Dev.ConfidenceLevel } else { "" } IsUpdated = $Dev.IsUpdated UEFICA2023Error = if ($Dev.PSObject.Properties['UEFICA2023Error']) { $Dev.UEFICA2023Error } else { $null } SecureBootTaskStatus = if ($Dev.PSObject.Properties['SecureBootTaskStatus']) { $Dev.SecureBootTaskStatus } else { "" } KnownIssueId = if ($Dev.PSObject.Properties['KnownIssueId']) { $Dev.KnownIssueId } else { $null } SkipReasonKnownIssue = if ($Dev.PSObject.Properties['SkipReasonKnownIssue']) { $Dev.SkipReasonKnownIssue } else { $null } UEFICA2023Status = if ($Dev.PSObject.Properties['UEFICA2023Status']) { $Dev.UEFICA2023Status } else { $null } AvailableUpdatesPolicy = if ($Dev.PSObject.Properties['AvailableUpdatesPolicy']) { $Dev.AvailableUpdatesPolicy } else { $null } WinCSKeyApplied = if ($Dev.PSObject.Properties['WinCSKeyApplied']) { $Dev.WinCSKeyApplied } else { $null } } } # Flush batch to JSON file (append mode) function Flush-DeviceBatch { param([string]$StreamName, [System.Collections.Generic.List[object]]$Batch) if ($Batch.Count -eq 0) { return } $fPath = $stDeviceFilePaths[$StreamName] $fSb = [System.Text.StringBuilder]::new() foreach ($fDev in $Batch) { if ($stDeviceFileCounts[$StreamName] -gt 0) { [void]$fSb.Append(",`n") } [void]$fSb.Append(($fDev | ConvertTo-Json -Compress)) $stDeviceFileCounts[$StreamName]++ } [System.IO.File]::AppendAllText($fPath, $fSb.ToString(), [System.Text.Encoding]::UTF8) } # MAIN STREAMING LOOP $stChunkSize = if ($totalFiles -le 10000) { $totalFiles } else { 10000 } $stTotalChunks = [math]::Ceiling($totalFiles / $stChunkSize) $stPeakMemMB = 0 if ($stTotalChunks -gt 1) { Write-Host "Processing $totalFiles files in $stTotalChunks chunks of $stChunkSize (streaming, $ParallelThreads threads):" -ForegroundColor Cyan } else { Write-Host "Processing $totalFiles files (streaming, $ParallelThreads threads):" -ForegroundColor Cyan } for ($ci = 0; $ci -lt $stTotalChunks; $ci++) { $cStart = $ci * $stChunkSize $cEnd = [math]::Min($cStart + $stChunkSize, $totalFiles) - 1 $cFiles = $jsonFiles[$cStart..$cEnd] if ($stTotalChunks -gt 1) { Write-Host " Chunk $($ci + 1)/$stTotalChunks ($($cFiles.Count) files): " -NoNewline -ForegroundColor Gray } else { Write-Host " Loading $($cFiles.Count) files: " -NoNewline -ForegroundColor Gray } $cSw = [System.Diagnostics.Stopwatch]::StartNew() $rawDevices = Load-FilesParallel -Files $cFiles -Threads $ParallelThreads # Per-chunk batch lists $cBatches = @{} foreach ($df in $stDeviceFiles) { $cBatches[$df] = [System.Collections.Generic.List[object]]::new() } $cNew = 0; $cDupe = 0 foreach ($raw in $rawDevices) { if (-not $raw) { continue } $device = Normalize-DeviceRecord $raw $hostname = $device.HostName if (-not $hostname) { continue } if ($seenHostnames.Contains($hostname)) { $cDupe++; continue } [void]$seenHostnames.Add($hostname) $cNew++; $c.Total++ $sbOn = ($device.SecureBootEnabled -ne $false -and "$($device.SecureBootEnabled)" -ne "False") if ($sbOn) { $c.SBEnabled++ } else { $c.SBOff++; $cBatches["secureboot_off"].Add((Get-SlimDevice $device)) } $isUpd = $device.IsUpdated -eq $true $conf = if ($device.PSObject.Properties['ConfidenceLevel'] -and $device.ConfidenceLevel) { "$($device.ConfidenceLevel)" } else { "" } $hasErr = (-not [string]::IsNullOrEmpty($device.UEFICA2023Error) -and "$($device.UEFICA2023Error)" -ne "0" -and "$($device.UEFICA2023Error)" -ne "") $tskDis = ($device.SecureBootTaskEnabled -eq $false -or "$($device.SecureBootTaskStatus)" -eq 'Disabled' -or "$($device.SecureBootTaskStatus)" -eq 'NotFound') $tskNF = ("$($device.SecureBootTaskStatus)" -eq 'NotFound') $bid = if ($device.PSObject.Properties['BucketId'] -and $device.BucketId) { "$($device.BucketId)" } else { "" } $e1808 = if ($device.PSObject.Properties['Event1808Count']) { [int]$device.Event1808Count } else { 0 } $e1801 = if ($device.PSObject.Properties['Event1801Count']) { [int]$device.Event1801Count } else { 0 } $e1803 = if ($device.PSObject.Properties['Event1803Count']) { [int]$device.Event1803Count } else { 0 } $mKEK = ($e1803 -gt 0 -or $device.MissingKEK -eq $true -or "$($device.MissingKEK)" -eq "True") $hKI = ((-not [string]::IsNullOrEmpty($device.SkipReasonKnownIssue)) -or (-not [string]::IsNullOrEmpty($device.KnownIssueId))) $rStat = if ($device.PSObject.Properties['RolloutStatus']) { $device.RolloutStatus } else { "" } $mfr = if ($device.PSObject.Properties['WMI_Manufacturer'] -and -not [string]::IsNullOrEmpty($device.WMI_Manufacturer)) { $device.WMI_Manufacturer } else { "Unknown" } $bid = if (-not [string]::IsNullOrEmpty($bid)) { $bid } else { "" } # Pre-compute Update Pending flag (policy/WinCS applied, status not yet Updated, SB ON, task not disabled) $uefiStatus = if ($device.PSObject.Properties['UEFICA2023Status']) { "$($device.UEFICA2023Status)" } else { "" } $hasPolicy = ($device.PSObject.Properties['AvailableUpdatesPolicy'] -and $null -ne $device.AvailableUpdatesPolicy -and "$($device.AvailableUpdatesPolicy)" -ne '') $hasWinCS = ($device.PSObject.Properties['WinCSKeyApplied'] -and $device.WinCSKeyApplied -eq $true) $statusPending = ([string]::IsNullOrEmpty($uefiStatus) -or $uefiStatus -eq 'NotStarted' -or $uefiStatus -eq 'InProgress') $isUpdatePending = (($hasPolicy -or $hasWinCS) -and $statusPending -and -not $isUpd -and $sbOn -and -not $tskDis) if ($isUpd) { $c.Updated++; [void]$stSuccessBuckets.Add($bid); $cBatches["updated_devices"].Add((Get-SlimDevice $device)) # Track Updated devices that need reboot (UEFICA2023Status=Updated but Event1808=0) if ($e1808 -eq 0) { $c.NeedsReboot++; $cBatches["needs_reboot"].Add((Get-SlimDevice $device)) } } elseif (-not $sbOn) { # SecureBoot OFF — out of scope, don't classify by confidence } else { if ($isUpdatePending) { } # Counted separately in Update Pending — mutually exclusive for pie chart elseif (Test-ConfidenceLevel $conf "HighConfidence") { $c.HighConf++ } elseif (Test-ConfidenceLevel $conf "UnderObservation") { $c.UnderObs++ } elseif (Test-ConfidenceLevel $conf "TemporarilyPaused") { $c.TempPaused++ } elseif (Test-ConfidenceLevel $conf "NotSupported") { $c.NotSupported++ } else { $c.ActionReq++ } if ([string]::IsNullOrEmpty($conf)) { $c.NoConfData++ } } if ($tskDis) { $c.TaskDisabled++; $cBatches["task_disabled"].Add((Get-SlimDevice $device)) } if ($tskNF) { $c.TaskNotFound++ } if (-not $isUpd -and $tskDis) { $c.TaskDisabledNotUpdated++ } if ($hasErr) { $c.WithErrors++; [void]$stFailedBuckets.Add($bid); $cBatches["errors"].Add((Get-SlimDevice $device)) $ec = $device.UEFICA2023Error if (-not $stErrorCodeCounts.ContainsKey($ec)) { $stErrorCodeCounts[$ec] = 0; $stErrorCodeSamples[$ec] = @() } $stErrorCodeCounts[$ec]++ if ($stErrorCodeSamples[$ec].Count -lt 5) { $stErrorCodeSamples[$ec] += $hostname } } if ($hKI) { $c.WithKnownIssues++; $cBatches["known_issues"].Add((Get-SlimDevice $device)) $ki = if (-not [string]::IsNullOrEmpty($device.SkipReasonKnownIssue)) { $device.SkipReasonKnownIssue } else { $device.KnownIssueId } if (-not $stKnownIssueCounts.ContainsKey($ki)) { $stKnownIssueCounts[$ki] = 0 }; $stKnownIssueCounts[$ki]++ } if ($mKEK) { $c.WithMissingKEK++; $cBatches["missing_kek"].Add((Get-SlimDevice $device)) } if (-not $isUpd -and ($tskDis -or (Test-ConfidenceLevel $conf 'TemporarilyPaused'))) { $c.TempFailures++; $cBatches["temp_failures"].Add((Get-SlimDevice $device)) } if (-not $isUpd -and ((Test-ConfidenceLevel $conf 'NotSupported') -or ($tskNF -and $hasErr))) { $c.PermFailures++; $cBatches["perm_failures"].Add((Get-SlimDevice $device)) } if ($e1801 -gt 0 -and $e1808 -eq 0 -and -not $hasErr -and $rStat -eq "InProgress") { $c.RolloutInProgress++; $cBatches["rollout_inprogress"].Add((Get-SlimDevice $device)) } if ($e1801 -gt 0 -and $e1808 -eq 0 -and -not $hasErr -and $rStat -ne "InProgress") { $c.NotYetInitiated++ } if ($rStat -eq "InProgress" -and $e1808 -eq 0) { $c.InProgress++ } # Update Pending: policy or WinCS applied, status pending, SB ON, task not disabled if ($isUpdatePending) { $c.UpdatePending++; $cBatches["update_pending"].Add((Get-SlimDevice $device)) } if (-not $isUpd -and $sbOn) { $cBatches["not_updated"].Add((Get-SlimDevice $device)) } # Under Observation devices (separate from Action Required) if (-not $isUpd -and (Test-ConfidenceLevel $conf 'UnderObservation')) { $cBatches["under_observation"].Add((Get-SlimDevice $device)) } # Action Required: not-updated, SB ON, not matching other confidence categories, not Update Pending if (-not $isUpd -and $sbOn -and -not $isUpdatePending -and -not (Test-ConfidenceLevel $conf 'HighConfidence') -and -not (Test-ConfidenceLevel $conf 'UnderObservation') -and -not (Test-ConfidenceLevel $conf 'TemporarilyPaused') -and -not (Test-ConfidenceLevel $conf 'NotSupported')) { $cBatches["action_required"].Add((Get-SlimDevice $device)) } if (-not $stMfrCounts.ContainsKey($mfr)) { $stMfrCounts[$mfr] = @{ Total=0; Updated=0; UpdatePending=0; HighConf=0; UnderObs=0; ActionReq=0; TempPaused=0; NotSupported=0; SBOff=0; WithErrors=0 } } $stMfrCounts[$mfr].Total++ if ($isUpd) { $stMfrCounts[$mfr].Updated++ } elseif (-not $sbOn) { $stMfrCounts[$mfr].SBOff++ } elseif ($isUpdatePending) { $stMfrCounts[$mfr].UpdatePending++ } elseif (Test-ConfidenceLevel $conf "HighConfidence") { $stMfrCounts[$mfr].HighConf++ } elseif (Test-ConfidenceLevel $conf "UnderObservation") { $stMfrCounts[$mfr].UnderObs++ } elseif (Test-ConfidenceLevel $conf "TemporarilyPaused") { $stMfrCounts[$mfr].TempPaused++ } elseif (Test-ConfidenceLevel $conf "NotSupported") { $stMfrCounts[$mfr].NotSupported++ } else { $stMfrCounts[$mfr].ActionReq++ } if ($hasErr) { $stMfrCounts[$mfr].WithErrors++ } # Track all devices by bucket (including empty BucketId) $bucketKey = if ($bid -and $bid -ne "") { $bid } else { "(empty)" } if (-not $stAllBuckets.ContainsKey($bucketKey)) { $stAllBuckets[$bucketKey] = @{ Count=0; Updated=0; Manufacturer=$mfr; Model=""; BIOS="" } if ($device.PSObject.Properties['WMI_Model']) { $stAllBuckets[$bucketKey].Model = $device.WMI_Model } if ($device.PSObject.Properties['BIOSDescription']) { $stAllBuckets[$bucketKey].BIOS = $device.BIOSDescription } } $stAllBuckets[$bucketKey].Count++ if ($isUpd) { $stAllBuckets[$bucketKey].Updated++ } } # Flush batches to disk foreach ($df in $stDeviceFiles) { Flush-DeviceBatch -StreamName $df -Batch $cBatches[$df] } $rawDevices = $null; $cBatches = $null; [System.GC]::Collect() $cSw.Stop() $cTime = [Math]::Round($cSw.Elapsed.TotalSeconds, 1) $cRem = $stTotalChunks - $ci - 1 $cEta = if ($cRem -gt 0) { " | ETA: ~$([Math]::Round($cRem * $cSw.Elapsed.TotalSeconds / 60, 1)) min" } else { "" } $cMem = [math]::Round([System.GC]::GetTotalMemory($false) / 1MB, 0) if ($cMem -gt $stPeakMemMB) { $stPeakMemMB = $cMem } Write-Host " +$cNew new, $cDupe dupes, ${cTime}s | Mem: ${cMem}MB$cEta" -ForegroundColor Green } # Finalize JSON arrays foreach ($dfName in $stDeviceFiles) { [System.IO.File]::AppendAllText($stDeviceFilePaths[$dfName], "`n]", [System.Text.Encoding]::UTF8) Write-Host " $dfName.json: $($stDeviceFileCounts[$dfName]) devices" -ForegroundColor DarkGray } # Compute derived stats $stAtRisk = 0; $stSafeList = 0 foreach ($bid in $stAllBuckets.Keys) { $b = $stAllBuckets[$bid]; $nu = $b.Count - $b.Updated if ($stFailedBuckets.Contains($bid)) { $stAtRisk += $nu } elseif ($stSuccessBuckets.Contains($bid)) { $stSafeList += $nu } } $stAtRisk = [math]::Max(0, $stAtRisk - $c.WithErrors) # NotUptodate = count from not_updated batch (devices with SB ON and not updated) $stNotUptodate = $stDeviceFileCounts["not_updated"] $stats = [ordered]@{ ReportGeneratedAt = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") TotalDevices = $c.Total; SecureBootEnabled = $c.SBEnabled; SecureBootOFF = $c.SBOff Updated = $c.Updated; HighConfidence = $c.HighConf; UnderObservation = $c.UnderObs ActionRequired = $c.ActionReq; TemporarilyPaused = $c.TempPaused; NotSupported = $c.NotSupported NoConfidenceData = $c.NoConfData; TaskDisabled = $c.TaskDisabled; TaskNotFound = $c.TaskNotFound TaskDisabledNotUpdated = $c.TaskDisabledNotUpdated CertificatesUpdated = $c.Updated; NotUptodate = $stNotUptodate; FullyUpdated = $c.Updated UpdatesPending = $stNotUptodate; UpdatesComplete = $c.Updated WithErrors = $c.WithErrors; InProgress = $c.InProgress; NotYetInitiated = $c.NotYetInitiated RolloutInProgress = $c.RolloutInProgress; WithKnownIssues = $c.WithKnownIssues WithMissingKEK = $c.WithMissingKEK; TemporaryFailures = $c.TempFailures; PermanentFailures = $c.PermFailures NeedsReboot = $c.NeedsReboot; UpdatePending = $c.UpdatePending AtRiskDevices = $stAtRisk; SafeListDevices = $stSafeList PercentWithErrors = if ($c.Total -gt 0) { [math]::Round(($c.WithErrors/$c.Total)*100,2) } else { 0 } PercentAtRisk = if ($c.Total -gt 0) { [math]::Round(($stAtRisk/$c.Total)*100,2) } else { 0 } PercentSafeList = if ($c.Total -gt 0) { [math]::Round(($stSafeList/$c.Total)*100,2) } else { 0 } PercentHighConfidence = if ($c.Total -gt 0) { [math]::Round(($c.HighConf/$c.Total)*100,1) } else { 0 } PercentCertUpdated = if ($c.Total -gt 0) { [math]::Round(($c.Updated/$c.Total)*100,1) } else { 0 } PercentActionRequired = if ($c.Total -gt 0) { [math]::Round(($c.ActionReq/$c.Total)*100,1) } else { 0 } PercentNotUptodate = if ($c.Total -gt 0) { [math]::Round($stNotUptodate/$c.Total*100,1) } else { 0 } PercentFullyUpdated = if ($c.Total -gt 0) { [math]::Round(($c.Updated/$c.Total)*100,1) } else { 0 } UniqueBuckets = $stAllBuckets.Count; PeakMemoryMB = $stPeakMemMB; ProcessingMode = "Streaming" } # Write CSVs [PSCustomObject]$stats | Export-Csv -Path (Join-Path $OutputPath "SecureBoot_Summary_$timestamp.csv") -NoTypeInformation -Encoding UTF8 $stMfrCounts.GetEnumerator() | Sort-Object { $_.Value.Total } -Descending | ForEach-Object { [PSCustomObject]@{ Manufacturer=$_.Key; Count=$_.Value.Total; Updated=$_.Value.Updated; HighConfidence=$_.Value.HighConf; ActionRequired=$_.Value.ActionReq } } | Export-Csv -Path (Join-Path $OutputPath "SecureBoot_ByManufacturer_$timestamp.csv") -NoTypeInformation -Encoding UTF8 $stErrorCodeCounts.GetEnumerator() | Sort-Object Value -Descending | ForEach-Object { [PSCustomObject]@{ ErrorCode=$_.Key; Count=$_.Value; SampleDevices=($stErrorCodeSamples[$_.Key] -join ", ") } } | Export-Csv -Path (Join-Path $OutputPath "SecureBoot_ErrorCodes_$timestamp.csv") -NoTypeInformation -Encoding UTF8 $stAllBuckets.GetEnumerator() | Sort-Object { $_.Value.Count } -Descending | ForEach-Object { [PSCustomObject]@{ BucketId=$_.Key; Count=$_.Value.Count; Updated=$_.Value.Updated; NotUpdated=$_.Value.Count-$_.Value.Updated; Manufacturer=$_.Value.Manufacturer } } | Export-Csv -Path (Join-Path $OutputPath "SecureBoot_UniqueBuckets_$timestamp.csv") -NoTypeInformation -Encoding UTF8 # Generate orchestrator-compatible CSVs (expected filenames for Start-SecureBootRolloutOrchestrator.ps1) $notUpdatedJsonPath = Join-Path $dataDir "not_updated.json" if (Test-Path $notUpdatedJsonPath) { try { $nuData = Get-Content $notUpdatedJsonPath -Raw | ConvertFrom-Json if ($nuData.Count -gt 0) { # NotUptodate CSV - orchestrator searches for *NotUptodate*.csv $nuData | Export-Csv -Path (Join-Path $OutputPath "SecureBoot_NotUptodate_$timestamp.csv") -NoTypeInformation -Encoding UTF8 Write-Host " Orchestrator CSV: SecureBoot_NotUptodate_$timestamp.csv ($($nuData.Count) devices)" -ForegroundColor Gray } } catch { } } # Write JSON data for dashboard $stats | ConvertTo-Json -Depth 3 | Set-Content (Join-Path $dataDir "summary.json") -Encoding UTF8 # HISTORICAL TRACKING: Save data point for trend chart # Use a stable cache location so trend data persists across timestamped aggregation folders. # If OutputPath looks like "...\Aggregation_yyyyMMdd_HHmmss", cache goes in the parent folder. # Otherwise, cache goes inside OutputPath itself. $parentDir = Split-Path $OutputPath -Parent $leafName = Split-Path $OutputPath -Leaf if ($leafName -match '^Aggregation_\d{8}' -or $leafName -eq 'Aggregation_Current') { # Orchestrator-created timestamped folder — use parent for stable cache $historyPath = Join-Path $parentDir ".cache\trend_history.json" } else { $historyPath = Join-Path $OutputPath ".cache\trend_history.json" } $historyDir = Split-Path $historyPath -Parent if (-not (Test-Path $historyDir)) { New-Item -ItemType Directory -Path $historyDir -Force | Out-Null } $historyData = @() if (Test-Path $historyPath) { try { $historyData = @(Get-Content $historyPath -Raw | ConvertFrom-Json) } catch { $historyData = @() } } # Also check inside OutputPath\.cache\ (legacy location from older versions) # Merge any data points not already in the primary history if ($leafName -eq 'Aggregation_Current' -or $leafName -match '^Aggregation_\d{8}') { $innerHistoryPath = Join-Path $OutputPath ".cache\trend_history.json" if ((Test-Path $innerHistoryPath) -and $innerHistoryPath -ne $historyPath) { try { $innerData = @(Get-Content $innerHistoryPath -Raw | ConvertFrom-Json) $existingDates = @($historyData | ForEach-Object { $_.Date }) foreach ($entry in $innerData) { if ($entry.Date -and $entry.Date -notin $existingDates) { $historyData += $entry } } if ($innerData.Count -gt 0) { Write-Host " Merged $($innerData.Count) data points from inner cache" -ForegroundColor DarkGray } } catch { } } } # BOOTSTRAP: If trend history is empty/sparse, reconstruct from historical data if ($historyData.Count -lt 2 -and ($leafName -match '^Aggregation_\d{8}' -or $leafName -eq 'Aggregation_Current')) { Write-Host " Bootstrapping trend history from historical data..." -ForegroundColor Yellow $dailyData = @{} # Source 1: Summary CSVs inside current folder (Aggregation_Current keeps all Summary CSVs) $localSummaries = Get-ChildItem $OutputPath -Filter "SecureBoot_Summary_*.csv" -EA SilentlyContinue | Sort-Object Name foreach ($summCsv in $localSummaries) { try { $summ = Import-Csv $summCsv.FullName | Select-Object -First 1 if ($summ.TotalDevices -and [int]$summ.TotalDevices -gt 0 -and $summ.ReportGeneratedAt) { $dateStr = ([datetime]$summ.ReportGeneratedAt).ToString("yyyy-MM-dd") $updated = if ($summ.Updated) { [int]$summ.Updated } else { 0 } $notUpd = if ($summ.NotUptodate) { [int]$summ.NotUptodate } else { [int]$summ.TotalDevices - $updated } $dailyData[$dateStr] = [PSCustomObject]@{ Date = $dateStr; Total = [int]$summ.TotalDevices; Updated = $updated; NotUpdated = $notUpd NeedsReboot = 0; Errors = 0; ActionRequired = if ($summ.ActionRequired) { [int]$summ.ActionRequired } else { 0 } } } } catch { } } # Source 2: Old timestamped Aggregation_* folders (legacy, if they still exist) $aggFolders = Get-ChildItem $parentDir -Directory -Filter "Aggregation_*" -EA SilentlyContinue | Where-Object { $_.Name -match '^Aggregation_\d{8}' } | Sort-Object Name foreach ($folder in $aggFolders) { $summCsv = Get-ChildItem $folder.FullName -Filter "SecureBoot_Summary_*.csv" -EA SilentlyContinue | Select-Object -First 1 if ($summCsv) { try { $summ = Import-Csv $summCsv.FullName | Select-Object -First 1 if ($summ.TotalDevices -and [int]$summ.TotalDevices -gt 0) { $dateStr = $folder.Name -replace '^Aggregation_(\d{4})(\d{2})(\d{2})_.*', '$1-$2-$3' $updated = if ($summ.Updated) { [int]$summ.Updated } else { 0 } $notUpd = if ($summ.NotUptodate) { [int]$summ.NotUptodate } else { [int]$summ.TotalDevices - $updated } $dailyData[$dateStr] = [PSCustomObject]@{ Date = $dateStr; Total = [int]$summ.TotalDevices; Updated = $updated; NotUpdated = $notUpd NeedsReboot = 0; Errors = 0; ActionRequired = if ($summ.ActionRequired) { [int]$summ.ActionRequired } else { 0 } } } } catch { } } } # Source 3: RolloutState.json WaveHistory (has per-wave timestamps from day 1) # This provides baseline data points even when no old aggregation folders exist $rolloutStatePaths = @( (Join-Path $parentDir "RolloutState\RolloutState.json"), (Join-Path $OutputPath "RolloutState\RolloutState.json") ) foreach ($rsPath in $rolloutStatePaths) { if (Test-Path $rsPath) { try { $rsData = Get-Content $rsPath -Raw | ConvertFrom-Json if ($rsData.WaveHistory) { # Use wave start dates as trend data points # Calculate cumulative devices targeted at each wave $cumulativeTargeted = 0 foreach ($wave in $rsData.WaveHistory) { if ($wave.StartedAt -and $wave.DeviceCount) { $waveDate = ([datetime]$wave.StartedAt).ToString("yyyy-MM-dd") $cumulativeTargeted += [int]$wave.DeviceCount if (-not $dailyData.ContainsKey($waveDate)) { # Approximate: at wave start time, only devices from prior waves were updated $dailyData[$waveDate] = [PSCustomObject]@{ Date = $waveDate; Total = $c.Total; Updated = [math]::Max(0, $cumulativeTargeted - [int]$wave.DeviceCount) NotUpdated = $c.Total - [math]::Max(0, $cumulativeTargeted - [int]$wave.DeviceCount) NeedsReboot = 0; Errors = 0; ActionRequired = 0 } } } } } } catch { } break # Use first found } } if ($dailyData.Count -gt 0) { $historyData = @($dailyData.GetEnumerator() | Sort-Object Key | ForEach-Object { $_.Value }) Write-Host " Bootstrapped $($historyData.Count) data points from historical summaries" -ForegroundColor Green } } # Add current data point (deduplicate by day - keep latest per day) $todayKey = (Get-Date).ToString("yyyy-MM-dd") $existingToday = $historyData | Where-Object { "$($_.Date)" -like "$todayKey*" } if ($existingToday) { # Replace today's entry $historyData = @($historyData | Where-Object { "$($_.Date)" -notlike "$todayKey*" }) } $historyData += [PSCustomObject]@{ Date = $todayKey Total = $c.Total Updated = $c.Updated NotUpdated = $stNotUptodate NeedsReboot = $c.NeedsReboot Errors = $c.WithErrors ActionRequired = $c.ActionReq } # Remove bad data points (0 total) and keep last 90 $historyData = @($historyData | Where-Object { [int]$_.Total -gt 0 }) # No cap — trend data is ~100 bytes/entry, a full year = ~36 KB $historyData | ConvertTo-Json -Depth 3 | Set-Content $historyPath -Encoding UTF8 Write-Host " Trend history: $($historyData.Count) data points" -ForegroundColor DarkGray # Build trend chart data for HTML $trendLabels = ($historyData | ForEach-Object { "'$($_.Date)'" }) -join "," $trendUpdated = ($historyData | ForEach-Object { $_.Updated }) -join "," $trendNotUpdated = ($historyData | ForEach-Object { $_.NotUpdated }) -join "," $trendTotal = ($historyData | ForEach-Object { $_.Total }) -join "," # Projection: extend trend line using exponential doubling (2,4,8,16...) # Derives wave size and observation period from actual trend history data. # - Wave size = largest single-period increase seen in history (the most recent wave deployed) # - Observation days = average calendar days between trend data points (how often we run) # Then doubles the wave size each period, matching the orchestrator's 2x growth strategy. $projLabels = ""; $projUpdated = ""; $projNotUpdated = ""; $hasProjection = $false if ($historyData.Count -ge 2) { $lastUpdated = $c.Updated $remaining = $stNotUptodate # Only SB-ON not-updated devices (excludes SecureBoot OFF) $projDates = @(); $projValues = @(); $projNotUpdValues = @() $projDate = Get-Date # Derive wave size and observation period from trend history $increments = @() $dayGaps = @() for ($hi = 1; $hi -lt $historyData.Count; $hi++) { $inc = $historyData[$hi].Updated - $historyData[$hi-1].Updated if ($inc -gt 0) { $increments += $inc } try { $d1 = [datetime]::Parse($historyData[$hi-1].Date) $d2 = [datetime]::Parse($historyData[$hi].Date) $gap = ($d2 - $d1).TotalDays if ($gap -gt 0) { $dayGaps += $gap } } catch {} } # Wave size = most recent positive increment (current wave), fallback to average, minimum 2 $waveSize = if ($increments.Count -gt 0) { [math]::Max(2, $increments[-1]) } else { 2 } # Observation period = average gap between data points (calendar days per wave), minimum 1 $waveDays = if ($dayGaps.Count -gt 0) { [math]::Max(1, [math]::Round(($dayGaps | Measure-Object -Average).Average, 0)) } else { 1 } Write-Host " Projection: waveSize=$waveSize (from last increment), waveDays=$waveDays (avg gap from history)" -ForegroundColor DarkGray $dayCounter = 0 # Project until all devices updated or 365 days max for ($pi = 1; $pi -le 365; $pi++) { $projDate = $projDate.AddDays(1) $dayCounter++ # At each observation period boundary, deploy a wave then double if ($dayCounter -ge $waveDays) { $devicesThisWave = [math]::Min($waveSize, $remaining) $lastUpdated += $devicesThisWave $remaining -= $devicesThisWave if ($lastUpdated -gt ($c.Updated + $stNotUptodate)) { $lastUpdated = $c.Updated + $stNotUptodate; $remaining = 0 } # Double wave size for next period (orchestrator 2x strategy) $waveSize = $waveSize * 2 $dayCounter = 0 } $projDates += "'$($projDate.ToString("yyyy-MM-dd"))'" $projValues += $lastUpdated $projNotUpdValues += [math]::Max(0, $remaining) if ($remaining -le 0) { break } } $projLabels = $projDates -join "," $projUpdated = $projValues -join "," $projNotUpdated = $projNotUpdValues -join "," $hasProjection = $projDates.Count -gt 0 } elseif ($historyData.Count -eq 1) { Write-Host " Projection: need at least 2 trend data points to derive wave timing" -ForegroundColor DarkGray } # Build combined chart data strings for the here-string $allChartLabels = if ($hasProjection) { "$trendLabels,$projLabels" } else { $trendLabels } $projDataJS = if ($hasProjection) { $projUpdated } else { "" } $projNotUpdJS = if ($hasProjection) { $projNotUpdated } else { "" } $histCount = ($historyData | Measure-Object).Count $stMfrCounts.GetEnumerator() | Sort-Object { $_.Value.Total } -Descending | ForEach-Object { @{ name=$_.Key; total=$_.Value.Total; updated=$_.Value.Updated; highConf=$_.Value.HighConf; actionReq=$_.Value.ActionReq } } | ConvertTo-Json -Depth 3 | Set-Content (Join-Path $dataDir "manufacturers.json") -Encoding UTF8 # Convert JSON data files to CSV for human-readable Excel downloads Write-Host "Converting device data to CSV for Excel download..." -ForegroundColor Gray foreach ($dfName in $stDeviceFiles) { $jsonFile = Join-Path $dataDir "$dfName.json" $csvFile = Join-Path $OutputPath "SecureBoot_${dfName}_$timestamp.csv" if (Test-Path $jsonFile) { try { $jsonData = Get-Content $jsonFile -Raw | ConvertFrom-Json if ($jsonData.Count -gt 0) { # Include extra columns for update_pending CSV $selectProps = if ($dfName -eq "update_pending") { @('HostName', 'WMI_Manufacturer', 'WMI_Model', 'BucketId', 'ConfidenceLevel', 'IsUpdated', 'UEFICA2023Status', 'UEFICA2023Error', 'AvailableUpdatesPolicy', 'WinCSKeyApplied', 'SecureBootTaskStatus') } else { @('HostName', 'WMI_Manufacturer', 'WMI_Model', 'BucketId', 'ConfidenceLevel', 'IsUpdated', 'UEFICA2023Error', 'SecureBootTaskStatus', 'KnownIssueId', 'SkipReasonKnownIssue') } $jsonData | Select-Object $selectProps | Export-Csv -Path $csvFile -NoTypeInformation -Encoding UTF8 Write-Host " $dfName -> $($jsonData.Count) rows -> CSV" -ForegroundColor DarkGray } } catch { Write-Host " $dfName - skipped" -ForegroundColor DarkYellow } } } # Generate self-contained HTML dashboard $htmlPath = Join-Path $OutputPath "SecureBoot_Dashboard_$timestamp.html" Write-Host "Generating self-contained HTML dashboard..." -ForegroundColor Yellow # VELOCITY PROJECTION: Calculate from scan history or previous summary $stDeadline = [datetime]"2026-06-24" # KEK cert expiry $stDaysToDeadline = [math]::Max(0, ($stDeadline - (Get-Date)).Days) $stDevicesPerDay = 0 $stProjectedDate = $null $stVelocitySource = "N/A" $stWorkingDays = 0 $stCalendarDays = 0 # Try trend history first (lightweight, already maintained by aggregator — replaces bloated ScanHistory.json) if ($historyData.Count -ge 2) { $validHistory = @($historyData | Where-Object { [int]$_.Total -gt 0 -and [int]$_.Updated -ge 0 }) if ($validHistory.Count -ge 2) { $prev = $validHistory[-2]; $curr = $validHistory[-1] $prevDate = [datetime]::Parse($prev.Date.Substring(0, [Math]::Min(10, $prev.Date.Length))) $currDate = [datetime]::Parse($curr.Date.Substring(0, [Math]::Min(10, $curr.Date.Length))) $daysDiff = ($currDate - $prevDate).TotalDays if ($daysDiff -gt 0) { $updDiff = [int]$curr.Updated - [int]$prev.Updated if ($updDiff -gt 0) { $stDevicesPerDay = [math]::Round($updDiff / $daysDiff, 0) $stVelocitySource = "TrendHistory" } } } } # Try orchestrator rollout summary (has pre-computed velocity) if ($stVelocitySource -eq "N/A" -and $RolloutSummaryPath -and (Test-Path $RolloutSummaryPath)) { try { $rolloutSummary = Get-Content $RolloutSummaryPath -Raw | ConvertFrom-Json if ($rolloutSummary.DevicesPerDay -and [double]$rolloutSummary.DevicesPerDay -gt 0) { $stDevicesPerDay = [math]::Round([double]$rolloutSummary.DevicesPerDay, 1) $stVelocitySource = "Orchestrator" if ($rolloutSummary.ProjectedCompletionDate) { $stProjectedDate = $rolloutSummary.ProjectedCompletionDate } if ($rolloutSummary.WorkingDaysRemaining) { $stWorkingDays = [int]$rolloutSummary.WorkingDaysRemaining } if ($rolloutSummary.CalendarDaysRemaining) { $stCalendarDays = [int]$rolloutSummary.CalendarDaysRemaining } } } catch { } } # Fallback: try previous summary CSV (search current folder AND parent/sibling aggregation folders) if ($stVelocitySource -eq "N/A") { $searchPaths = @( (Join-Path $OutputPath "SecureBoot_Summary_*.csv") ) # Also search sibling aggregation folders (orchestrator creates new folder each run) $parentPath = Split-Path $OutputPath -Parent if ($parentPath) { $searchPaths += (Join-Path $parentPath "Aggregation_*\SecureBoot_Summary_*.csv") $searchPaths += (Join-Path $parentPath "SecureBoot_Summary_*.csv") } $prevSummary = $searchPaths | ForEach-Object { Get-ChildItem $_ -EA SilentlyContinue } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($prevSummary) { try { $prevStats = Get-Content $prevSummary.FullName | ConvertFrom-Csv $prevDate = [datetime]$prevStats.ReportGeneratedAt $daysSinceLast = ((Get-Date) - $prevDate).TotalDays if ($daysSinceLast -gt 0.01) { $prevUpdated = [int]$prevStats.Updated $updDelta = $c.Updated - $prevUpdated if ($updDelta -gt 0) { $stDevicesPerDay = [math]::Round($updDelta / $daysSinceLast, 0) $stVelocitySource = "PreviousReport" } } } catch { } } } # Fallback: calculate velocity from full trend history span (first vs latest data point) if ($stVelocitySource -eq "N/A" -and $historyData.Count -ge 2) { $validHistory = @($historyData | Where-Object { [int]$_.Total -gt 0 -and [int]$_.Updated -ge 0 }) if ($validHistory.Count -ge 2) { $first = $validHistory[0] $last = $validHistory[-1] $firstDate = [datetime]::Parse($first.Date.Substring(0, [Math]::Min(10, $first.Date.Length))) $lastDate = [datetime]::Parse($last.Date.Substring(0, [Math]::Min(10, $last.Date.Length))) $daysDiff = ($lastDate - $firstDate).TotalDays if ($daysDiff -gt 0) { $updDiff = [int]$last.Updated - [int]$first.Updated if ($updDiff -gt 0) { $stDevicesPerDay = [math]::Round($updDiff / $daysDiff, 1) $stVelocitySource = "TrendHistory" } } } } # Calculate projection using exponential doubling (consistent with trend chart) # Reuse the projection data already computed for the chart if available if ($hasProjection -and $projDates.Count -gt 0) { # Use the last projected date (when all devices are updated) $lastProjDateStr = $projDates[-1] -replace "'", "" $stProjectedDate = ([datetime]::Parse($lastProjDateStr)).ToString("MMM dd, yyyy") $stCalendarDays = ([datetime]::Parse($lastProjDateStr) - (Get-Date)).Days $stWorkingDays = 0 $d = Get-Date for ($i = 0; $i -lt $stCalendarDays; $i++) { $d = $d.AddDays(1) if ($d.DayOfWeek -ne 'Saturday' -and $d.DayOfWeek -ne 'Sunday') { $stWorkingDays++ } } } elseif ($stDevicesPerDay -gt 0 -and $stNotUptodate -gt 0) { # Fallback: linear projection if no exponential data available $daysNeeded = [math]::Ceiling($stNotUptodate / $stDevicesPerDay) $stProjectedDate = (Get-Date).AddDays($daysNeeded).ToString("MMM dd, yyyy") $stWorkingDays = 0; $stCalendarDays = $daysNeeded $d = Get-Date for ($i = 0; $i -lt $daysNeeded; $i++) { $d = $d.AddDays(1) if ($d.DayOfWeek -ne 'Saturday' -and $d.DayOfWeek -ne 'Sunday') { $stWorkingDays++ } } } # Build velocity HTML $velocityHtml = if ($stDevicesPerDay -gt 0) { "
🚀 Devices/Day: $($stDevicesPerDay.ToString('N0')) (source: $stVelocitySource)
" + "
📅 Projected Completion: $stProjectedDate" + $(if ($stProjectedDate -and [datetime]::Parse($stProjectedDate) -gt $stDeadline) { " ⚠ PAST DEADLINE" } else { " ✓ Before deadline" }) + "
" + "
🕐 Working Days: $stWorkingDays | Calendar Days: $stCalendarDays
" + "
Deadline: Jun 24, 2026 (KEK certificate expiry) | Days remaining: $stDaysToDeadline
" } else { "
" + "📅 Projected Completion: Insufficient data for velocity calculation. " + "Run aggregation at least twice with data changes to establish a rate.
" + "Deadline: Jun 24, 2026 (KEK certificate expiry) | Days remaining: $stDaysToDeadline
" } # Cert expiry countdown $certToday = Get-Date $certKekExpiry = [datetime]"2026-06-24" $certUefiExpiry = [datetime]"2026-06-27" $certPcaExpiry = [datetime]"2026-10-19" $daysToKek = [math]::Max(0, ($certKekExpiry - $certToday).Days) $daysToUefi = [math]::Max(0, ($certUefiExpiry - $certToday).Days) $daysToPca = [math]::Max(0, ($certPcaExpiry - $certToday).Days) $certUrgency = if ($daysToKek -lt 30) { '#dc3545' } elseif ($daysToKek -lt 90) { '#fd7e14' } else { '#28a745' } # Helper: Read records from JSON, build bucket summary + first N device rows $maxInlineRows = 200 function Build-InlineTable { param([string]$JsonPath, [int]$MaxRows = 200, [string]$CsvFileName = "") $bucketSummary = "" $deviceRows = "" $totalCount = 0 if (Test-Path $JsonPath) { try { $data = Get-Content $JsonPath -Raw | ConvertFrom-Json $totalCount = $data.Count # BUCKET SUMMARY: Group by BucketId, show counts per bucket with Updated from global bucket stats if ($totalCount -gt 0) { $buckets = $data | Group-Object BucketId | Sort-Object Count -Descending $bucketSummary = "

By Hardware Bucket ($($buckets.Count) buckets)

" $bucketSummary += "
" foreach ($b in $buckets) { $bid = if ($b.Name) { $b.Name } else { "(empty)" } $mfr = ($b.Group | Select-Object -First 1).WMI_Manufacturer # Get Updated count from global bucket stats (all devices in this bucket across the entire dataset) $lookupKey = $bid $globalBucket = if ($stAllBuckets.ContainsKey($lookupKey)) { $stAllBuckets[$lookupKey] } else { $null } $bUpdatedGlobal = if ($globalBucket) { $globalBucket.Updated } else { 0 } $bTotalGlobal = if ($globalBucket) { $globalBucket.Count } else { $b.Count } $bNotUpdatedGlobal = $bTotalGlobal - $bUpdatedGlobal $bucketSummary += "`n" } $bucketSummary += "
BucketIDTotalUpdatedNot UpdatedManufacturer
$bid$bTotalGlobal$bUpdatedGlobal$bNotUpdatedGlobal$mfr
" } # DEVICE DETAIL: First N rows as flat list $slice = $data | Select-Object -First $MaxRows foreach ($d in $slice) { $conf = $d.ConfidenceLevel $confBadge = if ($conf -match "High") { 'High Conf' } elseif ($conf -match "Not Sup") { 'Not Supported' } elseif ($conf -match "Under") { 'Under Obs' } elseif ($conf -match "Paused") { 'Paused' } else { 'Action Req' } $statusBadge = if ($d.IsUpdated) { 'Updated' } elseif ($d.UEFICA2023Error) { 'Error' } else { 'Pending' } $deviceRows += "$($d.HostName)$($d.WMI_Manufacturer)$($d.WMI_Model)$confBadge$statusBadge$(if($d.UEFICA2023Error){$d.UEFICA2023Error}else{'-'})$($d.BucketId)`n" } } catch { } } if ($totalCount -eq 0) { return "
No devices in this category.
" } $showing = [math]::Min($MaxRows, $totalCount) $header = "
Total: $($totalCount.ToString("N0")) devices" if ($CsvFileName) { $header += " | 📄 Download Full CSV for Excel" } $header += "
" $deviceHeader = "

Device Details (showing first $showing)

" $deviceTable = "
$deviceRows
HostNameManufacturerModelConfidenceStatusErrorBucketId
" return "$header$bucketSummary$deviceHeader$deviceTable" } # Build inline tables from the JSON files already on disk, linking to CSVs $tblErrors = Build-InlineTable -JsonPath (Join-Path $dataDir "errors.json") -MaxRows $maxInlineRows -CsvFileName "SecureBoot_errors_$timestamp.csv" $tblKI = Build-InlineTable -JsonPath (Join-Path $dataDir "known_issues.json") -MaxRows $maxInlineRows -CsvFileName "SecureBoot_known_issues_$timestamp.csv" $tblKEK = Build-InlineTable -JsonPath (Join-Path $dataDir "missing_kek.json") -MaxRows $maxInlineRows -CsvFileName "SecureBoot_missing_kek_$timestamp.csv" $tblNotUpd = Build-InlineTable -JsonPath (Join-Path $dataDir "not_updated.json") -MaxRows $maxInlineRows -CsvFileName "SecureBoot_not_updated_$timestamp.csv" $tblTaskDis = Build-InlineTable -JsonPath (Join-Path $dataDir "task_disabled.json") -MaxRows $maxInlineRows -CsvFileName "SecureBoot_task_disabled_$timestamp.csv" $tblTemp = Build-InlineTable -JsonPath (Join-Path $dataDir "temp_failures.json") -MaxRows $maxInlineRows -CsvFileName "SecureBoot_temp_failures_$timestamp.csv" $tblPerm = Build-InlineTable -JsonPath (Join-Path $dataDir "perm_failures.json") -MaxRows $maxInlineRows -CsvFileName "SecureBoot_perm_failures_$timestamp.csv" $tblUpdated = Build-InlineTable -JsonPath (Join-Path $dataDir "updated_devices.json") -MaxRows $maxInlineRows -CsvFileName "SecureBoot_updated_devices_$timestamp.csv" $tblActionReq = Build-InlineTable -JsonPath (Join-Path $dataDir "action_required.json") -MaxRows $maxInlineRows -CsvFileName "SecureBoot_action_required_$timestamp.csv" $tblUnderObs = Build-InlineTable -JsonPath (Join-Path $dataDir "under_observation.json") -MaxRows $maxInlineRows -CsvFileName "SecureBoot_under_observation_$timestamp.csv" $tblNeedsReboot = Build-InlineTable -JsonPath (Join-Path $dataDir "needs_reboot.json") -MaxRows $maxInlineRows -CsvFileName "SecureBoot_needs_reboot_$timestamp.csv" $tblSBOff = Build-InlineTable -JsonPath (Join-Path $dataDir "secureboot_off.json") -MaxRows $maxInlineRows -CsvFileName "SecureBoot_secureboot_off_$timestamp.csv" $tblRolloutIP = Build-InlineTable -JsonPath (Join-Path $dataDir "rollout_inprogress.json") -MaxRows $maxInlineRows -CsvFileName "SecureBoot_rollout_inprogress_$timestamp.csv" # Custom table for Update Pending — includes UEFICA2023Status and UEFICA2023Error columns $tblUpdatePending = "" $upJsonPath = Join-Path $dataDir "update_pending.json" if (Test-Path $upJsonPath) { try { $upData = Get-Content $upJsonPath -Raw | ConvertFrom-Json $upCount = $upData.Count if ($upCount -gt 0) { $upHeader = "
Total: $($upCount.ToString("N0")) devices | 📄 Download Full CSV for Excel
" $upRows = "" $upSlice = $upData | Select-Object -First $maxInlineRows foreach ($d in $upSlice) { $uefiSt = if ($d.UEFICA2023Status) { $d.UEFICA2023Status } else { 'null' } $uefiErr = if ($d.UEFICA2023Error) { "$($d.UEFICA2023Error)" } else { '-' } $policyVal = if ($d.AvailableUpdatesPolicy) { $d.AvailableUpdatesPolicy } else { '-' } $wincsVal = if ($d.WinCSKeyApplied) { 'Yes' } else { '-' } $upRows += "$($d.HostName)$($d.WMI_Manufacturer)$($d.WMI_Model)$uefiSt$uefiErr$policyVal$wincsVal$($d.BucketId)`n" } $upShowing = [math]::Min($maxInlineRows, $upCount) $upDevHeader = "

Device Details (showing first $upShowing)

" $upTable = "
$upRows
HostNameManufacturerModelUEFICA2023StatusUEFICA2023ErrorPolicyWinCS KeyBucketId
" $tblUpdatePending = "$upHeader$upDevHeader$upTable" } else { $tblUpdatePending = "
No devices in this category.
" } } catch { $tblUpdatePending = "
No devices in this category.
" } } else { $tblUpdatePending = "
No devices in this category.
" } # Cert expiry countdown $certToday = Get-Date $certKekExpiry = [datetime]"2026-06-24" $certUefiExpiry = [datetime]"2026-06-27" $certPcaExpiry = [datetime]"2026-10-19" $daysToKek = [math]::Max(0, ($certKekExpiry - $certToday).Days) $daysToUefi = [math]::Max(0, ($certUefiExpiry - $certToday).Days) $daysToPca = [math]::Max(0, ($certPcaExpiry - $certToday).Days) $certUrgency = if ($daysToKek -lt 30) { '#dc3545' } elseif ($daysToKek -lt 90) { '#fd7e14' } else { '#28a745' } # Build manufacturer chart data inline (Top 10 by device count) $mfrSorted = $stMfrCounts.GetEnumerator() | Sort-Object { $_.Value.Total } -Descending | Select-Object -First 10 $mfrChartTitle = if ($stMfrCounts.Count -le 10) { "By Manufacturer" } else { "Top 10 Manufacturers" } $mfrLabels = ($mfrSorted | ForEach-Object { "'$($_.Key)'" }) -join "," $mfrUpdated = ($mfrSorted | ForEach-Object { $_.Value.Updated }) -join "," $mfrUpdatePending = ($mfrSorted | ForEach-Object { $_.Value.UpdatePending }) -join "," $mfrHighConf = ($mfrSorted | ForEach-Object { $_.Value.HighConf }) -join "," $mfrUnderObs = ($mfrSorted | ForEach-Object { $_.Value.UnderObs }) -join "," $mfrActionReq = ($mfrSorted | ForEach-Object { $_.Value.ActionReq }) -join "," $mfrTempPaused = ($mfrSorted | ForEach-Object { $_.Value.TempPaused }) -join "," $mfrNotSupported = ($mfrSorted | ForEach-Object { $_.Value.NotSupported }) -join "," $mfrSBOff = ($mfrSorted | ForEach-Object { $_.Value.SBOff }) -join "," $mfrWithErrors = ($mfrSorted | ForEach-Object { $_.Value.WithErrors }) -join "," # Build manufacturer table $mfrTableRows = "" $stMfrCounts.GetEnumerator() | Sort-Object { $_.Value.Total } -Descending | ForEach-Object { $mfrTableRows += "$($_.Key)$($_.Value.Total.ToString("N0"))$($_.Value.Updated.ToString("N0"))$($_.Value.HighConf.ToString("N0"))$($_.Value.ActionReq.ToString("N0"))`n" } # HTML close-tag fragments: broken into parts so web CMS platforms don't # interpret them as real HTML and inject invisible Unicode characters around them. $endScript = '' $endStyle = '' $endHead = '' $endBody = '' $endHtml = '' $htmlContent = @" Secure Boot Certificate Status Dashboard