helpers/gcp/GCPHelpers.ps1

# =============================================================================
# GCP Helpers for WhatsUpGoldPS
# Requires the GoogleCloud PowerShell module and the gcloud CLI.
# Install the module:
# Install-Module -Name GoogleCloud -Scope CurrentUser -Force
# Install the gcloud CLI:
# https://cloud.google.com/sdk/docs/install
# Authenticate:
# gcloud auth activate-service-account --key-file="path/to/service-account-key.json"
# gcloud config set project YOUR_PROJECT_ID
# =============================================================================

function Connect-GCPAccount {
    <#
    .SYNOPSIS
        Authenticates to GCP using a service account key file via the gcloud CLI.
    .DESCRIPTION
        Activates a service account, sets the default project, and validates
        connectivity. Requires the gcloud CLI to be installed and in PATH.
    .PARAMETER KeyFilePath
        Path to the service account JSON key file.
    .PARAMETER Project
        The GCP project ID to set as default.
    .EXAMPLE
        Connect-GCPAccount -KeyFilePath "C:\keys\service-account.json" -Project "my-gcp-project-123"
        Authenticates to GCP using the specified service account key and sets the default project.
    .EXAMPLE
        Connect-GCPAccount -KeyFilePath $env:GCP_KEY_FILE -Project $env:GCP_PROJECT
        Authenticates using paths from environment variables.
    #>

    param(
        [Parameter(Mandatory)][string]$KeyFilePath,
        [Parameter(Mandatory)][string]$Project
    )

    if (-not (Get-Command gcloud -ErrorAction SilentlyContinue)) {
        throw "gcloud CLI is not installed or not in PATH. Install from: https://cloud.google.com/sdk/docs/install"
    }

    if (-not (Test-Path $KeyFilePath)) {
        throw "Service account key file not found: $KeyFilePath"
    }

    # Activate service account
    $result = & gcloud auth activate-service-account --key-file="$KeyFilePath" 2>&1
    if ($LASTEXITCODE -ne 0) {
        throw "Failed to activate service account: $result"
    }

    # Set default project
    & gcloud config set project $Project 2>&1 | Out-Null

    # Validate connectivity
    try {
        $projectInfo = & gcloud projects describe $Project --format=json 2>&1 | ConvertFrom-Json
        if (-not $projectInfo.projectId) { throw "No project info returned" }
        Write-Verbose "Connected to GCP project $($projectInfo.projectId) ($($projectInfo.name))"
    }
    catch {
        throw "Failed to validate GCP connectivity: $($_.Exception.Message)"
    }
}

function Get-GCPAccessToken {
    <#
    .SYNOPSIS
        Returns a current OAuth2 access token from gcloud for REST API calls.
    .EXAMPLE
        $token = Get-GCPAccessToken
        Returns a bearer token string for use in REST API Authorization headers.
    #>


    $token = & gcloud auth print-access-token 2>&1
    if ($LASTEXITCODE -ne 0) {
        throw "Failed to get access token: $token"
    }
    return $token.Trim()
}

function Get-GCPProjects {
    <#
    .SYNOPSIS
        Returns all accessible GCP projects.
    .EXAMPLE
        Get-GCPProjects
        Returns all GCP projects accessible to the authenticated service account.
    .EXAMPLE
        Get-GCPProjects | Where-Object { $_.State -eq "ACTIVE" }
        Returns only active GCP projects.
    #>


    $json = & gcloud projects list --format=json 2>&1
    if ($LASTEXITCODE -ne 0) { throw "Failed to list projects: $json" }

    $projects = $json | ConvertFrom-Json
    foreach ($p in $projects) {
        [PSCustomObject]@{
            ProjectId   = "$($p.projectId)"
            ProjectName = "$($p.name)"
            State       = "$($p.lifecycleState)"
            CreateTime  = "$($p.createTime)"
        }
    }
}

