Private/Write-AACVirtualNetworkPdf.ps1

function Write-AACVirtualNetworkPdf {
    <#
    .SYNOPSIS
        Writes the virtual network assessment
        (ConvertTo-AACVirtualNetworkAssessment) as a landscape A4 PDF report.
    .DESCRIPTION
        1. Summary: title, scope, tiles, and a table of the networks - role,
           address space, IPs used and available, subnets, peerings, DNS,
           DDoS, flow logs, findings by severity.
        2. Findings, most severe first: severity (High red, Medium amber, Low
           blue, Info grey), category, where, what was found, what to do.
        3. One section per network (a bookmark each): its facts, subnets
           with their capacity, NSG, routes and outbound path, peerings,
           free ranges and private endpoints.
 
        -Path must be a full path; see Save-AACPdfDocument.
    #>

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

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

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

        [System.Collections.IDictionary] $Detail
    )

    $stats = $Assessment.Stats
    $vnets = @($Assessment.VirtualNetworks)
    $pdf = New-AACPdfDocument -Title $Title -Subject "$($stats.VirtualNetworks) virtual networks" -Landscape
    $section = $pdf.Section
    $colors = $pdf.Colors
    $pt = $pdf.Pt
    $right = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Right
    $blue = [MigraDoc.DocumentObjectModel.Color]::Parse('#0369A1')
    $severityColor = @{ High = $pdf.Tone.Bad.Solid; Medium = $pdf.Tone.Warn.Solid; Low = $blue; Info = $colors.Muted }
    $small = { param($Paragraph) $Paragraph.Format.Font.Size = 7.5; $Paragraph }
    $muted = { param($Paragraph) $Paragraph.Format.Font.Size = 7.5; $Paragraph.Format.Font.Color = $colors.Muted; $Paragraph }
    $number = { param($Cell, $Value) $p = & $small ($Cell.AddParagraph($(if ($null -eq $Value) { '-' } else { '{0:N0}' -f $Value }))); $p.Format.Alignment = $right; $p }
    $colored = {
        param($Cell, [string] $Text, $Color, [switch] $Bold)
        $p = $Cell.AddParagraph($Text)
        if ($Color) { $p.Format.Font.Color = $Color }
        if ($Bold) { $p.Format.Font.Name = 'Segoe UI Semibold' }
        $p
    }
    $usageColor = { param($Percent) if ($null -eq $Percent) { $colors.Muted } elseif ($Percent -ge 95) { $pdf.Tone.Bad.Solid } elseif ($Percent -ge 80) { $pdf.Tone.Warn.Solid } else { $pdf.Tone.Good.Solid } }
    $findingCounts = {
        param($Cell, $Item)
        $p = $Cell.AddParagraph()
        $p.Format.Alignment = $right
        foreach ($level in 'High', 'Medium', 'Low') {
            $n = [int]$Item.$level
            if (-not $n) { continue }
            $t = $p.AddFormattedText("$($level.Substring(0, 1))$n ")
            $t.Color = $severityColor[$level]
            $t.Bold = $true
        }
    }

    # --- 1. Summary ---------------------------------------------------------------------------------
    & $pdf.AddTitle "Virtual network assessment · 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] } }
    $facts['How IPs are counted'] = 'Usable: each subnet''s addresses less the 5 Azure keeps. Used: the IP configurations in the subnet (NICs, private endpoints, gateways, load balancers). Unallocated: addresses in no subnet.'
    foreach ($key in @($Assessment['Failed'] | Where-Object { $_ })) { $facts["Not read: $key"] = 'The findings that need them may be missing.' }
    $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 = $stats.VirtualNetworks; Label = 'virtual networks' }
        @{ Value = $stats.Subnets; Label = 'subnets' }
        @{ Value = $stats.AvailableIps; Label = "IPs available ($($stats.UsedPercent)% used)"; Color = $pdf.Tone.Good.Solid }
        @{ Value = $stats.FullSubnets; Label = 'subnets 80%+ used'; Color = $(if ($stats.FullSubnets) { $pdf.Tone.Warn.Solid }) }
        @{ Value = $stats.PeeringProblems; Label = 'peering problems'; Color = $(if ($stats.PeeringProblems) { $pdf.Tone.Bad.Solid }) }
        @{ Value = $stats.High; Label = 'high findings'; Color = $(if ($stats.High) { $pdf.Tone.Bad.Solid }) }
        @{ Value = $stats.Medium; Label = 'medium findings'; Color = $(if ($stats.Medium) { $pdf.Tone.Warn.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(('{0:N0}' -f $tileData[$i].Value))
        $value.Format.Alignment = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Center
        $value.Format.Font.Size = 18
        $value.Format.Font.Name = 'Segoe UI Semibold'
        if ($tileData[$i].Contains('Color') -and $tileData[$i].Color) { $value.Format.Font.Color = $tileData[$i].Color }
        $caption = $cell.AddParagraph($tileData[$i].Label)
        $caption.Format.Alignment = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Center
        $caption.Format.Font.Size = 8
        $caption.Format.Font.Color = $colors.Muted
    }

    $section.AddParagraph('Virtual networks', 'Heading2') | Out-Null
    $table = & $pdf.NewTable @(5.2, 1.7, 3.6, 1.5, 2.0, 2.0, 1.6, 2.8, 2.5, 1.6, 1.6)
    & $pdf.AddHeaderRow $table @('Network', 'Role', 'Address space', 'Subnets', 'Used', 'Available', 'Peerings', 'DNS', 'DDoS / flow logs', 'Used %', 'Findings') @(3, 4, 5, 6, 9, 10)
    foreach ($vnet in $vnets) {
        $row = & $pdf.AddBodyRow $table
        $name = $row.Cells[0].AddParagraph($vnet.Name)
        $name.Format.Font.Name = 'Segoe UI Semibold'
        & $muted ($row.Cells[0].AddParagraph("$($vnet.Location) · $($vnet.ResourceGroup) · $($vnet.SubscriptionName)")) | Out-Null
        & $small ($row.Cells[1].AddParagraph($vnet.Role)) | Out-Null
        & $small ($row.Cells[2].AddParagraph(($vnet.AddressSpace -replace ', ', "`n"))) | Out-Null
        if ($vnet.Overlaps) { (& $small ($row.Cells[2].AddParagraph("overlaps $($vnet.Overlaps)"))).Format.Font.Color = $pdf.Tone.Warn.Solid }
        & $number $row.Cells[3] $vnet.SubnetCount | Out-Null
        & $number $row.Cells[4] $vnet.UsedIps | Out-Null
        & $number $row.Cells[5] $vnet.AvailableIps | Out-Null
        $peers = & $number $row.Cells[6] $vnet.PeeringCount
        if ($vnet.PeeringsNotConnected) { $peers.Format.Font.Color = $pdf.Tone.Bad.Solid }
        & $small ($row.Cells[7].AddParagraph($vnet.DnsServers)) | Out-Null
        & $small ($row.Cells[8].AddParagraph($vnet.DdosProtection)) | Out-Null
        (& $small ($row.Cells[8].AddParagraph("Flow logs: $($vnet.FlowLogs)"))).Format.Font.Color = $(if ($vnet.FlowLogs -eq 'None') { $pdf.Tone.Warn.Solid } else { $colors.Muted })
        $used = & $small ($row.Cells[9].AddParagraph($(if ($null -eq $vnet.UsedPercent) { '-' } else { "$($vnet.UsedPercent)%" })))
        $used.Format.Alignment = $right
        $used.Format.Font.Color = & $usageColor $vnet.UsedPercent
        & $findingCounts $row.Cells[10] $vnet
    }

    # --- 2. Findings --------------------------------------------------------------------------------------
    $findings = @($Assessment.Findings)
    if ($findings.Count) {
        $section.AddPageBreak()
        $section.AddParagraph('Findings', 'Heading2') | Out-Null
        $table = & $pdf.NewTable @(1.8, 2.3, 4.2, 4.2, 7.6, 6.0)
        & $pdf.AddHeaderRow $table @('Severity', 'Category', 'Finding', 'Where', 'What was found', 'What to do')
        foreach ($finding in $findings) {
            $row = & $pdf.AddBodyRow $table
            & $colored $row.Cells[0] $finding.Severity $severityColor[$finding.Severity] -Bold | Out-Null
            & $small ($row.Cells[1].AddParagraph($finding.Category)) | Out-Null
            & $small ($row.Cells[2].AddParagraph($finding.Finding)) | Out-Null
            & $small ($row.Cells[3].AddParagraph((@($finding.VirtualNetwork, $finding.Item) | Where-Object { $_ }) -join "`n")) | Out-Null
            & $small ($row.Cells[4].AddParagraph($finding.Detail)) | Out-Null
            & $muted ($row.Cells[5].AddParagraph($finding.Action)) | Out-Null
        }
    }

    # --- 3. Each network ------------------------------------------------------------------------------------
    foreach ($vnet in $vnets) {
        $section.AddPageBreak()
        $section.AddParagraph("$($vnet.Name) ($($vnet.Role))", 'Heading1') | Out-Null
        & $muted ($section.AddParagraph($vnet.ResourceId)) | Out-Null
        $meta = & $pdf.NewTable @(3.6, 9.4, 3.6, 9.5)
        $pairs = @(
            , @('Subscription', $vnet.SubscriptionName, 'Resource group', $vnet.ResourceGroup)
            , @('Location', $vnet.Location, 'Tags', $vnet.Tags)
            , @('Address space', $vnet.AddressSpace, 'IPs', "$('{0:N0}' -f $vnet.TotalIps) in all; $('{0:N0}' -f $vnet.IpsInSubnets) in subnets, $('{0:N0}' -f $vnet.UnallocatedIps) unallocated`n$('{0:N0}' -f $vnet.UsableIps) usable, $('{0:N0}' -f $vnet.UsedIps) used, $('{0:N0}' -f $vnet.AvailableIps) available")
            , @('DNS servers', $vnet.DnsServers, 'Private DNS zones', $vnet.PrivateDnsZones)
            , @('DDoS', $vnet.DdosProtection, 'Encryption', $vnet.Encryption)
            , @('Flow logs', $vnet.FlowLogs, 'DNS resolvers', $vnet.DnsResolvers)
            , @('Gateways', $vnet.Gateways, 'Firewalls / Bastion', ((@($vnet.Firewalls, $vnet.Bastions) | Where-Object { $_ }) -join "`n"))
            , @('Overlaps with', $vnet.Overlaps, 'Flow timeout / BGP', ((@($(if ($vnet.FlowTimeoutMinutes) { "$($vnet.FlowTimeoutMinutes) min" }), $vnet.BgpCommunity) | Where-Object { $_ }) -join ' · '))
        )
        foreach ($pair in $pairs) {
            $row = & $pdf.AddBodyRow $meta
            $row.Cells[0].AddParagraph($pair[0]).Format.Font.Color = $colors.Muted
            & $small ($row.Cells[1].AddParagraph($(if ($pair[1]) { [string]$pair[1] } else { '-' }))) | Out-Null
            $row.Cells[2].AddParagraph($pair[2]).Format.Font.Color = $colors.Muted
            & $small ($row.Cells[3].AddParagraph($(if ($pair[3]) { [string]$pair[3] } else { '-' }))) | Out-Null
        }

        if ($vnet.Subnets.Count) {
            $section.AddParagraph('Subnets', 'Heading3') | Out-Null
            $table = & $pdf.NewTable @(4.2, 2.6, 1.4, 1.4, 1.4, 2.8, 4.4, 4.0, 3.9)
            & $pdf.AddHeaderRow $table @('Subnet', 'Prefix', 'Usable', 'Used', 'Free', 'NSG', 'Route / outbound', 'Endpoints / delegations', 'Workloads') @(2, 3, 4)
            foreach ($subnet in $vnet.Subnets) {
                $row = & $pdf.AddBodyRow $table
                & $small ($row.Cells[0].AddParagraph($subnet.Subnet)) | ForEach-Object { $_.Format.Font.Name = 'Segoe UI Semibold' }
                & $muted ($row.Cells[0].AddParagraph($subnet.Purpose)) | Out-Null
                & $small ($row.Cells[1].AddParagraph($subnet.Prefix)) | Out-Null
                & $number $row.Cells[2] $subnet.Usable | Out-Null
                (& $number $row.Cells[3] $subnet.Used).Format.Font.Color = & $usageColor $subnet.UsedPercent
                & $number $row.Cells[4] $subnet.Available | Out-Null
                if ($subnet.Nsg) { & $small ($row.Cells[5].AddParagraph($subnet.Nsg)) | Out-Null }
                elseif ($subnet.Purpose -in 'Gateway', 'Azure Firewall', 'Azure Firewall management', 'Route Server') { & $muted ($row.Cells[5].AddParagraph('n/a')) | Out-Null }
                else { (& $small ($row.Cells[5].AddParagraph('none'))).Format.Font.Color = $pdf.Tone.Warn.Solid }
                & $small ($row.Cells[6].AddParagraph((@($(if ($subnet.RouteTable) { "$($subnet.RouteTable): $(if ($subnet.DefaultRoute) { "0.0.0.0/0 → $($subnet.DefaultRoute)" } else { 'no default route' })" }), $(if ($subnet.NatGateway) { "NAT: $($subnet.NatGateway)" }), $subnet.Outbound) | Where-Object { $_ } | Select-Object -Unique) -join "`n")) | Out-Null
                & $small ($row.Cells[7].AddParagraph((@($(if ($subnet.ServiceEndpoints) { "SE: $($subnet.ServiceEndpoints)" }), $(if ($subnet.Delegations) { "Delegated: $($subnet.Delegations)" }), $(if ($subnet.PrivateEndpoints) { "$($subnet.PrivateEndpoints) private endpoint(s), policies $($subnet.PrivateEndpointPolicies)" })) | Where-Object { $_ }) -join "`n")) | Out-Null
                & $small ($row.Cells[8].AddParagraph($(if ($subnet.Nics) { "$($subnet.Nics) NIC(s), $($subnet.Vms) VM(s)$(if ($subnet.PublicIpNics) { "`n$($subnet.PublicIpNics) with a public IP" })" } else { '' }))) | Out-Null
            }
        }
        if ($vnet.Peerings.Count) {
            $section.AddParagraph('Peerings', 'Heading3') | Out-Null
            $table = & $pdf.NewTable @(4.0, 5.0, 2.2, 2.8, 3.0, 2.2, 6.9)
            & $pdf.AddHeaderRow $table @('Peering', 'Remote network', 'State', 'Sync', 'Gateway transit', 'Forwarded', 'Remote address space')
            foreach ($peering in $vnet.Peerings) {
                $row = & $pdf.AddBodyRow $table
                & $small ($row.Cells[0].AddParagraph($peering.Peering)) | Out-Null
                & $small ($row.Cells[1].AddParagraph($peering.RemoteVirtualNetwork)) | Out-Null
                & $muted ($row.Cells[1].AddParagraph((@($peering.RemoteSubscription, $peering.RemoteLocation, $(if ($peering.Global) { 'global' })) | Where-Object { $_ }) -join ' · ')) | Out-Null
                & $colored $row.Cells[2] $peering.State $(if ($peering.State -eq 'Connected') { $pdf.Tone.Good.Solid } else { $pdf.Tone.Bad.Solid }) -Bold | Out-Null
                & $colored $row.Cells[3] $peering.Sync $(if ($peering.Sync -in '', 'FullyInSync') { $pdf.Tone.Good.Solid } else { $pdf.Tone.Warn.Solid }) | Out-Null
                & $small ($row.Cells[4].AddParagraph($(if ($peering.AllowGatewayTransit) { 'offers transit' } elseif ($peering.UseRemoteGateways) { 'uses remote gateways' } else { '-' }))) | Out-Null
                & $small ($row.Cells[5].AddParagraph($(if ($peering.AllowForwardedTraffic) { 'allowed' } else { 'blocked' }))) | Out-Null
                & $small ($row.Cells[6].AddParagraph($peering.RemoteAddressSpace)) | Out-Null
            }
        }
        $ranges = @($vnet.FreeRangeList | ForEach-Object { $_.Prefix })
        $section.AddParagraph('Free for new subnets', 'Heading3') | Out-Null
        & $small ($section.AddParagraph($(if ($ranges.Count) { $ranges -join ', ' } else { 'None: every address is in a subnet.' }))) | Out-Null
        if ($vnet.PrivateEndpointList.Count) {
            $section.AddParagraph('Private endpoints', 'Heading3') | Out-Null
            $table = & $pdf.NewTable @(4.4, 3.0, 5.0, 5.4, 2.6, 2.4, 3.3)
            & $pdf.AddHeaderRow $table @('Private endpoint', 'Subnet', 'Target', 'Target type', 'Sub-resource', 'Connection', 'Zone linked')
            foreach ($endpoint in $vnet.PrivateEndpointList) {
                $row = & $pdf.AddBodyRow $table
                & $small ($row.Cells[0].AddParagraph($endpoint.PrivateEndpoint)) | Out-Null
                & $small ($row.Cells[1].AddParagraph($endpoint.Subnet)) | Out-Null
                & $small ($row.Cells[2].AddParagraph($endpoint.Target)) | Out-Null
                & $small ($row.Cells[3].AddParagraph($endpoint.TargetType)) | Out-Null
                & $small ($row.Cells[4].AddParagraph($endpoint.Groups)) | Out-Null
                & $colored $row.Cells[5] $endpoint.Status $(if ($endpoint.Status -eq 'Approved') { $pdf.Tone.Good.Solid } else { $pdf.Tone.Warn.Solid }) | Out-Null
                & $colored $row.Cells[6] $endpoint.ZoneLinked $(if ($endpoint.ZoneLinked -eq 'No') { $pdf.Tone.Bad.Solid } else { $colors.Muted }) | Out-Null
            }
        }
    }

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