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'

$Script:SessionIndexTask   = 'SMBpulse-SessionIndex'
$Script:SessionIndexScript = Join-Path $Script:OutputDir 'SMBpulse-SessionIndex.ps1'
$Script:SessionIndexCsv    = Join-Path $Script:OutputDir 'SMBpulse-Sessions.csv'

$Script:ModuleVersion = [version]'0.0.0'

try {
    if ($MyInvocation.MyCommand.Module) {
        $Script:ModuleVersion = $MyInvocation.MyCommand.Module.Version
    }
    else {
        $psd1 = Join-Path $PSScriptRoot 'SMBpulse.psd1'

        if (Test-Path -LiteralPath $psd1) {
            $psdData = Import-PowerShellDataFile -Path $psd1

            if ($psdData -and $psdData.ModuleVersion) {
                $Script:ModuleVersion = [version]$psdData.ModuleVersion
            }
        }
    }
}
catch {
    $Script:ModuleVersion = [version]'0.0.0'
}

# ----------------------------------------------------------------------------
# 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)) + '|'

    $versionText = 'SMBpulse Version ' + $Script:ModuleVersion
    $vpad  = [Math]::Max(0, [int](($width - 2 - $versionText.Length) / 2))
    $vline = '|' + (' ' * $vpad) + $versionText + (' ' * ($width - 2 - $vpad - $versionText.Length)) + '|'

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

    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
}

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

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

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

    return $parts[$parts.Count - 1]
}

function Test-AdminShareName {
    param(
        [Parameter(Mandatory)][string]$Name
    )

    return ($Name -match '\$$')
}

function Get-SmbPrinterShareNames {
    $names = @()

    try {
        $names = @(
            Get-SmbShare -ErrorAction Stop |
                Where-Object { $_.Description -match 'LocalsplOnly' } |
                Select-Object -ExpandProperty Name
        )
    }
    catch { }

    return $names
}

function Get-SmbSessionMap {
    $map = @{}

    try {
        foreach ($session in @(Get-SmbSession -ErrorAction Stop)) {
            $map[[string]$session.SessionId] = @{
                Client = [string]$session.ClientComputerName
                User   = [string]$session.ClientUserName
            }
        }
    }
    catch { }

    return $map
}

function Invoke-SmbSessionSnapshot {
    param(
        [string]$Path = $Script:SessionIndexCsv
    )

    $dir = Split-Path -Parent $Path

    if ($dir) {
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
    }

    $rows = @(Get-SmbSession -ErrorAction SilentlyContinue | Select-Object -Property SessionId, ClientComputerName, ClientUserName)

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

    if (-not (Test-Path -LiteralPath $Path)) {
        '"Timestamp","SessionId","ClientComputerName","ClientUserName"' |
            Out-File -LiteralPath $Path -Encoding UTF8
    }

    $stamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')

    $lines = foreach ($row in $rows) {
        $client = ([string]$row.ClientComputerName).Replace('"', '""')
        $user   = ([string]$row.ClientUserName).Replace('"', '""')

        '"{0}","{1}","{2}","{3}"' -f $stamp, $row.SessionId, $client, $user
    }

    $lines | Out-File -LiteralPath $Path -Encoding UTF8 -Append
}

function New-SmbSessionIndexScript {
    $content = @'
# SMBpulse Session-Index (wird von Start-SmbPulse erzeugt)
$ErrorActionPreference = 'SilentlyContinue'

$csv = 'C:\SMBpulse\SMBpulse-Sessions.csv'
$dir = Split-Path -Parent $csv

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

if (-not (Test-Path -LiteralPath $csv)) {
    '"Timestamp","SessionId","ClientComputerName","ClientUserName"' | Out-File -LiteralPath $csv -Encoding UTF8
}

$rows = @(Get-SmbSession | Select-Object -Property SessionId, ClientComputerName, ClientUserName)

if ($rows.Count -gt 0) {
    $stamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')

    $lines = foreach ($row in $rows) {
        $client = ([string]$row.ClientComputerName).Replace('"', '""')
        $user = ([string]$row.ClientUserName).Replace('"', '""')

        '"{0}","{1}","{2}","{3}"' -f $stamp, $row.SessionId, $client, $user
    }

    $lines | Out-File -LiteralPath $csv -Encoding UTF8 -Append
}
'@


    $script = Join-Path $Script:OutputDir 'SMBpulse-SessionIndex.ps1'

    $dir = Split-Path -Parent $script

    if ($dir) {
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
    }

    [System.IO.File]::WriteAllText($script, $content, [System.Text.Encoding]::ASCII)
}

