SMBpulse.psm1

#requires -version 5.1
<#
.SYNOPSIS
    SMBpulse - Messung und Auswertung des SMB-Durchsatzes auf Windows-Fileservern.

.DESCRIPTION
    SMBpulse ist ein PowerShell-Modul und enthaelt vier Cmdlets:

      Start-SmbPulse
        Erstellt und startet eine Langzeitmessung als Data Collector Set
        "SMB_Pulse" (Standard: 7 Tage, 15-Sekunden-Intervall, stuendliche
        BLG-Segmente).

      Watch-SmbPulse
        Zeigt den aktuellen SMB-Durchsatz direkt aus den Performance Countern
        an - mit farbcodierter Auslastungsanzeige fuer einen 1-Gbit/s-Link.

      Get-SmbPulseReport
        Wertet alle bereits abgeschlossenen BLG-Segmente aus und exportiert
        die Zusammenfassung sowie die groessten Einzelspitzen als CSV.

      Uninstall-SmbPulse
        Stoppt und entfernt Collector und Messdaten wieder.

    Voraussetzungen:
      - Windows PowerShell 5.1
      - Start-SmbPulse und Uninstall-SmbPulse erfordern eine Administratorsitzung

    Fruehere Versionen des Monitoring-Kits (Publisher-Name "SMB-Throughput-Monitor")
    mit dem Aelierungen Collector "SMB_Throughput_Monitor", Log "SMB_Throughput-Monitor"
    oder Collector "SMB_7Tage"/"SMB-Messung" werden automatisch erkannt und beim
    Start des neuen Collectors aufgeraeumt bzw. vom Uninstaller mitentfernt.

.NOTES
    Autor: Manuel Berfelde
#>


$ErrorActionPreference = 'Stop'

$Script:CollectorName    = 'SMB_Pulse'
$Script:LegacyCollectors = @(
    'SMB_Throughput_Monitor',
    'SMB_7Tage'
)

$Script:LogDirs = @(
    'C:\SMBpulse',
    'C:\PerfLogs\SMB-Throughput-Monitor',
    'C:\PerfLogs\SMB-Messung'
)

$Script:OutputDir  = $Script:LogDirs[0]
$Script:SummaryCsv = Join-Path $Script:OutputDir 'SMBpulse-Report.csv'
$Script:PeaksCsv   = Join-Path $Script:OutputDir 'SMBpulse-Peaks.csv'

# ----------------------------------------------------------------------------
# Interne Hilfsfunktionen
# ----------------------------------------------------------------------------

function Test-Administrator {
    $identity  = [Security.Principal.WindowsIdentity]::GetCurrent()
    $principal = New-Object Security.Principal.WindowsPrincipal($identity)
    return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}

function Assert-Administrator {
    if (-not (Test-Administrator)) {
        throw 'Dieses Cmdlet muss in einer PowerShell als Administrator ausgefuehrt werden.'
    }
}

function Get-SmbCounterSet {
    param([Object]$AllSets)

    if (-not $AllSets) {
        $AllSets = @(Get-Counter -ListSet *)
    }

    $set = @($AllSets) |
        Where-Object {
            $_.CounterSetName -eq 'SMB-Serverfreigaben' -or
            $_.CounterSetName -eq 'SMB Server Shares' -or
            $_.CounterSetName -match '^SMB.*Server.*(Share|Freig)'
        } |
        Select-Object -First 1

    if (-not $set) {
        throw 'Der Performance-Counter-Satz fuer SMB-Serverfreigaben wurde nicht gefunden.'
    }

    return $set
}

function Get-SmbCounterKind {
    param([Parameter(Mandatory = $true)][string]$Path)

    if ($Path -match '\\(Datenbytes/Sek\.|Data Bytes/sec)$') {
        return 'Gesamt'
    }

    if ($Path -match '\\(Gelesene Bytes/s|Read Bytes/sec)$') {
        return 'Lesen'
    }

    if ($Path -match '\\(Geschriebene Bytes/Sek\.|Write Bytes/sec)$') {
        return 'Schreiben'
    }

    return $null
}

function Write-MonitorBanner {
    param(
        [Parameter(Mandatory = $true)][string]$Title,
        [string]$Subtitle,
        [string]$Color = 'Cyan'
    )

    $width  = 62
    $top    = '+' + ('=' * ($width - 2)) + '+'
    $empty  = '|' + (' ' * ($width - 2)) + '|'

    $pad  = [Math]::Max(0, [int](($width - 2 - $Title.Length) / 2))
    $line = '|' + (' ' * $pad) + $Title + (' ' * ($width - 2 - $pad - $Title.Length)) + '|'

    Write-Host ''
    Write-Host $top   -ForegroundColor $Color
    Write-Host $empty -ForegroundColor $Color
    Write-Host $line  -ForegroundColor $Color

    if ($Subtitle) {
        Write-Host $empty -ForegroundColor $Color
        $pad  = [Math]::Max(0, [int](($width - 2 - $Subtitle.Length) / 2))
        $line = '|' + (' ' * $pad) + $Subtitle + (' ' * ($width - 2 - $pad - $Subtitle.Length)) + '|'
        Write-Host $line -ForegroundColor Gray
    }

    Write-Host $empty -ForegroundColor $Color
    Write-Host $top   -ForegroundColor $Color
    Write-Host ''
}

function Write-SectionTitle {
    param([Parameter(Mandatory = $true)][string]$Title)

    Write-Host ''
    Write-Host ('--- ' + $Title + ' ---') -ForegroundColor Cyan
}

function Write-InfoBox {
    param(
        [string[]]$Lines,
        [string]$Title = 'Ueberblick',
        [string]$Color = 'Cyan'
    )

    if (-not $Lines) {
        return
    }

    $width = 76
    $top   = '+' + ('=' * ($width - 2)) + '+'
    $empty = '|' + (' ' * ($width - 2)) + '|'

    Write-Host ''
    Write-Host $top -ForegroundColor $Color

    $pad  = [Math]::Max(0, [int](($width - 2 - $Title.Length) / 2))
    $line = '|' + (' ' * $pad) + $Title + (' ' * ($width - 2 - $pad - $Title.Length)) + '|'
    Write-Host $line -ForegroundColor $Color

    Write-Host $empty -ForegroundColor $Color

    foreach ($line in $Lines) {
        $text = $line

        if ($text -match ':') {
            $index = $text.IndexOf(':')
            $label = $text.Substring(0, $index).Trim()
            $value = $text.Substring($index + 1).Trim()
            $text  = (' {0,-28}: {1}' -f $label, $value)
        }
        else {
            $text = ' ' + $text
        }

        if ($text.Length -gt ($width - 3)) {
            $text = $text.Substring(0, $width - 3)
        }

        $pad = $width - 2 - $text.Length
        Write-Host ('|' + $text + (' ' * $pad) + '|')
    }

    Write-Host $empty -ForegroundColor $Color
    Write-Host $top   -ForegroundColor $Color
    Write-Host ''
}

function Write-Ok {
    param([string]$Text)
    Write-Host $Text -ForegroundColor Green
}

function Write-Warn {
    param([string]$Text)
    Write-Host $Text -ForegroundColor Yellow
}

function Write-Err {
    param([string]$Text)
    Write-Host $Text -ForegroundColor Red
}

function Write-Muted {
    param([string]$Text)
    Write-Host $Text -ForegroundColor DarkGray
}

function Convert-ToLogmanDuration {
    param([int]$TotalSeconds)

    $hours   = [Math]::Floor($TotalSeconds / 3600)
    $minutes = [Math]::Floor(($TotalSeconds % 3600) / 60)
    $seconds = $TotalSeconds % 60

    return ('{0:00}:{1:00}:{2:00}' -f $hours, $minutes, $seconds)
}

function Invoke-Logman {
    param(
        [Parameter(Mandatory = $true)][string[]]$ArgumentList,
        [switch]$Quiet
    )

    $logman = Join-Path $env:SystemRoot 'System32\logman.exe'
    if (Test-Path (Join-Path $env:SystemRoot 'Sysnative\logman.exe')) {
        $logman = Join-Path $env:SystemRoot 'Sysnative\logman.exe'
    }

    # logman startet gelegentlich nicht, wenn das aktuelle Arbeitsverzeichnis
    # fuer den Child-Prozess ungueltig ist ("Der Verzeichnisname ist ungueltig").
    # Deshalb immer aus einem garantiert gueltigen Verzeichnis heraus starten.
    Push-Location $env:SystemRoot
    try {
        if ($Quiet) {
            & $logman @ArgumentList 2>$null | Out-Null
        }
        else {
            $lines = @(& $logman @ArgumentList)
            foreach ($line in $lines) {
                Write-Host $line
            }
        }
        return $LASTEXITCODE
    }
    finally {
        Pop-Location
    }
}

function Stop-AndDeleteCollector {
    param([string]$Name)

    if ((Invoke-Logman -ArgumentList @('query', $Name) -Quiet) -ne 0) {
        return
    }

    $null = Invoke-Logman -ArgumentList @('stop', $Name) -Quiet
    Start-Sleep -Milliseconds 500
    $null = Invoke-Logman -ArgumentList @('delete', $Name) -Quiet

    # logman/PLA gibt die aktive BLG-Datei ggf. leicht verzoegert frei.
    for ($i = 0; $i -lt 20; $i++) {
        if ((Invoke-Logman -ArgumentList @('query', $Name) -Quiet) -ne 0) {
            break
        }
        Start-Sleep -Milliseconds 500
    }

    Write-Ok "Vorhandenen Datensammler '$Name' entfernt."
}

function Find-SmbTotalCounter {
    param(
        [string[]]$Patterns,
        [string[]]$Paths
    )

    foreach ($pattern in $Patterns) {
        $match = $Paths |
            Where-Object {
                $_ -match '\(_Total\)\\' -and $_ -match $pattern
            } |
            Select-Object -First 1

        if ($match) {
            return $match
        }
    }

    return $null
}

# ----------------------------------------------------------------------------
# Start-SmbPulse
# ----------------------------------------------------------------------------

