src/report/renderers/Export-React.ps1

#Requires -Version 7.0
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#
.SYNOPSIS
    Render a self-contained, interactive HTML report (client-side filter/sort/
    search dashboard + optional cross-run drift panel) from scored findings.
 
.DESCRIPTION
    Same renderer contract as Export-Html/Export-Pptx/Export-Excel: consumes the
    scored Findings object produced by Get-Score (GeneratedOn/Frameworks/Areas/
    Gaps/Manual/Errors/Findings) plus the raw Collect object (only its optional
    _meta.scope / _meta.managementGroupId are read, mirroring Export-Pptx).
 
    Everything is inlined into one report-react.html file — CSS, JS, and the
    findings/drift data as a JSON blob in a <script> tag. No CDN fetch, no
    external <script src="http...">/<link href="http..."> references, so the
    report opens and works fully offline (same offline-artifact rule the PPTX
    renderer follows for the OpenXML SDK).
 
    The interactive UI (Dashboard / Findings / Gaps / Manual review / Drift
    tabs) is hand-written vanilla JS reading window.__SCOUT_DATA__ — no React
    UMD bundle, no build step, per the "vanilla JS component model" option in
    the design brief for this renderer.
 
.PARAMETER Findings
    The scored Findings object from Get-Score.
 
.PARAMETER Collect
    The raw Collect object (only _meta.scope / _meta.managementGroupId read).
 
.PARAMETER OutputPath
    Folder to write report-react.html into. Created if missing.
 
.PARAMETER Drift
    Optional. The drift object from Get-ScoutDrift (see
    src/report/Get-ScoutDrift.ps1). When supplied, the report embeds it and
    renders a Drift tab (New/Resolved/Regressed/Unchanged + per-finding
    changes + overall score delta). When omitted/$null, the Drift tab is not
    shown — this keeps Export-React callable with the exact same 3-argument
    shape as every other Export-* renderer for callers that don't have drift
    data yet (e.g. a first CollectOnly/FromCollect run), while still letting
    Invoke-ScoutAssessment pass -Drift once Get-ScoutDrift has run.
 
.OUTPUTS
    [string] the full path to the written report-react.html file.
 
.NOTES
    Tracks ADO Story AB#5053. Also feeds the interactive-visuals feature trio
    (AB#376-378, 380, 386-393): VNet topology, MG hierarchy, Governance
    section (budgets/locks/tag chips/policy) and inventory KPI cards all need
    a slice of the raw Collect object (networking/compute/governance/tags),
    not just Findings — embedded below via the same StrictMode-safe optional
    access `_meta` already used, so a Collect missing any of those keys (an
    older collect.json, a CollectOnly run before Import-Governance ran, or a
    scope that skipped a category) degrades to an empty/absent client-side
    section instead of throwing.
#>

function Export-React {
    param(
        $Findings,
        $Collect,
        [string] $OutputPath,
        $Drift = $null
    )

    if (-not (Test-Path $OutputPath)) {
        New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null
    }

    # Safe nested property access — $Collect (and, defensively, $Findings) may
    # be a plain deserialized PSCustomObject missing keys we don't require,
    # and Set-StrictMode -Version Latest throws on a missing-property
    # dot-access. Walks a dotted path one segment at a time, returning $null
    # the moment any segment is absent instead of throwing.
    function Get-ReactSafeProp {
        param($Object, [string[]] $Path)
        $cur = $Object
        foreach ($seg in $Path) {
            if ($null -eq $cur) { return $null }
            $prop = $cur.PSObject.Properties[$seg]
            if (-not $prop) { return $null }
            $cur = $prop.Value
        }
        return $cur
    }

    $metaSrc = Get-ReactSafeProp $Collect @('_meta')
    $scope = Get-ReactSafeProp $metaSrc @('scope')
    $mgId  = Get-ReactSafeProp $metaSrc @('managementGroupId')
    $meta = [pscustomobject]@{ Scope = $scope; ManagementGroupId = $mgId }

    # Only the fields the client-side topology/governance/KPI visuals read —
    # deliberately not the whole Collect object (keeps the embedded payload
    # small and avoids leaking any raw data those visuals never surface).
    $networking = [pscustomobject]@{
        VirtualNetworks  = Get-ReactSafeProp $Collect @('networking', 'virtualNetworks')
        Subnets          = Get-ReactSafeProp $Collect @('networking', 'subnets')
        AzureFirewalls   = Get-ReactSafeProp $Collect @('networking', 'azureFirewalls')
        VpnGateways      = Get-ReactSafeProp $Collect @('networking', 'vpnGateways')
        PrivateEndpoints = Get-ReactSafeProp $Collect @('networking', 'privateEndpoints')
    }
    $compute = [pscustomobject]@{
        VirtualMachines = Get-ReactSafeProp $Collect @('compute', 'virtualMachines')
    }
    $governance = [pscustomobject]@{
        ManagementGroups  = Get-ReactSafeProp $Collect @('governance', 'managementGroups')
        PolicyAssignments = Get-ReactSafeProp $Collect @('governance', 'policyAssignments')
        Budgets           = Get-ReactSafeProp $Collect @('governance', 'budgets')
        ResourceLocks     = Get-ReactSafeProp $Collect @('governance', 'resourceLocks')
    }
    $tags = Get-ReactSafeProp $Collect @('tags')

    $payload = [pscustomobject]@{
        Findings   = $Findings
        Drift      = $Drift
        Meta       = $meta
        Networking = $networking
        Compute    = $compute
        Governance = $governance
        Tags       = $tags
    }

    # </script> inside embedded JSON would otherwise close the <script> tag early.
    $json = ($payload | ConvertTo-Json -Depth 100) -replace '</', '<\/'

    $tplPath = "$PSScriptRoot/../templates/report-react.html.template"
    $tpl = Get-Content $tplPath -Raw
    $html = $tpl.Replace('/*__SCOUT_DATA__*/', $json)

    $outFile = Join-Path $OutputPath 'report-react.html'
    $html | Out-File $outFile -Encoding utf8

    return $outFile
}