function Invoke-Schtasks {
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$Arguments
    )

    $schtasks = Join-Path $env:SystemRoot 'System32\schtasks.exe'
    $oldEap   = $ErrorActionPreference
    $ErrorActionPreference = 'Continue'

    try {
        & $schtasks @Arguments 2>&1 | Out-Null
        return $LASTEXITCODE
    }
    finally {
        $ErrorActionPreference = $oldEap
    }
}

function Remove-SmbSessionIndexTask {
    param(
        [string]$Name = $Script:SessionIndexTask
    )

    $exit = Invoke-Schtasks -Arguments @('/query', '/tn', $Name)

    if ($exit -ne 0) {
        return
    }

    Invoke-Schtasks -Arguments @('/end', '/tn', $Name) | Out-Null
    Invoke-Schtasks -Arguments @('/delete', '/tn', $Name, '/f') | Out-Null
}

function New-SmbSessionIndexTask {
    param(
        [string]$Name       = $Script:SessionIndexTask,
        [string]$ScriptPath = $Script:SessionIndexScript,
        [int]$Minutes       = 5
    )

    Remove-SmbSessionIndexTask -Name $Name

    $inner = 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' + $ScriptPath + '"'
    $tr    = '"' + $inner.Replace('"', '\"') + '"'

    $exit = Invoke-Schtasks -Arguments @('/create', '/tn', $Name, '/tr', $tr, '/sc', 'minute', '/mo', ('{0}' -f $Minutes), '/ru', 'SYSTEM', '/f')

    if ($exit -ne 0) {
        throw "Der geplante Task '$Name' konnte nicht erstellt werden (Exitcode $exit)."
    }

    Invoke-Schtasks -Arguments @('/run', '/tn', $Name) | Out-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.

        Ergaenzend wird ein geplanter Task "SMBpulse-SessionIndex" angelegt, der
        alle 5 Minuten einen Schnappschuss der SMB-Serversitzungen in eine CSV
        schreibt. Dadurch koennen die gemessenen Sitzungs-IDs im Report den
        Clients und Benutzern zugeordnet werden.

        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.'
    }

    $SessionSet = @($AllSets) |
        Where-Object {
            $_.CounterSetName -eq 'SMB-Serversitzungen' -or
            $_.CounterSetName -eq 'SMB Server Sessions'
        } |
        Select-Object -First 1

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

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

    if ($SessionPaths.Count -eq 0) {
        Write-Warn 'Der Counter-Satz "SMB Server Sessions" wurde nicht gefunden - Client-/Benutzer-Details werden nicht aufgezeichnet.'
    }

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

    if ($SharePaths.Count -gt 0 -and $SmbData) {
        $SmbCounters = @($SmbCounters | Where-Object { $_ -ne $SmbData })
    }

    $Counters = @($SmbCounters | Sort-Object -Unique) + @($NetCounters | Sort-Object -Unique) +
        @($SessionPaths | 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-SectionTitle 'Session-Index einrichten'
    New-SmbSessionIndexScript
    Invoke-SmbSessionSnapshot -Path $Script:SessionIndexCsv
    New-SmbSessionIndexTask
    Write-Ok "Session-Index-Task '$($Script:SessionIndexTask)' aktiv (alle 5 Minuten)."

    Write-InfoBox -Title 'Ueberblick' -Lines @(
        "Collector: $CollectorName",
        "Dauer: $Days Tag(e)",
        "Intervall: $SampleSeconds Sekunden",
        "BLG-Segment: $SegmentMinutes Minuten",
        "Ausgabe: $OutputDir",
        "Session-Index: $($Script:SessionIndexCsv)"
    )

    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 Get-SmbReferenceMbps {
    param(
        [string]$Mode = 'Auto'
    )

    $speeds = @{}
    $norm   = 1000.0

    if ($Mode -ne 'Auto') {
        return [PSCustomObject]@{
            Norm   = [double]$Mode
            Speeds = $speeds
        }
    }

    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) {
                    $speeds[$key] = $mbit
                }
            }
        }

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

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

    return [PSCustomObject]@{
        Norm   = $norm
        Speeds = $speeds
    }
}

