Scripts/measure-report.ps1

#Requires -Version 5.1
# Measures the local Show-SMBeat report path without opening the report.

<#
.SYNOPSIS
    Measures the local SMBeat report pipeline by phase.
.DESCRIPTION
    Uses the same private readers, aggregation, renderer, and optional CSV writer as
    Show-SMBeat. The report is never opened. By default, diagnostic HTML with an
    SMBeat-Measure prefix is written to the report directory. CSV only with -Csv.
.PARAMETER NoExport
    Render HTML in memory but do not write HTML or CSV files.
.PARAMETER Csv
    Also write diagnostic CSV files. Off by default.
.PARAMETER ModulePath
    Manifest path or module name. Defaults to the manifest above this script,
    then to the installed SMBeat module.
.EXAMPLE
    .\measure-report.ps1
.EXAMPLE
    .\measure-report.ps1 -LastDays 7 -NoExport
.EXAMPLE
    .\measure-report.ps1 -Path 'D:\SMBeat' -OutputPath 'D:\SMBeat-Measure'
#>


[CmdletBinding()]
param(
    [datetime]$Start,
    [datetime]$End,
    [int]$LastHours,
    [int]$LastDays,
    [int]$LastWeeks,
    [ValidateSet('Hour', 'Day', 'Week')]
    [string[]]$Granularity = @('Hour', 'Day', 'Week'),
    [object]$TimeZone,
    [string]$Path,
    [string]$OutputPath,
    [ValidateSet('de', 'en')]
    [string]$Language,
    [ValidateSet('de', 'en')]
    [string]$CsvCulture,
    [ValidateSet('health', 'pine', 'dark')]
    [string]$Theme = 'health',
    [string]$ModulePath,
    [switch]$NoExport,
    [switch]$Csv
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

if ([string]::IsNullOrWhiteSpace($ModulePath)) {
    $candidate = Join-Path (Split-Path -Parent $PSScriptRoot) 'SMBeat.psd1'
    if (Test-Path -LiteralPath $candidate) {
        $ModulePath = $candidate
    }
    else {
        $ModulePath = 'SMBeat'
    }
}

$totalWatch = [System.Diagnostics.Stopwatch]::StartNew()
$importWatch = [System.Diagnostics.Stopwatch]::StartNew()
Import-Module $ModulePath -Force -ErrorAction Stop
$importWatch.Stop()

$module = Get-Module -Name SMBeat
if ($null -eq $module) {
    throw 'Import-Module SMBeat failed.'
}

$payload = [PSCustomObject]@{
    HasStart     = $PSBoundParameters.ContainsKey('Start')
    HasEnd       = $PSBoundParameters.ContainsKey('End')
    Start        = $Start
    End          = $End
    LastHours    = $LastHours
    LastDays     = $LastDays
    LastWeeks    = $LastWeeks
    Granularity  = @($Granularity)
    TimeZone     = $TimeZone
    Path         = $Path
    OutputPath   = $OutputPath
    Language     = $Language
    CsvCulture   = $CsvCulture
    Theme        = $Theme
    NoExport     = [bool]$NoExport
    Csv          = [bool]$Csv
}

$measurement = & $module {
    param($P, [double]$ImportMs)

    $phases = New-Object System.Collections.Generic.List[object]
    $outputFiles = New-Object System.Collections.Generic.List[string]

    function Add-SMBeatMeasurePhase {
        param(
            [Parameter(Mandatory = $true)]
            [string]$Name,
            [Parameter(Mandatory = $true)]
            [System.Diagnostics.Stopwatch]$Watch,
            [string]$Detail = ''
        )

        $Watch.Stop()
        [void]$phases.Add([PSCustomObject]@{
            Phase   = $Name
            Seconds = [math]::Round($Watch.Elapsed.TotalSeconds, 3)
            Detail  = $Detail
        })
    }

    [void]$phases.Add([PSCustomObject]@{
        Phase   = 'module-import'
        Seconds = [math]::Round(($ImportMs / 1000.0), 3)
        Detail  = ''
    })

    $watch = [System.Diagnostics.Stopwatch]::StartNew()
    $nativeJson = Initialize-SMBeatSampleJsonNative
    Add-SMBeatMeasurePhase -Name 'native-json-init' -Watch $watch -Detail ('native={0}' -f $nativeJson)

    $watch = [System.Diagnostics.Stopwatch]::StartNew()
    $root = Get-SMBeatDataRoot -Path $P.Path
    $tz = Resolve-SMBeatTimeZone -TimeZone $P.TimeZone
    $config = Read-SMBeatConfig -DataRoot $root
    $interval = [int](Get-SMBeatPropertyValue -Object $config -Name 'IntervalSec' -Default 60)
    if ($interval -le 0) {
        $interval = 60
    }

    $useAvailable = -not $P.HasStart -and $P.LastHours -le 0 -and $P.LastDays -le 0 -and $P.LastWeeks -le 0
    if ($useAvailable) {
        $windowArgs = @{ DataRoot = $root }
        if ($P.HasEnd) {
            $windowArgs['End'] = $P.End
        }
    }
    else {
        $windowArgs = @{
            LastHours = $P.LastHours
            LastDays  = $P.LastDays
            LastWeeks = $P.LastWeeks
        }
        if ($P.HasStart) {
            $windowArgs['Start'] = $P.Start
        }
        if ($P.HasEnd) {
            $windowArgs['End'] = $P.End
        }
    }
    $window = Resolve-SMBeatReportWindow @windowArgs
    $windowStart = $window.StartUtc
    $windowEnd = $window.EndUtc
    $duration = $windowEnd - $windowStart
    $prevEnd = $windowStart
    $prevStart = $windowStart - $duration
    Add-SMBeatMeasurePhase -Name 'resolve-window' -Watch $watch -Detail ('days={0:0.###}' -f $duration.TotalDays)

    $generatedUtc = Get-SMBeatUtcNow
    $spanStart = $generatedUtc.AddDays(-7)
    $loadStart = $windowStart
    if ($spanStart -lt $loadStart) {
        $loadStart = $spanStart
    }
    $loadEnd = $windowEnd
    if ($generatedUtc -gt $loadEnd) {
        $loadEnd = $generatedUtc
    }

    $watch = [System.Diagnostics.Stopwatch]::StartNew()
    $sampleDir = Get-SMBeatSampleDirectory -DataRoot $root
    $inputFiles = @()
    $inputBytes = [int64]0
    if (Test-Path -LiteralPath $sampleDir) {
        $readStart = $prevStart
        if ($loadStart -lt $readStart) {
            $readStart = $loadStart
        }
        $readEnd = $prevEnd
        if ($loadEnd -gt $readEnd) {
            $readEnd = $loadEnd
        }
        $startDay = $readStart.Date.AddDays(-1)
        $endDay = $readEnd.Date.AddDays(1)
        $fileList = New-Object System.Collections.Generic.List[object]
        foreach ($file in @(Get-ChildItem -LiteralPath $sampleDir -Filter '*.jsonl' -ErrorAction SilentlyContinue)) {
            $dayPart = [System.IO.Path]::GetFileNameWithoutExtension($file.Name)
            $fileDay = [datetime]::MinValue
            if (-not [datetime]::TryParseExact($dayPart, 'yyyy-MM-dd', [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None, [ref]$fileDay)) {
                continue
            }
            if ($fileDay -lt $startDay -or $fileDay -gt $endDay) {
                continue
            }
            [void]$fileList.Add($file)
            $inputBytes += [int64]$file.Length
        }
        $inputFiles = @($fileList.ToArray())
    }
    Add-SMBeatMeasurePhase -Name 'scan-input' -Watch $watch -Detail ('files={0} bytes={1}' -f $inputFiles.Count, $inputBytes)

    $watch = [System.Diagnostics.Stopwatch]::StartNew()
    $windows = Read-SMBeatSampleWindows -DataRoot $root -PrevStart $prevStart -PrevEnd $prevEnd -WindowStart $loadStart -WindowEnd $loadEnd
    $loaded = @($windows.Current)
    $previousRecords = @($windows.Previous)
    Add-SMBeatMeasurePhase -Name 'read-samples' -Watch $watch -Detail ('current={0} previous={1}' -f $loaded.Count, $previousRecords.Count)

    $watch = [System.Diagnostics.Stopwatch]::StartNew()
    $records = @(Get-SMBeatRecordsInWindow -Records $loaded -StartUtc $windowStart -EndUtc $windowEnd)
    Add-SMBeatMeasurePhase -Name 'filter-current' -Watch $watch -Detail ('records={0}' -f $records.Count)

    $watch = [System.Diagnostics.Stopwatch]::StartNew()
    $previousTotals = $null
    if ($previousRecords.Count -gt 0) {
        $previousReport = New-SMBeatReportFromSamples -Records $previousRecords -StartUtc $prevStart -EndUtc $prevEnd -TimeZone $tz -Granularity @() -IntervalSec $interval -TotalsOnly
        $previousTotals = $previousReport.Totals
    }
    Add-SMBeatMeasurePhase -Name 'aggregate-previous' -Watch $watch -Detail ('records={0}' -f $previousRecords.Count)

    $watch = [System.Diagnostics.Stopwatch]::StartNew()
    $report = New-SMBeatReportFromSamples -Records $records -StartUtc $windowStart -EndUtc $windowEnd -TimeZone $tz -Granularity $P.Granularity -IntervalSec $interval -PreviousTotals $previousTotals -GeneratedUtc $generatedUtc -SkipSpans
    Add-SMBeatMeasurePhase -Name 'aggregate-current' -Watch $watch -Detail ('records={0}' -f $records.Count)

    $watch = [System.Diagnostics.Stopwatch]::StartNew()
    Add-SMBeatReportSpans -Report $report -Records $loaded -TimeZone $tz -IntervalSec $interval | Out-Null
    Add-SMBeatMeasurePhase -Name 'aggregate-spans' -Watch $watch -Detail ('records={0}' -f $loaded.Count)

    $watch = [System.Diagnostics.Stopwatch]::StartNew()
    $diskSince = Get-SMBeatCollectionStartUtc -DataRoot $root
    if ($null -ne $diskSince) {
        $report | Add-Member -NotePropertyName CollectedSinceUtc -NotePropertyValue $diskSince -Force
    }
    $eventStart = $windowStart.AddHours(-2)
    $events = @(Read-SMBeatCollectorEvents -DataRoot $root -StartUtc $eventStart -EndUtc $windowEnd)
    $report | Add-Member -NotePropertyName Events -NotePropertyValue $events -Force
    Merge-SMBeatTechFromDataRoot -Report $report -DataRoot $root -Config $config
    Add-SMBeatMeasurePhase -Name 'events-and-tech' -Watch $watch -Detail ('events={0}' -f $events.Count)

    $language = Get-SMBeatLanguage -Language $P.Language
    $csvCulture = $P.CsvCulture
    if ([string]::IsNullOrWhiteSpace($csvCulture)) {
        $csvCulture = $language
    }

    $watch = [System.Diagnostics.Stopwatch]::StartNew()
    $html = ConvertTo-SMBeatHtmlReport -Report $report -Language $language -Theme $P.Theme
    Add-SMBeatMeasurePhase -Name 'render-html' -Watch $watch -Detail ('chars={0}' -f $html.Length)

    $reportOutputPath = $P.OutputPath
    if ([string]::IsNullOrWhiteSpace($reportOutputPath)) {
        $reportOutputPath = Get-SMBeatDefaultReportRoot
    }

    if (-not $P.NoExport) {
        $stamp = Get-SMBeatExportStamp
        $watch = [System.Diagnostics.Stopwatch]::StartNew()
        if (-not (Test-Path -LiteralPath $reportOutputPath)) {
            New-Item -ItemType Directory -Path $reportOutputPath -Force | Out-Null
        }
        $htmlFile = Join-Path $reportOutputPath ('SMBeat-Measure-Report-{0}.html' -f $stamp)
        $utf8Bom = New-Object System.Text.UTF8Encoding $true
        [System.IO.File]::WriteAllText($htmlFile, $html, $utf8Bom)
        [void]$outputFiles.Add($htmlFile)
        Add-SMBeatMeasurePhase -Name 'write-html' -Watch $watch -Detail $htmlFile

        if ($P.Csv) {
            $watch = [System.Diagnostics.Stopwatch]::StartNew()
            $csvSets = @(
                [PSCustomObject]@{ Name = 'shares'; Rows = @($report.ByShare) }
                [PSCustomObject]@{ Name = 'clients'; Rows = @($report.ByClient) }
                [PSCustomObject]@{ Name = 'users'; Rows = @($report.ByUser) }
                [PSCustomObject]@{ Name = 'servers'; Rows = @($report.ByServer) }
            )
            if ($report.PSObject.Properties['BySmbPerf']) {
                $csvSets += [PSCustomObject]@{ Name = 'smbperf'; Rows = @($report.BySmbPerf) }
            }
            if ($report.Hourly -and $report.Hourly.Count -gt 0) {
                $csvSets += [PSCustomObject]@{ Name = 'hourly'; Rows = @($report.Hourly) }
            }
            if ($report.Daily -and $report.Daily.Count -gt 0) {
                $csvSets += [PSCustomObject]@{ Name = 'daily'; Rows = @($report.Daily) }
            }
            if ($report.Weekly -and $report.Weekly.Count -gt 0) {
                $csvSets += [PSCustomObject]@{ Name = 'weekly'; Rows = @($report.Weekly) }
            }
            foreach ($set in $csvSets) {
                $csvFile = Join-Path $reportOutputPath ('SMBeat-Measure-{0}-{1}.csv' -f $set.Name, $stamp)
                Write-SMBeatCsvFile -Path $csvFile -InputObject $set.Rows -CsvCulture $csvCulture
                [void]$outputFiles.Add($csvFile)
            }
            Add-SMBeatMeasurePhase -Name 'write-csv' -Watch $watch -Detail ('files={0}' -f $csvSets.Count)
        }
    }

    [PSCustomObject]@{
        ModuleVersion = Get-SMBeatModuleVersion
        ComputerName  = [string]$env:COMPUTERNAME
        DataRoot      = $root
        WindowStart   = $windowStart
        WindowEnd     = $windowEnd
        WindowDays    = [math]::Round($duration.TotalDays, 3)
        NativeJson    = [bool]$nativeJson
        InputFiles    = $inputFiles.Count
        InputBytes    = $inputBytes
        LoadedRecords = $loaded.Count
        ReportRecords = $records.Count
        SampleCount   = [int]$report.SampleCount
        OutputFiles   = $outputFiles.ToArray()
        Phases        = $phases.ToArray()
    }
} $payload $importWatch.Elapsed.TotalMilliseconds

$totalWatch.Stop()
$measurement | Add-Member -NotePropertyName TotalSeconds -NotePropertyValue ([math]::Round($totalWatch.Elapsed.TotalSeconds, 3)) -Force

foreach ($phase in @($measurement.Phases)) {
    $pct = 0.0
    if ($measurement.TotalSeconds -gt 0) {
        $pct = [math]::Round((100.0 * [double]$phase.Seconds / [double]$measurement.TotalSeconds), 1)
    }
    $phase | Add-Member -NotePropertyName Percent -NotePropertyValue $pct -Force
}

Write-Host ''
Write-Host ('SMBeat {0} report measurement' -f $measurement.ModuleVersion)
Write-Host ('Data root : {0}' -f $measurement.DataRoot)
Write-Host ('Window : {0:u} - {1:u} ({2} days)' -f $measurement.WindowStart, $measurement.WindowEnd, $measurement.WindowDays)
Write-Host ('Input : {0} files, {1:N0} bytes' -f $measurement.InputFiles, $measurement.InputBytes)
Write-Host ('Records : loaded={0}, report={1}, samples={2}' -f $measurement.LoadedRecords, $measurement.ReportRecords, $measurement.SampleCount)
Write-Host ('Native JSON : {0}' -f $measurement.NativeJson)
Write-Host ('Total : {0:N3} s' -f $measurement.TotalSeconds)
Write-Host ''
Write-Host (($measurement.Phases | Select-Object Phase, Seconds, Percent, Detail | Format-Table -AutoSize | Out-String).TrimEnd())
if ($measurement.OutputFiles.Count -gt 0) {
    Write-Host ''
    Write-Host 'Output files:'
    foreach ($file in $measurement.OutputFiles) {
        Write-Host (' {0}' -f $file)
    }
}

$measurement