Private/New-AzLocalDeploymentReport.ps1

Function New-AzLocalDeploymentReport {
    <#
    .SYNOPSIS
 
    Generates HTML and Markdown deployment status reports.
 
    .DESCRIPTION
 
    Takes the output from Get-AzLocalDeploymentStatus and generates a self-contained
    HTML report with summary cards, a color-coded status table, and deployment details.
    Optionally generates a Markdown report suitable for GitHub Step Summary or Azure
    DevOps pipeline annotations.
 
    The HTML report uses the same visual style as the Pester test reports from
    Invoke-Tests.ps1 (Azure blue gradient header, summary cards, Segoe UI font).
 
    .PARAMETER StatusResults
    Array of PSCustomObjects from Get-AzLocalDeploymentStatus with properties:
    UniqueID, ClusterName, ResourceGroupName, DeploymentName, DeploymentStatus,
    ProvisioningState, Message, Duration.
 
    .PARAMETER HtmlOutputPath
    Optional. File path to write the HTML report. If omitted, no HTML is generated.
 
    .PARAMETER MarkdownOutputPath
    Optional. File path to write the Markdown report. If omitted, no Markdown is generated.
 
    .PARAMETER ReportTitle
    Optional. Title for the report header. Default: 'Azure Local Deployment Status Report'.
 
    .NOTES
    Author : Neil Bird, MSFT
    Version : 0.9.5
    Created : 2025-06-20
    #>


    [OutputType([hashtable])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [AllowEmptyCollection()]
        [PSCustomObject[]]$StatusResults,

        [Parameter(Mandatory = $false)]
        [string]$HtmlOutputPath = "",

        [Parameter(Mandatory = $false)]
        [string]$MarkdownOutputPath = "",

        [Parameter(Mandatory = $false)]
        [string]$ReportTitle = "Azure Local Deployment Status Report"
    )

    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $total = $StatusResults.Count

    # Categorise results
    $succeeded  = @($StatusResults | Where-Object { $_.DeploymentStatus -in @('DeploySucceeded','ValidateSucceeded','ClusterExists') }).Count
    $failed     = @($StatusResults | Where-Object { $_.DeploymentStatus -like '*Failed*' -or $_.DeploymentStatus -eq 'ContextError' }).Count
    $inProgress = @($StatusResults | Where-Object { $_.DeploymentStatus -like '*InProgress*' }).Count
    $notStarted = @($StatusResults | Where-Object { $_.DeploymentStatus -eq 'NotStarted' }).Count

    # Generate HTML report
    $htmlContent = ""
    if (-not [string]::IsNullOrWhiteSpace($HtmlOutputPath)) {
        $htmlContent = ConvertTo-AzLocalDeploymentHtml -StatusResults $StatusResults `
            -ReportTitle $ReportTitle -Timestamp $timestamp `
            -Total $total -Succeeded $succeeded -Failed $failed `
            -InProgress $inProgress -NotStarted $notStarted

        $outputDir = Split-Path $HtmlOutputPath -Parent
        if ($outputDir -and -not (Test-Path $outputDir)) {
            New-Item -Path $outputDir -ItemType Directory -Force | Out-Null
        }
        $htmlContent | Out-File -FilePath $HtmlOutputPath -Encoding utf8 -Force
        Write-AzLocalLog "HTML deployment report written to '$HtmlOutputPath'." -Level Success
    }

    # Generate Markdown report
    $markdownContent = ""
    if (-not [string]::IsNullOrWhiteSpace($MarkdownOutputPath)) {
        $markdownContent = ConvertTo-AzLocalDeploymentMarkdown -StatusResults $StatusResults `
            -ReportTitle $ReportTitle -Timestamp $timestamp `
            -Total $total -Succeeded $succeeded -Failed $failed `
            -InProgress $inProgress -NotStarted $notStarted

        $outputDir = Split-Path $MarkdownOutputPath -Parent
        if ($outputDir -and -not (Test-Path $outputDir)) {
            New-Item -Path $outputDir -ItemType Directory -Force | Out-Null
        }
        $markdownContent | Out-File -FilePath $MarkdownOutputPath -Encoding utf8 -Force
        Write-AzLocalLog "Markdown deployment report written to '$MarkdownOutputPath'." -Level Success
    }

    return @{
        Html     = $htmlContent
        Markdown = $markdownContent
    }
}


