Private/Html.ps1

#Requires -Version 5.1

function Get-SMBeatReportStrings {
    param(
        [ValidateSet('de', 'en')]
        [string]$Language
    )

    if ($Language -eq 'de') {
        return @{
            Title            = 'SMBeat Nutzungsbericht'
            Period           = 'Zeitraum'
            Coverage         = 'Abdeckung'
            Generated        = 'Erzeugt'
            Version          = 'SMBeat'
            Totals           = 'Volumen'
            Retrieved        = 'Abgerufen'
            RetrievedHint    = 'TCP 445, Server zum Client'
            Written          = 'Geschrieben'
            WrittenHint      = 'TCP 445, Client zum Server'
            Peak             = 'Spitze'
            PeakOut          = 'Spitze Abgerufen'
            PeakIn           = 'Spitze Geschrieben'
            AverageHour      = 'Schnitt'
            NicOut           = 'NIC Out'
            NicOutHint       = 'Kontrolle: alles was den Host verlaesst'
            NicIn            = 'NIC In'
            NicInHint        = 'Kontrolle: alles was ankommt'
            Previous         = 'Vorperiode'
            PreviousHint     = 'gleich langes Fenster davor'
            VersusPrevious   = 'gegenueber Vorperiode'
            ByServer         = 'Server'
            ByShare          = 'Freigaben'
            ByShareEmpty     = 'Keine Freigaben-Bytes in diesem Fenster. Offene Sitzungen vor Collector-Start erscheinen als (unknown) bis zum Neuverbinden.'
            ByClient         = 'Clients'
            ByClientHint     = 'Remote-IP auf TCP 445'
            ByUser           = 'Benutzer'
            ByUserEmpty      = 'Keine Benutzer-Bytes in diesem Fenster. Offene Sitzungen vor Collector-Start erscheinen als (unknown) bis zum Neuverbinden.'
            ByUserHint       = 'ETW Session, DOMAIN\Benutzer'
            Hourly           = 'Stunden'
            Daily            = 'Tage'
            Weekly           = 'Wochen'
            Warnings         = 'Hinweise'
            CoverageLow      = 'Niedrige Abdeckung: der Collector war nicht die volle Fensterlaenge aktiv. Nach einem frischen Start ist das normal.'
            Name             = 'Name'
            GiB              = 'GiB'
            Empty            = 'Keine Werte in diesem Fenster.'
            More             = 'und {0} weitere'
            Foot             = 'Abgerufen und Geschrieben sind TCP-Payload auf Port 445 (ohne Ethernet-Header). NIC Out/In sind die Leitung (inkl. RDP, Backup, Overhead). Freigaben und Benutzer kommen aus ETW und entsprechen nicht 1:1 dem 445-Volumen. Alte SMB-Zaehler werden nicht als Headline genutzt.'
            Company          = 'MERLIN Kommunikationstechnik'
            Utc              = 'UTC'
        }
    }

    return @{
        Title            = 'SMBeat usage report'
        Period           = 'Period'
        Coverage         = 'Coverage'
        Generated        = 'Generated'
        Version          = 'SMBeat'
        Totals           = 'Volume'
        Retrieved        = 'Retrieved'
        RetrievedHint    = 'TCP 445, server to client'
        Written          = 'Written'
        WrittenHint      = 'TCP 445, client to server'
        Peak             = 'Peak'
        PeakOut          = 'Peak retrieved'
        PeakIn           = 'Peak written'
        AverageHour      = 'Average'
        NicOut           = 'NIC Out'
        NicOutHint       = 'Control: all host egress'
        NicIn            = 'NIC In'
        NicInHint        = 'Control: all host ingress'
        Previous         = 'Previous period'
        PreviousHint     = 'window of equal length before this one'
        VersusPrevious   = 'vs previous period'
        ByServer         = 'Servers'
        ByShare          = 'Shares'
        ByShareEmpty     = 'No per-share bytes in this window. Sessions open before the collector started show as (unknown) until reconnect.'
        ByClient         = 'Clients'
        ByClientHint     = 'Remote IP on TCP 445'
        ByUser           = 'Users'
        ByUserEmpty      = 'No per-user bytes in this window. Sessions open before the collector started show as (unknown) until reconnect.'
        ByUserHint       = 'ETW session, DOMAIN\user'
        Hourly           = 'Hours'
        Daily            = 'Days'
        Weekly           = 'Weeks'
        Warnings         = 'Notes'
        CoverageLow      = 'Low coverage: the collector was not running for the full window. That is normal after a fresh start.'
        Name             = 'Name'
        GiB              = 'GiB'
        Empty            = 'No values in this window.'
        More             = 'and {0} more'
        Foot             = 'Retrieved and Written are TCP payload on port 445 (no Ethernet headers). NIC Out/In are the wire (including RDP, backup, overhead). Shares and users come from ETW and will not match 445 one-to-one. Legacy SMB counters are not the headline.'
        Company          = 'MERLIN Kommunikationstechnik'
        Utc              = 'UTC'
    }
}

function ConvertTo-SMBeatHtmlEncode {
    param(
        [string]$Text
    )

    if ($null -eq $Text) {
        return ''
    }

    [System.Net.WebUtility]::HtmlEncode($Text)
}

