중요 이 샘플 스크립트가 포함된 이 문서는 사용 중지되었습니다. 2026년 5월 12일 이후에 릴리스된 Windows 업데이트부터 샘플 스크립트는 디바이스의 %systemroot%\SecureBoot\ExampleRolloutScripts 폴더에 있습니다.
이 샘플 스크립트를 복사하여 붙여넣고 환경에 필요한 대로 수정합니다.
<# . 시놉시스 여러 디바이스의 보안 부팅 상태 JSON 데이터를 요약 보고서로 집계합니다.
. 설명 수집된 보안 부팅 상태 JSON 파일을 읽고 다음을 생성합니다. - 차트 및 필터링이 있는 HTML 대시보드 - ConfidenceLevel의 요약 - 테스트 전략을 위한 고유한 디바이스 버킷 분석 지원: - 컴퓨터별 파일: HOSTNAME_latest.json(권장) - 단일 JSON 파일 HostName에서 자동으로 중복 제거되어 최신 CollectionTime을 유지합니다. 기본적으로 에는 "Action Req" 또는 "High" 신뢰도가 있는 디바이스만 포함됩니다. 실행 가능한 버킷에 집중합니다. -IncludeAllConfidenceLevels를 사용하여 재정의합니다.
. PARAMETER InputPath JSON 파일의 경로: - 폴더: 모든 *_latest.json 파일(또는 _latest 파일이 없는 경우 *.json)을 읽습니다. - 파일: 단일 JSON 파일을 읽습니다.
. PARAMETER OutputPath 생성된 보고서의 경로(기본값: .\SecureBootReports)
. 예제 # 컴퓨터별 파일 폴더에서 집계(권장) .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" # 읽기: \\contoso\SecureBootLogs$\*_latest.json
. 예제 # 사용자 지정 출력 위치 .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -OutputPath "C:\Reports\SecureBoot"
. 예제 # Action Req 및 높은 신뢰도만 포함(기본 동작) .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" # 제외: 관찰, 일시 중지됨, 지원되지 않음
. 예제 # 모든 신뢰 수준 포함(필터 재정의) .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -IncludeAllConfidenceLevels
. 예제 # 사용자 지정 신뢰 수준 필터 .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -IncludeConfidenceLevels @("Action Req", "High", "Observation")
. 예제 # ENTERPRISE SCALE: 증분 모드 - 변경된 파일만 처리(빠른 후속 실행) .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -IncrementalMode # 첫 실행: 500K 디바이스의 전체 로드 ~2시간 # 후속 실행: 변경 내용이 없는 경우 초, 델타의 경우 분
. 예제 # 아무것도 변경되지 않은 경우 HTML 건너뛰기(가장 빠른 모니터링) .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -IncrementalMode -SkipReportIfUnchanged # 마지막 실행 이후 변경된 파일이 없는 경우: ~5초
. 예제 # 요약 전용 모드 - 큰 디바이스 테이블 건너뛰기(1-2분 및 20분 이상) .\Aggregate-SecureBootData.ps1 -InputPath "\\contoso\SecureBootLogs$" -SummaryOnly # CSV를 생성하지만 전체 디바이스 테이블을 사용하여 HTML dashboard 건너뜁니다.
. 노트 엔터프라이즈 배포를 위해 Detect-SecureBootCertUpdateStatus.ps1 페어링합니다.전체 배포 가이드는 GPO-DEPLOYMENT-GUIDE.md 참조하세요. 기본 동작은 관찰, 일시 중지됨 및 지원되지 않는 디바이스를 제외합니다. 실행 가능한 디바이스 버킷에만 보고를 집중합니다.#>
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, # InProgress 디바이스 를 식별하는 RolloutState.json 경로 [Parameter(Mandatory = $false)] [string]$RolloutSummaryPath, # Orchestrator에서 SecureBootRolloutSummary.json 경로(프로젝션 데이터 포함) [Parameter(Mandatory = $false)] [string[]]]$IncludeConfidenceLevels = @("Action Required", "High Confidence"), # 이러한 신뢰 수준만 포함(기본값: 실행 가능한 버킷만 해당) [Parameter(Mandatory = $false)] [switch]$IncludeAllConfidenceLevels, # 모든 신뢰 수준을 포함하도록 필터 재정의 [Parameter(Mandatory = $false)] [switch]$SkipHistoryTracking [Parameter(Mandatory = $false)] [switch]$IncrementalMode, # 델타 처리 사용 - 마지막 실행 이후 변경된 파일만 로드 [Parameter(Mandatory = $false)] [string]$CachePath, # 캐시 디렉터리 경로(기본값: OutputPath\.cache) [Parameter(Mandatory = $false)] [int]$ParallelThreads = 8, # 파일 로드를 위한 병렬 스레드 수(PS7 이상) [Parameter(Mandatory = $false)] [switch]$ForceFullRefresh, # 증분 모드 에서도 전체 다시 로드 강제 적용 [Parameter(Mandatory = $false)] [switch]$SkipReportIfUnchanged, 변경된 파일이 없는 경우 HTML/CSV 생성 건너뛰기(출력 통계만) [Parameter(Mandatory = $false)] [switch]$SummaryOnly, # 요약 통계만 생성(큰 디바이스 테이블 없음) - 훨씬 빠름 [Parameter(Mandatory = $false)] [switch]$StreamingMode # 메모리 효율적인 모드: 청크 처리, CSV 증분 쓰기, 메모리에 요약만 유지 )
# 자체 복구: HTML 아티클에서 복사 붙여넣을 때 웹 CMS에서 삽입된 보이지 않는 유니코드 문자를 제거합니다.# support.microsoft.com CMS는 너비가 0인 공백(U+200B), 호환되지 않는 공백(U+00A0) 및 기타를 삽입합니다. # 여기 문자열 내의 html 태그 주위에 보이지 않는 문자로 인해 PowerShell 구문 분석 오류가 발생합니다.if ($MyInvocation.MyCommand.Path) { $rawScript = [System.IO.File]::ReadAllText($MyInvocation.MyCommand.Path) if ($rawScript -match '[\u200B-\u200F\uFEFF]' -또는 $rawScript -match '\xA0') { Write-Host "경고: 보이지 않는 유니코드 문자 감지(웹 복사 붙여넣기에서 가능) - 자동 정리 스크립트..." -ForegroundColor 노란색 $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 "스크립트가 성공적으로 정리되었습니다. 다시 시작..." -ForegroundColor 녹색 & $MyInvocation.MyCommand.Path @PSBoundParameters 종료 $LASTEXITCODE } }
# 사용 가능한 경우 PowerShell 7로 자동 상승(큰 데이터 세트의 경우 6배 더 빠름) if ($PSVersionTable.PSVersion.Major -lt 7) { $pwshPath = Get-Command pwsh -ErrorAction SilentlyContinue | Select-Object -ExpandProperty 원본 if ($pwshPath) { Write-Host "PowerShell $($PSVersionTable.PSVersion)이 검색됨 - 더 빠른 처리를 위해 PowerShell 7로 다시 시작..." -ForegroundColor Yellow # 바인딩된 매개 변수에서 인수 목록 다시 작성 $relaunchArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $MyInvocation.MyCommand.Path) foreach($PSBoundParameters.Keys의 $key) { $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 종료 $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 = "배포 및 모니터링 샘플"
# 참고: 이 스크립트에는 다른 스크립트에 대한 종속성이 없습니다.# 전체 도구 집합의 경우 다음에서 다운로드합니다. $DownloadUrl -> $DownloadSubPage
#region 설정 Write-Host "=" * 60 -ForegroundColor Cyan Write-Host "보안 부팅 데이터 집계" -ForegroundColor Cyan Write-Host "=" * 60 -ForegroundColor Cyan
# 출력 디렉터리 만들기 if (-not (Test-Path $OutputPath)) { New-Item -ItemType 디렉터리 -경로 $OutputPath -Force | Out-Null }
# 데이터 로드 - CSV(레거시) 및 JSON(네이티브) 형식을 지원합니다. Write-Host "'n에서 데이터 로드: $InputPath" -ForegroundColor 노란색
# 디바이스 개체를 정규화하는 도우미 함수(필드 이름 차이 처리) 함수 Normalize-DeviceRecord { param($device) # Hostname 및 HostName 처리(JSON은 호스트 이름을 사용하고 CSV는 HostName을 사용합니다.) if($device. PSObject.Properties['Hostname'] -and -not $device. PSObject.Properties['HostName']) { $device | Add-Member -NotePropertyName 'HostName' -NotePropertyValue $device. 호스트 이름 -Force } # 신뢰도 및 ConfidenceLevel 처리(JSON은 신뢰도, CSV는 ConfidenceLevel 사용) # ConfidenceLevel은 공식 필드 이름입니다. 신뢰도를 매핑합니다. if($device. PSObject.Properties['Confidence'] -and -not $device. PSObject.Properties['ConfidenceLevel']) { $device | Add-Member -NotePropertyName 'ConfidenceLevel' -NotePropertyValue $device. 신뢰도 -Force } # Event1808Count 또는 UEFICA2023Status="Update"를 통해 업데이트 상태 추적 # 이렇게 하면 각 신뢰 버킷에서 업데이트된 디바이스 수를 추적할 수 있습니다. $event 1808 = 0 if($device. PSObject.Properties['Event1808Count']) { $event 1808 = [int]$device. Event1808Count } $uefiCaUpdated = $false if($device. PSObject.Properties['UEFICA2023Status'] -및 $device. UEFICA2023Status -eq "Updated") { $uefiCaUpdated = $true } if ($event 1808 -gt 0 -또는 $uefiCaUpdated) { # dashboard/롤아웃 논리에 대해 업데이트된 것으로 표시하지만 ConfidenceLevel을 재정의하지 마세요. $device | Add-Member -NotePropertyName 'IsUpdated' -NotePropertyValue $true -Force } else { $device | Add-Member -NotePropertyName 'IsUpdated' -NotePropertyValue $false -Force # ConfidenceLevel 분류: # - "높은 신뢰도", "관찰 중...", "일시적으로 일시 중지됨...", "지원되지 않음..." = 있는 그대로 사용 # - 다른 모든 항목(null, empty, "UpdateType:...", "Unknown", "N/A") = 카운터에서 필요한 작업으로 대체됩니다. # 정규화가 필요하지 않음 - 스트리밍 카운터의 다른 분기에서 처리합니다. } # OEMManufacturerName 및 WMI_Manufacturer 처리(JSON은 OEM*를 사용하고 레거시는 WMI_*를 사용합니다.) if($device. PSObject.Properties['OEMManufacturerName'] -and -not $device. PSObject.Properties['WMI_Manufacturer']) { $device | Add-Member -NotePropertyName 'WMI_Manufacturer' -NotePropertyValue $device. OEMManufacturerName -Force } # OEMModelNumber 및 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 # 캐시 경로 설정 if (-not $CachePath) { $CachePath = Join-Path $OutputPath ".cache" } $manifestPath = Join-Path $CachePath "FileManifest.json" $deviceCachePath = Join-Path $CachePath "DeviceCache.json"
# 캐시 관리 함수 함수 Get-FileManifest { param([string]$Path) if (Test-Path $Path) { try { $json = Get-Content $Path -Raw | ConvertFrom-Json # PSObject를 해시 테이블로 변환(PS5.1 호환 - PS7에는 -AsHashtable이 있습니다.) $ht = @{} $json. PSObject.Properties | ForEach-Object { $ht[$_. 이름] = $_. 값 } return $ht } catch { return @{} } } return @{} }
함수 Save-FileManifest { param([hashtable]$Manifest, [string]$Path) $dir = Split-Path $Path -Parent if (-not (Test-Path $dir)) { New-Item -ItemType 디렉터리 -경로 $dir -Force | Out-Null } $Manifest | ConvertTo-Json -Depth 3 -Compress | Set-Content $Path -Force }
함수 Get-DeviceCache { param([string]$Path) if (Test-Path $Path) { try { $cacheData = Get-Content $Path -Raw | ConvertFrom-Json Write-Host "로드된 디바이스 캐시: $($cacheData.Count) 디바이스" -ForegroundColor DarkGray return $cacheData } catch { Write-Host "캐시가 손상되고 다시 빌드됩니다." -ForegroundColor Yellow return @() } } return @() }
함수 Save-DeviceCache { param($Devices, [string]$Path) $dir = Split-Path $Path -Parent if (-not (Test-Path $dir)) { New-Item -ItemType 디렉터리 -경로 $dir -Force | Out-Null } # 배열로 변환 및 저장 $deviceArray = @($Devices) $deviceArray | ConvertTo-Json -깊이 10 -압축 | Set-Content $Path -Force Write-Host "저장된 디바이스 캐시: $($deviceArray.Count) 디바이스" -ForegroundColor DarkGray }
함수 Get-ChangedFiles { param( [System.IO.FileInfo[]]$AllFiles, [hashtable]$Manifest ) $changed = [System.Collections.ArrayList]::new() $unchanged = [System.Collections.ArrayList]::new() $newManifest = @{} # 매니페스트에서 대/소문자를 구분하지 않는 조회 빌드(소문자로 정규화) $manifestLookup = @{} foreach($Manifest.Keys의 $mk) { $manifestLookup[$mk. ToLowerInvariant()] = $Manifest[$mk] } foreach($AllFiles $file) { $key = $file. FullName.ToLowerInvariant() # 소문자로 경로 정규화 $lwt = $file. LastWriteTimeUtc.ToString("o") $newManifest[$key] = @{ LastWriteTimeUtc = $lwt 크기 = $file. 길이 } if ($manifestLookup.ContainsKey($key)) { $cached = $manifestLookup[$key] if($cached. LastWriteTimeUtc -eq $lwt 및 $cached. 크기 -eq $file. Length) { [void]$unchanged. Add($file) 계속 } } [void]$changed. Add($file) } return @{ 변경됨 = $changed 변경되지 않음 = $unchanged NewManifest = $newManifest } }
# 일괄 처리를 사용하는 초고속 병렬 파일 로드 함수 Load-FilesParallel { param( [System.IO.FileInfo[]]$Files, [int]$Threads = 8 )
$totalFiles = $Files. 횟수 # 메모리 제어를 향상하기 위해 각각 최대 1,000개 파일의 일괄 처리 사용 $batchSize = [math]::Min(1000, [math]::Ceiling($totalFiles / [math]::Max(1, $Threads))) $batches = [System.Collections.Generic.List[object]]::new()
($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) ~$batchSize 파일의 일괄 처리)" -NoNewline -ForegroundColor DarkGray $flatResults = [System.Collections.Generic.List[object]]::new() # PowerShell 7+ 병렬을 사용할 수 있는지 확인 $canParallel = $PSVersionTable.PSVersion.Major -ge 7 if ($canParallel -and $Threads -gt 1) { # PS7+: 병렬로 일괄 처리 $results = $batches | ForEach-Object -ThrottleLimit $Threads -Parallel { $batchFiles = $_ $batchResults = [System.Collections.Generic.List[object]]::new() foreach($batchFiles $file) { try { $content = [System.IO.File]::ReadAllText($file. FullName) | ConvertFrom-Json $batchResults.Add($content) } catch { } } $batchResults.ToArray() } foreach($results $batch) { if ($batch) { foreach ($item in $batch) { $flatResults.Add($item) } } } } else { # PS5.1 대체: 순차적 처리(<10K 파일의 경우 여전히 빠름) foreach($Files $file) { 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) { # 단일 JSON 파일 if ($InputPath -like "*.json") { $jsonContent = Get-Content -Path $InputPath -Raw | ConvertFrom-Json $allDevices = @($jsonContent) | ForEach-Object { Normalize-DeviceRecord $_ } Write-Host "파일에서 $($allDevices.Count) 레코드 로드됨" } else { Write-Error "JSON 형식만 지원됩니다. 파일에는 .json 확장명이 있어야 합니다." 종료 1 } } elseif(Test-Path $InputPath -PathType Container) { # 폴더 - JSON만 $jsonFiles = @(Get-ChildItem -Path $InputPath -Filter "*.json" -Recurse -ErrorAction SilentlyContinue | Where-Object { $_. Name -notmatch "ScanHistory|RolloutState|RolloutPlan" }) # *_latest.json 파일이 있는 경우(컴퓨터별 모드) 선호 $latestJson = $jsonFiles | Where-Object { $_. 이름 -like "*_latest.json" } if ($latestJson.Count -gt 0) { $jsonFiles = $latestJson } $totalFiles = $jsonFiles.Count if ($totalFiles -eq 0) { Write-Error "에서 JSON 파일을 찾을 수 없음: $InputPath" 종료 1 } Write-Host "$totalFiles JSON 파일 찾기" -ForegroundColor 회색 # 신뢰도 수준과 일치하는 도우미 함수(짧고 전체 형식 모두 처리) # StreamingMode와 일반 경로가 모두 사용할 수 있도록 초기에 정의됨 함수 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*" -또는 $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 - 대용량 데이터 세트에 대한 메모리 효율적인 처리 # 항상 메모리 효율적인 처리 및 새 스타일의 dashboard StreamingMode를 사용합니다. if (-not $StreamingMode) { Write-Host "StreamingMode 자동 사용(새 스타일 dashboard)" -ForegroundColor Yellow $StreamingMode = $true if (-not $IncrementalMode) { $IncrementalMode = $true } } # -StreamingMode를 사용하도록 설정하면 청크에서 파일을 처리하여 카운터만 메모리에 보관합니다. # 디바이스 수준 데이터는 dashboard 주문형 로드를 위해 청크당 JSON 파일에 기록됩니다.# 메모리 사용량: 데이터 세트 크기에 관계없이 최대 1.5GB(스트리밍 없이 10~20GB)if ($StreamingMode) { Write-Host "STREAMING MODE 사용 - 메모리 효율적인 처리" -ForegroundColor Green $streamSw = [System.Diagnostics.Stopwatch]::StartNew() # INCREMENTAL CHECK: 마지막 실행 이후 변경된 파일이 없으면 완전히 처리를 건너뜁니다. if ($IncrementalMode -and -not $ForceFullRefresh) { $stManifestDir = Join-Path $OutputPath ".cache" $stManifestPath = Join-Path $stManifestDir "StreamingManifest.json" if (Test-Path $stManifestPath) { Write-Host "마지막 스트리밍 실행 이후 변경 내용 확인..." -ForegroundColor Cyan $stOldManifest = Get-FileManifest -Path $stManifestPath if ($stOldManifest.Count -gt 0) { $stChanged = $false # 빠른 검사: 동일한 파일 수? if ($stOldManifest.Count -eq $totalFiles) { # 100개 최신 파일 확인(LastWriteTime 내림차순으로 정렬) # 파일이 변경된 경우 가장 최근 타임스탬프가 있고 먼저 표시됩니다. $sampleSize = [math]::Min(100, $totalFiles) $sampleFiles = $jsonFiles | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First $sampleSize foreach($sampleFiles $sf) { $sfKey = $sf. FullName.ToLowerInvariant() if (-not $stOldManifest.ContainsKey($sfKey)) { $stChanged = $true 휴식 } # 타임스탬프 비교 - 캐시된 날짜/시간 또는 JSON 왕복 후 문자열일 수 있습니다. $cachedLWT = $stOldManifest[$sfKey]. LastWriteTimeUtc $fileDT = $sf. LastWriteTimeUtc try { # 캐시된 가 이미 DateTime인 경우(ConvertFrom-Json 자동 변환) 직접 사용 if ($cachedLWT -is [DateTime]) { $cachedDT = $cachedLWT.ToUniversalTime() } else { $cachedDT = [DateTimeOffset]::P arse("$cachedLWT"). UtcDateTime } if ([math]::Abs(($cachedDT - $fileDT). TotalSeconds) -gt 1) { $stChanged = $true 휴식 } } catch { $stChanged = $true 휴식 } } } else { $stChanged = $true } if (-not $stChanged) { # 출력 파일이 있는지 확인 $stSummaryExists = Get-ChildItem(조인 경로 $OutputPath "SecureBoot_Summary_*.csv") -EA SilentlyContinue | Select-Object -First 1 $stDashExists = Get-ChildItem(조인 경로 $OutputPath "SecureBoot_Dashboard_*.html") -EA SilentlyContinue | Select-Object -First 1 if ($stSummaryExists -and $stDashExists) { Write-Host "변경 내용이 검색되지 않음($totalFiles 파일 변경되지 않음) - 처리 건너뛰기" -ForegroundColor Green Write-Host " 마지막 dashboard: $($stDashExists.FullName)" -ForegroundColor White $cachedStats = Get-Content $stSummaryExists.FullName | ConvertFrom-Csv Write-Host " 디바이스: $($cachedStats.TotalDevices) | 업데이트됨: $($cachedStats.Updated) | 오류: $($cachedStats.WithErrors)" -ForegroundColor Gray Write-Host "$([math]::Round($streamSw.Elapsed.TotalSeconds, 1))s에서 완료됨(처리 필요 없음)" -ForegroundColor Green return $cachedStats } } else { # DELTA PATCH: 변경된 파일을 정확히 찾습니다. Write-Host " 변경 내용 검색 - 변경된 파일 식별..." -ForegroundColor 노란색 $changedFiles = [System.Collections.ArrayList]::new() $newFiles = [System.Collections.ArrayList]::new() foreach($jsonFiles $jf) { $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]::P arse("$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 " 변경됨: $($changedFiles.Count) | 새로 만들기: $($newFiles.Count) | 합계: $totalChanged($changePct%)" -포그라운드콜러 옐로우 if ($totalChanged -gt 0 -and $changePct -lt 10) { # DELTA PATCH MODE: <10% 변경됨, 기존 데이터 패치 Write-Host " 델타 패치 모드($changePct% < 10%) - $totalChanged 파일 패치..." -ForegroundColor Green $dataDir = Join-Path $OutputPath "데이터" # 변경된 디바이스 레코드 로드/새 디바이스 레코드 로드 $deltaDevices = @{} $allDeltaFiles = @($changedFiles) + @($newFiles) foreach($allDeltaFiles $df) { try { $devData = Get-Content $df. FullName -Raw | ConvertFrom-Json $dev = Normalize-DeviceRecord $devData if($dev. HostName) { $deltaDevices[$dev. HostName] = $dev } } catch { } } Write-Host "로드된 $($deltaDevices.Count) 변경된 디바이스 레코드" -ForegroundColor Gray # 각 범주 JSON: 변경된 호스트 이름에 대한 이전 항목을 제거하고 새 항목을 추가합니다. $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($deltaDevices.Keys의 $hn) { [void]$changedHostnames.Add($hn) } foreach($categoryFiles $cat) { $catPath = Join-Path $dataDir "$cat.json" if (Test-Path $catPath) { try { $catData = Get-Content $catPath -Raw | ConvertFrom-Json # 변경된 호스트 이름에 대한 이전 항목 제거 $catData = @($catData | Where-Object { -not $changedHostnames.Contains($_. HostName) }) # 변경된 각 디바이스를 범주로 다시 분류 #(분류 후 아래에 추가됨) $catData | ConvertTo-Json -Depth 5 | Set-Content $catPath -UTF8 인코딩 } catch { } } } # 변경된 각 디바이스를 분류하고 올바른 범주 파일에 추가 foreach($deltaDevices.Values의 $dev) { $slim = [ordered]@{ HostName = $dev. 호스트 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) -및 $dev. UEFICA2023Error -ne "0" -및 $dev. UEFICA2023Error -ne "") $tskDis = ($dev. SecureBootTaskEnabled -eq $false 또는 $dev. SecureBootTaskStatus -eq 'Disabled' -또는 $dev. SecureBootTaskStatus -eq 'NotFound') $tskNF = ($dev. SecureBootTaskStatus -eq 'NotFound') $sbOn = ($dev. SecureBootEnabled -ne $false -및 "$($dev. SecureBootEnabled)" -ne "False") $e 1801 = if($dev. PSObject.Properties['Event1801Count']) { [int]$dev. Event1801Count } else { 0 } $e 1808 = if($dev. PSObject.Properties['Event1808Count']) { [int]$dev. Event1808Count } else { 0 } $e 1803 = if($dev. PSObject.Properties['Event1803Count']) { [int]$dev. Event1803Count } else { 0 } $mKEK = ($e 1803 -gt 0 -또는 $dev. MissingKEK -eq $true) $hKI = ((-not [string]::IsNullOrEmpty($dev. SkipReasonKnownIssue)) -또는 (-not [string]::IsNullOrEmpty($dev. KnownIssueId))) $rStat = if($dev. PSObject.Properties['RolloutStatus']) { $dev. RolloutStatus } else { "" } # 일치하는 범주 파일에 추가 $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 -또는 (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 ($e 1801 -gt 0 -and $e 1808 -eq 0 -and -not $hasErr -and $rStat -eq "InProgress") { $targets += "rollout_inprogress" } foreach($targets $tgt) { $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 } } } # 패치된 JSON에서 CSV 다시 생성 Write-Host " 패치된 데이터에서 CSV 다시 생성..." -ForegroundColor Gray $newTimestamp = Get-Date -Format "yyyyMMdd-HHmmss" foreach($categoryFiles $cat) { $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 { } } } # 패치된 JSON 파일의 통계 재검표 Write-Host " 패치된 데이터에서 요약 다시 계산..." -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($categoryFiles $cat) { $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" { } # 계산됨 "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). 횟수 $pTotal = $pUpdated + $pNotUpdated + $pSBOff Write-Host "델타 패치 완료: $totalChanged 디바이스 업데이트됨" -ForegroundColor Green Write-Host " 합계: $pTotal | 업데이트됨: $pUpdated | NotUpdated: $pNotUpdated | 오류: $pErrors" -ForegroundColor White # 매니페스트 업데이트 $stManifestDir = Join-Path $OutputPath ".cache" $stNewManifest = @{} foreach($jsonFiles $jf) { $stNewManifest[$jf. FullName.ToLowerInvariant()] = @{ LastWriteTimeUtc = $jf. LastWriteTimeUtc.ToString("o"); 크기 = $jf. 길이 } } Save-FileManifest -매니페스트 $stNewManifest -경로 $stManifestPath Write-Host "$([math]::Round($streamSw.Elapsed.TotalSeconds, 1)s(델타 패치 - $totalChanged 디바이스)에서 완료됨" -ForegroundColor Green # HTML dashboard 다시 생성하기 위해 전체 스트리밍 다시 처리로 넘어가기 # 데이터 파일이 이미 패치되었으므로 dashboard 최신 상태로 유지됩니다. Write-Host "패치된 데이터에서 dashboard 다시 생성..." -ForegroundColor Yellow } else { Write-Host " $changePct% 파일이 변경됨(>= 10%) - 전체 스트리밍 다시 처리 필요" -ForegroundColor Yellow } } } } } # 주문형 디바이스 JSON 파일에 대한 데이터 하위 디렉터리 만들기 $dataDir = Join-Path $OutputPath "데이터" if (-not (Test-Path $dataDir)) { New-Item -ItemType Directory -Path $dataDir -Force | Out-Null } # HashSet를 통한 중복 제거(조회당 O(1), 600K 호스트 이름에 대해 ~50MB) $seenHostnames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) # 간단한 요약 카운터(메모리의 $allDevices + $uniqueDevices 대체) $c = @{ Total = 0; SBEnabled = 0; SBOff = 0 업데이트됨 = 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 } # AtRisk/SafeList에 대한 버킷 추적(경량 집합) $stFailedBuckets = [System.Collections.Generic.HashSet[string]]::new() $stSuccessBuckets = [System.Collections.Generic.HashSet[string]]::new() $stAllBuckets = @{} $stMfrCounts = @{} $stErrorCodeCounts = @{}; $stErrorCodeSamples = @{} $stKnownIssueCounts = @{} # 일괄 처리 모드 디바이스 데이터 파일: 청크당 누적, 청크 경계에서 플러시 $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($stDeviceFiles $dfName) { $dfPath = Join-Path $dataDir "$dfName.json" [System.IO.File]::WriteAllText($dfPath, "['n", [System.Text.Encoding]::UTF8) $stDeviceFilePaths[$dfName] = $dfPath; $stDeviceFileCounts[$dfName] = 0 } # JSON 출력에 대한 슬림 디바이스 레코드(필수 필드만, 최대 200바이트 및 최대 2KB 전체) 함수 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 } } } # 일괄 처리를 JSON 파일로 플러시(추가 모드) 함수 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($Batch $fDev) { 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) } # 기본 스트리밍 루프 $stChunkSize = if ($totalFiles -le 10000) { $totalFiles } else { 10000 } $stTotalChunks = [math]::Ceiling($totalFiles/$stChunkSize) $stPeakMemMB = 0 if ($stTotalChunks -gt 1) { Write-Host "$stChunkSize(스트리밍, $ParallelThreads 스레드)의 $stTotalChunks 청크에서 $totalFiles 파일 처리):" -ForegroundColor Cyan } else { Write-Host "$totalFiles 파일 처리(스트리밍, $ParallelThreads 스레드):" -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 " 청크 $($ci + 1)/$stTotalChunks($($cFiles.Count) 파일): " -NoNewline -ForegroundColor Gray } else { Write-Host " $($cFiles.Count) 파일 로드: " -NoNewline -ForegroundColor Gray } $cSw = [System.Diagnostics.Stopwatch]::StartNew() $rawDevices = Load-FilesParallel -Files $cFiles -스레드 $ParallelThreads # 청크 단위 일괄 처리 목록 $cBatches = @{} foreach($stDeviceFiles $df) { $cBatches[$df] = [System.Collections.Generic.List[object]]::new() } $cNew = 0; $cDupe = 0 foreach($rawDevices $raw) { if (-not $raw) { continue } $device = Normalize-DeviceRecord $raw $hostname = $device. 호스트 if (-not $hostname) { continue } if ($seenHostnames.Contains($hostname)) { $cDupe++; continue } [void]$seenHostnames.Add($hostname) $cNew++; $c.Total++ $sbOn = ($device. SecureBootEnabled -ne $false -및 "$($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'] -및 $device. ConfidenceLevel) { "$($device. ConfidenceLevel)" } else { "" } $hasErr = (-not [string]::IsNullOrEmpty($device. UEFICA2023Error) -및 "$($device. UEFICA2023Error)" -ne "0" -and "$($device. UEFICA2023Error)" -ne "") $tskDis = ($device. SecureBootTaskEnabled -eq $false -또는 "$($device. SecureBootTaskStatus)" -eq 'Disabled' -또는 "$($device. SecureBootTaskStatus)" -eq 'NotFound') $tskNF = ("$($device. SecureBootTaskStatus)" -eq 'NotFound') $bid = if($device. PSObject.Properties['BucketId'] -및 $device. BucketId) { "$($device. BucketId)" } else { "" } $e 1808 = if($device. PSObject.Properties['Event1808Count']) { [int]$device. Event1808Count } else { 0 } $e 1801 = if($device. PSObject.Properties['Event1801Count']) { [int]$device. Event1801Count } else { 0 } $e 1803 = if($device. PSObject.Properties['Event1803Count']) { [int]$device. Event1803Count } else { 0 } $mKEK = ($e 1803 -gt 0 -또는 $device. MissingKEK -eq $true -또는 "$($device. MissingKEK)" -eq "True") $hKI = ((-not [string]::IsNullOrEmpty($device. SkipReasonKnownIssue)) -또는 (-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 { "" } # 사전 컴퓨팅 업데이트 보류 중 플래그(정책/WinCS 적용, 아직 업데이트되지 상태, SB ON, 작업 사용 안 함) $uefiStatus = if($device. PSObject.Properties['UEFICA2023Status']) { "$($device. UEFICA2023Status)" } else { "" } $hasPolicy = ($device. PSObject.Properties['AvailableUpdatesPolicy'] -and $null -ne $device. AvailableUpdatesPolicy -및 "$($device. AvailableUpdatesPolicy)" -ne '') $hasWinCS = ($device. PSObject.Properties['WinCSKeyApplied'] -및 $device. WinCSKeyApplied -eq $true) $statusPending = ([string]::IsNullOrEmpty($uefiStatus) -또는 $uefiStatus -eq 'NotStarted' -또는 $uefiStatus -eq 'InProgress') $isUpdatePending = (($hasPolicy -또는 $hasWinCS) - 및 $statusPending -와 -not $isUpd -and $sbOn -and -not -not $tskDis) if ($isUpd) { $c.Updated++; [void]$stSuccessBuckets.Add($bid); $cBatches["updated_devices"]. Add((Get-SlimDevice $device)) # 다시 부팅이 필요한 업데이트된 디바이스 추적(UEFICA2023Status=업데이트되었지만 Event1808=0) if ($e 1808 -eq 0) { $c.NeedsReboot++; $cBatches["needs_reboot"]. Add((Get-SlimDevice $device)) } } elseif (-not $sbOn) { # SecureBoot OFF - scope, 신뢰도로 분류하지 않음 } else { if ($isUpdatePending) { } # Counted 별도로 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 -또는 (Test-ConfidenceLevel $conf 'TemporarilyPaused')) { $c.TempFailures++; $cBatches["temp_failures"]. Add((Get-SlimDevice $device)) } if (-not $isUpd -and ((Test-ConfidenceLevel $conf 'NotSupported') -또는 ($tskNF -and $hasErr)) { $c.PermFailures++; $cBatches["perm_failures"]. Add((Get-SlimDevice $device)) } if ($e 1801 -gt 0 -and $e 1808 -eq 0 -and -not $hasErr -and $rStat -eq "InProgress") { $c.RolloutInProgress++; $cBatches["rollout_inprogress"]. Add((Get-SlimDevice $device)) } if ($e 1801 -gt 0 -and $e 1808 -eq 0 -and -not $hasErr -and $rStat -ne "InProgress") { $c.NotYetInitiated++ } if ($rStat -eq "InProgress" -and $e 1808 -eq 0) { $c.InProgress++ } # 업데이트 보류 중: 정책 또는 WinCS 적용, 상태 보류 중, SB ON, 작업을 사용하지 않도록 설정하지 않음 if ($isUpdatePending) { $c.UpdatePending++; $cBatches["update_pending"]. Add((Get-SlimDevice $device)) } if (-not $isUpd -and $sbOn) { $cBatches["not_updated"]. Add((Get-SlimDevice $device)) } # 관찰 디바이스에서(작업 필요와 별개) if (-not $isUpd -and (Test-ConfidenceLevel $conf 'UnderObservation')) { $cBatches["under_observation"]. Add((Get-SlimDevice $device)) } # 작업 필요: 업데이트되지 않음, SB ON, 다른 신뢰 범주와 일치하지 않음, 업데이트 보류 중 아님 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++ } # 버킷별로 모든 디바이스 추적(빈 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]. 모델 = $device. WMI_Model } if($device. PSObject.Properties['BIOSDescription']) { $stAllBuckets[$bucketKey]. BIOS = $device. BIOSDescription } } $stAllBuckets[$bucketKey]. Count++ if ($isUpd) { $stAllBuckets[$bucketKey]. Updated++ } } # 디스크에 일괄 처리 플러시 foreach($stDeviceFiles $df) { 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 } # JSON 배열 완료 foreach($stDeviceFiles $dfName) { [System.IO.File]::AppendAllText($stDeviceFilePaths[$dfName], "'n]", [System.Text.Encoding]::UTF8) Write-Host " $dfName.json: $($stDeviceFileCounts[$dfName]) 디바이스" -ForegroundColor DarkGray } # 컴퓨팅 파생 통계 $stAtRisk = 0; $stSafeList = 0 foreach($stAllBuckets.Keys의 $bid) { $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 = not_updated 일괄 처리의 개수(SB ON이 있고 업데이트되지 않은 디바이스) $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 업데이트됨 = $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" } # CSV 쓰기 [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=$_. 키; 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 값 -내림차순 | ForEach-Object { [PSCustomObject]@{ ErrorCode=$_. 키; Count=$_. 값; 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=$_. 키; Count=$_. Value.Count; Updated=$_. Value.Updated; NotUpdated=$_. Value.Count-$_. Value.Updated; Manufacturer=$_. Value.Manufacturer } } | Export-Csv -Path(조인 경로 $OutputPath "SecureBoot_UniqueBuckets_$timestamp.csv") -NoTypeInformation -Encoding UTF8 # 오케스트레이터 호환 CSV 생성(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 - 오케스트레이터가 *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) 디바이스)" -ForegroundColor Gray } } catch { } } # dashboard 대한 JSON 데이터 쓰기 $stats | ConvertTo-Json -Depth 3 | Set-Content(조인 경로 $dataDir "summary.json") -ENcoding UTF8 # 기록 추적: 추세 차트에 대한 데이터 요소 저장 # 추세 데이터가 타임스탬프가 있는 집계 폴더 간에 유지되도록 안정적인 캐시 위치를 사용합니다. # OutputPath가 "...\Aggregation_yyyyMMdd_HHmmss"처럼 보이면 캐시가 부모 폴더로 이동합니다.# 그렇지 않으면 캐시가 OutputPath 자체 내에 들어갑니다.$parentDir = Split-Path $OutputPath -Parent $leafName = Split-Path $OutputPath -Leaf if ($leafName -match '^Aggregation_\d{8}' -또는 $leafName -eq 'Aggregation_Current') { # Orchestrator에서 만든 타임스탬프 폴더 - 안정적인 캐시에 부모 사용 $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 = @() } } # 또한 OutputPath\.cache\ 내에 검사(이전 버전의 레거시 위치) # 기본 기록에 없는 데이터 요소를 병합합니다. if ($leafName -eq 'Aggregation_Current' -또는 $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 { $_. 날짜 }) foreach($innerData $entry) { if($entry. 날짜 및 $entry. Date -notin $existingDates) { $historyData += $entry } } if ($innerData.Count -gt 0) { Write-Host "내부 캐시에서 $($innerData.Count) 데이터 요소를 병합했습니다." -ForegroundColor DarkGray } } catch { } } }
# BOOTSTRAP: 추세 기록이 비어 있거나 스파스인 경우 기록 데이터에서 재구성합니다. if ($historyData.Count -lt 2 -and ($leafName -match '^Aggregation_\d{8}' -또는 $leafName -eq 'Aggregation_Current')) { Write-Host "기록 데이터에서 추세 기록 부트스트랩..." -ForegroundColor 노란색 $dailyData = @{} # 원본 1: 현재 폴더 내의 CSV 요약(Aggregation_Current 모든 요약 CSV 유지) $localSummaries = Get-ChildItem $OutputPath -필터 "SecureBoot_Summary_*.csv" -EA SilentlyContinue | Sort-Object 이름 foreach($localSummaries $summCsv) { try { $summ = Import-Csv $summCsv.FullName | Select-Object -First 1 if($summ. TotalDevices -및 [int]$summ. TotalDevices -gt 0 -및 $summ. ReportGeneratedAt) { $dateStr = ([datetime]$summ. ReportGeneratedAt). ToString("yyyy-MM-dd") $updated = if($summ. 업데이트됨) { [int]$summ. 업데이트됨 } else { 0 } $notUpd = if($summ. NotUptodate) { [int]$summ. NotUptodate } else { [int]$summ. TotalDevices - $updated } $dailyData[$dateStr] = [PSCustomObject]@{ 날짜 = $dateStr; Total = [int]$summ. TotalDevices; 업데이트됨 = $updated; NotUpdated = $notUpd NeedsReboot = 0; 오류 = 0; ActionRequired = if($summ. ActionRequired) { [int]$summ. ActionRequired } else { 0 } } } } catch { } } # 원본 2: 오래된 타임스탬프 Aggregation_* 폴더(레거시, 여전히 존재하는 경우) $aggFolders = Get-ChildItem $parentDir -Directory -Filter "Aggregation_*" -EA SilentlyContinue | Where-Object { $_. 이름 -match '^Aggregation_\d{8}' } | Sort-Object 이름 foreach($aggFolders $folder) { $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 -및 [int]$summ. TotalDevices -gt 0) { $dateStr = $folder. Name -replace '^Aggregation_(\d{4})(\d{2})(\d{2})_.*', '$1-$2-$3' $updated = if($summ. 업데이트됨) { [int]$summ. 업데이트됨 } else { 0 } $notUpd = if($summ. NotUptodate) { [int]$summ. NotUptodate } else { [int]$summ. TotalDevices - $updated } $dailyData[$dateStr] = [PSCustomObject]@{ 날짜 = $dateStr; Total = [int]$summ. TotalDevices; 업데이트됨 = $updated; NotUpdated = $notUpd NeedsReboot = 0; 오류 = 0; ActionRequired = if($summ. ActionRequired) { [int]$summ. ActionRequired } else { 0 } } } } catch { } } } # 소스 3: RolloutState.json WaveHistory(1일차의 웨이브별 타임스탬프 포함) # 이전 집계 폴더가 없는 경우에도 기준 데이터 요소를 제공합니다. $rolloutStatePaths = @( (Join-Path $parentDir "RolloutState\RolloutState.json") (Join-Path $OutputPath "RolloutState\RolloutState.json") ) foreach($rolloutStatePaths $rsPath) { if (Test-Path $rsPath) { try { $rsData = Get-Content $rsPath -Raw | ConvertFrom-Json if ($rsData.WaveHistory) { # 웨이브 시작 날짜를 추세 데이터 요소로 사용 # 각 웨이브를 대상으로 하는 누적 디바이스 계산 $cumulativeTargeted = 0 foreach($rsData.WaveHistory의 $wave) { if($wave. StartedAt -and $wave. DeviceCount) { $waveDate = ([datetime]$wave. StartedAt). ToString("yyyy-MM-dd") $cumulativeTargeted += [int]$wave. DeviceCount if (-not $dailyData.ContainsKey($waveDate)) { # 근사: 웨이브 시작 시 이전 웨이브의 디바이스만 업데이트되었습니다. $dailyData[$waveDate] = [PSCustomObject]@{ 날짜 = $waveDate; Total = $c.Total; 업데이트됨 = [math]::Max(0, $cumulativeTargeted - [int]$wave. DeviceCount) NotUpdated = $c.Total - [math]::Max(0, $cumulativeTargeted - [int]$wave. DeviceCount) NeedsReboot = 0; 오류 = 0; ActionRequired = 0 } } } } } } catch { } break # 처음 찾은 항목 사용 } }
if ($dailyData.Count -gt 0) { $historyData = @($dailyData.GetEnumerator() | Sort-Object 키 | ForEach-Object { $_. 값 }) Write-Host " 기록 요약에서 부트스트랩된 $($historyData.Count) 데이터 포인트" -ForegroundColor Green } }
# 현재 데이터 요소 추가(요일별 중복 제거 - 일별 최신 유지) $todayKey = (Get-Date). ToString("yyyy-MM-dd") $existingToday = $historyData | Where-Object { "$($_. Date)" -like "$todayKey*" } if ($existingToday) { # 오늘의 항목 바꾸기 $historyData = @($historyData | Where-Object { "$($_. Date)" -notlike "$todayKey*" }) } $historyData += [PSCustomObject]@{ 날짜 = $todayKey Total = $c.Total 업데이트됨 = $c.Updated NotUpdated = $stNotUptodate NeedsReboot = $c.NeedsReboot 오류 = $c.WithErrors ActionRequired = $c.ActionReq } # 잘못된 데이터 요소를 제거하고(총 0개) 마지막 90개 유지 $historyData = @($historyData | Where-Object { [int]$_. 총 -gt 0 }) # 상한 없음 - 추세 데이터는 ~100바이트/항목, 1년 = ~36KB입니다. $historyData | ConvertTo-Json -Depth 3 | Set-Content $historyPath -ENcoding UTF8 Write-Host " 추세 기록: $($historyData.Count) 데이터 포인트" -ForegroundColor DarkGray # HTML에 대한 추세 차트 데이터 빌드 $trendLabels = ($historyData | ForEach-Object { "'$($_. Date)'" }) -join "," $trendUpdated = ($historyData | ForEach-Object { $_. 업데이트된 }) -join "," $trendNotUpdated = ($historyData | ForEach-Object { $_. NotUpdated }) -join "," $trendTotal = ($historyData | ForEach-Object { $_. Total }) -join "," # 프로젝션: 지수를 두 배로 사용하여 추세선 확장(2,4,8,16...) # 실제 추세 기록 데이터에서 웨이브 크기 및 관찰 기간을 파생합니다. # - 웨이브 크기 = 역사상 가장 큰 단일 기간 증가(배포된 가장 최근 웨이브) # - 관찰 일 = 추세 데이터 포인트 사이의 평균 달력 일(실행 빈도) #은 오케스트레이터의 2배 증가 전략과 일치하여 각 기간마다 웨이브 크기를 두 배로 큽니다.$projLabels = ""; $projUpdated = ""; $projNotUpdated = ""; $hasProjection = $false if ($historyData.Count -ge 2) { $lastUpdated = $c.Updated $remaining = $stNotUptodate # SB-ON 업데이트되지 않은 디바이스만(SecureBoot OFF 제외) $projDates = @(); $projValues = @(); $projNotUpdValues = @() $projDate = Get-Date
# 추세 기록에서 웨이브 크기 및 관찰 기간 파생 $increments = @() $dayGaps = @() for ($hi = 1; $hi -lt $historyData.Count; $hi++) { $inc = $historyData[$hi]. 업데이트됨 - $historyData[$hi-1]. 업데이트 if ($inc -gt 0) { $increments += $inc } try { $d 1 = [datetime]::P arse($historyData[$hi-1]. 날짜) $d 2 = [datetime]::P arse($historyData[$hi]. 날짜) $gap = ($d 2 - $d 1). TotalDays if ($gap -gt 0) { $dayGaps += $gap } } catch {} } # 웨이브 크기 = 가장 최근의 양수 증가(현재 웨이브), 평균으로 대체, 최소 2 $waveSize = if($increments. Count -gt 0) { [math]::Max(2, $increments[-1]) } else { 2 } # 관찰 기간 = 데이터 포인트 간의 평균 간격(웨이브당 달력 일 수), 최소 1 $waveDays = if ($dayGaps.Count -gt 0) { [math]::Max(1, [math]::Round(($dayGaps | Measure-Object -Average). 평균, 0)) } else { 1 }
Write-Host " 프로젝션: waveSize=$waveSize(마지막 증분부터), waveDays=$waveDays(기록의 평균 간격)" -ForegroundColor DarkGray
$dayCounter = 0 # 모든 디바이스가 업데이트되거나 최대 365일이 될 때까지 프로젝트 ($pi = 1; $pi -le 365; $pi++) { $projDate = $projDate.AddDays(1) $dayCounter++ # 각 관찰 기간 경계에서 웨이브를 배포한 다음 두 번 배포합니다. 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 } # 다음 기간의 이중 웨이브 크기(오케스트레이터 2배 전략) $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 "프로젝션: 웨이브 타이밍을 파생하려면 2개 이상의 추세 데이터 요소가 필요합니다." -ForegroundColor DarkGray } # here-string에 대한 결합된 차트 데이터 문자열 빌드 $allChartLabels = if ($hasProjection) { "$trendLabels,$projLabels" } else { $trendLabels } $projDataJS = if ($hasProjection) { $projUpdated } else { "" } $projNotUpdJS = if ($hasProjection) { $projNotUpdated } else { "" } $histCount = ($historyData | Measure-Object). 횟수 $stMfrCounts.GetEnumerator() | Sort-Object { $_. Value.Total } -Descending | ForEach-Object { @{ name=$_. 키; total=$_. Value.Total; updated=$_. Value.Updated; highConf=$_. Value.HighConf; actionReq=$_. Value.ActionReq } } | ConvertTo-Json -Depth 3 | Set-Content(조인 경로 $dataDir "manufacturers.json") -UTF8 인코딩 # 사람이 읽을 수 있는 Excel 다운로드를 위해 JSON 데이터 파일을 CSV로 변환 Write-Host "Excel용 CSV로 디바이스 데이터 변환 다운로드..." -ForegroundColor Gray foreach($stDeviceFiles $dfName) { $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) { # 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) 행 -> CSV" -ForegroundColor DarkGray } } catch { Write-Host " $dfName - 건너뛰기" -ForegroundColor DarkYellow } } } # 자체 포함 HTML dashboard 생성 $htmlPath = Join-Path $OutputPath "SecureBoot_Dashboard_$timestamp.html" Write-Host "자체 포함 HTML dashboard 생성..." -ForegroundColor 노란색 # VELOCITY PROJECTION: 검사 기록 또는 이전 요약에서 계산 $stDeadline = [datetime]"2026-06-24" # KEK 인증서 만료 $stDaysToDeadline = [math]::Max(0, ($stDeadline - (Get-Date)). 일) $stDevicesPerDay = 0 $stProjectedDate = $null $stVelocitySource = "해당/A" $stWorkingDays = 0 $stCalendarDays = 0 # 먼저 추세 기록 사용해 보기(aggregator에서 이미 유지 관리되는 경량 - 부풀어 오른 ScanHistory.json 대체) if ($historyData.Count -ge 2) { $validHistory = @($historyData | Where-Object { [int]$_. 총 -gt 0 -및 [int]$_. 업데이트됨 -ge 0 }) if ($validHistory.Count -ge 2) { $prev = $validHistory[-2]; $curr = $validHistory[-1] $prevDate = [datetime]::P arse($prev. Date.Substring(0, [Math]::Min(10, $prev. Date.Length))) $currDate = [datetime]::P arse($curr. Date.Substring(0, [Math]::Min(10, $curr. Date.Length))) $daysDiff = ($currDate - $prevDate). TotalDays if ($daysDiff -gt 0) { $updDiff = [int]$curr. 업데이트됨 - [int]$prev. 업데이트 if ($updDiff -gt 0) { $stDevicesPerDay = [math]::Round($updDiff/$daysDiff, 0) $stVelocitySource = "TrendHistory" } } } } # 오케스트레이터 출시 요약 사용해 보기(미리 계산된 속도 포함) 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 { } } # 대체: 이전 요약 CSV 사용해 보기(현재 폴더 및 부모/형제 집계 폴더 검색) if ($stVelocitySource -eq "N/A") { $searchPaths = @( (Join-Path $OutputPath "SecureBoot_Summary_*.csv") ) # 또한 형제 집계 폴더 검색(오케스트레이터는 각 실행마다 새 폴더를 만듭니다.) $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 { } } } # 대체: 전체 추세 기록 범위에서 속도 계산(첫 번째 및 최신 데이터 요소) if ($stVelocitySource -eq "N/A" -and $historyData.Count -ge 2) { $validHistory = @($historyData | Where-Object { [int]$_. 총 -gt 0 -및 [int]$_. 업데이트됨 -ge 0 }) if ($validHistory.Count -ge 2) { $first = $validHistory[0] $last = $validHistory[-1] $firstDate = [datetime]::P arse($first. Date.Substring(0, [Math]::Min(10, $first. Date.Length))) $lastDate = [datetime]::P arse($last. Date.Substring(0, [Math]::Min(10, $last. Date.Length))) $daysDiff = ($lastDate - $firstDate). TotalDays if ($daysDiff -gt 0) { $updDiff = [int]$last. 업데이트됨 - [int]$first. 업데이트 if ($updDiff -gt 0) { $stDevicesPerDay = [math]::Round($updDiff / $daysDiff, 1) $stVelocitySource = "TrendHistory" } } } } # 지수를 두 배로 사용하여 프로젝션 계산(추세 차트와 일치) # 사용 가능한 경우 차트에 대해 이미 계산된 프로젝션 데이터 다시 사용 if ($hasProjection -및 $projDates.Count -gt 0) { # 마지막으로 예상한 날짜 사용(모든 디바이스가 업데이트되는 경우) $lastProjDateStr = $projDates[-1] -replace "'", "" $stProjectedDate = ([datetime]::P arse($lastProjDateStr)). ToString("MMM dd, yyyy") $stCalendarDays = ([datetime]::P arse($lastProjDateStr) - (Get-Date)). 일 $stWorkingDays = 0 $d = Get-Date ($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 -및 $stNotUptodate -gt 0) { # 대체: 사용할 수 있는 지수 데이터가 없는 경우 선형 프로젝션 $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++ } } } # 빌드 속도 HTML $velocityHtml = if ($stDevicesPerDay -gt 0) { "<div><강한>🚀 Devices/Day:</strong> $($stDevicesPerDay.ToString('N0'))(source: $stVelocitySource)</div>" + "<div><강한>📅 예상 완료:</strong> $stProjectedDate" + $(if ($stProjectedDate -and [datetime]::P arse($stProjectedDate) -gt $stDeadline) { " <span style='color:#dc3545; font-weight:bold'>⚠ PAST DEADLINE</span>" } else { " <span style='color:#28a745'>✓ 마감일</span>" }) + "</div>" + "<div><강력한>🕐 작업일:</strong> $stWorkingDays | <강한>Calendar 일:</strong> $stCalendarDays</div>" + "<div style='font-size:.8em; color:#888'>Deadline: Jun 24, 2026(KEK 인증서 만료) | 남은 일: $stDaysToDeadline</div>" } else { "<div style='padding:8px; background:#fff3cd; border-radius:4px; border-left:3px solid #ffc107'>" + "강력한>📅 <. 예상 완료:</strong> 속도 계산에 데이터가 부족합니다. " + "데이터 변경으로 집계를 두 번 이상 실행하여 rate.<br/> 설정" + "<강력한>Deadline:</strong> 2026년 6월 24일(KEK 인증서 만료) | <강한>일 남은 일:</strong> $stDaysToDeadline</div>" } # 인증서 만료 카운트다운 $certToday = Get-Date $certKekExpiry = [datetime]"2026-06-24" $certUefiExpiry = [datetime]"2026-06-27" $certPcaExpiry = [datetime]"2026-10-19" $daysToKek = [math]::Max(0, ($certKekExpiry - $certToday). 일) $daysToUefi = [math]::Max(0, ($certUefiExpiry - $certToday). 일) $daysToPca = [math]::Max(0, ($certPcaExpiry - $certToday). 일) $certUrgency = if ($daysToKek -lt 30) { '#dc3545' } elseif ($daysToKek -lt 90) { '#fd7e14' } else { '#28a745' } # 도우미: JSON에서 레코드 읽기, 버킷 요약 빌드 + 첫 번째 N 디바이스 행 $maxInlineRows = 200 함수 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. 횟수 # 버킷 요약: BucketId별로 그룹화, 전역 버킷 통계에서 업데이트된 버킷당 개수 표시 if ($totalCount -gt 0) { $buckets = $data | Group-Object BucketId | Sort-Object 개수 -내림차순 $bucketSummary = "><2 h3 style='font-size:.95em; color:#333; margin:10px 0 5px'><3 By Hardware Bucket($($buckets. Count) buckets)><4 /h3>" $bucketSummary += "><6 div style='max-height:300px; overflow-y:auto; margin-bottom:15px'><table><thead><tr><th><5 BucketID><6 /th><th style='text-align:right'>Total</th><th style='text-align:right; color:#28a745'>업데이트된</th><th style='text-align:right; color:#dc3545 업데이트되지 않음 ></th><th><1 Manufacturer><2 /th></tr></thead><tbody>" foreach($buckets $b) { $bid = if ($b.Name) { $b.Name } else { "(empty)" } $mfr = ($b.Group | Select-Object -First 1). WMI_Manufacturer # 전역 버킷 통계에서 업데이트된 수 가져오기(전체 데이터 세트에서 이 버킷의 모든 디바이스) $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 += "<tr tr><td style='font-size:.8em'>$bid><4 /td><td style='text-align:right; font-weight:bold'>$bTotalGlobal><8 /td><td style='text-align:right; color:#28a745; font-weight:bold'>$bUpdatedGlobal><2 /td><td style='text-align:right; color:#dc3545; font-weight:bold'>$bNotUpdatedGlobal><6 /td><td><9 $mfr</td></tr>'n' } $bucketSummary += "</tbody></table></div>" } # 디바이스 세부 정보: 첫 번째 N행을 플랫 목록으로 $slice = $data | Select-Object -First $MaxRows foreach($slice $d) { $conf = $d.ConfidenceLevel $confBadge = if ($conf -match "High") { '<span class="배지 배지 성공">High Conf><2 /span>' } elseif($conf -match "Not Sup") { '<span class="배지 배지 위험">지원되지 않음><6 /span>' } elseif($conf -match "Under") { '<span class="badge badge-info">Under Obs><0 /span>' } elseif($conf -match "Paused") { '<span class="badge badge-warning">Paused><4 /span>' } else { '<span class="badge badge-warning">Action Req><8 /span>' } $statusBadge = if ($d.IsUpdated) { '><00 span class="배지 배지 성공"><01 업데이트된</span>' } elseif ($d.UEFICA2023Error) { '><04 span class="배지 배지 위험"><05 오류</span>' } else { '><08 span class="배지 배지 경고"><09 보류 중인><0 /span>' } $deviceRows += "><12 tr><td><5 $($d.HostName)><16 /td><td><9 $($d.WMI_Manufacturer )><20 /td><td><3 $($d.WMI_Model)><24 /td><td><7 $confBadge><8 /td><td><1 $statusBadge><2 /td><td><5 $(if($d.UEFICA2023Error){$d.UEFICA2023Error}else{'-'})><36 $d /td><td style='font-size:.75em'><39 $($d.BucketId)><40 /td></tr><3 'n' } } catch { } } if ($totalCount -eq 0) { return "><44 div style='padding:20px; color:#888; font-style:italic'><45 이 범주에 디바이스가 없습니다.><46 /div>" } $showing = [math]::Min($MaxRows, $totalCount) $header = "><48 div style='margin:5px 0; font-size:.85em; color:#666'><49 Total: $($totalCount.ToString("N0")) 디바이스" if ($CsvFileName) { $header += " | href='$CsvFileName' style='color:#1a237e ><50; font-weight:bold'>📄 Excel용 전체 CSV 다운로드><3 /a>" } $header += "><55 /div>" $deviceHeader = "><57 h3 style='font-size:.95em; color:#333; margin:10px 0 5px'><58 디바이스 세부 정보(첫 번째 $showing 표시)><59 /h3>" $deviceTable = "><61 div style='max-height:500px; overflow-y:auto'><table><thead><tr><th><0 HostName><1 /th><th><4 Manufacturer><5 /th><th><8 Model><9 /th><th><2 Confidence><3 /th><th><6 Status><7 /th><th><0 Error><1 /th><th><4 BucketId><5 /th></tr></thead><tbody><2 $deviceRows><3 /tbody></table></div>" 반환 "$header$bucketSummary$deviceHeader$deviceTable" } # CSV에 연결하여 디스크에 이미 있는 JSON 파일에서 인라인 테이블 빌드 $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" # 업데이트 보류 중인 사용자 지정 테이블 - UEFICA2023Status 및 UEFICA2023Error 열 포함 $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 = "<div style='margin:5px 0; font-size:.85em; color:#666'>Total: $($upCount.ToString("N0")) 디바이스 | href='SecureBoot_update_pending_$timestamp.csv' style='color:#1a237e <; font-weight:bold'>📄 Excel용 전체 CSV 다운로드><4 /a></div>" $upRows = "" $upSlice = $upData | Select-Object -First $maxInlineRows foreach($upSlice $d) { $uefiSt = if ($d.UEFICA2023Status) { $d.UEFICA2023Status } else { {'<span style="color:#999">null><0 /span>' } $uefiErr = if ($d.UEFICA2023Error) { "<span style='color:#dc3545'>$($d.UEFICA2023Error)</span>" } else { '-' } $policyVal = if ($d.AvailableUpdatesPolicy) { $d.AvailableUpdatesPolicy } else { '-' } $wincsVal = if ($d.WinCSKeyApplied) { '<span class="배지 배지 성공">예><8 /span>' } else { '-' } $upRows += "<tr><td><3 $($d.HostName)</td><td><7 $($d.WMI_Manufacturer)</td><td><1 $($d.WMI_Model)</td><td><5 $uefiSt><6 /td><td><9 $uefiErr><50 /td><td><53 $policyVal><54 /td><td><57 $wincsVal><58 /td><td style='font-size:.75em'>$($d.BucketId)</td></tr><65 'n' } $upShowing = [math]::Min($maxInlineRows, $upCount) $upDevHeader = "<h3 style='font-size:.95em; color:#333; margin:10px 0 5px'>디바이스 세부 정보(첫 번째 $upShowing 표시)</h3>" $upTable = "<div style='max-height:500px; overflow-y:auto'><table><thead><tr><th><9 HostName><0 /th><th><3 Manufacturer><4 /th><><7 Model><8 /th><th><1 UEFICA2023Status><2 /th><th><5 UEFICA2023Error><6 /th><th><9 Policy</th><th>WinCS Key</th><th>BucketId</th></tr></thead><tbody><5 $upRows><6 /tbody></table></div>" $tblUpdatePending = "$upHeader$upDevHeader$upTable" } else { $tblUpdatePending = "<div style='padding:20px; color:#888; font-style:italic'>이 범주에 디바이스가 없습니다.</div>" } } catch { $tblUpdatePending = "<div style='padding:20px; color:#888; font-style:italic'>이 범주에 디바이스가 없습니다.</div>" } } else { $tblUpdatePending = "<div style='padding:20px; color:#888; font-style:italic'>이 범주에 디바이스가 없습니다.</div>" } # 인증서 만료 카운트다운 $certToday = Get-Date $certKekExpiry = [datetime]"2026-06-24" $certUefiExpiry = [datetime]"2026-06-27" $certPcaExpiry = [datetime]"2026-10-19" $daysToKek = [math]::Max(0, ($certKekExpiry - $certToday). 일) $daysToUefi = [math]::Max(0, ($certUefiExpiry - $certToday). 일) $daysToPca = [math]::Max(0, ($certPcaExpiry - $certToday). 일) $certUrgency = if ($daysToKek -lt 30) { '#dc3545' } elseif ($daysToKek -lt 90) { '#fd7e14' } else { '#28a745' } # 제조업체 차트 데이터 인라인 빌드(디바이스 수별 상위 10개) $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 { "'$($_. 키)'" }) -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 "," # 빌드 제조업체 테이블 $mfrTableRows = "" $stMfrCounts.GetEnumerator() | Sort-Object { $_. Value.Total } -Descending | ForEach-Object { $mfrTableRows += "<tr><td><7 $($_. 키)</td><td>$($_. Value.Total.ToString("N0"))</td><td>$($_. Value.Updated.ToString("N0"))</td><td>$($_. Value.HighConf.ToString("N0"))><0 /td><td>$($_. Value.ActionReq.ToString("N0"))><4 /td></tr>'n' } # HTML 클로즈 태그 조각: 웹 CMS 플랫폼이 분할되지 않도록 파트로 분할됨 # 실제 HTML로 해석하고 주위에 보이지 않는 유니코드 문자를 삽입합니다.$endScript = '</scr' + 'ipt>' $endStyle = '</sty' + 'le>' $endHead = '</he' + '광고>' $endBody = '</bo' + 'dy>' $endHtml = '</ht' + 'ml>' $htmlContent = @" <! DOCTYPE html> <html lang="en"> 헤드>< <meta charset="UTF-8"> <메타 이름="뷰포트" content="width=device-width, initial-scale=1.0"> <타이틀>보안 부팅 인증서 상태 대시보드</title> <스크립트 src="https://cdn.jsdelivr.net/npm/chart.js">$endScript <스타일> *{box-sizing:border-box; margin:0; padding:0} body{font-family:'Segoe UI',Tahoma,sans-serif; background:#f0f2f5; color:#333} .header{background:linear-gradient(135deg,#1a237e,#0d47a1); color:#fff; padding:20px 30px} .header h1{font-size:1.6em; margin-bottom:5px} .header .meta{font-size:.85em; opacity:.9} .container{max-width:1400px; margin:0 auto; padding:20px} .cards{display:grid; grid-template-columns:repeat(auto-fill,minmax(170px,1fr)); gap:12px; margin:20px 0} . 카드{background:#fff; border-radius:10px; padding:15px; box-shadow:0 2px 8px rgba(0,0,0,.08); border-left:4px solid #ccc;transition:transform .2s} . 카드:hover{transform:translateY(-2px); box-shadow:0 4px 15px rgba(0,0,0,.12)} . 카드 .value{font-size:1.8em; font-weight:700} . 카드 .label{font-size:.8em; color:#666; margin-top:4px} . 카드 .pct{font-size:.75em; color:#888} .section{background:#fff; border-radius:10px; padding:20px; margin:15px 0; box-shadow:0 2px 8px rgba(0,0,0,.08)} .section h2{font-size:1.2em; color:#1a237e; margin-bottom:10px; cursor:pointer; user-select:none} .section h2:hover{text-decoration:underline} .section-body{display:none} .section-body.open{display:block} .charts{display:grid; grid-template-columns:1fr 1fr; gap:20px; margin:20px 0} .chart-box{background:#fff; border-radius:10px; padding:20px; box-shadow:0 2px 8px rgba(0,0,0,.08)} table{width:100%; border-collapse:collapse; font-size:.85em} th{background:#e8eaf6; padding:8px 10px; text-align:left; position:sticky; top:0; z-index:1} td{padding:6px 10px; border-bottom:1px solid #eee} tr:hover{background:#f5f5f5} .badge{display:inline-block; padding:2px 8px;border-radius:10px; font-size:.75em; font-weight:700} .badge-success{background:#d4edda; color:#155724} .badge-danger{background:#f8d7da; color:#721c24} .badge-warning{background:#fff3cd; color:#856404} .badge-info{background:#d1ecf1; color:#0c5460} .top-link{float:right; font-size:.8em; color:#1a237e; text-decoration:none} .footer{text-align:center; padding:20px; color:#999; font-size:.8em} a{color:#1a237e}$endStyle $endHead <본문> <div class="header"> <h1>보안 부팅 인증서 상태 대시보드</h1> <div class="meta">Generated: $($stats. ReportGeneratedAt) | 총 디바이스: $($c.Total.ToString("N0")) | 고유 버킷: $($stAllBuckets.Count)</div> </div> <div class="container">
<!-- KPI 카드 - 클릭 가능, 섹션에 연결 --> <div class="cards"> <class="카드" href="#s-nu" onclick="openSection('d-nu')" style="border-left-color:#dc3545; text-decoration:none; position:relative"><div style="position:absolute; top:8px; right:8px; background:#dc3545; color:#fff; padding:1px 6px; border-radius:8px; font-size:.65em; font-weight:700">PRIMARY</div><div class="value" style="color:#dc3545">$($stNotUptodate.ToString ("N0"))</div><div class="label">업데이트되지 않음><6 /div><div class="pct">$($stats. PercentNotUptodate)% - 작업><0 /div></a><3 <class="카드" href="#s-upd" onclick="openSection('d-upd')" style="border-left-color:#28a745; text-decoration:none; position:relative"><div style="position:absolute; top:8px; right:8px; background:#28a745; color:#fff; padding:1px 6px; border-radius:8px; font-size:.65em; font-weight:700">PRIMARY><8 /div><div class="value" style="color:#28a745">$($c.Updated.ToString ("N0"))</div><div class="label">업데이트된><6 /div><div class="pct">$($stats. PercentCertUpdated)%</div></a><3 <class="카드" href="#s-sboff" onclick="openSection('d-sboff')" style="border-left-color:#6c757d; text-decoration:none; position:relative"><div style="position:absolute; top:8px; right:8px; background:#6c757d; color:#fff; padding:1px 6px; border-radius:8px; font-size:.65em; font-weight:700">PRIMARY><8 /div><div class="value"><1 $($c.SBOff.ToString("N0"))><2 /div><div class="label"><5 SecureBoot OFF</div><div class="pct"><9 $(if($c.Total -gt 0){[math]::Round(($c.SBOff/$c.Total)*100,1)}else{0})% - 범위><0 /div></a><3 <class="카드" href="#s-nrb" onclick="openSection('d-nrb')" style="border-left-color:#ffc107; text-decoration:none"><div class="value" style="color:#ffc107">$($c.NeedsReboot.ToString("N0"))</div><div class="label"label">재부팅 필요><2 /div><div class="pct">$(if($c.Total -gt 0){[math]::Round(($c.NeedsReboot/$c.Total)*100,1)}else{0})% - 다시 부팅 대기><6 /div></a><9 <class="카드" href="#s-upd-pend" onclick="openSection('d-upd-pend')" style="border-left-color:#6f42c1; text-decoration:none"><div class="value" style="color:#6f42c1">$($c.UpdatePending.ToString("N0"))</div><div class="label">Update Pending</div><<div class="pct">$(if($c.Total -gt 0){[math]::Round(($c.UpdatePending/$c.Total)*100,1)}else{0})% - Policy/WinCS 적용, 업데이트 대기><2 /div></a><5 <class="카드" href="#s-rip" onclick="openSection('d-rip')" style="border-left-color:#17a2b8; text-decoration:none"><div class="value">$($c.RolloutInProgress)</div><div class="label">Rollout In Progress><4 /div><div class="pct">$(if($c.Total -gt 0){[math]::Round(($c.RolloutInProgress/$c.Total)*100,1)}else{0})%</div></a><11 <class="카드" href="#s-nu" onclick="openSection('d-nu')" style="border-left-color:#28a745; text-decoration:none"><div class="value" style="color:#28a745">$($c.HighConf.ToString("N0"))</div><div class="label">High Confidence><20 /div><div class="pct">$($stats. PercentHighConfidence)% - /div></a ><27><24 출시에 안전합니다. <class="카드" href="#s-uo" onclick="openSection('d-uo')" style="border-left-color:#17a2b8; text-decoration:none"><div class="value" style="color:#ffc107"><1 $($c.UnderObs.ToString("N0"))><2 /div><div class="label"><5 관찰><36 /div><div 클래스 ="pct"><9 $(if($c.Total -gt 0){[math]::Round(($c.UnderObs/$c.Total)*100,1)}else{0})%</div></a><3 <class="카드" href="#s-ar" onclick="openSection('d-ar')" style="border-left-color:#fd7e14; text-decoration:none"><div class="value" style="color:#fd7e14">$($c.ActionReq.ToString("N0"))</div><div class="label">Action Required><2 /div><div class="pct">$($stats. PercentActionRequired)% - /div></a><9><6 테스트해야 합니다. <class="카드" href="#s-err" onclick="openSection('d-err')" style="border-left-color:#dc3545; text-decoration:none"><div class="value" style="color:#dc3545">$($stAtRisk.ToString("N0"))</div><div class="label">At Risk><68 /div><div class="pct">$($stats. PercentAtRisk)% - 실패한><2 /div></a><5 <class="카드" href="#s-td" onclick="openSection('d-td')" style="border-left-color:#dc3545; text-decoration:none"><div class="value" style="color:#dc3545">$($c.TaskDisabled.ToString("N0"))</div><div class="label">Task Disabled><4 /div><div 클래스 ="pct">$(if($c.Total -gt 0){[math]::Round(($c.TaskDisabled/$c.Total)*100,1)}else{0})% - 차단된><8 /div></a><91 class="카드" href="#s-tf" onclick="openSection('d-tf')" style="border-left-color:#fd7e14 <; text-decoration:none"><div class="value" style="color:#fd7e14">$($c.TempPaused.ToString("N0"))</div><div class="label">Temp. 일시 중지된</div><div class="pct">$(if($c.Total -gt 0){[math]::Round(($c.TempPaused/$c.Total)*100,1)}else{0})%</div></a> <class="카드" href="#s-ki" onclick="openSection('d-ki')" style="border-left-color:#dc3545; text-decoration:none"><div class="value" style="color:#dc3545">$($c.WithKnownIssues.ToString("N0"))</div><div class="label">알려진 문제><6 /div><div class="pct">$(if($c.Total -gt 0){[math]::Round(($c.WithKnownIssues/$c.Total)*100,1)}else{0})%</div></a><3 <class="카드" href="#s-kek" onclick="openSection('d-kek')" style="border-left-color:#fd7e14; text-decoration:none"><div class="value" style="color:#fd7e14">$($c.WithMissingKEK.ToString("N0"))</div><div class="label">누락된 KEK</div><div class="pct">$(if($c.Total -gt 0){[math]::Round(($c.WithMissingKEK/$c.Total)*100,1)}else{0})%</div></a> <class="카드" href="#s-err" onclick="openSection('d-err')" style="border-left-color:#dc3545; text-decoration:none"><div class="value" style="color:#dc3545">$($c.WithErrors.ToString("N0"))</div><div class="label"label">With Errors</div><div class="pct"><1 $($stats. PercentWithErrors)% - /div></a>< UEFI 오류 class="카드" href="#s-tf" onclick="openSection('d-tf')" style="border-left-color:#dc3545 ><6; text-decoration:none"><div class="value" style="color:#dc3545"><9 $($c.TempFailures.ToString("N0"))</div><div class="label">Temp. 실패</div><div class="pct">$(if($c.Total -gt 0){[math]::Round(($c.TempFailures/$c.Total)*100,1)}else{0})%</div></a> <class="카드" href="#s-pf" onclick="openSection('d-pf')" style="border-left-color:#721c24; text-decoration:none"><div class="value" style="color:#721c24">$($c.PermFailures.ToString("N0"))</div><div class="label"label">지원되지 않음><6 /div><div class="pct">$(if($c.Total -gt 0){[math]::Round(($c.PermFailures/$c.Total)*100,1)}else{0})%</div></a><3 </div>
<!-- 배포 속도 & 인증서 만료 --> <div id="s-velocity" style="display:grid; grid-template-columns:1fr 1fr; gap:20px; margin:15px 0"> <div class="section" style="margin:0"> <h2>📅 배포 속도</h2> ><2 div class="section-body open"><3 ><4 div style="font-size:2.5em; font-weight:700; color:#28a745"><5 $($c.Updated.ToString("N0"))><6 /div> ><8 div style="color:#666"><9 디바이스가 $($c.Total.ToString("N0"))</div> 업데이트됨 <div style="margin:10px 0; background:#e8eaf6; height:20px; border-radius:10px; overflow:hidden"><div style="background:#28a745; height:100%; width:$($stats. PercentCertUpdated)%; border-radius:10px"></div></div> <div style="font-size:.8em; color:#888">$($stats. PercentCertUpdated)% complete</div> <div style="margin-top:10px; padding:10px; background:#f8f9fa; border-radius:8px; font-size:.85em"> <div><강력한>나머지:</strong> $($stNotUptodate.ToString("N0")) 디바이스에는 /div<작업이 필요합니다> <div><강력한>차단:</strong> $($c.WithErrors + $c.PermFailures + $c.TaskDisabledNotUpdated) 디바이스(오류 + 영구 + 작업 사용 안 함)</div> <div><강력한>안전한 배포:</strong> $($stSafeList.ToString("N0")) 디바이스(성공과 동일한 버킷)</div> $velocityHtml </div> </div> </div> <div class="section" style="margin:0; border-left:4px solid #dc3545"> <h2 style="color:#dc3545">⚠ 인증서 만료 카운트다운</h2> <div class="section-body open"> <div style="display:flex; gap:15px; margin-top:10px"> <div style="text-align:center; padding:15px; border-radius:8px; min-width:120px; background:linear-gradient(135deg,#fff5f5,#ffe0e0); border:2px solid #dc3545; flex:1"> <div style="font-size:.65em; color:#721c24; text-transform:대문자; font-weight:bold">⚠ 첫 번째 만료</div> ><4 div style="font-size:.85em; font-weight:bold; color:#dc3545; margin:3px 0"><5 KEK CA 2011</div> ><8 div id="daysKek" style="font-size:2.5em; font-weight:700; color:#dc3545; line-height:1"><9 $daysToKek><0 /div><1 <div style="font-size:.8em; color:#721c24">일(2026년 6월 24일)</div><5 </div><7 <div style="text-align:center; padding:15px; border-radius:8px; min-width:120px; background:linear-gradient(135deg,#fffef5,#fff3cd); border:2px solid #ffc107; flex:1"> ><00 div style="font-size:.65em; color:#856404; text-transform:대문자; font-weight:bold"><01 UEFI CA 2011</div> ><04 div id="daysUefi" style="font-size:2.2em; font-weight:700; color:#856404; 줄 높이:1; margin:5px 0"><05 $daysToUefi</div> ><08 div style="font-size:.8em; color:#856404"><09 일(2026년 6월 27일)><10 /div> ><12 /div> ><14 div style="text-align:center; padding:15px; border-radius:8px; min-width:120px; background:linear-gradient(135deg,#f0f8ff,#d4edff); border:2px solid #0078d4; flex:1"><15 ><16 div style="font-size:.65em; color:#0078d4; text-transform:대문자; font-weight:bold"><17 Windows PCA</div> ><20 div id="daysPca" style="font-size:2.2em; font-weight:700; color:#0078d4; 줄 높이:1; margin:5px 0"><21 $daysToPca><2 /div><3 ><24 div style="font-size:.8em; color:#0078d4"><25 일(2026년 10월 19일)><26 /div><7 ><28 /div><9 ><30 /div><1 ><32 div style="margin-top:15px; padding:10px; background:#f8d7da; border-radius:8px; font-size:.85em; border-left:4px solid #dc3545"><33 ><34 강력한>⚠ CRITICAL:><37 /strong> 인증서가 만료되기 전에 모든 디바이스를 업데이트해야 합니다. 최종 기한까지 업데이트되지 않은 디바이스는 만료 후 부팅 관리자 및 보안 부팅에 대한 향후 보안 업데이트를 적용할 수 없습니다.</div> </div> </div> </div>
<!-- 차트 --> <div class="charts"> <div class="chart-box"><h3>배포 상태</h3><canvas id="deployChart" height="200"></canvas></div><5 <div class="chart-box"><h3><9 $mfrChartTitle</h3><canvas id="mfrChart" height="200"></canvas></div> </div>
$(if ($historyData.Count -ge 1) { "<!-- 기록 추세 차트 --> <div class='section'> <h2 onclick='"toggle('d-trend')''">📈 시간에 따른 업데이트 진행률 <class='top-link' href='#'>↑ Top</a></h2> <div id='d-trend' class='section-body open'> <캔버스 id='trendChart' height='120'></canvas> <div style='font-size:.75em; color:#888; margin-top:5px'>실선 = 실제 데이터$(if ($historyData.Count -ge 2) { " | 파선 = 투영됨(지수 두 배로: 2→4→8→16... 웨이브당 디바이스)" } else { " | 내일 다시 집계를 실행하여 추세선 및 프로젝션을 확인합니다." })</div> </div> </div>" })
CSV 다운로드 <!-- --> <div class="section"> <h2 onclick="toggle('dl-csv')">📥 class="top-link" href="#">Top</a></h2 ><전체 데이터(Excel용 CSV) 다운로드 <div id="dl-csv" class="section-body open" style="display:flex; flex-wrap:wrap; gap:5px"> href="SecureBoot_not_updated_$timestamp.csv" style="display:inline-block을 <. background:#dc3545; color:#fff; padding:6px 14px; border-radius:5px; text-decoration:none; font-size:.8em">업데이트되지 않음($($stNotUptodate.ToString("N0")))</a> href="SecureBoot_errors_$timestamp.csv" style="display:inline-block을 <. background:#dc3545; color:#fff; padding:6px 14px; border-radius:5px; text-decoration:none; font-size:.8em">오류($($c.WithErrors.ToString("N0")))</a><2 href="SecureBoot_action_required_$timestamp.csv" style="display:inline-block을 <. background:#fd7e14; color:#fff; padding:6px 14px; border-radius:5px; text-decoration:none; font-size:.8em">작업 필요($($c.ActionReq.ToString("N0")))</a><6 href="SecureBoot_known_issues_$timestamp.csv" style="display:inline-block을 <. background:#dc3545; color:#fff; padding:6px 14px; border-radius:5px; text-decoration:none; font-size:.8em">알려진 문제($($c.WithKnownIssues.ToString("N0")))</a> href="SecureBoot_task_disabled_$timestamp.csv" style="display:inline-block을 <. background:#dc3545; color:#fff; padding:6px 14px; border-radius:5px; text-decoration:none; font-size:.8em">작업 사용 안 함($($c.TaskDisabled.ToString("N0")))</a> href="SecureBoot_updated_devices_$timestamp.csv" style="display:inline-block을 <. background:#28a745; color:#fff; padding:6px 14px; border-radius:5px; text-decoration:none; font-size:.8em">업데이트됨($($c.Updated.ToString("N0")))</a> href="SecureBoot_Summary_$timestamp.csv" style="display:inline-block을 <. background:#6c757d; color:#fff; padding:6px 14px; border-radius:5px; text-decoration:none; font-size:.8em">Summary</a> <div style="width:100%; font-size:.75em; color:#888; margin-top:5px">CSV 파일이 Excel에서 열립니다. web server.</div> 호스트되는 경우 사용 가능 </div> </div>
<!-- 제조업체 분석 --> <div class="section"> <h2 onclick="toggle('mfr')">By Manufacturer <a class="top-link" href="#">Top</a></h2><1 <div id="mfr" class="section-body open"> <테이블><thead><tr><th><1 Manufacturer><2 /th><th><5 Total><6 /th><th><9 업데이트됨><0 /th><><3 높은 신뢰도><4 /th><><7 작업 필요><8 /th></tr></thead><3 <tbody><5 $mfrTableRows><6 /tbody></table><9 </div><1 </div>
<!-- 디바이스 섹션(처음 200개 인라인 + CSV 다운로드) --> <div class="section" id="s-err"> <h2 onclick="toggle('d-err')">🔴 class="top-link" href="#">↑ Top</a></h2 ><오류($($c.WithErrors.ToString("N0")))가 있는 디바이스 <div id="d-err" class="section-body">$tblErrors</div> </div> <div class="section" id="s-ki"> <h2 onclick="toggle('d-ki')" style="color:#dc3545">🔴 알려진 문제($($c.WithKnownIssues.ToString("N0")))는 class="top-link" href="#">↑ Top</a></h2><. <div id="d-ki" class="section-body">$tblKI</div> </div> <div class="section" id="s-kek"> <h2 onclick="toggle('d-kek')">🟠 누락된 KEK - 이벤트 1803($($c.WithMissingKEK.ToString("N0"))) <class="top-link" href="#">↑ Top</a></h2> >↑ 0 div id="d-kek" class="section-body">↑ 1 $tblKEK</div> >↑ 4 /div> >↑ 6 div class="section" id="s-ar">↑ 7 >↑ 8 h2 onclick="toggle('d-ar')" style="color:#fd7e14">🟠 작업 필요($($c.ActionReq.ToString("N0")))는 class="top-link" href="#">↑ Top><4 /a></h2><7 <. <div id="d-ar" class="section-body">$tblActionReq</div> </div> <div class="section" id="s-uo"> <h2 onclick="toggle('d-uo')" style="color:#17a2b8">🔵 관찰($($c.UnderObs.ToString("N0"))에서 class="top-link" href="#">↑ Top</a></h2><. <div id="d-uo" class="section-body">$tblUnderObs</div> </div> <div class="section" id="s-nu"> <h2 onclick="toggle('d-nu')" style="color:#dc3545">🔴 업데이트되지 않음($($stNotUptodate.ToString("N0")))은 class="top-link" href="#">↑ Top</a></h2><. <div id="d-nu" class="section-body">$tblNotUpd</div> </div> >↑ 0 div class="section" id="s-td">↑ 1 >↑ 2 h2 onclick="toggle('d-td')" style="color:#dc3545">🔴 task Disabled($($c.TaskDisabled.ToString("N0")))는 class="top-link" href="#">↑ Top</a></h2><1 >↑ 5 <div id="d-td" class="section-body">$tblTaskDis><4 /div><5 </div><7 <div class="section" id="s-tf"> <h2 onclick="toggle('d-tf')" style="color:#dc3545">🔴 임시 오류($($c.TempFailures.ToString("N0")))는 class="top-link" href="#">↑ Top</a></h2><. <div id="d-tf" class="section-body">$tblTemp</div> </div> <div class="section" id="s-pf"> <h2 onclick="toggle('d-pf')" style="color:#721c24">🔴 영구 실패/지원되지 않음($($c.PermFailures.ToString("N0")))은 class="top-link" href="#">↑ Top</a></h2>< <div id="d-pf" class="section-body">$tblPerm</div> </div> <div class="section" id="s-upd-pend"> <h2 onclick="toggle('d-upd-pend')" style="color:#6f42c1">⏳ 업데이트 보류 중($($c.UpdatePending.ToString("N0")) - Policy/WinCS 적용됨 업데이트 <대기 중 a class="top-link" href="#">↑ Top</a></h2> <div id="d-upd-pend" class="section-body"><p style="color:#666; margin-bottom:10px">Devices where AvailableUpdatesPolicy 또는 WinCS 키가 적용되지만 UEFICA2023Status는 여전히 NotStarted, InProgress 또는 null.</p>$tblUpdatePending</div> </div> <div class="section" id="s-rip"> <h2 onclick="toggle('d-rip')" style="color:#17a2b8">🔵 롤아웃 진행 중($($c.RolloutInProgress.ToString("N0")) <class="top-link" href="#">↑ Top</a></h2> <div id="d-rip" class="section-body">$tblRolloutIP</div> </div> <div class="section" id="s-sboff"> <h2 onclick="toggle('d-sboff')" style="color:#6c757d">⚫ SecureBoot OFF ($($c.SBOff.ToString("N0")) - 범위 외 <class="top-link" href="#">↑ Top</a></h2> <div id="d-sboff" class="section-body">$tblSBOff</div> </div> <div class="section" id="s-upd"> <h2 onclick="toggle('d-upd')" style="color:#28a745">🟢 업데이트된 디바이스($($c.Updated.ToString("N0")))는 class="top-link" href="#">↑ Top</a></h2><. <div id="d-upd" class="section-body">$tblUpdated</div> </div> <div class="section" id="s-nrb"> <h2 onclick="toggle('d-nrb')" style="color:#ffc107">🔄 업데이트됨 - class="top-link" href="#">↑ Top</a></h2 ><다시 부팅($($c.NeedsReboot.ToString("N0")))이 필요합니다. <div id="d-nrb" class="section-body">$tblNeedsReboot</div> </div>
<div class="footer">보안 부팅 인증서 롤아웃 대시보드 | 생성된 $($stats. ReportGeneratedAt) | StreamingMode | 최대 메모리: ${stPeakMemMB} MB</div> </div><!-- /container -->
스크립트>< function toggle(id){var e=document.getElementById(id); e.classList.toggle('open')} function openSection(id){var e=document.getElementById(id); if(e&&!e.classList.contains('open')){e.classList.add('open')}} new Chart(document.getElementById('deployChart'),{type:'doughnut',data:{labels:['Updated','Update Pending','High Confidence','Under Observation','Action Required','Temp. 일시 중지됨','지원되지 않음','SecureBoot OFF','With Errors'],datasets:[{data:[$($c.Updated),$($c.UpdatePending),$($c.HighConf),$($c.UnderObs),$($c.ActionReq),$($c.TempPaused),$($c.NotSupported),$($c.SBOff), $($c.WithErrors)],backgroundColor:['#28a745','#6f42c1','#20c997','#17a2b8','#fd7e14','#6c757d',' #721c24','#adb5bd','#dc3545']}]},options:{responsive:true,plugins:{legend:{position:'right',labels:{font:{size:11}}}}}}); new Chart(document.getElementById('mfrChart'),{type:'bar',data:{labels:[$mfrLabels],datasets:[{label:'Updated',data:[$mfrUpdated],backgroundColor:'#28a745'},{label:'Update Pending',data:[$mfrUpdatePending],backgroundColor:'#6f42c1'},{label:'High Confidence',data:[$mfrHighConf],backgroundColor:'#20c997'},{label:'Under Observation',data:[$mfrUnderObs],backgroundColor:'#17a2b8'},{label:'Action Required',data:[$mfrActionReq],backgroundColor:'#fd7e14'},{ label:'Temp. Paused',data:[$mfrTempPaused],backgroundColor:'#6c757d'},{label:'Not Supported',data:[$mfrNotSupported],backgroundColor:'#721c24'},{label:'SecureBoot OFF',data:[$mfrSBOff],backgroundColor:'#adb5bd''},{label:'With Errors',data:[$mfrWithErrors],backgroundColor:'#dc3545'}]},options:{responsive:true,scales:{x:{stacked:true},y:{stacked:true}},plugins:{legend:{position:'top'}}}}} ); 기록 추세 차트 if (document.getElementById('trendChart')) { var allLabels = [$allChartLabels]; var actualUpdated = [$trendUpdated]; var actualNotUpdated = [$trendNotUpdated]; var actualTotal = [$trendTotal]; var projData = [$projDataJS]; var projNotUpdData = [$projNotUpdJS]; var histLen = actualUpdated.length; var projLen = projData.length; var paddedUpdated = actualUpdated.concat(Array(projLen).fill(null)); var paddedNotUpdated = actualNotUpdated.concat(Array(projLen).fill(null)); var paddedTotal = actualTotal.concat(Array(projLen).fill(null)); var projLine = Array(histLen).fill(null); var projNotUpdLine = Array(histLen).fill(null); if (projLen > 0) { projLine[histLen-1] = actualUpdated[histLen-1]; projLine = projLine.concat(projData); projNotUpdLine[histLen-1] = actualNotUpdated[histLen-1]; projNotUpdLine = projNotUpdLine.concat(projNotUpdData} var 데이터 세트 = [ {label:'Updated',data:paddedUpdated,borderColor:'#28a745',backgroundColor:'rgba(40,167,69,0.1)',fill:true,tension:0.3,borderWidth:2}, {label:'Not Update',data:paddedNotUpdated,borderColor:'#dc3545',backgroundColor:'rgba(220,53,69,0.1)',fill:true,tension:0.3,borderWidth:2}, {label:'Total',data:paddedTotal,borderColor:'#6c757d',borderDash:[5,5],fill:false,tension:0,pointRadius:0,borderWidth:1} ]; if (projLen > 0) { datasets.push({label:'Projected Updated (2x doubling)',data:projLine,borderColor:'#28a745',borderDash:[8,4],borderWidth:3,fill:false,tension:0.3,pointRadius:3,pointStyle:'triangle'}); datasets.push({label:'Projected Not Update',data:projNotUpdLine,borderColor:'#dc3545',borderDash:[8,4],borderWidth:3,fill:false,tension:0.3,pointRadius:3,pointStyle:'triangle'}); } new Chart(document.getElementById('trendChart'),{type:'line',data:{labels:allLabels,datasets:datasets},options:{responsive:true,scales:{y:{beginAtZero:true,title:{{display:true,text:'Devices'}},x:{title:{display:true,text:'Date'}},plugins:{legend:{position:'top'},title:{display:true,text:'Secure Boot Update Progress over Time'}}}}}); } 동적 카운트다운 (function(){var t=new Date(),k=new Date('2026-06-24'),u=new Date('2026-06-27'),p=new Date('2026-10-19'); var dk=document.getElementById('daysKek'),du=document.getElementById('daysUefi'),dp=document.getElementById('daysPca'); if(dk)dk.textContent=Math.max(0,Math.ceil((k-t)/864e5)); if(du)du.textContent=Math.max(0,Math.ceil((u-t)/864e5)); if(dp)dp.textContent=Math.max(0,Math.ceil((p-t)/864e5)})();$endScript $endBody $endHtml "@ [System.IO.File]::WriteAllText($htmlPath, $htmlContent, [System.Text.UTF8Encoding]::new($false)) # 관리자가 타임스탬프를 추적할 필요가 없도록 항상 안정적인 "최신" 복사본을 유지합니다. $latestPath = Join-Path $OutputPath "SecureBoot_Dashboard_Latest.html" Copy-Item $htmlPath $latestPath -Force $stTotal = $streamSw.Elapsed.TotalSeconds # 증분 모드에 대한 파일 매니페스트 저장(다음 실행 시 빠른 변경 없음 검색) if ($IncrementalMode -또는 $StreamingMode) { $stManifestDir = Join-Path $OutputPath ".cache" if (-not (Test-Path $stManifestDir)) { New-Item -ItemType Directory -Path $stManifestDir -Force | Out-Null } $stManifestPath = Join-Path $stManifestDir "StreamingManifest.json" $stNewManifest = @{} Write-Host "증분 모드에 대한 파일 매니페스트 저장..." -ForegroundColor Gray foreach($jsonFiles $jf) { $stNewManifest[$jf. FullName.ToLowerInvariant()] = @{ LastWriteTimeUtc = $jf. LastWriteTimeUtc.ToString("o") 크기 = $jf. 길이 } } Save-FileManifest -매니페스트 $stNewManifest -경로 $stManifestPath Write-Host "$($stNewManifest.Count) 파일에 대한 저장된 매니페스트" -ForegroundColor DarkGray } # 보존 정리 # Orchestrator 재사용 가능한 폴더(Aggregation_Current): 최신 실행만 유지(1) # 관리 수동 실행/기타 폴더: 마지막 7개 실행 유지 # 요약 CSV는 삭제되지 않습니다. 이 CSV는 작으며(~1KB) 추세 기록의 백업 원본입니다. $outputLeaf = Split-Path $OutputPath -Leaf $retentionCount = if ($outputLeaf -eq 'Aggregation_Current') { 1 } else { 7 } # 파일 접두사는 클린 안전합니다(실행당 임시 스냅샷) $cleanupPrefixes = @( 'SecureBoot_Dashboard_', 'SecureBoot_action_required_', 'SecureBoot_ByManufacturer_', 'SecureBoot_ErrorCodes_', 'SecureBoot_errors_', 'SecureBoot_known_issues_', 'SecureBoot_missing_kek_', 'SecureBoot_needs_reboot_', 'SecureBoot_not_updated_', 'SecureBoot_secureboot_off_', 'SecureBoot_task_disabled_', 'SecureBoot_temp_failures_', 'SecureBoot_perm_failures_', 'SecureBoot_under_observation_', 'SecureBoot_UniqueBuckets_', 'SecureBoot_update_pending_', 'SecureBoot_updated_devices_', 'SecureBoot_rollout_inprogress_', 'SecureBoot_NotUptodate_', 'SecureBoot_Kusto_' ) # 정리 가능한 파일에서만 모든 고유한 타임스탬프 찾기 $cleanableFiles = Get-ChildItem $OutputPath -File -EA SilentlyContinue | Where-Object { $f = $_. 이름; ($cleanupPrefixes | Where-Object { $f.StartsWith($_) }). Count -gt 0 } $allTimestamps = @($cleanableFiles | ForEach-Object { if ($_. Name -match '(\d{8}-\d{6})') { $Matches[1] } } | Sort-Object -Unique -Descending) if ($allTimestamps.Count -gt $retentionCount) { $oldTimestamps = $allTimestamps | Select-Object -건너뛰기 $retentionCount $removedFiles = 0; $freedBytes = 0 foreach($oldTimestamps $oldTs) { foreach($cleanupPrefixes $prefix) { $oldFiles = Get-ChildItem $OutputPath -File -Filter "${prefix}${oldTs}*" -EA SilentlyContinue foreach($oldFiles $f) { $freedBytes += $f.Length Remove-Item $f.FullName -Force -EA SilentlyContinue $removedFiles++ } } } $freedMB = [math]::Round($freedBytes/1MB, 1) Write-Host "보존 정리: $($oldTimestamps.Count) 이전 실행에서 $removedFiles 파일이 제거되었습니다. ${freedMB} MB(마지막 $retentionCount + 모든 요약/NotUptodate CSV 유지)" -ForegroundColor DarkGray } Write-Host "'n$("=" * 60)" -ForegroundColor Cyan Write-Host "스트리밍 집계 완료" -ForegroundColor 녹색 Write-Host ("=" * 60) -ForegroundColor Cyan Write-Host " 총 디바이스: $($c.Total.ToString("N0"))" -ForegroundColor White Write-Host " 업데이트되지 않음: $($stNotUptodate.ToString("N0"))($($stats. PercentNotUptodate)%)" -ForegroundColor $(if ($stNotUptodate -gt 0) { "Yellow" } else { "Green" }) Write-Host " 업데이트됨: $($c.Updated.ToString("N0"))($($stats. PercentCertUpdated)%)" -ForegroundColor Green Write-Host " 오류 포함: $($c.WithErrors.ToString("N0"))" -ForegroundColor $(if ($c.WithErrors -gt 0) { "Red" } else { "Green" }) Write-Host " 최대 메모리: ${stPeakMemMB} MB" -ForegroundColor Cyan Write-Host " 시간: $([math]::Round($stTotal/60,1)) 분" -ForegroundColor White Write-Host " 대시보드: $htmlPath" -ForegroundColor White 반환 [PSCustomObject]$stats } 스트리밍 모드 #endregion } else { Write-Error "입력 경로를 찾을 수 없음: $InputPath" 종료 1 }