$Script:CollectionCheckTime = $null
$Script:CollectionCheckLine = ''

function Get-SmbCollectionStatusLine {
    $now = Get-Date

    if (-not $Script:CollectionCheckTime -or ($now - $Script:CollectionCheckTime).TotalSeconds -ge 5) {
        $Script:CollectionCheckTime = $now

        $last = @(
            Get-ChildItem -LiteralPath $Script:OutputDir -Filter '*.blg' -File -ErrorAction SilentlyContinue |
                Sort-Object LastWriteTime -Descending |
                Select-Object -First 1
        )

        if ($last.Count -eq 0) {
            $Script:CollectionCheckLine = ("Sammlung: keine Messdaten in {0} - Start-SmbPulse" -f $Script:OutputDir)
        }
        else {
            $staleSeconds = ($now - $last[0].LastWriteTime).TotalSeconds

            if ($staleSeconds -le 60) {
                $Script:CollectionCheckLine = ("Sammlung: aktiv ({0}, letzte Probe {1:HH:mm:ss})" -f $Script:CollectorName, $last[0].LastWriteTime)
            }
            else {
                $Script:CollectionCheckLine = ("Sammlung: letzte Probe {0:HH:mm:ss} (vor {1:N0} s) - laeuft Start-SmbPulse?" -f $last[0].LastWriteTime, $staleSeconds)
            }
        }
    }

    return $Script:CollectionCheckLine
}

