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'
            Span             = 'Spanne'
            Spans            = 'Letzte Stunde, Tag, Woche'
            SpanHint         = 'Alle drei Spalten enden am Zeitpunkt Erzeugt oben. 1 Stunde ist die letzte Stunde davor, 1 Tag die letzten 24 Stunden, 1 Woche die letzten 7 Tage.'
            SpanHour         = '1 Stunde'
            SpanDay          = '1 Tag'
            SpanWeek         = '1 Woche'
            Coverage         = 'Abdeckung'
            Generated        = 'Erzeugt'
            CollectedSince   = 'Daten seit'
            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'
            SmbPerfOut       = 'SMB-Perf Out'
            SmbPerfOutHint   = 'Gegencheck: SMB-Server gesendet, nicht Headline'
            SmbPerfIn        = 'SMB-Perf In'
            SmbPerfInHint    = 'Gegencheck: SMB-Server empfangen'
            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.'
            BySmbPerf        = 'SMB-Perf Freigaben'
            BySmbPerfHint    = 'Windows SMB-Zaehler je Freigabe, Gegencheck'
            BySmbPerfEmpty   = 'Keine SMB-Perf-Freigaben in diesem Fenster. Alte Samples ohne kind=smbperf lassen die Tabelle leer.'
            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'
            Trend            = 'Verlauf'
            Warnings         = 'Hinweise'
            WarningsIntro    = 'Die folgenden Texte erklaeren auffaellige Zahlen. Es sind Erlaeuterungen, keine Stoerungen.'
            CoverageLowTitle = 'Zeitraum nicht vollstaendig gemessen'
            CoverageLow      = 'Die Messung hat nicht den ganzen gewaehlten Zeitraum erfasst (Abdeckung unter 90 Prozent). Das ist nach einem Neustart des Servers oder einem spaeteren Start der Messung normal. Die fehlenden Stunden sind unbekannt, nicht null. Die Summen gelten nur fuer die Zeit, die wirklich gemessen wurde.'
            WarnEtwWrittenTitle = 'Geschrieben kommt von den Dateizugriffen'
            WarnEtwWritten   = 'Die grosse Zahl Geschrieben zaehlt, welcher Benutzer auf welche Freigabe geschrieben hat. Die reine TCP-Zaehlung sieht kurze Uploads oft nicht: die Verbindung ist schon wieder zu, bevor der Zaehler sie vollstaendig erfasst. Benutzer, Freigabe und die Headline nutzen deshalb dieselbe Zahl. Das ist erwartet, kein Fehler.'
            WarnEstataReadTitle = 'Abgerufen an Benutzer und Freigabe angeglichen'
            WarnEstataRead   = 'Die grosse Zahl Abgerufen kommt hier von den Dateizugriffen (welcher Benutzer welche Freigabe gelesen hat), nicht von der reinen TCP-Leitung. Die TCP-Zaehlung lag hoeher als das, was die Netzwerkkarte insgesamt verlassen hat. Das kommt bei langen Lesevorgaengen vor. Wir nehmen dann die Dateizugriffe, damit Gesamtsumme, Benutzer und Freigabe zusammenpassen. Das ist eine Angleichung, kein Messfehler.'
            WarnShareLowTitle = 'Freigaben kleiner als die Gesamtsumme'
            WarnShareLow     = 'Die Freigaben zusammen zeigen deutlich weniger als Abgerufen. Oft war die Verbindung schon offen, bevor die Messung startete. Dann fehlt der Freigabenname und es steht (unknown), bis der Client sich neu verbindet. Die Gesamtsumme oben bleibt trotzdem gueltig.'
            WarnNicOutTitle  = 'Netzwerkkarte groesser als die Dateizugriffe'
            WarnNicOut       = 'Die Netzwerkkarte zaehlt alles, was den Server verlaesst: Dateien, Remote-Desktop, Backup, Updates. Wenn sie deutlich ueber den Dateizugriffen liegt, nutzt Abgerufen die Netzwerkkarte, damit die Headline nicht zu klein wird.'
            Name             = 'Name'
            GB               = 'GB'
            Empty            = 'Keine Werte in diesem Fenster.'
            More             = 'und {0} weitere'
            Foot             = 'Abgerufen und Geschrieben sind die Nutzdaten auf Port 445. NIC Out/In ist die ganze Leitung (auch RDP, Backup, Overhead). SMB-Perf ist der Gegencheck aus den Windows-SMB-Zaehlern, nicht die Headline. Freigaben und Benutzer kommen aus den Dateizugriffen und muessen nicht genau die Summe ergeben.'
            Company          = 'MERLIN Kommunikationstechnik'
            Utc              = 'UTC'
        }
    }

    return @{
        Title            = 'SMBeat usage report'
        Period           = 'Period'
        Span             = 'Span'
        Spans            = 'Last hour, day, week'
        SpanHint         = 'All three columns end at Generated above. 1 hour is the last hour before that, 1 day the last 24 hours, 1 week the last 7 days.'
        SpanHour         = '1 hour'
        SpanDay          = '1 day'
        SpanWeek         = '1 week'
        Coverage         = 'Coverage'
        Generated        = 'Generated'
        CollectedSince   = 'Data since'
        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'
        SmbPerfOut       = 'SMB-Perf Out'
        SmbPerfOutHint   = 'Control: SMB Server sent, not the headline'
        SmbPerfIn        = 'SMB-Perf In'
        SmbPerfInHint    = 'Control: SMB Server received'
        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.'
        BySmbPerf        = 'SMB-Perf shares'
        BySmbPerfHint    = 'Windows SMB counters per share, control only'
        BySmbPerfEmpty   = 'No SMB-Perf share rows in this window. Older samples without kind=smbperf leave this table empty.'
        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'
        Trend            = 'Trend'
        Warnings         = 'Notes'
        WarningsIntro    = 'The texts below explain numbers that look inconsistent. They are explanations, not faults.'
        CoverageLowTitle = 'The window was not measured in full'
        CoverageLow      = 'Collection did not cover the whole selected period (coverage under 90 percent). That is normal after a server restart or if collection started later. Missing hours are unknown, not zero. Totals apply only to the time that was actually measured.'
        WarnEtwWrittenTitle = 'Written comes from file access'
        WarnEtwWritten   = 'The headline Written figure counts which user wrote to which share. Plain TCP often misses short uploads because the connection is gone before the counter can record them fully. Users, shares, and the headline therefore use the same file-access number. That is expected, not an error.'
        WarnEstataReadTitle = 'Retrieved was aligned with users and shares'
        WarnEstataRead   = 'The headline Retrieved figure comes from file access (which user read which share), not from the raw TCP count. TCP was higher than everything the network card sent. That happens on long reads. We then use file access so the total matches users and shares. That is an alignment, not a measurement fault.'
        WarnShareLowTitle = 'Shares are smaller than the total'
        WarnShareLow     = 'Shares together show much less than Retrieved. The session was often already open before collection started. Then the share name is missing and the row stays (unknown) until the client reconnects. The headline total above is still valid.'
        WarnNicOutTitle  = 'The NIC is larger than file access'
        WarnNicOut       = 'The network card counts everything leaving the host: files, remote desktop, backup, updates. When it is much larger than file access, Retrieved uses the NIC so the headline is not too small.'
        Name             = 'Name'
        GB               = 'GB'
        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). SMB-Perf is the Windows SMB counter check, not the headline. Shares and users come from ETW and will not match 445 one-to-one.'
        Company          = 'MERLIN Kommunikationstechnik'
        Utc              = 'UTC'
    }
}

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

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

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

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

    if ($Language -eq 'de') {
        return [System.Globalization.CultureInfo]::GetCultureInfo('de-DE')
    }

    $script:SMBeatInvariant
}

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

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

    $n.ToString('0.000', (Get-SMBeatHtmlNumberCulture -Language $Language))
}

