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} a.jump{color:var(--accent);text-decoration:none;border-bottom:1px dotted var(--accent)} a.jump:hover{border-bottom-style:solid} details.group:target>summary{box-shadow:inset 3px 0 0 var(--accent)} 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:"-"} .sysinfo{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);margin:14px 0 4px;overflow:hidden} .sysinfo>summary{display:flex;align-items:center;gap:10px;padding:11px 16px;font-weight:600} .sysinfo>summary:hover{background:var(--panel2)} .sysinfo>summary:after{content:"+";margin-left:auto;color:var(--faint);font:16px var(--mono)} .sysinfo[open]>summary:after{content:"-"} .sysgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(330px,1fr));gap:4px 28px;padding:4px 16px 14px} .sysgrid h4{margin:10px 0 4px;font:600 11px var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--accent)} .sysgrid dl{display:grid;grid-template-columns:118px 1fr;gap:3px 12px;margin:0} .sysgrid dt{color:var(--dim)} .sysgrid dd{margin:0;overflow-wrap:anywhere} .kind{margin-bottom:18px} .kind>.kindname{display:flex;align-items:baseline;gap:8px;margin:0 2px 7px;font:600 12px var(--mono);letter-spacing:.06em;text-transform:uppercase;color:var(--accent)} .kind>.kindname small{color:var(--faint);font-weight:400} .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} .sysinfo>summary:after{content:""} .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'; }); // A heading with nothing left under it goes too. each('#evidence .kind', function(k){ var left = Array.prototype.some.call(k.querySelectorAll('details'), function(d){ return d.style.display !== 'none'; }); k.style.display = left ? '' : 'none'; }); }); // A Hint that names a piece of evidence leads there: shown again if filtered away, and open. function reveal(){ var d = location.hash.length > 1 ? document.getElementById(location.hash.slice(1)) : null; if (!d || d.tagName !== 'DETAILS') return; d.style.display = ''; if (d.parentNode && d.parentNode.style) d.parentNode.style.display = ''; d.open = true; d.scrollIntoView(); } window.addEventListener('hashchange', reveal); document.addEventListener('click', function(e){ if (e.target.closest && e.target.closest('a.jump')) setTimeout(reveal, 0); }); reveal(); 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 Get-ReportSectionLink { <# .SYNOPSIS Which piece of evidence a Hint can point at: Section title to the id of its fold. Pure. .DESCRIPTION "Die Liste der Verzoegerungen unten pruefen" sent a Technician looking through fifty folds for a list that is called something else. A Hint names the Section by its title, in quotation marks, and the Report makes a link of it. Only a title one Section alone carries: "Laufzeitmessungen (Server)" is five Sections, and a link to the first of them would be a link to the wrong one. #> [CmdletBinding()] [OutputType([hashtable])] param([AllowNull()][AllowEmptyCollection()]$Section) $seen = @{} foreach ($s in @($Section | Where-Object { $_ })) { $title = "$($s.Title)".Trim() if ($title) { $seen[$title] = 1 + [int]$seen[$title] } } $link = @{} $n = 0 foreach ($s in @($Section | Where-Object { $_ })) { $title = "$($s.Title)".Trim() if ($title -and $seen[$title] -eq 1) { $n++; $link[$title] = 'evi-{0}' -f $n } } $link } function ConvertTo-ReportHintTextHtml { <# .SYNOPSIS One statement of a Hint, with every Section it names in quotation marks made a link to that Section. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Text, [AllowNull()][hashtable]$SectionLink) $html = ConvertTo-HtmlText $Text if (-not $SectionLink -or -not $SectionLink.Count) { return $html } foreach ($title in $SectionLink.Keys) { $quoted = ConvertTo-HtmlText ('"{0}"' -f $title) if ($html.Contains($quoted)) { $html = $html.Replace($quoted, ('<a class="jump" href="#{0}">{1}</a>' -f $SectionLink[$title], (ConvertTo-HtmlText $title))) } } $html } 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, [AllowNull()][hashtable]$SectionLink) # 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-ReportHintTextHtml -Text $_ -SectionLink $SectionLink) }) -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 -SectionLink $Word.SectionLink) } 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, [AllowNull()][hashtable]$SectionLink) $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-ReportHintTextHtml -Text $_ -SectionLink $SectionLink }) -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 $_ -SectionLink $Word.SectionLink }) $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 $_ -SectionLink $Word.SectionLink }) } '<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-ReportTopHtml { <# .SYNOPSIS A Section placed at the top as a box: its rows by Group, each a name and a value. .DESCRIPTION Open, because it is what a reader wants first - which machine, which Windows - and foldable, because the fifth Report of the same machine does not need it. A value that names a piece of evidence in quotation marks links to it, as a Hint does. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)]$Section, [hashtable]$Word = @{}) $order = New-Object 'System.Collections.Generic.List[string]' $groups = @{} foreach ($row in @($Section.Row | Where-Object { $_ })) { $group = "$(Get-DataProperty $row 'Group')" if (-not $groups.ContainsKey($group)) { $order.Add($group); $groups[$group] = New-Object 'System.Collections.Generic.List[string]' } $groups[$group].Add(('<dt>{0}</dt><dd>{1}</dd>' -f (ConvertTo-HtmlText "$(Get-DataProperty $row 'Name')"), (ConvertTo-ReportHintTextHtml -Text "$(Get-DataProperty $row 'Value')" -SectionLink $Word.SectionLink))) } if (-not $order.Count) { return '' } $blocks = foreach ($group in $order) { '<section><h4>{0}</h4><dl>{1}</dl></section>' -f (ConvertTo-HtmlText $group), ($groups[$group] -join '') } '<details class="sysinfo" open><summary>{0}</summary><div class="sysgrid">{1}</div></details>' -f ` (ConvertTo-HtmlText "$($Section.Title)"), ($blocks -join '') } function Get-ReportKindTitle { <# .SYNOPSIS What the evidence of one Kind stands under. Pure. .DESCRIPTION A Kind this Report has no word for stands under its own name: a Kind added later is then still grouped, and it shows that the word is missing. Evidence that names no Kind - it came from somewhere other than a Check - stands under "Weitere". #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Kind) switch ("$Kind".Trim()) { 'App' { return Get-Text 'Report.Kind.App' } 'AppConnection' { return Get-Text 'Report.Kind.AppConnection' } 'AppLog' { return Get-Text 'Report.Kind.AppLog' } 'Autodiscover' { return Get-Text 'Report.Kind.Autodiscover' } 'BootPerformance' { return Get-Text 'Report.Kind.BootPerformance' } 'Cpu' { return Get-Text 'Report.Kind.Cpu' } 'CrashDump' { return Get-Text 'Report.Kind.CrashDump' } 'DotNet' { return Get-Text 'Report.Kind.DotNet' } 'GroupPolicy' { return Get-Text 'Report.Kind.GroupPolicy' } 'HttpsEndpoint' { return Get-Text 'Report.Kind.HttpsEndpoint' } 'Memory' { return Get-Text 'Report.Kind.Memory' } 'Network' { return Get-Text 'Report.Kind.Network' } 'NetworkDependency' { return Get-Text 'Report.Kind.NetworkDependency' } 'NetworkDisruption' { return Get-Text 'Report.Kind.NetworkDisruption' } 'Odbc' { return Get-Text 'Report.Kind.Odbc' } 'OfficeAddins' { return Get-Text 'Report.Kind.OfficeAddins' } 'OutlookAuth' { return Get-Text 'Report.Kind.OutlookAuth' } 'Printing' { return Get-Text 'Report.Kind.Printing' } 'Security' { return Get-Text 'Report.Kind.Security' } 'Server' { return Get-Text 'Report.Kind.Server' } 'Service' { return Get-Text 'Report.Kind.Service' } 'Stability' { return Get-Text 'Report.Kind.Stability' } 'Startup' { return Get-Text 'Report.Kind.Startup' } 'Stress' { return Get-Text 'Report.Kind.Stress' } 'System' { return Get-Text 'Report.Kind.System' } '' { return Get-Text 'Report.Kind.Other' } default { return "$Kind".Trim() } } } function ConvertTo-ReportEvidenceHtml { <# .SYNOPSIS Every Section, grouped by the Kind of the Check that produced it. .DESCRIPTION Fifty folds in the order the Checks ran put an application's processes between the printers and the proxy. Under its Kind, what belongs together stands together: every application's processes, every application's Servers. Kinds stand in the order the Run first met them, and the Sections of each in the order they came. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyCollection()]$Section, [hashtable]$Word = @{}) $kinds = New-Object System.Collections.Generic.List[string] $byKind = @{} foreach ($s in @($Section | Where-Object { $_ })) { $kind = "$(Get-DataProperty $s 'Kind')".Trim() if (-not $byKind.ContainsKey($kind)) { $kinds.Add($kind); $byKind[$kind] = New-Object System.Collections.Generic.List[psobject] } $byKind[$kind].Add($s) } # What names no Kind goes last, whenever it turned up. if ($kinds.Remove('')) { $kinds.Add('') } foreach ($kind in $kinds) { $title = Get-ReportKindTitle -Kind $kind $folds = foreach ($s in $byKind[$kind]) { ConvertTo-SectionHtml -Section $s -Word $Word -KindTitle $title } '<div class="kind"><div class="kindname">{0}<small>{1}</small></div>{2}</div>' -f ` (ConvertTo-HtmlText $title), $byKind[$kind].Count, ($folds -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 = @{}, [string]$KindTitle = '') 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} {3}' -f $subject, $Section.Title, (Get-DataProperty $Section 'CheckName'), $KindTitle).ToLowerInvariant() # The id a Hint that names this Section links to. See Get-ReportSectionLink. $id = '' $anchor = Get-DataProperty $Word 'SectionLink' if ($anchor -and $anchor.ContainsKey("$($Section.Title)".Trim())) { $id = ' id="{0}"' -f $anchor["$($Section.Title)".Trim()] } '<details class="group"{4} 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, $id } 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' } # What is placed at the top is a box there and not a fold among the evidence. $all = @($Section | Where-Object { $_ }) $topParts = @($all | Where-Object { "$(Get-DataProperty $_ 'Placement')" -eq 'Top' }) $Section = @($all | Where-Object { "$(Get-DataProperty $_ 'Placement')" -ne 'Top' }) $word.SectionLink = Get-ReportSectionLink -Section $Section $topHtml = @($topParts | ForEach-Object { ConvertTo-ReportTopHtml -Section $_ -Word $word }) -join "`n" $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 = @(ConvertTo-ReportEvidenceHtml -Section $sections -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> $topHtml <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 } |