function Start-SmbPulse {
    <#
    .SYNOPSIS
        Erstellt und startet eine Langzeitmessung des SMB- und Netzwerkdurchsatzes.

    .DESCRIPTION
        Erstellt den Data Collector Set "SMB_Pulse" ueber logman. Storage:
          - 7 Tage Laufzeit
          - 15 Sekunden Abtastintervall
          - stuendliche BLG-Segmente

        Die stuendlichen Segmente erlauben bereits waehrend der Messwoche eine
        Auswertung abgeschlossener BLG-Dateien.

        Eventuell vorhandene Collector frueherer Versionen ("SMB_Throughput_Monitor",
        "SMB_7Tage") werden gestoppt und entfernt. Alte Messdaten bleiben erhalten.

    .PARAMETER Days
        Laufzeit der Messung in Tagen (1-30, Standard 7).

    .PARAMETER SampleSeconds
        Abstand zwischen zwei Messwerten in Sekunden (1-3600, Standard 15).

    .PARAMETER SegmentMinutes
        Groesse eines BLG-Zeitsegments in Minuten (5-1440, Standard 60).

    .EXAMPLE
        Start-SmbPulse

    .EXAMPLE
        Start-SmbPulse -Days 14 -SampleSeconds 10 -SegmentMinutes 30
    #>


    [CmdletBinding()]
    param(
        [ValidateRange(1,30)]
        [int]$Days = 7,

        [ValidateRange(1,3600)]
        [int]$SampleSeconds = 15,

        [ValidateRange(5,1440)]
        [int]$SegmentMinutes = 60
    )

    Assert-Administrator

    $CollectorName = $Script:CollectorName
    $OutputDir     = $Script:OutputDir

    Write-MonitorBanner -Title 'SMBpulse' -Subtitle 'Langzeitmessung von SMB- und Netzwerkdurchsatz'

    # Alte bzw. bereits vorhandene Collector sauber entfernen.
    Write-SectionTitle 'Bestehende Collector entfernen'
    foreach ($name in @($Script:LegacyCollectors + $CollectorName)) {
        Stop-AndDeleteCollector -Name $name
    }

    New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null

    Write-SectionTitle 'Performance Counter ermitteln'

    $AllSets = Get-Counter -ListSet *

    $SmbSet = Get-SmbCounterSet -AllSets $AllSets

    $NetSet = @($AllSets) |
        Where-Object {
            $_.CounterSetName -eq 'Netzwerkschnittstelle' -or
            $_.CounterSetName -eq 'Network Interface'
        } |
        Select-Object -First 1

    if (-not $NetSet) {
        throw 'Der Performance-Counter-Satz fuer Netzwerkschnittstellen wurde nicht gefunden.'
    }

    $SmbPaths = @($SmbSet.PathsWithInstances)
    $NetPaths = @($NetSet.PathsWithInstances)

    $SmbData = Find-SmbTotalCounter -Paths $SmbPaths -Patterns @(
        '\\Datenbytes/Sek\.$',
        '\\Data Bytes/sec$'
    )

    $SmbRead = Find-SmbTotalCounter -Paths $SmbPaths -Patterns @(
        '\\Gelesene Bytes/s$',
        '\\Read Bytes/sec$'
    )

    $SmbWrite = Find-SmbTotalCounter -Paths $SmbPaths -Patterns @(
        '\\Geschriebene Bytes/Sek\.$',
        '\\Write Bytes/sec$'
    )

    $SmbCounters = @($SmbData, $SmbRead, $SmbWrite) | Where-Object { $_ }

    if ($SmbCounters.Count -lt 3) {
        Write-Host ''
        Write-Warn 'Gefundene SMB-Counter:'
        $SmbPaths |
            Where-Object { $_ -match '\(_Total\)\\' } |
            ForEach-Object { Write-Host " $_" }

        throw 'Nicht alle benoetigten SMB-Durchsatzzaehler wurden gefunden.'
    }

    $NetCounters = @(
        $NetPaths |
            Where-Object {
                $_ -match '\\(Gesamtanzahl Bytes/s|Bytes Total/sec|Bytes gesendet/s|Bytes Sent/sec|Empfangene Bytes/s|Bytes Received/sec)$'
            }
    )

    if ($NetCounters.Count -eq 0) {
        throw 'Keine Netzwerk-Byte/s-Counter wurden gefunden.'
    }

    $ClientSet = @($AllSets) |
        Where-Object {
            $_.CounterSetName -eq 'SMB-Clientkontexte' -or
            $_.CounterSetName -eq 'SMB Client Contexts'
        } |
        Select-Object -First 1

    $ShareSet = @($AllSets) |
        Where-Object {
            $_.CounterSetName -eq 'SMB-Clientfreigaben' -or
            $_.CounterSetName -eq 'SMB Client Shares'
        } |
        Select-Object -First 1

    $ClientPaths = @(Get-WildcardCounterPaths -CounterSet $ClientSet -CounterRegex '\\(Datenbytes/Sek\.|Data bytes/sec)$')
    $SharePaths  = @(Get-WildcardCounterPaths -CounterSet $ShareSet  -CounterRegex '\\(Datenbytes/Sek\.|Data bytes/sec)$')

    if ($ClientPaths.Count -eq 0) {
        Write-Warn 'Der Counter-Satz "SMB Client Contexts" wurde nicht gefunden - Client-Details werden nicht aufgezeichnet.'
    }

    if ($SharePaths.Count -eq 0) {
        Write-Warn 'Der Counter-Satz "SMB Client Shares" wurde nicht gefunden - Freigaben-Details werden nicht aufgezeichnet.'
    }

    $Counters = @($SmbCounters | Sort-Object -Unique) + @($NetCounters | Sort-Object -Unique) +
        @($ClientPaths | Sort-Object -Unique) + @($SharePaths | Sort-Object -Unique)

    Write-SectionTitle 'Aufzuzeichnende Counter'
    $Counters | ForEach-Object { Write-Host " $_" }

    $SampleInterval  = Convert-ToLogmanDuration -TotalSeconds $SampleSeconds
    $RunDuration     = Convert-ToLogmanDuration -TotalSeconds ($Days * 24 * 3600)
    $SegmentDuration = Convert-ToLogmanDuration -TotalSeconds ($SegmentMinutes * 60)

    $Arguments = @(
        'create', 'counter', $CollectorName,
        '-f', 'bin',
        '-o', "$OutputDir\SMB",
        '-si', $SampleInterval,
        '-rf', $RunDuration,
        '-cnf', $SegmentDuration,
        '-v', 'mmddhhmm',
        '-c'
    )
    $Arguments += $Counters

    Write-SectionTitle 'Datensammler erstellen'
    $createExit = Invoke-Logman -ArgumentList $Arguments
    if ($createExit -ne 0) {
        throw "logman create ist mit Exitcode $createExit fehlgeschlagen."
    }

    Write-SectionTitle 'Messung starten'
    $startExit = Invoke-Logman -ArgumentList @('start', $CollectorName)
    if ($startExit -ne 0) {
        throw "logman start ist mit Exitcode $startExit fehlgeschlagen."
    }

    Write-Ok 'Messung laeuft.'
    Write-InfoBox -Title 'Ueberblick' -Lines @(
        "Collector: $CollectorName",
        "Dauer: $Days Tag(e)",
        "Intervall: $SampleSeconds Sekunden",
        "BLG-Segment: $SegmentMinutes Minuten",
        "Ausgabe: $OutputDir"
    )

    Write-Muted 'Status pruefen:'
    Write-Muted " logman query $CollectorName"
    Write-Muted ''
    Write-Muted 'Live-Monitor:'
    Write-Muted ' Watch-SmbPulse'
    Write-Muted ''
    Write-Muted 'Auswertung:'
    Write-Muted ' Get-SmbPulseReport'
    Write-Host ''
}

# ----------------------------------------------------------------------------
# Watch-SmbPulse
# ----------------------------------------------------------------------------