function Format-SMBeatHtmlNumber {
    param(
        $Value,
        [string]$Format = '0.#',
        [ValidateSet('de', 'en')]
        [string]$Language = 'en'
    )

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

    $n.ToString($Format, (Get-SMBeatHtmlNumberCulture -Language $Language))
}

function Format-SMBeatHtmlSpan {
    param(
        [datetime]$StartUtc,
        [datetime]$EndUtc,
        [ValidateSet('de', 'en')]
        [string]$Language = 'en'
    )

    $span = $EndUtc - $StartUtc
    if ($span.Ticks -lt 0) {
        $span = $span.Negate()
    }

    $days = [int]$span.Days
    $hours = [int]$span.Hours
    $minutes = [int]$span.Minutes
    $parts = New-Object System.Collections.Generic.List[string]

    if ($Language -eq 'de') {
        if ($days -eq 1) { $parts.Add('1 Tag') | Out-Null }
        elseif ($days -gt 1) { $parts.Add(('{0} Tage' -f $days)) | Out-Null }
        if ($hours -eq 1) { $parts.Add('1 Stunde') | Out-Null }
        elseif ($hours -gt 1) { $parts.Add(('{0} Stunden' -f $hours)) | Out-Null }
        if ($minutes -eq 1) { $parts.Add('1 Minute') | Out-Null }
        elseif ($minutes -gt 1) { $parts.Add(('{0} Minuten' -f $minutes)) | Out-Null }
        if ($parts.Count -eq 0) {
            return 'unter 1 Minute'
        }
        return ($parts -join ' ')
    }

    if ($days -eq 1) { $parts.Add('1 day') | Out-Null }
    elseif ($days -gt 1) { $parts.Add(('{0} days' -f $days)) | Out-Null }
    if ($hours -eq 1) { $parts.Add('1 hour') | Out-Null }
    elseif ($hours -gt 1) { $parts.Add(('{0} hours' -f $hours)) | Out-Null }
    if ($minutes -eq 1) { $parts.Add('1 minute') | Out-Null }
    elseif ($minutes -gt 1) { $parts.Add(('{0} minutes' -f $minutes)) | Out-Null }
    if ($parts.Count -eq 0) {
        return 'under 1 minute'
    }

    $parts -join ' '
}

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-SMBeatHtmlRowGB {
    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,
        [ValidateSet('de', 'en')]
        [string]$Language = 'en'
    )

    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 = Format-SMBeatHtmlNumber -Value $chg -Format '+0.0;-0.0;0.0' -Language $Language
    '<div class="delta {0}">{1}% {2}</div>' -f `
        $cls, `
        (ConvertTo-SMBeatHtmlEncode -Text $chgText), `
        (ConvertTo-SMBeatHtmlEncode -Text $VersusText)
}

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

    $outGiB = 0.0
    $inGiB = 0.0
    if ($Span -and $Span.Totals) {
        if ($Span.Totals.PSObject.Properties['RetrievedGB'] -and $null -ne $Span.Totals.RetrievedGB) {
            $outGiB = [double]$Span.Totals.RetrievedGB
        }
        if ($Span.Totals.PSObject.Properties['WrittenGB'] -and $null -ne $Span.Totals.WrittenGB) {
            $inGiB = [double]$Span.Totals.WrittenGB
        }
    }

    [PSCustomObject]@{
        RetrievedGB   = $outGiB
        WrittenGB     = $inGiB
        RetrievedText = (Format-SMBeatHtmlGB -Value $outGiB -Language $Language)
        WrittenText   = (Format-SMBeatHtmlGB -Value $inGiB -Language $Language)
    }
}

function ConvertTo-SMBeatHtmlSpanCompare {
    param(
        $Hour,
        $Day,
        $Week,
        [string]$HourLabel,
        [string]$DayLabel,
        [string]$WeekLabel,
        [string]$RetrievedLabel,
        [string]$WrittenLabel,
        [ValidateSet('de', 'en')]
        [string]$Language
    )

    $hourT = Get-SMBeatHtmlSpanTotals -Span $Hour -Language $Language
    $dayT = Get-SMBeatHtmlSpanTotals -Span $Day -Language $Language
    $weekT = Get-SMBeatHtmlSpanTotals -Span $Week -Language $Language
    $max = 0.0
    foreach ($n in @($hourT.RetrievedGB, $hourT.WrittenGB, $dayT.RetrievedGB, $dayT.WrittenGB, $weekT.RetrievedGB, $weekT.WrittenGB)) {
        if ($n -gt $max) {
            $max = $n
        }
    }

    $sb = New-Object System.Text.StringBuilder
    [void]$sb.AppendLine('<table class="spans"><thead><tr>')
    [void]$sb.AppendLine(('<th></th><th class="num">{0}</th><th class="num">{1}</th><th class="num">{2}</th></tr></thead><tbody>' -f `
        (ConvertTo-SMBeatHtmlEncode -Text $HourLabel), `
        (ConvertTo-SMBeatHtmlEncode -Text $DayLabel), `
        (ConvertTo-SMBeatHtmlEncode -Text $WeekLabel)))
    [void]$sb.AppendLine(('<tr><td class="who">{0}</td>{1}{2}{3}</tr>' -f `
        (ConvertTo-SMBeatHtmlEncode -Text $RetrievedLabel), `
        (ConvertTo-SMBeatHtmlSpanCell -Value $hourT.RetrievedGB -Text $hourT.RetrievedText -Kind 'out' -Max $max), `
        (ConvertTo-SMBeatHtmlSpanCell -Value $dayT.RetrievedGB -Text $dayT.RetrievedText -Kind 'out' -Max $max), `
        (ConvertTo-SMBeatHtmlSpanCell -Value $weekT.RetrievedGB -Text $weekT.RetrievedText -Kind 'out' -Max $max)))
    [void]$sb.AppendLine(('<tr><td class="who">{0}</td>{1}{2}{3}</tr>' -f `
        (ConvertTo-SMBeatHtmlEncode -Text $WrittenLabel), `
        (ConvertTo-SMBeatHtmlSpanCell -Value $hourT.WrittenGB -Text $hourT.WrittenText -Kind 'in' -Max $max), `
        (ConvertTo-SMBeatHtmlSpanCell -Value $dayT.WrittenGB -Text $dayT.WrittenText -Kind 'in' -Max $max), `
        (ConvertTo-SMBeatHtmlSpanCell -Value $weekT.WrittenGB -Text $weekT.WrittenText -Kind 'in' -Max $max)))
    [void]$sb.AppendLine('</tbody></table>')
    $sb.ToString()
}