function Watch-SmbPulse {
    <#
    .SYNOPSIS
        Live-Anzeige des aktuellen SMB- und Netzwerk-Durchsatzes samt Status der
        Langzeit-Erfassung.

    .DESCRIPTION
        Zeigt den aktuellen Durchsatz direkt aus den Windows Performance Countern
        und ob der Langzeit-Collector aktuell Messdaten aufzeichnet (letzte Probe
        der BLG-Datei in C:\SMBpulse). 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 Auslastung relativ zur
            tatsaechlichen Link-Geschwindigkeit (via Get-NetAdapter)
          - Status der Langzeit-Erfassung (letzte gemessene Probe)

        Jede Zeile zeigt die Auslastung in Prozent relativ zur jeweiligen
        Link-Geschwindigkeit (gruen unter 70 %, gelb 70-90 %, rot ueber 90 %).

        Die Analyse der Top-Clients, Top-Benutzer und Top-Freigaben liefert
        Get-SmbPulseReport (Konsolenausgabe, CSV und HTML-Report).

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

    .PARAMETER PerShare
        Zeigt zusaetzlich die einzelnen SMB-Freigaben statt nur _Total.
        Verursacht eine zusaetzliche Performance-Counter-Abfrage.

    .PARAMETER ReferenceMbps
        Referenz-Linkgeschwindigkeit in Mbit/s fuer die Auslastungs-Skala.
        'Auto' nutzt die schnellste aktive NIC (Standard). Bei virtuellen
        NICs (z.B. Hyper-V VirtIO mit 10 Gbit/s intern, nach aussen aber
        nur 1 Gbit/s) kann die Skala mit 100, 1000 oder 10000 manuell
        gesetzt werden. Wirkt auf Kopfzeile, SMB-Durchsatz und die
        Netzwerk-Sektion.

    .EXAMPLE
        Watch-SmbPulse

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


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

        [switch]$PerShare,

        [ValidateSet('Auto', '100', '1000', '10000')]
        [string]$ReferenceMbps = 'Auto'
    )

    $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)$')

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

    $ShareCounterPaths = @(Get-WildcardCounterPaths -CounterSet $ShareSet  -CounterRegex '\\(Datenbytes/Sek\.|Data bytes/sec)$')
    $ShareReadPaths    = @(Get-WildcardCounterPaths -CounterSet $ShareSet  -CounterRegex '\\(Gelesene Bytes/s|Read Bytes/sec)$')
    $ShareWritePaths   = @(Get-WildcardCounterPaths -CounterSet $ShareSet  -CounterRegex '\\(Geschriebene Bytes/Sek\.|Write Bytes/sec)$')

    $shareExcludes = @(Get-SmbPrinterShareNames)

    $ShareTotalPaths = @(
        @($ShareCounterPaths) + @($ShareReadPaths) + @($ShareWritePaths) |
            ForEach-Object { $_ -replace '\(\*\)', '(_Total)' } |
            Select-Object -Unique
    )

    $allCounterPaths = @($NetCounterPaths + $ShareTotalPaths)

    if ($PerShare) {
        $allCounterPaths = @($allCounterPaths + $ShareCounterPaths + $ShareReadPaths + $ShareWritePaths)
    }

    $refDetect   = Get-SmbReferenceMbps -Mode $ReferenceMbps
    $refLinkMbps = $refDetect.Norm
    $linkSpeeds  = $refDetect.Speeds
    $forceNorm   = ($ReferenceMbps -ne 'Auto')

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

        $percent = if ($RefMbps -gt 0) { ($MbitSec / $RefMbps) * 100 } else { 0 }

        return (' {0,-24}{1,-10}{2,10}{3,10} {4,6:N1} % ' -f $Label, $Kind, ('{0:N2}' -f $MBPS), ('{0:N2}' -f $MbitSec), $percent)
    }

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

    Write-Host "Intervall: $IntervalSeconds Sekunde(n)"

    if ($forceNorm) {
        Write-Host ("Link-Norm: {0:N0} Mbit/s (manuell via -ReferenceMbps)" -f $refLinkMbps)
    }
    else {
        Write-Host ("Link-Norm: {0:N0} Mbit/s (via Get-NetAdapter)" -f $refLinkMbps)
    }

    Write-Host 'Beenden: STRG+C'
    Write-Host ''

    $useConsoleFast = $true
    $cursorVisible  = $true

    try {
        $cursorVisible = [Console]::CursorVisible
        [Console]::CursorVisible = $false
    }
    catch {
        $useConsoleFast = $false
    }

    try {
        while ($true) {
            try {
                try {
                    if ($useConsoleFast) {
                        [Console]::Clear()
                    }
                    else {
                        Clear-Host
                    }
                }
                catch {
                    $useConsoleFast = $false
                }

                try {
                    $allSample = @((Get-Counter -Counter $allCounterPaths -ErrorAction Stop).CounterSamples)

                    $rows    = New-Object System.Collections.Generic.List[object]
                    $netRows = @()
                    $seen    = @{}

                    foreach ($counter in $allSample) {
                        $path  = [string]$counter.Path
                        $value = [double]$counter.CookedValue

                        if ($seen.ContainsKey($path)) {
                            continue
                        }

                        $seen[$path] = $true

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

                        $instance = [string]$counter.InstanceName

                        if ($path -match 'Netzwerkschnittstelle|Network Interface') {
                            $linkMbps = $refLinkMbps

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

                            $netRows += [PSCustomObject]@{
                                Nic      = $instance
                                MBPS     = $value / 1MB
                                MbitSec  = ($value * 8) / 1000000
                                LinkMbps = [double]$linkMbps
                            }
                        }
                        else {
                            $kind = Get-SmbCounterKind -Path $path

                            if (-not $kind) {
                                continue
                            }

                            $share      = ($instance -split '\\')[-1]

                            if ($instance -eq '_Total') {
                                $rows.Add([PSCustomObject]@{
                                    Freigabe = 'Alle Freigaben'
                                    Typ      = $kind
                                    MBPS     = $value / 1MB
                                    MbitSec  = ($value * 8) / 1000000
                                })
                            }
                            elseif ($PerShare) {
                                if (-not $share -or (Test-AdminShareName -Name $share) -or ($share -in $shareExcludes)) {
                                    continue
                                }

                                $rows.Add([PSCustomObject]@{
                                    Freigabe = $share
                                    Typ      = $kind
                                    MBPS     = $value / 1MB
                                    MbitSec  = ($value * 8) / 1000000
                                })
                            }
                            else {
                                continue
                            }
                        }
                    }

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

                Write-Host (("SMBpulse Live Monitor v{0} {1}" -f $Script:ModuleVersion, (Get-Date -Format 'dd.MM.yyyy HH:mm:ss'))) -ForegroundColor Cyan

                $sb = New-Object System.Text.StringBuilder

                [void]$sb.AppendLine('=' * 100)
                [void]$sb.AppendLine(("Intervall: {0} Sekunde(n) | Link-Norm: {1:N0} Mbit/s" -f $IntervalSeconds, $refLinkMbps))
                [void]$sb.AppendLine('Beenden: STRG+C')
                [void]$sb.AppendLine('')
                [void]$sb.AppendLine((Get-SmbCollectionStatusLine))
                [void]$sb.AppendLine('')

                [void]$sb.AppendLine('--- SMB-Durchsatz ---')

                if ($sorted.Count -eq 0) {
                    [void]$sb.AppendLine(' (momentan keine verwertbaren Messwerte)')
                }
                else {
                    foreach ($row in $sorted) {
                        [void]$sb.AppendLine((Get-LiveRowText -Label $row.Freigabe -Kind $row.Typ -MBPS $row.MBPS -MbitSec $row.MbitSec -RefMbps $refLinkMbps))
                    }
                }

                [void]$sb.AppendLine('')
                [void]$sb.AppendLine('--- Netzwerk (je Schnittstelle) ---')

                if ($netRows.Count -eq 0) {
                    [void]$sb.AppendLine(' (keine Netzwerk-Gesamtwerte verfuegbar)')
                }
                else {
                    [void]$sb.AppendLine((' {0,-34}{1,-10}{2,10}{3,10} {4,7}' -f 'Schnittstelle', 'Link', 'MB/s', 'Mbit/s', 'Auslast.'))

                    foreach ($row in $netRows) {
                        $percent = if ($row.LinkMbps -gt 0) { ($row.MbitSec / $row.LinkMbps) * 100 } else { 0 }

                        $nicName = [string]$row.Nic

                        if ($nicName.Length -lt 34) {
                            $nicName = $nicName.PadRight(34)
                        }
                        else {
                            $nicName = $nicName + ' '
                        }

                        [void]$sb.AppendLine((' {0}{1,-10}{2,10}{3,10} {4,6:N1} % ' -f $nicName, ('{0:N0}' -f $row.LinkMbps), ('{0:N2}' -f $row.MBPS), ('{0:N2}' -f $row.MbitSec), $percent))
                    }
                }

                [void]$sb.AppendLine('')
                $sz = [string][char]0x00DF
                [void]$sb.AppendLine(("(Auslastung in % relativ zur Link-Norm {0:N0} Mbit/s; Ma{1}stab wie Kopfzeile)" -f $refLinkMbps, $sz))

                Write-Host $sb.ToString()

                Start-Sleep -Seconds $IntervalSeconds
        }
        catch {
            try {
                if ($useConsoleFast) {
                    [Console]::Clear()
                }
                else {
                    Clear-Host
                }
            }
            catch {
            }

            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
        }
    }
}
finally {
    try {
        [Console]::CursorVisible = $cursorVisible
    }
    catch { }

    Clear-Host
}
}

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

