helpers/cisco-wlc/Export-Wireless-Dashboard-Pack.ps1
|
#requires -Version 5.1 [CmdletBinding()] param( [string]$SummaryDirectory, [string]$OutputDirectory, [string]$FullDirectory = '', [string]$WhatsUpGoldPsRepoPath, [ValidateRange(1000, 50000)] [int]$RogueVariableLimit = 20000, [bool]$IncludeRogues = $false, [bool]$IncludeCoverageDashboard = $false, [bool]$IncludeRfHealth = $false, [bool]$IncludeJoinTime = $false, [switch]$Offline ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # Set dynamic defaults if not provided $discoveryOutputBase = Join-Path $env:LOCALAPPDATA 'WhatsUpGoldPS\DiscoveryHelpers\Output' if (-not $SummaryDirectory) { $SummaryDirectory = Join-Path $discoveryOutputBase 'summary' } if (-not $OutputDirectory) { $OutputDirectory = Join-Path $discoveryOutputBase 'summary\dashboards' } if (-not $WhatsUpGoldPsRepoPath) { # Try to find the WhatsUpGoldPS module root (contains helpers/ folder) $mod = Get-Module -Name WhatsUpGoldPS -ErrorAction SilentlyContinue if ($mod -and $mod.ModuleBase -and (Test-Path (Join-Path $mod.ModuleBase 'helpers'))) { $WhatsUpGoldPsRepoPath = $mod.ModuleBase } else { # Fallback: resolve relative to this script (helpers/cisco-wlc -> repo root) $WhatsUpGoldPsRepoPath = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent } } # Ensure directories exist foreach ($dir in @($SummaryDirectory, $OutputDirectory)) { if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } } function Ensure-FileExists { param([Parameter(Mandatory)] [string]$Path) if (-not (Test-Path -LiteralPath $Path)) { throw "Required file not found: $Path" } } function Ensure-Directory { param([Parameter(Mandatory)] [string]$Path) if (-not (Test-Path -LiteralPath $Path)) { New-Item -ItemType Directory -Path $Path -Force | Out-Null } } function Publish-OfflineDependencies { param( [Parameter(Mandatory)] [string]$SourceDirectory, [Parameter(Mandatory)] [string]$DestinationDirectory, [Parameter(Mandatory)] [string[]]$HtmlPaths ) if (-not (Test-Path -LiteralPath $SourceDirectory)) { throw "Offline dependency directory not found: $SourceDirectory" } Ensure-Directory -Path $DestinationDirectory Copy-Item -Path (Join-Path $SourceDirectory '*') -Destination $DestinationDirectory -Recurse -Force $resolvedSourceDirectory = (Resolve-Path -LiteralPath $SourceDirectory).Path.TrimEnd('\','/') $sourceUriPrefix = 'file:///' + $resolvedSourceDirectory.Replace('\', '/') + '/' $resetAllSearchOld = @' setTableData(allData); $table.bootstrapTable('resetSearch', ''); $table.bootstrapTable('selectPage', 1); '@ $resetAllSearchNew = @' $table.bootstrapTable('resetSearch', ''); $table.bootstrapTable('refreshOptions', { data: allData }); $table.bootstrapTable('selectPage', 1); '@ $resetFilteredSearchOld = @' setTableData(filtered); $table.bootstrapTable('resetSearch', ''); $table.bootstrapTable('selectPage', 1); '@ $resetFilteredSearchNew = @' $table.bootstrapTable('resetSearch', ''); $table.bootstrapTable('refreshOptions', { data: filtered }); $table.bootstrapTable('selectPage', 1); '@ foreach ($htmlPath in $HtmlPaths) { if (-not (Test-Path -LiteralPath $htmlPath)) { continue } $html = Get-Content -LiteralPath $htmlPath -Raw $html = $html.Replace($sourceUriPrefix, 'dependency/') $html = [regex]::Replace($html, '\s+crossorigin="anonymous"', '') $html = $html.Replace($resetAllSearchOld, $resetAllSearchNew) $html = $html.Replace($resetFilteredSearchOld, $resetFilteredSearchNew) $html | Out-File -LiteralPath $htmlPath -Encoding UTF8 } } function Normalize-String { param([AllowNull()] [object]$Value) if ($null -eq $Value) { return '' } return [string]$Value } function Normalize-DisplayKey { param([AllowNull()] [object]$Value) $text = Normalize-String $Value if ([string]::IsNullOrEmpty($text)) { return '' } $clean = [regex]::Replace($text, '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '') return $clean.Trim() } function Test-IsPopulatedValue { param([AllowNull()] [object]$Value) if ($null -eq $Value) { return $false } $text = [string]$Value if ([string]::IsNullOrWhiteSpace($text)) { return $false } $trimmed = $text.Trim() if ($trimmed -eq '0' -or $trimmed -eq '0.0.0.0') { return $false } return $true } function Remove-UnpopulatedColumns { param( [Parameter(Mandatory)] [object[]]$Rows, [string[]]$KeepFields = @(), [string]$DatasetName = 'dataset' ) if (@($Rows).Count -eq 0) { return @($Rows) } $props = @($Rows[0].PSObject.Properties.Name) $keptProps = New-Object System.Collections.Generic.List[string] $removedProps = New-Object System.Collections.Generic.List[string] foreach ($p in $props) { if ($KeepFields -contains $p) { $keptProps.Add($p) continue } $hasData = $false foreach ($r in $Rows) { if (Test-IsPopulatedValue -Value $r.$p) { $hasData = $true break } } if ($hasData) { $keptProps.Add($p) } else { $removedProps.Add($p) } } if ($removedProps.Count -gt 0) { Write-Host "Pruned empty columns from ${DatasetName}: $($removedProps -join ', ')" -ForegroundColor Yellow } $result = foreach ($r in $Rows) { $row = [ordered]@{} foreach ($p in $keptProps) { $row[$p] = $r.$p } [PSCustomObject]$row } return @($result) } function Get-RogueTableRelativePath { param([string]$Oid) $base = '1.3.6.1.4.1.9.9.610.' if ([string]::IsNullOrWhiteSpace($Oid) -or -not $Oid.StartsWith($base)) { return '' } $parts = $Oid.Substring($base.Length).Split('.') if ($parts.Count -eq 0) { return '' } $take = [Math]::Min(4, $parts.Count) return ($parts[0..($take - 1)] -join '.') } function Get-RogueDisplayValue { param( [string]$Type, [string]$Value, [string]$HexValue ) if ($Type -eq 'OctetString' -and -not [string]::IsNullOrWhiteSpace($HexValue)) { $hasControl = [regex]::IsMatch(([string]$Value), '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]') if ($hasControl) { return "0x$HexValue" } } return Normalize-DisplayKey $Value } function Get-ClientBestState { param([AllowNull()] [string]$Status) switch ((Normalize-String $Status).ToLowerInvariant()) { 'authenticated' { 'healthy' } 'associated' { 'healthy' } 'idle' { 'warning' } 'aaa-pending' { 'warning' } 'powersave' { 'warning' } default { 'unknown' } } } function Get-RowStatusLabel { param([AllowNull()] [string]$Code) switch (Normalize-String $Code) { '1' { 'active' } '2' { 'notInService' } '3' { 'notReady' } '4' { 'createAndGo' } '5' { 'createAndWait' } '6' { 'destroy' } default { if ([string]::IsNullOrWhiteSpace((Normalize-String $Code))) { 'unknown' } else { "rowstatus-$Code" } } } } function Export-Dashboard { param( [Parameter(Mandatory)] [object[]]$Data, [Parameter(Mandatory)] [string]$OutputHtmlPath, [Parameter(Mandatory)] [string]$OutputJsonPath, [Parameter(Mandatory)] [string]$ReportTitle, [string[]]$CardField, [string]$StatusField, [string]$ExportPrefix, [Parameter(Mandatory)] [string]$TemplatePath, [string]$IndexUrl, [switch]$Offline ) ($Data | ConvertTo-Json -Depth 20) | Out-File -LiteralPath $OutputJsonPath -Encoding UTF8 Write-Host " [Export-Dashboard] Wrote JSON: $($Data.Count) input rows, file=$(Split-Path $OutputJsonPath -Leaf)" -ForegroundColor DarkGray $reportData = @(Get-Content -LiteralPath $OutputJsonPath -Raw | ConvertFrom-Json) # Unwrap PS5.1 ConvertFrom-Json array nesting if ($reportData.Count -eq 1 -and $reportData[0] -is [System.Object[]]) { $reportData = $reportData[0] } Write-Host " [Export-Dashboard] Read back: $($reportData.Count) rows" -ForegroundColor DarkGray $params = @{ OutputPath = $OutputHtmlPath ReportTitle = $ReportTitle CardField = $CardField StatusField = $StatusField ExportPrefix = $ExportPrefix TemplatePath = $TemplatePath } if ($IndexUrl) { $params.IndexUrl = $IndexUrl } if ($Offline) { $params.Offline = $true } return ($reportData | Export-DynamicDashboardHtml @params) } function Add-DashboardPageHeader { param( [Parameter(Mandatory)] [string]$HtmlPath, [Parameter(Mandatory)] [string]$Title, [Parameter(Mandatory)] [string]$ViewName, [int]$RowCount, [AllowNull()] [object]$Kpis ) if (-not (Test-Path -LiteralPath $HtmlPath)) { return } $html = Get-Content -LiteralPath $HtmlPath -Raw if ($html -match 'id="dashboard-page-title"') { return } $titleEsc = [System.Net.WebUtility]::HtmlEncode($Title) $viewEsc = [System.Net.WebUtility]::HtmlEncode($ViewName) $subtitle = "View: $viewEsc | Rows: $RowCount" if ($null -ne $Kpis) { $subtitle += " | APs: $($Kpis.APCount), Clients: $($Kpis.ClientCount), WLANs: $($Kpis.WlanCount)" } $subtitleEsc = [System.Net.WebUtility]::HtmlEncode($subtitle) $headerHtml = @" <div id="dashboard-page-title" class="mb-3 pb-2 border-bottom"> <h4 class="mb-1">$titleEsc</h4> <div class="text-muted small">$subtitleEsc</div> </div> "@ $containerMarker = '<div class="container-fluid p-3">' if ($html.Contains($containerMarker)) { $html = $html.Replace($containerMarker, "$containerMarker`r`n$headerHtml") $html | Out-File -LiteralPath $HtmlPath -Encoding UTF8 } } $clientJsonPath = Join-Path $SummaryDirectory 'wireless-client-inventory-summary.json' $apJsonPath = Join-Path $SummaryDirectory 'wireless-ap-inventory-summary.json' $wlanJsonPath = Join-Path $SummaryDirectory 'wireless-wlan-inventory-summary.json' $rogueProximityJsonPath = Join-Path $SummaryDirectory 'wireless-rogue-proximity-summary.json' $datasetJsonPath = Join-Path $SummaryDirectory 'wireless-dashboard-datasets.json' $rootSummaryFiles = @( (Join-Path $SummaryDirectory 'wireless-ap-summary.json'), (Join-Path $SummaryDirectory 'wireless-dot11-client-summary.json'), (Join-Path $SummaryDirectory 'wireless-dot11-summary.json'), (Join-Path $SummaryDirectory 'wireless-rogue-summary.json'), (Join-Path $SummaryDirectory 'wireless-rf-summary.json'), (Join-Path $SummaryDirectory 'wireless-mobility-ext-summary.json'), (Join-Path $SummaryDirectory 'wireless-wlan-summary.json'), (Join-Path $SummaryDirectory 'wireless-sys-summary.json') ) Ensure-FileExists -Path $clientJsonPath Ensure-FileExists -Path $apJsonPath Ensure-FileExists -Path $wlanJsonPath foreach ($p in $rootSummaryFiles) { if (-not (Test-Path -LiteralPath $p)) { Write-Warning "Root summary not found (skipping in coverage): $p" } } Ensure-Directory -Path $OutputDirectory $exportFnPath = Join-Path $WhatsUpGoldPsRepoPath 'helpers\reports\Export-DynamicDashboardHtml.ps1' $templatePath = Join-Path $WhatsUpGoldPsRepoPath 'helpers\reports\Dynamic-Dashboard-Template.html' Ensure-FileExists -Path $exportFnPath Ensure-FileExists -Path $templatePath $dependencySourceDirectory = Join-Path (Split-Path -Parent $exportFnPath) 'dependency' if ([string]::IsNullOrWhiteSpace($FullDirectory)) { $FullDirectory = Join-Path (Split-Path -Parent $SummaryDirectory) 'wireless-full' } # External helper uses loose scope semantics. Set-StrictMode -Off . $exportFnPath $clients = @(Get-Content -LiteralPath $clientJsonPath -Raw | ConvertFrom-Json) $aps = @(Get-Content -LiteralPath $apJsonPath -Raw | ConvertFrom-Json) $wlans = @(Get-Content -LiteralPath $wlanJsonPath -Raw | ConvertFrom-Json) $rogueProximity = @() if ($IncludeRogues -and (Test-Path -LiteralPath $rogueProximityJsonPath)) { $rogueProximity = @(Get-Content -LiteralPath $rogueProximityJsonPath -Raw | ConvertFrom-Json) } # Unwrap PS5.1 ConvertFrom-Json array nesting if ($clients.Count -eq 1 -and $clients[0] -is [System.Object[]]) { $clients = $clients[0] } if ($aps.Count -eq 1 -and $aps[0] -is [System.Object[]]) { $aps = $aps[0] } if ($wlans.Count -eq 1 -and $wlans[0] -is [System.Object[]]) { $wlans = $wlans[0] } if ($rogueProximity.Count -eq 1 -and $rogueProximity[0] -is [System.Object[]]) { $rogueProximity = $rogueProximity[0] } $dataset = $null if (Test-Path -LiteralPath $datasetJsonPath) { $dataset = Get-Content -LiteralPath $datasetJsonPath -Raw | ConvertFrom-Json } $apByMac = @{} foreach ($ap in $aps) { $k = (Normalize-String $ap.APMac).ToUpperInvariant() if (-not [string]::IsNullOrWhiteSpace($k) -and -not $apByMac.ContainsKey($k)) { $apByMac[$k] = $ap } } $wlanByProfile = @{} foreach ($w in $wlans) { $k = Normalize-String $w.ProfileName if (-not [string]::IsNullOrWhiteSpace($k) -and -not $wlanByProfile.ContainsKey($k)) { $wlanByProfile[$k] = $w } } $clientsDashboard = foreach ($c in $clients) { $apMac = (Normalize-String $c.APMac).ToUpperInvariant() $ap = if ($apByMac.ContainsKey($apMac)) { $apByMac[$apMac] } else { $null } $wlanProfileName = Normalize-String $c.WlanProfileName $wlan = if ($wlanByProfile.ContainsKey($wlanProfileName)) { $wlanByProfile[$wlanProfileName] } else { $null } $status = Normalize-String $c.Status [PSCustomObject]@{ ClientMac = Normalize-String $c.ClientMac ClientIP = Normalize-String $c.ClientIP Username = Normalize-DisplayKey $c.Username SSID = Normalize-DisplayKey $c.SSID WlanProfileName = Normalize-DisplayKey $wlanProfileName Status = $status BestState = Get-ClientBestState -Status $status DeviceType = Normalize-String $c.DeviceType AuthMode = Normalize-String $c.AuthMode AccessVLAN = Normalize-String $c.AccessVLAN Channel = Normalize-String $c.Channel APName = if ($null -ne $ap) { Normalize-String $ap.APName } else { '' } APMac = Normalize-String $c.APMac APControllerPrimary = if ($null -ne $ap) { Normalize-String $ap.PrimaryControllerAddress } else { '' } APControllerSecondary = if ($null -ne $ap) { Normalize-String $ap.SecondaryControllerAddress } else { '' } } } $clientsDashboard = @($clientsDashboard | Sort-Object SSID, APName, ClientMac) $clientsDashboard = Remove-UnpopulatedColumns -Rows $clientsDashboard -KeepFields @('ClientMac','ClientIP','Username','SSID','WlanProfileName','Status','BestState','APName','APMac') -DatasetName 'clients' $clientsByAp = @{} foreach ($row in $clientsDashboard) { $k = (Normalize-String $row.APMac).ToUpperInvariant() if ([string]::IsNullOrWhiteSpace($k)) { continue } if (-not $clientsByAp.ContainsKey($k)) { $clientsByAp[$k] = New-Object System.Collections.Generic.List[object] } $clientsByAp[$k].Add($row) } $bssidDashboard = foreach ($bssid in $clientsByAp.Keys) { $cl = @($clientsByAp[$bssid].ToArray()) $ssidGroups = @($cl | Group-Object SSID | Where-Object { -not [string]::IsNullOrWhiteSpace($_.Name) } | Sort-Object Count -Descending) $profileGroups = @($cl | Group-Object WlanProfileName | Where-Object { -not [string]::IsNullOrWhiteSpace($_.Name) } | Sort-Object Count -Descending) $topSsid = if ($ssidGroups.Count -gt 0) { $ssidGroups[0].Name } else { '' } $profiles = @($profileGroups | Select-Object -First 5 -ExpandProperty Name) -join ', ' $sampleApName = @($cl | Where-Object { -not [string]::IsNullOrWhiteSpace($_.APName) } | Select-Object -First 1 -ExpandProperty APName) [PSCustomObject]@{ BSSIDMac = $bssid APNameHint = if ($sampleApName.Count -gt 0) { $sampleApName[0] } else { '' } ClientCount = @($cl).Count DistinctSsidCount = @($ssidGroups).Count TopSSID = $topSsid TopProfiles = $profiles BestState = if (@($cl).Count -gt 0) { 'active' } else { 'idle' } } } $bssidDashboard = @($bssidDashboard | Sort-Object -Property @( @{ Expression = 'ClientCount'; Descending = $true }, @{ Expression = 'BSSIDMac'; Descending = $false } )) $apDashboard = @() if ($dataset -and $dataset.PSObject.Properties.Name -contains 'ApOperational' -and @($dataset.ApOperational).Count -gt 0) { $apDashboard = @($dataset.ApOperational | ForEach-Object { [PSCustomObject]@{ APName = Normalize-String $_.APName APMac = Normalize-String $_.APMac Dot11Slots = Normalize-String $_.Dot11Slots ClientCount = $_.ClientCount DistinctSsidCount = $_.DistinctSsidCount TopSSID = Normalize-String $_.TopSSID ControllerPrimary = Normalize-String $_.ControllerPrimary ControllerSecondary = Normalize-String $_.ControllerSecondary ControllerTertiary = Normalize-String $_.ControllerTertiary SiteTagName = Normalize-String $_.SiteTagName RfTagName = Normalize-String $_.RfTagName PolicyTagName = Normalize-String $_.PolicyTagName DomainName = Normalize-String $_.DomainName FilterName = Normalize-String $_.FilterName UsbSerialNumber = Normalize-String $_.UsbSerialNumber ApUptime = Normalize-String $_.ApUptime LastChange = Normalize-String $_.LastChange BestState = Normalize-String $_.BestState AdminStatus = Normalize-String $_.AdminStatus PowerStatus = Normalize-String $_.PowerStatus FailoverPriority = Normalize-String $_.FailoverPriority LastRebootReason = Normalize-String $_.LastRebootReason SubMode = Normalize-String $_.SubMode AntennaBandMode = Normalize-String $_.AntennaBandMode CpuCurrentPct = Normalize-String $_.CpuCurrentPct CpuAvgPct = Normalize-String $_.CpuAvgPct MemCurrentPct = Normalize-String $_.MemCurrentPct MemAvgPct = Normalize-String $_.MemAvgPct AssocClientCount = Normalize-String $_.AssocClientCount ActiveClientCount = Normalize-String $_.ActiveClientCount } }) } else { $apDashboard = foreach ($ap in $aps) { $apMac = (Normalize-String $ap.APMac).ToUpperInvariant() $cl = @() if (-not [string]::IsNullOrWhiteSpace($apMac) -and $clientsByAp.ContainsKey($apMac)) { $cl = @($clientsByAp[$apMac].ToArray()) } $ssidGroups = $cl | Group-Object SSID | Where-Object { -not [string]::IsNullOrWhiteSpace($_.Name) } | Sort-Object Count -Descending $topSsid = if ($ssidGroups) { $ssidGroups[0].Name } else { '' } [PSCustomObject]@{ APName = Normalize-String $ap.APName APMac = Normalize-String $ap.APMac Dot11Slots = Normalize-String $ap.Dot11Slots ClientCount = @($cl).Count DistinctSsidCount = @($ssidGroups).Count TopSSID = $topSsid ControllerPrimary = Normalize-String $ap.PrimaryControllerAddress ControllerSecondary = Normalize-String $ap.SecondaryControllerAddress ControllerTertiary = Normalize-String $ap.TertiaryControllerAddress SiteTagName = Normalize-String $ap.SiteTagName RfTagName = Normalize-String $ap.RfTagName PolicyTagName = Normalize-String $ap.PolicyTagName DomainName = Normalize-String $ap.DomainName FilterName = Normalize-String $ap.FilterName UsbSerialNumber = Normalize-String $ap.UsbSerialNumber ApUptime = Normalize-String $ap.ApUptime LastChange = Normalize-String $ap.LastChange BestState = if (@($cl).Count -gt 0) { 'active' } else { 'idle' } AdminStatus = Normalize-String $ap.AdminStatus PowerStatus = Normalize-String $ap.PowerStatus FailoverPriority = Normalize-String $ap.FailoverPriority LastRebootReason = Normalize-String $ap.LastRebootReason SubMode = Normalize-String $ap.SubMode AntennaBandMode = Normalize-String $ap.AntennaBandMode CpuCurrentPct = Normalize-String $ap.CpuCurrentPct CpuAvgPct = Normalize-String $ap.CpuAvgPct MemCurrentPct = Normalize-String $ap.MemCurrentPct MemAvgPct = Normalize-String $ap.MemAvgPct AssocClientCount = Normalize-String $ap.AssocClientCount ActiveClientCount = Normalize-String $ap.ActiveClientCount } } } $apDashboard = @($apDashboard | Sort-Object -Property @( @{ Expression = 'ClientCount'; Descending = $true }, @{ Expression = 'APName'; Descending = $false } )) $apDashboard = Remove-UnpopulatedColumns -Rows $apDashboard -KeepFields @('APName','APMac','ClientCount','DistinctSsidCount','TopSSID','BestState') -DatasetName 'aps' $apJoinCount = @($apDashboard | Where-Object { [int]$_.ClientCount -gt 0 }).Count if ($apJoinCount -eq 0) { Write-Warning 'No client-to-AP joins were found; AP client/SSID rollups will be marked as unavailable for this run.' $apDashboard = @($apDashboard | ForEach-Object { [PSCustomObject]@{ APName = $_.APName APMac = $_.APMac Dot11Slots = $_.Dot11Slots ClientCount = '' DistinctSsidCount = '' TopSSID = 'unavailable' ControllerPrimary = $_.ControllerPrimary ControllerSecondary = $_.ControllerSecondary ControllerTertiary = $_.ControllerTertiary ApUptime = $_.ApUptime LastChange = $_.LastChange BestState = $_.BestState } }) } $clientsByWlan = @{} foreach ($row in $clientsDashboard) { $wlanProfileName = Normalize-String $row.WlanProfileName if (-not $clientsByWlan.ContainsKey($wlanProfileName)) { $clientsByWlan[$wlanProfileName] = New-Object System.Collections.Generic.List[object] } $clientsByWlan[$wlanProfileName].Add($row) } $wlanDashboard = foreach ($w in $wlans) { $wlanProfileName = Normalize-String $w.ProfileName $cl = @() if (-not [string]::IsNullOrWhiteSpace($wlanProfileName) -and $clientsByWlan.ContainsKey($wlanProfileName)) { $cl = @($clientsByWlan[$wlanProfileName].ToArray()) } $apCount = @($cl | Group-Object APMac | Where-Object { -not [string]::IsNullOrWhiteSpace($_.Name) }).Count $statusText = Get-RowStatusLabel -Code (Normalize-String $w.RowStatus) [PSCustomObject]@{ WlanIndex = Normalize-String $w.WlanIndex ProfileName = $wlanProfileName SSID = Normalize-String $w.SSID ClientCount = @($cl).Count APCount = $apCount RowStatusCode = Normalize-String $w.RowStatus RowStatus = $statusText IsWired = Normalize-String $w.IsWired NACSupport = Normalize-String $w.NACSupport MaxClientsAllowedPerRadio = Normalize-String $w.MaxClientsAllowedPerRadio BestState = if ($statusText -eq 'active') { 'active' } else { 'warning' } } } $wlanDashboard = @($wlanDashboard | Sort-Object -Property @( @{ Expression = 'ClientCount'; Descending = $true }, @{ Expression = 'ProfileName'; Descending = $false } )) $wlanDashboard = Remove-UnpopulatedColumns -Rows $wlanDashboard -KeepFields @('WlanIndex','ProfileName','SSID','ClientCount','APCount','RowStatus','BestState') -DatasetName 'wlans' $rootCoverage = @() foreach ($file in $rootSummaryFiles) { if (-not (Test-Path -LiteralPath $file)) { continue } $rows = @(Get-Content -LiteralPath $file -Raw | ConvertFrom-Json) foreach ($r in $rows) { $varCount = 0 try { $varCount = [int]$r.VariableCount } catch { $varCount = 0 } $health = if ($varCount -eq 0) { 'empty' } elseif ($varCount -lt 100) { 'small' } else { 'active' } $rootCoverage += [PSCustomObject]@{ Root = Normalize-String $r.Root TableRelativePath = Normalize-String $r.TableRelativePath TableOidPrefix = Normalize-String $r.TableOidPrefix VariableCount = $varCount DistinctColumnCount = Normalize-String $r.DistinctColumnCount DistinctIndexCount = Normalize-String $r.DistinctIndexCount TypeBreakdown = Normalize-String $r.TypeBreakdown BestState = $health } } } $rootCoverage = if ($IncludeRogues) { @($rootCoverage) } else { @($rootCoverage | Where-Object { $_.Root -ne 'rogue' }) } $rootCoverage = @($rootCoverage | Sort-Object -Property @( @{ Expression = 'VariableCount'; Descending = $true }, @{ Expression = 'Root'; Descending = $false } )) $rogueTables = @($rootCoverage | Where-Object { $_.Root -eq 'rogue' } | Sort-Object VariableCount -Descending) $rogueVariables = @() $rogueJsonlPath = Join-Path $FullDirectory 'wireless-rogue.jsonl' if ($IncludeRogues -and (Test-Path -LiteralPath $rogueJsonlPath)) { $reader = [System.IO.StreamReader]::new($rogueJsonlPath) try { $count = 0 while (($null -ne ($line = $reader.ReadLine())) -and $count -lt $RogueVariableLimit) { if ([string]::IsNullOrWhiteSpace($line)) { continue } $item = $line | ConvertFrom-Json $oid = Normalize-String $item.OID $type = Normalize-String $item.Type $value = Normalize-String $item.Value $hexValue = if ($item.PSObject.Properties.Name -contains 'HexValue') { Normalize-String $item.HexValue } else { '' } $rogueVariables += [PSCustomObject]@{ TableRelativePath = Get-RogueTableRelativePath -Oid $oid OID = $oid Type = $type DisplayValue = Get-RogueDisplayValue -Type $type -Value $value -HexValue $hexValue HexValue = $hexValue } $count++ } } finally { $reader.Dispose() } $rogueVariables = @($rogueVariables | Sort-Object TableRelativePath, OID) Write-Host "Built rogue variable view: $($rogueVariables.Count) rows (limit=$RogueVariableLimit)" -ForegroundColor Cyan } elseif ($IncludeRogues) { Write-Warning "Rogue JSONL not found for detail dashboard: $rogueJsonlPath" } $dashboardDefs = @( [PSCustomObject]@{ Name = 'clients' Title = 'Wireless Clients by SSID and State' Data = $clientsDashboard CardField = @('BestState','SSID') StatusField = 'BestState' }, [PSCustomObject]@{ Name = 'aps' Title = 'Wireless Access Point Health' Data = $apDashboard CardField = @('BestState') StatusField = 'BestState' }, [PSCustomObject]@{ Name = 'bssid-activity' Title = 'Wireless BSSID Activity (Client-Reported)' Data = $bssidDashboard CardField = @('BestState') StatusField = 'BestState' }, [PSCustomObject]@{ Name = 'wlans' Title = 'Wireless WLAN Inventory and Load' Data = $wlanDashboard CardField = @('BestState') StatusField = 'BestState' }, [PSCustomObject]@{ Name = 'coverage' Title = 'Wireless SNMP Table Coverage' Data = $rootCoverage CardField = @('Root','BestState') StatusField = 'BestState' }, [PSCustomObject]@{ Name = 'rogue-proximity' Title = 'Detected Rogue Access Points' Data = $rogueProximity CardField = @('SSID','Channel') StatusField = '' } ) if (-not $IncludeRogues) { $dashboardDefs = @($dashboardDefs | Where-Object { $_.Name -ne 'rogue-proximity' }) } if (-not $IncludeCoverageDashboard) { $dashboardDefs = @($dashboardDefs | Where-Object { $_.Name -ne 'coverage' }) } $artifactRows = @() foreach ($d in $dashboardDefs) { if (@($d.Data).Count -eq 0) { Write-Warning "Skipping dashboard '$($d.Name)' because it has no rows." continue } $htmlPath = Join-Path $OutputDirectory ("wireless-dashboard-$($d.Name).html") $jsonPath = Join-Path $OutputDirectory ("wireless-dashboard-$($d.Name)-data.json") $finalTitle = $d.Title $null = Export-Dashboard ` -Data @($d.Data) ` -OutputHtmlPath $htmlPath ` -OutputJsonPath $jsonPath ` -ReportTitle $finalTitle ` -CardField $d.CardField ` -StatusField $d.StatusField ` -ExportPrefix ("wireless_dashboard_$($d.Name)") ` -TemplatePath $templatePath ` -IndexUrl 'wireless-dashboard-index.html' ` -Offline:$Offline $kpisForHeader = $null if ($dataset -and $dataset.Kpis) { $kpisForHeader = $dataset.Kpis } Add-DashboardPageHeader ` -HtmlPath $htmlPath ` -Title $d.Title ` -ViewName $d.Name ` -RowCount @($d.Data).Count ` -Kpis $kpisForHeader $artifactRows += [PSCustomObject]@{ Dashboard = $d.Name Title = $d.Title Rows = @($d.Data).Count Html = $htmlPath DataJson = $jsonPath } Write-Host "Generated dashboard: $($d.Name) ($(@($d.Data).Count) rows)" -ForegroundColor Green } if ($Offline -and $artifactRows.Count -gt 0) { Publish-OfflineDependencies ` -SourceDirectory $dependencySourceDirectory ` -DestinationDirectory (Join-Path $OutputDirectory 'dependency') ` -HtmlPaths @($artifactRows | ForEach-Object { $_.Html }) } $indexPath = Join-Path $OutputDirectory 'wireless-dashboard-index.html' $indexRowsHtml = ($artifactRows | ForEach-Object { $name = [System.IO.Path]::GetFileName($_.Html) $title = $_.Title $rows = $_.Rows '<tr><td><a href="{0}">{1}</a></td><td>{2}</td><td>{0}</td></tr>' -f $name, $title, $rows }) -join [Environment]::NewLine $indexContent = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Wireless Dashboard Index</title> <style> body { font-family: Segoe UI, Arial, sans-serif; margin: 24px; background: #f6f8fb; color: #1f2937; } .wrap { background: #fff; border: 1px solid #d1d5db; border-radius: 10px; padding: 20px; max-width: 1100px; } h1 { margin-top: 0; font-size: 1.4rem; } table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #e5e7eb; padding: 10px; text-align: left; } th { background: #f3f4f6; } a { color: #2563eb; text-decoration: none; } a:hover { text-decoration: underline; } .hint { color: #4b5563; margin-bottom: 14px; } </style> </head> <body> <div class="wrap"> <h1>Wireless Dashboard Pack</h1> <p class="hint">Distinct dashboards generated from linked wireless datasets. Join keys included in tables (AP MAC, AP Name, WLAN profile/SSID) so cross-navigation can happen via filtering/search.</p> <table> <thead> <tr><th>Dashboard</th><th>Rows</th><th>File</th></tr> </thead> <tbody> $indexRowsHtml </tbody> </table> </div> </body> </html> "@ $indexContent | Out-File -LiteralPath $indexPath -Encoding UTF8 $manifestPath = Join-Path $OutputDirectory 'wireless-dashboard-pack-manifest.json' ($artifactRows | ConvertTo-Json -Depth 10) | Out-File -LiteralPath $manifestPath -Encoding UTF8 if (-not $IncludeRogues) { foreach ($stale in @( 'wireless-dashboard-rogue-proximity.html', 'wireless-dashboard-rogue-proximity-data.json' )) { $stalePath = Join-Path $OutputDirectory $stale if (Test-Path -LiteralPath $stalePath) { Remove-Item -LiteralPath $stalePath -Force } } } # Clean up old rogue-tables and rogue-variables dashboards (replaced by rogue-proximity) foreach ($stale in @( 'wireless-dashboard-rogue-tables.html', 'wireless-dashboard-rogue-tables-data.json', 'wireless-dashboard-rogue-variables.html', 'wireless-dashboard-rogue-variables-data.json' )) { $stalePath = Join-Path $OutputDirectory $stale if (Test-Path -LiteralPath $stalePath) { Remove-Item -LiteralPath $stalePath -Force } } Write-Host '' Write-Host 'Wireless dashboard pack export complete.' -ForegroundColor Green Write-Host "Dashboards directory: $OutputDirectory" -ForegroundColor Green Write-Host "Index: $indexPath" -ForegroundColor Green Write-Host "Manifest: $manifestPath" -ForegroundColor Green # SIG # Begin signature block # MIIr+wYJKoZIhvcNAQcCoIIr7DCCK+gCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAFxzYU7ut8uvcS # OsLDkWb2UzLJX6H/kpK2Iv6IjY+5sKCCJQ0wggVvMIIEV6ADAgECAhBI/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/mBlXpnYzyOmJRvOwkDynUWICE5EV7WtgwggWNMIIEdaADAgECAhAOmxiO # +dAt5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYD # VQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAi # BgNVBAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAw # MDBaFw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdp # Q2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERp # Z2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC # AgoCggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsb # hA3EMB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iT # cMKyunWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGb # NOsFxl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclP # XuU15zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCr # VYJBMtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFP # ObURWBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTv # kpI6nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWM # cCxBYKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls # 5Q5SUUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBR # a2+xq4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6 # MIIBNjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qY # rhwPTzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8E # BAMCAYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5k # aWdpY2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0 # LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDig # NoY0aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9v # dENBLmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCg # v0NcVec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQT # SnovLbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh # 65ZyoUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSw # uKFWjuyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAO # QGPFmCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjD # TZ9ztwGpn1eqXijiuZQwggYaMIIEAqADAgECAhBiHW0MUgGeO5B5FSCJIRwKMA0G # CSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExp # bWl0ZWQxLTArBgNVBAMTJFNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBSb290 # IFI0NjAeFw0yMTAzMjIwMDAwMDBaFw0zNjAzMjEyMzU5NTlaMFQxCzAJBgNVBAYT # AkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNlY3RpZ28g # UHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwggGiMA0GCSqGSIb3DQEBAQUAA4IB # jwAwggGKAoIBgQCbK51T+jU/jmAGQ2rAz/V/9shTUxjIztNsfvxYB5UXeWUzCxEe # AEZGbEN4QMgCsJLZUKhWThj/yPqy0iSZhXkZ6Pg2A2NVDgFigOMYzB2OKhdqfWGV # oYW3haT29PSTahYkwmMv0b/83nbeECbiMXhSOtbam+/36F09fy1tsB8je/RV0mIk # 8XL/tfCK6cPuYHE215wzrK0h1SWHTxPbPuYkRdkP05ZwmRmTnAO5/arnY83jeNzh # P06ShdnRqtZlV59+8yv+KIhE5ILMqgOZYAENHNX9SJDm+qxp4VqpB3MV/h53yl41 # aHU5pledi9lCBbH9JeIkNFICiVHNkRmq4TpxtwfvjsUedyz8rNyfQJy/aOs5b4s+ # ac7IH60B+Ja7TVM+EKv1WuTGwcLmoU3FpOFMbmPj8pz44MPZ1f9+YEQIQty/NQd/ # 2yGgW+ufflcZ/ZE9o1M7a5Jnqf2i2/uMSWymR8r2oQBMdlyh2n5HirY4jKnFH/9g # Rvd+QOfdRrJZb1sCAwEAAaOCAWQwggFgMB8GA1UdIwQYMBaAFDLrkpr/NZZILyhA # QnAgNpFcF4XmMB0GA1UdDgQWBBQPKssghyi47G9IritUpimqF6TNDDAOBgNVHQ8B # Af8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADATBgNVHSUEDDAKBggrBgEFBQcD # AzAbBgNVHSAEFDASMAYGBFUdIAAwCAYGZ4EMAQQBMEsGA1UdHwREMEIwQKA+oDyG # Omh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVTaWduaW5n # Um9vdFI0Ni5jcmwwewYIKwYBBQUHAQEEbzBtMEYGCCsGAQUFBzAChjpodHRwOi8v # Y3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNDb2RlU2lnbmluZ1Jvb3RSNDYu # cDdjMCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTANBgkqhkiG # 9w0BAQwFAAOCAgEABv+C4XdjNm57oRUgmxP/BP6YdURhw1aVcdGRP4Wh60BAscjW # 4HL9hcpkOTz5jUug2oeunbYAowbFC2AKK+cMcXIBD0ZdOaWTsyNyBBsMLHqafvIh # rCymlaS98+QpoBCyKppP0OcxYEdU0hpsaqBBIZOtBajjcw5+w/KeFvPYfLF/ldYp # mlG+vd0xqlqd099iChnyIMvY5HexjO2AmtsbpVn0OhNcWbWDRF/3sBp6fWXhz7Dc # ML4iTAWS+MVXeNLj1lJziVKEoroGs9Mlizg0bUMbOalOhOfCipnx8CaLZeVme5yE # Lg09Jlo8BMe80jO37PU8ejfkP9/uPak7VLwELKxAMcJszkyeiaerlphwoKx1uHRz # NyE6bxuSKcutisqmKL5OTunAvtONEoteSiabkPVSZ2z76mKnzAfZxCl/3dq3dUNw # 4rg3sTCggkHSRqTqlLMS7gjrhTqBmzu1L90Y1KWN/Y5JKdGvspbOrTfOXyXvmPL6 # E52z1NZJ6ctuMFBQZH3pwWvqURR8AgQdULUvrxjUYbHHj95Ejza63zdrEcxWLDX6 # xWls/GDnVNueKjWUH3fTv1Y8Wdho698YADR7TNx8X8z2Bev6SivBBOHY+uqiirZt # g0y9ShQoPzmCcn63Syatatvx157YK9hlcPmVoa1oDE5/L9Uo2bC5a4CH2RwwggY+ # MIIEpqADAgECAhAHnODk0RR/hc05c892LTfrMA0GCSqGSIb3DQEBDAUAMFQxCzAJ # BgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNl # Y3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwHhcNMjYwMjA5MDAwMDAw # WhcNMjkwNDIxMjM1OTU5WjBVMQswCQYDVQQGEwJVUzEUMBIGA1UECAwLQ29ubmVj # dGljdXQxFzAVBgNVBAoMDkphc29uIEFsYmVyaW5vMRcwFQYDVQQDDA5KYXNvbiBB # bGJlcmlubzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAPN6aN4B1yYW # kI5b5TBj3I0VV/peETrHb6EY4BHGxt8Ap+eT+WpEpJyEtRYPxEmNJL3A38Bkg7mw # zPE3/1NK570ZBCuBjSAn4mSDIgIuXZnvyBO9W1OQs5d67MlJLUAEufl18tOr3ST1 # DeO9gSjQSAE5Nql0QDxPnm93OZBon+Fz3CmE+z3MwAe2h4KdtRAnCqwM+/V7iBdb # w+JOxolpx+7RVjGyProTENIG3pe/hKvPb501lf8uBAADLdjZr5ip8vIWbf857Yw1 # Bu10nVI7HW3eE8Cl5//d1ribHlzTzQLfttW+k+DaFsKZBBL56l4YAlIVRsrOiE1k # dHYYx6IGrEA809R7+TZA9DzGqyFiv9qmJAbL4fDwetDeyIq+Oztz1LvEdy8Rcd0J # BY+J4S0eDEFIA3X0N8VcLeAwabKb9AjulKXwUeqCJLvN79CJ90UTZb2+I+tamj0d # n+IKMEsJ4v4Ggx72sxFr9+6XziodtTg5Luf2xd6+PhhamOxF2px9LObhBLLEMyRs # CHZIzVZOFKu9BpHQH7ufGB+Sa80Tli0/6LEyn9+bMYWi2ttn6lLOPThXMiQaooRU # q6q2u3+F4SaPlxVFLI7OJVMhar6nW6joBvELTJPmANSMjDSRFDfHRCdGbZsL/keE # LJNy+jZctF6VvxQEjFM8/bazu6qYhrA7AgMBAAGjggGJMIIBhTAfBgNVHSMEGDAW # gBQPKssghyi47G9IritUpimqF6TNDDAdBgNVHQ4EFgQU6YF0o0D5AVhKHbVocr8G # aSIBibAwDgYDVR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQCMAAwEwYDVR0lBAwwCgYI # KwYBBQUHAwMwSgYDVR0gBEMwQTA1BgwrBgEEAbIxAQIBAwIwJTAjBggrBgEFBQcC # ARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EMAQQBMEkGA1UdHwRCMEAw # PqA8oDqGOGh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVT # aWduaW5nQ0FSMzYuY3JsMHkGCCsGAQUFBwEBBG0wazBEBggrBgEFBQcwAoY4aHR0 # cDovL2NydC5zZWN0aWdvLmNvbS9TZWN0aWdvUHVibGljQ29kZVNpZ25pbmdDQVIz # Ni5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNlY3RpZ28uY29tMA0GCSqG # SIb3DQEBDAUAA4IBgQAEIsm4xnOd/tZMVrKwi3doAXvCwOA/RYQnFJD7R/bSQRu3 # wXEK4o9SIefye18B/q4fhBkhNAJuEvTQAGfqbbpxow03J5PrDTp1WPCWbXKX8Oz9 # vGWJFyJxRGftkdzZ57JE00synEMS8XCwLO9P32MyR9Z9URrpiLPJ9rQjfHMb1BUd # vaNayomm7aWLAnD+X7jm6o8sNT5An1cwEAob7obWDM6sX93wphwJNBJAstH9Ozs6 # LwISOX6sKS7CKm9N3Kp8hOUue0ZHAtZdFl6o5u12wy+zzieGEI50fKnN77FfNKFO # WKlS6OJwlArcbFegB5K89LcE5iNSmaM3VMB2ADV1FEcjGSHw4lTg1Wx+WMAMdl/7 # nbvfFxJ9uu5tNiT54B0s+lZO/HztwXYQUczdsFon3pjsNrsk9ZlalBi5SHkIu+F6 # g7tWiEv3rtVApmJRnLkUr2Xq2a4nbslUCt4jKs5UX4V1nSX8OM++AXoyVGO+iTj7 # z+pl6XE9Gw/Td6WKKKswgga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0G # CSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ # bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 # IFRydXN0ZWQgUm9vdCBHNDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTla # MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE # AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEy # NTYgMjAyNSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHT # CphBcr48RsAcrHXbo0ZodLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPh # of6pvF4uGjwjqNjfEvUi6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mA # xAHeHYNnQxqXmRinvuNgxVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBv # MgEdgkFiDNYiOTx4OtiFcMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps # 0wjUjsZvkgFkriK9tUKJm/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF # 83bRVFLeGkuAhHiGPMvSGmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXi # UOeSLsJygoLPp66bkDX1ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOM # CZIVNSaz7BX8VtYGqLt9MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydP # pOjL6s36czwzsucuoKs7Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrU # G2ZdSoQbU2rMkpLiQ6bGRinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+ # sdFUeEY0qVjPKOWug/G6X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0T # AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYD # VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG # A1UdJQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY # aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj # ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV # HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU # cnVzdGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1s # BwEwDQYJKoZIhvcNAQELBQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WI # GjB/T8ObXAZz8OjuhUxjaaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+ # IQhQE7jU/kXjjytJgnn0hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8M # yb9rEVKChHyfpzee5kH0F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2 # th9y1IsA0QF8dTXqvcnTmpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjaj # V/gxdEkMx1NKU4uHQcKfZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2 # Lr3ty9qIijanrUR3anzEwlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFze # GxcytL5TTLL4ZaoBdqbhOhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG # 7uEBYTptMSbhdhGQDpOXgpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+N # Jpud/v4+7RWsWCiKi9EOLLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckT # etiSuEtQvLsNz3Qbp7wGWqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszW # kPZPubdcMIIG7TCCBNWgAwIBAgIQCoDvGEuN8QWC0cR2p5V0aDANBgkqhkiG9w0B # AQsFADBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/ # BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYg # U0hBMjU2IDIwMjUgQ0ExMB4XDTI1MDYwNDAwMDAwMFoXDTM2MDkwMzIzNTk1OVow # YzELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQD # EzJEaWdpQ2VydCBTSEEyNTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIw # MjUgMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANBGrC0Sxp7Q6q5g # VrMrV7pvUf+GcAoB38o3zBlCMGMyqJnfFNZx+wvA69HFTBdwbHwBSOeLpvPnZ8ZN # +vo8dE2/pPvOx/Vj8TchTySA2R4QKpVD7dvNZh6wW2R6kSu9RJt/4QhguSssp3qo # me7MrxVyfQO9sMx6ZAWjFDYOzDi8SOhPUWlLnh00Cll8pjrUcCV3K3E0zz09ldQ/ # /nBZZREr4h/GI6Dxb2UoyrN0ijtUDVHRXdmncOOMA3CoB/iUSROUINDT98oksouT # MYFOnHoRh6+86Ltc5zjPKHW5KqCvpSduSwhwUmotuQhcg9tw2YD3w6ySSSu+3qU8 # DD+nigNJFmt6LAHvH3KSuNLoZLc1Hf2JNMVL4Q1OpbybpMe46YceNA0LfNsnqcnp # JeItK/DhKbPxTTuGoX7wJNdoRORVbPR1VVnDuSeHVZlc4seAO+6d2sC26/PQPdP5 # 1ho1zBp+xUIZkpSFA8vWdoUoHLWnqWU3dCCyFG1roSrgHjSHlq8xymLnjCbSLZ49 # kPmk8iyyizNDIXj//cOgrY7rlRyTlaCCfw7aSUROwnu7zER6EaJ+AliL7ojTdS5P # WPsWeupWs7NpChUk555K096V1hE0yZIXe+giAwW00aHzrDchIc2bQhpp0IoKRR7Y # ufAkprxMiXAJQ1XCmnCfgPf8+3mnAgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAA # MB0GA1UdDgQWBBTkO/zyMe39/dfzkXFjGVBDz2GM6DAfBgNVHSMEGDAWgBTvb1NK # 6eQGfHrK4pBW9i/USezLTjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYI # KwYBBQUHAwgwgZUGCCsGAQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8v # b2NzcC5kaWdpY2VydC5jb20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRp # Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZT # SEEyNTYyMDI1Q0ExLmNydDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5k # aWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2 # U0hBMjU2MjAyNUNBMS5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9 # bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQBlKq3xHCcEua5gQezRCESeY0ByIfjk9iJP # 2zWLpQq1b4URGnwWBdEZD9gBq9fNaNmFj6Eh8/YmRDfxT7C0k8FUFqNh+tshgb4O # 6Lgjg8K8elC4+oWCqnU/ML9lFfim8/9yJmZSe2F8AQ/UdKFOtj7YMTmqPO9mzskg # iC3QYIUP2S3HQvHG1FDu+WUqW4daIqToXFE/JQ/EABgfZXLWU0ziTN6R3ygQBHMU # BaB5bdrPbF6MRYs03h4obEMnxYOX8VBRKe1uNnzQVTeLni2nHkX/QqvXnNb+YkDF # kxUGtMTaiLR9wjxUxu2hECZpqyU1d0IbX6Wq8/gVutDojBIFeRlqAcuEVT0cKsb+ # zJNEsuEB7O7/cuvTQasnM9AWcIQfVjnzrvwiCZ85EE8LUkqRhoS3Y50OHgaY7T/l # wd6UArb+BOVAkg2oOvol/DJgddJ35XTxfUlQ+8Hggt8l2Yv7roancJIFcbojBcxl # RcGG0LIhp6GvReQGgMgYxQbV1S3CrWqZzBt1R9xJgKf47CdxVRd/ndUlQ05oxYy2 # zRWVFjF7mcr4C34Mj3ocCVccAvlKV9jEnstrniLvUxxVZE/rptb7IRE2lskKPIJg # baP5t2nGj/ULLi49xTcBZU8atufk+EMF/cWuiC7POGT75qaL6vdCvHlshtjdNXOC # IUjsarfNZzGCBkQwggZAAgEBMGgwVDELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1Nl # Y3RpZ28gTGltaXRlZDErMCkGA1UEAxMiU2VjdGlnbyBQdWJsaWMgQ29kZSBTaWdu # aW5nIENBIFIzNgIQB5zg5NEUf4XNOXPPdi036zANBglghkgBZQMEAgEFAKCBhDAY # BgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3 # AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEi # BCCtF1UJTm3dpXEarbCXCida/8+CNp1dzFZ0wfy6rlXZkTANBgkqhkiG9w0BAQEF # AASCAgBYBzuwXSOSh7ZHzptofoSPrBL4BqgGcAxRJ08GNGGzQHntgSGhuZrEkvbQ # tqfiPRfGPs8MWDJUNe0uM+1OGaeOL6g1YltfhONnOJ5DLTJkw3KlVUrAKTYEfUkg # UoFSgrng7zBCGsKXFVqNvJF1C3hn9rK1IHdtAO6pVe8UxE053XTQGZY1b/be/2Im # 0oxwigy/Pp+w+ND5RE2ktiEq0OJh7wI43GsJBK8O8d1xMw1eLcZz86Dlc2HZy7Jf # PKJqplZrzhoQEJkSVlScJMqJU0uwwWDwxiHna1/Nzx22+PQOVbyTsy4+qKgZoCcZ # ND3Q+0FbtTDdOfBfFdzQM8oiCTPdVvw8AbZyFkegAJHK823JNtyDWeqvr3n9Ugvj # nhDLrM1ZH7nTbdZLw/5N31QiCw2Y6++jUJaj7RcvpMTu9ETfl8HovbeFdeRpG8Cq # 9oIw48PRHGSVQf68aUHww0jMb16epn+hxjSwXkZXv2xKb+21ju92JMIVH2sbq8kD # Kgn6J2k4CnioLQiNzD/ImwsueVoU/WTQEBXNqlTQyfuu7AO+LCWWJKSqTXP7bTnj # QU7Ls7K7TT7b5sy2mFcNAcZCmnx5RIE+oWOzrCIBBVV6CYBwXLmeArqqgH9qUBmp # QaDoJBDCxnEtalq77WuJkoXiMiqrm7HW/Su8gGs2wV9z3Vbla6GCAyYwggMiBgkq # hkiG9w0BCQYxggMTMIIDDwIBATB9MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5E # aWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1l # U3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTECEAqA7xhLjfEFgtHEdqeV # dGgwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwG # CSqGSIb3DQEJBTEPFw0yNjA2MjkxNDEzMjFaMC8GCSqGSIb3DQEJBDEiBCDZmP8A # 6JYTzh8XAjmnjPGAzjk3tX5+mUlwS4r/wAT1XTANBgkqhkiG9w0BAQEFAASCAgB7 # c1uQFK4XbB7G9Ju+acJBVTrxVl0R7aiinFjZNd2t2oz1Osx0kxJmpYVlNl21aNRh # BRT+Pa/eMHA6VVZbY/mkg1fukCdEUg00DWUj5cZIginsH2OOAtwQNbfT+/EF0m5Z # cC83Z5Qq/mdPjfrlh6hmMQezwuCbz+J61BTXIDdY/qmle+nRT42bwsnTB5fSehqQ # XsPIU1n+I5fwo+oGA28sdggc7eQsX2sNJLVCYGPYN8w4pZwOrp07DNq31s+Iek6w # ZYAwmQamZq5nruNGaeW7S4VmuZiEo5sbmHHmU9gajAozKF5c0aYynDGbV8in8fuN # A20XjVBX/ZFtv5mBV3q5kU4mTcwYHJ+8halqkSMiRkn7SOA4BIaT8xFQQu8dfOOT # mf9Ttnsx3d5rILD00aaS+NpDtV8blnHbBKCrTDevrJN7ZsODR3Y9ofcCI7js51N1 # Y9ZPlriF3tsiBO0YeQWSdo3ohxV7LpuW+LDWClnahfsNh2KOBvQD2CLKoEoqaNVp # G2G2o+6r6auMM6KjVnKrwji7J94fe0DUp6zg9HK1pmi84QFhLLHRS8JJ1dGia1dZ # U3wZR6nuXg0ERrOmTz3GvqOQXzRxHw5HGRf1V00ALOEYqToEwMj2U8euL4K45xA3 # fsmez3caRYavoW31BU5AQBneIINa60DNJwnNp7WuGw== # SIG # End signature block |