function ConvertTo-SMBeatHtmlSpanCell {
    param(
        [double]$Value,
        [string]$Text,
        [string]$Kind,
        [double]$Max
    )

    $pct = Get-SMBeatHtmlBarPct -Value $Value -Max $Max -Floor 2
    '<td class="num"><div>{0}</div><div class="bar {1}"><i style="width:{2}%"></i></div></td>' -f `
        (ConvertTo-SMBeatHtmlEncode -Text $Text), `
        $Kind, `
        $pct
}

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

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

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

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

    '<div class="{0}"><div class="label">{1}</div><div class="value">{2} <span class="unit">GB</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,
        [ValidateSet('de', 'en')]
        [string]$Language = 'en'
    )

    $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-SMBeatHtmlRowGB -Row $row -Primary 'RetrievedGB' -Fallback 'SentGB'
        $inGiB = Get-SMBeatHtmlRowGB -Row $row -Primary 'WrittenGB' -Fallback 'ReceivedGB'
        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-SMBeatHtmlRowGB -Row $row -Primary 'RetrievedGB' -Fallback 'SentGB'
        $inGiB = Get-SMBeatHtmlRowGB -Row $row -Primary 'WrittenGB' -Fallback 'ReceivedGB'
        $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-SMBeatHtmlGB -Value $outGiB -Language $Language), `
            $outPct, `
            (Format-SMBeatHtmlGB -Value $inGiB -Language $Language), `
            $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 Get-SMBeatHtmlNiceMax {
    param(
        [double]$Value
    )

    if ($Value -le 0) {
        return 1.0
    }

    $exp = [math]::Pow(10.0, [math]::Floor([math]::Log10($Value)))
    $n = $Value / $exp
    $nice = 10.0
    if ($n -le 1.0) {
        $nice = 1.0
    }
    elseif ($n -le 2.0) {
        $nice = 2.0
    }
    elseif ($n -le 5.0) {
        $nice = 5.0
    }

    $nice * $exp
}

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

    if ($null -eq $Value -or -not ($Value -is [datetime])) {
        return (Format-SMBeatHtmlWhen -Value $Value -TimeZone $TimeZone -Language $Language)
    }

    $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 = 'MM-dd HH:mm'
    if ($Language -eq 'de') {
        $pattern = 'dd.MM HH:mm'
    }

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

function ConvertTo-SMBeatHtmlLineChart {
    param(
        [object[]]$Rows,
        [TimeZoneInfo]$TimeZone,
        [ValidateSet('de', 'en')]
        [string]$Language = 'en',
        [string]$RetrievedLabel = 'Retrieved',
        [string]$WrittenLabel = 'Written'
    )

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

    $dataMax = 0.0
    $sumOut = 0.0
    $sumIn = 0.0
    $outs = New-Object 'System.Collections.Generic.List[double]'
    $ins = New-Object 'System.Collections.Generic.List[double]'
    foreach ($row in $Rows) {
        $outVal = Get-SMBeatHtmlRowGB -Row $row -Primary 'RetrievedGB' -Fallback ''
        $inVal = Get-SMBeatHtmlRowGB -Row $row -Primary 'WrittenGB' -Fallback 'ReceivedGB'
        [void]$outs.Add($outVal)
        [void]$ins.Add($inVal)
        $sumOut += $outVal
        $sumIn += $inVal
        if ($outVal -gt $dataMax) { $dataMax = $outVal }
        if ($inVal -gt $dataMax) { $dataMax = $inVal }
    }

    $scaleMax = Get-SMBeatHtmlNiceMax -Value $dataMax
    $vbW = 720.0
    $vbH = 200.0
    $padL = 54.0
    $padR = 22.0
    $padT = 28.0
    $padB = 34.0
    $innerW = $vbW - $padL - $padR
    $innerH = $vbH - $padT - $padB
    $n = $Rows.Count
    $seg = $n - 1
    if ($seg -lt 1) {
        $seg = 1
    }
    $step = $innerW / [double]$seg
    $plotRight = $padL + $innerW
    $plotBottom = $padT + $innerH

    $outPts = New-Object System.Text.StringBuilder
    $inPts = New-Object System.Text.StringBuilder
    $lastOutX = 0.0
    $lastOutY = 0.0
    $lastInX = 0.0
    $lastInY = 0.0
    for ($i = 0; $i -le $seg; $i++) {
        $src = $i
        if ($src -ge $n) {
            $src = $n - 1
        }
        $x = $padL + ($i * $step)
        $outY = $plotBottom
        $inY = $plotBottom
        if ($scaleMax -gt 0) {
            $outY = $padT + ((1.0 - ($outs[$src] / $scaleMax)) * $innerH)
            $inY = $padT + ((1.0 - ($ins[$src] / $scaleMax)) * $innerH)
        }
        $xText = $x.ToString('0.##', $script:SMBeatInvariant)
        $outText = $outY.ToString('0.##', $script:SMBeatInvariant)
        $inText = $inY.ToString('0.##', $script:SMBeatInvariant)
        if ($i -gt 0) {
            [void]$outPts.Append(' ')
            [void]$inPts.Append(' ')
        }
        [void]$outPts.Append($xText)
        [void]$outPts.Append(',')
        [void]$outPts.Append($outText)
        [void]$inPts.Append($xText)
        [void]$inPts.Append(',')
        [void]$inPts.Append($inText)
        if ($i -eq $seg) {
            $lastOutX = $x
            $lastOutY = $outY
            $lastInX = $x
            $lastInY = $inY
        }
    }

    $title = '{0} - {1} Out {2} GB In {3} GB' -f `
        (Format-SMBeatHtmlWhen -Value $Rows[0].PeriodStart -TimeZone $TimeZone -Language $Language), `
        (Format-SMBeatHtmlWhen -Value $Rows[$n - 1].PeriodStart -TimeZone $TimeZone -Language $Language), `
        (Format-SMBeatHtmlGB -Value $sumOut -Language $Language), `
        (Format-SMBeatHtmlGB -Value $sumIn -Language $Language)

    $sb = New-Object System.Text.StringBuilder
    [void]$sb.Append('<div class="beat"><svg viewBox="0 0 720 200" width="100%" preserveAspectRatio="xMidYMin meet" role="img">')
    [void]$sb.Append(('<title>{0}</title>' -f (ConvertTo-SMBeatHtmlEncode -Text $title)))
    [void]$sb.Append('<rect x="1.6" y="1.6" width="716.8" height="196.8" rx="12" fill="none" stroke="var(--line)" stroke-width="1"/>')

    $yTicks = @(0.0, 0.25, 0.5, 0.75, 1.0)
    foreach ($frac in $yTicks) {
        $yVal = $scaleMax * $frac
        $y = $plotBottom - ($frac * $innerH)
        $yText = $y.ToString('0.##', $script:SMBeatInvariant)
        $x1 = $padL.ToString('0.##', $script:SMBeatInvariant)
        $x2 = $plotRight.ToString('0.##', $script:SMBeatInvariant)
        [void]$sb.Append(('<line x1="{0}" y1="{1}" x2="{2}" y2="{1}" stroke="var(--line)" stroke-width="0.7" opacity="0.9"/>' -f $x1, $yText, $x2))
        $label = '{0} GB' -f (Format-SMBeatHtmlGB -Value $yVal -Language $Language)
        $lx = ($padL - 6.0).ToString('0.##', $script:SMBeatInvariant)
        [void]$sb.Append(('<text x="{0}" y="{1}" text-anchor="end" dominant-baseline="middle" fill="var(--mute)" font-size="9" font-family="Segoe UI, Helvetica Neue, Arial, sans-serif">{2}</text>' -f `
            $lx, $yText, (ConvertTo-SMBeatHtmlEncode -Text $label)))
    }

    $xIdx = New-Object 'System.Collections.Generic.List[int]'
    if ($seg -le 5) {
        for ($i = 0; $i -le $seg; $i++) { [void]$xIdx.Add($i) }
    }
    else {
        $wanted = @(0, [int][math]::Floor($seg / 4.0), [int][math]::Floor($seg / 2.0), [int][math]::Floor(($seg * 3.0) / 4.0), $seg)
        $seen = @{}
        foreach ($idx in $wanted) {
            if (-not $seen.ContainsKey($idx)) {
                $seen[$idx] = $true
                [void]$xIdx.Add($idx)
            }
        }
    }
    foreach ($idx in $xIdx) {
        $x = $padL + ($idx * $step)
        $xText = $x.ToString('0.##', $script:SMBeatInvariant)
        $tickY1 = $plotBottom.ToString('0.##', $script:SMBeatInvariant)
        $tickY2 = ($plotBottom + 4.0).ToString('0.##', $script:SMBeatInvariant)
        [void]$sb.Append(('<line x1="{0}" y1="{1}" x2="{0}" y2="{2}" stroke="var(--line)" stroke-width="0.8"/>' -f $xText, $tickY1, $tickY2))
        $rowIdx = $idx
        if ($rowIdx -ge $n) {
            $rowIdx = $n - 1
        }
        $when = Format-SMBeatHtmlChartWhen -Value $Rows[$rowIdx].PeriodStart -TimeZone $TimeZone -Language $Language
        $ly = ($plotBottom + 16.0).ToString('0.##', $script:SMBeatInvariant)
        $anchor = 'middle'
        if ($idx -eq 0) {
            $anchor = 'start'
        }
        elseif ($idx -eq $seg) {
            $anchor = 'end'
        }
        [void]$sb.Append(('<text x="{0}" y="{1}" text-anchor="{2}" fill="var(--mute)" font-size="9" font-family="Segoe UI, Helvetica Neue, Arial, sans-serif">{3}</text>' -f `
            $xText, $ly, $anchor, (ConvertTo-SMBeatHtmlEncode -Text $when)))
    }

    $legY = 16.0
    [void]$sb.Append(('<line x1="{0}" y1="{1}" x2="{2}" y2="{1}" stroke="var(--accent-out)" stroke-width="2.2" stroke-linecap="round"/>' -f `
        $padL.ToString('0.##', $script:SMBeatInvariant), `
        $legY.ToString('0.##', $script:SMBeatInvariant), `
        ($padL + 14.0).ToString('0.##', $script:SMBeatInvariant)))
    [void]$sb.Append(('<text x="{0}" y="{1}" fill="var(--mute)" font-size="9" font-family="Segoe UI, Helvetica Neue, Arial, sans-serif">{2}</text>' -f `
        ($padL + 18.0).ToString('0.##', $script:SMBeatInvariant), `
        ($legY + 3.0).ToString('0.##', $script:SMBeatInvariant), `
        (ConvertTo-SMBeatHtmlEncode -Text $RetrievedLabel)))
    [void]$sb.Append(('<line x1="{0}" y1="{1}" x2="{2}" y2="{1}" stroke="var(--accent-in)" stroke-width="2.2" stroke-linecap="round"/>' -f `
        ($padL + 118.0).ToString('0.##', $script:SMBeatInvariant), `
        $legY.ToString('0.##', $script:SMBeatInvariant), `
        ($padL + 132.0).ToString('0.##', $script:SMBeatInvariant)))
    [void]$sb.Append(('<text x="{0}" y="{1}" fill="var(--mute)" font-size="9" font-family="Segoe UI, Helvetica Neue, Arial, sans-serif">{2}</text>' -f `
        ($padL + 136.0).ToString('0.##', $script:SMBeatInvariant), `
        ($legY + 3.0).ToString('0.##', $script:SMBeatInvariant), `
        (ConvertTo-SMBeatHtmlEncode -Text $WrittenLabel)))

    [void]$sb.Append(('<polyline fill="none" stroke="var(--accent-in)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" points="{0}"/>' -f $inPts.ToString()))
    [void]$sb.Append(('<polyline fill="none" stroke="var(--accent-out)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" points="{0}"/>' -f $outPts.ToString()))
    [void]$sb.Append(('<circle cx="{0}" cy="{1}" r="3.1" fill="var(--accent-in)"/>' -f ($lastInX.ToString('0.##', $script:SMBeatInvariant)), ($lastInY.ToString('0.##', $script:SMBeatInvariant))))
    [void]$sb.Append(('<circle cx="{0}" cy="{1}" r="3.1" fill="var(--accent-out)"/>' -f ($lastOutX.ToString('0.##', $script:SMBeatInvariant)), ($lastOutY.ToString('0.##', $script:SMBeatInvariant))))
    [void]$sb.Append('</svg></div>')
    $sb.ToString()
}

function ConvertTo-SMBeatHtmlBeatBlock {
    param(
        [string]$Title,
        [object[]]$Rows,
        [string]$EmptyText,
        [TimeZoneInfo]$TimeZone,
        [ValidateSet('de', 'en')]
        [string]$Language = 'en',
        [string]$RetrievedLabel,
        [string]$WrittenLabel
    )

    $chart = ConvertTo-SMBeatHtmlLineChart -Rows $Rows -TimeZone $TimeZone -Language $Language -RetrievedLabel $RetrievedLabel -WrittenLabel $WrittenLabel
    if ([string]::IsNullOrWhiteSpace($chart)) {
        $chart = '<p class="empty">{0}</p>' -f (ConvertTo-SMBeatHtmlEncode -Text $EmptyText)
    }

    '<div class="beat-block"><h3>{0}</h3>{1}</div>' -f (ConvertTo-SMBeatHtmlEncode -Text $Title), $chart
}

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-SMBeatHtmlRowGB -Row $row -Primary 'RetrievedGB' -Fallback ''
        $inGiB = Get-SMBeatHtmlRowGB -Row $row -Primary 'WrittenGB' -Fallback 'ReceivedGB'
        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
    $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-SMBeatHtmlRowGB -Row $row -Primary 'RetrievedGB' -Fallback ''
        $inGiB = Get-SMBeatHtmlRowGB -Row $row -Primary 'WrittenGB' -Fallback 'ReceivedGB'
        $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-SMBeatHtmlGB -Value $outGiB -Language $Language), `
            (Format-SMBeatHtmlGB -Value $inGiB -Language $Language), `
            (Format-SMBeatHtmlGB -Value $row.NicOutGB -Language $Language), `
            (Format-SMBeatHtmlGB -Value $row.NicInGB -Language $Language)))
    }
    [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-SMBeatHtmlEmbeddedRows {
    param(
        [object[]]$Rows
    )

    $list = New-Object System.Collections.Generic.List[object]
    foreach ($row in @($Rows)) {
        if ($null -eq $row) {
            continue
        }
        $name = ''
        if ($row.PSObject.Properties['Name']) {
            $name = [string]$row.Name
        }
        $sent = [int64]0
        $recv = [int64]0
        if ($row.PSObject.Properties['SentBytes'] -and $row.SentBytes) {
            $sent = [int64]$row.SentBytes
        }
        if ($row.PSObject.Properties['ReceivedBytes'] -and $row.ReceivedBytes) {
            $recv = [int64]$row.ReceivedBytes
        }
        [void]$list.Add([PSCustomObject]@{
            Name          = $name
            SentBytes     = $sent
            ReceivedBytes = $recv
        })
    }

    return ,$list
}

function ConvertTo-SMBeatHtmlEmbeddedSeries {
    param(
        [object[]]$Rows
    )

    $list = New-Object System.Collections.Generic.List[object]
    foreach ($row in @($Rows)) {
        if ($null -eq $row) {
            continue
        }
        $start = ''
        if ($row.PSObject.Properties['PeriodStart'] -and $row.PeriodStart) {
            $start = ([datetime]$row.PeriodStart).ToString('yyyy-MM-ddTHH:mm:ss', $script:SMBeatInvariant)
        }
        $retrieved = [int64]0
        $written = [int64]0
        $nicOut = [int64]0
        $nicIn = [int64]0
        if ($row.PSObject.Properties['RetrievedBytes'] -and $row.RetrievedBytes) {
            $retrieved = [int64]$row.RetrievedBytes
        }
        if ($row.PSObject.Properties['WrittenBytes'] -and $row.WrittenBytes) {
            $written = [int64]$row.WrittenBytes
        }
        if ($row.PSObject.Properties['NicBytesOut'] -and $row.NicBytesOut) {
            $nicOut = [int64]$row.NicBytesOut
        }
        if ($row.PSObject.Properties['NicBytesIn'] -and $row.NicBytesIn) {
            $nicIn = [int64]$row.NicBytesIn
        }
        [void]$list.Add([PSCustomObject]@{
            PeriodStart    = $start
            RetrievedBytes = $retrieved
            WrittenBytes   = $written
            NicBytesOut    = $nicOut
            NicBytesIn     = $nicIn
        })
    }

    return ,$list
}

function ConvertTo-SMBeatHtmlEmbeddedData {
    param(
        [Parameter(Mandatory = $true)]
        $Report
    )

    $totals = $Report.Totals
    $smbSent = [int64]0
    $smbRecv = [int64]0
    if ($totals -and $totals.PSObject.Properties['SmbPerfSentBytes'] -and $totals.SmbPerfSentBytes) {
        $smbSent = [int64]$totals.SmbPerfSentBytes
    }
    if ($totals -and $totals.PSObject.Properties['SmbPerfReceivedBytes'] -and $totals.SmbPerfReceivedBytes) {
        $smbRecv = [int64]$totals.SmbPerfReceivedBytes
    }

    $bySmbPerf = New-Object System.Collections.Generic.List[object]
    if ($Report.PSObject.Properties['BySmbPerf']) {
        $bySmbPerf = ConvertTo-SMBeatHtmlEmbeddedRows -Rows $Report.BySmbPerf
    }

    $collectedSinceText = $null
    if ($Report.PSObject.Properties['CollectedSinceUtc'] -and $null -ne $Report.CollectedSinceUtc -and $Report.CollectedSinceUtc -ne [datetime]::MinValue) {
        $collectedSinceText = ConvertTo-SMBeatUtcString -DateTime ([datetime]$Report.CollectedSinceUtc)
    }

    $payload = [PSCustomObject]@{
        Format         = 'SMBeat-Report/1'
        ModuleVersion  = [string]$Report.ModuleVersion
        StartUtc       = (ConvertTo-SMBeatUtcString -DateTime $Report.StartUtc)
        EndUtc         = (ConvertTo-SMBeatUtcString -DateTime $Report.EndUtc)
        TimeZone       = [string]$Report.TimeZone
        CoveragePct    = $Report.CoveragePct
        SampleCount    = $Report.SampleCount
        CollectedSinceUtc = $collectedSinceText
        Totals         = [PSCustomObject]@{
            RetrievedBytes       = [int64]$totals.RetrievedBytes
            WrittenBytes         = [int64]$totals.WrittenBytes
            NicBytesOut          = [int64]$totals.NicBytesOut
            NicBytesIn           = [int64]$totals.NicBytesIn
            SmbPerfSentBytes     = $smbSent
            SmbPerfReceivedBytes = $smbRecv
        }
        ByShare        = (ConvertTo-SMBeatHtmlEmbeddedRows -Rows $Report.ByShare)
        BySmbPerf      = $bySmbPerf
        ByUser         = (ConvertTo-SMBeatHtmlEmbeddedRows -Rows $Report.ByUser)
        ByClient       = (ConvertTo-SMBeatHtmlEmbeddedRows -Rows $Report.ByClient)
        ByServer       = (ConvertTo-SMBeatHtmlEmbeddedRows -Rows $Report.ByServer)
        Hourly         = (ConvertTo-SMBeatHtmlEmbeddedSeries -Rows $Report.Hourly)
        Daily          = (ConvertTo-SMBeatHtmlEmbeddedSeries -Rows $Report.Daily)
        Weekly         = (ConvertTo-SMBeatHtmlEmbeddedSeries -Rows $Report.Weekly)
        Warnings       = @($Report.Warnings)
    }

    $json = ConvertTo-Json -InputObject $payload -Depth 6 -Compress
    $json.Replace('</', '\u003c/')
}

function Get-SMBeatHtmlTheme {
    param(
        [ValidateSet('health', 'pine')]
        [string]$Theme = 'health'
    )

    if ($Theme -eq 'pine') {
        return [ordered]@{
            name             = 'pine'
            ink              = '#122018'
            paper            = '#efe8d8'
            panel            = '#fffaf0'
            line             = '#d8cbb3'
            mute             = '#5d685f'
            heading          = '#3d4a40'
            'accent-out'     = '#2a6b45'
            'accent-in'      = '#c4a35a'
            'header-from'    = '#0e1a14'
            'header-mid'     = '#163226'
            'header-to'      = '#1d3d2c'
            'header-ink'     = '#f6f0e4'
            'header-accent'  = '#d7c38a'
            'header-mute'    = '#c9d4cb'
            'header-border'  = '#c4a35a'
            'wash-a'         = 'rgba(196,163,90,0.18)'
            'wash-b'         = 'rgba(42,107,69,0.12)'
            'chip-bg'        = 'rgba(255,250,240,0.08)'
            'chip-border'    = 'rgba(246,240,228,0.14)'
            'chip-ink'       = '#e8e2d4'
            'chip-strong'    = '#d7c38a'
            'chip-ok-bg'     = 'rgba(80,160,110,0.18)'
            'chip-ok-border' = 'rgba(140,210,160,0.28)'
            'chip-warn-bg'   = 'rgba(196,120,50,0.22)'
            'chip-warn-border' = 'rgba(232,176,110,0.35)'
            'chip-warn-ink'  = '#f3d7b0'
            'ver-border'     = 'rgba(196,163,90,0.35)'
            warn             = '#8a4310'
            'warn-bg'        = '#f4e0c8'
            'warn-line'      = '#e0c49a'
            'card-shadow'    = 'rgba(18,32,24,0.04)'
            'hero-out-from'  = '#163226'
            'hero-out-to'    = '#10241b'
            'hero-out-border' = '#1f3d2d'
            'hero-out-shadow' = 'rgba(16,36,27,0.18)'
            'hero-in-from'   = '#3a2a14'
            'hero-in-to'     = '#241910'
            'hero-in-border' = '#4a3820'
            'hero-in-shadow' = 'rgba(36,25,16,0.18)'
            'hero-ink'       = '#f6f0e4'
            'hero-value'     = '#fff8ea'
            'hero-label'     = '#d7c38a'
            'hero-label-in'  = '#e8c98a'
            'hero-hint'      = '#c9d4cb'
            'hero-hint-in'   = '#e4d4b4'
            'peak-out-border' = '#2a6b45'
            'peak-out-from'  = '#e8f3ea'
            'peak-out-to'    = '#fffaf0'
            'peak-in-border' = '#c4a35a'
            'peak-in-from'   = '#fff6e2'
            'peak-in-to'     = '#fffaf0'
            'delta-up'       = '#8ee0a8'
            'delta-down'     = '#f0b4a0'
            'delta-flat'     = '#c9d4cb'
            'empty-bg'       = '#f3ece0'
            'row-line'       = '#eee4d2'
            'bar-track'      = '#efe4ce'
            hot              = '#fff4d8'
            'mark-fill'      = '#0e1a14'
            'mark-stroke'    = '#c4a35a'
            'mark-path'      = '#d7c38a'
            'mark-dot'       = '#2a6b45'
            'print-paper'    = '#fff'
        }
    }

    return [ordered]@{
        name             = 'health'
        ink              = '#1b2430'
        paper            = '#e8eef3'
        panel            = '#f7fafc'
        line             = '#c5d0db'
        mute             = '#5b6774'
        heading          = '#3e4d5c'
        'accent-out'     = '#3d7eab'
        'accent-in'      = '#c2473d'
        'header-from'    = '#4a7fa3'
        'header-mid'     = '#5a8fb4'
        'header-to'      = '#6b9dc0'
        'header-ink'     = '#f4f8fb'
        'header-accent'  = '#d9ebf6'
        'header-mute'    = '#d0e0ec'
        'header-border'  = '#8eb4cc'
        'wash-a'         = 'rgba(90,155,196,0.16)'
        'wash-b'         = 'rgba(160,170,180,0.14)'
        'chip-bg'        = 'rgba(247,250,252,0.10)'
        'chip-border'    = 'rgba(244,248,251,0.16)'
        'chip-ink'       = '#eef4f8'
        'chip-strong'    = '#d9ebf6'
        'chip-ok-bg'     = 'rgba(80,150,190,0.22)'
        'chip-ok-border' = 'rgba(160,210,230,0.32)'
        'chip-warn-bg'   = 'rgba(194,71,61,0.22)'
        'chip-warn-border' = 'rgba(232,140,130,0.35)'
        'chip-warn-ink'  = '#f6d4d0'
        'ver-border'     = 'rgba(217,235,246,0.35)'
        warn             = '#a33b32'
        'warn-bg'        = '#f6e9e7'
        'warn-line'      = '#e0b8b4'
        'card-shadow'    = 'rgba(27,36,48,0.05)'
        'hero-out-from'  = '#2d5f82'
        'hero-out-to'    = '#214a66'
        'hero-out-border' = '#2a5474'
        'hero-out-shadow' = 'rgba(33,74,102,0.20)'
        'hero-in-from'   = '#8f3a34'
        'hero-in-to'     = '#5e2420'
        'hero-in-border' = '#7a322c'
        'hero-in-shadow' = 'rgba(94,36,32,0.18)'
        'hero-ink'       = '#f4f8fb'
        'hero-value'     = '#ffffff'
        'hero-label'     = '#c5dcec'
        'hero-label-in'  = '#f0c8c4'
        'hero-hint'      = '#c8d8e4'
        'hero-hint-in'   = '#e8d0cc'
        'peak-out-border' = '#3d7eab'
        'peak-out-from'  = '#e3eef6'
        'peak-out-to'    = '#f7fafc'
        'peak-in-border' = '#c2473d'
        'peak-in-from'   = '#f8eaea'
        'peak-in-to'     = '#f7fafc'
        'delta-up'       = '#4a9bc7'
        'delta-down'     = '#e08a82'
        'delta-flat'     = '#c8d4de'
        'empty-bg'       = '#e4ebf1'
        'row-line'       = '#e2e8ee'
        'bar-track'      = '#dce4ec'
        hot              = '#f3e6e5'
        'mark-fill'      = '#2d5f82'
        'mark-stroke'    = '#d9ebf6'
        'mark-path'      = '#e8f2f8'
        'mark-dot'       = '#c2473d'
        'print-paper'    = '#fff'
    }
}

function ConvertTo-SMBeatHtmlThemeCss {
    param(
        [Parameter(Mandatory = $true)]
        $Theme
    )

    $sb = New-Object System.Text.StringBuilder
    [void]$sb.AppendLine(' :root {')
    foreach ($entry in $Theme.GetEnumerator()) {
        if ($entry.Key -eq 'name') {
            continue
        }
        [void]$sb.Append(' --')
        [void]$sb.Append([string]$entry.Key)
        [void]$sb.Append(': ')
        [void]$sb.Append([string]$entry.Value)
        [void]$sb.AppendLine(';')
    }
    [void]$sb.Append(' }')
    $sb.ToString()
}

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

    $themeMap = Get-SMBeatHtmlTheme -Theme $Theme
    $themeName = [string]$themeMap['name']
    $themeCss = ConvertTo-SMBeatHtmlThemeCss -Theme $themeMap
    $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
    $spanText = Format-SMBeatHtmlSpan -StartUtc $Report.StartUtc -EndUtc $Report.EndUtc -Language $Language
    $tzLabel = $tz.Id
    $sinceChip = ''
    if ($Report.PSObject.Properties['CollectedSinceUtc'] -and $null -ne $Report.CollectedSinceUtc -and $Report.CollectedSinceUtc -ne [datetime]::MinValue) {
        $sinceText = Format-SMBeatHtmlWhen -Value $Report.CollectedSinceUtc -TimeZone $tz -Language $Language
        $sinceChip = '<span class="chip"><strong>{0}:</strong> {1}</span>' -f `
            (ConvertTo-SMBeatHtmlEncode -Text $t.CollectedSince), `
            (ConvertTo-SMBeatHtmlEncode -Text $sinceText)
    }

    $totals = $Report.Totals
    $retrievedText = Format-SMBeatHtmlGB -Value $totals.RetrievedGB -Language $Language
    $writtenText = Format-SMBeatHtmlGB -Value 0 -Language $Language
    if ($totals.PSObject.Properties['WrittenGB']) {
        $writtenText = Format-SMBeatHtmlGB -Value $totals.WrittenGB -Language $Language
    }
    $nicOutText = Format-SMBeatHtmlGB -Value $totals.NicOutGB -Language $Language
    $nicInText = Format-SMBeatHtmlGB -Value $totals.NicInGB -Language $Language
    $smbPerfOutText = Format-SMBeatHtmlGB -Value 0 -Language $Language
    $smbPerfInText = Format-SMBeatHtmlGB -Value 0 -Language $Language
    if ($totals.PSObject.Properties['SmbPerfSentGB']) {
        $smbPerfOutText = Format-SMBeatHtmlGB -Value $totals.SmbPerfSentGB -Language $Language
    }
    if ($totals.PSObject.Properties['SmbPerfReceivedGB']) {
        $smbPerfInText = Format-SMBeatHtmlGB -Value $totals.SmbPerfReceivedGB -Language $Language
    }

    $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-SMBeatHtmlGB -Value $Report.PeakRetrievedGB -Language $Language
        $avgGiB = Format-SMBeatHtmlGB -Value $Report.AverageRetrievedGBPerHour -Language $Language
        $peakHint = '{0}<br>{1} {2} GB/h' -f `
            (ConvertTo-SMBeatHtmlEncode -Text $peakWhen), `
            (ConvertTo-SMBeatHtmlEncode -Text $t.AverageHour), `
            (ConvertTo-SMBeatHtmlEncode -Text $avgGiB)
        $peakHtml = ConvertTo-SMBeatHtmlCard -Label $peakOutLabel -Hint $peakHint -Value $peakGiB -CssClass 'card peak' -RawHint
    }

    $peakInHtml = ''
    if ($Report.PSObject.Properties['PeakWrittenStart'] -and $Report.PeakWrittenStart) {
        $peakInWhen = Format-SMBeatHtmlWhen -Value $Report.PeakWrittenStart -TimeZone $tz -Language $Language
        $peakInGiB = Format-SMBeatHtmlGB -Value $Report.PeakWrittenGB -Language $Language
        $avgInGiB = Format-SMBeatHtmlGB -Value $Report.AverageWrittenGBPerHour -Language $Language
        $peakInHint = '{0}<br>{1} {2} GB/h' -f `
            (ConvertTo-SMBeatHtmlEncode -Text $peakInWhen), `
            (ConvertTo-SMBeatHtmlEncode -Text $t.AverageHour), `
            (ConvertTo-SMBeatHtmlEncode -Text $avgInGiB)
        $peakInHtml = ConvertTo-SMBeatHtmlCard -Label $peakInLabel -Hint $peakInHint -Value $peakInGiB -CssClass 'card peak in' -RawHint
    }

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

    $spanHour = $null
    $spanDay = $null
    $spanWeek = $null
    if ($Report.PSObject.Properties['SpanHour']) { $spanHour = $Report.SpanHour }
    if ($Report.PSObject.Properties['SpanDay']) { $spanDay = $Report.SpanDay }
    if ($Report.PSObject.Properties['SpanWeek']) { $spanWeek = $Report.SpanWeek }
    $spansHtml = ConvertTo-SMBeatHtmlPanel -Title $t.Spans -Hint $t.SpanHint -Body (
        ConvertTo-SMBeatHtmlSpanCompare -Hour $spanHour -Day $spanDay -Week $spanWeek -HourLabel $t.SpanHour -DayLabel $t.SpanDay -WeekLabel $t.SpanWeek -RetrievedLabel $t.Retrieved -WrittenLabel $t.Written -Language $Language
    )

    $notes = New-Object System.Collections.Generic.List[object]
    $coverage = 0.0
    if ($null -ne $Report.CoveragePct) {
        $coverage = [double]$Report.CoveragePct
    }
    if ($coverage -lt 90) {
        $notes.Add([PSCustomObject]@{ Title = $t.CoverageLowTitle; Body = $t.CoverageLow }) | Out-Null
    }
    foreach ($w in @($Report.Warnings)) {
        if (-not $w -or ([string]$w -like 'Coverage *')) {
            continue
        }
        $title = ''
        $text = [string]$w
        if ($text -like 'TCP 445 inbound is much smaller*') {
            $title = $t.WarnEtwWrittenTitle
            $text = $t.WarnEtwWritten
        }
        elseif ($text -like 'TCP 445 outbound is larger*') {
            $title = $t.WarnEstataReadTitle
            $text = $t.WarnEstataRead
        }
        elseif ($text -like 'Share bytes are much smaller*') {
            $title = $t.WarnShareLowTitle
            $text = $t.WarnShareLow
        }
        elseif ($text -like 'NIC Out is much larger*') {
            $title = $t.WarnNicOutTitle
            $text = $t.WarnNicOut
        }
        $notes.Add([PSCustomObject]@{ Title = $title; Body = $text }) | Out-Null
    }

    $warnHtml = ''
    if ($notes.Count -gt 0) {
        $intro = ''
        if ($t.ContainsKey('WarningsIntro') -and -not [string]::IsNullOrWhiteSpace($t.WarningsIntro)) {
            $intro = '<p class="lede">{0}</p>' -f (ConvertTo-SMBeatHtmlEncode -Text $t.WarningsIntro)
        }
        $items = New-Object System.Text.StringBuilder
        foreach ($note in $notes) {
            $head = ''
            if (-not [string]::IsNullOrWhiteSpace([string]$note.Title)) {
                $head = '<h3>{0}</h3>' -f (ConvertTo-SMBeatHtmlEncode -Text $note.Title)
            }
            [void]$items.Append(('<div class="note">{0}<p>{1}</p></div>' -f $head, (ConvertTo-SMBeatHtmlEncode -Text $note.Body)))
        }
        $warnHtml = '<section class="notes-banner"><h2>{0}</h2>{1}{2}</section>' -f `
            (ConvertTo-SMBeatHtmlEncode -Text $t.Warnings), `
            $intro, `
            $items.ToString()
    }

    $covClass = 'ok'
    if ($coverage -lt 90) {
        $covClass = 'warn'
    }
    $coverageText = Format-SMBeatHtmlNumber -Value $coverage -Format '0.#' -Language $Language

    $embeddedJson = ConvertTo-SMBeatHtmlEmbeddedData -Report $Report

    $more = $t.More
    $userBody = ConvertTo-SMBeatHtmlTable -Rows $Report.ByUser -NameHeader $t.Name -OutHeader $t.Retrieved -InHeader $t.Written -EmptyText $t.ByUserEmpty -MoreText $more -Language $Language
    $clientBody = ConvertTo-SMBeatHtmlTable -Rows $Report.ByClient -NameHeader $t.Name -OutHeader $t.Retrieved -InHeader $t.Written -EmptyText $t.Empty -MoreText $more -Language $Language
    $serverBody = ConvertTo-SMBeatHtmlTable -Rows $Report.ByServer -NameHeader $t.Name -OutHeader $t.Retrieved -InHeader $t.Written -EmptyText $t.Empty -MoreText $more -Language $Language
    $shareBody = ConvertTo-SMBeatHtmlTable -Rows $Report.ByShare -NameHeader $t.Name -OutHeader $t.Retrieved -InHeader $t.Written -EmptyText $t.ByShareEmpty -MoreText $more -Language $Language
    $smbPerfRows = @()
    if ($Report.PSObject.Properties['BySmbPerf']) {
        $smbPerfRows = @($Report.BySmbPerf)
    }
    $smbPerfBody = ConvertTo-SMBeatHtmlTable -Rows $smbPerfRows -NameHeader $t.Name -OutHeader $t.SmbPerfOut -InHeader $t.SmbPerfIn -EmptyText $t.BySmbPerfEmpty -MoreText $more -Language $Language

    @"
