Public/Invoke-AACHealthCheck.ps1

function Invoke-AACHealthCheck {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Checks that your applications and their Azure platform are healthy
        right now - endpoints answering (synthetic requests, latency, TLS
        certificates), databases reachable, queues flowing, Resource Health,
        Service Health and fired alerts - by tier, with an SLA report.
    .DESCRIPTION
        Endpoints - synthetic transactions, -Count rounds -IntervalSeconds
        apart, all at once:
          -Uri URLs; up when they answer 200-399
          -Endpoint @{ Name; Uri; Tier; ExpectedStatus; Contains; Method;
                        TimeoutSeconds } - Contains is text the response must
                        have, for an end-to-end check (a /health page that
                        reports its dependencies)
          -Discover the public endpoints of the App Service and Function
                        apps, Container Apps, Static Web Apps, Front Door
                        endpoints and API Management gateways (its status
                        endpoint) in scope; up unless the server errs (5xx)
        Each: availability, 50th and 95th percentile latency, and its TLS
        certificate's expiry. The requests carry no Azure credential.
        -TcpEndpoint 'host:port', and with -IncludeDatabase the SQL,
        PostgreSQL, MySQL and Redis servers in scope, are connected to (TCP)
        from where the command runs.
 
        The platform (unless -SkipAzure): Service Bus queues (backlog and
        dead letters), Resource Health (unavailable and degraded resources),
        active Service Health events and alerts fired in the last 24 hours.
 
        Each check is Success, Warning or Failed, with what was found and
        what to do; the SLA table compares each endpoint's availability with
        -SlaTarget. -Watch repeats the whole check until Ctrl+C, redrawing
        it each time - a live board to keep open during an incident.
 
        Read-only: GET requests and TCP connections only. -SkipAzure needs
        no sign-in at all.
    .PARAMETER Uri
        URLs to check.
    .PARAMETER Endpoint
        Endpoints to check, with what to expect (see the description).
    .PARAMETER TcpEndpoint
        host:port pairs to connect to.
    .PARAMETER Discover
        Also check the public endpoints of the web apps, container apps,
        static web apps, Front Door and API Management in scope.
    .PARAMETER IncludeDatabase
        Also connect to the SQL, PostgreSQL, MySQL and Redis servers in scope.
    .PARAMETER SkipAzure
        Check only the endpoints given - no Azure reads, no sign-in.
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups.
    .PARAMETER ResourceGroupName
        Only these resource groups (discovery, queues and Resource Health).
    .PARAMETER Count
        How many rounds of requests (1 to 100; 3 by default).
    .PARAMETER IntervalSeconds
        Seconds between rounds (0 to 3600; 5 by default).
    .PARAMETER TimeoutSeconds
        How long to wait for each request (1 to 120; 10 by default).
    .PARAMETER SlaTarget
        The availability target, in percent (99.9 by default).
    .PARAMETER LatencyThresholdMs
        Slower 95th percentile latency is a warning (2000 ms by default).
    .PARAMETER CertificateDays
        A certificate expiring sooner is a warning (30 days by default).
    .PARAMETER QueueBacklog
        More messages waiting is a warning (1000 by default).
    .PARAMETER MaxEndpoints
        At most this many discovered endpoints are checked (100 by default).
    .PARAMETER Watch
        Repeat the check until Ctrl+C, redrawing the view each time.
    .PARAMETER CsvPath
        Write the checks to this CSV file.
    .PARAMETER HtmlPath
        Write an interactive HTML report, with the SLA table.
    .PARAMETER PdfPath
        Write a PDF report.
    .PARAMETER Title
        The reports' title.
    .PARAMETER PassThru
        Show the view and also return the checks.
    .PARAMETER NoDisplay
        Return the checks without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Invoke-AACHealthCheck -Discover -ResourceGroupName 'rg-app-prod'
        The production app's endpoints, queues and platform health.
    .EXAMPLE
        Invoke-AACHealthCheck -SkipAzure -Endpoint @{ Name = 'Checkout'; Uri = 'https://shop.contoso.com/health'; Tier = 'Critical'; Contains = '"status":"Healthy"' } -Count 10 -IntervalSeconds 30 -HtmlPath .\out\Sla.html
        Ten checks of the checkout's health page, five minutes apart, as an SLA report.
    .EXAMPLE
        Invoke-AACHealthCheck -Discover -Watch -IntervalSeconds 60
        A live board, refreshed every minute, until Ctrl+C.
    .OUTPUTS
        AAC.HealthCheck
    #>

    [CmdletBinding()]
    [OutputType('AAC.HealthCheck')]
    param(
        [string[]] $Uri,

        [hashtable[]] $Endpoint,

        [ValidatePattern('^[^:\s]+:\d{1,5}$')]
        [string[]] $TcpEndpoint,

        [switch] $Discover,

        [switch] $IncludeDatabase,

        [switch] $SkipAzure,

        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [string[]] $ManagementGroupId,

        [string[]] $ResourceGroupName,

        [ValidateRange(1, 100)]
        [int] $Count = 3,

        [ValidateRange(0, 3600)]
        [int] $IntervalSeconds = 5,

        [ValidateRange(1, 120)]
        [int] $TimeoutSeconds = 10,

        [ValidateRange(0, 100)]
        [double] $SlaTarget = 99.9,

        [ValidateRange(1, 600000)]
        [int] $LatencyThresholdMs = 2000,

        [ValidateRange(1, 365)]
        [int] $CertificateDays = 30,

        [ValidateRange(1, [long]::MaxValue)]
        [long] $QueueBacklog = 1000,

        [ValidateRange(1, 1000)]
        [int] $MaxEndpoints = 100,

        [switch] $Watch,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Health check',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) }

    $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
    $interactive = -not $NoDisplay -and -not $pipedOnward
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    if ($SkipAzure -and ($Discover -or $IncludeDatabase)) { throw '-Discover and -IncludeDatabase read Azure: leave out -SkipAzure.' }
    if ($SkipAzure -and -not ($Uri -or $Endpoint -or $TcpEndpoint)) { throw 'Nothing to check: give -Uri, -Endpoint or -TcpEndpoint with -SkipAzure.' }

    # The endpoints given.
    $given = [System.Collections.Generic.List[object]]::new()
    $n = 0
    foreach ($u in @($Uri | Where-Object { $_ })) { $n++; $given.Add(@{ Key = "uri$n"; Name = ([uri]$u).Host + ([uri]$u).AbsolutePath.TrimEnd('/'); Kind = 'Http'; Uri = $u; Tier = 'Default'; TimeoutSeconds = $TimeoutSeconds }) }
    foreach ($e in @($Endpoint | Where-Object { $_ })) {
        if (-not $e.Contains('Uri')) { throw "An -Endpoint has no Uri: @{ $(@($e.Keys) -join '; ') }." }
        $n++
        $spec = @{ Key = "endpoint$n"; Name = $(if ($e.Contains('Name')) { [string]$e.Name } else { ([uri]$e.Uri).Host }); Kind = 'Http'; Uri = [string]$e.Uri; Tier = $(if ($e.Contains('Tier')) { [string]$e.Tier } else { 'Default' }); TimeoutSeconds = $(if ($e.Contains('TimeoutSeconds')) { [int]$e.TimeoutSeconds } else { $TimeoutSeconds }) }
        foreach ($k in 'ExpectedStatus', 'Contains', 'Method') { if ($e.Contains($k)) { $spec[$k] = $e[$k] } }
        $given.Add($spec)
    }
    foreach ($t in @($TcpEndpoint | Where-Object { $_ })) { $n++; $hostName, $port = $t.Split(':'); $given.Add(@{ Key = "tcp$n"; Name = $t; Kind = 'Tcp'; Host = $hostName; Port = [int]$port; Tier = 'Data'; TimeoutSeconds = $TimeoutSeconds }) }

    $request = @{
        SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); ResourceGroupName = @($ResourceGroupName | Where-Object { $_ })
        Discover = [bool]$Discover; IncludeDatabase = [bool]$IncludeDatabase; SkipAzure = [bool]$SkipAzure; Given = $given.ToArray(); Count = $Count; IntervalSeconds = $IntervalSeconds; TimeoutSeconds = $TimeoutSeconds; MaxEndpoints = $MaxEndpoints
    }
    $settings = @{ SlaTarget = $SlaTarget; LatencyThresholdMs = $LatencyThresholdMs; CertificateDays = $CertificateDays; QueueBacklog = $QueueBacklog }
    if (-not $SkipAzure) { $null = Get-AACAccessToken }

    $run = {
        if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Health check' -Color 'deepskyblue3_1' }
        Invoke-AACProgress -ScriptBlock {
            $notices = [System.Collections.Generic.List[string]]::new()
            $endpoints = [System.Collections.Generic.List[object]]::new()
            foreach ($g in $request.Given) { $endpoints.Add($g) }
            $platform = @{ Health = @(); Events = @(); Alerts = @(); Queues = @() }
            $scopeLabel = 'the endpoints given'
            if (-not $request.SkipAzure) {
                Update-AACProgress -Id 'scope' -Indeterminate -Description 'Finding the subscriptions'
                $scope = Resolve-AACScope -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId
                $scopeLabel = $scope.Label
                Update-AACProgress -Id 'scope' -Complete -Description "Scope: $($scope.Label)"
                $quote = { param([string] $Text) "'" + ($Text -replace "'", "\'") + "'" }
                $groupFilter = if ($request.ResourceGroupName.Count) { " | where resourceGroup in~ ($((@($request.ResourceGroupName | ForEach-Object { & $quote $_ })) -join ', '))" } else { '' }
                $queries = [ordered]@{
                    health   = "healthresources | where type =~ 'microsoft.resourcehealth/availabilitystatuses' | extend resourceId = tolower(tostring(properties.targetResourceId)), resourceGroup = tostring(split(tostring(properties.targetResourceId), '/')[4]) $groupFilter | project id, resourceId, state = tostring(properties.availabilityState), summary = tostring(properties.summary), since = tostring(properties.occuredTime)"
                    events   = "servicehealthresources | where type =~ 'microsoft.resourcehealth/events' | extend p = properties | where tostring(p.Status) =~ 'Active' | project id, trackingId = name, title = tostring(p.Title), eventType = tostring(p.EventType)"
                    alerts   = "alertsmanagementresources | where type =~ 'microsoft.alertsmanagement/alerts' | extend e = properties.essentials | where tostring(e.monitorCondition) =~ 'Fired' and todatetime(e.startDateTime) > ago(24h) | extend resourceGroup = tostring(e.targetResourceGroup) $groupFilter | project id, name, severity = tostring(e.severity), fired = tostring(e.startDateTime), target = tolower(tostring(e.targetResource)), description = tostring(e.description)"
                    services = "resources | where type =~ 'microsoft.servicebus/namespaces'$groupFilter | project id, name"
                }
                if ($request.Discover) {
                    $queries['web'] = "resources | where type in~ ('microsoft.web/sites', 'microsoft.web/staticsites', 'microsoft.app/containerapps', 'microsoft.cdn/profiles/afdendpoints', 'microsoft.apimanagement/service')$groupFilter | extend host = case(type =~ 'microsoft.web/sites', tostring(properties.defaultHostName), type =~ 'microsoft.web/staticsites', tostring(properties.defaultHostname), type =~ 'microsoft.app/containerapps', iff(tobool(properties.configuration.ingress.external), tostring(properties.configuration.ingress.fqdn), ''), type =~ 'microsoft.cdn/profiles/afdendpoints', tostring(properties.hostName), type =~ 'microsoft.apimanagement/service', tostring(parse_url(tostring(properties.gatewayUrl)).Host), '') | where isnotempty(host) and tostring(properties.publicNetworkAccess) !~ 'Disabled' and tostring(properties.state) !~ 'Stopped' | project id, name, type = tolower(type), host"
                }
                if ($request.IncludeDatabase) {
                    $queries['databases'] = "resources | where type in~ ('microsoft.sql/servers', 'microsoft.dbforpostgresql/flexibleservers', 'microsoft.dbformysql/flexibleservers', 'microsoft.cache/redis')$groupFilter | project id, name, type = tolower(type), host = coalesce(tostring(properties.fullyQualifiedDomainName), tostring(properties.hostName)), publicAccess = coalesce(tostring(properties.publicNetworkAccess), tostring(properties.network.publicNetworkAccess))"
                }
                Update-AACProgress -Id 'read' -Total $queries.Count -Description 'Reading Resource Health, Service Health, alerts and what to probe'
                $read = Invoke-AACGraphBatch -Query $queries -SubscriptionId $scope.GraphScope -AllowFailure @($queries.Keys) -OnProgress { param($Name, $Done, $Total) Update-AACProgress -Id 'read' -Increment 1 -Description "Read the $Name ($Done of $Total)" }
                foreach ($key in $read.Errors.Keys) { if ($read.Errors[$key]) { $notices.Add("The $key couldn't be read: $($read.Errors[$key])") } }
                $platform.Health = @($read.Rows['health'] | Where-Object { $_ })
                $platform.Events = @($read.Rows['events'] | Where-Object { $_ })
                $platform.Alerts = @($read.Rows['alerts'] | Where-Object { $_ })
                Update-AACProgress -Id 'read' -Complete -Description ('Read {0:N0} resource health status(es), {1:N0} service event(s), {2:N0} alert(s)' -f $platform.Health.Count, $platform.Events.Count, $platform.Alerts.Count)
                $tierOf = @{ 'microsoft.web/sites' = 'Web'; 'microsoft.web/staticsites' = 'Web'; 'microsoft.app/containerapps' = 'Web'; 'microsoft.cdn/profiles/afdendpoints' = 'Edge'; 'microsoft.apimanagement/service' = 'API' }
                $discovered = @(if ($request.Discover) { $read.Rows['web'] | Where-Object { $_ } | Select-Object -First $request.MaxEndpoints })
                if ($request.Discover -and @($read.Rows['web']).Count -gt $request.MaxEndpoints) { $notices.Add("Only the first $($request.MaxEndpoints) of $(@($read.Rows['web']).Count) discovered endpoints were checked (-MaxEndpoints).") }
                foreach ($w in $discovered) {
                    $path = if ([string]$w['type'] -eq 'microsoft.apimanagement/service') { '/status-0123456789abcdef' } else { '/' }
                    $endpoints.Add(@{ Key = "web|$($w['id'])"; Name = [string]$w['name']; Kind = 'Http'; Uri = "https://$($w['host'])$path"; Tier = $tierOf[[string]$w['type']]; ExpectedStatus = $(if ($path -eq '/') { 'Below500' } else { @(200) }); TimeoutSeconds = $request.TimeoutSeconds; ResourceId = [string]$w['id'] })
                }
                $ports = @{ 'microsoft.sql/servers' = 1433; 'microsoft.dbforpostgresql/flexibleservers' = 5432; 'microsoft.dbformysql/flexibleservers' = 3306; 'microsoft.cache/redis' = 6380 }
                foreach ($d in @(if ($request.IncludeDatabase) { $read.Rows['databases'] | Where-Object { $_ -and $_['host'] } })) {
                    $endpoints.Add(@{ Key = "db|$($d['id'])"; Name = [string]$d['name']; Kind = 'Tcp'; Host = [string]$d['host']; Port = $ports[[string]$d['type']]; Tier = 'Data'; TimeoutSeconds = $request.TimeoutSeconds; ResourceId = [string]$d['id']; Private = ([string]$d['publicAccess'] -eq 'Disabled') })
                }
                # Service Bus queues: their counts, from Resource Manager.
                $namespaces = @($read.Rows['services'] | Where-Object { $_ })
                if ($namespaces.Count) {
                    Update-AACProgress -Id 'queues' -Indeterminate -Description "Reading the queues of $($namespaces.Count) Service Bus namespace(s)"
                    $queueUris = @{}
                    foreach ($ns in $namespaces) { $queueUris["$($ns['id'])/queues?api-version=2021-11-01"] = [string]$ns['name'] }
                    $answers = Invoke-AACArmParallel -Uri @($queueUris.Keys)
                    $platform.Queues = @(foreach ($queueUri in $queueUris.Keys) {
                            $answer = $answers[$queueUri]
                            if (-not $answer -or $answer.Error) { $notices.Add("The queues of $($queueUris[$queueUri]) couldn't be read: $(if ($answer) { $answer.Error })"); continue }
                            foreach ($q in @($answer.Items | Where-Object { $_ })) {
                                $counts = $q['properties']['countDetails']
                                @{ Namespace = $queueUris[$queueUri]; Queue = [string]$q['name']; Status = [string]$q['properties']['status']; Active = [long]$counts['activeMessageCount']; DeadLetter = [long]$counts['deadLetterMessageCount']; ResourceId = [string]$q['id'] }
                            }
                        })
                    Update-AACProgress -Id 'queues' -Complete -Description ('Read {0:N0} queue(s)' -f $platform.Queues.Count)
                }
            }

            # --- Probe the endpoints, then their certificates --------------------------------------------------------------
            $probes = $endpoints.ToArray()
            $probeResult = @{}
            $certificates = @{}
            if ($probes.Count) {
                Update-AACProgress -Id 'probe' -Total $request.Count -Description "Checking $($probes.Count) endpoint(s), $($request.Count) round(s)"
                $probeResult = Invoke-AACEndpointProbe -Probe $probes -Round $request.Count -IntervalSeconds $request.IntervalSeconds -OnRound { param($ProbeRound, $ProbeRounds) Update-AACProgress -Id 'probe' -Increment 1 -Description "Checked $($probes.Count) endpoint(s): round $ProbeRound of $ProbeRounds" }
                Update-AACProgress -Id 'probe' -Complete -Description "Checked $($probes.Count) endpoint(s), $($request.Count) round(s)"
                $https = @($probes | Where-Object { $_.Kind -eq 'Http' -and ([string]$_.Uri) -like 'https://*' })
                if ($https.Count) {
                    Update-AACProgress -Id 'tls' -Total $https.Count -Description 'Reading the TLS certificates'
                    foreach ($p in $https) { $u = [uri]$p.Uri; $certificates[[string]$p.Key] = Get-AACTlsCertificate -HostName $u.Host -Port $u.Port -TimeoutSeconds $request.TimeoutSeconds; Update-AACProgress -Id 'tls' -Increment 1 }
                    Update-AACProgress -Id 'tls' -Complete -Description "Read $($https.Count) TLS certificate(s)"
                }
            }
            $result = ConvertTo-AACHealthCheck -Endpoint $probes -ProbeResult $probeResult -Certificate $certificates -Queue $platform.Queues -HealthStatus $platform.Health -ServiceEvent $platform.Events -Alert $platform.Alerts @settings
            @{ Result = $result; Scope = $scopeLabel; Notices = $notices.ToArray() }
        }
    }

    $view = {
        param($State)
        $result = $State.Result
        $s = $result.Stats
        $statusTones = @{ Failed = 'bad'; Warning = 'warn'; Success = 'good'; Info = 'info' }
        @{
            Subtitle = 'Health check: endpoints, databases, queues and the Azure platform'
            Facts    = [ordered]@{ Scope = $State.Scope; Rounds = "$Count, $IntervalSeconds s apart"; 'SLA target' = "$SlaTarget%" }
            Status   = $(if ($s.Failed) { 'Failed' } elseif ($s.Warnings) { 'Warning' } else { 'Success' })
            Headline = "$($s.Checks) check(s): $($s.Failed) failed, $($s.Warnings) warning(s), $($s.Passed) healthy$(if ($s.Endpoints) { " - $($s.SlaMet) of $($s.Endpoints) endpoint(s) met the $SlaTarget% target" })"
            Tiles    = @(
                @{ Value = '{0:N0}' -f $s.Failed; Label = 'failed'; Tone = $(if ($s.Failed) { 'bad' } else { 'good' }); Table = 'checks'; Filters = @{ Status = 'Failed' } }
                @{ Value = '{0:N0}' -f $s.Warnings; Label = 'warnings'; Tone = $(if ($s.Warnings) { 'warn' } else { 'good' }); Table = 'checks'; Filters = @{ Status = 'Warning' } }
                @{ Value = '{0:N0}' -f $s.Passed; Label = 'healthy'; Tone = 'good'; Table = 'checks'; Filters = @{ Status = 'Success' } }
                @{ Value = $(if ($null -ne $s.Availability) { "$($s.Availability)%" } else { '-' }); Label = 'endpoint availability'; Tone = $(if ($null -eq $s.Availability) { 'neutral' } elseif ($s.Availability -ge $SlaTarget) { 'good' } else { 'bad' }); Table = 'sla' }
                @{ Value = "$($s.SlaMet)/$($s.Endpoints)"; Label = 'met the SLA'; Tone = $(if ($s.SlaMet -eq $s.Endpoints) { 'good' } else { 'bad' }); Table = 'sla' }
            )
            Notices  = @($State.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } })
            Charts   = @(
                @{ Title = 'Checks by status'; Kind = 'donut'; CenterLabel = 'checks'; Items = @(foreach ($st in 'Failed', 'Warning', 'Info', 'Success') { $c = @($result.Checks | Where-Object Status -EQ $st).Count; if ($c) { @{ Label = $st; Value = $c; Tone = $statusTones[$st]; Filter = $st } } }); Table = 'checks'; Column = 'Status' }
                @{ Title = 'Problems by tier'; Items = @($result.Checks | Where-Object { $_.Status -in 'Failed', 'Warning' } | Group-Object Tier | Sort-Object Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'checks'; Column = 'Tier'; Tone = 'warn' }
                @{ Title = '95th percentile latency (ms)'; Items = @($result.Sla | Where-Object { $null -ne $_.LatencyP95 } | Sort-Object LatencyP95 -Descending | Select-Object -First 12 | ForEach-Object { @{ Label = $_.Endpoint; Value = $_.LatencyP95 } }); Tone = 'info'; Console = $true }
            )
            Tables   = @(
                @{ Id = 'checks'; Title = 'Checks'; Section = 'Checks'; Rows = $result.Checks; Noun = 'checks'; GroupBy = @('Tier', 'Category', 'Status'); ConsoleLimit = 40
                    Empty = 'Nothing to check.'; EmptyStatus = 'Info'
                    Columns = @(
                        @{ Key = 'Status'; Label = 'Status'; Type = 'badge'; Tones = $statusTones; Facet = $true; Console = $true; Pdf = $true }
                        @{ Key = 'Tier'; Label = 'Tier'; Facet = $true; Console = $true; Pdf = $true }
                        @{ Key = 'Category'; Label = 'Check'; Facet = $true; Console = $true; Pdf = $true }
                        @{ Key = 'Check'; Label = 'Name'; Console = $true; Pdf = $true }
                        @{ Key = 'Detail'; Label = 'Found'; Type = 'wide'; Console = $true; Pdf = $true }
                        @{ Key = 'Availability'; Label = 'Availability (%)'; Type = 'number'; Format = 'N2' }
                        @{ Key = 'LatencyP95'; Label = 'p95 (ms)'; Type = 'number'; Format = 'N0'; Console = $true }
                        @{ Key = 'CertificateDays'; Label = 'Certificate (days)'; Type = 'number' }
                        @{ Key = 'Target'; Label = 'Target'; Type = 'mono' }
                        @{ Key = 'Remediation'; Label = 'What to do'; Type = 'wide'; Pdf = $true }
                    ) }
                @{ Id = 'sla'; Title = 'SLA'; Section = 'SLA'; Rows = $result.Sla; Noun = 'endpoints'
                    Columns = @(
                        @{ Key = 'Endpoint'; Label = 'Endpoint'; Console = $true }
                        @{ Key = 'Tier'; Label = 'Tier'; Facet = $true; Console = $true }
                        @{ Key = 'Met'; Label = 'Met'; Type = 'badge'; Tones = @{ Yes = 'good'; No = 'bad' }; Facet = $true; Console = $true }
                        @{ Key = 'Availability'; Label = 'Availability (%)'; Type = 'number'; Format = 'N2'; Console = $true }
                        @{ Key = 'Target'; Label = 'Target (%)'; Type = 'number'; Format = 'N2' }
                        @{ Key = 'Up'; Label = 'Answered'; Type = 'number' }
                        @{ Key = 'Attempts'; Label = 'Attempts'; Type = 'number'; Console = $true }
                        @{ Key = 'LatencyP50'; Label = 'p50 (ms)'; Type = 'number'; Format = 'N0' }
                        @{ Key = 'LatencyP95'; Label = 'p95 (ms)'; Type = 'number'; Format = 'N0'; Console = $true }
                        @{ Key = 'Uri'; Label = 'URL'; Type = 'mono' }
                    ) }
            )
            Hint     = $(if ($Watch) { "Refreshing every $IntervalSeconds s - Ctrl+C to stop." } else { '-Discover finds the endpoints; -Count and -IntervalSeconds for an SLA; -Watch keeps it live; -NoDisplay returns the checks.' })
        }
    }

    do {
        $state = & $run
        $report = & $view $state
        if ($Watch -and $interactive) { [Spectre.Console.AnsiConsole]::Clear(); Write-AACRule -Title 'Azure Admin Console :: Health check' -Color 'deepskyblue3_1'; Show-AACReportView -Report $report; Start-Sleep -Seconds ([Math]::Max(5, $IntervalSeconds)); continue }
        Invoke-AACReportOutput -Report $report -Title $Title -CsvObject @($state.Result.Checks) -Noun 'check' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
            -ShowView:$interactive -NoPaging:$NoPaging -Object @($state.Result.Checks) -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
    } while ($Watch -and $interactive)
}