function Format-SMBeatHtmlGiB {
    param(
        $Value
    )

    $n = 0.0
    if ($null -ne $Value) {
        $n = [double]$Value
    }

    $n.ToString('0.000', $script:SMBeatInvariant)
}

function Format-SMBeatHtmlWhen {
    param(
        $Value,
        [TimeZoneInfo]$TimeZone,
        [ValidateSet('de', 'en')]
        [string]$Language = 'en'
    )

    if ($null -eq $Value) {
        return ''
    }

    if (-not ($Value -is [datetime])) {
        return [string]$Value
    }

    $dt = $Value
    if ($null -eq $TimeZone) {
        $TimeZone = [TimeZoneInfo]::Local
    }

    if ($dt.Kind -eq [DateTimeKind]::Utc) {
        $dt = Convert-SMBeatUtcToTimeZone -Utc $dt -TimeZone $TimeZone
    }
    elseif ($dt.Kind -eq [DateTimeKind]::Local) {
        $dt = Convert-SMBeatUtcToTimeZone -Utc $dt.ToUniversalTime() -TimeZone $TimeZone
    }

    $pattern = 'yyyy-MM-dd HH:mm'
    if ($Language -eq 'de') {
        $pattern = 'dd.MM.yyyy HH:mm'
    }

    $dt.ToString($pattern, $script:SMBeatInvariant)
}

function Get-SMBeatHtmlBarPct {
    param(
        [double]$Value,
        [double]$Max,
        [double]$Floor = 0
    )

    if ($Max -le 0) {
        return '0'
    }

    $p = [math]::Round(($Value / $Max) * 100.0, 1)
    if ($p -lt 0) {
        $p = 0
    }
    if ($p -gt 100) {
        $p = 100
    }
    if ($Value -gt 0 -and $Floor -gt 0 -and $p -lt $Floor) {
        $p = $Floor
    }

    $p.ToString('0.#', $script:SMBeatInvariant)
}

function Select-SMBeatHtmlActiveRows {
    param(
        [object[]]$Rows
    )

    if ($null -eq $Rows) {
        return @()
    }

    @($Rows | Where-Object {
        if (-not $_) { return $false }
        $sent = [int64]0
        $recv = [int64]0
        $write = [int64]0
        if ($_.SentBytes) { $sent = [int64]$_.SentBytes }
        if ($_.ReceivedBytes) { $recv = [int64]$_.ReceivedBytes }
        if ($_.WriteBytes) { $write = [int64]$_.WriteBytes }
        ($sent -gt 0 -or $recv -gt 0 -or $write -gt 0)
    })
}

function Get-SMBeatHtmlRowGiB {
    param(
        $Row,
        [string]$Primary,
        [string]$Fallback
    )

    if ($null -eq $Row) {
        return 0.0
    }
    if ($Row.PSObject.Properties[$Primary] -and $null -ne $Row.$Primary) {
        return [double]$Row.$Primary
    }
    if ($Fallback -and $Row.PSObject.Properties[$Fallback] -and $null -ne $Row.$Fallback) {
        return [double]$Row.$Fallback
    }
    0.0
}

function ConvertTo-SMBeatHtmlDelta {
    param(
        $Current,
        $Previous,
        [string]$VersusText
    )

    if ($null -eq $Previous) {
        return ''
    }

    $prevGiB = [double]$Previous
    $curGiB = 0.0
    if ($null -ne $Current) {
        $curGiB = [double]$Current
    }
    if ($prevGiB -le 0) {
        return ''
    }

    $chg = (($curGiB - $prevGiB) / $prevGiB) * 100.0
    $cls = 'up'
    if ($chg -lt 0) {
        $cls = 'down'
    }
    elseif ($chg -eq 0) {
        $cls = 'flat'
    }
    $chgText = $chg.ToString('+0.0;-0.0;0.0', $script:SMBeatInvariant)
    '<div class="delta {0}">{1}% {2}</div>' -f `
        $cls, `
        (ConvertTo-SMBeatHtmlEncode -Text $chgText), `
        (ConvertTo-SMBeatHtmlEncode -Text $VersusText)
}

function ConvertTo-SMBeatHtmlCard {
    param(
        [string]$Label,
        [string]$Hint,
        [string]$Value,
        [string]$CssClass,
        [string]$Extra
    )

    if ([string]::IsNullOrWhiteSpace($CssClass)) {
        $CssClass = 'card'
    }

    $hintHtml = ''
    if (-not [string]::IsNullOrWhiteSpace($Hint)) {
        $hintHtml = '<div class="hint">{0}</div>' -f (ConvertTo-SMBeatHtmlEncode -Text $Hint)
    }

    $extraHtml = ''
    if (-not [string]::IsNullOrWhiteSpace($Extra)) {
        $extraHtml = $Extra
    }

    '<div class="{0}"><div class="label">{1}</div><div class="value">{2} <span class="unit">GiB</span></div>{3}{4}</div>' -f `
        $CssClass, `
        (ConvertTo-SMBeatHtmlEncode -Text $Label), `
        (ConvertTo-SMBeatHtmlEncode -Text $Value), `
        $extraHtml, `
        $hintHtml
}

