Private/Write-AACDiagnosticSettingPdf.ps1

function Write-AACDiagnosticSettingPdf {
    <#
    .SYNOPSIS
        Writes Get-AACDiagnosticSetting's result as a landscape A4 PDF.
    .DESCRIPTION
        1. Summary: title, scope, tiles, notices.
        2. Coverage by resource type, and the workspaces receiving logs.
        3. Misconfigurations: each finding with the resources it affects.
        4. The resources whose logs don't all reach Log Analytics, by
           resource type - up to -MaxRows; the CSV and HTML report have
           every row.
        Each heading is a bookmark (Save-AACPdfDocument). -Path must be a
        full path.
    #>

    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Diagnostic,

        [Parameter(Mandatory)]
        [string] $Path,

        [Parameter(Mandatory)]
        [string] $Title,

        [System.Collections.IDictionary] $Detail,

        [int] $MaxRows = 5000
    )

    $stats = $Diagnostic.Stats
    $pdf = New-AACPdfDocument -Title $Title -Subject "Diagnostic settings: $($stats.WithLogs) resources with logs" -Landscape
    $section = $pdf.Section
    $colors = $pdf.Colors
    $pt = $pdf.Pt
    $right = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Right
    $rateColor = { param($Rate) if ($null -eq $Rate) { $colors.Muted } elseif ($Rate -ge 90) { $pdf.Tone.Good.Solid } elseif ($Rate -ge 60) { $pdf.Tone.Warn.Solid } else { $pdf.Tone.Bad.Solid } }
    $severityColor = @{ High = $pdf.Tone.Bad.Solid; Medium = $pdf.Tone.Warn.Solid; Low = $colors.Accent }
    $statusColor = @{ Exported = $pdf.Tone.Good.Solid; Partial = $pdf.Tone.Warn.Solid; 'Not to workspace' = $pdf.Tone.Bad.Solid; 'No setting' = $pdf.Tone.Bad.Solid }
    $number = { param($Cell, $Value, $Color) $p = $Cell.AddParagraph(('{0:N0}' -f [double]$Value)); $p.Format.Alignment = $right; if ([double]$Value -eq 0) { $p.Format.Font.Color = $colors.Muted } elseif ($Color) { $p.Format.Font.Color = $Color; $p.Format.Font.Name = 'Segoe UI Semibold' } }
    $small = { param($Cell, [string] $Text, $Color) $p = $Cell.AddParagraph($Text); $p.Format.Font.Size = 7; if ($Color) { $p.Format.Font.Color = $Color }; $p }
    $note = { param([string] $Text) $p = $section.AddParagraph($Text); $p.Format.Font.Size = 8; $p.Format.Font.Color = $colors.Muted; $p.Format.SpaceBefore = & $pt 4 }

    # --- 1. Summary ------------------------------------------------------------------------------------------------
    & $pdf.AddTitle "Diagnostic settings · generated $($pdf.Generated.ToString('dddd d MMMM yyyy, HH:mm'))"
    $facts = [ordered]@{}
    if ($script:AACSession) { $facts['Azure account'] = [string]$script:AACSession.Account; $facts['Tenant'] = [string]$script:AACSession.TenantId }
    if ($Detail) { foreach ($key in $Detail.Keys) { $facts[[string]$key] = [string]$Detail[$key] } }
    $factTable = & $pdf.NewTable @(4.0, ($pdf.PageWidth - 4.0))
    foreach ($key in $facts.Keys) {
        $row = & $pdf.AddBodyRow $factTable
        $row.Cells[0].AddParagraph($key).Format.Font.Color = $colors.Muted
        $row.Cells[1].AddParagraph($facts[$key]) | Out-Null
    }
    $section.AddParagraph().Format.SpaceAfter = & $pt 6
    $tileData = @(
        @{ Value = $(if ($null -ne $stats.CoveragePercent) { "$($stats.CoveragePercent)%" } else { '-' }); Label = ('exported to Log Analytics ({0:N0} of {1:N0})' -f $stats.Exported, ($stats.WithLogs - $stats.Unknown)); Color = (& $rateColor $stats.CoveragePercent) }
        @{ Value = '{0:N0}' -f $stats.Partial; Label = 'partly exported'; Color = $(if ($stats.Partial) { $pdf.Tone.Warn.Solid }) }
        @{ Value = '{0:N0}' -f $stats.NotToWorkspace; Label = 'settings, no workspace'; Color = $(if ($stats.NotToWorkspace) { $pdf.Tone.Bad.Solid }) }
        @{ Value = '{0:N0}' -f $stats.NoSetting; Label = 'no diagnostic setting'; Color = $(if ($stats.NoSetting) { $pdf.Tone.Bad.Solid }) }
        @{ Value = '{0:N0}' -f $stats.Settings; Label = 'diagnostic settings' }
        @{ Value = '{0:N0}' -f $stats.Workspaces; Label = 'workspaces receiving logs' }
        @{ Value = "$($stats.High) / $($stats.Medium) / $($stats.Low)"; Label = 'findings high / medium / low'; Color = $(if ($stats.High) { $pdf.Tone.Bad.Solid }) }
    )
    $tiles = & $pdf.NewTable @(1..$tileData.Count | ForEach-Object { $pdf.PageWidth / $tileData.Count })
    $tiles.TopPadding = & $pt 8
    $tiles.BottomPadding = & $pt 8
    $tileRow = $tiles.AddRow()
    for ($i = 0; $i -lt $tileData.Count; $i++) {
        $cell = $tileRow.Cells[$i]
        $cell.Shading.Color = $colors.Panel
        $cell.Borders.Left.Width = $(if ($i -gt 0) { 2 } else { 0 })
        $cell.Borders.Left.Color = $colors.White
        $value = $cell.AddParagraph([string]$tileData[$i].Value)
        if ($tileData[$i].Contains('Color') -and $tileData[$i].Color) { $value.Format.Font.Color = $tileData[$i].Color }
        $value.Format.Alignment = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Center
        $value.Format.Font.Size = 18
        $value.Format.Font.Name = 'Segoe UI Semibold'
        $caption = $cell.AddParagraph($tileData[$i].Label)
        $caption.Format.Alignment = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Center
        $caption.Format.Font.Size = 8
        $caption.Format.Font.Color = $colors.Muted
    }
    foreach ($text in @(@($Diagnostic.Notice | Where-Object { $_ }) + 'Exported: every log category of the resource reaches a Log Analytics workspace, by name or through a category group (allLogs, audit). Storage accounts are assessed per service (blob, file, queue, table); subscriptions by their activity log.')) { & $note $text }

    # --- 2. Coverage by type, workspaces ---------------------------------------------------------------------------------
    if (@($Diagnostic.ByType).Count) {
        $section.AddParagraph('Coverage by resource type', 'Heading2') | Out-Null
        $table = & $pdf.NewTable @(9.1, 2.6, 2.6, 2.6, 3.0, 3.0, 3.2)
        & $pdf.AddHeaderRow $table @('Resource type', 'Coverage', 'Exported', 'Partial', 'No workspace', 'No setting', 'Resources') @(1, 2, 3, 4, 5, 6)
        foreach ($item in $Diagnostic.ByType) {
            $row = & $pdf.AddBodyRow $table
            $row.Cells[0].AddParagraph($item.ResourceType).Format.Font.Name = 'Segoe UI Semibold'
            $p = $row.Cells[1].AddParagraph($(if ($null -ne $item.CoveragePercent) { "$($item.CoveragePercent)%" } else { '-' })); $p.Format.Alignment = $right; $p.Format.Font.Name = 'Segoe UI Semibold'; $p.Format.Font.Color = & $rateColor $item.CoveragePercent
            & $number $row.Cells[2] $item.Exported $pdf.Tone.Good.Solid
            & $number $row.Cells[3] $item.Partial $pdf.Tone.Warn.Solid
            & $number $row.Cells[4] $item.NotToWorkspace $pdf.Tone.Bad.Solid
            & $number $row.Cells[5] $item.NoSetting $pdf.Tone.Bad.Solid
            & $number $row.Cells[6] $item.Resources
        }
    }
    if (@($Diagnostic.Workspaces).Count) {
        $section.AddParagraph('Workspaces receiving logs', 'Heading2') | Out-Null
        $table = & $pdf.NewTable @(9.1, 4.0, 3.0, 3.0, 7.0)
        & $pdf.AddHeaderRow $table @('Workspace', 'Location', 'Resources', 'Settings', '') @(2, 3)
        foreach ($item in $Diagnostic.Workspaces) {
            $row = & $pdf.AddBodyRow $table
            $row.Cells[0].AddParagraph($item.Workspace).Format.Font.Name = 'Segoe UI Semibold'
            $row.Cells[1].AddParagraph($item.Location) | Out-Null
            & $number $row.Cells[2] $item.Resources
            & $number $row.Cells[3] $item.Settings
            if (-not $item.Found) { $null = & $small $row.Cells[4] 'not found: deleted, or not visible to the account' $pdf.Tone.Bad.Solid }
            elseif ($false -eq $item.Expected) { $null = & $small $row.Cells[4] 'not an expected workspace' $pdf.Tone.Warn.Solid }
        }
    }

    # --- 3. Misconfigurations ------------------------------------------------------------------------------------------------
    $rank = @{ High = 0; Medium = 1; Low = 2 }
    $groups = @($Diagnostic.Findings | Group-Object Severity, Finding | Sort-Object { $rank[$_.Group[0].Severity] }, { $_.Group[0].Finding })
    if ($groups.Count) {
        $section.AddPageBreak()
        $section.AddParagraph('Misconfigurations', 'Heading2') | Out-Null
        $shown = 0
        foreach ($group in $groups) {
            if ($shown -ge $MaxRows) { break }
            $first = $group.Group[0]
            $heading = $section.AddParagraph("$($first.Finding) ($($group.Count))", 'Heading3')
            $heading.Format.Font.Color = $severityColor[$first.Severity]
            & $note "$($first.Severity) · $($first.Action)"
            $table = & $pdf.NewTable @(7.0, 5.6, 4.0, 9.5)
            & $pdf.AddHeaderRow $table @('Resource', 'Type', 'Setting', 'Detail')
            foreach ($item in @($group.Group | Select-AACFirst ($MaxRows - $shown))) {
                $row = & $pdf.AddBodyRow $table
                $row.Cells[0].AddParagraph($item.Resource).Format.Font.Name = 'Segoe UI Semibold'
                $null = & $small $row.Cells[0] ((@($item.ResourceGroup, $item.SubscriptionName) | Where-Object { $_ }) -join ' / ') $colors.Muted
                $null = & $small $row.Cells[1] $item.ResourceType $null
                $row.Cells[2].AddParagraph([string]$item.Setting) | Out-Null
                $null = & $small $row.Cells[3] $item.Detail $null
                $shown++
            }
        }
    }

    # --- 4. Not exported, by resource type --------------------------------------------------------------------------------
    $missing = @($Diagnostic.Shown | Where-Object Status -In 'No setting', 'Not to workspace', 'Partial')
    if ($missing.Count) {
        $section.AddPageBreak()
        $section.AddParagraph("Logs not (all) reaching Log Analytics ($($missing.Count))", 'Heading2') | Out-Null
        $shown = 0
        foreach ($type in @($missing | Group-Object ResourceType | Sort-Object Count -Descending)) {
            if ($shown -ge $MaxRows) { break }
            $section.AddParagraph("$($type.Name) ($($type.Count))", 'Heading3') | Out-Null
            $table = & $pdf.NewTable @(7.0, 5.0, 5.0, 3.0, 6.1)
            & $pdf.AddHeaderRow $table @('Resource', 'Resource group', 'Subscription', 'Status', 'What''s missing')
            foreach ($item in @($type.Group | Select-AACFirst ($MaxRows - $shown))) {
                $row = & $pdf.AddBodyRow $table
                $row.Cells[0].AddParagraph($item.Resource).Format.Font.Name = 'Segoe UI Semibold'
                $row.Cells[1].AddParagraph($item.ResourceGroup) | Out-Null
                $row.Cells[2].AddParagraph($item.SubscriptionName) | Out-Null
                $status = $row.Cells[3].AddParagraph($item.Status); $status.Format.Font.Color = $statusColor[$item.Status]; $status.Format.Font.Name = 'Segoe UI Semibold'
                $null = & $small $row.Cells[4] $item.Reason $null
                $shown++
            }
        }
        if ($missing.Count -gt $MaxRows) { & $note "... and $($missing.Count - $MaxRows) more: the CSV and HTML reports list them all." }
    }

    Save-AACPdfDocument -Pdf $pdf -Path $Path
}