Private/Report.ps1
|
# The Report: the document the Technician reads after a Run. # # Findings, Sections and Provenance in, markup out. Nothing here talks to the Target # Machine or decides anything, which is why it can be tested: ordering, encoding, Hint # suppression and the Provenance statement are the things that break quietly. # # How it reads, top to bottom, is who reads it: # # 1. What needs doing. Every FAIL and WARN as a card: what it is about, what was found, # what to do. A first-level Technician stops here. # 2. Every Check, folded by Category. For the question "was that looked at, and what did # it say". # 3. The Sections, folded. Evidence for second level. # # One file, and nothing fetched: it is opened at a Customer Site, from a Desktop, by # whatever browser is there, with or without a network. The script only adds comfort - # filtering, opening everything at once - and the Report says all it has to without it. # # Dark on a screen and light on paper, because a dark page printed is a page of toner. $script:ReportCss = @' <style> :root{ --bg:#0b0f14; --panel:#121820; --panel2:#18202b; --line:#243042; --line2:#1a2330; --text:#e6edf3; --dim:#8b98a9; --faint:#5c6a7c; --fail:#ff5c5c; --warn:#f0b429; --ok:#3ecf8e; --info:#7d8ea3; --failbg:rgba(255,92,92,.10); --warnbg:rgba(240,180,41,.10); --okbg:rgba(62,207,142,.08); --infobg:rgba(125,142,163,.08); --failln:rgba(255,92,92,.35); --warnln:rgba(240,180,41,.35); --okln:rgba(62,207,142,.30); --infoln:rgba(125,142,163,.30); --accent:#3fd0c9; --merlin:#2f81f7; --radius:10px; --sans:"Segoe UI Variable Text","Segoe UI",system-ui,Arial,sans-serif; --mono:"Cascadia Mono",Consolas,monospace; } *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 var(--sans);-webkit-font-smoothing:antialiased} button{font:inherit;color:inherit;cursor:pointer} .FAIL{--c:var(--fail);--cb:var(--failbg);--cl:var(--failln)} .WARN{--c:var(--warn);--cb:var(--warnbg);--cl:var(--warnln)} .OK{--c:var(--ok);--cb:var(--okbg);--cl:var(--okln)} .INFO{--c:var(--info);--cb:var(--infobg);--cl:var(--infoln)} .badge{display:inline-block;font:600 11px/1 var(--mono);letter-spacing:.06em;padding:4px 7px;border-radius:5px;color:var(--c);background:var(--cb);border:1px solid var(--cl);white-space:nowrap} .dot{display:inline-block;width:9px;height:9px;border-radius:50%;background:var(--c);flex:none} .chip{display:inline-block;font-size:12px;padding:2px 9px;border-radius:999px;background:var(--panel2);border:1px solid var(--line);color:var(--dim);white-space:nowrap} .chip.admin{color:#c9a7ff;border-color:#3d2f5c;background:#1d1730} .brand{display:flex;align-items:center;gap:14px;padding:14px 28px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#0f151d,#0b0f14)} .brand .logo{font:700 20px var(--mono);color:var(--accent);letter-spacing:.5px} .brand .logo:before{content:"> ";color:var(--faint)} .brand .tag{color:var(--faint);font-style:italic} .brand .ver{font:12px var(--mono);color:var(--faint)} .brand .company{margin-left:auto;font:800 20px/1 var(--sans);letter-spacing:.22em;color:var(--merlin)} .wrap{max-width:1080px;margin:0 auto;padding:26px 28px 60px} .hero{display:grid;grid-template-columns:1fr auto;gap:20px;align-items:end;margin-bottom:8px} @media (max-width:760px){.hero{grid-template-columns:1fr}} .hero h1{margin:0;font:700 34px/1.1 var(--sans);letter-spacing:-.01em} .hero .meta{color:var(--dim);margin-top:6px} .tally{display:flex;gap:10px} .tally div{min-width:86px;padding:10px 14px;border-radius:var(--radius);background:var(--cb);border:1px solid var(--cl);text-align:center} .tally b{display:block;font:700 26px/1 var(--sans);color:var(--c)} .tally small{font:600 11px var(--mono);color:var(--c);letter-spacing:.08em} h2{font:600 13px var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--dim);margin:34px 0 12px;display:flex;gap:12px;align-items:center} h2 .tools{margin-left:auto;display:flex;gap:6px;text-transform:none;letter-spacing:0} h2 .tools button{background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:3px 11px;color:var(--dim);font:12px var(--sans)} h2 .tools button:hover{color:var(--text);border-color:var(--accent)} .none{color:var(--dim);padding:14px 16px;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius)} .card{background:var(--panel);border:1px solid var(--line);border-left:4px solid var(--c);border-radius:var(--radius);padding:16px 18px;margin-bottom:12px} .card header{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:10px} .card h3{margin:0;font:600 17px var(--sans);flex:1 1 300px} .sep{color:var(--faint);font-weight:400} .parts{margin:0;padding:0;list-style:none;display:grid;gap:6px} .parts>li{display:grid;grid-template-columns:minmax(120px,190px) 1fr;gap:10px;align-items:baseline;overflow-wrap:anywhere} .parts .k{color:var(--dim);font-size:12.5px} .parts .v ul{margin:0;padding-left:18px} .parts>li.lead{grid-template-columns:1fr;font-size:15px} .parts>li.plain{grid-template-columns:1fr} .parts>li.verdict{grid-template-columns:1fr;padding:10px 12px;border-radius:8px;background:var(--cb);border-left:3px solid var(--c);font-weight:600} .count{display:inline-block;font:700 15px/1 var(--sans);margin-right:10px;vertical-align:middle} .names{display:inline-flex;flex-wrap:wrap;gap:6px;margin:0;padding:0;list-style:none;vertical-align:middle} .names li{font:12.5px var(--mono);padding:3px 9px;border-radius:6px;background:var(--panel2);border:1px solid var(--line);color:var(--text)} .parts>li.lead .names{display:flex;margin-top:8px} .parts>li.lead .count{font-size:22px} .todo{margin-top:12px;padding:10px 12px;border-radius:8px;background:#0e1620;border:1px dashed #2b3a50} .todo h4{margin:0 0 4px;font:600 11px var(--mono);letter-spacing:.08em;color:var(--accent);text-transform:uppercase} .todo ul{margin:0;padding-left:18px} .todo li{margin:3px 0} details>summary{cursor:pointer;list-style:none} details>summary::-webkit-details-marker{display:none} .group{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:8px;overflow:hidden} .group>summary{display:flex;align-items:center;gap:10px;padding:11px 16px;flex-wrap:wrap} .group>summary:hover{background:var(--panel2)} .group>summary b{flex:1;font-weight:600} .group>summary:after{content:"+";color:var(--faint);font:16px var(--mono);width:12px;text-align:center} .group[open]>summary:after{content:"-"} .subject{padding:9px 16px 5px;border-top:1px solid var(--line2);font:600 12px var(--mono);letter-spacing:.06em;color:var(--accent);background:#0f151d} .row{display:grid;grid-template-columns:14px minmax(150px,240px) 1fr;gap:12px;padding:8px 16px;border-top:1px solid var(--line2);align-items:baseline} .row .what b{font-weight:600;overflow-wrap:anywhere} .row .val{overflow-wrap:anywhere} .row .val div+div{margin-top:2px;color:#c3cedb} .row .hint{margin-top:5px;color:var(--dim);font-size:12.5px} .row .hint:before{content:"\2192\00a0";color:var(--accent)} input.search{width:100%;margin-bottom:10px;background:var(--panel);border:1px solid var(--line);color:var(--text);border-radius:8px;padding:8px 12px;font:inherit;outline:none} input.search:focus{border-color:var(--accent)} .evi{margin:0 12px 12px;overflow:auto;max-height:70vh;border:1px solid var(--line);border-radius:8px} .evi table{border-collapse:collapse;width:100%;font-size:12.5px} .evi th{position:sticky;top:0;background:#1b2532;color:var(--dim);font-weight:600;text-align:left;padding:7px 9px;border-bottom:1px solid var(--line);white-space:nowrap} .evi td{padding:6px 9px;border-bottom:1px solid var(--line2);vertical-align:top;overflow-wrap:anywhere} .evi tr:hover td{background:#141c27} .evi pre{margin:0;padding:12px;font:12px/1.5 var(--mono);white-space:pre-wrap;color:#c8d3df} .long{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;cursor:pointer;min-width:260px} .long:after{content:""} .long.open{display:block} td.haslong{border-left:2px solid var(--line)} td.haslong:hover{border-left-color:var(--accent)} footer{margin-top:40px;padding-top:14px;border-top:1px solid var(--line);color:var(--faint);font-size:12px} @media print{ :root{--bg:#fff;--panel:#fff;--panel2:#f3f4f6;--line:#c9ced6;--line2:#e3e6ea;--text:#111;--dim:#444;--faint:#666; --fail:#b00020;--warn:#8a5a00;--ok:#0a6b3d;--info:#4a5563;--accent:#0b6e69;--merlin:#1456c4; --failbg:#fde7ea;--warnbg:#fff3d6;--okbg:#e6f6ee;--infobg:#f1f3f5} body{font-size:11.5px} .brand{background:none} .wrap{max-width:none;padding:10px 0} .tools,input.search{display:none!important} .card,.group{break-inside:avoid-page} .todo{background:#f6f8fa;border-color:#c9ced6} .subject{background:#f6f8fa} .evi{max-height:none;overflow:visible} .evi th{position:static;background:#eef0f3} .long{display:block} .group>summary:after{content:""} } </style> '@ # Comfort only. The Report is complete without it: <details> folds by itself. $script:ReportScript = @' <script> (function(){ function each(sel, fn){ Array.prototype.forEach.call(document.querySelectorAll(sel), fn); } each('[data-fold]', function(b){ b.addEventListener('click', function(){ var open = b.getAttribute('data-fold') === 'open'; each(b.getAttribute('data-target') + ' details', function(d){ if (d.style.display !== 'none') d.open = open; }); }); }); var q = document.getElementById('evidence-filter'); if (q) q.addEventListener('input', function(){ var words = q.value.toLowerCase().split(/\s+/).filter(Boolean); each('#evidence details', function(d){ var t = d.getAttribute('data-title') || ''; d.style.display = words.every(function(w){ return t.indexOf(w) >= 0; }) ? '' : 'none'; }); }); document.addEventListener('click', function(e){ var el = e.target.closest ? e.target.closest('.long') : null; if (el && !window.getSelection().toString()) el.classList.toggle('open'); }); // Paper has nothing to click: everything folded is printed open, and folded back after. var before = []; window.addEventListener('beforeprint', function(){ before = []; each('details', function(d){ before.push([d, d.open]); d.open = true; }); }); window.addEventListener('afterprint', function(){ before.forEach(function(p){ p[0].open = p[1]; }); }); })(); </script> '@ # What a Finding's Value and Hint put between their statements. A Judge that has several # things to say joins them with this, and the Report takes them apart again to give each # its own line instead of one long cell. $script:ReportStatementSeparator = ' | ' # How long a table cell may be before the Report folds it to three lines. $script:ReportLongCellLength = 180 function ConvertTo-HtmlText { [CmdletBinding()] param([AllowNull()][AllowEmptyString()]$Text) [System.Net.WebUtility]::HtmlEncode([string]$Text) } function Sort-Finding { <# .SYNOPSIS Findings worst first, then grouped by Category. #> [CmdletBinding()] param([AllowNull()][AllowEmptyCollection()]$Finding) # $script:SeverityValues is ordered worst first; its index is the sort key. $order = @{} for ($i = 0; $i -lt $script:SeverityValues.Count; $i++) { $order[$script:SeverityValues[$i]] = $i } @($Finding | Where-Object { $_ }) | Sort-Object ` @{ Expression = { $order[[string]$_.Severity] } }, @{ Expression = { [string]$_.Category } }, @{ Expression = { [string]$_.Check } } } function Split-ReportStatement { <# .SYNOPSIS The statements a Value or a Hint is made of. Pure. #> [CmdletBinding()] [OutputType([string[]])] param([AllowNull()][AllowEmptyString()][string]$Text) @("$Text" -split [regex]::Escape($script:ReportStatementSeparator) | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } function Get-ReportSubject { <# .SYNOPSIS What a Finding or a Section is about, where its own text does not already say. Pure. .DESCRIPTION The application its Check is for. "Abstuerze / .NET-Fehler / Haenger" needs "Server-Eye" in front of it. A Check that opens with its application - "Outlook: COM-Add-Ins" - needs nothing, and neither does a Check on the machine itself, which its Category names. That the name turns up somewhere inside the text is not enough: "Folder size C:\Users\x\AppData\Local\Microsoft\Edge\User Data" does not say Edge to anybody who is not reading paths. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$InputObject, [AllowNull()][AllowEmptyString()][string]$Text) $app = "$(Get-DataProperty $InputObject 'App')".Trim() if (-not $app) { return '' } if ("$Text".TrimStart().StartsWith($app, [StringComparison]::OrdinalIgnoreCase)) { return '' } $app } function ConvertTo-ReportTitleHtml { [CmdletBinding()] [OutputType([string])] param([AllowNull()]$InputObject, [AllowNull()][AllowEmptyString()][string]$Text) $subject = Get-ReportSubject -InputObject $InputObject -Text $Text if (-not $subject) { return (ConvertTo-HtmlText $Text) } '{0} <span class="sep">›</span> {1}' -f (ConvertTo-HtmlText $subject), (ConvertTo-HtmlText $Text) } function Split-ReportCountedList { <# .SYNOPSIS The names behind a count, when a statement is a count and its names. Pure. .DESCRIPTION "7: AdobeAcroOutlook.SendAsLink, IpPbxOutlookAddin.AddinModule, ..." is a number and what it counted, written as one sentence because a Value is one string. As a sentence it is a line nobody reads to the end; as a list it is seven things. Taken for a list only when the number in front is the number of names behind it. That is what tells "7: a, b, c, d, e, f, g" from a sentence that happens to open with a number and to hold a comma, and it costs nothing when it is wrong: the statement is then shown as it was written. #> [CmdletBinding()] [OutputType([string[]])] param([AllowNull()][AllowEmptyString()][string]$Statement) if ("$Statement" -notmatch '^(\d+):\s+(.+)$') { return @() } $names = @($Matches[2] -split ',\s+' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) if ($names.Count -lt 2 -or $names.Count -ne [int]$Matches[1]) { return @() } $names } function ConvertTo-ReportCountedListHtml { [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyCollection()][string[]]$Name) '<ul class="names">{0}</ul>' -f ((@($Name) | ForEach-Object { '<li>{0}</li>' -f (ConvertTo-HtmlText $_) }) -join '') } function ConvertTo-ReportValueHtml { <# .SYNOPSIS A Finding's Value as lines: what was found first, then each further statement. .DESCRIPTION A statement that opens with a short label - "Ausnahmen: ..." - is shown as label and content, and its content as a list where it holds several things. The verdict is set apart, because it is the sentence the rest leads up to. Everything else is a line of its own. Nothing is dropped and nothing reordered. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Value, [string]$VerdictLabel = '') $statements = @(Split-ReportStatement -Text $Value) if (-not $statements.Count) { return '' } $items = for ($i = 0; $i -lt $statements.Count; $i++) { $statement = $statements[$i] $names = @(Split-ReportCountedList -Statement $statement) if ($names.Count) { $class = if ($i -eq 0) { 'lead' } else { 'plain' } '<li class="{0}"><span class="count">{1}</span>{2}</li>' -f $class, $names.Count, (ConvertTo-ReportCountedListHtml -Name $names) continue } if ($i -eq 0) { '<li class="lead">{0}</li>' -f (ConvertTo-HtmlText $statement); continue } if ($statement -match '^([^:]{2,32}):\s+(.+)$') { $label = $Matches[1] $content = $Matches[2] if ($VerdictLabel -and $label -eq $VerdictLabel) { '<li class="verdict">{0}</li>' -f (ConvertTo-HtmlText $content) continue } $several = @($content -split '; ' | Where-Object { $_ }) if ($several.Count -gt 1) { $list = ($several | ForEach-Object { '<li>{0}</li>' -f (ConvertTo-HtmlText $_) }) -join '' '<li><span class="k">{0}</span><span class="v"><ul>{1}</ul></span></li>' -f (ConvertTo-HtmlText $label), $list } else { '<li><span class="k">{0}</span><span class="v">{1}</span></li>' -f (ConvertTo-HtmlText $label), (ConvertTo-HtmlText $content) } continue } '<li class="plain">{0}</li>' -f (ConvertTo-HtmlText $statement) } '<ul class="parts">{0}</ul>' -f ($items -join '') } function ConvertTo-ReportHintHtml { <# .SYNOPSIS A Finding's Hint as the list of things to do. Nothing for an OK Finding. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Finding, [string]$Heading) # An OK Finding has nothing for a Technician to act on, so it shows no Hint. The # constructor already drops it, but Findings merged from the Elevated Part arrive # over CliXML without passing through it. if ("$($Finding.Severity)" -eq 'OK') { return '' } $steps = @(Split-ReportStatement -Text "$($Finding.Hint)") if (-not $steps.Count) { return '' } '<div class="todo"><h4>{0}</h4><ul>{1}</ul></div>' -f (ConvertTo-HtmlText $Heading), (($steps | ForEach-Object { '<li>{0}</li>' -f (ConvertTo-HtmlText $_) }) -join '') } function ConvertTo-ReportPrivilegeHtml { [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Privilege, [string]$Title) $class = 'chip' if ($Privilege -eq 'admin') { $class = 'chip admin' } '<span class="{0}" title="{1}">{2}</span>' -f $class, (ConvertTo-HtmlText $Title), (ConvertTo-HtmlText $Privilege) } function ConvertTo-ReportCardHtml { <# .SYNOPSIS One Finding that needs doing something about, in full. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)]$Finding, [hashtable]$Word) '<article class="card {0}"><header><span class="badge">{0}</span><h3>{1}</h3><span class="chip">{2}</span>{3}</header>{4}{5}</article>' -f ` $Finding.Severity, (ConvertTo-ReportTitleHtml -InputObject $Finding -Text "$($Finding.Check)"), (ConvertTo-HtmlText $Finding.Category), (ConvertTo-ReportPrivilegeHtml -Privilege "$($Finding.Privilege)" -Title $Word.PrivilegeTitle), (ConvertTo-ReportValueHtml -Value "$($Finding.Value)" -VerdictLabel $Word.VerdictLabel), (ConvertTo-ReportHintHtml -Finding $Finding -Heading $Word.Todo) } function ConvertTo-ReportRowHtml { <# .SYNOPSIS One Finding as a line in its Category: what was checked and what it said. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)]$Finding) $lines = (Split-ReportStatement -Text "$($Finding.Value)" | ForEach-Object { $names = @(Split-ReportCountedList -Statement $_) if ($names.Count) { '<div><span class="count">{0}</span>{1}</div>' -f $names.Count, (ConvertTo-ReportCountedListHtml -Name $names) } else { '<div>{0}</div>' -f (ConvertTo-HtmlText $_) } }) -join '' $hint = '' if ("$($Finding.Severity)" -ne 'OK' -and "$($Finding.Hint)") { $hint = '<div class="hint">{0}</div>' -f ((Split-ReportStatement -Text "$($Finding.Hint)" | ForEach-Object { ConvertTo-HtmlText $_ }) -join ' · ') } '<div class="row {0}"><span class="dot" title="{0}"></span><div class="what"><b>{1}</b></div><div class="val">{2}{3}</div></div>' -f ` $Finding.Severity, (ConvertTo-HtmlText $Finding.Check), $lines, $hint } function ConvertTo-ReportCategoryHtml { <# .SYNOPSIS Every Finding of one Category, folded, and inside it by application. .DESCRIPTION The Checks on the machine itself come first, then each application under its own name, the one with the worst Finding first. A Category of 130 Findings is only a list a Technician can read once it says whose each is. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)][string]$Category, [Parameter(Mandatory)][AllowEmptyCollection()]$Finding, [hashtable]$Word ) $list = @(Sort-Finding -Finding $Finding) $order = @{} for ($i = 0; $i -lt $script:SeverityValues.Count; $i++) { $order[$script:SeverityValues[$i]] = $i } $worst = "$($list[0].Severity)" $badges = foreach ($severity in $script:SeverityValues) { $n = @($list | Where-Object { $_.Severity -eq $severity }).Count if ($n) { '<span class="badge {0}">{1} {0}</span>' -f $severity, $n } } $general = @($list | Where-Object { -not "$(Get-DataProperty $_ 'App')".Trim() }) $rows = @($general | ForEach-Object { ConvertTo-ReportRowHtml -Finding $_ }) $apps = @($list | Where-Object { "$(Get-DataProperty $_ 'App')".Trim() } | Group-Object { "$(Get-DataProperty $_ 'App')".Trim() } | Sort-Object @{ Expression = { $order[[string](@(Sort-Finding -Finding $_.Group)[0].Severity)] } }, Name) foreach ($app in $apps) { $rows += '<div class="subject">{0}</div>' -f (ConvertTo-HtmlText $app.Name) $rows += @(Sort-Finding -Finding $app.Group | ForEach-Object { ConvertTo-ReportRowHtml -Finding $_ }) } '<details class="group"><summary class="{0}"><span class="dot"></span><b>{1}</b>{2}</summary>{3}</details>' -f ` $worst, (ConvertTo-HtmlText $Category), ($badges -join ' '), ($rows -join "`n") } function ConvertTo-ReportTableHtml { <# .SYNOPSIS A Section's rows as a table, with a long cell folded to three lines. .DESCRIPTION Built here rather than by ConvertTo-Html, which knows nothing of a cell that holds a whole event message: shown in full it makes a table nobody can scan, and cut short it ends in the middle of a sentence. Folded, it is all there - a click opens it, and on paper it is printed open. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyCollection()]$Row, [string]$MoreTitle) $rows = @($Row | Where-Object { $null -ne $_ }) if (-not $rows.Count) { return '' } # The columns of the first row, like ConvertTo-Html. Rows that came back from the # Elevated Part are property bags, and a hashtable row has keys instead. $names = @(if ($rows[0] -is [System.Collections.IDictionary]) { $rows[0].Keys } else { $rows[0].PSObject.Properties.Name }) $head = ($names | ForEach-Object { '<th>{0}</th>' -f (ConvertTo-HtmlText $_) }) -join '' $body = foreach ($row in $rows) { $cells = foreach ($name in $names) { $value = if ($row -is [System.Collections.IDictionary]) { $row[$name] } else { Get-DataProperty $row $name } if ($value -is [System.Collections.IEnumerable] -and $value -isnot [string]) { $value = @($value) -join ', ' } $text = "$value" if ($text.Length -gt $script:ReportLongCellLength) { '<td class="haslong"><div class="long" title="{0}">{1}</div></td>' -f (ConvertTo-HtmlText $MoreTitle), (ConvertTo-HtmlText $text) } else { '<td>{0}</td>' -f (ConvertTo-HtmlText $text) } } '<tr>{0}</tr>' -f ($cells -join '') } "<table><tr>{0}</tr>`n{1}</table>" -f $head, ($body -join "`n") } function ConvertTo-SectionHtml { <# .SYNOPSIS One Section, folded, under a title that says whose it is. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)]$Section, [hashtable]$Word = @{}) if ($Section.Text) { $body = '<pre>' + (ConvertTo-HtmlText ([string]$Section.Text).Trim()) + '</pre>' } else { $body = ConvertTo-ReportTableHtml -Row $Section.Row -MoreTitle "$($Word.More)" } $subject = Get-ReportSubject -InputObject $Section -Text "$($Section.Title)" $search = ('{0} {1} {2}' -f $subject, $Section.Title, (Get-DataProperty $Section 'CheckName')).ToLowerInvariant() '<details class="group" data-title="{0}"><summary><b>{1}</b>{2}</summary><div class="evi">{3}</div></details>' -f ` (ConvertTo-HtmlText $search), (ConvertTo-ReportTitleHtml -InputObject $Section -Text "$($Section.Title)"), (ConvertTo-ReportPrivilegeHtml -Privilege "$($Section.Privilege)" -Title "$($Word.PrivilegeTitle)"), $body } function ConvertTo-Report { <# .SYNOPSIS Renders a Run as the HTML document a Technician reads. #> [CmdletBinding()] [OutputType([string])] param( [AllowNull()][AllowEmptyCollection()]$Finding, [AllowNull()][AllowEmptyCollection()]$Section, [Parameter(Mandatory)]$Provenance, [Parameter(Mandatory)][string]$ComputerName, [datetime]$AsOf = (Get-Date) ) $sorted = @(Sort-Finding -Finding $Finding) # Read before the template rather than inside it: an expression inside a here-string # that throws leaves a half-rendered Report, and Get-Text throws on a missing key. $word = @{ Todo = Get-Text 'Report.Label.Todo' VerdictLabel = Get-Text 'Report.Label.Verdict' PrivilegeTitle = Get-Text 'Report.Label.PrivilegeTitle' More = Get-Text 'Report.Label.More' } $title = ConvertTo-HtmlText (Get-Text 'Report.Title' $ComputerName) $tagline = ConvertTo-HtmlText (Get-Text 'Report.Tagline') $company = ConvertTo-HtmlText (Get-Text 'Report.Company') $generated = ConvertTo-HtmlText (Get-Text 'Report.Generated' ` $AsOf.ToString('yyyy-MM-dd HH:mm') $PSVersionTable.PSVersion) $headingAction = ConvertTo-HtmlText (Get-Text 'Report.Heading.Action') $headingChecks = ConvertTo-HtmlText (Get-Text 'Report.Heading.Checks') $headingEvidence = ConvertTo-HtmlText (Get-Text 'Report.Heading.Evidence') $nothingToDo = ConvertTo-HtmlText (Get-Text 'Report.Action.None') $expandAll = ConvertTo-HtmlText (Get-Text 'Report.Button.ExpandAll') $collapseAll = ConvertTo-HtmlText (Get-Text 'Report.Button.CollapseAll') $filterEvidence = ConvertTo-HtmlText (Get-Text 'Report.Filter.Evidence') # Not $provenance: PowerShell variable names are case-insensitive, so that would # overwrite the $Provenance parameter and leave every later read of it empty. $machine = ConvertTo-HtmlText $ComputerName $provenanceText = ConvertTo-HtmlText (Get-ProvenanceStatement -Provenance $Provenance -AsOf $AsOf) $moduleVersion = ConvertTo-HtmlText $Provenance.ModuleVersion $tally = ($script:SeverityValues | ForEach-Object { $severity = $_ '<div class="{0}"><b>{1}</b><small>{0}</small></div>' -f $severity, @($sorted | Where-Object { $_.Severity -eq $severity }).Count }) -join '' # 1. What needs doing: worst first, and every one of them, however many there are. $problems = @($sorted | Where-Object { $_.Severity -in 'FAIL', 'WARN' }) $cards = @($problems | ForEach-Object { ConvertTo-ReportCardHtml -Finding $_ -Word $word }) if (-not $cards.Count) { $cards = @('<p class="none">{0}</p>' -f $nothingToDo) } # 2. Every Check. The Category with the worst Finding first, as $sorted already has them. $categories = New-Object System.Collections.Generic.List[string] foreach ($f in $sorted) { if (-not $categories.Contains([string]$f.Category)) { $categories.Add([string]$f.Category) } } $groups = foreach ($category in $categories) { ConvertTo-ReportCategoryHtml -Category $category -Word $word ` -Finding @($sorted | Where-Object { [string]$_.Category -eq $category }) } # 3. The evidence. $sections = @($Section | Where-Object { $_ }) $sectionHtml = foreach ($s in $sections) { ConvertTo-SectionHtml -Section $s -Word $word } @" <!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>$title</title>$script:ReportCss</head><body> <div class="brand"><span class="logo">gutcheck</span><span class="tag">$tagline</span><span class="ver">v$moduleVersion</span><span class="company">$company</span></div> <div class="wrap"> <div class="hero"><div><h1>$machine</h1><div class="meta">$generated<br><span class="provenance">$provenanceText</span></div></div><div class="tally">$tally</div></div> <h2>$headingAction · $($problems.Count)</h2> $($cards -join "`n") <h2>$headingChecks · $($sorted.Count)<span class="tools"><button type="button" data-fold="open" data-target="#checks">$expandAll</button><button type="button" data-fold="shut" data-target="#checks">$collapseAll</button></span></h2> <div id="checks"> $($groups -join "`n") </div> <h2>$headingEvidence · $($sections.Count)<span class="tools"><button type="button" data-fold="open" data-target="#evidence">$expandAll</button><button type="button" data-fold="shut" data-target="#evidence">$collapseAll</button></span></h2> <input class="search" id="evidence-filter" type="search" placeholder="$filterEvidence"> <div id="evidence"> $($sectionHtml -join "`n") </div> <footer>$provenanceText</footer> </div> $script:ReportScript </body></html> "@ } function Write-Report { <# .SYNOPSIS Writes a Run's Report and its CSV companions, and returns the Report's path. .DESCRIPTION The CSVs are semicolon-delimited because Technicians open them in German Excel. The events CSV is written even when a Run found no events: a missing file and a clean machine must not look the same on disk. #> [CmdletBinding()] [OutputType([string])] param( [AllowNull()][AllowEmptyCollection()]$Finding, [AllowNull()][AllowEmptyCollection()]$Section, [AllowNull()][AllowEmptyCollection()]$EventLogEntry, [Parameter(Mandatory)][string]$OutputPath, [Parameter(Mandatory)]$Provenance, [Parameter(Mandatory)][string]$ComputerName, [datetime]$AsOf = (Get-Date) ) if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } $reportPath = Join-Path $OutputPath 'report.html' ConvertTo-Report -Finding $Finding -Section $Section -Provenance $Provenance ` -ComputerName $ComputerName -AsOf $AsOf | Out-File -FilePath $reportPath -Encoding utf8 # App and CheckName last: whoever has a sheet built on the first six columns keeps it. @(Sort-Finding -Finding $Finding) | Select-Object Severity, Category, Check, Value, Hint, Privilege, @{ Name = 'App'; Expression = { Get-DataProperty $_ 'App' } }, @{ Name = 'CheckName'; Expression = { Get-DataProperty $_ 'CheckName' } } | Export-Csv -Path (Join-Path $OutputPath 'findings.csv') -NoTypeInformation -Encoding UTF8 -Delimiter ';' $events = @($EventLogEntry | Where-Object { $_ }) if (-not $events.Count) { # Export-Csv writes nothing at all for an empty collection, which would leave the # Technician unable to tell "no events" from "the Run never got that far". 'Time;Category;Provider;Id;Message' | Out-File -FilePath (Join-Path $OutputPath 'events.csv') -Encoding utf8 } else { $events | Sort-Object Time -Descending | Select-Object Time, Category, Provider, Id, Message | Export-Csv -Path (Join-Path $OutputPath 'events.csv') -NoTypeInformation -Encoding UTF8 -Delimiter ';' } $reportPath } |