function Read-SmbSessionIndex {
    param(
        [string]$Path
    )

    $index = @{}

    if (-not (Test-Path -LiteralPath $Path)) {
        return $index
    }

    $rows = @(Import-Csv -LiteralPath $Path -Encoding UTF8 -ErrorAction SilentlyContinue | Where-Object { $_.SessionId })

    if ($rows.Count -eq 0) {
        return $index
    }

    foreach ($g in @($rows | Group-Object SessionId)) {
        $sorted = @(
            $g.Group |
                Sort-Object {
                    ConvertTo-SmbIndexTime -Value $_.Timestamp
                }
        )

        $index[[string]$g.Name] = @{
            Times   = @($sorted | ForEach-Object { ConvertTo-SmbIndexTime -Value $_.Timestamp })
            Clients = @($sorted | ForEach-Object { [string]$_.ClientComputerName })
            Users   = @($sorted | ForEach-Object { [string]$_.ClientUserName })
        }
    }

    return $index
}

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

    $time = [datetime]::MinValue

    [void][datetime]::TryParseExact(
        $Value,
        'yyyy-MM-dd HH:mm:ss',
        [Globalization.CultureInfo]::InvariantCulture,
        [Globalization.DateTimeStyles]::None,
        [ref]$time
    )

    return $time
}