function Watch-SmbPulse {
    <#
    .SYNOPSIS
        Live-Anzeige des aktuellen SMB-, Netzwerk- und Client-Durchsatzes.

    .DESCRIPTION
        Zeigt den aktuellen Durchsatz direkt aus den Windows Performance Countern.
        Es wird keine BLG-Datei benoetigt und der laufende Langzeit-Collector wird
        nicht beeinflusst.

        Angezeigt werden:
          - SMB Gesamt / Lesen / Schreiben (optional alle Freigaben)
          - Netzwerk-Gesamtwert je NIC samt Auslastungsbalken relativ zur
            tatsaechlichen Link-Geschwindigkeit (via Get-NetAdapter)
          - Top-Clients und Top-Benutzer (derzeit aktivster Durchsatz)
          - Top-Freigaben

        Farben und Balken kodieren die Auslastung relativ zur jeweiligen
        Link-Geschwindigkeit: gruen unter 70 %, gelb 70-90 %, rot ueber 90 %.

    .PARAMETER IntervalSeconds
        Aktualisierungsintervall in Sekunden (1-60, Standard 2).

    .PARAMETER PerShare
        Zeigt zusaetzlich die einzelnen SMB-Freigaben statt nur _Total.

    .PARAMETER TopN
        Anzahl der Zeilen in den Live-Listen (Clients/Benutzer/Freigaben,
        1-20, Standard 5).

    .EXAMPLE
        Watch-SmbPulse

    .EXAMPLE
        Watch-SmbPulse -IntervalSeconds 1 -PerShare -TopN 10
    #>


    [CmdletBinding()]
    param(
        [ValidateRange(1,60)]
        [int]$IntervalSeconds = 2,

        [switch]$PerShare,

        [ValidateRange(1,20)]
        [int]$TopN = 5
    )

    $SmbSet   = Get-SmbCounterSet
    $AllPaths = @($SmbSet.PathsWithInstances)

    if ($PerShare) {
        $CounterPaths = @(
            $AllPaths |
                Where-Object {
                    (Get-SmbCounterKind -Path $_) -ne $null
                } |
                Sort-Object -Unique
        )
    }
    else {
        $CounterPaths = @(
            $AllPaths |
                Where-Object {
                    $_ -match '\(_Total\)\\' -and
                    (Get-SmbCounterKind -Path $_) -ne $null
                } |
                Sort-Object -Unique
        )
    }

    if ($CounterPaths.Count -eq 0) {
        throw 'Keine passenden SMB-Durchsatzzaehler wurden gefunden.'
    }

    $AllSets = @(Get-Counter -ListSet *)

    $NetSet = @($AllSets) |
        Where-Object {
            $_.CounterSetName -eq 'Netzwerkschnittstelle' -or
            $_.CounterSetName -eq 'Network Interface'
        } |
        Select-Object -First 1

    $NetCounterPaths = @(Get-WildcardCounterPaths -CounterSet $NetSet -CounterRegex '\\(Gesamtanzahl Bytes/s|Bytes Total/sec)$')

    $ClientSet = @($AllSets) |
        Where-Object {
            $_.CounterSetName -eq 'SMB-Clientkontexte' -or
            $_.CounterSetName -eq 'SMB Client Contexts'
        } |
        Select-Object -First 1

    $ShareSet = @($AllSets) |
        Where-Object {
            $_.CounterSetName -eq 'SMB-Clientfreigaben' -or
            $_.CounterSetName -eq 'SMB Client Shares'
        } |
        Select-Object -First 1

    $ClientCounterPaths = @(Get-WildcardCounterPaths -CounterSet $ClientSet -CounterRegex '\\(Datenbytes/Sek\.|Data bytes/sec)$')
    $ShareCounterPaths  = @(Get-WildcardCounterPaths -CounterSet $ShareSet  -CounterRegex '\\(Datenbytes/Sek\.|Data bytes/sec)$')

    $refLinkMbps  = 1000.0
    $linkSpeeds   = @{}

    try {
        $upAdapters = @(Get-NetAdapter -ErrorAction Stop | Where-Object { $_.Status -eq 'Up' })

        foreach ($adapter in $upAdapters) {
            $mbit = 0.0

            if ($adapter.LinkSpeed -match '(\d+(?:\.\d+)?)\s*Gbps') {
                $mbit = [double]$Matches[1] * 1000
            }
            elseif ($adapter.LinkSpeed -match '(\d+(?:\.\d+)?)\s*Mbps') {
                $mbit = [double]$Matches[1]
            }

            foreach ($key in @($adapter.Name, $adapter.InterfaceDescription, $adapter.InterfaceAlias, $adapter.ifAlias)) {
                if ($key) {
                    $linkSpeeds[$key] = $mbit
                }
            }
        }

        $candidates = @($linkSpeeds.Values | Where-Object { $_ -gt 0 })

        if ($candidates.Count -gt 0) {
            $refLinkMbps = [double](($candidates | Measure-Object -Maximum).Maximum)
        }
    }
    catch {
        Write-Warn 'Get-NetAdapter nicht verfuegbar - Massstab faellt auf 1 Gbit/s zurueck.'
    }

    function Write-LiveRow {
        param(
            [string]$Label,
            [string]$Kind,
            [double]$MBPS,
            [double]$MbitSec,
            [double]$RefMbps
        )

        $percent = if ($RefMbps -gt 0) { ($MbitSec / $RefMbps) * 100 } else { 0 }
        $color   = if ($percent -ge 90) { 'Red' } elseif ($percent -ge 70) { 'Yellow' } else { 'Green' }

        Write-Host (" {0,-24}" -f $Label) -NoNewline
        Write-Host ("{0,-10}" -f $Kind) -NoNewline -ForegroundColor DarkCyan
        Write-Host ("{0,10}" -f ('{0:N2}' -f $MBPS)) -NoNewline -ForegroundColor $color
        Write-Host ("{0,10}" -f ('{0:N2}' -f $MbitSec)) -NoNewline -ForegroundColor $color
        Write-Host (" {0,6:N1} % " -f $percent) -NoNewline -ForegroundColor $color
        Write-Host (Get-UsageBar -Percent $percent) -ForegroundColor $color
    }

    Write-MonitorBanner -Title 'SMBpulse Live Monitor' -Subtitle 'SMB-, Netz- und Client-Durchsatz in Echtzeit'

    Write-Host "Intervall: $IntervalSeconds Sekunde(n)"
    Write-Host "Link-Norm: {0:N0} Mbit/s (via Get-NetAdapter)" -f $refLinkMbps
    Write-Host 'Beenden: STRG+C'
    Write-Host ''

    while ($true) {
        try {
            $sample = Get-Counter -Counter $CounterPaths -ErrorAction Stop

            $rows = foreach ($counter in $sample.CounterSamples) {
                $kind = Get-SmbCounterKind -Path ([string]$counter.Path)
                if (-not $kind) {
                    continue
                }

                $value = [double]$counter.CookedValue
                if ([double]::IsNaN($value) -or [double]::IsInfinity($value) -or $value -lt 0) {
                    continue
                }

                $instance = [string]$counter.InstanceName

                if (-not $PerShare -or $instance -eq '_Total') {
                    $displayInstance = 'Alle Freigaben'
                }
                else {
                    $displayInstance = $instance
                }

                [PSCustomObject]@{
                    Freigabe = $displayInstance
                    Typ      = $kind
                    MBPS     = $value / 1MB
                    MbitSec  = ($value * 8) / 1000000
                }
            }

            $sorted = @(
                $rows |
                    Sort-Object Freigabe, @{
                        Expression = {
                            switch ($_.Typ) {
                                'Gesamt'    { 1 }
                                'Lesen'     { 2 }
                                'Schreiben' { 3 }
                                default     { 9 }
                            }
                        }
                    }
            )

            $netRows = @()

            if ($NetCounterPaths.Count -gt 0) {
                try {
                    $netSample = Get-Counter -Counter $NetCounterPaths -ErrorAction Stop

                    foreach ($counter in $netSample.CounterSamples) {
                        $value = [double]$counter.CookedValue

                        if ([double]::IsNaN($value) -or [double]::IsInfinity($value) -or $value -lt 0) {
                            continue
                        }

                        $nic = [string]$counter.InstanceName

                        $linkMbps = $refLinkMbps
                        foreach ($key in $linkSpeeds.Keys) {
                            if ($nic -like ('*' + $key + '*')) {
                                $linkMbps = $linkSpeeds[$key]
                                break
                            }
                        }

                        $netRows += [PSCustomObject]@{
                            Nic      = $nic
                            MBPS     = $value / 1MB
                            MbitSec  = ($value * 8) / 1000000
                            LinkMbps = [double]$linkMbps
                        }
                    }
                }
                catch {
                    $netRows = @()
                }
            }

            $clientRows = @()
            $userRows   = @()

            if ($ClientCounterPaths.Count -gt 0) {
                try {
                    $clientSample = Get-Counter -Counter $ClientCounterPaths -ErrorAction Stop

                    $clientMap = @{}
                    $userMap   = @{}

                    foreach ($counter in $clientSample.CounterSamples) {
                        $value = [double]$counter.CookedValue

                        if ([double]::IsNaN($value) -or [double]::IsInfinity($value) -or $value -lt 0) {
                            continue
                        }

                        $parsed = Get-SmbClientParts -Instance ([string]$counter.InstanceName)

                        if (-not $parsed) {
                            continue
                        }

                        $mbitSec = ($value * 8) / 1000000

                        if (-not $clientMap.ContainsKey($parsed.Client)) {
                            $clientMap[$parsed.Client] = 0.0
                        }

                        $clientMap[$parsed.Client] += $mbitSec

                        if ($parsed.User) {
                            if (-not $userMap.ContainsKey($parsed.User)) {
                                $userMap[$parsed.User] = 0.0
                            }

                            $userMap[$parsed.User] += $mbitSec
                        }
                    }

                    $clientRows = @(
                        $clientMap.GetEnumerator() |
                            Sort-Object Value -Descending |
                            Select-Object -First $TopN |
                            ForEach-Object { [PSCustomObject]@{ Name = $_.Key; MbitSec = $_.Value } }
                    )

                    $userRows = @(
                        $userMap.GetEnumerator() |
                            Sort-Object Value -Descending |
                            Select-Object -First $TopN |
                            ForEach-Object { [PSCustomObject]@{ Name = $_.Key; MbitSec = $_.Value } }
                    )
                }
                catch {
                    $clientRows = @()
                    $userRows   = @()
                }
            }

            $shareRows = @()

            if ($ShareCounterPaths.Count -gt 0) {
                try {
                    $shareSample = Get-Counter -Counter $ShareCounterPaths -ErrorAction Stop

                    $shareMap = @{}

                    foreach ($counter in $shareSample.CounterSamples) {
                        $value = [double]$counter.CookedValue

                        if ([double]::IsNaN($value) -or [double]::IsInfinity($value) -or $value -lt 0) {
                            continue
                        }

                        $share = ($counter.InstanceName -split '\\')[-1]

                        if (-not $share) {
                            continue
                        }

                        $mbitSec = ($value * 8) / 1000000

                        if (-not $shareMap.ContainsKey($share)) {
                            $shareMap[$share] = 0.0
                        }

                        $shareMap[$share] += $mbitSec
                    }

                    $shareRows = @(
                        $shareMap.GetEnumerator() |
                            Sort-Object Value -Descending |
                            Select-Object -First $TopN |
                            ForEach-Object { [PSCustomObject]@{ Name = $_.Key; MbitSec = $_.Value } }
                    )
                }
                catch {
                    $shareRows = @()
                }
            }

            Clear-Host

            Write-Host 'SMBpulse Live Monitor' -ForegroundColor Cyan
            Write-Host ('=' * 100) -ForegroundColor Cyan
            Write-Host ("Zeit: {0}" -f (Get-Date -Format 'dd.MM.yyyy HH:mm:ss'))
            Write-Host ("Intervall: {0} Sekunde(n) | Link-Norm: {1:N0} Mbit/s" -f $IntervalSeconds, $refLinkMbps)
            Write-Host 'Beenden: STRG+C'
            Write-Host ''

            Write-SectionTitle 'SMB-Durchsatz'

            if ($sorted.Count -eq 0) {
                Write-Host ' (momentan keine verwertbaren Messwerte)' -ForegroundColor Yellow
            }
            else {
                foreach ($row in $sorted) {
                    Write-LiveRow -Label $row.Freigabe -Kind $row.Typ -MBPS $row.MBPS -MbitSec $row.MbitSec -RefMbps $refLinkMbps
                }
            }

            Write-Host ''

            Write-SectionTitle 'Netzwerk (je Schnittstelle)'

            if ($netRows.Count -eq 0) {
                Write-Host ' (keine Netzwerk-Gesamtwerte verfuegbar)' -ForegroundColor DarkGray
            }
            else {
                Write-Host (" {0,-24}{1,-10}{2,10}{3,10}{4,11}{5,-5}{6}" -f 'Schnittstelle', 'Link', 'MB/s', 'Mbit/s', 'Auslast.', '', 'Balken') -ForegroundColor DarkGray

                foreach ($row in $netRows) {
                    $percent = if ($row.LinkMbps -gt 0) { ($row.MbitSec / $row.LinkMbps) * 100 } else { 0 }
                    $color   = if ($percent -ge 90) { 'Red' } elseif ($percent -ge 70) { 'Yellow' } else { 'Green' }

                    Write-Host (" {0,-24}" -f $row.Nic) -NoNewline
                    Write-Host ("{0,-10}" -f ('{0:N0}' -f $row.LinkMbps)) -NoNewline -ForegroundColor DarkCyan
                    Write-Host ("{0,10}" -f ('{0:N2}' -f $row.MBPS)) -NoNewline -ForegroundColor $color
                    Write-Host ("{0,10}" -f ('{0:N2}' -f $row.MbitSec)) -NoNewline -ForegroundColor $color
                    Write-Host (" {0,6:N1} % " -f $percent) -NoNewline -ForegroundColor $color
                    Write-Host (Get-UsageBar -Percent $percent) -ForegroundColor $color
                }
            }

            Write-Host ''

            Write-SectionTitle ("Top-Clients (aktuell, Top {0})" -f $TopN)

            if ($clientRows.Count -eq 0) {
                Write-Host ' (keine Client-Daten verfuegbar)' -ForegroundColor DarkGray
            }
            else {
                Write-Host (" {0,-24}{1,10}{2,11}" -f 'Client', 'Mbit/s', 'Auslast.') -ForegroundColor DarkGray

                foreach ($row in $clientRows) {
                    $percent = ($row.MbitSec / $refLinkMbps) * 100
                    $color   = if ($percent -ge 90) { 'Red' } elseif ($percent -ge 70) { 'Yellow' } else { 'Green' }

                    Write-Host (" {0,-24}" -f $row.Name) -NoNewline
                    Write-Host ("{0,10}" -f ('{0:N2}' -f $row.MbitSec)) -NoNewline -ForegroundColor $color
                    Write-Host (" {0,6:N1} % " -f $percent) -NoNewline -ForegroundColor $color
                    Write-Host (Get-UsageBar -Percent $percent) -ForegroundColor $color
                }
            }

            Write-Host ''

            Write-SectionTitle ("Top-Benutzer (aktuell, Top {0})" -f $TopN)

            if ($userRows.Count -eq 0) {
                Write-Host ' (keine Benutzer-Daten verfuegbar)' -ForegroundColor DarkGray
            }
            else {
                Write-Host (" {0,-24}{1,10}{2,11}" -f 'Benutzer', 'Mbit/s', 'Auslast.') -ForegroundColor DarkGray

                foreach ($row in $userRows) {
                    $percent = ($row.MbitSec / $refLinkMbps) * 100
                    $color   = if ($percent -ge 90) { 'Red' } elseif ($percent -ge 70) { 'Yellow' } else { 'Green' }

                    Write-Host (" {0,-24}" -f $row.Name) -NoNewline
                    Write-Host ("{0,10}" -f ('{0:N2}' -f $row.MbitSec)) -NoNewline -ForegroundColor $color
                    Write-Host (" {0,6:N1} % " -f $percent) -NoNewline -ForegroundColor $color
                    Write-Host (Get-UsageBar -Percent $percent) -ForegroundColor $color
                }
            }

            Write-Host ''

            Write-SectionTitle ("Top-Freigaben (aktuell, Top {0})" -f $TopN)

            if ($shareRows.Count -eq 0) {
                Write-Host ' (keine Freigaben-Daten verfuegbar)' -ForegroundColor DarkGray
            }
            else {
                Write-Host (" {0,-24}{1,10}{2,11}" -f 'Freigabe', 'Mbit/s', 'Auslast.') -ForegroundColor DarkGray

                foreach ($row in $shareRows) {
                    $percent = ($row.MbitSec / $refLinkMbps) * 100
                    $color   = if ($percent -ge 90) { 'Red' } elseif ($percent -ge 70) { 'Yellow' } else { 'Green' }

                    Write-Host (" {0,-24}" -f $row.Name) -NoNewline
                    Write-Host ("{0,10}" -f ('{0:N2}' -f $row.MbitSec)) -NoNewline -ForegroundColor $color
                    Write-Host (" {0,6:N1} % " -f $percent) -NoNewline -ForegroundColor $color
                    Write-Host (Get-UsageBar -Percent $percent) -ForegroundColor $color
                }
            }

            Write-Host ''
            Write-Host (' ' + [char]0x25A0 + ' ') -NoNewline -ForegroundColor Green
            Write-Host '< 70 %' -NoNewline -ForegroundColor DarkGray
            Write-Host (' ' + [char]0x25A0 + ' ') -NoNewline -ForegroundColor Yellow
            Write-Host '70-90 %' -NoNewline -ForegroundColor DarkGray
            Write-Host (' ' + [char]0x25A0 + ' ') -NoNewline -ForegroundColor Red
            Write-Host ('> 90 % (Ma\u00dfstab: {0:N0} Mbit/s)' -f $refLinkMbps) -ForegroundColor DarkGray

            Start-Sleep -Seconds $IntervalSeconds
        }
        catch {
            Clear-Host

            Write-Host 'SMBpulse Live Monitor' -ForegroundColor Cyan
            Write-Host ('=' * 100) -ForegroundColor Cyan
            Write-Warn "Fehler beim Lesen der Performance-Counter: $($_.Exception.Message)"
            Write-Host ''

            Start-Sleep -Seconds $IntervalSeconds
        }
    }
}

