src/report/renderers/Export-GovernanceReport.ps1
|
#Requires -Version 7.0 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' <# .SYNOPSIS Render the consultant-grade Cloud Governance assessment report -- a self-contained, offline HTML document with a 1-10 domain maturity score per CAF Govern risk category, a radar chart across the seven domains, and a domain-by-status heatmap (AB#6459, Feature AB#6458, Epic AB#6454). .DESCRIPTION Consumes the SAME scored Findings object every other Export-* renderer in this folder consumes (Get-Score's GeneratedOn/Frameworks/Areas/Gaps/Manual/Errors/Findings), filtered to the 'Cloud Governance' framework (the seven caf.govern.*.yaml rule files -- Regulatory Compliance, Security, Cost Management, Operations, Data, Resource Management, Artificial Intelligence). Get-GovernanceDomainScore.ps1 relabels each domain's existing 0-100 percentage score onto a 1-10 scale; no rule is duplicated and no second evaluation pass runs. See docs/design/governance-domain-maturity-scale.md for why 1-10 (not WAF's 5-level model, which Get-MaturityLevel.ps1 already covers for WAF pillars -- a DIFFERENT framework at a DIFFERENT granularity; this report never mixes the two scales on one axis). OFFLINE / CSP: reuses Export-EChartsDashboard.ps1's vendored Apache ECharts v5.6.0 build ($Script:ScoutEChartsLibJs) rather than introducing a second charting mechanism or a CDN reference. AzureScout.psm1 dot-sources every src/**/*.ps1 file into the module's own script scope, so a `$Script:`-scoped variable set in one dot-sourced file is visible to every other dot-sourced file in the same module (the same soft-dependency shape Get-Score.ps1/Get-MaturityLevel.ps1 already use) -- read via Get-Variable -Scope Script so a caller that has NOT loaded Export-EChartsDashboard.ps1 (e.g. a narrow unit test) degrades to the fallback banner rather than throwing. THE FALSE-PASS CLASS THIS REPORT MUST NOT JOIN (AB#6839/#6844/#6845): a domain whose every rule is Manual/Unknown/Error has Get-Score's denominator at 0, so its percentage score is $null and Get-GovernanceDomainScore marks it NotAssessed = $true. This report renders those domains as "Not assessed" text (never a fabricated low score, and never silently dropped from the domain list or the heatmap) and plots them as a gap (null) in the radar line rather than a 0 -- a 0 on a 1-10 radar would visually read as "assessed and scored worst", which is exactly the false-pass-adjacent misreading this exists to prevent. See the "no automated evidence" callout the template renders below the radar. .PARAMETER Findings The scored Findings object from Get-Score (run against the caf.govern.* rule files, or any RuleSet including them -- e.g. LandingZone's caf.*). .PARAMETER Collect The raw Collect object. Only used for _meta.scope/_meta.managementGroupId (title-bar context); every read is StrictMode-safe optional access via the same Get-ScoutEchartsProp-style accessor pattern Export-EChartsDashboard.ps1 uses. .PARAMETER OutputPath Folder to write governance_report.html into. Created if missing. .OUTPUTS [string] the full path to the written governance_report.html file (or, on a genuine render failure, a minimal self-contained fallback HTML file -- this renderer never throws back into Export-Report.ps1's dispatch loop). .NOTES Tracks ADO Story AB#6459 (Feature AB#6458, Epic AB#6454). #> #region Palette (mirrors Export-EChartsDashboard.ps1/Export-Pptx.ps1/Export-Word.ps1's navy/steel/gold corporate palette) $Script:ScoutGovNavy = '1F4E78' $Script:ScoutGovSteel = '2E75B6' $Script:ScoutGovGreen = '2E7D32' $Script:ScoutGovGold = 'B8860B' $Script:ScoutGovRed = 'B00020' $Script:ScoutGovGray = '595959' #endregion #region Data helpers function Get-ScoutGovProp { param($Obj, [Parameter(Mandatory)][string]$Name, $Default = $null) if ($null -eq $Obj) { return $Default } $p = $Obj.PSObject.Properties[$Name] if ($p) { return $p.Value } else { return $Default } } function Get-ScoutGovScoreColor { # 1-10 scale color banding -- distinct thresholds from # Get-ScoutEchartsScoreColor's 0-100 banding (>=80/>=50), scaled proportionally # (>=8/>=5 on a 1-10 scale) so the two renderers stay visually consistent. param($Score) if ($null -eq $Score) { return $Script:ScoutGovGray } if ($Score -ge 8) { return $Script:ScoutGovGreen } if ($Score -ge 5) { return $Script:ScoutGovGold } return $Script:ScoutGovRed } #endregion #region HTML fallback (non-fatal-on-failure, mirrors Export-EChartsDashboard.ps1's pattern) function Export-ScoutGovernanceHtmlFallback { param($Findings, [string] $OutputPath, [string] $Reason) if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } $safeReason = $Reason -replace '&', '&' -replace '<', '<' -replace '>', '>' $generatedOn = Get-ScoutGovProp $Findings 'GeneratedOn' '(unknown)' $html = @" <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Azure Scout — Cloud Governance Report (fallback)</title> <style> body { font-family: 'Segoe UI', Arial, sans-serif; color:#1a1a1a; margin: 2rem; } .banner { background:#B00020; color:#fff; padding: 0.75rem 1rem; margin-bottom:1rem; } </style> </head> <body> <div class="banner"> Governance report generation failed for this run -- this is a minimal HTML placeholder, not the real report. Reason: $safeReason </div> <h1>Azure Scout — Cloud Governance Report</h1> <p>Generated: $generatedOn</p> </body> </html> "@ $path = Join-Path $OutputPath 'governance_report_fallback.html' $html | Out-File -FilePath $path -Encoding utf8 return $path } #endregion #region Report HTML shell (CSS + client-side JS, theme-aware, ECharts-or-plain-table fallback) $Script:ScoutGovShellTemplate = @' <!DOCTYPE html> <html lang="en" data-theme="light"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Azure Scout — Cloud Governance Report</title> <style> :root { --bg: #F6F9FD; --panel: #FFFFFF; --ink: #1A1A1A; --gray: #595959; --line: #E2E2E2; --navy: #1F4E78; --gold: #B8860B; } html[data-theme="dark"] { --bg: #14181D; --panel: #1E242B; --ink: #EAEDF0; --gray: #A9B4BF; --line: #333B44; --navy: #6EA8DC; --gold: #E0B84A; } * { box-sizing: border-box; } body { margin: 0; font-family: 'Segoe UI', Arial, sans-serif; background: var(--bg); color: var(--ink); transition: background-color 0.15s ease, color 0.15s ease; } header { background: var(--navy); color: #fff; padding: 1.1rem 1.5rem; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; } header h1 { margin: 0; font-size: 1.35rem; } header .meta { font-size: 0.85rem; opacity: 0.85; } #theme-toggle { background: transparent; border: 1px solid #ffffff66; color: #fff; padding: 0.35rem 0.8rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; } #theme-toggle:hover { background: #ffffff22; } .headline { display: flex; align-items: baseline; gap: 0.75rem; padding: 1rem 1.5rem; } .headline .score { font-size: 2.4rem; font-weight: 700; color: var(--navy); } .headline .label { font-size: 0.95rem; color: var(--gray); } .not-assessed-callout { background: #B8860B22; border: 1px solid var(--gold); color: var(--ink); padding: 0.6rem 1rem; margin: 0 1.5rem 0.5rem 1.5rem; border-radius: 6px; font-size: 0.85rem; } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 1rem; padding: 1.25rem; } .card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 0.9rem 1rem; } .card h2 { margin: 0 0 0.5rem 0; font-size: 1rem; color: var(--navy); } .chart { width: 100%; height: 380px; } table.domain-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; } table.domain-table th, table.domain-table td { padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--line); text-align: left; } table.domain-table th { color: var(--gray); font-weight: 600; } .pill { display: inline-block; padding: 0.1rem 0.55rem; border-radius: 999px; color: #fff; font-weight: 600; font-size: 0.8rem; } footer { text-align: center; color: var(--gray); font-size: 0.78rem; padding: 1rem; } </style> </head> <body> <header> <div> <h1>Azure Scout — Cloud Governance Report</h1> <div class="meta" id="meta-line"></div> </div> <button id="theme-toggle" type="button">Toggle theme</button> </header> <div class="headline"> <span class="score" id="overall-score">--</span> <span class="label">overall governance maturity (1-10 scale, Scout's own -- see the report footer)</span> </div> <div id="not-assessed-slot"></div> <div id="fallback-banner-slot"></div> <div class="grid"> <div class="card"><h2>Domain Maturity — Radar (1-10 per CAF Govern risk category)</h2><div class="chart" id="chart-radar"></div></div> <div class="card"><h2>Domain × Status — Heatmap</h2><div class="chart" id="chart-heatmap"></div></div> <div class="card" style="grid-column: 1 / -1;"> <h2>Seven Governance Domains</h2> <table class="domain-table" id="domain-table"> <thead><tr><th>Domain</th><th>Score</th><th>Pass</th><th>Partial</th><th>Fail</th><th>Manual</th><th>Unknown</th></tr></thead> <tbody></tbody> </table> </div> </div> <footer> Generated by Azure Scout — fully self-contained, works offline. The 1-10 domain scale is Scout's own (CAF Govern publishes no numeric maturity model); it is not comparable to the Well-Architected Framework's separate 5-level maturity model shown on WAF pillar reports. </footer> <script> window.__GOV_DATA__ = /*__GOV_DATA__*/; </script> <script> /*__ECHARTS_LIB__*/ </script> <script> (function () { 'use strict'; var DATA = window.__GOV_DATA__; var metaEl = document.getElementById('meta-line'); var metaParts = []; if (DATA.Meta && DATA.Meta.GeneratedOn) { metaParts.push('Generated ' + DATA.Meta.GeneratedOn); } if (DATA.Meta && DATA.Meta.Scope) { metaParts.push('Scope: ' + DATA.Meta.Scope); } if (DATA.Meta && DATA.Meta.ManagementGroupId) { metaParts.push('Management Group: ' + DATA.Meta.ManagementGroupId); } metaEl.textContent = metaParts.join(' · '); document.getElementById('overall-score').textContent = (DATA.OverallScore === null || DATA.OverallScore === undefined) ? 'Not assessed' : String(DATA.OverallScore) + ' / 10'; var notAssessedDomains = DATA.Domains.filter(function (d) { return d.NotAssessed; }).map(function (d) { return d.Area; }); if (notAssessedDomains.length > 0) { var callout = document.createElement('div'); callout.className = 'not-assessed-callout'; callout.textContent = 'Not assessed (no automated evidence collected -- shown as a gap in the radar, never a fabricated score): ' + notAssessedDomains.join(', '); document.getElementById('not-assessed-slot').appendChild(callout); } // ---- domain table (always rendered, chart library or not) ---- var tbody = document.querySelector('#domain-table tbody'); DATA.Domains.forEach(function (d) { var tr = document.createElement('tr'); function td(text) { var c = document.createElement('td'); c.textContent = text; return c; } tr.appendChild(td(d.Area)); var scoreCell = document.createElement('td'); if (d.NotAssessed) { scoreCell.textContent = 'Not assessed'; } else { var pill = document.createElement('span'); pill.className = 'pill'; pill.style.background = d.Color; pill.textContent = d.Score + ' / 10'; scoreCell.appendChild(pill); } tr.appendChild(scoreCell); tr.appendChild(td(d.Pass)); tr.appendChild(td(d.Partial)); tr.appendChild(td(d.Fail)); tr.appendChild(td(d.Manual)); tr.appendChild(td(d.Unknown)); tbody.appendChild(tr); }); var HAS_ECHARTS = !!(DATA.HasEchartsLib && typeof echarts !== 'undefined'); var charts = {}; function getThemeColors() { var dark = document.documentElement.getAttribute('data-theme') === 'dark'; return { text: dark ? '#EAEDF0' : '#1A1A1A', axis: dark ? '#333B44' : '#E2E2E2', bg: 'transparent' }; } function renderRadar() { var el = document.getElementById('chart-radar'); var chart = charts.radar || echarts.init(el); charts.radar = chart; var c = getThemeColors(); var indicators = DATA.Domains.map(function (d) { return { name: d.Area, max: 10 }; }); // NotAssessed domains plot as null -- ECharts breaks the radar line at a null point // rather than drawing a false "0" vertex, which would visually read as "assessed and // scored worst" instead of "no automated evidence" (see this file's header .DESCRIPTION). var values = DATA.Domains.map(function (d) { return d.NotAssessed ? null : d.Score; }); chart.setOption({ backgroundColor: c.bg, textStyle: { color: c.text }, tooltip: {}, radar: { indicator: indicators, axisName: { color: c.text }, splitLine: { lineStyle: { color: c.axis } }, splitArea: { show: false }, axisLine: { lineStyle: { color: c.axis } } }, series: [{ type: 'radar', data: [{ value: values, name: 'Domain maturity (1-10)', areaStyle: { opacity: 0.15 }, lineStyle: { color: '#2E75B6' }, itemStyle: { color: '#2E75B6' } }] }] }, true); } function renderHeatmap() { var el = document.getElementById('chart-heatmap'); var chart = charts.heatmap || echarts.init(el); charts.heatmap = chart; var c = getThemeColors(); var domains = DATA.Domains.map(function (d) { return d.Area; }); var statuses = ['Pass', 'Partial', 'Fail', 'Manual', 'Unknown']; var cells = []; var max = 1; DATA.Domains.forEach(function (d, di) { statuses.forEach(function (s, si) { var v = d[s] || 0; if (v > max) { max = v; } cells.push([si, di, v]); }); }); chart.setOption({ backgroundColor: c.bg, textStyle: { color: c.text }, tooltip: { position: 'top' }, grid: { left: '22%', right: '6%', top: 10, bottom: 40 }, xAxis: { type: 'category', data: statuses, axisLine: { lineStyle: { color: c.axis } }, axisLabel: { color: c.text }, splitArea: { show: true } }, yAxis: { type: 'category', data: domains, axisLine: { lineStyle: { color: c.axis } }, axisLabel: { color: c.text }, splitArea: { show: true } }, visualMap: { min: 0, max: max, calculable: true, orient: 'horizontal', left: 'center', bottom: 0, textStyle: { color: c.text }, inRange: { color: ['#F6F9FD', '#2E75B6', '#1F4E78'] } }, series: [{ type: 'heatmap', data: cells, label: { show: true, color: c.text }, emphasis: { itemStyle: { shadowBlur: 6, shadowColor: 'rgba(0,0,0,0.3)' } } }] }, true); } function renderFallbackTableNote() { var banner = document.createElement('div'); banner.className = 'not-assessed-callout'; banner.textContent = 'ECharts library was not available for this build -- the radar and heatmap charts are not rendered; the domain table above carries the same data.'; document.getElementById('fallback-banner-slot').appendChild(banner); } function renderAll() { if (HAS_ECHARTS) { renderRadar(); renderHeatmap(); } else { renderFallbackTableNote(); } } document.getElementById('theme-toggle').addEventListener('click', function () { var cur = document.documentElement.getAttribute('data-theme'); document.documentElement.setAttribute('data-theme', cur === 'dark' ? 'light' : 'dark'); if (HAS_ECHARTS) { renderRadar(); renderHeatmap(); } }); window.addEventListener('resize', function () { if (!HAS_ECHARTS) { return; } Object.keys(charts).forEach(function (k) { charts[k].resize(); }); }); renderAll(); })(); </script> </body> </html> '@ #endregion function Export-GovernanceReport { <# .SYNOPSIS Renders the self-contained Cloud Governance assessment report (radar + heatmap + 1-10 domain table). See this file's header comment-based help for the full design writeup (AB#6459). #> param($Findings, $Collect, [string] $OutputPath) try { if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } $areas = @(Get-ScoutGovProp $Findings 'Areas') $generatedOn = Get-ScoutGovProp $Findings 'GeneratedOn' $metaSrc = Get-ScoutGovProp $Collect '_meta' $scope = Get-ScoutGovProp $metaSrc 'scope' $mgId = Get-ScoutGovProp $metaSrc 'managementGroupId' # NOT wrapped in @(): Get-GovernanceDomainScore returns its array through # `Write-Output -NoEnumerate`, so @() here would produce a one-element array whose # sole element is the (possibly empty) real array -- the same gotcha # Invoke-Assessment.ps1 documents for Resolve-JsonPath's callers. $domainScores = Get-GovernanceDomainScore -Areas $areas -Framework 'Cloud Governance' # AB#6459 AC: seven governance domains. A collect run scored against a RuleSet that # never loaded the caf.govern.*.yaml files produces zero Cloud Governance areas -- # surface that honestly (all seven "Not assessed") rather than rendering an empty # page, the same "absence is visible, not silent" principle NotAssessed already # follows elsewhere in this codebase. $expectedDomains = @('Regulatory Compliance', 'Security', 'Cost Management', 'Operations', 'Data', 'Resource Management', 'Artificial Intelligence') if ($domainScores.Count -eq 0) { $domainScores = @($expectedDomains | ForEach-Object { [pscustomobject]@{ Area = $_; PercentScore = $null; Score = $null; NotAssessed = $true Pass = 0; Partial = 0; Fail = 0; Manual = 0; Unknown = 0; Error = 0 } }) } $domainPayload = @($domainScores | ForEach-Object { [pscustomobject]@{ Area = $_.Area Score = $_.Score NotAssessed = $_.NotAssessed Color = "#$(Get-ScoutGovScoreColor $_.Score)" Pass = $_.Pass Partial = $_.Partial Fail = $_.Fail Manual = $_.Manual Unknown = $_.Unknown } }) # Overall headline: the Cloud Governance framework's own weighted-mean percentage # (already computed by Get-Score across the seven domains, AreaWeight-weighted -- # AB#5087), relabeled onto the same 1-10 scale as the per-domain figures via the # exact same ConvertTo-ScoutGovernanceScale helper, so the headline and the seven # domain numbers can never use different arithmetic. $frameworks = @(Get-ScoutGovProp $Findings 'Frameworks') $govFramework = $frameworks | Where-Object { $_.Framework -eq 'Cloud Governance' } | Select-Object -First 1 $overallPct = if ($govFramework) { Get-ScoutGovProp $govFramework 'Score' } else { $null } $overallScore = ConvertTo-ScoutGovernanceScale -PercentScore $overallPct $hasLib = [bool](Get-Variable -Name ScoutEChartsLibJs -Scope Script -ErrorAction SilentlyContinue) -and -not [string]::IsNullOrWhiteSpace($Script:ScoutEChartsLibJs) $payload = [pscustomobject]@{ Meta = [pscustomobject]@{ GeneratedOn = $generatedOn; Scope = $scope; ManagementGroupId = $mgId } Domains = $domainPayload OverallScore = $overallScore HasEchartsLib = $hasLib } # </script> inside embedded JSON would otherwise close the <script> tag early. $json = ($payload | ConvertTo-Json -Depth 20) -replace '</', '<\/' $html = $Script:ScoutGovShellTemplate.Replace('/*__GOV_DATA__*/', $json) $html = $html.Replace('/*__ECHARTS_LIB__*/', $(if ($hasLib) { $Script:ScoutEChartsLibJs } else { '' })) $outFile = Join-Path $OutputPath 'governance_report.html' $html | Out-File -FilePath $outFile -Encoding utf8 return $outFile } catch { Write-Warning "Export-GovernanceReport: report generation failed ($_) -- writing a minimal HTML placeholder instead." return (Export-ScoutGovernanceHtmlFallback -Findings $Findings -OutputPath $OutputPath -Reason $_.Exception.Message) } } |