########################################
# Private helper: Build HTML report
########################################
Function ConvertTo-AzLocalDeploymentHtml {
    [OutputType([string])]
    [CmdletBinding()]
    param (
        [PSCustomObject[]]$StatusResults,
        [string]$ReportTitle,
        [string]$Timestamp,
        [int]$Total,
        [int]$Succeeded,
        [int]$Failed,
        [int]$InProgress,
        [int]$NotStarted
    )

    # Load System.Web for HtmlEncode
    Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue

    # Calculate progress percentages
    $pctSucceeded  = if ($Total -gt 0) { [math]::Round(($Succeeded  / $Total) * 100, 1) } else { 0 }
    $pctFailed     = if ($Total -gt 0) { [math]::Round(($Failed     / $Total) * 100, 1) } else { 0 }
    $pctInProgress = if ($Total -gt 0) { [math]::Round(($InProgress / $Total) * 100, 1) } else { 0 }
    $pctNotStarted = if ($Total -gt 0) { [math]::Round(($NotStarted / $Total) * 100, 1) } else { 0 }

    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.Append(@"
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>$([System.Web.HttpUtility]::HtmlEncode($ReportTitle))</title>
    <style>
        :root {
            --success-color: #28a745;
            --failure-color: #dc3545;
            --warning-color: #ffc107;
            --info-color: #17a2b8;
            --pending-color: #6c757d;
            --bg-color: #f8f9fa;
            --card-bg: #ffffff;
            --text-color: #212529;
            --border-color: #dee2e6;
        }
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background-color: var(--bg-color);
            color: var(--text-color);
            line-height: 1.6;
            padding: 20px;
        }
        .container { max-width: 1400px; margin: 0 auto; }
        header {
            background: linear-gradient(135deg, #0078d4, #005a9e);
            color: white;
            padding: 30px;
            border-radius: 10px;
            margin-bottom: 20px;
            box-shadow: 0 4px 6px rgba(0,0,0,0.1);
        }
        header h1 { font-size: 2em; margin-bottom: 10px; }
        header p { opacity: 0.9; }
        .summary {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
            gap: 15px;
            margin-bottom: 20px;
        }
        .summary-card {
            background: var(--card-bg);
            padding: 20px;
            border-radius: 8px;
            text-align: center;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            border-left: 4px solid var(--border-color);
        }
        .summary-card.succeeded { border-left-color: var(--success-color); }
        .summary-card.failed { border-left-color: var(--failure-color); }
        .summary-card.progress { border-left-color: var(--warning-color); }
        .summary-card.notstarted { border-left-color: var(--pending-color); }
        .summary-card.total { border-left-color: #0078d4; }
        .summary-card .number {
            font-size: 2.5em; font-weight: bold; display: block;
        }
        .summary-card.succeeded .number { color: var(--success-color); }
        .summary-card.failed .number { color: var(--failure-color); }
        .summary-card.progress .number { color: var(--warning-color); }
        .summary-card.notstarted .number { color: var(--pending-color); }
        .summary-card.total .number { color: #0078d4; }
        .summary-card .label {
            text-transform: uppercase; font-size: 0.85em;
            color: #6c757d; letter-spacing: 1px;
        }
        .progress-bar-container {
            background: var(--card-bg);
            border-radius: 8px;
            padding: 20px;
            margin-bottom: 20px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        .progress-bar-container h3 { margin-bottom: 10px; }
        .progress {
            height: 30px; background: #e9ecef;
            border-radius: 15px; overflow: hidden; display: flex;
        }
        .progress-succeeded { background: var(--success-color); }
        .progress-failed { background: var(--failure-color); }
        .progress-inprogress { background: var(--warning-color); }
        .progress-notstarted { background: var(--pending-color); }
        .cluster-table {
            background: var(--card-bg);
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            overflow: hidden;
            margin-bottom: 20px;
        }
        .cluster-table h2 {
            background: #f1f3f5;
            padding: 15px 20px;
            border-bottom: 1px solid var(--border-color);
        }
        table {
            width: 100%; border-collapse: collapse;
        }
        th {
            background: #f8f9fa; padding: 12px 16px;
            text-align: left; font-weight: 600;
            border-bottom: 2px solid var(--border-color);
            white-space: nowrap;
        }
        td {
            padding: 12px 16px;
            border-bottom: 1px solid #f1f3f5;
        }
        tr:hover td { background: #f8f9fa; }
        .status-badge {
            display: inline-block; padding: 4px 12px;
            border-radius: 12px; font-size: 0.85em;
            font-weight: 600; white-space: nowrap;
        }
        .status-succeeded { background: #d4edda; color: #155724; }
        .status-failed { background: #f8d7da; color: #721c24; }
        .status-inprogress { background: #fff3cd; color: #856404; }
        .status-notstarted { background: #e2e3e5; color: #383d41; }
        .message-cell {
            max-width: 400px; overflow: hidden;
            text-overflow: ellipsis; white-space: nowrap;
        }
        .message-cell:hover { white-space: normal; }
        footer {
            text-align: center; padding: 20px;
            color: #6c757d; font-size: 0.9em;
        }
    </style>
</head>
<body>
<div class="container">
    <header>
        <h1>$([System.Web.HttpUtility]::HtmlEncode($ReportTitle))</h1>
        <p>Generated $Timestamp</p>
    </header>
 
    <div class="summary">
        <div class="summary-card total">
            <span class="number">$Total</span>
            <span class="label">Total Clusters</span>
        </div>
        <div class="summary-card succeeded">
            <span class="number">$Succeeded</span>
            <span class="label">Succeeded</span>
        </div>
        <div class="summary-card failed">
            <span class="number">$Failed</span>
            <span class="label">Failed</span>
        </div>
        <div class="summary-card progress">
            <span class="number">$InProgress</span>
            <span class="label">In Progress</span>
        </div>
        <div class="summary-card notstarted">
            <span class="number">$NotStarted</span>
            <span class="label">Not Started</span>
        </div>
    </div>
 
    <div class="progress-bar-container">
        <h3>Overall Progress</h3>
        <div class="progress">
            <div class="progress-succeeded" style="width: $pctSucceeded%;" title="Succeeded: $Succeeded ($pctSucceeded%)"></div>
            <div class="progress-failed" style="width: $pctFailed%;" title="Failed: $Failed ($pctFailed%)"></div>
            <div class="progress-inprogress" style="width: $pctInProgress%;" title="In Progress: $InProgress ($pctInProgress%)"></div>
            <div class="progress-notstarted" style="width: $pctNotStarted%;" title="Not Started: $NotStarted ($pctNotStarted%)"></div>
        </div>
    </div>
 
    <div class="cluster-table">
        <h2>Cluster Status Details</h2>
        <table>
            <thead>
                <tr>
                    <th>UniqueID</th>
                    <th>Cluster Name</th>
                    <th>Resource Group</th>
                    <th>Status</th>
                    <th>Provisioning</th>
                    <th>Message</th>
                </tr>
            </thead>
            <tbody>
"@
)

    foreach ($result in $StatusResults) {
        $badgeClass = switch -Wildcard ($result.DeploymentStatus) {
            'DeploySucceeded'    { 'status-succeeded' }
            'ValidateSucceeded'  { 'status-succeeded' }
            'ClusterExists'      { 'status-succeeded' }
            '*Failed*'           { 'status-failed' }
            'ContextError'       { 'status-failed' }
            '*InProgress*'       { 'status-inprogress' }
            'NotStarted'         { 'status-notstarted' }
            default              { 'status-notstarted' }
        }

        $encodedUID     = [System.Web.HttpUtility]::HtmlEncode($result.UniqueID)
        $encodedCluster = [System.Web.HttpUtility]::HtmlEncode($result.ClusterName)
        $encodedRG      = [System.Web.HttpUtility]::HtmlEncode($result.ResourceGroupName)
        $encodedStatus  = [System.Web.HttpUtility]::HtmlEncode($result.DeploymentStatus)
        $encodedProv    = [System.Web.HttpUtility]::HtmlEncode($result.ProvisioningState)
        $encodedMsg     = [System.Web.HttpUtility]::HtmlEncode($result.Message)

        [void]$sb.Append(@"
 
                <tr>
                    <td><strong>$encodedUID</strong></td>
                    <td>$encodedCluster</td>
                    <td>$encodedRG</td>
                    <td><span class="status-badge $badgeClass">$encodedStatus</span></td>
                    <td>$encodedProv</td>
                    <td class="message-cell" title="$encodedMsg">$encodedMsg</td>
                </tr>
"@
)
    }

    if ($Total -eq 0) {
        [void]$sb.Append(@"
 
                <tr>
                    <td colspan="6" style="text-align: center; color: #6c757d; padding: 30px;">
                        No clusters with ReadyToDeploy = TRUE found in CSV.
                    </td>
                </tr>
"@
)
    }

    [void]$sb.Append(@"
 
            </tbody>
        </table>
    </div>
 
    <footer>
        <p>Generated by AzLocal.DeploymentAutomation v0.9.5 | $Timestamp</p>
    </footer>
</div>
</body>
</html>
"@
)

    return $sb.ToString()
}


########################################
# Private helper: Build Markdown report
########################################
Function ConvertTo-AzLocalDeploymentMarkdown {
    [OutputType([string])]
    [CmdletBinding()]
    param (
        [PSCustomObject[]]$StatusResults,
        [string]$ReportTitle,
        [string]$Timestamp,
        [int]$Total,
        [int]$Succeeded,
        [int]$Failed,
        [int]$InProgress,
        [int]$NotStarted
    )

    $sb = [System.Text.StringBuilder]::new()

    [void]$sb.AppendLine("## $ReportTitle")
    [void]$sb.AppendLine("")
    [void]$sb.AppendLine("**Generated:** $Timestamp")
    [void]$sb.AppendLine("")
    [void]$sb.AppendLine("### Summary")
    [void]$sb.AppendLine("")
    [void]$sb.AppendLine("| Status | Count |")
    [void]$sb.AppendLine("|--------|-------|")
    [void]$sb.AppendLine("| Succeeded | $Succeeded |")
    [void]$sb.AppendLine("| Failed | $Failed |")
    [void]$sb.AppendLine("| In Progress | $InProgress |")
    [void]$sb.AppendLine("| Not Started | $NotStarted |")
    [void]$sb.AppendLine("| **Total** | **$Total** |")
    [void]$sb.AppendLine("")

    if ($Total -gt 0) {
        [void]$sb.AppendLine("### Cluster Details")
        [void]$sb.AppendLine("")
        [void]$sb.AppendLine("| UniqueID | Cluster | Resource Group | Status | Provisioning |")
        [void]$sb.AppendLine("|----------|---------|----------------|--------|--------------|")

        foreach ($result in $StatusResults) {
            $statusIcon = switch -Wildcard ($result.DeploymentStatus) {
                'DeploySucceeded'   { '✅' }
                'ValidateSucceeded' { '✅' }
                'ClusterExists'     { '✅' }
                '*Failed*'          { '❌' }
                'ContextError'      { '❌' }
                '*InProgress*'      { '🔄' }
                'NotStarted'        { '⬜' }
                default             { '❓' }
            }
            [void]$sb.AppendLine("| $($result.UniqueID) | $($result.ClusterName) | $($result.ResourceGroupName) | $statusIcon $($result.DeploymentStatus) | $($result.ProvisioningState) |")
        }
        [void]$sb.AppendLine("")
    }

    # Add failed cluster details if any
    $failedResults = @($StatusResults | Where-Object { $_.DeploymentStatus -like '*Failed*' -or $_.DeploymentStatus -eq 'ContextError' })
    if ($failedResults.Count -gt 0) {
        [void]$sb.AppendLine("### Failed Deployments")
        [void]$sb.AppendLine("")
        foreach ($fail in $failedResults) {
            [void]$sb.AppendLine("- **$($fail.UniqueID)** ($($fail.ClusterName)): $($fail.Message)")
        }
        [void]$sb.AppendLine("")
    }

    return $sb.ToString()
}