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'
            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'
            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-Zuordnung in diesem Fenster.'
            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.'
            Foot             = 'Abgerufen ist TCP-Payload auf Port 445 (ohne Ethernet-Header). NIC Out/In sind die Leitung (inkl. RDP, Backup, Overhead). Freigaben kommen aus ETW (SMB2 Read/Write) 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'
        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'
        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 user attribution in this window.'
        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.'
        Foot             = 'Retrieved is TCP payload on port 445 (no Ethernet headers). NIC Out/In are the wire (including RDP, backup, overhead). Shares come from ETW (SMB2 Read/Write) 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
    )

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

    if ($Value -is [datetime]) {
        return $Value.ToString('yyyy-MM-dd HH:mm', $script:SMBeatInvariant)
    }

    return [string]$Value
}

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

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

    @($Rows | Where-Object { $_ -and ([int64]$_.SentBytes -gt 0 -or [int64]$_.WriteBytes -gt 0) })
}

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

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

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

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

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

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

    $sb = New-Object System.Text.StringBuilder
    [void]$sb.AppendLine('<table><thead><tr>')
    [void]$sb.AppendLine(('<th>{0}</th><th class="num">{1}</th></tr></thead><tbody>' -f `
        (ConvertTo-SMBeatHtmlEncode -Text $NameHeader), `
        (ConvertTo-SMBeatHtmlEncode -Text $ValueHeader)))
    $i = 0
    foreach ($row in $active) {
        if ($i -ge $Top) { break }
        $i++
        [void]$sb.AppendLine(('<tr><td>{0}</td><td class="num">{1}</td></tr>' -f `
            (ConvertTo-SMBeatHtmlEncode -Text ([string]$row.Name)), `
            (Format-SMBeatHtmlGiB -Value $row.SentGiB)))
    }
    [void]$sb.AppendLine('</tbody></table>')
    $sb.ToString()
}

