Public/New-TenantLensSummaryReport.ps1

function New-TenantLensSummaryReport {
    <#
    .SYNOPSIS
        Builds a multi-tenant overview report from a folder of assessment runs.
    .DESCRIPTION
        Scans a root folder (e.g. \\srv\assessments or .\out) for run folders
        containing findings.json, groups them by tenant, takes the newest run
        per tenant and renders one self-contained HTML overview: score, open
        findings by severity and the trend versus the previous run. Only
        findings.json is read - no snapshot data is required.
    .EXAMPLE
        New-TenantLensSummaryReport -Path \\srv\assessments -Open
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Path,

        # Target file (defaults to summary.html inside -Path).
        [string]$OutputPath,

        [string]$Title = 'TenantLens - tenant overview',

        [switch]$Open
    )

    function Get-TLEncoded { param([object]$Text) [System.Net.WebUtility]::HtmlEncode([string]$Text) }
    function Get-TLScoreBand {
        param([AllowNull()][object]$Score)
        if ($null -eq $Score) { return 'band-critical' }
        if ($Score -ge 85) { return 'band-good' }
        if ($Score -ge 60) { return 'band-warn' }
        if ($Score -ge 40) { return 'band-serious' }
        return 'band-critical'
    }

    $runFolders = @(Get-ChildItem -Path $Path -Directory -ErrorAction Stop |
            Where-Object { Test-Path -Path (Join-Path -Path $_.FullName -ChildPath 'findings.json') })
    if ($runFolders.Count -eq 0) {
        throw "No run folders with findings.json found under '$Path'. Run Invoke-TenantLens (or Test-TenantLens -OutputPath) per tenant first."
    }

    $runs = foreach ($folder in $runFolders) {
        $payload = $null
        try {
            $payload = Get-Content -Path (Join-Path -Path $folder.FullName -ChildPath 'findings.json') -Raw | ConvertFrom-Json
        }
        catch {
            Write-Warning ("'{0}\findings.json' could not be parsed: {1}" -f $folder.Name, $_.Exception.Message)
            continue
        }
        $label = $folder.Name
        $stamp = $null
        if ($folder.Name -match '^(?<label>.+)_(?<stamp>\d{4}-\d{2}-\d{2}_\d{4})$') {
            $label = $Matches['label']
            $stamp = $Matches['stamp']
        }
        $openBySeverity = [ordered]@{}
        foreach ($severityName in @('Critical', 'High', 'Medium', 'Low')) {
            $openBySeverity[$severityName] = @($payload.findings | Where-Object {
                    $_.Status -eq 'Fail' -and $_.Severity -eq $severityName
                }).Count
        }
        [pscustomobject]@{
            Label          = $label
            Stamp          = $stamp
            FolderName     = $folder.Name
            GeneratedUtc   = [string]$payload.generatedUtc
            Score          = (Get-TLFindingsScore -Payload $payload)
            OpenBySeverity = $openBySeverity
            FailTotal      = @($payload.findings | Where-Object { $_.Status -eq 'Fail' }).Count
        }
    }

    $tenants = foreach ($group in ($runs | Group-Object -Property Label)) {
        $ordered = @($group.Group | Sort-Object -Property @{ Expression = { "$($_.Stamp)$($_.GeneratedUtc)" } })
        $latest = $ordered[-1]
        $previous = $(if ($ordered.Count -gt 1) { $ordered[-2] } else { $null })
        [pscustomobject]@{
            Label    = $group.Name
            RunCount = $ordered.Count
            Latest   = $latest
            Previous = $previous
        }
    }
    $tenants = @($tenants | Sort-Object -Property @{ Expression = { if ($null -eq $_.Latest.Score) { 999 } else { $_.Latest.Score } } })

    # --- KPI tiles ------------------------------------------------------------
    $scores = @($tenants | ForEach-Object { $_.Latest.Score } | Where-Object { $null -ne $_ })
    $averageScore = $(if ($scores.Count -gt 0) { [int][Math]::Round(($scores | Measure-Object -Average).Average) } else { 'n/a' })
    $totalCritical = [int](@($tenants | ForEach-Object { $_.Latest.OpenBySeverity['Critical'] } | Measure-Object -Sum).Sum)
    $totalHigh = [int](@($tenants | ForEach-Object { $_.Latest.OpenBySeverity['High'] } | Measure-Object -Sum).Sum)

    $kpis = [System.Text.StringBuilder]::new()
    [void]$kpis.AppendLine(('<div class="tile"><div class="num">{0}</div><div class="lbl">Tenants</div></div>' -f $tenants.Count))
    [void]$kpis.AppendLine(('<div class="tile"><div class="num">{0}</div><div class="lbl">Average score</div></div>' -f $averageScore))
    [void]$kpis.AppendLine(('<div class="tile"><div class="num critical">{0}</div><div class="lbl">Open critical findings</div></div>' -f $totalCritical))
    [void]$kpis.AppendLine(('<div class="tile"><div class="num high">{0}</div><div class="lbl">Open high findings</div></div>' -f $totalHigh))

    # --- tenant rows ----------------------------------------------------------
    $rows = [System.Text.StringBuilder]::new()
    foreach ($tenant in $tenants) {
        $latest = $tenant.Latest
        $scoreText = $(if ($null -eq $latest.Score) { 'n/a' } else { [string]$latest.Score })
        $scoreWidth = $(if ($null -eq $latest.Score) { 0 } else { $latest.Score })
        $band = Get-TLScoreBand -Score $latest.Score

        $severityHtml = [System.Text.StringBuilder]::new()
        foreach ($severityName in @('Critical', 'High', 'Medium', 'Low')) {
            $count = [int]$latest.OpenBySeverity[$severityName]
            if ($count -eq 0) { continue }
            [void]$severityHtml.Append(('<span class="sev"><span class="dot d-{0}"></span>{1}</span>' -f $severityName.ToLowerInvariant(), $count))
        }
        if ($latest.FailTotal -eq 0) { [void]$severityHtml.Append('<span class="sev">none</span>') }

        $deltaHtml = '<span class="delta flat">first run</span>'
        if ($tenant.Previous -and $null -ne $tenant.Previous.Score -and $null -ne $latest.Score) {
            $delta = [int]$latest.Score - [int]$tenant.Previous.Score
            if ($delta -gt 0) { $deltaHtml = ('<span class="delta up">&#9650; +{0}</span>' -f $delta) }
            elseif ($delta -lt 0) { $deltaHtml = ('<span class="delta down">&#9660; {0}</span>' -f $delta) }
            else { $deltaHtml = '<span class="delta flat">&plusmn;0</span>' }
        }

        $dateText = $(if ($latest.Stamp) { $latest.Stamp -replace '_', ' ' } else { $latest.GeneratedUtc })
        [void]$rows.AppendLine(('<tr class="{0}"><td class="t-name">{1}<span class="runs">{2} run(s) &middot; {3}</span></td><td><div class="scorecell"><span class="score-num">{4}</span><div class="scorebar"><span style="width:{5}%"></span></div></div></td><td>{6}</td><td>{7}</td><td class="date">{8}</td></tr>' -f `
                    $band, (Get-TLEncoded -Text $tenant.Label), $tenant.RunCount, (Get-TLEncoded -Text $latest.FolderName),
                $scoreText, $scoreWidth, $severityHtml.ToString(), $deltaHtml, (Get-TLEncoded -Text $dateText)))
    }

    $templatePath = Join-Path -Path (Join-Path -Path $script:TLModuleRoot -ChildPath 'Templates') -ChildPath 'summary.html'
    $html = Get-Content -Path $templatePath -Raw
    $html = $html.Replace('{{TITLE}}', (Get-TLEncoded -Text $Title))
    $html = $html.Replace('{{TENANT_COUNT}}', "$($tenants.Count)")
    $html = $html.Replace('{{GENERATED}}', ([DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm') + ' UTC'))
    $html = $html.Replace('{{KPI_TILES}}', $kpis.ToString())
    $html = $html.Replace('{{TENANT_ROWS}}', $rows.ToString())
    $html = $html.Replace('{{TOOL_VERSION}}', (Get-TLEncoded -Text $script:TLVersion))

    if (-not $OutputPath) { $OutputPath = Join-Path -Path $Path -ChildPath 'summary.html' }
    Write-TLFile -Path $OutputPath -Content $html
    Write-Verbose ("Summary report written to '{0}'." -f $OutputPath)

    if ($Open) { Invoke-Item -Path $OutputPath }
    return Get-Item -Path $OutputPath
}