function ConvertTo-SMBeatHtmlTable {
    param(
        [object[]]$Rows,
        [string]$NameHeader,
        [string]$OutHeader,
        [string]$InHeader,
        [string]$EmptyText,
        [string]$MoreText,
        [int]$Top = 15
    )

    $active = @(Select-SMBeatHtmlActiveRows -Rows $Rows)
    if ($active.Count -eq 0) {
        return ('<p class="empty">{0}</p>' -f (ConvertTo-SMBeatHtmlEncode -Text $EmptyText))
    }

    $max = 0.0
    foreach ($row in $active) {
        $outGiB = Get-SMBeatHtmlRowGiB -Row $row -Primary 'SentGiB' -Fallback ''
        $inGiB = Get-SMBeatHtmlRowGiB -Row $row -Primary 'ReceivedGiB' -Fallback ''
        if ($outGiB -gt $max) { $max = $outGiB }
        if ($inGiB -gt $max) { $max = $inGiB }
    }

    $sb = New-Object System.Text.StringBuilder
    [void]$sb.AppendLine('<table class="rank"><thead><tr>')
    [void]$sb.AppendLine(('<th class="rank">#</th><th>{0}</th><th class="num">{1}</th><th class="num">{2}</th></tr></thead><tbody>' -f `
        (ConvertTo-SMBeatHtmlEncode -Text $NameHeader), `
        (ConvertTo-SMBeatHtmlEncode -Text $OutHeader), `
        (ConvertTo-SMBeatHtmlEncode -Text $InHeader)))
    $i = 0
    foreach ($row in $active) {
        if ($i -ge $Top) { break }
        $i++
        $name = [string]$row.Name
        $nameClass = 'who'
        if ($name -eq '(unknown)') {
            $nameClass = 'who dim'
        }
        $outGiB = Get-SMBeatHtmlRowGiB -Row $row -Primary 'SentGiB' -Fallback ''
        $inGiB = Get-SMBeatHtmlRowGiB -Row $row -Primary 'ReceivedGiB' -Fallback ''
        $outPct = Get-SMBeatHtmlBarPct -Value $outGiB -Max $max -Floor 2
        $inPct = Get-SMBeatHtmlBarPct -Value $inGiB -Max $max -Floor 2
        [void]$sb.AppendLine(('<tr><td class="rank">{0}</td><td><div class="{1}">{2}</div></td><td class="num"><div>{3}</div><div class="bar out"><i style="width:{4}%"></i></div></td><td class="num"><div>{5}</div><div class="bar in"><i style="width:{6}%"></i></div></td></tr>' -f `
            $i, `
            $nameClass, `
            (ConvertTo-SMBeatHtmlEncode -Text $name), `
            (Format-SMBeatHtmlGiB -Value $outGiB), `
            $outPct, `
            (Format-SMBeatHtmlGiB -Value $inGiB), `
            $inPct))
    }
    [void]$sb.AppendLine('</tbody></table>')
    if ($active.Count -gt $Top -and -not [string]::IsNullOrWhiteSpace($MoreText)) {
        $more = $active.Count - $Top
        [void]$sb.AppendLine(('<p class="more">{0}</p>' -f (ConvertTo-SMBeatHtmlEncode -Text ($MoreText -f $more))))
    }
    $sb.ToString()
}

function ConvertTo-SMBeatHtmlSpark {
    param(
        [object[]]$Rows,
        [TimeZoneInfo]$TimeZone,
        [ValidateSet('de', 'en')]
        [string]$Language = 'en'
    )

    if ($null -eq $Rows -or $Rows.Count -lt 2) {
        return ''
    }

    $max = 0.0
    foreach ($row in $Rows) {
        $outGiB = Get-SMBeatHtmlRowGiB -Row $row -Primary 'RetrievedGiB' -Fallback ''
        $inGiB = Get-SMBeatHtmlRowGiB -Row $row -Primary 'WrittenGiB' -Fallback 'ReceivedGiB'
        if ($outGiB -gt $max) { $max = $outGiB }
        if ($inGiB -gt $max) { $max = $inGiB }
    }

    $sb = New-Object System.Text.StringBuilder
    [void]$sb.Append('<div class="spark dual" aria-hidden="true">')
    foreach ($row in $Rows) {
        $outGiB = Get-SMBeatHtmlRowGiB -Row $row -Primary 'RetrievedGiB' -Fallback ''
        $inGiB = Get-SMBeatHtmlRowGiB -Row $row -Primary 'WrittenGiB' -Fallback 'ReceivedGiB'
        $outH = Get-SMBeatHtmlBarPct -Value $outGiB -Max $max -Floor 3
        $inH = Get-SMBeatHtmlBarPct -Value $inGiB -Max $max -Floor 3
        $title = '{0} Out {1} GiB In {2} GiB' -f `
            (Format-SMBeatHtmlWhen -Value $row.PeriodStart -TimeZone $TimeZone -Language $Language), `
            (Format-SMBeatHtmlGiB -Value $outGiB), `
            (Format-SMBeatHtmlGiB -Value $inGiB)
        [void]$sb.Append(('<span title="{0}"><i class="out" style="height:{1}%"></i><i class="in" style="height:{2}%"></i></span>' -f `
            (ConvertTo-SMBeatHtmlEncode -Text $title), `
            $outH, `
            $inH))
    }
    [void]$sb.Append('</div>')
    $sb.ToString()
}