function Get-GCPComputeInstances {
    <#
    .SYNOPSIS
        Returns all Compute Engine VM instances in the specified project.
    .DESCRIPTION
        Uses Get-GceInstance from the GoogleCloud module to enumerate all VMs
        across all zones, returning a simplified collection with key properties.
    .PARAMETER Project
        The GCP project ID. If omitted, uses the default gcloud project.
    .EXAMPLE
        Get-GCPComputeInstances
        Returns all Compute Engine VMs in the default project.
    .EXAMPLE
        Get-GCPComputeInstances -Project "my-gcp-project-123"
        Returns all VMs in the specified project.
    .EXAMPLE
        Get-GCPComputeInstances | Where-Object { $_.Status -eq "RUNNING" }
        Returns only running VM instances.
    #>

    param(
        [string]$Project
    )

    $splat = @{}
    if ($Project) { $splat["Project"] = $Project }

    $instances = Get-GceInstance @splat -ErrorAction Stop
    foreach ($inst in $instances) {
        # Extract zone short name
        $zone = if ($inst.Zone) { ($inst.Zone -split '/')[-1] } else { "N/A" }
        $region = if ($zone -ne "N/A") { $zone -replace '-[a-z]$', '' } else { "N/A" }

        # Network interfaces
        $primaryNic = $inst.NetworkInterfaces | Select-Object -First 1
        $internalIP = if ($primaryNic.NetworkIP) { "$($primaryNic.NetworkIP)" } else { "N/A" }

        # External IP (from access configs)
        $externalIP = "N/A"
        if ($primaryNic.AccessConfigs) {
            $natIP = ($primaryNic.AccessConfigs | Where-Object { $_.NatIP } | Select-Object -First 1).NatIP
            if ($natIP) { $externalIP = "$natIP" }
        }

        # Disks
        $diskCount = @($inst.Disks).Count
        $bootDisk = $inst.Disks | Where-Object { $_.Boot -eq $true } | Select-Object -First 1
        $bootDiskType = if ($bootDisk.Interface) { "$($bootDisk.Interface)" } else { "N/A" }

        # Machine type short name
        $machineType = if ($inst.MachineType) { ($inst.MachineType -split '/')[-1] } else { "N/A" }

        # Tags
        $tags = if ($inst.Tags -and $inst.Tags.Items) { $inst.Tags.Items -join ", " } else { "" }

        # Labels
        $labels = if ($inst.Labels) {
            ($inst.Labels.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "; "
        } else { "" }

        # Service accounts
        $serviceAccounts = if ($inst.ServiceAccounts) {
            ($inst.ServiceAccounts | ForEach-Object { $_.Email }) -join ", "
        } else { "N/A" }

        # Network name
        $networkName = if ($primaryNic.Network) { ($primaryNic.Network -split '/')[-1] } else { "N/A" }
        $subnetName  = if ($primaryNic.Subnetwork) { ($primaryNic.Subnetwork -split '/')[-1] } else { "N/A" }

        [PSCustomObject]@{
            Name            = "$($inst.Name)"
            InstanceId      = "$($inst.Id)"
            MachineType     = $machineType
            Status          = "$($inst.Status)"
            Zone            = $zone
            Region          = $region
            InternalIP      = $internalIP
            ExternalIP      = $externalIP
            Network         = $networkName
            Subnet          = $subnetName
            DiskCount       = "$diskCount"
            BootDiskType    = $bootDiskType
            Tags            = $tags
            Labels          = $labels
            ServiceAccounts = $serviceAccounts
            CreationTime    = if ($inst.CreationTimestamp) { "$($inst.CreationTimestamp)" } else { "N/A" }
            Description     = if ($inst.Description) { "$($inst.Description.Substring(0, [math]::Min(200, $inst.Description.Length)))" } else { "" }
        }
    }
}

function Get-GCPCloudSQLInstances {
    <#
    .SYNOPSIS
        Returns all Cloud SQL instances in the specified project.
    .PARAMETER Project
        The GCP project ID.
    .EXAMPLE
        Get-GCPCloudSQLInstances
        Returns all Cloud SQL instances in the default project.
    .EXAMPLE
        Get-GCPCloudSQLInstances -Project "my-gcp-project-123"
        Returns all Cloud SQL instances in the specified project with IP, tier, and status.
    .EXAMPLE
        Get-GCPCloudSQLInstances | Where-Object { $_.DatabaseVersion -like "MYSQL*" }
        Returns only MySQL Cloud SQL instances.
    #>

    param(
        [string]$Project
    )

    $splat = @{ ErrorAction = "Stop" }
    if ($Project) { $splat["Project"] = $Project }

    $instances = Get-GcSqlInstance @splat
    foreach ($db in $instances) {
        # IP addresses
        $publicIP  = "N/A"
        $privateIP = "N/A"
        if ($db.IpAddresses) {
            foreach ($addr in $db.IpAddresses) {
                if ($addr.Type -eq "PRIMARY")  { $publicIP  = "$($addr.IpAddress)" }
                if ($addr.Type -eq "PRIVATE")  { $privateIP = "$($addr.IpAddress)" }
            }
        }

        [PSCustomObject]@{
            InstanceName    = "$($db.Name)"
            DatabaseVersion = "$($db.DatabaseVersion)"
            Tier            = "$($db.Settings.Tier)"
            State           = "$($db.State)"
            Region          = "$($db.Region)"
            GceZone         = if ($db.GceZone) { "$($db.GceZone)" } else { "N/A" }
            PublicIP        = $publicIP
            PrivateIP       = $privateIP
            DataDiskSizeGB  = if ($db.Settings.DataDiskSizeGb) { "$($db.Settings.DataDiskSizeGb)" } else { "N/A" }
            DataDiskType    = if ($db.Settings.DataDiskType) { "$($db.Settings.DataDiskType)" } else { "N/A" }
            BackupEnabled   = if ($null -ne $db.Settings.BackupConfiguration.Enabled) { "$($db.Settings.BackupConfiguration.Enabled)" } else { "N/A" }
            HA              = if ($db.Settings.AvailabilityType) { "$($db.Settings.AvailabilityType)" } else { "N/A" }
            StorageAutoResize = if ($null -ne $db.Settings.StorageAutoResize) { "$($db.Settings.StorageAutoResize)" } else { "N/A" }
            ConnectionName  = if ($db.ConnectionName) { "$($db.ConnectionName)" } else { "N/A" }
            SelfLink        = if ($db.SelfLink) { "$($db.SelfLink)" } else { "N/A" }
        }
    }
}

function Get-GCPForwardingRules {
    <#
    .SYNOPSIS
        Returns all forwarding rules (load balancer frontends) in the project.
    .DESCRIPTION
        Uses the gcloud CLI to enumerate global and regional forwarding rules,
        which represent load balancer entry points with IP addresses.
    .PARAMETER Project
        The GCP project ID.
    .EXAMPLE
        Get-GCPForwardingRules
        Returns all global and regional forwarding rules (load balancer frontends) in the default project.
    .EXAMPLE
        Get-GCPForwardingRules -Project "my-gcp-project-123"
        Returns all forwarding rules in the specified project.
    .EXAMPLE
        Get-GCPForwardingRules | Where-Object { $_.Scheme -eq "EXTERNAL" }
        Returns only external-facing forwarding rules.
    #>

    param(
        [string]$Project
    )

    $projectArg = if ($Project) { "--project=$Project" } else { "" }

    # Global forwarding rules
    $globalJson = if ($projectArg) {
        & gcloud compute forwarding-rules list --global --format=json $projectArg 2>&1
    } else {
        & gcloud compute forwarding-rules list --global --format=json 2>&1
    }

    # Regional forwarding rules
    $regionalJson = if ($projectArg) {
        & gcloud compute forwarding-rules list --format=json $projectArg 2>&1
    } else {
        & gcloud compute forwarding-rules list --format=json 2>&1
    }

    $allRules = @()
    foreach ($json in @($globalJson, $regionalJson)) {
        if ($LASTEXITCODE -eq 0 -and $json) {
            try {
                $rules = $json | ConvertFrom-Json
                $allRules += $rules
            }
            catch { }
        }
    }

    # Deduplicate by selfLink
    $seen = @{}
    foreach ($rule in $allRules) {
        $key = "$($rule.selfLink)"
        if ($seen.ContainsKey($key)) { continue }
        $seen[$key] = $true

        $region = if ($rule.region) { ($rule.region -split '/')[-1] } else { "global" }

        [PSCustomObject]@{
            Name         = "$($rule.name)"
            IPAddress    = if ($rule.IPAddress) { "$($rule.IPAddress)" } else { "N/A" }
            IPProtocol   = if ($rule.IPProtocol) { "$($rule.IPProtocol)" } else { "N/A" }
            PortRange    = if ($rule.portRange) { "$($rule.portRange)" } else { "N/A" }
            Target       = if ($rule.target) { ($rule.target -split '/')[-1] } else { "N/A" }
            Region       = $region
            Network      = if ($rule.network) { ($rule.network -split '/')[-1] } else { "N/A" }
            Scheme       = if ($rule.loadBalancingScheme) { "$($rule.loadBalancingScheme)" } else { "N/A" }
            SelfLink     = "$($rule.selfLink)"
        }
    }
}

function Get-GCPCloudMonitoringMetrics {
    <#
    .SYNOPSIS
        Returns recent Cloud Monitoring metric data for a GCP resource.
    .DESCRIPTION
        Queries the Cloud Monitoring API v3 for time series data.
        Uses the gcloud access token for authentication.
    .PARAMETER Project
        The GCP project ID.
    .PARAMETER ResourceType
        The monitored resource type (e.g. gce_instance, cloudsql_database).
    .PARAMETER ResourceLabels
        Hashtable of resource labels to filter on (e.g. @{instance_id="123456"}).
    .PARAMETER MetricTypes
        Array of metric type strings to retrieve. If omitted, uses sensible defaults.
    .EXAMPLE
        Get-GCPCloudMonitoringMetrics -Project "my-project" -ResourceType "gce_instance" -ResourceLabels @{instance_id="1234567890"}
        Returns default Compute Engine metrics (CPU, network, disk) for the specified VM instance.
    .EXAMPLE
        Get-GCPCloudMonitoringMetrics -Project "my-project" -ResourceType "cloudsql_database" -ResourceLabels @{database_id="my-project:mydb"}
        Returns default Cloud SQL metrics (CPU, memory, disk utilization) for the specified database.
    .EXAMPLE
        Get-GCPCloudMonitoringMetrics -Project "my-project" -ResourceType "gce_instance" -ResourceLabels @{instance_id="123"} -MetricTypes @("compute.googleapis.com/instance/cpu/utilization")
        Returns only CPU utilization metrics for the specified instance.
    #>

    param(
        [Parameter(Mandatory)][string]$Project,
        [Parameter(Mandatory)][string]$ResourceType,
        [Parameter(Mandatory)][hashtable]$ResourceLabels,
        [string[]]$MetricTypes
    )

    # Default metrics per resource type
    if (-not $MetricTypes) {
        $MetricTypes = switch ($ResourceType) {
            "gce_instance" {
                @("compute.googleapis.com/instance/cpu/utilization",
                  "compute.googleapis.com/instance/network/received_bytes_count",
                  "compute.googleapis.com/instance/network/sent_bytes_count",
                  "compute.googleapis.com/instance/disk/read_ops_count",
                  "compute.googleapis.com/instance/disk/write_ops_count",
                  "compute.googleapis.com/instance/uptime")
            }
            "cloudsql_database" {
                @("cloudsql.googleapis.com/database/cpu/utilization",
                  "cloudsql.googleapis.com/database/memory/utilization",
                  "cloudsql.googleapis.com/database/disk/utilization",
                  "cloudsql.googleapis.com/database/network/connections",
                  "cloudsql.googleapis.com/database/up")
            }
            default { @() }
        }
    }

    $token = Get-GCPAccessToken
    $headers = @{ Authorization = "Bearer $token" }
    $baseUri = "https://monitoring.googleapis.com/v3/projects/$Project/timeSeries"

    $endTime   = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
    $startTime = (Get-Date).AddHours(-1).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")

    $results = @()
    foreach ($metricType in $MetricTypes) {
        try {
            # Build filter
            $filterParts = @("metric.type = `"$metricType`"", "resource.type = `"$ResourceType`"")
            foreach ($label in $ResourceLabels.GetEnumerator()) {
                $filterParts += "resource.labels.$($label.Key) = `"$($label.Value)`""
            }
            $filter = $filterParts -join " AND "

            $uri = "${baseUri}?filter=$([System.Uri]::EscapeDataString($filter))&interval.startTime=$startTime&interval.endTime=$endTime&aggregation.alignmentPeriod=300s&aggregation.perSeriesAligner=ALIGN_MEAN"

            $response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get -ErrorAction Stop

            $lastValue = "N/A"
            $unit = "N/A"
            if ($response.timeSeries) {
                $series = $response.timeSeries | Select-Object -First 1
                $unit = if ($series.unit) { "$($series.unit)" } else { "N/A" }
                $latest = $series.points | Select-Object -First 1
                if ($latest.value) {
                    if ($null -ne $latest.value.doubleValue) {
                        $lastValue = "$([math]::Round($latest.value.doubleValue, 4))"
                    }
                    elseif ($null -ne $latest.value.int64Value) {
                        $lastValue = "$($latest.value.int64Value)"
                    }
                }
            }

            # Friendly metric name from the full type
            $shortName = ($metricType -split '/')[-1]

            $results += [PSCustomObject]@{
                MetricType  = $metricType
                MetricName  = $shortName
                LastValue   = $lastValue
                Unit        = $unit
            }
        }
        catch {
            Write-Verbose "Could not retrieve metric $metricType : $($_.Exception.Message)"
        }
    }

    return $results
}

function Resolve-GCPResourceIP {
    <#
    .SYNOPSIS
        Resolves an IP address for a GCP resource.
    .DESCRIPTION
        For Compute Engine VMs returns external IP (preferred) or internal IP.
        For Cloud SQL returns public IP (preferred) or private IP.
        For forwarding rules returns the rule IP address.
    .PARAMETER ResourceType
        The type of resource: ComputeEngine, CloudSQL, or ForwardingRule.
    .PARAMETER Resource
        The resource object from the corresponding Get-GCP* function.
    .EXAMPLE
        $vms = Get-GCPComputeInstances
        $ip = Resolve-GCPResourceIP -ResourceType "ComputeEngine" -Resource $vms[0]
        Resolves the IP for the first Compute Engine VM (prefers external IP).
    .EXAMPLE
        $dbs = Get-GCPCloudSQLInstances
        Resolve-GCPResourceIP -ResourceType "CloudSQL" -Resource $dbs[0]
        Resolves the IP for a Cloud SQL instance (prefers public IP).
    .EXAMPLE
        $rules = Get-GCPForwardingRules
        Resolve-GCPResourceIP -ResourceType "ForwardingRule" -Resource $rules[0]
        Returns the IP address of the first forwarding rule.
    #>

    param(
        [Parameter(Mandatory)][ValidateSet("ComputeEngine", "CloudSQL", "ForwardingRule")][string]$ResourceType,
        [Parameter(Mandatory)]$Resource
    )

    $ip = $null

    switch ($ResourceType) {
        "ComputeEngine" {
            if ($Resource.ExternalIP -and $Resource.ExternalIP -ne "N/A") {
                $ip = $Resource.ExternalIP
            }
            elseif ($Resource.InternalIP -and $Resource.InternalIP -ne "N/A") {
                $ip = $Resource.InternalIP
            }
        }
        "CloudSQL" {
            if ($Resource.PublicIP -and $Resource.PublicIP -ne "N/A") {
                $ip = $Resource.PublicIP
            }
            elseif ($Resource.PrivateIP -and $Resource.PrivateIP -ne "N/A") {
                $ip = $Resource.PrivateIP
            }
        }
        "ForwardingRule" {
            if ($Resource.IPAddress -and $Resource.IPAddress -ne "N/A") {
                $ip = $Resource.IPAddress
            }
        }
    }

    return $ip
}

function Get-GCPDashboard {
    <#
    .SYNOPSIS
        Builds a unified dashboard view of GCP Compute, Cloud SQL, and Forwarding Rules.
    .DESCRIPTION
        Queries the specified project for Compute Engine VMs, Cloud SQL instances,
        and forwarding rules then returns a flat collection suitable for Bootstrap
        Table display. Each row contains resource type, name, status, resolved IP,
        region, zone, machine type, network, disk count, labels, and creation time.
    .PARAMETER Project
        The GCP project ID. If omitted, uses the default gcloud project.
    .PARAMETER IncludeCloudSQL
        Include Cloud SQL instances in the results. Defaults to $true.
    .PARAMETER IncludeForwardingRules
        Include forwarding rules (load balancers) in the results. Defaults to $true.
    .EXAMPLE
        Get-GCPDashboard
 
        Returns all Compute, Cloud SQL, and forwarding rule resources in the default project.
    .EXAMPLE
        Get-GCPDashboard -Project "my-project" -IncludeCloudSQL $false
 
        Returns only Compute and forwarding rule resources.
    .EXAMPLE
        Connect-GCPAccount -KeyFilePath "C:\keys\sa.json" -Project "my-project"
        $data = Get-GCPDashboard -Project "my-project"
        Export-GCPDashboardHtml -DashboardData $data -OutputPath "C:\Reports\gcp.html"
        Start-Process "C:\Reports\gcp.html"
 
        End-to-end: authenticate with service account, gather data, export HTML, and open in browser.
    .OUTPUTS
        PSCustomObject[]
        Each object contains: ResourceType, Name, Status, IPAddress, InternalIP,
        Region, Zone, MachineType, Network, DiskCount, Labels, CreationTime.
    .NOTES
        Author : jason@wug.ninja
        Version : 1.0.0
        Date : 2025-07-15
        Requires: PowerShell 5.1+, GoogleCloud PowerShell module, gcloud CLI authenticated.
    .LINK
        https://github.com/jayyx2/WhatsUpGoldPS
    #>

    param(
        [string]$Project,
        [bool]$IncludeCloudSQL = $true,
        [bool]$IncludeForwardingRules = $true
    )

    $results = @()

    # Compute Engine VMs
    try {
        $instances = Get-GCPComputeInstances -Project $Project
        foreach ($inst in $instances) {
            $ip = Resolve-GCPResourceIP -ResourceType "ComputeEngine" -Resource $inst
            $results += [PSCustomObject]@{
                ResourceType = "Compute"
                Name         = $inst.Name
                Status       = $inst.Status
                IPAddress    = if ($ip) { $ip } else { "N/A" }
                InternalIP   = $inst.InternalIP
                Region       = $inst.Region
                Zone         = $inst.Zone
                MachineType  = $inst.MachineType
                Network      = $inst.Network
                DiskCount    = $inst.DiskCount
                Labels       = $inst.Labels
                CreationTime = $inst.CreationTime
            }
        }
    }
    catch { Write-Warning "Compute query failed: $($_.Exception.Message)" }

    # Cloud SQL
    if ($IncludeCloudSQL) {
        try {
            $dbs = Get-GCPCloudSQLInstances -Project $Project
            foreach ($db in $dbs) {
                $ip = Resolve-GCPResourceIP -ResourceType "CloudSQL" -Resource $db
                $results += [PSCustomObject]@{
                    ResourceType = "CloudSQL"
                    Name         = $db.InstanceName
                    Status       = $db.State
                    IPAddress    = if ($ip) { $ip } else { "N/A" }
                    InternalIP   = $db.PrivateIP
                    Region       = $db.Region
                    Zone         = $db.GceZone
                    MachineType  = $db.Tier
                    Network      = "N/A"
                    DiskCount    = $db.DataDiskSizeGB
                    Labels       = ""
                    CreationTime = "N/A"
                }
            }
        }
        catch { Write-Warning "Cloud SQL query failed: $($_.Exception.Message)" }
    }

    # Forwarding Rules
    if ($IncludeForwardingRules) {
        try {
            $rules = Get-GCPForwardingRules -Project $Project
            foreach ($rule in $rules) {
                $ip = Resolve-GCPResourceIP -ResourceType "ForwardingRule" -Resource $rule
                $results += [PSCustomObject]@{
                    ResourceType = "ForwardingRule"
                    Name         = $rule.Name
                    Status       = $rule.Scheme
                    IPAddress    = if ($ip) { $ip } else { "N/A" }
                    InternalIP   = "N/A"
                    Region       = $rule.Region
                    Zone         = "N/A"
                    MachineType  = "$($rule.IPProtocol) $($rule.PortRange)"
                    Network      = $rule.Network
                    DiskCount    = "N/A"
                    Labels       = ""
                    CreationTime = "N/A"
                }
            }
        }
        catch { Write-Warning "Forwarding rules query failed: $($_.Exception.Message)" }
    }

    return $results
}

function Export-GCPDashboardHtml {
    <#
    .SYNOPSIS
        Renders GCP dashboard data into a self-contained HTML file.
    .DESCRIPTION
        Takes the output of Get-GCPDashboard and generates a Bootstrap-based
        HTML report with sortable, searchable, and exportable tables. The report
        uses Bootstrap 5 and Bootstrap-Table for interactive filtering, sorting,
        column toggling, and CSV/JSON export.
    .PARAMETER DashboardData
        Array of PSCustomObject from Get-GCPDashboard containing Compute, Cloud SQL,
        and forwarding rule details.
    .PARAMETER OutputPath
        File path for the output HTML file. Parent directory must exist.
    .PARAMETER ReportTitle
        Title shown in the report header. Defaults to "GCP Dashboard".
    .PARAMETER TemplatePath
        Optional path to a custom HTML template. If omitted, uses the
        GCP-Dashboard-Template.html in the same directory as this script.
    .EXAMPLE
        $data = Get-GCPDashboard -Project "my-project"
        Export-GCPDashboardHtml -DashboardData $data -OutputPath "C:\Reports\gcp.html"
 
        Exports the dashboard data to an HTML file using the default template.
    .EXAMPLE
        Export-GCPDashboardHtml -DashboardData $data -OutputPath "$env:TEMP\gcp.html" -ReportTitle "Prod GCP"
 
        Exports with a custom report title.
    .EXAMPLE
        Connect-GCPAccount -KeyFilePath "C:\keys\sa.json" -Project "my-project"
        $data = Get-GCPDashboard -Project "my-project"
        Export-GCPDashboardHtml -DashboardData $data -OutputPath "C:\Reports\gcp.html"
        Start-Process "C:\Reports\gcp.html"
 
        Full pipeline: authenticate, gather, export, and open the report in a browser.
    .OUTPUTS
        System.Void
        Writes an HTML file to the path specified by OutputPath.
    .NOTES
        Author : jason@wug.ninja
        Version : 1.0.0
        Date : 2025-07-15
        Requires: PowerShell 5.1+, GCP-Dashboard-Template.html in the script directory.
    .LINK
        https://github.com/jayyx2/WhatsUpGoldPS
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$DashboardData,
        [Parameter(Mandatory)][string]$OutputPath,
        [string]$ReportTitle = "GCP Dashboard",
        [string]$TemplatePath
    )

    if (-not $TemplatePath) {
        $TemplatePath = Join-Path $PSScriptRoot "GCP-Dashboard-Template.html"
    }

    if (-not (Test-Path $TemplatePath)) {
        throw "HTML template not found at $TemplatePath"
    }

    $firstObj = $DashboardData | Select-Object -First 1
    $columns = @()
    foreach ($prop in $firstObj.PSObject.Properties) {
        $col = @{
            field      = $prop.Name
            title      = ($prop.Name -creplace '([A-Z])', ' $1').Trim()
            sortable   = $true
            searchable = $true
        }
        if ($prop.Name -eq 'Status') {
            $col.formatter = 'formatStatus'
        }
        $columns += $col
    }

    $columnsJson = $columns | ConvertTo-Json -Depth 5 -Compress
    $dataJson    = $DashboardData | ConvertTo-Json -Depth 5 -Compress

    $tableConfig = @"
        columns: $columnsJson,
        data: $dataJson
"@


    $html = Get-Content -Path $TemplatePath -Raw
    $html = $html -replace 'replaceThisHere', $tableConfig
    $html = $html -replace 'ReplaceYourReportNameHere', $ReportTitle
    $html = $html -replace 'ReplaceUpdateTimeHere', (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")

    Set-Content -Path $OutputPath -Value $html -Encoding UTF8
    Write-Verbose "GCP Dashboard HTML written to $OutputPath"
}

# SIG # Begin signature block
# MIIVlwYJKoZIhvcNAQcCoIIViDCCFYQCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCGPwWLGfheLIFs
# X7gjLMwyDcTiJgO+vBD/1g6W29GVuaCCEdMwggVvMIIEV6ADAgECAhBI/JO0YFWU
# jTanyYqJ1pQWMA0GCSqGSIb3DQEBDAUAMHsxCzAJBgNVBAYTAkdCMRswGQYDVQQI
# DBJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAOBgNVBAcMB1NhbGZvcmQxGjAYBgNVBAoM
# EUNvbW9kbyBDQSBMaW1pdGVkMSEwHwYDVQQDDBhBQUEgQ2VydGlmaWNhdGUgU2Vy
# dmljZXMwHhcNMjEwNTI1MDAwMDAwWhcNMjgxMjMxMjM1OTU5WjBWMQswCQYDVQQG
# EwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMS0wKwYDVQQDEyRTZWN0aWdv
# IFB1YmxpYyBDb2RlIFNpZ25pbmcgUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEBAQUA
# A4ICDwAwggIKAoICAQCN55QSIgQkdC7/FiMCkoq2rjaFrEfUI5ErPtx94jGgUW+s
# hJHjUoq14pbe0IdjJImK/+8Skzt9u7aKvb0Ffyeba2XTpQxpsbxJOZrxbW6q5KCD
# J9qaDStQ6Utbs7hkNqR+Sj2pcaths3OzPAsM79szV+W+NDfjlxtd/R8SPYIDdub7
# P2bSlDFp+m2zNKzBenjcklDyZMeqLQSrw2rq4C+np9xu1+j/2iGrQL+57g2extme
# me/G3h+pDHazJyCh1rr9gOcB0u/rgimVcI3/uxXP/tEPNqIuTzKQdEZrRzUTdwUz
# T2MuuC3hv2WnBGsY2HH6zAjybYmZELGt2z4s5KoYsMYHAXVn3m3pY2MeNn9pib6q
# RT5uWl+PoVvLnTCGMOgDs0DGDQ84zWeoU4j6uDBl+m/H5x2xg3RpPqzEaDux5mcz
# mrYI4IAFSEDu9oJkRqj1c7AGlfJsZZ+/VVscnFcax3hGfHCqlBuCF6yH6bbJDoEc
# QNYWFyn8XJwYK+pF9e+91WdPKF4F7pBMeufG9ND8+s0+MkYTIDaKBOq3qgdGnA2T
# OglmmVhcKaO5DKYwODzQRjY1fJy67sPV+Qp2+n4FG0DKkjXp1XrRtX8ArqmQqsV/
# AZwQsRb8zG4Y3G9i/qZQp7h7uJ0VP/4gDHXIIloTlRmQAOka1cKG8eOO7F/05QID
# AQABo4IBEjCCAQ4wHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD
# VR0OBBYEFDLrkpr/NZZILyhAQnAgNpFcF4XmMA4GA1UdDwEB/wQEAwIBhjAPBgNV
# HRMBAf8EBTADAQH/MBMGA1UdJQQMMAoGCCsGAQUFBwMDMBsGA1UdIAQUMBIwBgYE
# VR0gADAIBgZngQwBBAEwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21v
# ZG9jYS5jb20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEE
# KDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZI
# hvcNAQEMBQADggEBABK/oe+LdJqYRLhpRrWrJAoMpIpnuDqBv0WKfVIHqI0fTiGF
# OaNrXi0ghr8QuK55O1PNtPvYRL4G2VxjZ9RAFodEhnIq1jIV9RKDwvnhXRFAZ/ZC
# J3LFI+ICOBpMIOLbAffNRk8monxmwFE2tokCVMf8WPtsAO7+mKYulaEMUykfb9gZ
# pk+e96wJ6l2CxouvgKe9gUhShDHaMuwV5KZMPWw5c9QLhTkg4IUaaOGnSDip0TYl
# d8GNGRbFiExmfS9jzpjoad+sPKhdnckcW67Y8y90z7h+9teDnRGWYpquRRPaf9xH
# +9/DUp/mBlXpnYzyOmJRvOwkDynUWICE5EV7WtgwggYaMIIEAqADAgECAhBiHW0M
# UgGeO5B5FSCJIRwKMA0GCSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYTAkdCMRgwFgYD
# VQQKEw9TZWN0aWdvIExpbWl0ZWQxLTArBgNVBAMTJFNlY3RpZ28gUHVibGljIENv
# ZGUgU2lnbmluZyBSb290IFI0NjAeFw0yMTAzMjIwMDAwMDBaFw0zNjAzMjEyMzU5
# NTlaMFQxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzAp
# BgNVBAMTIlNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwggGiMA0G
# CSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCbK51T+jU/jmAGQ2rAz/V/9shTUxjI
# ztNsfvxYB5UXeWUzCxEeAEZGbEN4QMgCsJLZUKhWThj/yPqy0iSZhXkZ6Pg2A2NV
# DgFigOMYzB2OKhdqfWGVoYW3haT29PSTahYkwmMv0b/83nbeECbiMXhSOtbam+/3
# 6F09fy1tsB8je/RV0mIk8XL/tfCK6cPuYHE215wzrK0h1SWHTxPbPuYkRdkP05Zw
# mRmTnAO5/arnY83jeNzhP06ShdnRqtZlV59+8yv+KIhE5ILMqgOZYAENHNX9SJDm
# +qxp4VqpB3MV/h53yl41aHU5pledi9lCBbH9JeIkNFICiVHNkRmq4TpxtwfvjsUe
# dyz8rNyfQJy/aOs5b4s+ac7IH60B+Ja7TVM+EKv1WuTGwcLmoU3FpOFMbmPj8pz4
# 4MPZ1f9+YEQIQty/NQd/2yGgW+ufflcZ/ZE9o1M7a5Jnqf2i2/uMSWymR8r2oQBM
# dlyh2n5HirY4jKnFH/9gRvd+QOfdRrJZb1sCAwEAAaOCAWQwggFgMB8GA1UdIwQY
# MBaAFDLrkpr/NZZILyhAQnAgNpFcF4XmMB0GA1UdDgQWBBQPKssghyi47G9IritU
# pimqF6TNDDAOBgNVHQ8BAf8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADATBgNV
# HSUEDDAKBggrBgEFBQcDAzAbBgNVHSAEFDASMAYGBFUdIAAwCAYGZ4EMAQQBMEsG
# A1UdHwREMEIwQKA+oDyGOmh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1
# YmxpY0NvZGVTaWduaW5nUm9vdFI0Ni5jcmwwewYIKwYBBQUHAQEEbzBtMEYGCCsG
# AQUFBzAChjpodHRwOi8vY3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNDb2Rl
# U2lnbmluZ1Jvb3RSNDYucDdjMCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5zZWN0
# aWdvLmNvbTANBgkqhkiG9w0BAQwFAAOCAgEABv+C4XdjNm57oRUgmxP/BP6YdURh
# w1aVcdGRP4Wh60BAscjW4HL9hcpkOTz5jUug2oeunbYAowbFC2AKK+cMcXIBD0Zd
# OaWTsyNyBBsMLHqafvIhrCymlaS98+QpoBCyKppP0OcxYEdU0hpsaqBBIZOtBajj
# cw5+w/KeFvPYfLF/ldYpmlG+vd0xqlqd099iChnyIMvY5HexjO2AmtsbpVn0OhNc
# WbWDRF/3sBp6fWXhz7DcML4iTAWS+MVXeNLj1lJziVKEoroGs9Mlizg0bUMbOalO
# hOfCipnx8CaLZeVme5yELg09Jlo8BMe80jO37PU8ejfkP9/uPak7VLwELKxAMcJs
# zkyeiaerlphwoKx1uHRzNyE6bxuSKcutisqmKL5OTunAvtONEoteSiabkPVSZ2z7
# 6mKnzAfZxCl/3dq3dUNw4rg3sTCggkHSRqTqlLMS7gjrhTqBmzu1L90Y1KWN/Y5J
# KdGvspbOrTfOXyXvmPL6E52z1NZJ6ctuMFBQZH3pwWvqURR8AgQdULUvrxjUYbHH
# j95Ejza63zdrEcxWLDX6xWls/GDnVNueKjWUH3fTv1Y8Wdho698YADR7TNx8X8z2
# Bev6SivBBOHY+uqiirZtg0y9ShQoPzmCcn63Syatatvx157YK9hlcPmVoa1oDE5/
# L9Uo2bC5a4CH2RwwggY+MIIEpqADAgECAhAHnODk0RR/hc05c892LTfrMA0GCSqG
# SIb3DQEBDAUAMFQxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0
# ZWQxKzApBgNVBAMTIlNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYw
# HhcNMjYwMjA5MDAwMDAwWhcNMjkwNDIxMjM1OTU5WjBVMQswCQYDVQQGEwJVUzEU
# MBIGA1UECAwLQ29ubmVjdGljdXQxFzAVBgNVBAoMDkphc29uIEFsYmVyaW5vMRcw
# FQYDVQQDDA5KYXNvbiBBbGJlcmlubzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC
# AgoCggIBAPN6aN4B1yYWkI5b5TBj3I0VV/peETrHb6EY4BHGxt8Ap+eT+WpEpJyE
# tRYPxEmNJL3A38Bkg7mwzPE3/1NK570ZBCuBjSAn4mSDIgIuXZnvyBO9W1OQs5d6
# 7MlJLUAEufl18tOr3ST1DeO9gSjQSAE5Nql0QDxPnm93OZBon+Fz3CmE+z3MwAe2
# h4KdtRAnCqwM+/V7iBdbw+JOxolpx+7RVjGyProTENIG3pe/hKvPb501lf8uBAAD
# LdjZr5ip8vIWbf857Yw1Bu10nVI7HW3eE8Cl5//d1ribHlzTzQLfttW+k+DaFsKZ
# BBL56l4YAlIVRsrOiE1kdHYYx6IGrEA809R7+TZA9DzGqyFiv9qmJAbL4fDwetDe
# yIq+Oztz1LvEdy8Rcd0JBY+J4S0eDEFIA3X0N8VcLeAwabKb9AjulKXwUeqCJLvN
# 79CJ90UTZb2+I+tamj0dn+IKMEsJ4v4Ggx72sxFr9+6XziodtTg5Luf2xd6+Phha
# mOxF2px9LObhBLLEMyRsCHZIzVZOFKu9BpHQH7ufGB+Sa80Tli0/6LEyn9+bMYWi
# 2ttn6lLOPThXMiQaooRUq6q2u3+F4SaPlxVFLI7OJVMhar6nW6joBvELTJPmANSM
# jDSRFDfHRCdGbZsL/keELJNy+jZctF6VvxQEjFM8/bazu6qYhrA7AgMBAAGjggGJ
# MIIBhTAfBgNVHSMEGDAWgBQPKssghyi47G9IritUpimqF6TNDDAdBgNVHQ4EFgQU
# 6YF0o0D5AVhKHbVocr8GaSIBibAwDgYDVR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQC
# MAAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwSgYDVR0gBEMwQTA1BgwrBgEEAbIxAQIB
# AwIwJTAjBggrBgEFBQcCARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EM
# AQQBMEkGA1UdHwRCMEAwPqA8oDqGOGh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2Vj
# dGlnb1B1YmxpY0NvZGVTaWduaW5nQ0FSMzYuY3JsMHkGCCsGAQUFBwEBBG0wazBE
# BggrBgEFBQcwAoY4aHR0cDovL2NydC5zZWN0aWdvLmNvbS9TZWN0aWdvUHVibGlj
# Q29kZVNpZ25pbmdDQVIzNi5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNl
# Y3RpZ28uY29tMA0GCSqGSIb3DQEBDAUAA4IBgQAEIsm4xnOd/tZMVrKwi3doAXvC
# wOA/RYQnFJD7R/bSQRu3wXEK4o9SIefye18B/q4fhBkhNAJuEvTQAGfqbbpxow03
# J5PrDTp1WPCWbXKX8Oz9vGWJFyJxRGftkdzZ57JE00synEMS8XCwLO9P32MyR9Z9
# URrpiLPJ9rQjfHMb1BUdvaNayomm7aWLAnD+X7jm6o8sNT5An1cwEAob7obWDM6s
# X93wphwJNBJAstH9Ozs6LwISOX6sKS7CKm9N3Kp8hOUue0ZHAtZdFl6o5u12wy+z
# zieGEI50fKnN77FfNKFOWKlS6OJwlArcbFegB5K89LcE5iNSmaM3VMB2ADV1FEcj
# GSHw4lTg1Wx+WMAMdl/7nbvfFxJ9uu5tNiT54B0s+lZO/HztwXYQUczdsFon3pjs
# Nrsk9ZlalBi5SHkIu+F6g7tWiEv3rtVApmJRnLkUr2Xq2a4nbslUCt4jKs5UX4V1
# nSX8OM++AXoyVGO+iTj7z+pl6XE9Gw/Td6WKKKsxggMaMIIDFgIBATBoMFQxCzAJ
# BgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNl
# Y3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYCEAec4OTRFH+FzTlzz3Yt
# N+swDQYJYIZIAWUDBAIBBQCggYQwGAYKKwYBBAGCNwIBDDEKMAigAoAAoQKAADAZ
# BgkqhkiG9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgorBgEEAYI3AgELMQ4wDAYKKwYB
# BAGCNwIBFTAvBgkqhkiG9w0BCQQxIgQgpv1pKoADVT0augqZJdP5sYJHpTPX99r2
# PoblzO0gE28wDQYJKoZIhvcNAQEBBQAEggIAKymshn3NB8fQ7VebCpHtSOLlZWW3
# 9PxgTchclYQlkLbcANN3syP+TcPNEhvRmbpjeMuxOLACoXoZZvGdKwsKiHoYcQVe
# tgQ/qxSPmpZ3Bz3EE0ZgLOwIc/nugfO2qvKEsBVwUPjAxH6FkK6uOaYXeC3ySmsR
# R2zxfwsAeAwO0DC3jvTLGbjbtaec2ycNDYHleoHJRzzmhoY6ve2VNIrNFlXJmB5E
# zw2Bd4iYz4JRgquCbozlO/twtMh9GTVJRzEBqOGeu/ATcg/2rDgUHfXb1+qi7hDR
# BdFmdyyYJhzk2KN/gmMxYbI2jXogcuUSVsxRVDAoheGVxGEeO20v+0Vx2VgCUEHK
# /5fP3us8n9RAJchOdQaqJ/vOPhcsX8+iTx4INg8QLXKbe74P+nIke/xCe8hBSeXA
# Vx/KwcpOHlgB+WuEGjSPeCj85vmsXyEZms+Tyvjb0YuuEdMo/0Cl48XhQK+xZTdC
# u0ggTztZNWuZ/ao9jdpLu/1sj4/HiMnsLW8+oyPHj3ywE/yBiAisStAx3CPJTAq1
# 2abR8f0v1oS/QWA2H/ECU6opAznezwxNMSf5Vv7RAmByw6Dq94WQSYzw2ESRrf9N
# vG7/ySRb40tPBU4itHXWFBmw/Hx7/I4e3r6rExnzFmEwepdfJ2y77w+anUsNjYTr
# ZK9ixp0KgjRGxGA=
# SIG # End signature block