function Find-SmbSessionSnapshot {
    param(
        [hashtable]$Index,
        [string]$SessionId,
        [datetime]$Timestamp
    )

    $entry = $Index[$SessionId]

    if (-not $entry -or $entry.Times.Count -eq 0) {
        return $null
    }

    $times = $entry.Times
    $lo    = 0
    $hi    = $times.Count - 1
    $best  = -1

    while ($lo -le $hi) {
        $mid = [int](($lo + $hi) / 2)

        if ($times[$mid] -le $Timestamp) {
            $best = $mid
            $lo   = $mid + 1
        }
        else {
            $hi = $mid - 1
        }
    }

    if ($best -lt 0) {
        return $null
    }

    return @{
        Client = $entry.Clients[$best]
        User   = $entry.Users[$best]
    }
}

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-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,
            [datetime]$Timestamp
        )

        $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
            }

            if ($kind -eq 'Gesamt') {
                if ($instance -eq '_Total') {
                    return @{ Group = 'SMB Gesamt'; Type = 'SMB'; Instance = $instance }
                }

                $share = Get-SmbServerShareName -Instance $instance

                if (-not $share -or (Test-AdminShareName -Name $share) -or ($share -in $shareExcludes)) {
                    return $null
                }

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

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

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

        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-Serversitzungen|SMB Server Sessions') {
            if ($path -match '\\(Datenbytes/Sek\.|Data bytes/sec)$') {
                if (-not $instance -or $instance -eq '_Total') {
                    return $null
                }

                $snapshot = Find-SmbSessionSnapshot -Index $sessionIndex -SessionId $instance -Timestamp $Timestamp

                $client = $instance
                $user   = $null

                if ($snapshot) {
                    if ($snapshot.Client) { $client = $snapshot.Client }
                    $user = $snapshot.User
                }

                return @{
                    Group    = "Client $client"
                    Type     = 'Client'
                    Instance = $instance
                    Client   = $client
                    User     = $user
                }
            }
        }

        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

    $sessionIndex = Read-SmbSessionIndex -Path $Script:SessionIndexCsv
    $shareExcludes = @(Get-SmbPrinterShareNames)

    if ($sessionIndex.Count -eq 0) {
        Write-Warn 'Kein Sitzungs-Index gefunden - Clients werden nur als Sitzungs-ID angezeigt.'
    }

    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 -Timestamp $sampleSet.Timestamp

                    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

    $topClientRows = @(
        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
        }
    )

    $topUserRows = @(
        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
        }
    )

    $topShareRows = @(
        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 $topClientRows -NameHeader 'Client'
    Write-TopTable -Title 'Top Benutzer (nach Datenmenge)' -Rows $topUserRows   -NameHeader 'Benutzer'
    Write-TopTable -Title 'Top Freigaben (nach Datenmenge)' -Rows $topShareRows -NameHeader 'Freigabe'

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

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

    if ($topShareRows.Count -gt 0) {
        $topShareRows | 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      = [string]$Script:ModuleVersion
    }

    New-SmbPulseHtmlReport -OutputPath $HtmlPath -LogoPath $LogoPath -Summary $htmlSummary -Daily $dayRows -Weekly $weekRows -Hourly $hourRows -Peaks $peaks -TopClients $topClientRows -TopUsers $topUserRows -TopShares $topShareRows

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

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

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

    if ($topShareRows.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
          - den geplanten Task SMBpulse-SessionIndex (Sitzungs-Index)

        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 ', ')),
        ('Session-Index-Task: ' + $Script:SessionIndexTask),
        ('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
    }

    Write-SectionTitle 'Session-Index entfernen'
    Remove-SmbSessionIndexTask -Name $Script:SessionIndexTask
    Write-Ok "Geplanten Task '$($Script:SessionIndexTask)' entfernt."

    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'