# ----------------------------------------------------------------------------
# Interne Funktionen der Auswertung und des HTML-Reports
# ----------------------------------------------------------------------------

function Get-SmbClientParts {
    param(
        [Parameter(Mandatory)]
        [string]$Instance
    )

    $parts = @($Instance -split '\\' | Where-Object { $_ -ne '' })

    if ($parts.Count -lt 2) {
        return $null
    }

    $artifactMatch = '(?i)(StatusFiles|LocalUser|Logon|Disconnected|Anonymous)'

    $user   = $parts[$parts.Count - 2] + '\' + $parts[$parts.Count - 1]
    $tokens = $parts[$parts.Count - 3] -split '[:;]'
    $client = $tokens[0]

    if (-not $client -or $client -notmatch '\d+\.\d+\.\d+\.\d+') {
        $client = ($Instance -replace ':\d+$', '')
    }

    if ($parts.Count -ge 3 -and $user -notmatch $artifactMatch -and $client -notmatch $artifactMatch) {
        return @{
            Client = $client
            User   = $user
        }
    }

    if ($parts.Count -lt 3) {
        return $null
    }

    return $null
}

function Get-WildcardCounterPaths {
    param(
        [Object]$CounterSet,
        [string]$CounterRegex
    )

    if (-not $CounterSet) {
        return @()
    }

    return @(
        $CounterSet.PathsWithInstances |
            Where-Object { $_ -match $CounterRegex } |
            ForEach-Object { $_ -replace '\([^)]*\)', '(*)' } |
            Sort-Object -Unique
    )
}

function Get-UsageBar {
    param([double]$Percent)

    if ($Percent -gt 100) {
        $Percent = 100
    }

    $full = [Math]::Floor($Percent / 10)

    if ($full -gt 10) {
        $full = 10
    }

    return ([string][char]0x2588 * $full) + ([string][char]0x2591 * (10 - $full))
}

function Get-SampleIntervalSeconds {
    param(
        [object[]]$OrderedSamples
    )

    $count = $OrderedSamples.Count

    if ($count -lt 2) {
        return 15.0
    }

    $deltas = [System.Collections.Generic.List[double]]::new()

    for ($i = 1; $i -lt $count; $i++) {
        $delta = ($OrderedSamples[$i].Timestamp - $OrderedSamples[$i - 1].Timestamp).TotalSeconds

        if ($delta -gt 0) {
            $deltas.Add($delta)
        }
    }

    if ($deltas.Count -eq 0) {
        return 15.0
    }

    $sorted = @($deltas | Sort-Object)

    if ($sorted.Count % 2 -eq 0) {
        $upper = [int][Math]::Floor($sorted.Count / 2)
        return ([double]$sorted[$upper - 1] + [double]$sorted[$upper]) / 2.0
    }

    return [double]$sorted[[int][Math]::Floor($sorted.Count / 2)]
}

function Get-TimeBucketStart {
    param(
        [datetime]$Timestamp,
        [ValidateSet('Hour', 'Day', 'Week')]
        [string]$Granularity
    )

    switch ($Granularity) {
        'Hour' {
            return $Timestamp.Date.AddHours($Timestamp.Hour)
        }

        'Day' {
            return $Timestamp.Date
        }

        'Week' {
            $date  = $Timestamp.Date
            $delta = ([DayOfWeek]::Monday - $date.DayOfWeek)

            if ($delta -gt 0) {
                $delta -= 7
            }

            return $date.AddDays($delta)
        }
    }
}

function Get-TimeBucketLabel {
    param(
        [datetime]$Timestamp,
        [ValidateSet('Hour', 'Day', 'Week')]
        [string]$Granularity
    )

    switch ($Granularity) {
        'Hour' {
            return $Timestamp.ToString('dd.MM.yyyy HH:00')
        }

        'Day' {
            return $Timestamp.ToString('dd.MM.yyyy')
        }

        'Week' {
            $week = [Globalization.CultureInfo]::InvariantCulture.Calendar.GetWeekOfYear(
                $Timestamp,
                [Globalization.CalendarWeekRule]::FirstFourDayWeek,
                [DayOfWeek]::Monday
            )

            return '{0:D4}-W{1:00}' -f $Timestamp.Year, $week
        }
    }
}

function Get-UsageRows {
    param(
        [ValidateSet('Hour', 'Day', 'Week')]
        [string]$Granularity,
        [object[]]$SmbSamples,
        [object[]]$NetSamples,
        [double]$SmbIntervalSeconds,
        [double]$NetIntervalSeconds
    )

    $buckets = @{}

    foreach ($sample in $SmbSamples) {
        $key = Get-TimeBucketStart -Timestamp $sample.Timestamp -Granularity $Granularity

        if (-not $buckets.ContainsKey($key)) {
            $buckets[$key] = @{ SmbBytes = 0.0; NetBytes = 0.0 }
        }

        $buckets[$key].SmbBytes += $sample.BytesSec
    }

    foreach ($sample in $NetSamples) {
        $key = Get-TimeBucketStart -Timestamp $sample.Timestamp -Granularity $Granularity

        if (-not $buckets.ContainsKey($key)) {
            $buckets[$key] = @{ SmbBytes = 0.0; NetBytes = 0.0 }
        }

        $buckets[$key].NetBytes += $sample.BytesSec
    }

    $rows = foreach ($key in @($buckets.Keys | Sort-Object)) {
        $bucket = $buckets[$key]

        [PSCustomObject]@{
            Zeitraum            = Get-TimeBucketLabel -Timestamp $key -Granularity $Granularity
            'SMB gesamt (GB)'   = [Math]::Round(($bucket.SmbBytes * $SmbIntervalSeconds) / 1GB, 2)
            'Netzwerk gesamt (GB)' = [Math]::Round(($bucket.NetBytes * $NetIntervalSeconds) / 1GB, 2)
        }
    }

    return @($rows)
}

