health-audit.ps1 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  1. <#
  2. .SYNOPSIS
  3. Comprehensive Windows workstation health audit. Produces a verdict.
  4. .DESCRIPTION
  5. Walks the diagnostic ladder: hardware errors, storage health per disk,
  6. recent crashes with BugCheck codes, top resource consumers, startup
  7. inventory across all five mechanisms. Emits [PASS]/[FAIL]/[WARN]
  8. markers per check and a final verdict block.
  9. Stdout is data only (a text report by default, or NDJSON when -Json).
  10. Stderr carries progress and section headers.
  11. .PARAMETER Days
  12. How many days back to scan event logs. Default: 30.
  13. .PARAMETER Json
  14. Emit machine-readable NDJSON to stdout (one finding per line).
  15. .PARAMETER Quiet
  16. Suppress section headers on stderr. Findings still emit.
  17. .EXAMPLE
  18. scripts/health-audit.ps1
  19. Run the full audit, scanning the last 30 days.
  20. .EXAMPLE
  21. scripts/health-audit.ps1 -Days 7
  22. Quick audit covering only the last week.
  23. .EXAMPLE
  24. scripts/health-audit.ps1 -Json | ConvertFrom-Json
  25. Pipe machine-readable output to a JSON consumer.
  26. .EXAMPLE
  27. scripts/health-audit.ps1 -Json > audit.ndjson
  28. Save audit findings as NDJSON for later processing.
  29. .NOTES
  30. Exit codes:
  31. 0 success — audit completed, no critical findings
  32. 1 general error during audit
  33. 2 usage error (bad arguments)
  34. 4 critical finding (failing drive, recent unexplained crashes)
  35. 5 missing precondition (PowerShell version, required module)
  36. #>
  37. [CmdletBinding()]
  38. param(
  39. [ValidateRange(1, 365)][int]$Days = 30,
  40. [switch]$Json,
  41. [switch]$Quiet
  42. )
  43. $ErrorActionPreference = 'Stop'
  44. . "$PSScriptRoot\_lib\common.ps1"
  45. . (Join-Path $PSScriptRoot '..\..\_lib\term.ps1')
  46. Initialize-Term
  47. $Findings = New-Object System.Collections.Generic.List[hashtable]
  48. function Add-Finding {
  49. param(
  50. [Parameter(Mandatory)][ValidateSet('pass','warn','fail','info')]$Level,
  51. [Parameter(Mandatory)][string]$Category,
  52. [Parameter(Mandatory)][string]$Subject,
  53. [Parameter(Mandatory)][string]$Detail,
  54. [hashtable]$Data = @{}
  55. )
  56. $f = @{
  57. level = $Level
  58. category = $Category
  59. subject = $Subject
  60. detail = $Detail
  61. data = $Data
  62. ts = (Get-Date).ToString('o')
  63. }
  64. $Findings.Add($f)
  65. # Inline trace only with -Verbose; default is silent walk + panel at end.
  66. if ($VerbosePreference -ne 'SilentlyContinue') {
  67. $tag = $Level.ToUpper()
  68. Write-Verbose "[$tag] $Category :: $Subject -> $Detail"
  69. }
  70. if ($Json) {
  71. [Console]::Out.WriteLine(($f | ConvertTo-Json -Compress -Depth 5))
  72. }
  73. }
  74. # ─────────────────────────────────────────────────────────────────────
  75. # Section: Hardware errors (WHEA)
  76. # ─────────────────────────────────────────────────────────────────────
  77. Write-Verbose "Section 1: Hardware errors (WHEA)"
  78. try {
  79. $whea = Get-WinEvent -FilterHashtable @{
  80. LogName='System'
  81. ProviderName='Microsoft-Windows-WHEA-Logger'
  82. StartTime=(Get-Date).AddDays(-$Days)
  83. } -ErrorAction SilentlyContinue
  84. $wheaError = $whea | Where-Object { $_.Level -le 2 } # Critical/Error
  85. $wheaWarn = $whea | Where-Object { $_.Level -eq 3 } # Warning
  86. if ($wheaError) {
  87. Add-Finding -Level fail -Category 'hardware' -Subject 'WHEA errors' `
  88. -Detail "$($wheaError.Count) uncorrectable hardware error(s) in last $Days days" `
  89. -Data @{ count = $wheaError.Count; first = $wheaError[0].TimeCreated.ToString('o') }
  90. } elseif ($wheaWarn) {
  91. Add-Finding -Level warn -Category 'hardware' -Subject 'WHEA warnings' `
  92. -Detail "$($wheaWarn.Count) corrected hardware event(s) — usually benign but trending"
  93. } else {
  94. Add-Finding -Level pass -Category 'hardware' -Subject 'WHEA' `
  95. -Detail "No hardware errors logged in last $Days days"
  96. }
  97. } catch {
  98. Add-Finding -Level warn -Category 'hardware' -Subject 'WHEA query' -Detail "Failed: $_"
  99. }
  100. # ─────────────────────────────────────────────────────────────────────
  101. # Section: Storage health per disk
  102. # ─────────────────────────────────────────────────────────────────────
  103. Write-Verbose "Section 2: Storage health per disk"
  104. $diskMap = Get-DiskMap
  105. foreach ($d in $diskMap) {
  106. Write-Verbose " Disk $($d.Number): $($d.Model) [$($d.MediaType), $($d.BusType), $($d.SizeGB) GB, $($d.DriveLetters)]"
  107. }
  108. # Aggregate disk errors across the time window
  109. # Event messages use TWO formats for naming the affected disk:
  110. # - Event 7/15/51: "\Device\Harddisk<N>\DR..."
  111. # - Event 153/154: "...for Disk <N> (PDO name: \Device\...)"
  112. # Match both so per-disk counts cover the full set.
  113. try {
  114. $diskErrs = Get-WinEvent -FilterHashtable @{
  115. LogName='System'
  116. ProviderName='disk'
  117. StartTime=(Get-Date).AddDays(-$Days)
  118. } -ErrorAction SilentlyContinue
  119. $errsByDisk = @{}
  120. foreach ($e in $diskErrs) {
  121. $n = $null
  122. if ($e.Message -match 'Harddisk(\d+)') { $n = $matches[1] }
  123. elseif ($e.Message -match '\bfor Disk (\d+)\b') { $n = $matches[1] }
  124. if ($null -eq $n) { continue }
  125. if (-not $errsByDisk.ContainsKey($n)) { $errsByDisk[$n] = @{} }
  126. $id = "$($e.Id)"
  127. if ($errsByDisk[$n].ContainsKey($id)) {
  128. $errsByDisk[$n][$id] = $errsByDisk[$n][$id] + 1
  129. } else {
  130. $errsByDisk[$n][$id] = 1
  131. }
  132. }
  133. } catch { $errsByDisk = @{} }
  134. # storahci controller resets
  135. try {
  136. $resets = Get-WinEvent -FilterHashtable @{
  137. LogName='System'
  138. ProviderName='storahci'
  139. Id=129
  140. StartTime=(Get-Date).AddDays(-$Days)
  141. } -ErrorAction SilentlyContinue
  142. $resetCount = if ($resets) { $resets.Count } else { 0 }
  143. } catch { $resetCount = 0 }
  144. # Per-disk verdict
  145. $failingDisks = @()
  146. foreach ($d in $diskMap) {
  147. $n = "$($d.Number)"
  148. $errs = if ($errsByDisk.ContainsKey($n)) { $errsByDisk[$n] } else { @{} }
  149. $event7 = if ($errs.ContainsKey('7')) { $errs['7'] } else { 0 }
  150. $event154 = if ($errs.ContainsKey('154')) { $errs['154'] } else { 0 }
  151. $event51 = if ($errs.ContainsKey('51')) { $errs['51'] } else { 0 }
  152. $isSsd = $d.MediaType -eq 'SSD'
  153. $threshold7 = if ($isSsd) { 10 } else { 50 }
  154. $threshold154 = if ($isSsd) { 5 } else { 10 }
  155. if ($event7 -gt $threshold7 -or $event154 -gt $threshold154 -or $event51 -gt 5) {
  156. Add-Finding -Level fail -Category 'storage' -Subject "Disk $n ($($d.Model))" `
  157. -Detail "Failing: Event7=$event7, Event154=$event154, Event51=$event51 over $Days days" `
  158. -Data @{ diskNumber=$d.Number; model=$d.Model; driveLetters=$d.DriveLetters;
  159. event7=$event7; event154=$event154; event51=$event51 }
  160. $failingDisks += $d
  161. } elseif ($event7 -gt 5 -or $event154 -gt 2) {
  162. Add-Finding -Level warn -Category 'storage' -Subject "Disk $n ($($d.Model))" `
  163. -Detail "Watchlist: Event7=$event7, Event154=$event154 — back up important data" `
  164. -Data @{ diskNumber=$d.Number; event7=$event7; event154=$event154 }
  165. } else {
  166. Add-Finding -Level pass -Category 'storage' -Subject "Disk $n ($($d.Model))" `
  167. -Detail "Clean — 0 hardware errors over $Days days"
  168. }
  169. }
  170. if ($resetCount -gt 5) {
  171. Add-Finding -Level fail -Category 'storage' -Subject 'Controller resets' `
  172. -Detail "$resetCount storahci controller resets in last $Days days — active storage failure"
  173. } elseif ($resetCount -gt 0) {
  174. Add-Finding -Level warn -Category 'storage' -Subject 'Controller resets' `
  175. -Detail "$resetCount storahci controller resets — drive intermittently unresponsive"
  176. } else {
  177. Add-Finding -Level pass -Category 'storage' -Subject 'Controller resets' `
  178. -Detail "No storahci resets in last $Days days"
  179. }
  180. # Pagefile location — flag if pagefile is on a failing drive
  181. try {
  182. $pagefiles = Get-CimInstance Win32_PageFileSetting -ErrorAction SilentlyContinue
  183. foreach ($pf in $pagefiles) {
  184. if (-not $pf.Name) { continue }
  185. $pfLetter = $pf.Name.Substring(0,1).ToUpper()
  186. $pfDisk = $diskMap | Where-Object { $_.DriveLetters -like "*$pfLetter*" } | Select-Object -First 1
  187. if ($pfDisk -and $failingDisks -contains $pfDisk) {
  188. Add-Finding -Level fail -Category 'storage' -Subject 'Pagefile location' `
  189. -Detail "Pagefile on FAILING drive: $($pf.Name) (Disk $($pfDisk.Number)). Move to a healthy drive."
  190. } else {
  191. Add-Finding -Level pass -Category 'storage' -Subject 'Pagefile location' `
  192. -Detail "Pagefile on healthy drive: $($pf.Name)"
  193. }
  194. }
  195. } catch {}
  196. # Windows Search index location — boot-time amplifier if on failing drive
  197. try {
  198. $idxDir = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows Search' -Name DataDirectory -ErrorAction SilentlyContinue).DataDirectory
  199. if ($idxDir) {
  200. $idxLetter = $idxDir.Substring(0,1).ToUpper()
  201. $idxDisk = $diskMap | Where-Object { $_.DriveLetters -like "*$idxLetter*" } | Select-Object -First 1
  202. if ($idxDisk -and $failingDisks -contains $idxDisk) {
  203. Add-Finding -Level fail -Category 'storage' -Subject 'Search index location' `
  204. -Detail "Search index on FAILING drive: $idxDir. Move to a healthy drive."
  205. } else {
  206. Add-Finding -Level pass -Category 'storage' -Subject 'Search index location' `
  207. -Detail "Search index on healthy drive: $idxDir"
  208. }
  209. }
  210. } catch {}
  211. # ─────────────────────────────────────────────────────────────────────
  212. # Section: Crash history
  213. # ─────────────────────────────────────────────────────────────────────
  214. Write-Verbose "Section 3: Crash history"
  215. try {
  216. $crashes = Get-WinEvent -FilterHashtable @{
  217. LogName='System'
  218. Id=41
  219. StartTime=(Get-Date).AddDays(-$Days)
  220. } -ErrorAction SilentlyContinue
  221. if ($crashes) {
  222. $hardShutdowns = 0
  223. foreach ($c in $crashes) {
  224. $bcCode = $c.Properties[0].Value
  225. $param1 = $c.Properties[1].Value
  226. $pwrBtn = if ($c.Properties.Count -gt 6) { $c.Properties[6].Value } else { 0 }
  227. $bcHex = '0x{0:X}' -f $bcCode
  228. if ($bcCode -eq 0) {
  229. $hardShutdowns++
  230. $why = if ($pwrBtn -ne 0) { 'power button held (hang)' } else { 'hard power loss or total hardware lockup' }
  231. Add-Finding -Level fail -Category 'crash' -Subject $c.TimeCreated.ToString('yyyy-MM-dd HH:mm') `
  232. -Detail "BugCheck=0x0 (no bugcheck recorded) — $why" `
  233. -Data @{ time=$c.TimeCreated.ToString('o'); bugcheck=$bcHex; powerButtonHeld=($pwrBtn -ne 0) }
  234. } else {
  235. Add-Finding -Level warn -Category 'crash' -Subject $c.TimeCreated.ToString('yyyy-MM-dd HH:mm') `
  236. -Detail "BugCheck=$bcHex Param1=0x$('{0:X}' -f $param1)" `
  237. -Data @{ time=$c.TimeCreated.ToString('o'); bugcheck=$bcHex; param1=('0x{0:X}' -f $param1) }
  238. }
  239. }
  240. if ($hardShutdowns -ge 2) {
  241. Add-Finding -Level fail -Category 'crash' -Subject 'Pattern' `
  242. -Detail "$hardShutdowns unclean shutdowns with no bugcheck — investigate PSU, thermals, storage cabling"
  243. }
  244. } else {
  245. Add-Finding -Level pass -Category 'crash' -Subject 'Crash log' -Detail "No Event 41 (Kernel-Power) crashes in last $Days days"
  246. }
  247. } catch {
  248. Add-Finding -Level warn -Category 'crash' -Subject 'Crash query' -Detail "Failed: $_"
  249. }
  250. # Crash dump configuration
  251. try {
  252. $dumpCfg = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl' -ErrorAction Stop
  253. $hasMinidumps = (Test-Path 'C:\Windows\Minidump\*.dmp')
  254. $hasMemoryDmp = (Test-Path 'C:\Windows\MEMORY.DMP')
  255. if ($dumpCfg.CrashDumpEnabled -eq 0) {
  256. Add-Finding -Level warn -Category 'crash' -Subject 'Dump config' -Detail "CrashDumpEnabled=0 — no dumps will be written on crash"
  257. } elseif (-not $hasMinidumps -and -not $hasMemoryDmp -and $crashes) {
  258. Add-Finding -Level warn -Category 'crash' -Subject 'Dump config' -Detail "Crashes recorded but no dump files exist — pagefile may be too small or crashes were power-loss"
  259. } else {
  260. $level = if ($dumpCfg.CrashDumpEnabled -eq 7) { 'pass' } else { 'info' }
  261. Add-Finding -Level $level -Category 'crash' -Subject 'Dump config' -Detail "CrashDumpEnabled=$($dumpCfg.CrashDumpEnabled)"
  262. }
  263. } catch {
  264. Add-Finding -Level warn -Category 'crash' -Subject 'Dump config' -Detail "Failed to read CrashControl key: $_"
  265. }
  266. # ─────────────────────────────────────────────────────────────────────
  267. # Section: Startup inventory
  268. # ─────────────────────────────────────────────────────────────────────
  269. Write-Verbose "Section 4: Startup inventory"
  270. $runPaths = @(
  271. 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
  272. 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
  273. 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'
  274. )
  275. $runEntries = 0
  276. foreach ($p in $runPaths) {
  277. if (Test-Path $p) {
  278. $props = (Get-ItemProperty $p -ErrorAction SilentlyContinue).PSObject.Properties |
  279. Where-Object { $_.Name -notmatch '^PS' }
  280. $runEntries += @($props).Count
  281. }
  282. }
  283. $autoSvcs = (Get-Service -ErrorAction SilentlyContinue | Where-Object {
  284. $_.StartType -eq 'Automatic' -and $_.Status -eq 'Running'
  285. }).Count
  286. $logonTasks = (Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object {
  287. $_.State -ne 'Disabled' -and ($_.Triggers.CimClass.CimClassName -match 'Logon|Boot')
  288. }).Count
  289. $startupFolderCount = 0
  290. foreach ($d in @("$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup",
  291. "$env:ALLUSERSPROFILE\Microsoft\Windows\Start Menu\Programs\StartUp")) {
  292. if (Test-Path $d) { $startupFolderCount += (Get-ChildItem $d -Filter *.lnk -ErrorAction SilentlyContinue).Count }
  293. }
  294. $totalStartup = $runEntries + $autoSvcs + $logonTasks + $startupFolderCount
  295. $level = if ($totalStartup -gt 60) { 'warn' } elseif ($totalStartup -gt 100) { 'fail' } else { 'pass' }
  296. Add-Finding -Level $level -Category 'startup' -Subject 'Total auto-launch items' `
  297. -Detail "$totalStartup ($runEntries Run + $autoSvcs services + $logonTasks tasks + $startupFolderCount shortcuts)" `
  298. -Data @{ runEntries=$runEntries; autoServices=$autoSvcs; logonTasks=$logonTasks; startupFolderShortcuts=$startupFolderCount }
  299. # ─────────────────────────────────────────────────────────────────────
  300. # Section: Resource pressure (right now)
  301. # ─────────────────────────────────────────────────────────────────────
  302. Write-Verbose "Section 5: Resource pressure (right now)"
  303. try {
  304. $os = Get-CimInstance Win32_OperatingSystem
  305. $memUsedPct = [math]::Round((($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize) * 100, 0)
  306. $level = if ($memUsedPct -gt 90) { 'warn' } elseif ($memUsedPct -gt 80) { 'info' } else { 'pass' }
  307. Add-Finding -Level $level -Category 'resource' -Subject 'Memory' -Detail "$memUsedPct% used"
  308. } catch {}
  309. # Thermal — CPU/chipset temps via WMI's MSAcpi_ThermalZoneTemperature.
  310. # Often returns nothing on desktops (vendor doesn't expose to ACPI thermal
  311. # zones) but always tries. Values are in tenths-of-Kelvin.
  312. try {
  313. $zones = Get-CimInstance -Namespace 'root/wmi' -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
  314. if ($zones) {
  315. foreach ($z in $zones) {
  316. $tempC = [math]::Round((($z.CurrentTemperature / 10.0) - 273.15), 1)
  317. $level = if ($tempC -ge 95) { 'fail' }
  318. elseif ($tempC -ge 85) { 'warn' }
  319. elseif ($tempC -gt 0) { 'pass' }
  320. else { 'info' }
  321. $detail = if ($tempC -ge 95) { "$tempC C — CRITICAL (CPU throttling / shutdown imminent)" }
  322. elseif ($tempC -ge 85) { "$tempC C — high (sustained loads risky)" }
  323. else { "$tempC C" }
  324. Add-Finding -Level $level -Category 'thermal' -Subject "Zone: $($z.InstanceName)" -Detail $detail
  325. }
  326. } else {
  327. Add-Finding -Level info -Category 'thermal' -Subject 'ACPI thermal zones' `
  328. -Detail "Not exposed via WMI (common on desktops). Install OpenHardwareMonitor / LibreHardwareMonitor for full thermal data."
  329. }
  330. } catch {
  331. Add-Finding -Level info -Category 'thermal' -Subject 'ACPI thermal zones' -Detail "Query failed: $_"
  332. }
  333. # Top processes by CURRENT CPU% over a 2-second sample (not accumulated CPU
  334. # time — that's misleading for long-running processes).
  335. try {
  336. $sample1 = Get-Process | Select-Object Id, ProcessName, CPU, WorkingSet
  337. Start-Sleep -Milliseconds 2000
  338. $sample2 = Get-Process | Select-Object Id, ProcessName, CPU, WorkingSet
  339. $cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
  340. if (-not $cores) { $cores = 1 }
  341. $top = @()
  342. foreach ($p2 in $sample2) {
  343. $p1 = $sample1 | Where-Object { $_.Id -eq $p2.Id } | Select-Object -First 1
  344. if (-not $p1) { continue }
  345. $deltaCpuSec = $p2.CPU - $p1.CPU
  346. $pct = [math]::Round(($deltaCpuSec / 2.0 / $cores) * 100, 1)
  347. if ($pct -gt 1.0) {
  348. $top += [PSCustomObject]@{
  349. Name = $p2.ProcessName
  350. Pid = $p2.Id
  351. Pct = $pct
  352. RamMB = [math]::Round($p2.WorkingSet / 1MB, 0)
  353. }
  354. }
  355. }
  356. $top = $top | Sort-Object Pct -Descending | Select-Object -First 5
  357. foreach ($p in $top) {
  358. Add-Finding -Level info -Category 'resource' -Subject "Active CPU: $($p.Name)" `
  359. -Detail "$($p.Pct)% CPU (sampled 2s), $($p.RamMB) MB RAM, PID $($p.Pid)"
  360. }
  361. if (-not $top) {
  362. Add-Finding -Level pass -Category 'resource' -Subject 'CPU pressure' -Detail "No process consuming >1% over 2s sample"
  363. }
  364. } catch {
  365. Add-Finding -Level info -Category 'resource' -Subject 'CPU sample' -Detail "Failed: $_"
  366. }
  367. # ─────────────────────────────────────────────────────────────────────
  368. # Verdict
  369. # ─────────────────────────────────────────────────────────────────────
  370. $failCount = ($Findings | Where-Object { $_.level -eq 'fail' }).Count
  371. $warnCount = ($Findings | Where-Object { $_.level -eq 'warn' }).Count
  372. $passCount = ($Findings | Where-Object { $_.level -eq 'pass' }).Count
  373. if (-not $Json) {
  374. # Right indicator: hostname
  375. $hostname = $env:COMPUTERNAME
  376. if (-not $hostname) { $hostname = (Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue).Name }
  377. Write-TermLine (New-TermPanelOpen -Brand 'windows-ops' -Name 'windows-ops' -Subtitle 'health-audit' -Indicator $hostname)
  378. Write-TermLine (New-TermPanelVert)
  379. # Summary line — single-glance digest
  380. $summary = "$($diskMap.Count) disks · $($failingDisks.Count) failing"
  381. $crashCount = ($Findings | Where-Object { $_.category -eq 'crash' -and $_.level -eq 'fail' -and $_.subject -ne 'Pattern' -and $_.subject -ne 'Dump config' }).Count
  382. if ($crashCount -gt 0) { $summary += " · $crashCount unclean shutdowns" }
  383. Write-TermLine (New-TermSummary -Text $summary)
  384. Write-TermLine (New-TermPanelVert)
  385. # Group findings by state (per approved decision #7)
  386. $byState = @{
  387. FAILING = $Findings | Where-Object { $_.level -eq 'fail' }
  388. WARN = $Findings | Where-Object { $_.level -eq 'warn' }
  389. PASS = $Findings | Where-Object { $_.level -eq 'pass' }
  390. INFO = $Findings | Where-Object { $_.level -eq 'info' }
  391. }
  392. function Format-CategoryLabel {
  393. param([string]$Cat)
  394. return $Cat
  395. }
  396. foreach ($state in @('FAILING','WARN','PASS','INFO')) {
  397. $items = @($byState[$state])
  398. if ($items.Count -eq 0) { continue }
  399. $stateLabel = $state.ToLower()
  400. Write-TermLine (New-TermSection -State $state -Label $stateLabel -Count $items.Count)
  401. for ($i = 0; $i -lt $items.Count; $i++) {
  402. $f = $items[$i]
  403. $cat = Format-CategoryLabel -Cat $f.category
  404. $name = "[$cat] $($f.subject)"
  405. $detail = Get-TermTruncated -Text $f.detail -MaxCols 60
  406. Write-TermLine (New-TermLeaf -Name $name -Meta $detail -IsLast:($i -eq $items.Count - 1) -NameColWidth 38 -RailColWidth 0 -MetaColWidth 60)
  407. }
  408. # Critical inline alert for FAILING section if a failing drive is identified
  409. if ($state -eq 'FAILING' -and $failingDisks) {
  410. $driveList = ($failingDisks | ForEach-Object { "Disk $($_.Number) ($($_.DriveLetters))" }) -join ', '
  411. Write-TermLine (New-TermAlert -Severity critical -Text "back up + disconnect $driveList — see recover-clone.ps1 and drive-dependencies.ps1")
  412. }
  413. Write-TermLine (New-TermPanelVert)
  414. }
  415. # Footer
  416. # Highest-action signals per decision #8
  417. $healthIndicators = New-Object System.Collections.Generic.List[string]
  418. if ($failingDisks) {
  419. $healthIndicators.Add((New-TermHealth -State 'busted' -Text 'storage'))
  420. }
  421. if ($crashCount -gt 0) {
  422. $word = if ($crashCount -eq 1) { 'crash' } else { 'crashes' }
  423. $healthIndicators.Add((New-TermHealth -State 'warning' -Text "$crashCount $word"))
  424. }
  425. # If neither, show a single healthy indicator
  426. if ($healthIndicators.Count -eq 0) {
  427. $healthIndicators.Add((New-TermHealth -State 'healthy' -Text 'clean'))
  428. }
  429. # Cap at 2 per design § 4.3
  430. $healthIndicators = $healthIndicators | Select-Object -First 2
  431. $hl = $healthIndicators | Join-TermHealths
  432. $hk = @(
  433. (New-TermHotkey -Key 'R' -Verb 'refresh')
  434. (New-TermHotkey -Key 'D' -Verb 'drill')
  435. (New-TermHotkey -Key '?' -Verb 'help')
  436. ) | Join-TermHotkeys
  437. Write-TermLine (New-TermPanelClose -Hotkeys $hk -Healths $hl)
  438. }
  439. # Exit code semantics
  440. if ($failCount -gt 0) { exit $script:EXIT_VALIDATION }
  441. exit $script:EXIT_OK