function ConvertTo-SMBeatHtmlSeries {
    param(
        [object[]]$Rows,
        [string]$RetrievedHeader,
        [string]$WrittenHeader,
        [string]$NicOutHeader,
        [string]$NicInHeader,
        [string]$EmptyText,
        [TimeZoneInfo]$TimeZone,
        [ValidateSet('de', 'en')]
        [string]$Language = 'en'
    )

    if ($null -eq $Rows -or $Rows.Count -eq 0) {
        return ('<p class="empty">{0}</p>' -f (ConvertTo-SMBeatHtmlEncode -Text $EmptyText))
    }

    $maxOut = 0.0
    $maxIn = 0.0
    foreach ($row in $Rows) {
        $outGiB = Get-SMBeatHtmlRowGiB -Row $row -Primary 'RetrievedGiB' -Fallback ''
        $inGiB = Get-SMBeatHtmlRowGiB -Row $row -Primary 'WrittenGiB' -Fallback 'ReceivedGiB'
        if ($outGiB -gt $maxOut) { $maxOut = $outGiB }
        if ($inGiB -gt $maxIn) { $maxIn = $inGiB }
    }
    $max = $maxOut
    if ($maxIn -gt $max) { $max = $maxIn }

    $sb = New-Object System.Text.StringBuilder
    $spark = ConvertTo-SMBeatHtmlSpark -Rows $Rows -TimeZone $TimeZone -Language $Language
    if (-not [string]::IsNullOrWhiteSpace($spark)) {
        [void]$sb.AppendLine($spark)
    }

    $scroll = ''
    if ($Rows.Count -gt 14) {
        $scroll = ' scroll'
    }

    [void]$sb.AppendLine(('<div class="table-wrap{0}"><table class="series"><thead><tr><th>Start</th><th class="num">{1}</th><th class="num">{2}</th><th class="num">{3}</th><th class="num">{4}</th></tr></thead><tbody>' -f `
        $scroll, `
        (ConvertTo-SMBeatHtmlEncode -Text $RetrievedHeader), `
        (ConvertTo-SMBeatHtmlEncode -Text $WrittenHeader), `
        (ConvertTo-SMBeatHtmlEncode -Text $NicOutHeader), `
        (ConvertTo-SMBeatHtmlEncode -Text $NicInHeader)))
    foreach ($row in $Rows) {
        $outGiB = Get-SMBeatHtmlRowGiB -Row $row -Primary 'RetrievedGiB' -Fallback ''
        $inGiB = Get-SMBeatHtmlRowGiB -Row $row -Primary 'WrittenGiB' -Fallback 'ReceivedGiB'
        $outPct = Get-SMBeatHtmlBarPct -Value $outGiB -Max $max -Floor 2
        $inPct = Get-SMBeatHtmlBarPct -Value $inGiB -Max $max -Floor 2
        $hot = ''
        if (($maxOut -gt 0 -and $outGiB -ge $maxOut) -or ($maxIn -gt 0 -and $inGiB -ge $maxIn)) {
            $hot = ' class="hot"'
        }
        [void]$sb.AppendLine(('<tr{0}><td>{1}<div class="duo"><div class="bar slim out"><i style="width:{2}%"></i></div><div class="bar slim in"><i style="width:{3}%"></i></div></div></td><td class="num">{4}</td><td class="num">{5}</td><td class="num mute">{6}</td><td class="num mute">{7}</td></tr>' -f `
            $hot, `
            (ConvertTo-SMBeatHtmlEncode -Text (Format-SMBeatHtmlWhen -Value $row.PeriodStart -TimeZone $TimeZone -Language $Language)), `
            $outPct, `
            $inPct, `
            (Format-SMBeatHtmlGiB -Value $outGiB), `
            (Format-SMBeatHtmlGiB -Value $inGiB), `
            (Format-SMBeatHtmlGiB -Value $row.NicOutGiB), `
            (Format-SMBeatHtmlGiB -Value $row.NicInGiB)))
    }
    [void]$sb.AppendLine('</tbody></table></div>')
    $sb.ToString()
}

function ConvertTo-SMBeatHtmlPanel {
    param(
        [string]$Title,
        [string]$Hint,
        [string]$Body
    )

    $hintHtml = ''
    if (-not [string]::IsNullOrWhiteSpace($Hint)) {
        $hintHtml = '<p class="lede">{0}</p>' -f (ConvertTo-SMBeatHtmlEncode -Text $Hint)
    }

    '<section class="panel"><div class="panel-head"><h2>{0}</h2></div>{1}{2}</section>' -f `
        (ConvertTo-SMBeatHtmlEncode -Text $Title), `
        $hintHtml, `
        $Body
}

function ConvertTo-SMBeatHtmlReport {
    param(
        [Parameter(Mandatory = $true)]
        $Report,
        [ValidateSet('de', 'en')]
        [string]$Language
    )

    $t = Get-SMBeatReportStrings -Language $Language
    $version = Get-SMBeatModuleVersion
    if ($Report.PSObject.Properties['ModuleVersion'] -and $Report.ModuleVersion) {
        $version = [string]$Report.ModuleVersion
    }

    $generated = Get-SMBeatUtcNow
    if ($Report.PSObject.Properties['GeneratedUtc'] -and $Report.GeneratedUtc) {
        $generated = $Report.GeneratedUtc
    }
    $tz = Resolve-SMBeatTimeZone -TimeZone $Report.TimeZone
    $generatedText = Format-SMBeatHtmlWhen -Value $generated -TimeZone $tz -Language $Language
    $periodStart = Format-SMBeatHtmlWhen -Value $Report.StartUtc -TimeZone $tz -Language $Language
    $periodEnd = Format-SMBeatHtmlWhen -Value $Report.EndUtc -TimeZone $tz -Language $Language
    $tzLabel = $tz.Id

    $totals = $Report.Totals
    $retrievedText = Format-SMBeatHtmlGiB -Value $totals.RetrievedGiB
    $writtenText = Format-SMBeatHtmlGiB -Value 0
    if ($totals.PSObject.Properties['WrittenGiB']) {
        $writtenText = Format-SMBeatHtmlGiB -Value $totals.WrittenGiB
    }
    $nicOutText = Format-SMBeatHtmlGiB -Value $totals.NicOutGiB
    $nicInText = Format-SMBeatHtmlGiB -Value $totals.NicInGiB

    $peakOutLabel = $t.Peak
    if ($t.ContainsKey('PeakOut')) {
        $peakOutLabel = $t.PeakOut
    }
    $peakInLabel = $t.Peak
    if ($t.ContainsKey('PeakIn')) {
        $peakInLabel = $t.PeakIn
    }

    $peakHtml = ''
    if ($Report.PSObject.Properties['PeakStart'] -and $Report.PeakStart) {
        $peakWhen = Format-SMBeatHtmlWhen -Value $Report.PeakStart -TimeZone $tz -Language $Language
        $peakGiB = Format-SMBeatHtmlGiB -Value $Report.PeakRetrievedGiB
        $avgGiB = Format-SMBeatHtmlGiB -Value $Report.AverageRetrievedGiBPerHour
        $peakHint = '{0} / {1} {2} GiB/h' -f $peakWhen, $t.AverageHour, $avgGiB
        $peakHtml = ConvertTo-SMBeatHtmlCard -Label $peakOutLabel -Hint $peakHint -Value $peakGiB -CssClass 'card peak'
    }

    $peakInHtml = ''
    if ($Report.PSObject.Properties['PeakWrittenStart'] -and $Report.PeakWrittenStart) {
        $peakInWhen = Format-SMBeatHtmlWhen -Value $Report.PeakWrittenStart -TimeZone $tz -Language $Language
        $peakInGiB = Format-SMBeatHtmlGiB -Value $Report.PeakWrittenGiB
        $avgInGiB = Format-SMBeatHtmlGiB -Value $Report.AverageWrittenGiBPerHour
        $peakInHint = '{0} / {1} {2} GiB/h' -f $peakInWhen, $t.AverageHour, $avgInGiB
        $peakInHtml = ConvertTo-SMBeatHtmlCard -Label $peakInLabel -Hint $peakInHint -Value $peakInGiB -CssClass 'card peak in'
    }

    $deltaOutHtml = ''
    $deltaInHtml = ''
    if ($Report.PreviousTotals) {
        $prevOut = $Report.PreviousTotals.RetrievedGiB
        $prevIn = $null
        if ($Report.PreviousTotals.PSObject.Properties['WrittenGiB']) {
            $prevIn = $Report.PreviousTotals.WrittenGiB
        }
        elseif ($Report.PreviousTotals.PSObject.Properties['ReceivedGiB']) {
            $prevIn = $Report.PreviousTotals.ReceivedGiB
        }
        $writtenGiB = 0.0
        if ($totals.PSObject.Properties['WrittenGiB'] -and $null -ne $totals.WrittenGiB) {
            $writtenGiB = [double]$totals.WrittenGiB
        }
        $deltaOutHtml = ConvertTo-SMBeatHtmlDelta -Current $totals.RetrievedGiB -Previous $prevOut -VersusText $t.VersusPrevious
        $deltaInHtml = ConvertTo-SMBeatHtmlDelta -Current $writtenGiB -Previous $prevIn -VersusText $t.VersusPrevious
    }

    $notes = New-Object System.Collections.Generic.List[string]
    $coverage = 0.0
    if ($null -ne $Report.CoveragePct) {
        $coverage = [double]$Report.CoveragePct
    }
    if ($coverage -lt 90) {
        $notes.Add($t.CoverageLow) | Out-Null
    }
    foreach ($w in @($Report.Warnings)) {
        if ($w -and ([string]$w -notlike 'Coverage *')) {
            $notes.Add([string]$w) | Out-Null
        }
    }

    $warnHtml = ''
    if ($notes.Count -gt 0) {
        $items = ($notes | ForEach-Object { '<li>' + (ConvertTo-SMBeatHtmlEncode -Text $_) + '</li>' }) -join ''
        $warnHtml = '<section class="notes-banner"><h2>{0}</h2><ul>{1}</ul></section>' -f (ConvertTo-SMBeatHtmlEncode -Text $t.Warnings), $items
    }

    $covClass = 'ok'
    if ($coverage -lt 90) {
        $covClass = 'warn'
    }
    $coverageText = $coverage.ToString('0.#', $script:SMBeatInvariant)

    $more = $t.More
    $userBody = ConvertTo-SMBeatHtmlTable -Rows $Report.ByUser -NameHeader $t.Name -OutHeader $t.Retrieved -InHeader $t.Written -EmptyText $t.ByUserEmpty -MoreText $more
    $clientBody = ConvertTo-SMBeatHtmlTable -Rows $Report.ByClient -NameHeader $t.Name -OutHeader $t.Retrieved -InHeader $t.Written -EmptyText $t.Empty -MoreText $more
    $serverBody = ConvertTo-SMBeatHtmlTable -Rows $Report.ByServer -NameHeader $t.Name -OutHeader $t.Retrieved -InHeader $t.Written -EmptyText $t.Empty -MoreText $more
    $shareBody = ConvertTo-SMBeatHtmlTable -Rows $Report.ByShare -NameHeader $t.Name -OutHeader $t.Retrieved -InHeader $t.Written -EmptyText $t.ByShareEmpty -MoreText $more

    @"
<!DOCTYPE html>
<html lang="$Language">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>$(ConvertTo-SMBeatHtmlEncode -Text $t.Title) $version</title>
  <style>
    :root {
      --ink: #122018;
      --paper: #efe8d8;
      --panel: #fffaf0;
      --line: #d8cbb3;
      --mute: #5d685f;
      --forest: #163226;
      --leaf: #2a6b45;
      --brass: #c4a35a;
      --warn: #8a4310;
      --warn-bg: #f4e0c8;
    }
    * { box-sizing: border-box; }
    html { font-size: 16px; }
    body {
      margin: 0;
      color: var(--ink);
      background:
        radial-gradient(1200px 420px at 8% -10%, rgba(196,163,90,0.18), transparent 55%),
        radial-gradient(900px 380px at 110% 8%, rgba(42,107,69,0.12), transparent 50%),
        var(--paper);
      font-family: "Segoe UI Variable", "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif;
      line-height: 1.45;
    }
    header {
      background:
        linear-gradient(115deg, #0e1a14 0%, #163226 58%, #1d3d2c 100%);
      color: #f6f0e4;
      padding: 1.6rem 2rem 1.35rem;
      border-bottom: 3px solid var(--brass);
    }
    .brand {
      display: flex;
      align-items: flex-start;
      gap: 1rem;
      max-width: 72rem;
      margin: 0 auto;
    }
    .mark {
      display: flex;
      align-items: flex-end;
      gap: 3px;
      width: 2.1rem;
      height: 2.1rem;
      padding: 0.28rem 0.32rem 0.24rem;
      border: 1.5px solid var(--brass);
      border-radius: 0.45rem;
      flex: 0 0 auto;
      margin-top: 0.15rem;
    }
    .mark span {
      display: block;
      flex: 1;
      background: var(--brass);
      border-radius: 1px;
    }
    .mark span:nth-child(1) { height: 42%; opacity: 0.55; }
    .mark span:nth-child(2) { height: 72%; opacity: 0.8; }
    .mark span:nth-child(3) { height: 100%; }
    .eyebrow {
      font-size: 0.72rem;
      letter-spacing: 0.08em;
      color: #d7c38a;
      margin: 0 0 0.2rem;
    }
    header h1 {
      margin: 0;
      font-size: 1.55rem;
      font-weight: 600;
      letter-spacing: -0.02em;
    }
    .ver {
      margin-left: auto;
      font-size: 0.78rem;
      color: #c9d4cb;
      padding: 0.28rem 0.6rem;
      border: 1px solid rgba(196,163,90,0.35);
      border-radius: 999px;
      white-space: nowrap;
    }
    .chips {
      display: flex;
      flex-wrap: wrap;
      gap: 0.45rem;
      max-width: 72rem;
      margin: 1rem auto 0;
    }
    .chip {
      display: inline-flex;
      align-items: baseline;
      gap: 0.4rem;
      background: rgba(255,250,240,0.08);
      border: 1px solid rgba(246,240,228,0.14);
      color: #e8e2d4;
      border-radius: 999px;
      padding: 0.28rem 0.75rem;
      font-size: 0.8rem;
    }
    .chip strong { color: #d7c38a; font-weight: 600; }
    .chip.ok { background: rgba(80,160,110,0.18); border-color: rgba(140,210,160,0.28); }
    .chip.warn { background: rgba(196,120,50,0.22); border-color: rgba(232,176,110,0.35); color: #f3d7b0; }
    main { padding: 1.4rem 2rem 3.2rem; max-width: 72rem; margin: 0 auto; }
    h2 {
      font-size: 0.78rem;
      margin: 0;
      letter-spacing: 0.12em;
      text-transform: uppercase;
      color: #3d4a40;
    }
    .notes-banner {
      background: var(--warn-bg);
      border: 1px solid #e0c49a;
      border-left: 4px solid var(--warn);
      border-radius: 0.7rem;
      padding: 0.85rem 1rem;
      margin: 0 0 1.1rem;
    }
    .notes-banner h2 { color: var(--warn); margin-bottom: 0.35rem; }
    .notes-banner ul { margin: 0; padding-left: 1.15rem; }
    .cards {
      display: flex;
      flex-wrap: wrap;
      gap: 0.75rem;
    }
    .card {
      flex: 1 1 11.5rem;
      background: var(--panel);
      border: 1px solid var(--line);
      border-radius: 0.85rem;
      padding: 0.95rem 1.05rem 0.9rem;
      box-shadow: 0 1px 0 rgba(18,32,24,0.04);
    }
    .card.hero {
      flex: 2 1 16rem;
      background: linear-gradient(160deg, #163226 0%, #10241b 100%);
      border-color: #1f3d2d;
      color: #f6f0e4;
      box-shadow: 0 10px 28px rgba(16,36,27,0.18);
    }
    .card.hero.in {
      background: linear-gradient(160deg, #3a2a14 0%, #241910 100%);
      border-color: #4a3820;
      box-shadow: 0 10px 28px rgba(36,25,16,0.18);
    }
    .card.peak {
      border-color: #2a6b45;
      background: linear-gradient(180deg, #e8f3ea 0%, #fffaf0 70%);
    }
    .card.peak.in {
      border-color: #c4a35a;
      background: linear-gradient(180deg, #fff6e2 0%, #fffaf0 70%);
    }
    .card .label {
      font-size: 0.7rem;
      letter-spacing: 0.1em;
      text-transform: uppercase;
      color: var(--mute);
      font-weight: 600;
    }
    .card.hero .label { color: #d7c38a; }
    .card.hero.in .label { color: #e8c98a; }
    .card .value {
      font-size: 2.05rem;
      font-weight: 700;
      letter-spacing: -0.03em;
      margin-top: 0.15rem;
      font-variant-numeric: tabular-nums;
      line-height: 1.1;
    }
    .card.hero .value { color: #fff8ea; }
    .card .unit {
      font-size: 0.82rem;
      font-weight: 600;
      color: var(--mute);
      letter-spacing: 0.04em;
    }
    .card.hero .unit { color: #d7c38a; }
    .card.hero.in .unit { color: #e8c98a; }
    .card .hint { margin-top: 0.4rem; font-size: 0.78rem; color: var(--mute); }
    .card.hero .hint { color: #c9d4cb; }
    .card.hero.in .hint { color: #e4d4b4; }
    .delta {
      margin-top: 0.4rem;
      font-size: 0.78rem;
      font-weight: 600;
    }
    .delta.up { color: #8ee0a8; }
    .delta.down { color: #f0b4a0; }
    .delta.flat { color: #c9d4cb; }
    .pair {
      display: flex;
      flex-wrap: wrap;
      gap: 0.9rem;
      margin-top: 0.9rem;
    }
    .pair > .panel { flex: 1 1 22rem; min-width: 0; margin-top: 0; }
    .panel {
      background: var(--panel);
      border: 1px solid var(--line);
      border-radius: 0.95rem;
      padding: 0.95rem 1.05rem 1rem;
      margin-top: 0.9rem;
      box-shadow: 0 1px 0 rgba(18,32,24,0.04);
    }
    .panel-head { margin-bottom: 0.55rem; }
    .lede, .empty, .more {
      margin: 0 0 0.65rem;
      font-size: 0.8rem;
      color: var(--mute);
    }
    .empty {
      background: #f3ece0;
      border-radius: 0.55rem;
      padding: 0.7rem 0.8rem;
    }
    table { border-collapse: collapse; width: 100%; }
    th, td {
      padding: 0.42rem 0.15rem 0.46rem;
      text-align: left;
      border-bottom: 1px solid #eee4d2;
      vertical-align: top;
    }
    th {
      font-size: 0.7rem;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: var(--mute);
      font-weight: 600;
    }
    td.num, th.num {
      text-align: right;
      font-variant-numeric: tabular-nums;
      white-space: nowrap;
      padding-left: 0.8rem;
    }
    td.rank, th.rank {
      width: 1.6rem;
      color: var(--mute);
      font-variant-numeric: tabular-nums;
      font-size: 0.78rem;
      padding-right: 0.55rem;
    }
    .who { font-weight: 600; font-size: 0.92rem; }
    .who.dim { color: var(--mute); font-style: italic; font-weight: 500; }
    .bar {
      height: 5px;
      background: #efe4ce;
      border-radius: 99px;
      margin-top: 0.28rem;
      overflow: hidden;
    }
    .bar i {
      display: block;
      height: 100%;
      background: #2a6b45;
      border-radius: 99px;
    }
    .bar.in i { background: #c4a35a; }
    .bar.slim { height: 3px; margin-top: 0.22rem; }
    .duo { margin-top: 0.32rem; }
    tr.hot td { background: #fff4d8; }
    td.mute { color: var(--mute); }
    .table-wrap.scroll {
      max-height: 26rem;
      overflow: auto;
      border-radius: 0.4rem;
    }
    .table-wrap.scroll thead th {
      position: sticky;
      top: 0;
      background: var(--panel);
      box-shadow: 0 1px 0 #eee4d2;
    }
    .spark {
      display: flex;
      align-items: flex-end;
      gap: 2px;
      height: 4.4rem;
      margin: 0.15rem 0 0.85rem;
      padding: 0.2rem 0 0;
    }
    .spark span {
      flex: 1 1 0;
      min-width: 4px;
      height: 100%;
      display: flex;
      align-items: flex-end;
      gap: 1px;
    }
    .spark i {
      display: block;
      flex: 1 1 0;
      border-radius: 1px 1px 0 0;
      min-height: 1px;
    }
    .spark i.out { background: #2a6b45; }
    .spark i.in { background: #c4a35a; }
    footer {
      margin-top: 1.4rem;
      padding-top: 0.9rem;
      border-top: 1px solid var(--line);
      font-size: 0.78rem;
      color: var(--mute);
      max-width: 48rem;
    }
    @media (max-width: 720px) {
      header, main { padding-left: 1rem; padding-right: 1rem; }
      .card .value { font-size: 1.65rem; }
      .ver { display: none; }
    }
    @media print {
      body { background: #fff; }
      header { border-bottom-width: 2px; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
      .card, .panel { box-shadow: none; break-inside: avoid; }
      .table-wrap.scroll { max-height: none; overflow: visible; }
    }
  </style>
</head>
<body>
  <header>
    <div class="brand">
      <div class="mark" aria-hidden="true"><span></span><span></span><span></span></div>
      <div>
        <div class="eyebrow">$(ConvertTo-SMBeatHtmlEncode -Text $t.Company)</div>
        <h1>$(ConvertTo-SMBeatHtmlEncode -Text $t.Title)</h1>
      </div>
      <div class="ver">$(ConvertTo-SMBeatHtmlEncode -Text $t.Version) $(ConvertTo-SMBeatHtmlEncode -Text $version)</div>
    </div>
    <div class="chips">
      <span class="chip"><strong>$(ConvertTo-SMBeatHtmlEncode -Text $t.Period):</strong> $(ConvertTo-SMBeatHtmlEncode -Text $periodStart) - $(ConvertTo-SMBeatHtmlEncode -Text $periodEnd) ($(ConvertTo-SMBeatHtmlEncode -Text $tzLabel))</span>
      <span class="chip $covClass"><strong>$(ConvertTo-SMBeatHtmlEncode -Text $t.Coverage):</strong> $coverageText%</span>
      <span class="chip"><strong>$(ConvertTo-SMBeatHtmlEncode -Text $t.Generated):</strong> $generatedText</span>
    </div>
  </header>
  <main>
    $warnHtml
    $(ConvertTo-SMBeatHtmlPanel -Title $t.Totals -Hint '' -Body ('<div class="cards">{0}{1}{2}{3}{4}{5}</div>' -f `
      (ConvertTo-SMBeatHtmlCard -Label $t.Retrieved -Hint $t.RetrievedHint -Value $retrievedText -CssClass 'card hero' -Extra $deltaOutHtml), `
      (ConvertTo-SMBeatHtmlCard -Label $t.Written -Hint $t.WrittenHint -Value $writtenText -CssClass 'card hero in' -Extra $deltaInHtml), `
      $peakHtml, `
      $peakInHtml, `
      (ConvertTo-SMBeatHtmlCard -Label $t.NicOut -Hint $t.NicOutHint -Value $nicOutText -CssClass 'card'), `
      (ConvertTo-SMBeatHtmlCard -Label $t.NicIn -Hint $t.NicInHint -Value $nicInText -CssClass 'card')))
    <div class="pair">
      $(ConvertTo-SMBeatHtmlPanel -Title $t.ByClient -Hint $t.ByClientHint -Body $clientBody)
      $(ConvertTo-SMBeatHtmlPanel -Title $t.ByUser -Hint $t.ByUserHint -Body $userBody)
    </div>
    <div class="pair">
      $(ConvertTo-SMBeatHtmlPanel -Title $t.ByShare -Hint '' -Body $shareBody)
      $(ConvertTo-SMBeatHtmlPanel -Title $t.ByServer -Hint '' -Body $serverBody)
    </div>
    $(ConvertTo-SMBeatHtmlPanel -Title $t.Hourly -Hint '' -Body (ConvertTo-SMBeatHtmlSeries -Rows $Report.Hourly -RetrievedHeader $t.Retrieved -WrittenHeader $t.Written -NicOutHeader $t.NicOut -NicInHeader $t.NicIn -EmptyText $t.Empty -TimeZone $tz -Language $Language))
    $(ConvertTo-SMBeatHtmlPanel -Title $t.Daily -Hint '' -Body (ConvertTo-SMBeatHtmlSeries -Rows $Report.Daily -RetrievedHeader $t.Retrieved -WrittenHeader $t.Written -NicOutHeader $t.NicOut -NicInHeader $t.NicIn -EmptyText $t.Empty -TimeZone $tz -Language $Language))
    $(ConvertTo-SMBeatHtmlPanel -Title $t.Weekly -Hint '' -Body (ConvertTo-SMBeatHtmlSeries -Rows $Report.Weekly -RetrievedHeader $t.Retrieved -WrittenHeader $t.Written -NicOutHeader $t.NicOut -NicInHeader $t.NicIn -EmptyText $t.Empty -TimeZone $tz -Language $Language))
    <footer>$(ConvertTo-SMBeatHtmlEncode -Text $t.Foot)</footer>
  </main>
</body>
</html>
"@

}