function Get-SmbPulseUsage {
    param(
        [object[]]$Samples
    )

    $smbSamples = @(
        $Samples |
            Where-Object { $_.Type -eq 'SMB' -and $_.Group -eq 'SMB Gesamt' } |
            Sort-Object Timestamp
    )

    $netSamples = @(
        $Samples |
            Where-Object { $_.Type -eq 'Netzwerk' -and $_.Group -like 'Netzwerk Gesamt - *' } |
            Sort-Object Timestamp
    )

    $smbInterval = Get-SampleIntervalSeconds -OrderedSamples $smbSamples
    $netInterval = Get-SampleIntervalSeconds -OrderedSamples $netSamples

    return @{
        Hourly = Get-UsageRows -Granularity Hour  -SmbSamples $smbSamples -NetSamples $netSamples -SmbIntervalSeconds $smbInterval -NetIntervalSeconds $netInterval
        Daily  = Get-UsageRows -Granularity Day   -SmbSamples $smbSamples -NetSamples $netSamples -SmbIntervalSeconds $smbInterval -NetIntervalSeconds $netInterval
        Weekly = Get-UsageRows -Granularity Week  -SmbSamples $smbSamples -NetSamples $netSamples -SmbIntervalSeconds $smbInterval -NetIntervalSeconds $netInterval
    }
}

function Write-UsageTable {
    param(
        [string]$Title,
        [object[]]$Rows
    )

    Write-SectionTitle $Title

    if (-not $Rows -or $Rows.Count -eq 0) {
        Write-Warn ' keine Daten vorhanden'
        Write-Host ''
        return
    }

    Write-Host (' {0,-22} {1,18} {2,18}' -f 'Zeitraum', 'SMB gesamt (GB)', 'Netzwerk gesamt (GB)') -ForegroundColor DarkGray

    foreach ($row in $Rows) {
        Write-Host (' {0,-22} {1,18:N2} {2,18:N2}' -f $row.Zeitraum, $row.'SMB gesamt (GB)', $row.'Netzwerk gesamt (GB)')
    }

    Write-Host ''
}

function Write-TopTable {
    param(
        [string]$Title,
        [object[]]$Rows,
        [string]$NameHeader
    )

    Write-SectionTitle $Title

    if (-not $Rows -or $Rows.Count -eq 0) {
        Write-Warn " keine $NameHeader-Daten vorhanden (Collector ohne Client-Counter oder noch kein Segment abgeschlossen)"
        Write-Host ''
        return
    }

    Write-Host (' {0,-32}{1,16}{2,12}' -f $NameHeader, 'Datenmenge (GB)', 'Anteil (%)') -ForegroundColor DarkGray

    foreach ($row in $Rows) {
        Write-Host (' {0,-32}{1,16:N2}{2,11:N1} %' -f $row.Name, $row.'Datenmenge (GB)', $row.'Anteil (%)')
    }

    Write-Host ''
}

function ConvertTo-HtmlText {
    param([string]$Value)

    return [Net.WebUtility]::HtmlEncode([string]$Value)
}

function Format-Decimal {
    param(
        [double]$Value,
        [int]$Digits = 2
    )

    return $Value.ToString('N' + $Digits, [Globalization.CultureInfo]::InvariantCulture)
}

function Get-UsageTableHtml {
    param(
        [object[]]$Rows
    )

    $max = 0.0

    foreach ($row in $Rows) {
        $smb = [double]$row.'SMB gesamt (GB)'
        $net = [double]$row.'Netzwerk gesamt (GB)'

        if ($smb -gt $max) {
            $max = $smb
        }

        if ($net -gt $max) {
            $max = $net
        }
    }

    if ($max -le 0) {
        $max = 1
    }

    $html = [Text.StringBuilder]::new()
    [void]$html.Append('<table><thead><tr><th>Zeitraum</th><th>SMB gesamt (GB)</th><th>Netzwerk gesamt (GB)</th></tr></thead><tbody>')

    foreach ($row in $Rows) {
        $smb = [double]$row.'SMB gesamt (GB)'
        $net = [double]$row.'Netzwerk gesamt (GB)'

        $smbPercent = [Math]::Round(($smb / $max) * 100, 1)
        $netPercent = [Math]::Round(($net / $max) * 100, 1)

        [void]$html.Append('<tr>')
        [void]$html.Append('<td>' + (ConvertTo-HtmlText -Value $row.Zeitraum) + '</td>')
        [void]$html.Append(('<td><div class="num" data-color="smb">{0} GB</div><div class="bar" data-color="smb"><span style="width:{1}%"></span></div></td>' -f (Format-Decimal $smb), $smbPercent))
        [void]$html.Append(('<td><div class="num" data-color="net">{0} GB</div><div class="bar" data-color="net"><span style="width:{1}%"></span></div></td>' -f (Format-Decimal $net), $netPercent))
        [void]$html.Append('</tr>')
    }

    [void]$html.Append('</tbody></table>')

    return $html.ToString()
}

function Get-PeaksTableHtml {
    param(
        [object[]]$Peaks
    )

    $html = [Text.StringBuilder]::new()
    [void]$html.Append('<table><thead><tr><th>Nr.</th><th>Zeitpunkt</th><th>Messgruppe</th><th>Instanz</th><th>MB/s</th><th>Mbit/s</th></tr></thead><tbody>')

    $index = 1

    foreach ($peak in $Peaks) {
        [void]$html.Append('<tr>')
        [void]$html.Append('<td>' + $index + '</td>')
        [void]$html.Append('<td>' + $peak.Timestamp.ToString('dd.MM.yyyy HH:mm') + '</td>')
        [void]$html.Append('<td>' + (ConvertTo-HtmlText -Value $peak.Group) + '</td>')
        [void]$html.Append('<td>' + (ConvertTo-HtmlText -Value $peak.Instance) + '</td>')
        [void]$html.Append('<td>' + (Format-Decimal $peak.MBsec) + '</td>')
        [void]$html.Append('<td>' + (Format-Decimal $peak.MbitSec) + '</td>')
        [void]$html.Append('</tr>')

        $index++
    }

    [void]$html.Append('</tbody></table>')

    return $html.ToString()
}

function Get-TopTableHtml {
    param(
        [object[]]$Rows,
        [string]$NameHeader
    )

    if (-not $Rows -or $Rows.Count -eq 0) {
        return '<div class="hint">keine Daten vorhanden</div>'
    }

    $max = [double]$Rows[0].'Datenmenge (GB)'

    if ($max -le 0) {
        $max = 1
    }

    $html = [Text.StringBuilder]::new()
    [void]$html.Append(('<table><thead><tr><th>{0}</th><th>Datenmenge (GB)</th><th>Anteil (%)</th></tr></thead><tbody>' -f $NameHeader))

    foreach ($row in $Rows) {
        $gb    = [double]$row.'Datenmenge (GB)'
        $pct   = [double]$row.'Anteil (%)'
        $width = [Math]::Round(($gb / $max) * 100, 1)

        [void]$html.Append('<tr>')
        [void]$html.Append('<td>' + (ConvertTo-HtmlText -Value $row.Name) + '</td>')
        [void]$html.Append(('<td><div class="num" data-color="smb">{0} GB</div><div class="bar" data-color="smb"><span style="width:{1}%"></span></div></td>' -f (Format-Decimal $gb), $width))
        [void]$html.Append(('<td>{0}</td>' -f (Format-Decimal $pct)))
        [void]$html.Append('</tr>')
    }

    [void]$html.Append('</tbody></table>')

    return $html.ToString()
}

