Private/ConvertTo-AACResourceHealth.ps1

function ConvertTo-AACResourceHealth {
    <#
    .SYNOPSIS
        Builds Get-AACResourceHealth's model from the Resource Graph rows of
        Get-AACResourceHealthQuery (and, with -History, each resource's
        Resource Health history): every resource's availability, why and
        since when, its latest health event and the Service Health events
        that hit it - and the Service Health events themselves.
    .DESCRIPTION
        No Azure calls. -Read is Invoke-AACGraphBatch's result; -History maps
        a resource ID (lower case) to the availabilityStatuses Resource
        Health returned for it (newest first), from which its availability
        over the last -Days and its state changes are worked out. Rows of
        subscriptions not in -SubscriptionName are left out.
 
        Resources (AAC.ResourceHealth): Unavailable, then Degraded, Unknown
        and Available, the longest-down first. Service Health events
        (AAC.ServiceHealthEvent): active first, then outages, maintenance,
        advisories - with the regions and services they name and the
        resources in scope they hit.
 
        Returns @{ Resources; Events; Notices; Stats }.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Read,

        [hashtable] $SubscriptionName = @{},

        [hashtable] $History = @{},

        [ValidateRange(1, 90)]
        [int] $Days = 7,

        [datetime] $Now = [datetime]::UtcNow
    )

    $get = ${function:Get-AACMember}
    $text = { param($Object, [string] $Key) $v = & $get $Object $Key; if ($null -eq $v) { '' } else { [string]$v } }
    $rowsOf = { param([string] $Name) @(if ($Read.Rows -and $Read.Rows.Contains($Name)) { $Read.Rows[$Name] | Where-Object { $null -ne $_ } }) }
    $subscriptionOf = { param([string] $Id) if ($Id -match '^/subscriptions/([^/]+)') { $Matches[1].ToLowerInvariant() } else { '' } }
    $label = { param([string] $Id) $s = ([string]$Id).ToLowerInvariant(); if ($SubscriptionName.Contains($s)) { $SubscriptionName[$s] } else { $Id } }
    $date = {
        param([string] $Value)
        $d = [datetime]::MinValue
        if ($Value -and [datetime]::TryParse($Value, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$d)) { $d } else { $null }
    }
    $plain = {
        param([string] $Html)
        if (-not $Html) { return '' }
        $t = $Html -replace '(?i)<br\s*/?>|</p>|</li>', "`n" -replace '(?i)<li[^>]*>', '- ' -replace '<[^>]+>', ''
        ([System.Net.WebUtility]::HtmlDecode($t).Replace([char]0x00A0, ' ') -replace '[ \t]+\n', "`n" -replace '\n{3,}', "`n`n").Trim()
    }
    $parse = {
        param([string] $Id)
        $r = @{ Name = ''; Type = ''; Group = '' }
        if (-not $Id) { return $r }
        $parts = $Id.Trim('/').Split('/')
        $lower = @($parts | ForEach-Object { $_.ToLowerInvariant() })
        $g = [array]::IndexOf($lower, 'resourcegroups')
        if ($g -ge 0 -and $g + 1 -lt $parts.Count) { $r.Group = $parts[$g + 1] }
        $p = [array]::LastIndexOf($lower, 'providers')
        if ($p -ge 0 -and $p + 2 -lt $parts.Count) { $r.Type = ($lower[$p + 1] + '/' + (@(for ($j = $p + 2; $j -lt $parts.Count; $j += 2) { $lower[$j] }) -join '/')) }
        $r.Name = $parts[-1]
        $r
    }
    $notices = [System.Collections.Generic.List[string]]::new()
    foreach ($key in @($Read.Errors.Keys | Sort-Object)) { $notices.Add("The $(@{ statuses = 'Resource Health statuses'; events = 'resource health events'; service = 'Service Health events'; impacted = 'resources impacted by Service Health events' }[$key]) couldn't be read: $($Read.Errors[$key] -replace '\s+', ' ')") }

    # --- Service Health events, and what they hit -----------------------------------------------------------------
    $hits = @{}       # resource ID -> tracking IDs
    $hitCount = @{}   # tracking ID -> resources in scope
    foreach ($row in (& $rowsOf 'impacted')) {
        $id = & $text $row 'resourceId'; $tracking = & $text $row 'trackingId'
        if (-not $id -or -not $tracking -or -not $SubscriptionName.Contains((& $subscriptionOf $id))) { continue }
        if (-not $hits.Contains($id)) { $hits[$id] = [System.Collections.Generic.List[string]]::new() }
        if (-not $hits[$id].Contains($tracking)) { $hits[$id].Add($tracking); $hitCount[$tracking] = 1 + [int]$hitCount[$tracking] }
    }
    $typeOrder = @{ ServiceIssue = 0; PlannedMaintenance = 1; SecurityAdvisory = 2; HealthAdvisory = 3 }
    $typeName = @{ ServiceIssue = 'Service issue'; PlannedMaintenance = 'Planned maintenance'; HealthAdvisory = 'Health advisory'; SecurityAdvisory = 'Security advisory'; Billing = 'Billing' }
    $events = @(foreach ($group in @(& $rowsOf 'service' | Where-Object { $SubscriptionName.Contains((& $text $_ 'subscriptionId').ToLowerInvariant()) } | Group-Object { & $text $_ 'trackingId' })) {
            $row = $group.Group[0]
            $kind = & $text $row 'eventType'
            $impact = @(@(& $get $row 'impact') | Where-Object { $_ -is [System.Collections.IDictionary] })
            [pscustomobject][ordered]@{
                PSTypeName        = 'AAC.ServiceHealthEvent'
                TrackingId        = $group.Name
                EventType         = $(if ($typeName.Contains($kind)) { $typeName[$kind] } else { $kind })
                Status            = & $text $row 'status'
                Level             = & $text $row 'level'
                Title             = & $text $row 'heading'
                Services          = (@($impact | ForEach-Object { & $text $_ 'ImpactedService' } | Where-Object { $_ } | Select-Object -Unique) -join ', ')
                Regions           = (@($impact | ForEach-Object { @(& $get $_ 'ImpactedRegions') } | ForEach-Object { & $text $_ 'ImpactedRegion' } | Where-Object { $_ } | Select-Object -Unique) -join ', ')
                Subscriptions     = (@($group.Group | ForEach-Object { & $label (& $text $_ 'subscriptionId') } | Select-Object -Unique) -join ', ')
                ImpactedResources = [int]$hitCount[$group.Name]
                Started           = & $date (& $text $row 'started')
                Mitigated         = & $date (& $text $row 'mitigated')
                LastUpdate        = & $date (& $text $row 'updated')
                Summary           = & $plain (& $text $row 'summary')
                Link              = "https://app.azure.com/h/$($group.Name)"
                Order             = $(if ($typeOrder.Contains($kind)) { $typeOrder[$kind] } else { 9 })
            }
        })
    $events = @($events | Sort-Object -Property @{ Expression = { if ($_.Status -eq 'Active') { 0 } else { 1 } } }, Order, @{ Expression = 'LastUpdate'; Descending = $true } | Select-Object -Property * -ExcludeProperty Order)
    foreach ($e in $events) { $e.PSObject.TypeNames.Insert(0, 'AAC.ServiceHealthEvent') }

    # --- Latest health event per resource -------------------------------------------------------------------------
    $latest = @{}
    foreach ($row in (& $rowsOf 'events')) {
        $when = & $date (& $text $row 'occurred')
        if (-not $when -or $when -lt $Now.AddDays(-$Days)) { continue }
        $latest[(& $text $row 'resourceId')] = @{ When = $when; Name = (& $text $row 'annotation'); Summary = (& $plain (& $text $row 'summary')); Context = (& $text $row 'context') }
    }

    # --- Availability over the window, from the history -----------------------------------------------------------
    $windowStart = $Now.AddDays(-$Days)
    $availabilityOf = {
        param([object[]] $Statuses)
        $points = @($Statuses | ForEach-Object { @{ When = (& $date ([string](@((& $text $_ 'properties.occuredTime'), (& $text $_ 'properties.occurredTime')) | Where-Object { $_ } | Select-Object -First 1))); State = (& $text $_ 'properties.availabilityState') } } | Where-Object { $_.When } | Sort-Object { $_.When })
        if (-not $points.Count) { return $null }
        $up = 0.0; $known = 0.0; $changes = 0
        for ($i = 0; $i -lt $points.Count; $i++) {
            $from = if ($points[$i].When -lt $windowStart) { $windowStart } else { $points[$i].When }
            $to = if ($i + 1 -lt $points.Count) { $points[$i + 1].When } else { $Now }
            if ($to -le $windowStart) { continue }
            if ($points[$i].When -ge $windowStart -and $i -gt 0 -and $points[$i].State -ne $points[$i - 1].State) { $changes++ }
            $span = ($to - $from).TotalSeconds
            if ($points[$i].State -eq 'Unknown') { continue }
            $known += $span
            if ($points[$i].State -eq 'Available') { $up += $span }
        }
        @{ Percent = $(if ($known -gt 0) { [Math]::Round(100 * $up / $known, 2) } else { $null }); Changes = $changes }
    }

    # --- Resources ---------------------------------------------------------------------------------------------------
    $stateOrder = @{ Unavailable = 0; Degraded = 1; Unknown = 2; Available = 3 }
    $resources = @(foreach ($row in (& $rowsOf 'statuses')) {
            $id = & $text $row 'resourceId'
            $subscription = & $subscriptionOf $id
            if (-not $subscription) { $subscription = (& $text $row 'subscriptionId').ToLowerInvariant() }
            if (-not $SubscriptionName.Contains($subscription)) { continue }
            $where = & $parse $id
            $state = & $text $row 'state'
            $since = & $date (& $text $row 'since')
            $annotation = if ($latest.Contains($id)) { $latest[$id] } else { $null }
            $measured = if ($History.Contains($id)) { & $availabilityOf @($History[$id]) } else { $null }
            [pscustomobject][ordered]@{
                PSTypeName       = 'AAC.ResourceHealth'
                State            = $(if ($state) { $state } else { 'Unknown' })
                Resource         = $where.Name
                ResourceType     = $where.Type
                ResourceGroup    = $where.Group
                Subscription     = & $label $subscription
                Reason           = & $text $row 'reason'
                Title            = & $text $row 'heading'
                Summary          = & $plain (& $text $row 'summary')
                Since            = $since
                Hours            = $(if ($since -and $state -ne 'Available') { [Math]::Round(($Now - $since).TotalHours, 1) } else { $null })
                Chronicity       = & $text $row 'chronicity'
                ResolutionEta    = & $date (& $text $row 'eta')
                LastEvent        = $(if ($annotation) { $annotation.Name } else { '' })
                LastEventTime    = $(if ($annotation) { $annotation.When } else { $null })
                LastEventSummary = $(if ($annotation) { $annotation.Summary } else { '' })
                LastEventContext = $(if ($annotation) { $annotation.Context } else { '' })
                ServiceIssues    = $(if ($hits.Contains($id)) { $hits[$id] -join ', ' } else { '' })
                Availability     = $(if ($measured) { $measured.Percent } else { $null })
                StateChanges     = $(if ($measured) { $measured.Changes } else { $null })
                Location         = & $text $row 'location'
                SubscriptionId   = $subscription
                ResourceId       = $id
            }
        })
    $resources = @($resources | Sort-Object -Property @{ Expression = { if ($stateOrder.Contains($_.State)) { $stateOrder[$_.State] } else { 2 } } }, @{ Expression = { if ($null -ne $_.Hours) { $_.Hours } else { 0 } }; Descending = $true }, Resource)

    $activeIssues = @($events | Where-Object { $_.Status -eq 'Active' -and $_.EventType -eq 'Service issue' })
    @{
        Resources = $resources
        Events    = $events
        Notices   = @($notices)
        Stats     = @{
            Resources    = $resources.Count
            Unavailable  = @($resources | Where-Object State -EQ 'Unavailable').Count
            Degraded     = @($resources | Where-Object State -EQ 'Degraded').Count
            Unknown      = @($resources | Where-Object State -EQ 'Unknown').Count
            Available    = @($resources | Where-Object State -EQ 'Available').Count
            ActiveIssues = $activeIssues.Count
            Maintenance  = @($events | Where-Object { $_.Status -eq 'Active' -and $_.EventType -eq 'Planned maintenance' }).Count
            Events       = $events.Count
            Recent       = @($resources | Where-Object LastEvent).Count
        }
    }
}