Public/ConvertTo-HDTReport.ps1
|
function ConvertTo-HDTReport { <# .SYNOPSIS Renders a deployment run's HDT.jsonl into one self-contained HTML report. .DESCRIPTION HDT.jsonl is the structured source of truth, and this is what turns it into the thing a technician actually opens. Two operational facts shape every decision in here. IT IS SELF-CONTAINED. Inline CSS, no script, no CDN, no external font, no image. A report is read from a USB stick, from a share, and from a machine with no network - anything external is a report that renders blank exactly when it matters. IT TOLERATES A BROKEN LOG. The file is parsed LINE BY LINE: a blank line is ignored and a line that does not parse is COUNTED AND REPORTED IN THE REPORT rather than thrown. A truncated final line is the normal state of a log from a machine that died, which is exactly when somebody renders a report. A renderer that throws on it is a renderer that never works on the day it is needed. EVERYTHING IS ESCAPED, through ConvertTo-HDTHtmlText. A step message containing '<' is normal - a command line, an XML fragment - and a report that breaks on it is worse than no report. THE DEPLOYMENT PASSWORD NEVER APPEARS. The report is built from the JSONL, which never carries it, and from -State, from which it reads only status, leg and the step records. A report gets emailed; that is the whole reason this is a rule rather than a preference. Sections, in order: the header (run, sequence, computer, phases, start, end, duration, outcome), the summary (counts, and the failing step called out), the steps in index order, the reboot legs as a timeline, the variable resolutions with their source - the "explains every value" surfacing in the report - and every log record. .PARAMETER JsonlPath The run's HDT.jsonl, read through the injected filesystem. .PARAMETER Path Where the report is written, through the same filesystem: UTF-8 with no byte order mark, with <meta charset="utf-8"> in the head. .PARAMETER FileSystem An IFileSystem. A fake in a unit test, New-HDTFileSystem against a machine - which is what makes rendering a report provable without writing one. .PARAMETER State The run state document, when one is available. Only its status, its leg and its step records are read, which is how a step that never reached the log at all still appears in the report as Pending. .PARAMETER ComputerName The machine this run built. Without it the name is taken from the stream's own var.resolve record for HDTComputerName, which is where a gathered run puts it - and a run whose gather phase is not in this log has no other honest source. It is NOT read from -State: a variable map may carry a join password or a share credential, and the rule that the report reads only status, leg and the step records from the state is what keeps that out of an emailed file. .PARAMETER Title The page title and heading. .PARAMETER Timestamp When the report was rendered, for the footer. Omitted rather than defaulted, because the engine takes time from an injected clock and this function has none. .OUTPUTS System.String - the path it wrote. .EXAMPLE ConvertTo-HDTReport -JsonlPath C:\HDT\Logs\HDT.jsonl ` -Path C:\HDT\Logs\report.html -FileSystem (New-HDTFileSystem) .EXAMPLE Start-Process (ConvertTo-HDTReport -JsonlPath $log.JsonlPath -Path $out ` -FileSystem $fs -State $run.State -Title 'DEMO-M2 deployment') Render and open, which is how the run reads when it failed. #> [CmdletBinding(SupportsShouldProcess = $true)] [OutputType([string])] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateNotNullOrEmpty()] [string] $JsonlPath, [Parameter(Mandatory = $true, Position = 1)] [ValidateNotNullOrEmpty()] [string] $Path, [Parameter(Mandatory = $true)] [ValidateNotNull()] [object] $FileSystem, [Parameter()] [AllowNull()] [object] $State, [Parameter()] [ValidateNotNullOrEmpty()] [string] $ComputerName, [Parameter()] [ValidateNotNullOrEmpty()] [string] $Title = 'HDT deployment report', [Parameter()] [AllowNull()] [System.Nullable[datetime]] $Timestamp ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if (-not $FileSystem.TestPath($JsonlPath)) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $JsonlPath ` -Message 'the log stream does not exist. A report is rendered from the HDT.jsonl a run wrote.' ` -ErrorId 'HDTLogNotFound' -Category ObjectNotFound)) } if (-not $PSCmdlet.ShouldProcess($Path, 'Write deployment report')) { return } # -- the shared little helpers ---------------------------------------- # # Scriptblocks rather than functions: they are private to one renderer, and # a module-level function per formatting rule would be six more names in a # namespace that already carries the engine. $fieldMap = { param([object] $Object) $map = [System.Collections.Hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) if ($null -ne $Object) { foreach ($property in @($Object.PSObject.Properties)) { $map[$property.Name] = $property.Value } } return $map } $escape = { param([object] $Value) return (ConvertTo-HDTHtmlText -Value $Value) } $cell = { param([object] $Value) return ('<td>{0}</td>' -f (ConvertTo-HDTHtmlText -Value $Value)) } $formatDuration = { param([object] $Millisecond) if ($null -eq $Millisecond -or ([string] $Millisecond) -eq '') { return '' } $value = [double] $Millisecond if ($value -lt 1000) { return ('{0} ms' -f $value.ToString('0', [System.Globalization.CultureInfo]::InvariantCulture)) } return ('{0} s' -f ($value / 1000).ToString('0.0', [System.Globalization.CultureInfo]::InvariantCulture)) } # 'o' in, HH:mm:ss out. The full stamp is in the log table; a header that # repeated it would be unreadable. $parseStamp = { param([string] $Text) $parsed = [datetime]::MinValue $styles = [System.Globalization.DateTimeStyles]::RoundtripKind if ([datetime]::TryParse($Text, [System.Globalization.CultureInfo]::InvariantCulture, $styles, [ref] $parsed)) { return $parsed } return $null } # -- parse, line by line ---------------------------------------------- $text = [string] $FileSystem.ReadAllText($JsonlPath) $record = New-Object -TypeName System.Collections.ArrayList $unparseable = 0 foreach ($line in @($text -split "`r?`n")) { if ([string]::IsNullOrWhiteSpace($line)) { continue } $parsed = $null try { $parsed = ConvertFrom-Json -InputObject $line } catch { # Counted, never fatal. See the description. $unparseable++ continue } [void] $record.Add((& $fieldMap $parsed)) } # -- the step model ---------------------------------------------------- $stepByIndex = @{} $stepOrder = New-Object -TypeName System.Collections.ArrayList $touchStep = { param([int] $Index) if (-not $stepByIndex.ContainsKey($Index)) { $entry = [pscustomobject] ([ordered] @{ Index = $Index Group = '' Name = '' Type = '' Status = 'Pending' Attempt = 0 DurationMs = $null ExitCode = $null Message = '' }) $stepByIndex[$Index] = $entry [void] $stepOrder.Add($entry) } return $stepByIndex[$Index] } foreach ($item in $record) { if (-not $item.ContainsKey('stepIndex') -or $null -eq $item['stepIndex']) { continue } $entry = & $touchStep ([int] $item['stepIndex']) $data = & $fieldMap $item['data'] if ($item.ContainsKey('stepName') -and -not [string]::IsNullOrEmpty([string] $item['stepName'])) { $entry.Name = [string] $item['stepName'] } if ($item.ContainsKey('stepType') -and -not [string]::IsNullOrEmpty([string] $item['stepType'])) { $entry.Type = [string] $item['stepType'] } if ($data.ContainsKey('attempt') -and $null -ne $data['attempt']) { $attempt = [int] $data['attempt'] if ($attempt -gt $entry.Attempt) { $entry.Attempt = $attempt } } if ($data.ContainsKey('exitCode') -and $null -ne $data['exitCode']) { $entry.ExitCode = [int] $data['exitCode'] } if ($item.ContainsKey('durationMs') -and $null -ne $item['durationMs']) { $entry.DurationMs = [long] $item['durationMs'] } switch ([string] $item['event']) { 'step.complete' { $entry.Status = 'Completed' $entry.Message = [string] $item['message'] } 'step.fail' { $entry.Status = 'Failed' $entry.Message = [string] $item['message'] } 'step.skip' { # Only from Pending. A step already Completed on an earlier leg # logs a step.skip on the next one, and reporting that as Skipped # would erase the leg it actually ran on. if ($entry.Status -eq 'Pending') { $entry.Status = 'Skipped' $entry.Message = [string] $item['message'] } } 'step.start' { if ($entry.Status -eq 'Pending') { $entry.Status = 'Running' } } } } # The state fills in what the log cannot: the group path, and every step # that never reached the log at all because the run stopped before it. if ($null -ne $State -and $null -ne $State.PSObject.Properties['step']) { foreach ($row in @($State.step)) { $source = & $fieldMap $row if (-not $source.ContainsKey('index')) { continue } $entry = & $touchStep ([int] $source['index']) if ($source.ContainsKey('group') -and $null -ne $source['group']) { $entry.Group = (@($source['group']) -join ' / ') } if ([string]::IsNullOrEmpty($entry.Name) -and $source.ContainsKey('name')) { $entry.Name = [string] $source['name'] } if ([string]::IsNullOrEmpty($entry.Type) -and $source.ContainsKey('type')) { $entry.Type = [string] $source['type'] } # THE STATE WINS ON STATUS. A Restart step logs step.start and then # the machine goes down - there is no step.complete in the stream, # ever - so a status read from the log alone leaves every reboot # step Running for good, and a finished deployment renders as one # still in progress. The state document is the run's own record of # what happened to each step; Pending is the one value that means # "the document knows nothing", so the log keeps precedence there. if ($source.ContainsKey('status') -and [string] $source['status'] -ne 'Pending') { $entry.Status = [string] $source['status'] } if ($entry.Attempt -eq 0 -and $source.ContainsKey('attempt') -and $null -ne $source['attempt']) { $entry.Attempt = [int] $source['attempt'] } if ($null -eq $entry.ExitCode -and $source.ContainsKey('exitCode') -and $null -ne $source['exitCode']) { $entry.ExitCode = [int] $source['exitCode'] } if ($null -eq $entry.DurationMs -and $source.ContainsKey('durationMs') -and $null -ne $source['durationMs']) { $entry.DurationMs = [long] $source['durationMs'] } if ([string]::IsNullOrEmpty($entry.Message) -and $source.ContainsKey('message')) { $entry.Message = [string] $source['message'] } } } $step = @($stepOrder | Sort-Object -Property Index) $completedCount = @($step | Where-Object { $_.Status -eq 'Completed' }).Count $failedCount = @($step | Where-Object { $_.Status -eq 'Failed' }).Count $skippedCount = @($step | Where-Object { $_.Status -eq 'Skipped' }).Count $failingStep = @($step | Where-Object { $_.Status -eq 'Failed' }) # -- the header facts -------------------------------------------------- $runId = '' $sequenceId = '' $computerText = '' $outcome = 'Unknown' $phase = New-Object -TypeName System.Collections.ArrayList $legRecord = New-Object -TypeName System.Collections.ArrayList $variableRecord = New-Object -TypeName System.Collections.ArrayList $legEvent = @('run.start', 'phase.change', 'reboot.arm', 'reboot.resume', 'reboot.teardown', 'run.end') foreach ($item in $record) { if ([string]::IsNullOrEmpty($runId) -and $item.ContainsKey('runId')) { $runId = [string] $item['runId'] } if ($item.ContainsKey('phase') -and -not [string]::IsNullOrEmpty([string] $item['phase']) -and $phase -notcontains [string] $item['phase']) { [void] $phase.Add([string] $item['phase']) } $data = & $fieldMap $item['data'] $eventName = [string] $item['event'] if ($eventName -eq 'run.start' -and $data.ContainsKey('sequenceId')) { $sequenceId = [string] $data['sequenceId'] } if ($eventName -eq 'run.end' -and $data.ContainsKey('status')) { $outcome = [string] $data['status'] } if ($eventName -eq 'var.resolve') { [void] $variableRecord.Add($item) if ($data.ContainsKey('name') -and [string] $data['name'] -eq 'HDTComputerName' -and $data.ContainsKey('value')) { $computerText = [string] $data['value'] } } if ($legEvent -contains $eventName) { [void] $legRecord.Add($item) } } if ($outcome -eq 'Unknown' -and $null -ne $State -and $null -ne $State.PSObject.Properties['status']) { $outcome = [string] $State.status } if ($PSBoundParameters.ContainsKey('ComputerName')) { $computerText = $ComputerName } if ([string]::IsNullOrEmpty($computerText)) { $computerText = '(not resolved)' } $startText = '' $endText = '' $durationText = '' if ($record.Count -gt 0) { $startText = [string] $record[0]['ts'] $endText = [string] $record[$record.Count - 1]['ts'] $startStamp = & $parseStamp $startText $endStamp = & $parseStamp $endText if ($null -ne $startStamp -and $null -ne $endStamp) { $durationText = & $formatDuration ([long] ($endStamp - $startStamp).TotalMilliseconds) } } # -- render ------------------------------------------------------------ $html = New-Object -TypeName System.Collections.ArrayList $add = { param([string] $Line) [void] $html.Add($Line) } & $add '<!DOCTYPE html>' & $add '<html lang="en">' & $add '<head>' & $add '<meta charset="utf-8">' & $add ('<title>{0}</title>' -f (& $escape $Title)) & $add '<style>' & $add 'body { font-family: Segoe UI, Tahoma, sans-serif; margin: 1.5rem; color: #202020; background: #ffffff; }' & $add 'h1 { font-size: 1.5rem; } h2 { font-size: 1.1rem; margin-top: 1.8rem; border-bottom: 1px solid #d0d0d0; padding-bottom: 0.2rem; }' & $add 'table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; }' & $add 'th, td { border: 1px solid #d8d8d8; padding: 0.25rem 0.5rem; text-align: left; vertical-align: top; font-size: 0.85rem; }' & $add 'th { background: #f2f2f2; }' & $add 'td.msg { font-family: Consolas, monospace; white-space: pre-wrap; word-break: break-word; }' & $add '.status-completed { background: #e6f4ea; } .status-failed { background: #fce8e6; }' & $add '.status-skipped { background: #f5f5f5; color: #606060; } .status-pending { background: #fffbe6; }' & $add '.status-running { background: #e8f0fe; }' & $add '.level-error td { color: #9b1c1c; } .level-warning td { color: #8a6100; } .level-debug td { color: #606060; }' & $add '.warn { background: #fce8e6; padding: 0.4rem 0.6rem; border: 1px solid #f0b3ae; }' & $add '.count { font-size: 1.2rem; font-weight: 600; }' & $add '</style>' & $add '</head>' & $add '<body>' & $add ('<h1>{0}</h1>' -f (& $escape $Title)) if ($unparseable -gt 0) { & $add ('<p class="warn">{0} line(s) could not be parsed and are not shown below. A truncated final line is what a machine that died mid-write leaves behind.</p>' -f $unparseable) } # 1. Header. & $add '<h2>Run</h2>' & $add '<section id="header">' & $add '<table>' & $add ('<tr><th>Run id</th>{0}</tr>' -f (& $cell $runId)) & $add ('<tr><th>Sequence</th>{0}</tr>' -f (& $cell $sequenceId)) & $add ('<tr><th>Computer</th>{0}</tr>' -f (& $cell $computerText)) & $add ('<tr><th>Phases</th>{0}</tr>' -f (& $cell (@($phase) -join ', '))) & $add ('<tr><th>Started</th>{0}</tr>' -f (& $cell $startText)) & $add ('<tr><th>Ended</th>{0}</tr>' -f (& $cell $endText)) & $add ('<tr><th>Duration</th>{0}</tr>' -f (& $cell $durationText)) & $add ('<tr><th>Outcome</th><td class="status-{0}">{1}</td></tr>' -f $outcome.ToLowerInvariant(), (& $escape $outcome)) & $add '</table>' & $add '</section>' # 2. Summary. & $add '<h2>Summary</h2>' & $add '<section id="summary">' & $add '<table>' & $add '<tr><th>Completed</th><th>Failed</th><th>Skipped</th><th>Total</th></tr>' & $add ('<tr><td class="count status-completed">{0}</td><td class="count status-failed">{1}</td><td class="count status-skipped">{2}</td><td class="count">{3}</td></tr>' -f $completedCount, $failedCount, $skippedCount, @($step).Count) & $add '</table>' if ($failingStep.Count -gt 0) { & $add ('<p class="warn">Step {0} "{1}" ({2}) failed: {3}</p>' -f $failingStep[0].Index, (& $escape $failingStep[0].Name), (& $escape $failingStep[0].Type), (& $escape $failingStep[0].Message)) } & $add '</section>' # 3. Steps. & $add '<h2>Steps</h2>' & $add '<section id="steps">' & $add '<table>' & $add '<tr><th>#</th><th>Group</th><th>Name</th><th>Type</th><th>Status</th><th>Attempts</th><th>Duration</th><th>Exit code</th><th>Message</th></tr>' if (@($step).Count -eq 0) { & $add '<tr><td colspan="9">No steps were recorded.</td></tr>' } foreach ($row in $step) { & $add ('<tr class="step status-{0}">{1}{2}{3}{4}{5}{6}{7}{8}<td class="msg">{9}</td></tr>' -f ([string] $row.Status).ToLowerInvariant(), (& $cell $row.Index), (& $cell $row.Group), (& $cell $row.Name), (& $cell $row.Type), (& $cell $row.Status), (& $cell $row.Attempt), (& $cell (& $formatDuration $row.DurationMs)), (& $cell $row.ExitCode), (& $escape $row.Message)) } & $add '</table>' & $add '</section>' # 4. Legs - the multi-leg deployment as a timeline. & $add '<h2>Legs and reboots</h2>' & $add '<section id="legs">' & $add '<table>' & $add '<tr><th>Seq</th><th>Time</th><th>Phase</th><th>Event</th><th>Message</th></tr>' if ($legRecord.Count -eq 0) { & $add '<tr><td colspan="5">No run or reboot records.</td></tr>' } foreach ($item in $legRecord) { & $add ('<tr class="leg">{0}{1}{2}{3}<td class="msg">{4}</td></tr>' -f (& $cell $item['seq']), (& $cell $item['ts']), (& $cell $item['phase']), (& $cell $item['event']), (& $escape $item['message'])) } & $add '</table>' & $add '</section>' # 5. Variables - DESIGN 3.1's "explains every value", in the report. & $add '<h2>Variables</h2>' & $add '<section id="variables">' & $add '<table>' & $add '<tr><th>Seq</th><th>Name</th><th>Value</th><th>Source</th><th>Rule</th></tr>' if ($variableRecord.Count -eq 0) { & $add '<tr><td colspan="5">No variable resolutions were recorded.</td></tr>' } foreach ($item in $variableRecord) { $data = & $fieldMap $item['data'] $name = '' if ($data.ContainsKey('name')) { $name = $data['name'] } $value = '' if ($data.ContainsKey('value')) { $value = $data['value'] } $source = '' if ($data.ContainsKey('source')) { $source = $data['source'] } $rule = '' if ($data.ContainsKey('rule')) { $rule = $data['rule'] } if ([string]::IsNullOrEmpty([string] $rule) -and $data.ContainsKey('step')) { $rule = $data['step'] } & $add ('<tr class="var">{0}{1}{2}{3}{4}</tr>' -f (& $cell $item['seq']), (& $cell $name), (& $cell $value), (& $cell $source), (& $cell $rule)) } & $add '</table>' & $add '</section>' # 6. The log itself. & $add '<h2>Log</h2>' & $add '<section id="log">' & $add '<table>' & $add '<tr><th>Seq</th><th>Time</th><th>Level</th><th>Phase</th><th>Step</th><th>Component</th><th>Event</th><th>Message</th></tr>' if ($record.Count -eq 0) { & $add '<tr><td colspan="8">No log records were found in this stream.</td></tr>' } foreach ($item in $record) { $stepText = '' if ($item.ContainsKey('stepIndex') -and $null -ne $item['stepIndex']) { $stepText = '{0}. {1}' -f $item['stepIndex'], $item['stepName'] } & $add ('<tr class="log level-{0}">{1}{2}{3}{4}{5}{6}{7}<td class="msg">{8}</td></tr>' -f ([string] $item['level']).ToLowerInvariant(), (& $cell $item['seq']), (& $cell $item['ts']), (& $cell $item['level']), (& $cell $item['phase']), (& $cell $stepText), (& $cell $item['component']), (& $cell $item['event']), (& $escape $item['message'])) } & $add '</table>' & $add '</section>' $footer = 'Rendered by HDT from {0}' -f $JsonlPath if ($PSBoundParameters.ContainsKey('Timestamp')) { # $Timestamp, not $Timestamp.Value: the binder converts a bound # [System.Nullable[datetime]] to a plain [datetime], so .Value is a # property that does not exist - and under Set-StrictMode that throws. $footer = '{0} at {1}' -f $footer, $Timestamp.ToUniversalTime().ToString('u', [System.Globalization.CultureInfo]::InvariantCulture) } & $add ('<p class="footer">{0}</p>' -f (& $escape $footer)) & $add '</body>' & $add '</html>' $FileSystem.WriteAllText($Path, ((@($html) -join "`n") + "`n")) return $Path } |