function New-SmbPulseHtmlReport {
    param(
        [Parameter(Mandatory)]
        [string]$OutputPath,

        [string]$LogoPath,

        [hashtable]$Summary,

        [object[]]$Daily,
        [object[]]$Weekly,
        [object[]]$Hourly,
        [object[]]$Peaks,
        [object[]]$TopClients,
        [object[]]$TopUsers,
        [object[]]$TopShares
    )

    $css = @'
body {
    margin: 0;
    padding: 0;
    font-family: 'Segoe UI', system-ui, -apple-system, Arial, sans-serif;
    background: #eef2f7;
    color: #1f2937;
}
.wrap {
    max-width: 980px;
    margin: 0 auto;
    padding: 24px 16px 48px;
}
.hero {
    background: linear-gradient(135deg, #4f46e5, #0ea5e9);
    color: #ffffff;
    border-radius: 16px;
    padding: 22px 26px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 16px;
    flex-wrap: wrap;
    box-shadow: 0 8px 24px rgba(79, 70, 229, 0.25);
}
.logo {
    height: 56px;
    width: auto;
    border-radius: 10px;
    background: #ffffff;
    padding: 6px;
}
.hero-text h1 {
    margin: 0;
    font-size: 24px;
}
.hero-text p {
    margin: 4px 0 0;
    opacity: 0.92;
    font-size: 14px;
}
.meta {
    text-align: right;
    font-size: 12px;
    opacity: 0.85;
    line-height: 1.5;
}
.cards {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
    gap: 14px;
    margin: 22px 0;
}
.card {
    background: #ffffff;
    border-radius: 14px;
    padding: 16px 18px;
    box-shadow: 0 2px 8px rgba(31, 41, 55, 0.06);
}
.card .kpi {
    font-size: 12px;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: #6b7280;
}
.card .val {
    font-size: 22px;
    font-weight: 700;
    margin-top: 6px;
}
.card .sub {
    font-size: 12px;
    color: #9ca3af;
    margin-top: 2px;
}
section.box {
    background: #ffffff;
    border-radius: 14px;
    padding: 18px 22px;
    margin: 18px 0;
    box-shadow: 0 2px 8px rgba(31, 41, 55, 0.06);
}
section.box h2 {
    margin: 0 0 12px;
    font-size: 18px;
}
.hint {
    font-size: 12px;
    color: #9ca3af;
    margin-top: 8px;
}
table {
    width: 100%;
    border-collapse: collapse;
    font-size: 14px;
}
th {
    text-align: left;
    color: #6b7280;
    font-weight: 600;
    padding: 8px 10px;
    border-bottom: 2px solid #e5e7eb;
}
td {
    padding: 8px 10px;
    border-bottom: 1px solid #f0f2f5;
}
tbody tr:nth-child(even) td {
    background: #fafbfc;
}
.num {
    font-weight: 600;
}
.num[data-color="smb"] { color: #2563eb; }
.num[data-color="net"] { color: #10b981; }
.bar {
    background: #eef2f7;
    border-radius: 999px;
    height: 8px;
    margin-top: 5px;
    overflow: hidden;
}
.bar span {
    display: block;
    height: 100%;
    border-radius: 999px;
}
.bar[data-color="smb"] span { background: #2563eb; }
.bar[data-color="net"] span { background: #10b981; }
details {
    margin-top: 4px;
}
summary {
    cursor: pointer;
    font-weight: 600;
    color: #2563eb;
    padding: 6px 0;
}
footer {
    text-align: center;
    color: #9ca3af;
    font-size: 12px;
    margin-top: 26px;
}
'@


    $logoImage = ''

    if (Test-Path -LiteralPath $LogoPath -PathType Leaf) {
        $bytes    = [IO.File]::ReadAllBytes($LogoPath)
        $logoImage = '<img src="data:image/png;base64,' + [Convert]::ToBase64String($bytes) + '" alt="SMBpulse" class="logo">'
    }

    $html = [Text.StringBuilder]::new()
    [void]$html.Append('<!DOCTYPE html>')
    [void]$html.Append('<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SMBpulse Report</title><style>')
    [void]$html.Append($css)
    [void]$html.Append('</style></head><body><div class="wrap">')

    [void]$html.Append('<header class="hero">')
    [void]$html.Append($logoImage)
    [void]$html.Append('<div class="hero-text"><h1>SMBpulse - Durchsatz-Report</h1><p>Langzeitmessung des SMB- und Netzwerkdurchsatzes</p></div>')
    [void]$html.Append(('<div class="meta">Erstellt am {0}<br>Zeitraum: {1} - {2}</div>' -f (ConvertTo-HtmlText -Value $Summary.Generated), (ConvertTo-HtmlText -Value $Summary.PeriodFrom), (ConvertTo-HtmlText -Value $Summary.PeriodTo)))
    [void]$html.Append('</header>')

    $cards = @(
        @{ Kpi = 'SMB gesamt';     Val = (Format-Decimal $Summary.SmbTotalGb) + ' GB'; Sub = $Summary.DurationText }
        @{ Kpi = 'Netzwerk gesamt'; Val = (Format-Decimal $Summary.NetTotalGb) + ' GB'; Sub = 'alle Interfaces' }
        @{ Kpi = 'Durchschnitt';   Val = (Format-Decimal $Summary.AverageMbs) + ' MB/s'; Sub = 'SMB' }
        @{ Kpi = 'P95';            Val = (Format-Decimal $Summary.P95Mbs) + ' MB/s';  Sub = 'SMB' }
        @{ Kpi = 'Maximum';        Val = (Format-Decimal $Summary.MaximumMbs) + ' MB/s'; Sub = 'SMB' }
        @{ Kpi = 'Messdauer';      Val = $Summary.DurationText; Sub = 'abgeschlossene Segmente' }
    )

    [void]$html.Append('<div class="cards">')

    foreach ($card in $cards) {
        [void]$html.Append('<div class="card">')
        [void]$html.Append('<div class="kpi">' + (ConvertTo-HtmlText -Value $card.Kpi) + '</div>')
        [void]$html.Append('<div class="val">' + (ConvertTo-HtmlText -Value $card.Val) + '</div>')
        [void]$html.Append('<div class="sub">' + (ConvertTo-HtmlText -Value $card.Sub) + '</div>')
        [void]$html.Append('</div>')
    }

    [void]$html.Append('</div>')

    [void]$html.Append('<section class="box"><h2>Tagesuebersicht</h2>')
    [void]$html.Append((Get-UsageTableHtml -Rows $Daily))
    [void]$html.Append('<div class="hint">Balken relativ zum groessten Tageswert.</div>')
    [void]$html.Append('</section>')

    [void]$html.Append('<section class="box"><h2>Wochenuebersicht</h2>')
    [void]$html.Append((Get-UsageTableHtml -Rows $Weekly))
    [void]$html.Append('<div class="hint">Kalenderwochen nach ISO 8601 (Montag als Wochenbeginn).</div>')
    [void]$html.Append('</section>')

    [void]$html.Append('<section class="box"><h2>Stundenuebersicht</h2>')
    [void]$html.Append(('<details><summary>Stundenwerte anzeigen ({0} Stunden)</summary>' -f $Hourly.Count))
    [void]$html.Append((Get-UsageTableHtml -Rows $Hourly))
    [void]$html.Append('</details>')
    [void]$html.Append('</section>')

    [void]$html.Append('<section class="box"><h2>Spitzenwerte</h2>')
    [void]$html.Append((Get-PeaksTableHtml -Peaks $Peaks))
    [void]$html.Append('</section>')

    if ($TopClients -and $TopClients.Count -gt 0) {
        [void]$html.Append('<section class="box"><h2>Top Clients</h2>')
        [void]$html.Append((Get-TopTableHtml -Rows $TopClients -NameHeader 'Client'))
        [void]$html.Append('<div class="hint">Uebertragene Datenmenge je Client ueber den gesamten Messzeitraum.</div>')
        [void]$html.Append('</section>')
    }

    if ($TopUsers -and $TopUsers.Count -gt 0) {
        [void]$html.Append('<section class="box"><h2>Top Benutzer</h2>')
        [void]$html.Append((Get-TopTableHtml -Rows $TopUsers -NameHeader 'Benutzer'))
        [void]$html.Append('<div class="hint">Uebertragene Datenmenge je Benutzer (Domaene\Benutzer) ueber den gesamten Messzeitraum.</div>')
        [void]$html.Append('</section>')
    }

    if ($TopShares -and $TopShares.Count -gt 0) {
        [void]$html.Append('<section class="box"><h2>Top Freigaben</h2>')
        [void]$html.Append((Get-TopTableHtml -Rows $TopShares -NameHeader 'Freigabe'))
        [void]$html.Append('<div class="hint">Uebertragene Datenmenge je SMB-Freigabe ueber den gesamten Messzeitraum.</div>')
        [void]$html.Append('</section>')
    }

    [void]$html.Append(('<footer>SMBpulse {0} - {1}</footer>' -f (ConvertTo-HtmlText -Value $Summary.Version), (ConvertTo-HtmlText -Value $Summary.Generated)))
    [void]$html.Append('</div></body></html>')

    $parent = Split-Path -Path $OutputPath -Parent

    if (-not (Test-Path -LiteralPath $parent)) {
        New-Item -ItemType Directory -Path $parent -Force | Out-Null
    }

    [IO.File]::WriteAllText($OutputPath, $html.ToString(), [Text.UTF8Encoding]::new($false))
}

# ----------------------------------------------------------------------------
# Get-SmbPulseReport
# ----------------------------------------------------------------------------

function Get-SmbPulseReport {
    <#
    .SYNOPSIS
        Wertet die BLG-Dateien von SMBpulse aus.

    .DESCRIPTION
        Liest alle bereits abgeschlossenen BLG-Segmente aus dem aktuellen sowie aus den
        vorherigen Messpfaden ein. Eine momentan noch aktive/unvollstaendige BLG-Datei
        wird automatisch uebersprungen.

Ausgabe:
          - Konsolenuebersicht mit Durchschnitt, P95 und Maximum
          - Zeitaufschluesselung (stundenweise, tagesweise, wochenweise)
          - Top Clients / Top Benutzer / Top Freigaben (Gesamtzeitraum)
          - SMBpulse-Report.csv
          - SMBpulse-Peaks.csv mit den groessten Einzelspitzen
          - SMBpulse-Stunden.csv / SMBpulse-Tage.csv / SMBpulse-Wochen.csv
          - SMBpulse-Clients.csv / SMBpulse-Benutzer.csv / SMBpulse-Freigaben.csv
          - SMBpulse-Report.html (selbst-enthaltender HTML-Report)

    .PARAMETER TopPeaks
        Anzahl der groessten Einzelspitzen in der Peaks-CSV (1-500, Standard 50).

    .PARAMETER TopClients
        Anzahl der groessten Clients/Benutzer/Freigaben in den Top-Listen und CSVs
        (1-100, Standard 20).

    .EXAMPLE
        Get-SmbPulseReport

    .EXAMPLE
        Get-SmbPulseReport -TopPeaks 100

    .EXAMPLE
        Get-SmbPulseReport -TopClients 10
    #>


    [CmdletBinding()]
    param(
        [ValidateRange(1,500)]
        [int]$TopPeaks = 50,

        [ValidateRange(1,100)]
        [int]$TopClients = 20
    )

    $OutputDir = $Script:OutputDir
    $SummaryCsv = $Script:SummaryCsv
    $PeaksCsv   = $Script:PeaksCsv
    $HourCsv    = Join-Path $OutputDir 'SMBpulse-Stunden.csv'
    $DayCsv     = Join-Path $OutputDir 'SMBpulse-Tage.csv'
    $WeekCsv    = Join-Path $OutputDir 'SMBpulse-Wochen.csv'
    $ClientCsv  = Join-Path $OutputDir 'SMBpulse-Clients.csv'
    $UserCsv    = Join-Path $OutputDir 'SMBpulse-Benutzer.csv'
    $ShareCsv   = Join-Path $OutputDir 'SMBpulse-Freigaben.csv'
    $HtmlPath   = Join-Path $OutputDir 'SMBpulse-Report.html'
    $LogoPath   = Join-Path $PSScriptRoot 'logo.png'

    function Get-Percentile {
        param(
            [double[]]$Values,
            [ValidateRange(0,100)]
            [double]$Percentile
        )

        if (-not $Values -or $Values.Count -eq 0) {
            return 0
        }

        $sorted = @($Values | Sort-Object)
        $index  = [Math]::Ceiling(($Percentile / 100) * $sorted.Count) - 1

        if ($index -lt 0) {
            $index = 0
        }

        return [double]$sorted[$index]
    }

    function Get-CounterInfo {
        param($Counter)

        $path     = [string]$Counter.Path
        $instance = [string]$Counter.InstanceName

        if ($path -match 'SMB-Serverfreigaben|SMB Server Shares') {
            $kind = Get-SmbCounterKind -Path $path

            if (-not $kind) {
                return $null
            }

            $group = switch ($kind) {
                'Gesamt'    { 'SMB Gesamt' }
                'Lesen'     { 'SMB Lesen' }
                'Schreiben' { 'SMB Schreiben' }
            }

            return @{ Group = $group; Type = 'SMB'; Instance = '_Total' }
        }

        if ($path -match 'Netzwerkschnittstelle|Network Interface') {
            if ($path -match '\\(Gesamtanzahl Bytes/s|Bytes Total/sec)$') {
                return @{ Group = "Netzwerk Gesamt - $instance"; Type = 'Netzwerk'; Instance = $instance }
            }

            if ($path -match '\\(Empfangene Bytes/s|Bytes Received/sec)$') {
                return @{ Group = "Netzwerk Empfang - $instance"; Type = 'Netzwerk'; Instance = $instance }
            }

            if ($path -match '\\(Bytes gesendet/s|Bytes Sent/sec)$') {
                return @{ Group = "Netzwerk Senden - $instance"; Type = 'Netzwerk'; Instance = $instance }
            }
        }

        if ($path -match 'SMB-Clientkontexte|SMB Client Contexts') {
            if ($path -match '\\(Datenbytes/Sek\.|Data bytes/sec)$') {
                $parsed = Get-SmbClientParts -Instance $instance

                if (-not $parsed) {
                    return $null
                }

                return @{
                    Group    = "Client $($parsed.Client)"
                    Type     = 'Client'
                    Instance = $instance
                    Client   = $parsed.Client
                    User     = $parsed.User
                }
            }
        }

        if ($path -match 'SMB-Clientfreigaben|SMB Client Shares') {
            if ($path -match '\\(Datenbytes/Sek\.|Data bytes/sec)$') {
                $share = ($instance -split '\\')[-1]

                if (-not $share) {
                    return $null
                }

                return @{
                    Group  = "Freigabe $share"
                    Type   = 'Share'
                    Instance = $instance
                    Share  = $share
                }
            }
        }

        return $null
    }

    Write-MonitorBanner -Title 'SMBpulse Report' -Subtitle 'Auswertung der abgeschlossenen BLG-Segmente'

    New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null

    $files = @()

    foreach ($dir in $Script:LogDirs) {
        if (Test-Path $dir) {
            $files += Get-ChildItem -Path $dir -Filter '*.blg' -File -ErrorAction SilentlyContinue
        }
    }

    $files = @($files | Sort-Object FullName -Unique)

    if ($files.Count -eq 0) {
        Write-Warn 'Es wurden noch keine BLG-Dateien gefunden.'
        return
    }

    Write-Muted "Gefundene BLG-Dateien: $($files.Count)"
    Write-Host ''

    $samples       = New-Object System.Collections.Generic.List[object]
    $readableFiles = 0
    $skippedFiles  = 0

    foreach ($file in ($files | Sort-Object LastWriteTime)) {
        Write-Host -NoNewline "Pruefe $($file.FullName) ... "

        try {
            $data  = @(Import-Counter -Path $file.FullName -ErrorAction Stop)
            $added = 0

            foreach ($sampleSet in $data) {
                foreach ($counter in $sampleSet.CounterSamples) {
                    $info = Get-CounterInfo -Counter $counter

                    if (-not $info) {
                        continue
                    }

                    $value = [double]$counter.CookedValue

                    if ([double]::IsNaN($value) -or [double]::IsInfinity($value) -or $value -lt 0) {
                        continue
                    }

                    $samples.Add([PSCustomObject]@{
                        Timestamp = $sampleSet.Timestamp
                        Group     = $info.Group
                        Type      = $info.Type
                        Instance  = $info.Instance
                        Client    = $info.Client
                        User      = $info.User
                        Share     = $info.Share
                        BytesSec  = $value
                        MBsec     = [Math]::Round($value / 1MB, 3)
                        MbitSec   = [Math]::Round(($value * 8) / 1000000, 3)
                        Source    = $file.Name
                    })

                    $added++
                }
            }

            if ($added -gt 0) {
                Write-Ok "OK ($added Samples)"
                $readableFiles++
            }
            else {
                Write-Warn 'keine relevanten Daten'
            }
        }
        catch {
            Write-Warn 'noch aktiv/unlesbar - uebersprungen'
            $skippedFiles++
        }
    }

    if ($samples.Count -eq 0) {
        Write-Host ''
        Write-Warn 'Noch keine abgeschlossene BLG-Datei mit auswertbaren Daten vorhanden.'
        Write-Warn 'Bei der Standardkonfiguration entsteht nach etwa einer Stunde das erste abgeschlossene Segment.'
        return
    }

    $results = foreach ($group in ($samples | Group-Object Group)) {
        $values = @($group.Group | ForEach-Object { [double]$_.BytesSec })

        $avg = [double](($values | Measure-Object -Average).Average)
        $max = [double](($values | Measure-Object -Maximum).Maximum)
        $p95 = [double](Get-Percentile -Values $values -Percentile 95)

        $first = ($group.Group | Sort-Object Timestamp | Select-Object -First 1).Timestamp
        $last  = ($group.Group | Sort-Object Timestamp | Select-Object -Last 1).Timestamp

        [PSCustomObject]@{
            Counter               = $group.Name
            Samples               = $values.Count
            Von                   = $first
            Bis                   = $last
            'Durchschnitt MB/s'   = [Math]::Round($avg / 1MB, 2)
            'P95 MB/s'            = [Math]::Round($p95 / 1MB, 2)
            'Maximum MB/s'        = [Math]::Round($max / 1MB, 2)
            'Durchschnitt Mbit/s' = [Math]::Round(($avg * 8) / 1000000, 2)
            'P95 Mbit/s'          = [Math]::Round(($p95 * 8) / 1000000, 2)
            'Maximum Mbit/s'      = [Math]::Round(($max * 8) / 1000000, 2)
        }
    }

    $results = @($results | Sort-Object Counter)

    Write-Host ''
    Write-Ok "Lesbare BLG-Dateien: $readableFiles"
    Write-Warn "Uebersprungene BLG-Dateien: $skippedFiles"

    Write-SectionTitle 'Zusammenfassung'

    foreach ($result in $results) {
        Write-Host ''
        Write-Host $result.Counter -ForegroundColor DarkCyan
        Write-Muted (" {0} Messwerte ({1} - {2})" -f $result.Samples, $result.Von.ToString('dd.MM.yyyy HH:mm'), $result.Bis.ToString('dd.MM.yyyy HH:mm'))

        Write-Host (" Durchschnitt: {0,9:N2} MB/s {1,9:N2} Mbit/s" -f $result.'Durchschnitt MB/s', $result.'Durchschnitt Mbit/s') -ForegroundColor Green
        Write-Host (" P95: {0,9:N2} MB/s {1,9:N2} Mbit/s" -f $result.'P95 MB/s', $result.'P95 Mbit/s') -ForegroundColor Yellow
        Write-Host (" Maximum: {0,9:N2} MB/s {1,9:N2} Mbit/s" -f $result.'Maximum MB/s', $result.'Maximum Mbit/s') -ForegroundColor Red
    }

    $results |
        Export-Csv -Path $SummaryCsv -Delimiter ';' -NoTypeInformation -Encoding UTF8

    $peaks = @(
        $samples |
            Sort-Object BytesSec -Descending |
            Select-Object -First $TopPeaks Timestamp, Group, Instance, MBsec, MbitSec, Source
    )

    $peaks |
        Export-Csv -Path $PeaksCsv -Delimiter ';' -NoTypeInformation -Encoding UTF8

    $usage    = Get-SmbPulseUsage -Samples $samples
    $hourRows = $usage.Hourly
    $dayRows  = $usage.Daily
    $weekRows = $usage.Weekly

    Write-UsageTable -Title 'Datenmenge nach Stunde' -Rows $hourRows
    Write-UsageTable -Title 'Datenmenge nach Tag'    -Rows $dayRows
    Write-UsageTable -Title 'Datenmenge nach Woche'  -Rows $weekRows

    $hourRows | Select-Object Zeitraum, 'SMB gesamt (GB)', 'Netzwerk gesamt (GB)' |
        Export-Csv -Path $HourCsv -Delimiter ';' -NoTypeInformation -Encoding UTF8

    $dayRows | Select-Object Zeitraum, 'SMB gesamt (GB)', 'Netzwerk gesamt (GB)' |
        Export-Csv -Path $DayCsv -Delimiter ';' -NoTypeInformation -Encoding UTF8

    $weekRows | Select-Object Zeitraum, 'SMB gesamt (GB)', 'Netzwerk gesamt (GB)' |
        Export-Csv -Path $WeekCsv -Delimiter ';' -NoTypeInformation -Encoding UTF8

    $clientSamples = @($samples | Where-Object { $_.Type -eq 'Client' } | Sort-Object Timestamp)
    $shareSamples  = @($samples | Where-Object { $_.Type -eq 'Share' }  | Sort-Object Timestamp)

    $clientDt = Get-SampleIntervalSeconds -OrderedSamples $clientSamples
    $shareDt  = Get-SampleIntervalSeconds -OrderedSamples $shareSamples

    $topClients = @(
        if ($clientSamples.Count -gt 0) {
            $clientGroups = @($clientSamples | Group-Object Client)
            $clientTotal  = [double](($clientGroups | ForEach-Object { [double](($_.Group | Measure-Object BytesSec -Sum).Sum) } | Measure-Object -Sum).Sum)

            $clientGroups | ForEach-Object {
                $gb = [double](($_.Group | Measure-Object BytesSec -Sum).Sum) * $clientDt / 1GB

                [PSCustomObject]@{
                    Name              = $_.Name
                    'Datenmenge (GB)' = [Math]::Round($gb, 2)
                    'Anteil (%)'      = if ($clientTotal -gt 0) { [Math]::Round(($gb / $clientTotal) * 100, 1) } else { 0 }
                }
            } | Sort-Object 'Datenmenge (GB)' -Descending | Select-Object -First $TopClients
        }
    )

    $topUsers = @(
        if ($clientSamples.Count -gt 0) {
            $userSamples = @($clientSamples | Where-Object { $_.User })
            $userGroups  = @($userSamples | Group-Object User)
            $userTotal   = [double](($userGroups | ForEach-Object { [double](($_.Group | Measure-Object BytesSec -Sum).Sum) } | Measure-Object -Sum).Sum)

            $userGroups | ForEach-Object {
                $gb = [double](($_.Group | Measure-Object BytesSec -Sum).Sum) * $clientDt / 1GB

                [PSCustomObject]@{
                    Name              = $_.Name
                    'Datenmenge (GB)' = [Math]::Round($gb, 2)
                    'Anteil (%)'      = if ($userTotal -gt 0) { [Math]::Round(($gb / $userTotal) * 100, 1) } else { 0 }
                }
            } | Sort-Object 'Datenmenge (GB)' -Descending | Select-Object -First $TopClients
        }
    )

    $topShares = @(
        if ($shareSamples.Count -gt 0) {
            $shareGroups = @($shareSamples | Group-Object Share)
            $shareTotal  = [double](($shareGroups | ForEach-Object { [double](($_.Group | Measure-Object BytesSec -Sum).Sum) } | Measure-Object -Sum).Sum)

            $shareGroups | ForEach-Object {
                $gb = [double](($_.Group | Measure-Object BytesSec -Sum).Sum) * $shareDt / 1GB

                [PSCustomObject]@{
                    Name              = $_.Name
                    'Datenmenge (GB)' = [Math]::Round($gb, 2)
                    'Anteil (%)'      = if ($shareTotal -gt 0) { [Math]::Round(($gb / $shareTotal) * 100, 1) } else { 0 }
                }
            } | Sort-Object 'Datenmenge (GB)' -Descending | Select-Object -First $TopClients
        }
    )

    Write-TopTable -Title 'Top Clients (nach Datenmenge)'  -Rows $topClients -NameHeader 'Client'
    Write-TopTable -Title 'Top Benutzer (nach Datenmenge)' -Rows $topUsers   -NameHeader 'Benutzer'
    Write-TopTable -Title 'Top Freigaben (nach Datenmenge)' -Rows $topShares -NameHeader 'Freigabe'

    if ($topClients.Count -gt 0) {
        $topClients | Select-Object Name, 'Datenmenge (GB)', 'Anteil (%)' |
            Export-Csv -Path $ClientCsv -Delimiter ';' -NoTypeInformation -Encoding UTF8
    }

    if ($topUsers.Count -gt 0) {
        $topUsers | Select-Object Name, 'Datenmenge (GB)', 'Anteil (%)' |
            Export-Csv -Path $UserCsv -Delimiter ';' -NoTypeInformation -Encoding UTF8
    }

    if ($topShares.Count -gt 0) {
        $topShares | Select-Object Name, 'Datenmenge (GB)', 'Anteil (%)' |
            Export-Csv -Path $ShareCsv -Delimiter ';' -NoTypeInformation -Encoding UTF8
    }

    $smbResult = @($results | Where-Object { $_.Counter -eq 'SMB Gesamt' }) | Select-Object -First 1

    $averageMbs = 0.0
    $p95Mbs     = 0.0
    $maxMbs     = 0.0

    if ($smbResult) {
        $averageMbs = [double]$smbResult.'Durchschnitt MB/s'
        $p95Mbs     = [double]$smbResult.'P95 MB/s'
        $maxMbs     = [double]$smbResult.'Maximum MB/s'
    }

    $allSamples   = @($samples | Sort-Object Timestamp)
    $periodFrom   = $allSamples[0].Timestamp
    $periodTo     = $allSamples[$allSamples.Count - 1].Timestamp
    $periodLength = $periodTo - $periodFrom

    if ($periodLength.TotalDays -ge 1) {
        $durationText = '{0} Tage {1} Std' -f [int][Math]::Floor($periodLength.TotalDays), $periodLength.Hours
    }
    else {
        $durationText = '{0} Std {1} Min' -f $periodLength.Hours, $periodLength.Minutes
    }

    $smbTotalGb = [Math]::Round([double](($hourRows | Measure-Object -Property 'SMB gesamt (GB)' -Sum).Sum), 2)
    $netTotalGb = [Math]::Round([double](($hourRows | Measure-Object -Property 'Netzwerk gesamt (GB)' -Sum).Sum), 2)

    $htmlSummary = @{
        SmbTotalGb   = $smbTotalGb
        NetTotalGb   = $netTotalGb
        AverageMbs   = $averageMbs
        P95Mbs       = $p95Mbs
        MaximumMbs   = $maxMbs
        DurationText = $durationText
        PeriodFrom   = $periodFrom.ToString('dd.MM.yyyy HH:mm')
        PeriodTo     = $periodTo.ToString('dd.MM.yyyy HH:mm')
        Generated    = (Get-Date).ToString('dd.MM.yyyy HH:mm')
        Version      = $MyInvocation.MyCommand.Module.Version.ToString()
    }

    New-SmbPulseHtmlReport -OutputPath $HtmlPath -LogoPath $LogoPath -Summary $htmlSummary -Daily $dayRows -Weekly $weekRows -Hourly $hourRows -Peaks $peaks -TopClients $topClients -TopUsers $topUsers -TopShares $topShares

    Write-SectionTitle 'Ergebnisdateien'
    Write-Ok " $SummaryCsv"
    Write-Ok " $PeaksCsv"
    Write-Ok " $HourCsv"
    Write-Ok " $DayCsv"
    Write-Ok " $WeekCsv"

    if ($topClients.Count -gt 0) {
        Write-Ok " $ClientCsv"
    }

    if ($topUsers.Count -gt 0) {
        Write-Ok " $UserCsv"
    }

    if ($topShares.Count -gt 0) {
        Write-Ok " $ShareCsv"
    }

    Write-Ok " $HtmlPath"
    Write-Host ''

    return $results
}

# ----------------------------------------------------------------------------
# Uninstall-SmbPulse
# ----------------------------------------------------------------------------

function Uninstall-SmbPulse {
    <#
    .SYNOPSIS
        Entfernt alle systemseitigen Aenderungen von SMBpulse.

    .DESCRIPTION
        Stoppt und loescht:
          - SMB_Pulse
          - den alten Collector SMB_Throughput_Monitor
          - den alten Collector SMB_7Tage

        Standardmaessig werden ausserdem die von SMBpulse verwendeten
        Messdaten-Verzeichnisse geloescht. Vor dem Loeschen der Messdaten fragt das
        Cmdlet nach; mit -Force wird die Rueckfrage uebersprungen, mit -KeepLogs
        werden die Messdaten grundsaetzlich behalten.

        Windows Performance Logs and Alerts (PLA) kann die zuletzt beschriebene BLG-Datei
        nach "logman stop/delete" noch einige Sekunden geoeffnet halten. Das Cmdlet
        wartet deshalb gezielt auf die Dateifreigabe und wiederholt die Loeschung.

        Der Dienst "Performance Logs & Alerts" wird bewusst NICHT global beendet,
        damit andere Performance-Collector-Sets auf dem Server nicht gestoert werden.

    .PARAMETER KeepLogs
        Behaelt die Messdaten, stoppt und entfernt aber die Collector-Sets.

    .PARAMETER Force
        Ueberspringt die Rueckfrage vor dem Loeschen der Messdaten.

    .PARAMETER WaitSeconds
        Maximale Wartezeit auf die Freigabe geoeffneter BLG-/CSV-Dateien (5-120, Standard 30).

    .EXAMPLE
        Uninstall-SmbPulse

    .EXAMPLE
        Uninstall-SmbPulse -Force

    .EXAMPLE
        Uninstall-SmbPulse -KeepLogs -WaitSeconds 60
    #>


    [CmdletBinding()]
    param(
        [switch]$KeepLogs,

        [switch]$Force,

        [ValidateRange(5,120)]
        [int]$WaitSeconds = 30
    )

    Assert-Administrator

    $Collectors = $Script:LegacyCollectors + @($Script:CollectorName)
    $LogDirs    = $Script:LogDirs

    function Test-CollectorExists {
        param([string]$Name)

        return ((Invoke-Logman -ArgumentList @('query', $Name) -Quiet) -eq 0)
    }

    function Stop-AndRemoveCollector {
        param([string]$Name)

        if (-not (Test-CollectorExists -Name $Name)) {
            Write-Muted "Datensammler '$Name' ist nicht vorhanden."
            return
        }

        Write-Host "Stoppe Datensammler '$Name' ..."
        $null = Invoke-Logman -ArgumentList @('stop', $Name) -Quiet

        # PLA arbeitet beim Schliessen der aktiven BLG teilweise asynchron.
        Start-Sleep -Seconds 1

        Write-Host "Loesche Datensammler '$Name' ..."
        $null = Invoke-Logman -ArgumentList @('delete', $Name) -Quiet

        for ($i = 0; $i -lt $WaitSeconds; $i++) {
            if (-not (Test-CollectorExists -Name $Name)) {
                Write-Ok ' entfernt'
                return
            }

            Start-Sleep -Seconds 1
        }

        throw "Der Datensammler '$Name' konnte innerhalb von $WaitSeconds Sekunden nicht vollstaendig entfernt werden."
    }

    function Remove-DirectoryWithRetry {
        param(
            [string]$Path,
            [int]$TimeoutSeconds
        )

        if (-not (Test-Path -LiteralPath $Path)) {
            Write-Muted "$Path ist nicht vorhanden."
            return
        }

        Write-Host "Loesche $Path ..."

        $lastError = $null

        for ($i = 0; $i -lt $TimeoutSeconds; $i++) {
            try {
                Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop

                if (-not (Test-Path -LiteralPath $Path)) {
                    Write-Ok ' entfernt'
                    return
                }
            }
            catch [System.IO.IOException] {
                $lastError = $_
            }
            catch [System.UnauthorizedAccessException] {
                $lastError = $_
            }

            if ($i -eq 0) {
                Write-Warn ' Datei noch durch Windows/PLA geoeffnet - warte auf Freigabe ...'
            }

            Start-Sleep -Seconds 1
        }

        # Noch einmal anzeigen, welche Dateien ggf. uebrig sind.
        $remaining = @(
            Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue |
            Select-Object -ExpandProperty FullName
        )

        Write-Host ''
        Write-Err "Das Verzeichnis konnte nach $TimeoutSeconds Sekunden noch nicht vollstaendig geloescht werden."

        if ($remaining.Count -gt 0) {
            Write-Warn 'Noch vorhandene Dateien:'
            $remaining | ForEach-Object { Write-Host " $_" }
        }

        if ($lastError) {
            throw $lastError
        }

        throw "Loeschen von '$Path' fehlgeschlagen."
    }

    Write-MonitorBanner -Title 'SMBpulse' -Subtitle 'Deinstallation'

    $removeLogs = -not $KeepLogs

    $planLines = @(
        ('Datensammler: ' + ($Collectors -join ', ')),
        ('Messpfad: ' + ($LogDirs -join ', '))
    )

    if ($KeepLogs) {
        $planLines += 'Messdaten: bleiben erhalten (-KeepLogs)'
    }

    Write-InfoBox -Title 'Durchgefuehrte Aenderungen' -Lines $planLines

    if ($removeLogs -and -not $Force) {
        Write-Warn 'Die Messdaten-Verzeichnisse werden vollstaendig geloescht.'
        $answer = Read-Host 'Fortfahren [J/N]'

        if ($answer -notmatch '^(j|ja|y|yes)$') {
            $removeLogs = $false
            Write-Warn 'Messdaten werden behalten.'
        }
    }

    Write-SectionTitle 'Datensammler entfernen'

    foreach ($collector in $Collectors) {
        Stop-AndRemoveCollector -Name $collector
    }

    if ($removeLogs) {
        Write-SectionTitle 'Messdaten loeschen'
        Write-Host 'Warte kurz auf die Freigabe eventuell noch geoeffneter BLG-Dateien ...'
        Start-Sleep -Seconds 2

        foreach ($dir in $LogDirs) {
            Remove-DirectoryWithRetry -Path $dir -TimeoutSeconds $WaitSeconds
        }
    }
    else {
        Write-SectionTitle 'Messdaten behalten'
        Write-Warn 'Die Messdaten werden nicht geloescht.'
    }

    Write-Host ''
    Write-Ok 'Deinstallation abgeschlossen.'
    Write-Host ''
}

Export-ModuleMember -Function 'Start-SmbPulse', 'Watch-SmbPulse', 'Get-SmbPulseReport', 'Uninstall-SmbPulse'