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 CSV writer as
    Show-SMBeat. The report is never opened. By default, diagnostic report
    files with an SMBeat-Measure prefix are written to the report directory.
.PARAMETER NoExport
    Render HTML in memory but do not write HTML or CSV files.
.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
)

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
}

$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

        $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