Public/Add-DhAlertBanner.ps1

function Add-DhAlertBanner {
    <#
    .SYNOPSIS
        Add a top-of-page alert banner to the dashboard.

    .DESCRIPTION
        Renders a strip *above* the KPI summary tiles. Useful for surfacing
        current operational conditions — open incidents, recent failures,
        compliance breaches — so they are visible the moment the report opens.

        Multiple banners can be added; they stack vertically in the order they
        were declared. Each banner carries a severity (info / ok / warning /
        critical) which drives the colour via the existing RAG palette.

        Optionally:
          -Dismissible — adds a close button; the dismissal is remembered in
                          the URL hash so it survives a page reload.
          -Action — adds a button that either navigates to a registered
                          table (with an optional pre-filter) or opens an
                          external URL.

        Distinct from Add-DhEventFeed (v1.4+): a banner surfaces a single
        current condition; an event feed lists a chronological audit trail.

    .PARAMETER Report
        Dashboard object from New-DhDashboard.

    .PARAMETER Id
        Unique identifier (alphanumeric, dash, or underscore). Used as the
        DOM id and as the dismissal key in the URL hash.

    .PARAMETER Severity
        Visual treatment: 'info' | 'ok' | 'warning' | 'critical'.
        Default: 'info'.

    .PARAMETER Message
        The text shown in the banner (required). May contain inline HTML —
        use Add-DhHtmlBlock for richer content.

        SECURITY: Message is injected into the DOM via innerHTML. Never pass
        untrusted external data (AD attributes, ticket titles, log lines,
        anything supplied by an actor outside your trust boundary) into this
        parameter without HTML-encoding it first with
        [System.Web.HttpUtility]::HtmlEncode(). The same rule applies to
        Add-DhHtmlBlock, Add-DhTabs (per-tab Content), and Add-DhCollapsible
        (Content).

    .PARAMETER Icon
        Optional emoji / unicode glyph shown before the message.

    .PARAMETER Dismissible
        Adds a close (x) button. Dismissal persists across reloads via the URL
        hash. The banner re-appears in a new browser session.

    .PARAMETER Action
        Optional action button hashtable:
          @{
              Label = 'View incidents' # REQUIRED — button text
              TableId = 'incidents' # navigate to this registered table
              Filter = 'severity:critical' # optional pre-filter text
              # ─ OR ─
              Url = 'https://...' # external link instead of TableId
          }

    .EXAMPLE
        # Simplest banner — just a message
        Add-DhAlertBanner -Report $report -Id 'maintenance' `
            -Severity 'info' -Message 'Maintenance window tonight 22:00–02:00 UTC'

    .EXAMPLE
        # Critical alert with action button — clicking jumps to a filtered table
        Add-DhAlertBanner -Report $report -Id 'open-incidents' `
            -Severity 'critical' -Icon '!' `
            -Message '3 critical incidents open in the last 24h' `
            -Dismissible `
            -Action @{ Label='View incidents'; TableId='incidents'; Filter='critical' }

    .EXAMPLE
        # External link
        Add-DhAlertBanner -Report $report -Id 'release-notes' `
            -Severity 'ok' -Message 'Patch level KB5044277 applied to 98% of hosts' `
            -Action @{ Label='Open KB'; Url='https://support.microsoft.com/kb/5044277' }
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary] $Report,
        [Parameter(Mandatory)]
        [ValidatePattern('^[A-Za-z0-9_-]+$')]
        [string] $Id,
        [Parameter(Mandatory)] [string] $Message,
        [ValidateSet('info','ok','warning','critical')]
        [string]    $Severity = 'info',
        [string]    $Icon     = '',
        [switch]    $Dismissible,
        [hashtable] $Action   = $null,

        # v1.5.1 — collapsible banner chrome. Defaults to $true (matches the
        # rest of v1.5.1). The banner's message + icon + action button live
        # inside a clickable header bar. -Title controls the header text;
        # when empty it auto-derives from the severity ('Alert' / 'Notice' /
        # 'Warning' / 'Critical'). Pass -Collapsible:$false to keep the
        # original flat banner strip.
        [bool]      $Collapsible = $true,
        [bool]      $DefaultOpen = $true,
        [string]    $Title       = '',

        # v1.4.2+ — optional NavGroup binding. When set, the banner is visible
        # only while the matching nav group (and optional subgroup) is active.
        # Without these, the banner stays page-global (visible everywhere) as
        # in v1.4.x — full backward compatibility.
        [string]    $NavGroup    = '',
        [string]    $NavSubGroup = ''
    )

    # Init the collection lazily — keeps reports that don't use banners clean
    if (-not $Report.Contains('AlertBanners')) {
        $Report['AlertBanners'] = [System.Collections.Generic.List[hashtable]]::new()
    }

    # Duplicate-Id guard (same rule as Add-DhTable)
    foreach ($existing in $Report['AlertBanners']) {
        if ($existing.Id -eq $Id) {
            throw "Add-DhAlertBanner: A banner with Id '$Id' already exists in this report. Use a unique Id."
        }
    }

    # Validate Action shape when provided
    $normAction = $null
    if ($Action) {
        if (-not $Action.Contains('Label') -or [string]::IsNullOrWhiteSpace([string]$Action.Label)) {
            throw "Add-DhAlertBanner: -Action requires a non-empty 'Label' key."
        }
        $hasTableId = $Action.Contains('TableId') -and -not [string]::IsNullOrWhiteSpace([string]$Action.TableId)
        $hasUrl     = $Action.Contains('Url')     -and -not [string]::IsNullOrWhiteSpace([string]$Action.Url)
        if (-not $hasTableId -and -not $hasUrl) {
            throw "Add-DhAlertBanner: -Action requires either a 'TableId' key (jump to table) or a 'Url' key (external link)."
        }
        if ($hasTableId -and $hasUrl) {
            throw "Add-DhAlertBanner: -Action must specify EITHER 'TableId' OR 'Url', not both."
        }
        # v1.5.3 — the JS renderer passes Url straight to window.open(); restrict the
        # scheme so a 'javascript:' Url cannot reach window.open() and execute in the
        # dashboard's origin. Test-DhSafeActionUrl throws on any unsupported scheme.
        $safeUrl = if ($hasUrl) { Test-DhSafeActionUrl -Url ([string]$Action.Url) -Context "Add-DhAlertBanner '$Id': Action.Url" } else { '' }

        $normAction = @{
            Label   = [string]$Action.Label
            TableId = if ($hasTableId) { [string]$Action.TableId } else { '' }
            Filter  = if ($Action.Contains('Filter')) { [string]$Action.Filter } else { '' }
            Url     = $safeUrl
        }
    }

    # Auto-derive a collapsible-header Title from severity when caller didn't supply one.
    $resolvedTitle = if (-not [string]::IsNullOrWhiteSpace($Title)) { $Title }
                     else { switch ($Severity) {
                         'critical' { 'Critical' }
                         'warning'  { 'Warning'  }
                         'ok'       { 'Notice'   }
                         default    { 'Alert'    }
                     } }

    $banner = [ordered]@{
        Id          = $Id
        Severity    = $Severity
        Message     = $Message
        Icon        = $Icon
        Dismissible = [bool]$Dismissible
        Action      = $normAction
        Collapsible = [bool]$Collapsible
        DefaultOpen = [bool]$DefaultOpen
        Title       = $resolvedTitle
        NavGroup    = $NavGroup
        NavSubGroup = $NavSubGroup
    }

    $Report['AlertBanners'].Add($banner)
    Write-Verbose "Add-DhAlertBanner: Added '$Id' (severity=$Severity, dismissible=$([bool]$Dismissible))."
}