Public/Export-DhDashboard.ps1
|
function Export-DhDashboard { <# .SYNOPSIS Write the self-contained HTML dashboard to disk. .DESCRIPTION Generates a single self-contained HTML file. Both the light and dark CSS themes are embedded directly inside the HTML — no external CSS file is ever written. A toggle button in the nav bar lets viewers switch themes at runtime. The HTML also embeds: - All table data as JSON (no server round-trips) - The full JavaScript table engine (sort / filter / page / link / export) - The logo as a Base64 data URI (if supplied to New-DhDashboard) External JS libraries referenced from cdnjs (internet required for export): - SheetJS xlsx v0.18.5 - XLSX export - jsPDF v2.5.1 - PDF export - jsPDF-AutoTable v3.8.2 - PDF table formatting If the machine opening the report has no internet access, CSV export (pure JS, no CDN dependency) continues to work; XLSX and PDF buttons will show an alert. .PARAMETER Report Dashboard object built with New-DhDashboard / Add-DhTable / Set-DhTableLink. .PARAMETER OutputPath Full path for the HTML file (e.g. C:\Reports\dashboard.html). .PARAMETER Force Overwrite an existing file without prompting. .PARAMETER OpenInBrowser Attempt to open the dashboard in the default browser after writing. .EXAMPLE Export-DhDashboard -Report $report -OutputPath 'C:\Reports\dashboard.html' -Force Export-DhDashboard -Report $report -OutputPath '.\report.html' -Force -OpenInBrowser #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary] $Report, [Parameter(Mandatory)] [string] $OutputPath, [switch] $Force, [switch] $OpenInBrowser ) # Resolve module version dynamically so the HTML footer stays accurate after bumps $moduleVersion = if ($MyInvocation.MyCommand.Module) { $MyInvocation.MyCommand.Module.Version.ToString() } else { '1.0.0' } # Resolve paths $OutputPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) $outDir = Split-Path $OutputPath -Parent if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null Write-Verbose "Export-DhDashboard: Created directory '$outDir'." } # ---- Resolve themes (light = primary, dark = alternate, always embedded) ---- $theme = if ($Report.Contains('Theme')) { $Report.Theme } else { 'DefaultLight' } $alternateTheme = if ($Report.Contains('AlternateTheme')) { $Report.AlternateTheme } else { 'DefaultDark' } $themeFamily = if ($Report.Contains('ThemeFamily')) { $Report.ThemeFamily } else { 'Default' } Write-Verbose "Export-DhDashboard: [CSS ] Themes embedded: $theme (light) + $alternateTheme (dark)" # ---- Build table JSON --------------------------------------------------------- $jsConfigBlocks = foreach ($t in $Report.Tables) { # Columns -> JSON $colsJson = ($t.Columns | ForEach-Object { $col = $_ $w = if ($col.Contains('Width') -and $col.Width) { ",`"width`":$(ConvertTo-DhJsonString $col.Width)" } else { '' } $s = if ($col.Contains('Sortable')) { ",`"sortable`":$(([bool]$col.Sortable).ToString().ToLower())" } else { '' } $ct = if ($col.Contains('CellType') -and $col.CellType){ ",`"cellType`":$(ConvertTo-DhJsonString $col.CellType)" } else { '' } $pm = if ($col.Contains('ProgressMax')) { ",`"progressMax`":$($col.ProgressMax)" } else { '' } $bl = if ($col.Contains('Bold')) { ",`"bold`":$(([bool]$col.Bold).ToString().ToLower())" } else { '' } $it = if ($col.Contains('Italic')) { ",`"italic`":$(([bool]$col.Italic).ToString().ToLower())" } else { '' } $fn = if ($col.Contains('Font') -and $col.Font) { ",`"font`":$(ConvertTo-DhJsonString $col.Font)" } else { '' } $al = if ($col.Contains('Align') -and $col.Align) { ",`"align`":$(ConvertTo-DhJsonString $col.Align)" } else { '' } $fo = if ($col.Contains('Format') -and $col.Format) { ",`"format`":$(ConvertTo-DhJsonString $col.Format)" } else { '' } $lc = if ($col.Contains('Locale') -and $col.Locale) { ",`"locale`":$(ConvertTo-DhJsonString $col.Locale)" } else { '' } $dc = if ($col.Contains('Decimals') -and $col.Decimals -ge 0) { ",`"decimals`":$($col.Decimals)" } else { '' } $cu = if ($col.Contains('Currency') -and $col.Currency) { ",`"currency`":$(ConvertTo-DhJsonString $col.Currency)" } else { '' } $dp = if ($col.Contains('DatePattern') -and $col.DatePattern) { ",`"datePattern`":$(ConvertTo-DhJsonString $col.DatePattern)" } else { '' } $rh = if ($col.Contains('RowHighlight')) { ",`"rowHighlight`":$(([bool]$col.RowHighlight).ToString().ToLower())" } else { '' } $pf = if ($col.Contains('PinFirst') -and $col.PinFirst) { ',"pinFirst":true' } else { '' } $agg = if ($col.Contains('Aggregate') -and $col.Aggregate) { ",`"aggregate`":$(ConvertTo-DhJsonString $col.Aggregate)" } else { '' } # ragNoData flag (v1.4 F13) — tells the JS matcher to paint null/missing # cells with cell-nodata when the column was declared with -Rag NoData=$true $rnd = if ($col.Contains('RagNoData') -and $col.RagNoData) { ',"ragNoData":true' } else { '' } # v1.4 F8 — sparkline cell column extras (sortBy + sparklineStyle) $sb = if ($col.Contains('SortBy') -and $col.SortBy) { ",`"sortBy`":$(ConvertTo-DhJsonString $col.SortBy)" } else { '' } $sps = if ($col.Contains('SparklineStyle') -and $col.SparklineStyle) { ",`"sparklineStyle`":$(ConvertTo-DhJsonString $col.SparklineStyle)" } else { '' } # Thresholds — supports both numeric (Min/Max) and string (Value) rules $thJson = '' if ($col.Contains('Thresholds') -and $col.Thresholds -and $col.Thresholds.Count -gt 0) { $thEntries = ($col.Thresholds | ForEach-Object { $th = $_ # String threshold: Value key (exact match in JS) if ($th.Contains('Value') -and $null -ne $th.Value) { $tCls = ConvertTo-DhJsonString ([string]$th.Class) $tVal = ConvertTo-DhJsonString ([string]$th.Value) "{`"value`":$tVal,`"class`":$tCls}" } else { # Numeric threshold: Min/Max $tMin = if ($th.Contains('Min') -and $null -ne $th.Min) { "`"min`":$($th.Min)," } else { '' } $tMax = if ($th.Contains('Max') -and $null -ne $th.Max) { "`"max`":$($th.Max)," } else { '' } $tCls = ConvertTo-DhJsonString ([string]$th.Class) "{$tMin$tMax`"class`":$tCls}" } }) -join ',' $thJson = ",`"thresholds`":[$thEntries]" } "{`"field`":$(ConvertTo-DhJsonString $col.Field),`"label`":$(ConvertTo-DhJsonString $col.Label)$w$s$ct$pm$bl$it$fn$al$fo$lc$dc$cu$dp$rh$pf$agg$rnd$sb$sps$thJson}" }) -join ',' # Rows -> JSON $dataJson = ($t.Data | ForEach-Object { $row = $_ $props = ($row.Keys | ForEach-Object { $k = $_ $v = $row[$k] $vs = if ($null -eq $v) { 'null' } elseif ($v -is [bool]) { $v.ToString().ToLower() } elseif ($v -is [int] -or $v -is [long] -or $v -is [double] -or $v -is [float] -or $v -is [decimal]) { [string]$v } elseif ($v -is [string]) { ConvertTo-DhJsonString $v } elseif ($v -is [System.Collections.IEnumerable]) { # v1.4 F8 — arrays preserve their structure so 'sparkline' cell # types can render an inline mini-chart. Each element is either # a number (emitted bare) or null (for line breaks); anything # else collapses to its string form for fidelity. $items = foreach ($el in $v) { if ($null -eq $el) { 'null' } elseif ($el -is [bool]) { $el.ToString().ToLower() } elseif ($el -is [int] -or $el -is [long] -or $el -is [double] -or $el -is [float] -or $el -is [decimal]) { [string]$el } else { $nNum = 0.0 if ([double]::TryParse( [string]$el, [System.Globalization.NumberStyles]::Float -bor [System.Globalization.NumberStyles]::AllowThousands, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$nNum)) { [string]$nNum } else { ConvertTo-DhJsonString ([string]$el) } } } "[$($items -join ',')]" } else { ConvertTo-DhJsonString ([string]$v) } "$(ConvertTo-DhJsonString $k):$vs" }) -join ',' "{$props}" }) -join ',' # outLinks for this master table $outLinks = ($Report.Links | Where-Object { $_.MasterTableId -eq $t.Id } | ForEach-Object { "{`"detailTableId`":$(ConvertTo-DhJsonString $_.DetailTableId),`"masterField`":$(ConvertTo-DhJsonString $_.MasterField),`"detailField`":$(ConvertTo-DhJsonString $_.DetailField)}" }) -join ',' # Charts -> JSON $chartsJson = '' if ($t.Contains('Charts') -and $t.Charts.Count -gt 0) { $chartsJson = ($t.Charts | ForEach-Object { $ch = $_ $chType = if ($ch.Contains('Type') -and $ch.Type) { $ch.Type } else { 'pie' } "{`"title`":$(ConvertTo-DhJsonString $ch.Title),`"field`":$(ConvertTo-DhJsonString $ch.Field),`"type`":$(ConvertTo-DhJsonString $chType)}" }) -join ',' } # Full table config object $expFn = if ($t.Contains('ExportFileName') -and $t.ExportFileName) { ConvertTo-DhJsonString $t.ExportFileName } else { ConvertTo-DhJsonString $t.Id } [string]::Format( '{{"id":{0},"title":{1},"description":{2},"pageSize":{3},"multiSelect":{4},"filterable":{5},"pageable":{6},"exportFileName":{7},"columns":[{8}],"data":[{9}],"outLinks":[{10}],"charts":[{11}]}}', (ConvertTo-DhJsonString $t.Id), (ConvertTo-DhJsonString $t.Title), (ConvertTo-DhJsonString $t.Description), $t.PageSize, $t.MultiSelect.ToString().ToLower(), $t.Filterable.ToString().ToLower(), $t.Pageable.ToString().ToLower(), $expFn, $colsJson, $dataJson, $outLinks, $chartsJson ) } $tablesConfigJson = "[$( $jsConfigBlocks -join ',' )]" # ---- Build summary JSON -------------------------------------------------------- $summaryConfigJson = '[]' if ($Report.Contains('Summary') -and $Report.Summary.Count -gt 0) { $summaryItems = ($Report.Summary | ForEach-Object { $s = $_ $val = if ($null -eq $s.Value) { 'null' } ` elseif ($s.Value -is [bool]) { $s.Value.ToString().ToLower() } ` elseif ($s.Value -is [int] -or $s.Value -is [long] -or $s.Value -is [double] -or $s.Value -is [float] -or $s.Value -is [decimal]) { [string]$s.Value } ` else { ConvertTo-DhJsonString ([string]$s.Value) } $dec = if ($s.Contains('Decimals') -and $s.Decimals -ge 0) { $s.Decimals } else { -1 } # Trend / delta (v1.4 F1) — emit only when Previous or an explicit Trend is supplied $deltaJson = '' $hasPrev = $s.Contains('Previous') -and $null -ne $s.Previous $hasTrend = $s.Contains('Trend') -and $s.Trend -ne 'auto' if ($hasPrev -or $hasTrend) { $prevJson = if ($hasPrev) { [string]$s.Previous } else { 'null' } $trendStr = if ($s.Contains('Trend') -and $s.Trend) { [string]$s.Trend } else { 'auto' } $tigJson = if ($null -ne $s.TrendIsGood) { ([bool]$s.TrendIsGood).ToString().ToLower() } else { 'null' } $dfStr = if ($s.Contains('DeltaFormat') -and $s.DeltaFormat) { [string]$s.DeltaFormat } else { 'both' } $deltaJson = ",`"previous`":$prevJson,`"trend`":$(ConvertTo-DhJsonString $trendStr),`"trendIsGood`":$tigJson,`"deltaFormat`":$(ConvertTo-DhJsonString $dfStr)" } # Sparkline (v1.4 F2) — emit only when the series is non-empty $sparkJson = '' if ($s.Contains('Sparkline') -and $s.Sparkline -and @($s.Sparkline).Count -gt 0) { $sparkValues = ((@($s.Sparkline) | ForEach-Object { [string]$_ }) -join ',') $sparkMinJson = if ($null -ne $s.SparklineMin) { [string]$s.SparklineMin } else { 'null' } $sparkMaxJson = if ($null -ne $s.SparklineMax) { [string]$s.SparklineMax } else { 'null' } $sparkStyle = if ($s.Contains('SparklineStyle') -and $s.SparklineStyle) { [string]$s.SparklineStyle } else { 'line' } $sparkJson = ",`"sparkline`":[$sparkValues],`"sparklineMin`":$sparkMinJson,`"sparklineMax`":$sparkMaxJson,`"sparklineStyle`":$(ConvertTo-DhJsonString $sparkStyle)" } # Tile style (v1.4 F3 + F4) — always emit; default 'tile' preserves the existing renderer $styleStr = if ($s.Contains('Style') -and $s.Style) { [string]$s.Style } else { 'tile' } $styleJson = ",`"style`":$(ConvertTo-DhJsonString $styleStr)" # Bignumber caption (only when style is bignumber and caption is set) $captionJson = '' if ($styleStr -eq 'bignumber' -and $s.Contains('Caption') -and -not [string]::IsNullOrWhiteSpace([string]$s.Caption)) { $captionJson = ",`"caption`":$(ConvertTo-DhJsonString $s.Caption)" } # Gauge fields (only when style is gauge) $gaugeJson = '' if ($styleStr -eq 'gauge') { $minJson = if ($null -ne $s.Min) { [string]$s.Min } else { '0' } $maxJson = if ($null -ne $s.Max) { [string]$s.Max } else { '100' } $unitStr = if ($s.Contains('Unit') -and $s.Unit) { [string]$s.Unit } else { '' } $thJson = '' if ($s.Contains('Thresholds') -and @($s.Thresholds).Count -gt 0) { $thItems = (@($s.Thresholds) | ForEach-Object { $th = $_ $minPart = if ($th.Contains('Min')) { "`"min`":$([string]$th.Min)," } else { '' } $maxPart = if ($th.Contains('Max')) { "`"max`":$([string]$th.Max)," } else { '' } "{$minPart$maxPart`"class`":$(ConvertTo-DhJsonString $th.Class)}" }) -join ',' $thJson = ",`"thresholds`":[$thItems]" } else { $thJson = ',"thresholds":[]' } # ragNoData (v1.4 F13) — when true the gauge paints itself cell-nodata # if the value is null/NaN or outside [min,max] $rndJson = if ($s.Contains('RagNoData') -and $s.RagNoData) { ',"ragNoData":true' } else { '' } $gaugeJson = ",`"min`":$minJson,`"max`":$maxJson,`"unit`":$(ConvertTo-DhJsonString $unitStr)$thJson$rndJson" } # Per-tile Action (v1.4.2 — click-to-jump) — emit only when set $actionJson = '' if ($s.Contains('Action') -and $null -ne $s.Action) { $a = $s.Action $actionJson = ",`"action`":{`"label`":$(ConvertTo-DhJsonString $a.Label),`"tableId`":$(ConvertTo-DhJsonString $a.TableId),`"filter`":$(ConvertTo-DhJsonString $a.Filter),`"url`":$(ConvertTo-DhJsonString $a.Url)}" } "{`"label`":$(ConvertTo-DhJsonString $s.Label),`"value`":$val,`"icon`":$(ConvertTo-DhJsonString $s.Icon),`"subLabel`":$(ConvertTo-DhJsonString $s.SubLabel),`"class`":$(ConvertTo-DhJsonString $s.Class),`"format`":$(ConvertTo-DhJsonString $s.Format),`"locale`":$(ConvertTo-DhJsonString $s.Locale),`"decimals`":$dec,`"currency`":$(ConvertTo-DhJsonString $s.Currency)$styleJson$captionJson$gaugeJson$deltaJson$sparkJson$actionJson}" }) -join ',' $summaryConfigJson = "[$summaryItems]" } # ---- Build blocks JSON --------------------------------------------------------- $blocksConfigJson = '[]' if ($Report.Contains('Blocks') -and $Report.Blocks.Count -gt 0) { $blockItems = ($Report.Blocks | ForEach-Object { $b = $_ $bt = ConvertTo-DhJsonString $b.BlockType # v1.5.1 — central Collapsible / DefaultOpen flags shared by every block emit $bCollStr = if ($b.Contains('Collapsible')) { ([bool]$b.Collapsible).ToString().ToLower() } else { 'false' } $bDoStr = if ($b.Contains('DefaultOpen')) { ([bool]$b.DefaultOpen).ToString().ToLower() } else { 'true' } switch ($b.BlockType) { 'html' { "{`"blockType`":$bt,`"id`":$(ConvertTo-DhJsonString $b.Id),`"title`":$(ConvertTo-DhJsonString $b.Title),`"icon`":$(ConvertTo-DhJsonString $b.Icon),`"content`":$(ConvertTo-DhJsonString $b.Content),`"style`":$(ConvertTo-DhJsonString $b.Style),`"collapsible`":$bCollStr,`"defaultOpen`":$bDoStr}" } 'collapsible' { $cardsJson = ($b.Cards | ForEach-Object { $card = $_ $fieldsJson = ($card.Fields | ForEach-Object { "{`"label`":$(ConvertTo-DhJsonString $_.Label),`"value`":$(ConvertTo-DhJsonString $_.Value),`"class`":$(ConvertTo-DhJsonString $_.Class)}" }) -join ',' "{`"title`":$(ConvertTo-DhJsonString $card.Title),`"badge`":$(ConvertTo-DhJsonString $card.Badge),`"badgeClass`":$(ConvertTo-DhJsonString $card.BadgeClass),`"fields`":[$fieldsJson]}" }) -join ',' $openStr = $b.DefaultOpen.ToString().ToLower() $cwStr = if ($b.Contains('CardWidth') -and $b.CardWidth) { ConvertTo-DhJsonString $b.CardWidth } else { '"normal"' } "{`"blockType`":$bt,`"id`":$(ConvertTo-DhJsonString $b.Id),`"title`":$(ConvertTo-DhJsonString $b.Title),`"icon`":$(ConvertTo-DhJsonString $b.Icon),`"defaultOpen`":$openStr,`"badge`":$($b.Badge),`"cards`":[$cardsJson],`"content`":$(ConvertTo-DhJsonString $b.Content),`"cardWidth`":$cwStr}" } 'filtercardgrid' { $fcJson = ($b.Cards | ForEach-Object { $c = $_ $cnt = if ($null -eq $c.Count) { 'null' } else { [string]$c.Count } "{`"label`":$(ConvertTo-DhJsonString $c.Label),`"value`":$(ConvertTo-DhJsonString $c.Value),`"subLabel`":$(ConvertTo-DhJsonString $c.SubLabel),`"count`":$cnt,`"icon`":$(ConvertTo-DhJsonString $c.Icon)}" }) -join ',' $mf = $b.MultiFilter.ToString().ToLower() $sc = $b.ShowCount.ToString().ToLower() "{`"blockType`":$bt,`"id`":$(ConvertTo-DhJsonString $b.Id),`"title`":$(ConvertTo-DhJsonString $b.Title),`"targetTableId`":$(ConvertTo-DhJsonString $b.TargetTableId),`"filterField`":$(ConvertTo-DhJsonString $b.FilterField),`"multiFilter`":$mf,`"showCount`":$sc,`"cards`":[$fcJson],`"collapsible`":$bCollStr,`"defaultOpen`":$bDoStr}" } 'barchart' { $spStr = $b.ShowPercent.ToString().ToLower() $scStr = $b.ShowCount.ToString().ToLower() $cfStr = $b.ClickFilters.ToString().ToLower() # v1.4 F12 — stacked mode adds seriesField + stacked flag $stStr = if ($b.Contains('Stacked')) { ([bool]$b.Stacked).ToString().ToLower() } else { 'false' } $sfStr = if ($b.Contains('SeriesField') -and $b.SeriesField) { [string]$b.SeriesField } else { '' } $stacked = ",`"stacked`":$stStr,`"seriesField`":$(ConvertTo-DhJsonString $sfStr)" "{`"blockType`":$bt,`"id`":$(ConvertTo-DhJsonString $b.Id),`"title`":$(ConvertTo-DhJsonString $b.Title),`"tableId`":$(ConvertTo-DhJsonString $b.TableId),`"field`":$(ConvertTo-DhJsonString $b.Field),`"topN`":$($b.TopN),`"showCount`":$scStr,`"showPercent`":$spStr,`"clickFilters`":$cfStr$stacked,`"collapsible`":$bCollStr,`"defaultOpen`":$bDoStr}" } 'heatmap' { # v1.5 — rows × columns matrix with sequential / diverging colour scale. $rowsJ = (@($b.Rows) | ForEach-Object { ConvertTo-DhJsonString ([string]$_) }) -join ',' $columnsJ = (@($b.Columns) | ForEach-Object { ConvertTo-DhJsonString ([string]$_) }) -join ',' $cellsJ = '' if ($b.Cells -and @($b.Cells).Count -gt 0) { $cellsJ = (@($b.Cells) | ForEach-Object { $hc = $_ "{`"row`":$(ConvertTo-DhJsonString $hc.Row),`"column`":$(ConvertTo-DhJsonString $hc.Column),`"value`":$([string]$hc.Value),`"tooltip`":$(ConvertTo-DhJsonString $hc.Tooltip)}" }) -join ',' } $minJ = if ($null -ne $b.Min) { [string]$b.Min } else { 'null' } $maxJ = if ($null -ne $b.Max) { [string]$b.Max } else { 'null' } $svStr = ([bool]$b.ShowValues).ToString().ToLower() "{`"blockType`":$bt,`"id`":$(ConvertTo-DhJsonString $b.Id),`"title`":$(ConvertTo-DhJsonString $b.Title),`"rows`":[$rowsJ],`"columns`":[$columnsJ],`"cells`":[$cellsJ],`"rowLabel`":$(ConvertTo-DhJsonString $b.RowLabel),`"columnLabel`":$(ConvertTo-DhJsonString $b.ColumnLabel),`"colorScale`":$(ConvertTo-DhJsonString $b.ColorScale),`"min`":$minJ,`"max`":$maxJ,`"unit`":$(ConvertTo-DhJsonString $b.Unit),`"cellSize`":$(ConvertTo-DhJsonString $b.CellSize),`"showValues`":$svStr,`"collapsible`":$bCollStr,`"defaultOpen`":$bDoStr}" } 'tabs' { # v1.5 — inline tabbed content block. $tabsJson = '' if ($b.Tabs -and @($b.Tabs).Count -gt 0) { $tabsJson = (@($b.Tabs) | ForEach-Object { $tb = $_ $actStr = ([bool]$tb.Active).ToString().ToLower() "{`"title`":$(ConvertTo-DhJsonString $tb.Title),`"content`":$(ConvertTo-DhJsonString $tb.Content),`"icon`":$(ConvertTo-DhJsonString $tb.Icon),`"active`":$actStr}" }) -join ',' } "{`"blockType`":$bt,`"id`":$(ConvertTo-DhJsonString $b.Id),`"title`":$(ConvertTo-DhJsonString $b.Title),`"tabs`":[$tabsJson],`"collapsible`":$bCollStr,`"defaultOpen`":$bDoStr}" } 'topologymap' { # v1.5 — parent/child tree visualisation. Each node carries its # id, label, parent id (or '' for root), optional status / icon / # badge / tooltip, plus optional LinkTableId / LinkFilter for # click-to-jump navigation. $nodesJson = '' if ($b.Nodes -and @($b.Nodes).Count -gt 0) { $nodesJson = (@($b.Nodes) | ForEach-Object { $tn = $_ "{`"id`":$(ConvertTo-DhJsonString $tn.Id),`"label`":$(ConvertTo-DhJsonString $tn.Label),`"parent`":$(ConvertTo-DhJsonString $tn.Parent),`"status`":$(ConvertTo-DhJsonString $tn.Status),`"icon`":$(ConvertTo-DhJsonString $tn.Icon),`"badge`":$(ConvertTo-DhJsonString $tn.Badge),`"tooltip`":$(ConvertTo-DhJsonString $tn.Tooltip),`"linkTableId`":$(ConvertTo-DhJsonString $tn.LinkTableId),`"linkFilter`":$(ConvertTo-DhJsonString $tn.LinkFilter)}" }) -join ',' } $afStr = if ($b.Contains('AutoFitWidth')) { ([bool]$b.AutoFitWidth).ToString().ToLower() } else { 'false' } $afMaxV = if ($b.Contains('AutoFitMaxWidth') -and $b.AutoFitMaxWidth) { [string]$b.AutoFitMaxWidth } else { '360' } "{`"blockType`":$bt,`"id`":$(ConvertTo-DhJsonString $b.Id),`"title`":$(ConvertTo-DhJsonString $b.Title),`"nodes`":[$nodesJson],`"direction`":$(ConvertTo-DhJsonString $b.Direction),`"nodeWidth`":$($b.NodeWidth),`"nodeHeight`":$($b.NodeHeight),`"autoFitWidth`":$afStr,`"autoFitMaxWidth`":$afMaxV,`"collapsible`":$bCollStr,`"defaultOpen`":$bDoStr}" } 'statusgrid' { # v1.4 F5 — rows × columns RAG matrix. $rowsJson = (@($b.Rows) | ForEach-Object { ConvertTo-DhJsonString ([string]$_) }) -join ',' $columnsJson = (@($b.Columns) | ForEach-Object { ConvertTo-DhJsonString ([string]$_) }) -join ',' $cellsJson = '' if ($b.Cells -and @($b.Cells).Count -gt 0) { $cellsJson = (@($b.Cells) | ForEach-Object { $cell = $_ "{`"row`":$(ConvertTo-DhJsonString $cell.Row),`"column`":$(ConvertTo-DhJsonString $cell.Column),`"status`":$(ConvertTo-DhJsonString $cell.Status),`"tooltip`":$(ConvertTo-DhJsonString $cell.Tooltip),`"linkTableId`":$(ConvertTo-DhJsonString $cell.LinkTableId),`"linkFilter`":$(ConvertTo-DhJsonString $cell.LinkFilter)}" }) -join ',' } "{`"blockType`":$bt,`"id`":$(ConvertTo-DhJsonString $b.Id),`"title`":$(ConvertTo-DhJsonString $b.Title),`"rows`":[$rowsJson],`"columns`":[$columnsJson],`"cells`":[$cellsJson],`"rowLabel`":$(ConvertTo-DhJsonString $b.RowLabel),`"columnLabel`":$(ConvertTo-DhJsonString $b.ColumnLabel),`"cellSize`":$(ConvertTo-DhJsonString $b.CellSize),`"collapsible`":$bCollStr,`"defaultOpen`":$bDoStr}" } 'summarystrip' { # v1.4.1 F-multi-summary — per-NavGroup KPI strip emitted as # a regular block. The JS renderer reuses the same tile # helpers as the top-of-page strip; only the host container # differs (per-block instead of #report-summary). $stItems = '' if ($b.Items -and @($b.Items).Count -gt 0) { $stItems = (@($b.Items) | ForEach-Object { $it = $_ # Mirrors the summary-tile JSON in $summaryItems above — # delta + sparkline + style + gauge fields all included. $val = if ($null -eq $it.Value) { 'null' } ` elseif ($it.Value -is [bool]) { $it.Value.ToString().ToLower() } ` elseif ($it.Value -is [int] -or $it.Value -is [long] -or $it.Value -is [double] -or $it.Value -is [float] -or $it.Value -is [decimal]) { [string]$it.Value } ` else { ConvertTo-DhJsonString ([string]$it.Value) } $dec = if ($it.Contains('Decimals') -and $it.Decimals -ge 0) { $it.Decimals } else { -1 } # Trend / delta $deltaJson2 = '' $hasPrev2 = $it.Contains('Previous') -and $null -ne $it.Previous $hasTrend2 = $it.Contains('Trend') -and $it.Trend -ne 'auto' if ($hasPrev2 -or $hasTrend2) { $prevJson2 = if ($hasPrev2) { [string]$it.Previous } else { 'null' } $trendStr2 = if ($it.Contains('Trend') -and $it.Trend) { [string]$it.Trend } else { 'auto' } $tigJson2 = if ($null -ne $it.TrendIsGood) { ([bool]$it.TrendIsGood).ToString().ToLower() } else { 'null' } $dfStr2 = if ($it.Contains('DeltaFormat') -and $it.DeltaFormat) { [string]$it.DeltaFormat } else { 'both' } $deltaJson2 = ",`"previous`":$prevJson2,`"trend`":$(ConvertTo-DhJsonString $trendStr2),`"trendIsGood`":$tigJson2,`"deltaFormat`":$(ConvertTo-DhJsonString $dfStr2)" } # Sparkline $sparkJson2 = '' if ($it.Contains('Sparkline') -and $it.Sparkline -and @($it.Sparkline).Count -gt 0) { $sparkValues2 = ((@($it.Sparkline) | ForEach-Object { [string]$_ }) -join ',') $sparkMinJson2 = if ($null -ne $it.SparklineMin) { [string]$it.SparklineMin } else { 'null' } $sparkMaxJson2 = if ($null -ne $it.SparklineMax) { [string]$it.SparklineMax } else { 'null' } $sparkStyle2 = if ($it.Contains('SparklineStyle') -and $it.SparklineStyle) { [string]$it.SparklineStyle } else { 'line' } $sparkJson2 = ",`"sparkline`":[$sparkValues2],`"sparklineMin`":$sparkMinJson2,`"sparklineMax`":$sparkMaxJson2,`"sparklineStyle`":$(ConvertTo-DhJsonString $sparkStyle2)" } # Style + style-specific fields (bignumber caption, gauge fields) $styleStr2 = if ($it.Contains('Style') -and $it.Style) { [string]$it.Style } else { 'tile' } $styleJson2 = ",`"style`":$(ConvertTo-DhJsonString $styleStr2)" $captionJson2 = '' if ($styleStr2 -eq 'bignumber' -and $it.Contains('Caption') -and -not [string]::IsNullOrWhiteSpace([string]$it.Caption)) { $captionJson2 = ",`"caption`":$(ConvertTo-DhJsonString $it.Caption)" } $gaugeJson2 = '' if ($styleStr2 -eq 'gauge') { $minJson2 = if ($null -ne $it.Min) { [string]$it.Min } else { '0' } $maxJson2 = if ($null -ne $it.Max) { [string]$it.Max } else { '100' } $unitStr2 = if ($it.Contains('Unit') -and $it.Unit) { [string]$it.Unit } else { '' } $thJson2 = '' if ($it.Contains('Thresholds') -and @($it.Thresholds).Count -gt 0) { $thItems2 = (@($it.Thresholds) | ForEach-Object { $th = $_ $minPart = if ($th.Contains('Min')) { "`"min`":$([string]$th.Min)," } else { '' } $maxPart = if ($th.Contains('Max')) { "`"max`":$([string]$th.Max)," } else { '' } "{$minPart$maxPart`"class`":$(ConvertTo-DhJsonString $th.Class)}" }) -join ',' $thJson2 = ",`"thresholds`":[$thItems2]" } else { $thJson2 = ',"thresholds":[]' } $rndJson2 = if ($it.Contains('RagNoData') -and $it.RagNoData) { ',"ragNoData":true' } else { '' } $gaugeJson2 = ",`"min`":$minJson2,`"max`":$maxJson2,`"unit`":$(ConvertTo-DhJsonString $unitStr2)$thJson2$rndJson2" } # Per-tile Action (v1.4.2) — same shape as the top-of-page strip $actionJson2 = '' if ($it.Contains('Action') -and $null -ne $it.Action) { $a2 = $it.Action $actionJson2 = ",`"action`":{`"label`":$(ConvertTo-DhJsonString $a2.Label),`"tableId`":$(ConvertTo-DhJsonString $a2.TableId),`"filter`":$(ConvertTo-DhJsonString $a2.Filter),`"url`":$(ConvertTo-DhJsonString $a2.Url)}" } "{`"label`":$(ConvertTo-DhJsonString $it.Label),`"value`":$val,`"icon`":$(ConvertTo-DhJsonString $it.Icon),`"subLabel`":$(ConvertTo-DhJsonString $it.SubLabel),`"class`":$(ConvertTo-DhJsonString $it.Class),`"format`":$(ConvertTo-DhJsonString $it.Format),`"locale`":$(ConvertTo-DhJsonString $it.Locale),`"decimals`":$dec,`"currency`":$(ConvertTo-DhJsonString $it.Currency)$styleJson2$captionJson2$gaugeJson2$deltaJson2$sparkJson2$actionJson2}" }) -join ',' } $collStr = ([bool]$b.Collapsible).ToString().ToLower() $doStr = ([bool]$b.DefaultOpen).ToString().ToLower() "{`"blockType`":$bt,`"id`":$(ConvertTo-DhJsonString $b.Id),`"title`":$(ConvertTo-DhJsonString $b.Title),`"icon`":$(ConvertTo-DhJsonString $b.Icon),`"collapsible`":$collStr,`"defaultOpen`":$doStr,`"items`":[$stItems]}" } 'eventfeed' { # v1.4 F11 — chronological severity-tagged audit trail. $eventsJson = '' if ($b.Events -and @($b.Events).Count -gt 0) { $eventsJson = (@($b.Events) | ForEach-Object { $ev = $_ "{`"timestamp`":$(ConvertTo-DhJsonString $ev.Timestamp),`"severity`":$(ConvertTo-DhJsonString $ev.Severity),`"message`":$(ConvertTo-DhJsonString $ev.Message),`"source`":$(ConvertTo-DhJsonString $ev.Source),`"icon`":$(ConvertTo-DhJsonString $ev.Icon)}" }) -join ',' } $sdStr = $b.SortDescending.ToString().ToLower() "{`"blockType`":$bt,`"id`":$(ConvertTo-DhJsonString $b.Id),`"title`":$(ConvertTo-DhJsonString $b.Title),`"events`":[$eventsJson],`"maxItems`":$($b.MaxItems),`"groupBy`":$(ConvertTo-DhJsonString $b.GroupBy),`"sortDescending`":$sdStr,`"collapsible`":$bCollStr,`"defaultOpen`":$bDoStr}" } 'linechart' { # v1.4 F9 — pure-SVG line / area chart with optional target line. # xAxis is a string array; series is an array of {label, values[], class, color}. $xAxisJson = '' if ($b.XAxis -and @($b.XAxis).Count -gt 0) { $xAxisJson = (@($b.XAxis) | ForEach-Object { ConvertTo-DhJsonString ([string]$_) }) -join ',' } $seriesJson = '' if ($b.Series -and @($b.Series).Count -gt 0) { $seriesJson = (@($b.Series) | ForEach-Object { $sr = $_ # NaN doubles serialise as 'NaN' which is not valid JSON — emit null instead $valsJoin = (@($sr.Values) | ForEach-Object { if ([double]::IsNaN([double]$_)) { 'null' } else { [string]$_ } }) -join ',' "{`"label`":$(ConvertTo-DhJsonString $sr.Label),`"values`":[$valsJoin],`"class`":$(ConvertTo-DhJsonString $sr.Class),`"color`":$(ConvertTo-DhJsonString $sr.Color)}" }) -join ',' } $filledStr = $b.Filled.ToString().ToLower() $tlStr = if ($null -ne $b.TargetLine) { [string]$b.TargetLine } else { 'null' } $tlLabel = ConvertTo-DhJsonString ([string]$b.TargetLabel) $yMinStr = if ($null -ne $b.YAxisMin) { [string]$b.YAxisMin } else { 'null' } $yMaxStr = if ($null -ne $b.YAxisMax) { [string]$b.YAxisMax } else { 'null' } $yUnitStr = ConvertTo-DhJsonString ([string]$b.YAxisUnit) $slStr = $b.ShowLegend.ToString().ToLower() $sgStr = $b.ShowGrid.ToString().ToLower() "{`"blockType`":$bt,`"id`":$(ConvertTo-DhJsonString $b.Id),`"title`":$(ConvertTo-DhJsonString $b.Title),`"xAxis`":[$xAxisJson],`"series`":[$seriesJson],`"filled`":$filledStr,`"targetLine`":$tlStr,`"targetLabel`":$tlLabel,`"yAxisMin`":$yMinStr,`"yAxisMax`":$yMaxStr,`"yAxisUnit`":$yUnitStr,`"showLegend`":$slStr,`"showGrid`":$sgStr,`"height`":$($b.Height),`"collapsible`":$bCollStr,`"defaultOpen`":$bDoStr}" } 'bullet' { # v1.4 F10 — each item carries Value/Target/Min/Max/Unit/Class # + a thresholds[] array + an optional ragNoData flag. $itemsJson = '' if ($b.Items -and @($b.Items).Count -gt 0) { $itemsJson = (@($b.Items) | ForEach-Object { $bi = $_ $targetJson = if ($null -ne $bi.Target) { [string]$bi.Target } else { 'null' } $thArr = '' if ($bi.Thresholds -and @($bi.Thresholds).Count -gt 0) { $thArr = (@($bi.Thresholds) | ForEach-Object { $th = $_ $mn = if ($th.Contains('Min')) { "`"min`":$([string]$th.Min)," } else { '' } $mx = if ($th.Contains('Max')) { "`"max`":$([string]$th.Max)," } else { '' } "{$mn$mx`"class`":$(ConvertTo-DhJsonString $th.Class)}" }) -join ',' } $rndPart = if ($bi.RagNoData) { ',"ragNoData":true' } else { '' } $valueJson = if ($null -eq $bi.Value) { 'null' } else { [string]$bi.Value } "{`"label`":$(ConvertTo-DhJsonString $bi.Label),`"value`":$valueJson,`"target`":$targetJson,`"min`":$([string]$bi.Min),`"max`":$([string]$bi.Max),`"unit`":$(ConvertTo-DhJsonString $bi.Unit),`"class`":$(ConvertTo-DhJsonString $bi.Class),`"thresholds`":[$thArr]$rndPart}" }) -join ',' } "{`"blockType`":$bt,`"id`":$(ConvertTo-DhJsonString $b.Id),`"title`":$(ConvertTo-DhJsonString $b.Title),`"items`":[$itemsJson],`"collapsible`":$bCollStr,`"defaultOpen`":$bDoStr}" } 'piechart' { $cfStr = $b.ClickFilters.ToString().ToLower() $stStr = $b.ShowTotal.ToString().ToLower() $slStr = $b.ShowLegend.ToString().ToLower() $slicesJson = '' if ($b.Slices -and $b.Slices.Count -gt 0) { $slicesJson = ($b.Slices | ForEach-Object { $s = $_ "{`"label`":$(ConvertTo-DhJsonString $s.Label),`"value`":$($s.Value),`"color`":$(ConvertTo-DhJsonString $s.Color)}" }) -join ',' } "{`"blockType`":$bt,`"id`":$(ConvertTo-DhJsonString $b.Id),`"title`":$(ConvertTo-DhJsonString $b.Title),`"style`":$(ConvertTo-DhJsonString $b.Style),`"mode`":$(ConvertTo-DhJsonString $b.Mode),`"tableId`":$(ConvertTo-DhJsonString $b.TableId),`"field`":$(ConvertTo-DhJsonString $b.Field),`"topN`":$($b.TopN),`"clickFilters`":$cfStr,`"slices`":[$slicesJson],`"showTotal`":$stStr,`"showLegend`":$slStr,`"collapsible`":$bCollStr,`"defaultOpen`":$bDoStr}" } } }) -join ',' $blocksConfigJson = "[$blockItems]" } # ---- Build alert banners JSON -------------------------------------------------- $alertsConfigJson = '[]' if ($Report.Contains('AlertBanners') -and $Report.AlertBanners.Count -gt 0) { $alertItems = ($Report.AlertBanners | ForEach-Object { $a = $_ $dismissStr = $a.Dismissible.ToString().ToLower() $actionJson = 'null' if ($a.Action) { $actionJson = "{`"label`":$(ConvertTo-DhJsonString $a.Action.Label),`"tableId`":$(ConvertTo-DhJsonString $a.Action.TableId),`"filter`":$(ConvertTo-DhJsonString $a.Action.Filter),`"url`":$(ConvertTo-DhJsonString $a.Action.Url)}" } # v1.4.2+ — optional NavGroup / NavSubGroup so the banner can bind # to a single panel rather than staying page-global. $ngStr = if ($a.Contains('NavGroup') -and $a.NavGroup) { [string]$a.NavGroup } else { '' } $sgStr = if ($a.Contains('NavSubGroup') -and $a.NavSubGroup) { [string]$a.NavSubGroup } else { '' } # v1.5.1 — collapsible banner chrome $aCollStr = if ($a.Contains('Collapsible')) { ([bool]$a.Collapsible).ToString().ToLower() } else { 'false' } $aDoStr = if ($a.Contains('DefaultOpen')) { ([bool]$a.DefaultOpen).ToString().ToLower() } else { 'true' } $aTitleStr = if ($a.Contains('Title') -and $a.Title) { [string]$a.Title } else { '' } "{`"id`":$(ConvertTo-DhJsonString $a.Id),`"severity`":$(ConvertTo-DhJsonString $a.Severity),`"message`":$(ConvertTo-DhJsonString $a.Message),`"icon`":$(ConvertTo-DhJsonString $a.Icon),`"dismissible`":$dismissStr,`"action`":$actionJson,`"navGroup`":$(ConvertTo-DhJsonString $ngStr),`"navSubGroup`":$(ConvertTo-DhJsonString $sgStr),`"collapsible`":$aCollStr,`"defaultOpen`":$aDoStr,`"title`":$(ConvertTo-DhJsonString $aTitleStr)}" }) -join ',' $alertsConfigJson = "[$alertItems]" } # ---- Inject config into JS engine -------------------------------------------- # NB: PowerShell -replace uses .NET regex substitution syntax, where the # REPLACEMENT string treats $&, $', $`, $1.., $+, $_ as backreferences. When # injected JSON happens to contain those sequences (e.g. an event message # containing a SAM account name like "PV04$'..." produces a literal $'), # the substitution silently expands and bloats the file by repeating large # chunks of the template. Use [regex]::Replace with a MatchEvaluator so the # JSON is inserted verbatim. $jsTpl = Get-DhJsContent $js = [regex]::Replace($jsTpl, '/\*%%TABLES_CONFIG%%\*/\[\]', { param($m) $tablesConfigJson }) $js = [regex]::Replace($js, '/\*%%SUMMARY_CONFIG%%\*/\[\]', { param($m) $summaryConfigJson }) $js = [regex]::Replace($js, '/\*%%BLOCKS_CONFIG%%\*/\[\]', { param($m) $blocksConfigJson }) $js = [regex]::Replace($js, '/\*%%ALERTS_CONFIG%%\*/\[\]', { param($m) $alertsConfigJson }) # ---- Build theme style tag (light primary, dark alternate, always embedded) -- $primaryCss = Get-DhThemeCss -Theme $theme $alternateCss = Get-DhThemeCss -Theme $alternateTheme $themeStyleTag = @" <style id="theme-primary" data-theme="$theme">$primaryCss</style> <style id="theme-alternate" data-theme="$alternateTheme" media="none">$alternateCss</style> "@ # ---- Logo HTML --------------------------------------------------------------- $logoMime = if ($Report.LogoMime) { $Report.LogoMime } else { 'image/jpeg' } $logoHtml = if ($Report.LogoBase64) { "<img src=`"data:$logoMime;base64,$($Report.LogoBase64)`" class=`"report-logo`" alt=`"Logo`">" } else { '<div class="report-logo-placeholder" title="No logo supplied"></div>' } # Nav logo suppressed by design $navLogoHtml = '' # ---- Detect two-tier nav (any table or block has a NavGroup) -- $hasTwoTier = ($Report.Tables | Where-Object { $_.NavGroup }) -or ($Report.Contains('Blocks') -and ($Report.Blocks | Where-Object { $_.NavGroup })) if ($hasTwoTier) { # Collect ordered unique groups — tables first (preserve order), then any block-only groups $groupOrder = [System.Collections.Generic.List[string]]::new() foreach ($t in $Report.Tables) { if ($t.NavGroup -and -not $groupOrder.Contains($t.NavGroup)) { $groupOrder.Add($t.NavGroup) } } # Blocks may introduce groups that have no matching table (e.g. a filter-card-only group) if ($Report.Contains('Blocks')) { foreach ($b in $Report.Blocks) { if ($b.NavGroup -and -not $groupOrder.Contains($b.NavGroup)) { $groupOrder.Add($b.NavGroup) } } } # Ungrouped tables get flat links in the primary bar $flatLinks = ($Report.Tables | Where-Object { -not $_.NavGroup } | ForEach-Object { $tid = $_.Id $tn = [System.Web.HttpUtility]::HtmlEncode($_.Title) "<a class=`"nav-link`" href=`"#`" data-table=`"$tid`">$tn<span class=`"nav-badge`" data-table=`"$tid`"></span></a>" }) -join "`n " # Group tabs for primary nav $groupTabsHtml = ($groupOrder | ForEach-Object { $g = [System.Web.HttpUtility]::HtmlEncode($_) "<a class=`"nav-group-tab`" href=`"#`" data-group=`"$g`">$g</a>" }) -join "`n " # All grouped table links go in subnav (include data-subgroup when defined) $subnavLinks = ($Report.Tables | Where-Object { $_.NavGroup } | ForEach-Object { $tid = $_.Id $tn = [System.Web.HttpUtility]::HtmlEncode($_.Title) $g = [System.Web.HttpUtility]::HtmlEncode($_.NavGroup) $sg = if ($_.Contains('NavSubGroup') -and $_.NavSubGroup) { $_.NavSubGroup } else { '' } $sgAttr = if ($sg) { " data-subgroup=`"$([System.Web.HttpUtility]::HtmlEncode($sg))`"" } else { '' } "<a class=`"nav-link`" href=`"#`" data-table=`"$tid`" data-group=`"$g`"$sgAttr>$tn<span class=`"nav-badge`" data-table=`"$tid`"></span></a>" }) -join "`n " # ---- Three-tier nav: build subgroup pills ---- # Collect (group, subgroup) pairs from tables first, then block-only subgroups $subGroupPairs = [System.Collections.Generic.List[object]]::new() $seenPairs = @{} foreach ($t in $Report.Tables) { if ($t.NavGroup -and $t.Contains('NavSubGroup') -and $t.NavSubGroup) { $key = "$($t.NavGroup)`0$($t.NavSubGroup)" if (-not $seenPairs.ContainsKey($key)) { $seenPairs[$key] = $true $subGroupPairs.Add(@{ Group = $t.NavGroup; SubGroup = $t.NavSubGroup }) } } } if ($Report.Contains('Blocks')) { foreach ($b in $Report.Blocks) { if ($b.NavGroup -and $b.Contains('NavSubGroup') -and $b.NavSubGroup) { $key = "$($b.NavGroup)`0$($b.NavSubGroup)" if (-not $seenPairs.ContainsKey($key)) { $seenPairs[$key] = $true $subGroupPairs.Add(@{ Group = $b.NavGroup; SubGroup = $b.NavSubGroup }) } } } } $subgroupHtml = '' if ($subGroupPairs.Count -gt 0) { $subgroupPillsHtml = ($subGroupPairs | ForEach-Object { $g = [System.Web.HttpUtility]::HtmlEncode($_.Group) $sg = [System.Web.HttpUtility]::HtmlEncode($_.SubGroup) "<a class=`"subgroup-pill`" href=`"#`" data-group=`"$g`" data-subgroup=`"$sg`">$sg</a>" }) -join "`n " $subgroupHtml = "<div class=`"nav-subgroup`" id=`"nav-subgroup`" style=`"display:none`"><div class=`"subgroup-inner`">$subgroupPillsHtml</div></div>" } $navLinksHtml = $flatLinks $groupTabsHtml = if ($groupTabsHtml) { "<div class=`"nav-group-tabs`" id=`"nav-group-tabs`">$groupTabsHtml</div>" } else { '' } $subnavHtml = "<div class=`"nav-subnav`" id=`"nav-subnav`"><div class=`"subnav-inner`">$subnavLinks</div></div>" } else { $navLinksHtml = ($Report.Tables | ForEach-Object { $tid = $_.Id $tname = [System.Web.HttpUtility]::HtmlEncode($_.Title) "<a class=`"nav-link`" href=`"#`" data-table=`"$tid`">$tname<span class=`"nav-badge`" data-table=`"$tid`"></span></a>" }) -join "`n " $groupTabsHtml = '' $subnavHtml = '' $subgroupHtml = '' } $subtitleHtml = if ($Report.Subtitle) { "<p class=`"report-subtitle`">$([System.Web.HttpUtility]::HtmlEncode($Report.Subtitle))</p>" } else { '' } # Info fields grid (key-value pairs displayed in the report header) $infoFieldsHtml = '' if ($Report.Contains('InfoFields') -and $Report.InfoFields.Count -gt 0) { $fieldItems = ($Report.InfoFields | ForEach-Object { $f = $_ "<div class=`"info-field-item`"><span class=`"info-field-label`">$([System.Web.HttpUtility]::HtmlEncode($f.Label))</span><span class=`"info-field-value`">$([System.Web.HttpUtility]::HtmlEncode($f.Value))</span></div>" }) -join "`n " $infoFieldsHtml = "<div class=`"info-fields-grid`">`n $fieldItems`n </div>" } # ---- Summary container shell ------------------------------------------------ # When -Collapsible was passed to Add-DhSummary, emit data-* attributes that # the JS engine reads to wrap the tile strip in a collapsible header. $summaryOpts = if ($Report.Contains('SummaryOptions')) { $Report.SummaryOptions } else { $null } if ($summaryOpts -and $summaryOpts.Collapsible) { $sTitleAttr = [System.Web.HttpUtility]::HtmlEncode([string]$summaryOpts.Title) $sIconAttr = if ($summaryOpts.Icon) { [System.Web.HttpUtility]::HtmlEncode([string]$summaryOpts.Icon) } else { '' } $sOpenAttr = if ($summaryOpts.DefaultOpen) { 'true' } else { 'false' } $summaryContainerHtml = "<div class=`"report-summary`" id=`"report-summary`" data-collapsible=`"true`" data-title=`"$sTitleAttr`" data-icon=`"$sIconAttr`" data-default-open=`"$sOpenAttr`"></div>" } else { $summaryContainerHtml = '<div class="report-summary" id="report-summary"></div>' } # ---- Table section shells --------------------------------------------------- $tableSections = if ($Report.Tables.Count -gt 0) { Build-DhTableSections -Tables $Report.Tables } else { '' } # Build block section HTML shells $blockSectionsHtml = '' if ($Report.Contains('Blocks') -and $Report.Blocks.Count -gt 0) { $blockSectionsHtml = ($Report.Blocks | ForEach-Object { $b = $_ $bNg = if ($b.NavGroup) { $b.NavGroup } else { '' } $bNgAttr = if ($bNg) { " data-navgroup=`"$([System.Web.HttpUtility]::HtmlEncode($bNg))`"" } else { '' } $bSg = if ($b.Contains('NavSubGroup') -and $b.NavSubGroup) { $b.NavSubGroup } else { '' } $bSgAttr = if ($bSg) { " data-navsubgroup=`"$([System.Web.HttpUtility]::HtmlEncode($bSg))`"" } else { '' } $bActive = if (-not $bNg) { ' panel-active' } else { '' } "<div class=`"block-section$bActive`" id=`"bsection-$($b.Id)`"$bNgAttr$bSgAttr><div id=`"block-$($b.Id)`"></div></div>" }) -join "`n" } # ---- Auto-refresh meta tag (v1.4.2 — -AutoRefresh on New-DhDashboard) ---- $autoRefreshTag = '' if ($Report.Contains('AutoRefreshSec') -and [int]$Report.AutoRefreshSec -ge 1) { $autoRefreshTag = " <meta http-equiv=`"refresh`" content=`"$([int]$Report.AutoRefreshSec)`">" } # ---- Assemble HTML ---------------------------------------------------------- $html = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="generator" content="DashHtml PowerShell Module v$moduleVersion"> <meta name="report-theme" content="$themeFamily"> $autoRefreshTag <title>$([System.Web.HttpUtility]::HtmlEncode($Report.Title))</title> $themeStyleTag <!-- Export libraries (cdnjs - internet required for XLSX/PDF export) --> <script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js" crossorigin="anonymous"></script> <!-- Bridge: jsPDF 2.x UMD exposes window.jspdf.jsPDF but AutoTable looks for window.jsPDF --> <script>if(window.jspdf&&window.jspdf.jsPDF&&!window.jsPDF){window.jsPDF=window.jspdf.jsPDF;}</script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js" crossorigin="anonymous"></script> </head> <body> <header class="report-header"> <div class="header-brand"> $logoHtml <div class="header-titles"> <h1 class="report-title">$([System.Web.HttpUtility]::HtmlEncode($Report.Title))</h1> $subtitleHtml $infoFieldsHtml </div> </div> <div class="header-meta"> <span class="meta-label">Generated</span> <span class="meta-value">$($Report.GeneratedAt)</span> </div> </header> <nav class="report-nav" id="report-nav" role="navigation" aria-label="Report sections"> <div class="nav-inner"> $navLogoHtml $(if ($Report.Contains('NavTitle') -and $Report.NavTitle) { "<span class=`"nav-title`">$([System.Web.HttpUtility]::HtmlEncode($Report.NavTitle))</span>" }) <div class="nav-divider"></div> $(if ($navLinksHtml) { "<div class=`"nav-links`">$navLinksHtml</div>" }) $groupTabsHtml <button class="nav-top-btn" onclick="window.scrollTo({top:0,behavior:'smooth'})" title="Back to top">⇧ Top</button> <button class="nav-utility-btn" id="btn-density-toggle" title="Toggle table row density">⊞ Normal</button> <button class="nav-utility-btn" id="btn-theme-toggle" data-primary="$theme" data-alternate="$alternateTheme" title="Toggle theme">☾ Dark</button> </div> $subgroupHtml $subnavHtml </nav> <main class="report-body"> <div class="report-alerts" id="report-alerts"></div> $summaryContainerHtml $blockSectionsHtml $tableSections </main> <footer class="report-footer"> Generated by <strong>DashHtml</strong> v$moduleVersion — $($Report.GeneratedAt)$(if ($Report.Contains('GeneratedBy') -and $Report.GeneratedBy) { " — $([System.Web.HttpUtility]::HtmlEncode($Report.GeneratedBy))" }) </footer> <script> $js </script> </body> </html> "@ # ---- Write HTML ------------------------------------------------------------- if ($PSCmdlet.ShouldProcess($OutputPath, 'Write HTML dashboard')) { if ((Test-Path $OutputPath) -and -not $Force) { Write-Warning "HTML already exists: $OutputPath (use -Force to overwrite)" return } Set-Content -Path $OutputPath -Value $html -Encoding UTF8 $size = [math]::Round((Get-Item $OutputPath).Length / 1KB, 1) Write-Verbose "Export-DhDashboard: [HTML] $OutputPath ($size KB)" Write-Verbose "Export-DhDashboard: Themes: $theme (light) <-> $alternateTheme (dark) — embedded, toggle button in nav" Write-Verbose "Export-DhDashboard: Tables=$($Report.Tables.Count) Links=$($Report.Links.Count)" } # ---- Open in browser -------------------------------------------------------- if ($OpenInBrowser -and (Test-Path $OutputPath)) { try { Start-Process $OutputPath } catch { Write-Warning "Export-DhDashboard: Could not open dashboard in browser: $_" } } } |