<!DOCTYPE html>
<html lang="$Language" class="theme-$themeName">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>$(ConvertTo-SMBeatHtmlEncode -Text $t.Title) $version</title>
  <style>
$themeCss
    * { box-sizing: border-box; }
    html { font-size: 16px; }
    body {
      margin: 0;
      color: var(--ink);
      background:
        radial-gradient(1200px 420px at 8% -10%, var(--wash-a), transparent 55%),
        radial-gradient(900px 380px at 110% 8%, var(--wash-b), 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, var(--header-from) 0%, var(--header-mid) 58%, var(--header-to) 100%);
      color: var(--header-ink);
      padding: 1.6rem 2rem 1.35rem;
      border-bottom: 3px solid var(--header-border);
    }
    .brand {
      display: flex;
      align-items: flex-start;
      gap: 1rem;
      max-width: 72rem;
      margin: 0 auto;
    }
    .mark {
      width: 2.55rem;
      height: 2.55rem;
      flex: 0 0 auto;
      margin-top: 0.05rem;
    }
    .mark svg { display: block; width: 100%; height: 100%; }
    .eyebrow {
      font-size: 0.72rem;
      letter-spacing: 0.08em;
      color: var(--header-accent);
      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: var(--header-mute);
      padding: 0.28rem 0.6rem;
      border: 1px solid var(--ver-border);
      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: var(--chip-bg);
      border: 1px solid var(--chip-border);
      color: var(--chip-ink);
      border-radius: 999px;
      padding: 0.28rem 0.75rem;
      font-size: 0.8rem;
    }
    .chip strong { color: var(--chip-strong); font-weight: 600; }
    .chip.ok { background: var(--chip-ok-bg); border-color: var(--chip-ok-border); }
    .chip.warn { background: var(--chip-warn-bg); border-color: var(--chip-warn-border); color: var(--chip-warn-ink); }
    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: var(--heading);
    }
    .notes-banner {
      background: var(--warn-bg);
      border: 1px solid var(--warn-line);
      border-left: 4px solid var(--warn);
      border-radius: 0.7rem;
      padding: 0.95rem 1.1rem 1.1rem;
      margin: 1.4rem 0 0;
    }
    .notes-banner h2 { color: var(--warn); margin-bottom: 0.35rem; }
    .notes-banner .lede { margin: 0 0 0.85rem; }
    .notes-banner .note + .note { margin-top: 0.95rem; padding-top: 0.85rem; border-top: 1px solid var(--warn-line); }
    .notes-banner h3 {
      margin: 0 0 0.3rem;
      font-size: 0.95rem;
      letter-spacing: 0;
      text-transform: none;
      color: var(--ink);
    }
    .notes-banner .note p {
      margin: 0;
      font-size: 0.86rem;
      color: var(--ink);
      max-width: 48rem;
    }
    .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 var(--card-shadow);
    }
    .card.hero {
      flex: 2 1 16rem;
      background: linear-gradient(160deg, var(--hero-out-from) 0%, var(--hero-out-to) 100%);
      border-color: var(--hero-out-border);
      color: var(--hero-ink);
      box-shadow: 0 10px 28px var(--hero-out-shadow);
    }
    .card.hero.in {
      background: linear-gradient(160deg, var(--hero-in-from) 0%, var(--hero-in-to) 100%);
      border-color: var(--hero-in-border);
      box-shadow: 0 10px 28px var(--hero-in-shadow);
    }
    .card.peak {
      border-color: var(--peak-out-border);
      background: linear-gradient(180deg, var(--peak-out-from) 0%, var(--peak-out-to) 70%);
    }
    .card.peak.in {
      border-color: var(--peak-in-border);
      background: linear-gradient(180deg, var(--peak-in-from) 0%, var(--peak-in-to) 70%);
    }
    .card .label {
      font-size: 0.7rem;
      letter-spacing: 0.1em;
      text-transform: uppercase;
      color: var(--mute);
      font-weight: 600;
    }
    .card.hero .label { color: var(--hero-label); }
    .card.hero.in .label { color: var(--hero-label-in); }
    .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: var(--hero-value); }
    .card .unit {
      font-size: 0.82rem;
      font-weight: 600;
      color: var(--mute);
      letter-spacing: 0.04em;
    }
    .card.hero .unit { color: var(--hero-label); }
    .card.hero.in .unit { color: var(--hero-label-in); }
    .card .hint { margin-top: 0.4rem; font-size: 0.78rem; color: var(--mute); }
    .card.hero .hint { color: var(--hero-hint); }
    .card.hero.in .hint { color: var(--hero-hint-in); }
    .delta {
      margin-top: 0.4rem;
      font-size: 0.78rem;
      font-weight: 600;
    }
    .delta.up { color: var(--delta-up); }
    .delta.down { color: var(--delta-down); }
    .delta.flat { color: var(--delta-flat); }
    .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 var(--card-shadow);
    }
    .panel-head { margin-bottom: 0.55rem; }
    .lede, .empty, .more {
      margin: 0 0 0.65rem;
      font-size: 0.8rem;
      color: var(--mute);
    }
    .empty {
      background: var(--empty-bg);
      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 var(--row-line);
      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;
    }
    table.spans th.num, table.spans td.num { width: 22%; }
    table.spans td.num { font-size: 1.05rem; font-weight: 600; }
    .who { font-weight: 600; font-size: 0.92rem; }
    .who.dim { color: var(--mute); font-style: italic; font-weight: 500; }
    .bar {
      height: 5px;
      background: var(--bar-track);
      border-radius: 99px;
      margin-top: 0.28rem;
      overflow: hidden;
    }
    .bar i {
      display: block;
      height: 100%;
      background: var(--accent-out);
      border-radius: 99px;
    }
    .bar.in i { background: var(--accent-in); }
    .bar.slim { height: 3px; margin-top: 0.22rem; }
    .duo { margin-top: 0.32rem; }
    tr.hot td { background: var(--hot); }
    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 var(--row-line);
    }
    .beat-block { margin: 0 0 1rem; }
    .beat-block:last-child { margin-bottom: 0.15rem; }
    .beat-block h3 {
      font-size: 0.78rem;
      letter-spacing: 0.12em;
      text-transform: uppercase;
      color: var(--heading);
      margin: 0 0 0.4rem;
    }
    .beat {
      width: 100%;
      margin: 0.15rem 0 0.85rem;
    }
    .beat svg {
      display: block;
      width: 100%;
      height: auto;
    }
    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: var(--print-paper); }
      header { border-bottom-width: 2px; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
      .beat svg { -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"><svg viewBox="0 0 48 48" focusable="false"><rect x="1.8" y="1.8" width="44.4" height="44.4" rx="12" fill="var(--mark-fill)" stroke="var(--mark-stroke)" stroke-width="1.35"/><rect x="5.1" y="5.1" width="37.8" height="37.8" rx="9.4" fill="none" stroke="var(--mark-stroke)" stroke-width="0.7" opacity="0.5"/><path d="M7.4 25.6 H14.2 L16.1 21.6 L17.8 25.6 H19.2 L21.4 9.2 L23.6 36.2 L26.2 25.6 L28.6 18.4 L31.2 25.6 L33.1 21.8 L34.8 25.6 H39.2" fill="none" stroke="var(--mark-path)" stroke-width="2.05" stroke-linecap="round" stroke-linejoin="round"/><circle cx="39.2" cy="25.6" r="2.15" fill="var(--mark-dot)"/></svg></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"><strong>$(ConvertTo-SMBeatHtmlEncode -Text $t.Span):</strong> $(ConvertTo-SMBeatHtmlEncode -Text $spanText)</span>
      $sinceChip
      <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>
    $(ConvertTo-SMBeatHtmlPanel -Title $t.Totals -Hint '' -Body ('<div class="cards">{0}{1}{2}{3}{4}{5}{6}{7}</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'), `
      (ConvertTo-SMBeatHtmlCard -Label $t.SmbPerfOut -Hint $t.SmbPerfOutHint -Value $smbPerfOutText -CssClass 'card'), `
      (ConvertTo-SMBeatHtmlCard -Label $t.SmbPerfIn -Hint $t.SmbPerfInHint -Value $smbPerfInText -CssClass 'card')))
    $spansHtml
    $(ConvertTo-SMBeatHtmlPanel -Title $t.Trend -Hint '' -Body (
        (ConvertTo-SMBeatHtmlBeatBlock -Title $t.Hourly -Rows $Report.Hourly -EmptyText $t.Empty -TimeZone $tz -Language $Language -RetrievedLabel $t.Retrieved -WrittenLabel $t.Written) +
        (ConvertTo-SMBeatHtmlBeatBlock -Title $t.Daily -Rows $Report.Daily -EmptyText $t.Empty -TimeZone $tz -Language $Language -RetrievedLabel $t.Retrieved -WrittenLabel $t.Written) +
        (ConvertTo-SMBeatHtmlBeatBlock -Title $t.Weekly -Rows $Report.Weekly -EmptyText $t.Empty -TimeZone $tz -Language $Language -RetrievedLabel $t.Retrieved -WrittenLabel $t.Written)
    ))
    <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.BySmbPerf -Hint $t.BySmbPerfHint -Body $smbPerfBody)
    </div>
    $(ConvertTo-SMBeatHtmlPanel -Title $t.ByServer -Hint '' -Body $serverBody)
    $(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))
    $warnHtml
    <footer>$(ConvertTo-SMBeatHtmlEncode -Text $t.Foot)</footer>
  </main>
  <script type="application/json" id="smbeat-data">$embeddedJson</script>
</body>
</html>
"@

}