function ConvertTo-SMBeatHtmlSeries {
    param(
        [object[]]$Rows,
        [string]$RetrievedHeader,
        [string]$NicOutHeader,
        [string]$EmptyText
    )

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

    $sb = New-Object System.Text.StringBuilder
    [void]$sb.AppendLine(('<table><thead><tr><th>Start</th><th class="num">{0}</th><th class="num">{1}</th></tr></thead><tbody>' -f `
        (ConvertTo-SMBeatHtmlEncode -Text $RetrievedHeader), `
        (ConvertTo-SMBeatHtmlEncode -Text $NicOutHeader)))
    foreach ($row in $Rows) {
        [void]$sb.AppendLine(('<tr><td>{0}</td><td class="num">{1}</td><td class="num">{2}</td></tr>' -f `
            (ConvertTo-SMBeatHtmlEncode -Text (Format-SMBeatHtmlWhen -Value $row.PeriodStart)), `
            (Format-SMBeatHtmlGiB -Value $row.RetrievedGiB), `
            (Format-SMBeatHtmlGiB -Value $row.NicOutGiB)))
    }
    [void]$sb.AppendLine('</tbody></table>')
    $sb.ToString()
}

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
    }
    $generatedText = (ConvertTo-SMBeatUtcString -DateTime $generated)

    $startUtc = $Report.StartUtc.ToString('yyyy-MM-dd HH:mm', $script:SMBeatInvariant) + 'Z'
    $endUtc = $Report.EndUtc.ToString('yyyy-MM-dd HH:mm', $script:SMBeatInvariant) + 'Z'

    $totals = $Report.Totals
    $retrievedText = Format-SMBeatHtmlGiB -Value $totals.RetrievedGiB
    $nicOutText = Format-SMBeatHtmlGiB -Value $totals.NicOutGiB
    $nicInText = Format-SMBeatHtmlGiB -Value $totals.NicInGiB

    $prevHtml = ''
    $prevValue = '0.000'
    if ($Report.PreviousTotals) {
        $prevValue = Format-SMBeatHtmlGiB -Value $Report.PreviousTotals.RetrievedGiB
        $prevHtml = ConvertTo-SMBeatHtmlCard -Label $t.Previous -Hint $t.PreviousHint -Value $prevValue -CssClass 'card'
    }

    $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><h2>{0}</h2><ul class="notes">{1}</ul></section>' -f (ConvertTo-SMBeatHtmlEncode -Text $t.Warnings), $items
    }

    $userRows = @(Select-SMBeatHtmlActiveRows -Rows $Report.ByUser)
    $userSection = ''
    if ($userRows.Count -gt 0) {
        $userSection = '<section><h2>{0}</h2>{1}</section>' -f `
            (ConvertTo-SMBeatHtmlEncode -Text $t.ByUser), `
            (ConvertTo-SMBeatHtmlTable -Rows $userRows -NameHeader $t.Name -ValueHeader $t.GiB -EmptyText $t.ByUserEmpty)
    }

    @"
<!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>
    body { font-family: Segoe UI, Helvetica, Arial, sans-serif; margin: 0; color: #1a1a1a; background: #f4f4f2; }
    header { background: #1f2a22; color: #f4f4f2; padding: 1.4rem 2rem; }
    header h1 { margin: 0 0 0.25rem; font-size: 1.35rem; font-weight: 600; }
    header .meta { color: #c8d0c4; font-size: 0.9rem; }
    main { padding: 1.5rem 2rem 3rem; max-width: 56rem; }
    h2 { font-size: 1rem; margin: 1.8rem 0 0.6rem; color: #1f2a22; }
    .cards { display: flex; flex-wrap: wrap; gap: 0.8rem; }
    .card { flex: 1 1 11rem; background: #fff; border: 1px solid #d8d8d2; padding: 0.9rem 1rem; }
    .card.hero { flex: 2 1 16rem; background: #eef3ea; border-color: #c5d2bc; }
    .card .label { font-size: 0.75rem; letter-spacing: 0.04em; text-transform: uppercase; color: #5c655c; }
    .card .value { font-size: 1.85rem; font-weight: 600; margin-top: 0.2rem; }
    .card .unit { font-size: 0.85rem; font-weight: 500; color: #5c655c; }
    .card .hint { margin-top: 0.35rem; font-size: 0.8rem; color: #5c655c; }
    .facts { margin: 1rem 0 0; font-size: 0.92rem; color: #333; }
    .facts div { margin: 0.2rem 0; }
    table { border-collapse: collapse; width: 100%; background: #fff; margin: 0.4rem 0 0; }
    th, td { border: 1px solid #d8d8d2; padding: 0.4rem 0.6rem; text-align: left; }
    th { background: #eef3ea; font-weight: 600; }
    td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
    .muted { color: #666; }
    ul.notes { margin: 0.4rem 0 0 1.1rem; }
    footer { margin-top: 2rem; font-size: 0.82rem; color: #555; max-width: 46rem; }
  </style>
</head>
<body>
  <header>
    <h1>$(ConvertTo-SMBeatHtmlEncode -Text $t.Title)</h1>
    <div class="meta">$(ConvertTo-SMBeatHtmlEncode -Text $t.Company) &middot; $(ConvertTo-SMBeatHtmlEncode -Text $t.Version) $(ConvertTo-SMBeatHtmlEncode -Text $version)</div>
  </header>
  <main>
    <div class="facts">
      <div><strong>$(ConvertTo-SMBeatHtmlEncode -Text $t.Period):</strong> $startUtc - $endUtc ($(ConvertTo-SMBeatHtmlEncode -Text ([string]$Report.TimeZone)))</div>
      <div><strong>$(ConvertTo-SMBeatHtmlEncode -Text $t.Coverage):</strong> $($Report.CoveragePct)%</div>
      <div><strong>$(ConvertTo-SMBeatHtmlEncode -Text $t.Generated):</strong> $generatedText</div>
    </div>
    $warnHtml
    <section>
      <h2>$(ConvertTo-SMBeatHtmlEncode -Text $t.Totals)</h2>
      <div class="cards">
        $(ConvertTo-SMBeatHtmlCard -Label $t.Retrieved -Hint $t.RetrievedHint -Value $retrievedText -CssClass 'card hero')
        $prevHtml
        $(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>
    </section>
    <section>
      <h2>$(ConvertTo-SMBeatHtmlEncode -Text $t.ByClient)</h2>
      <p class="muted">$(ConvertTo-SMBeatHtmlEncode -Text $t.ByClientHint)</p>
      $(ConvertTo-SMBeatHtmlTable -Rows $Report.ByClient -NameHeader $t.Name -ValueHeader $t.GiB -EmptyText $t.Empty)
    </section>
    <section>
      <h2>$(ConvertTo-SMBeatHtmlEncode -Text $t.ByServer)</h2>
      $(ConvertTo-SMBeatHtmlTable -Rows $Report.ByServer -NameHeader $t.Name -ValueHeader $t.GiB -EmptyText $t.Empty)
    </section>
    <section>
      <h2>$(ConvertTo-SMBeatHtmlEncode -Text $t.ByShare)</h2>
      $(ConvertTo-SMBeatHtmlTable -Rows $Report.ByShare -NameHeader $t.Name -ValueHeader $t.GiB -EmptyText $t.ByShareEmpty)
    </section>
    $userSection
    <section>
      <h2>$(ConvertTo-SMBeatHtmlEncode -Text $t.Hourly)</h2>
      $(ConvertTo-SMBeatHtmlSeries -Rows $Report.Hourly -RetrievedHeader $t.Retrieved -NicOutHeader $t.NicOut -EmptyText $t.Empty)
    </section>
    <section>
      <h2>$(ConvertTo-SMBeatHtmlEncode -Text $t.Daily)</h2>
      $(ConvertTo-SMBeatHtmlSeries -Rows $Report.Daily -RetrievedHeader $t.Retrieved -NicOutHeader $t.NicOut -EmptyText $t.Empty)
    </section>
    <section>
      <h2>$(ConvertTo-SMBeatHtmlEncode -Text $t.Weekly)</h2>
      $(ConvertTo-SMBeatHtmlSeries -Rows $Report.Weekly -RetrievedHeader $t.Retrieved -NicOutHeader $t.NicOut -EmptyText $t.Empty)
    </section>
    <footer>$(ConvertTo-SMBeatHtmlEncode -Text $t.Foot)</footer>
  </main>
</body>
</html>
"@

}