Public/Report/New-TBDashboard.ps1
|
function New-TBDashboard { <# .SYNOPSIS Generates an interactive HTML dashboard for tenant configuration monitoring. .DESCRIPTION Collects monitoring data and generates a self-contained, offline-capable HTML dashboard with drift timelines, monitor details, and optional snapshot comparison. All data is embedded as JSON within the HTML file. .PARAMETER OutputPath The file path for the dashboard HTML file. Defaults to TBDashboard-{timestamp}.html. .PARAMETER MonitorId Optional monitor ID to scope the dashboard data. .PARAMETER IncludeSnapshots Include snapshot metadata in the dashboard. .PARAMETER IncludeSnapshotContent Export and embed snapshot content for property-level comparison. Implies -IncludeSnapshots. .EXAMPLE New-TBDashboard -OutputPath './dashboard.html' .EXAMPLE New-TBDashboard -IncludeSnapshots -IncludeSnapshotContent #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter()] [string]$OutputPath, [Parameter()] [string]$MonitorId, [Parameter()] [switch]$IncludeSnapshots, [Parameter()] [switch]$IncludeSnapshotContent ) if ($IncludeSnapshotContent) { $IncludeSnapshots = [switch]::new($true) } if (-not $OutputPath) { $dateStamp = Get-Date -Format 'yyyyMMdd-HHmmss' $OutputPath = 'TBDashboard-{0}.html' -f $dateStamp } $driftParams = @{} if ($MonitorId) { $driftParams['MonitorId'] = $MonitorId } Write-TBLog -Message 'Collecting data for dashboard' $monitors = @(Get-TBMonitor) $drifts = @(Get-TBDrift @driftParams) $driftSummary = Get-TBDriftSummary @driftParams $monitorResults = @(Get-TBMonitorResult) # Collect baselines per monitor $baselines = @() foreach ($monitor in $monitors) { try { $bl = Get-TBBaseline -MonitorId $monitor.Id if ($bl) { $baselines += $bl } } catch { Write-TBLog -Message ('Could not retrieve baseline for monitor {0}: {1}' -f $monitor.Id, $_.Exception.Message) -Level 'Warning' } } $snapshots = @() $snapshotContents = @() if ($IncludeSnapshots) { $snapshots = @(Get-TBSnapshot) if ($IncludeSnapshotContent) { foreach ($snap in $snapshots) { $snapStatus = '' if ($snap -is [hashtable]) { $snapStatus = $snap['status'] } elseif ($snap.PSObject.Properties['Status']) { $snapStatus = $snap.Status } if ($snapStatus -eq 'succeeded' -or $snapStatus -eq 'partiallySuccessful') { $snapId = '' if ($snap -is [hashtable]) { $snapId = $snap['id'] } elseif ($snap.PSObject.Properties['Id']) { $snapId = $snap.Id } try { $exported = Export-TBSnapshot -SnapshotId $snapId -OutputPath ([System.IO.Path]::GetTempFileName()) if ($exported -and $exported.OutputPath) { $contentJson = Get-Content -Path $exported.OutputPath -Raw $snapshotContents += [PSCustomObject]@{ SnapshotId = $snapId Content = ($contentJson | ConvertFrom-Json) } Remove-Item -Path $exported.OutputPath -Force -ErrorAction SilentlyContinue } } catch { Write-TBLog -Message ('Could not export snapshot {0}: {1}' -f $snapId, $_.Exception.Message) -Level 'Warning' } } } } } $timestamp = (Get-Date).ToString('o') $dashboardData = [PSCustomObject]@{ GeneratedAt = $timestamp Monitors = $monitors Drifts = $drifts DriftSummary = $driftSummary MonitorResults = $monitorResults Baselines = $baselines Snapshots = $snapshots SnapshotContents = $snapshotContents } if ($PSCmdlet.ShouldProcess($OutputPath, 'Generate dashboard')) { $parentDir = Split-Path -Path $OutputPath -Parent if ($parentDir -and -not (Test-Path -Path $parentDir)) { $null = New-Item -Path $parentDir -ItemType Directory -Force } $html = New-TBDashboardHtml -DashboardData $dashboardData $html | Out-File -FilePath $OutputPath -Encoding utf8 -Force Write-TBLog -Message ('Dashboard generated: {0}' -f $OutputPath) [PSCustomObject]@{ OutputPath = (Resolve-Path -Path $OutputPath).Path MonitorCount = $monitors.Count DriftCount = $drifts.Count SnapshotCount = $snapshots.Count GeneratedAt = $timestamp } } } function New-TBDashboardHtml { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [PSCustomObject]$DashboardData ) $dataJson = $DashboardData | ConvertTo-Json -Depth 20 -Compress $styleTokens = Get-TBFluentHtmlStyleTokenSet # All dynamic values in the JavaScript use the esc() function which creates # a temporary DOM element and sets textContent to safely encode values before # inserting them into the page. The data source is the module's own API # responses serialized as JSON at generation time, not external user input. $html = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>TenantBaseline Dashboard</title> <style> $styleTokens * { box-sizing: border-box; margin: 0; padding: 0; } body { color: var(--tb-text); } .header { background: linear-gradient(135deg, var(--tb-accent) 0%, #204a96 100%); color: #fff; padding: 1rem 2rem; } .header h1 { font-size: 1.4rem; font-weight: 600; } .header .subtitle { font-size: 0.85rem; opacity: 0.85; margin-top: 0.2rem; } .tabs { display: flex; background: var(--tb-surface); border-bottom: 2px solid var(--tb-border); padding: 0 2rem; } .tab { padding: 0.75rem 1.5rem; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; font-weight: 500; color: var(--tb-text-muted); transition: all 0.15s; } .tab:hover { color: var(--tb-accent); } .tab.active { color: var(--tb-accent); border-bottom-color: var(--tb-accent); } .content { padding: 1.5rem 2rem; max-width: 1400px; } .tab-panel { display: none; } .tab-panel.active { display: block; } .summary-cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } .card { background: #fff; border: 1px solid #edebe9; border-radius: 4px; padding: 1rem 1.5rem; min-width: 180px; flex: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } .card .label { font-size: 0.8rem; color: #605e5c; text-transform: uppercase; letter-spacing: 0.5px; } .card .value { font-size: 2rem; font-weight: 600; color: #323130; } .card.alert .value { color: #d83b01; } .card.success .value { color: #107c10; } h2 { color: #323130; margin: 1.5rem 0 1rem; font-size: 1.2rem; } h3 { color: #605e5c; margin: 1rem 0 0.5rem; font-size: 1rem; } table { width: 100%; border-collapse: collapse; background: #fff; margin: 0.5rem 0 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } th { background: #0078d4; color: #fff; text-align: left; padding: 0.6rem 1rem; font-weight: 600; font-size: 0.85rem; } td { padding: 0.5rem 1rem; border-bottom: 1px solid #edebe9; font-size: 0.9rem; } tr:hover td { background: #f3f2f1; } code { background: #f3f2f1; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.85em; } .badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 3px; font-size: 0.8rem; font-weight: 600; } .badge-active { background: #fde7e9; color: #d83b01; } .badge-fixed { background: #dff6dd; color: #107c10; } .badge-status-active { background: #dff6dd; color: #107c10; } .badge-running { background: #fff4ce; color: #797673; } .badge-succeeded { background: #dff6dd; color: #107c10; } .badge-failed { background: #fde7e9; color: #d83b01; } .filter-bar { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; align-items: center; } .filter-bar label { font-size: 0.85rem; color: #605e5c; } .filter-bar select { padding: 0.4rem 0.6rem; border: 1px solid #edebe9; border-radius: 3px; font-size: 0.85rem; background: #fff; } .svg-container { background: #fff; border: 1px solid #edebe9; border-radius: 4px; padding: 1rem; margin: 1rem 0; overflow-x: auto; } svg text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .tooltip { position: absolute; background: #323130; color: #fff; padding: 0.4rem 0.8rem; border-radius: 3px; font-size: 0.8rem; pointer-events: none; z-index: 100; white-space: nowrap; display: none; } .monitor-card { background: #fff; border: 1px solid #edebe9; border-radius: 4px; padding: 1rem 1.5rem; margin: 0.75rem 0; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } .monitor-card h3 { margin-top: 0; color: #323130; } .monitor-card .meta { font-size: 0.85rem; color: #605e5c; margin: 0.3rem 0; } .resource-list { margin: 0.5rem 0; padding-left: 1.5rem; } .resource-list li { font-size: 0.85rem; margin: 0.2rem 0; color: #323130; } .expandable-header { cursor: pointer; user-select: none; } .expandable-header::before { content: '+ '; font-weight: bold; color: #0078d4; } .expandable-header.expanded::before { content: '- '; } .expandable-content { display: none; } .expandable-content.expanded { display: block; } .comparison-controls { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; align-items: flex-end; } .comparison-controls .select-group { display: flex; flex-direction: column; gap: 0.3rem; } .comparison-controls label { font-size: 0.85rem; color: #605e5c; font-weight: 600; } .comparison-controls select { padding: 0.4rem 0.6rem; border: 1px solid #edebe9; border-radius: 3px; font-size: 0.85rem; min-width: 200px; } .comparison-controls button { padding: 0.4rem 1rem; background: #0078d4; color: #fff; border: none; border-radius: 3px; cursor: pointer; font-size: 0.85rem; } .comparison-controls button:hover { background: #106ebe; } .diff-added { background: #dff6dd; } .diff-removed { background: #fde7e9; } .diff-changed { background: #fff4ce; } .no-data { text-align: center; color: #a19f9d; padding: 2rem; font-style: italic; } @media print { .tabs, .filter-bar, .comparison-controls button { display: none; } .tab-panel { display: block !important; page-break-inside: avoid; } body { background: #fff; } .header { background: #323130; } } @media (max-width: 768px) { .content { padding: 1rem; } .tabs { padding: 0 1rem; overflow-x: auto; } .summary-cards { flex-direction: column; } } </style> </head> <body> <div class="header"> <h1>TenantBaseline Dashboard</h1> <div class="subtitle" id="generated-at"></div> </div> <div class="tabs" id="tab-bar"></div> <div class="content"> <div class="tab-panel active" id="panel-overview"> <div class="summary-cards" id="summary-cards"></div> <h2>Status Breakdown</h2> <div class="svg-container" id="status-chart"></div> <h2>Resource Type Breakdown</h2> <div class="svg-container" id="resource-chart"></div> </div> <div class="tab-panel" id="panel-timeline"> <div class="filter-bar" id="filter-bar"></div> <div class="svg-container" id="timeline-chart"></div> <h2>Drift Details</h2> <div id="drift-table-container"></div> </div> <div class="tab-panel" id="panel-monitors"> <div id="monitor-cards"></div> </div> <div class="tab-panel" id="panel-snapshots"> <div id="snapshot-section"></div> </div> </div> <div class="tooltip" id="tooltip"></div> <script id="tb-data" type="application/json">$dataJson</script> <script> (function() { 'use strict'; var dataEl = document.getElementById('tb-data'); var data = JSON.parse(dataEl.textContent); var tooltip = document.getElementById('tooltip'); // Safe text encoding: creates a temporary element, sets textContent, // then reads back the safely-encoded content. function esc(str) { if (str === null || str === undefined) return ''; var d = document.createElement('span'); d.textContent = String(str); return d.textContent; } function prop(obj, name) { if (!obj) return ''; if (typeof obj[name] !== 'undefined') return obj[name]; var lower = name.charAt(0).toLowerCase() + name.slice(1); if (typeof obj[lower] !== 'undefined') return obj[lower]; return ''; } // DOM helper: creates an element with attributes and children function el(tag, attrs, children) { var e = document.createElement(tag); if (attrs) { for (var k in attrs) { if (attrs.hasOwnProperty(k)) { if (k === 'className') e.className = attrs[k]; else if (k === 'textContent') e.textContent = attrs[k]; else e.setAttribute(k, attrs[k]); } } } if (children) { if (typeof children === 'string') e.textContent = children; else if (Array.isArray(children)) { for (var i = 0; i < children.length; i++) { if (children[i]) e.appendChild(children[i]); } } } return e; } function textEl(tag, text, className) { var e = document.createElement(tag); e.textContent = text; if (className) e.className = className; return e; } // Tab setup using DOM methods var tabDefs = [ { id: 'overview', label: 'Overview' }, { id: 'timeline', label: 'Drift Timeline' }, { id: 'monitors', label: 'Monitor Details' }, { id: 'snapshots', label: 'Snapshot Comparison' } ]; var tabBar = document.getElementById('tab-bar'); var panels = []; for (var i = 0; i < tabDefs.length; i++) { var tabEl = textEl('div', tabDefs[i].label, 'tab' + (i === 0 ? ' active' : '')); tabEl.setAttribute('data-tab', tabDefs[i].id); tabBar.appendChild(tabEl); panels.push(document.getElementById('panel-' + tabDefs[i].id)); } tabBar.addEventListener('click', function(e) { if (!e.target.classList.contains('tab')) return; var target = e.target.getAttribute('data-tab'); var allTabs = tabBar.querySelectorAll('.tab'); for (var j = 0; j < allTabs.length; j++) allTabs[j].classList.remove('active'); for (var j = 0; j < panels.length; j++) panels[j].classList.remove('active'); e.target.classList.add('active'); document.getElementById('panel-' + target).classList.add('active'); }); // Timestamp document.getElementById('generated-at').textContent = 'Generated: ' + esc(data.GeneratedAt || data.generatedAt); var monitors = data.Monitors || data.monitors || []; var drifts = data.Drifts || data.drifts || []; var summary = data.DriftSummary || data.driftSummary || {}; var snapshots = data.Snapshots || data.snapshots || []; var monitorResults = data.MonitorResults || data.monitorResults || []; var baselines = data.Baselines || data.baselines || []; var snapshotContents = data.SnapshotContents || data.snapshotContents || []; // Overview: Summary Cards (DOM-based) var activeDrifts = 0, fixedDrifts = 0, totalProps = 0; for (var i = 0; i < drifts.length; i++) { var st = prop(drifts[i], 'Status') || prop(drifts[i], 'status'); if (st === 'active') activeDrifts++; if (st === 'fixed') fixedDrifts++; var dp = prop(drifts[i], 'DriftedProperties') || prop(drifts[i], 'driftedProperties'); if (dp && dp.length) totalProps += dp.length; } var cardsContainer = document.getElementById('summary-cards'); var cardDefs = [ { label: 'Monitors', value: monitors.length, cls: '' }, { label: 'Active Drifts', value: activeDrifts, cls: activeDrifts > 0 ? ' alert' : '' }, { label: 'Fixed Drifts', value: fixedDrifts, cls: ' success' }, { label: 'Drifted Properties', value: totalProps, cls: '' } ]; for (var i = 0; i < cardDefs.length; i++) { var card = el('div', { className: 'card' + cardDefs[i].cls }, [ textEl('div', cardDefs[i].label, 'label'), textEl('div', String(cardDefs[i].value), 'value') ]); cardsContainer.appendChild(card); } // Overview: Bar charts (SVG is inherently safe as we use createElementNS // and textContent for all text nodes) function renderBarChart(containerId, dataMap, colorMap) { var container = document.getElementById(containerId); container.textContent = ''; var keys = []; for (var k in dataMap) { if (dataMap.hasOwnProperty(k)) keys.push(k); } if (keys.length === 0) { container.appendChild(textEl('div', 'No data available.', 'no-data')); return; } var maxVal = 0; for (var i = 0; i < keys.length; i++) { if (dataMap[keys[i]] > maxVal) maxVal = dataMap[keys[i]]; } if (maxVal === 0) maxVal = 1; var barHeight = 28, labelWidth = 220, chartWidth = 500; var svgHeight = keys.length * (barHeight + 8) + 20; var svgWidth = labelWidth + chartWidth + 60; var ns = 'http://www.w3.org/2000/svg'; var svg = document.createElementNS(ns, 'svg'); svg.setAttribute('width', svgWidth); svg.setAttribute('height', svgHeight); for (var i = 0; i < keys.length; i++) { var y = i * (barHeight + 8) + 10; var val = dataMap[keys[i]]; var barW = Math.max((val / maxVal) * chartWidth, 2); var color = (colorMap && colorMap[keys[i]]) || '#0078d4'; var lbl = document.createElementNS(ns, 'text'); lbl.setAttribute('x', labelWidth - 8); lbl.setAttribute('y', y + barHeight / 2 + 4); lbl.setAttribute('text-anchor', 'end'); lbl.setAttribute('font-size', '12'); lbl.setAttribute('fill', '#323130'); lbl.textContent = keys[i]; svg.appendChild(lbl); var rect = document.createElementNS(ns, 'rect'); rect.setAttribute('x', labelWidth); rect.setAttribute('y', y); rect.setAttribute('width', barW); rect.setAttribute('height', barHeight); rect.setAttribute('fill', color); rect.setAttribute('rx', '3'); svg.appendChild(rect); var valTxt = document.createElementNS(ns, 'text'); valTxt.setAttribute('x', labelWidth + barW + 8); valTxt.setAttribute('y', y + barHeight / 2 + 4); valTxt.setAttribute('font-size', '12'); valTxt.setAttribute('fill', '#605e5c'); valTxt.textContent = String(val); svg.appendChild(valTxt); } container.appendChild(svg); } var statusMap = {}; var byStatus = prop(summary, 'ByStatus') || prop(summary, 'byStatus'); if (byStatus) { for (var k in byStatus) { if (byStatus.hasOwnProperty(k)) statusMap[k] = byStatus[k]; } } renderBarChart('status-chart', statusMap, { active: '#d83b01', fixed: '#107c10' }); var resourceMap = {}; var byResource = prop(summary, 'ByResourceType') || prop(summary, 'byResourceType'); if (byResource) { for (var k in byResource) { if (byResource.hasOwnProperty(k)) resourceMap[k] = byResource[k]; } } renderBarChart('resource-chart', resourceMap, {}); // Timeline Tab: Build filter bar using DOM var filterBar = document.getElementById('filter-bar'); var statusLabel = textEl('label', 'Status:'); statusLabel.setAttribute('for', 'filter-status'); var statusSelect = el('select', { id: 'filter-status' }); var statusOpts = [['all', 'All'], ['active', 'Active'], ['fixed', 'Fixed']]; for (var i = 0; i < statusOpts.length; i++) { var o = el('option', { value: statusOpts[i][0] }, statusOpts[i][1]); statusSelect.appendChild(o); } var statusDiv = el('div', {}, [statusLabel, statusSelect]); filterBar.appendChild(statusDiv); var rtLabel = textEl('label', 'Resource Type:'); rtLabel.setAttribute('for', 'filter-resource'); var rtSelect = el('select', { id: 'filter-resource' }); rtSelect.appendChild(el('option', { value: 'all' }, 'All')); var resourceTypes = {}; for (var i = 0; i < drifts.length; i++) { var rt = prop(drifts[i], 'ResourceType') || prop(drifts[i], 'resourceType') || 'Unknown'; if (!resourceTypes[rt]) { resourceTypes[rt] = true; rtSelect.appendChild(el('option', { value: rt }, rt)); } } var rtDiv = el('div', {}, [rtLabel, rtSelect]); filterBar.appendChild(rtDiv); function renderTimeline() { var statusFilter = document.getElementById('filter-status').value; var rtFilter = document.getElementById('filter-resource').value; var filtered = []; for (var i = 0; i < drifts.length; i++) { var d = drifts[i]; var st = (prop(d, 'Status') || prop(d, 'status') || '').toLowerCase(); var rt = prop(d, 'ResourceType') || prop(d, 'resourceType') || 'Unknown'; if (statusFilter !== 'all' && st !== statusFilter) continue; if (rtFilter !== 'all' && rt !== rtFilter) continue; filtered.push(d); } // Build drift table using DOM var tableContainer = document.getElementById('drift-table-container'); tableContainer.textContent = ''; var table = document.createElement('table'); var thead = document.createElement('thead'); var headerRow = document.createElement('tr'); var headers = ['Resource Type', 'Resource', 'Status', 'First Detected', 'Drifted Properties']; for (var h = 0; h < headers.length; h++) { headerRow.appendChild(textEl('th', headers[h])); } thead.appendChild(headerRow); table.appendChild(thead); var tbody = document.createElement('tbody'); if (filtered.length === 0) { var emptyRow = document.createElement('tr'); var emptyCell = textEl('td', 'No drifts match the current filters.', 'no-data'); emptyCell.setAttribute('colspan', '5'); emptyRow.appendChild(emptyCell); tbody.appendChild(emptyRow); } else { for (var i = 0; i < filtered.length; i++) { var d = filtered[i]; var row = document.createElement('tr'); row.appendChild(textEl('td', prop(d, 'ResourceType') || prop(d, 'resourceType') || '')); row.appendChild(textEl('td', prop(d, 'BaselineResourceDisplayName') || prop(d, 'baselineResourceDisplayName') || '')); var stVal = prop(d, 'Status') || prop(d, 'status') || ''; var stCell = document.createElement('td'); stCell.appendChild(textEl('span', stVal, 'badge ' + (stVal === 'active' ? 'badge-active' : 'badge-fixed'))); row.appendChild(stCell); row.appendChild(textEl('td', String(prop(d, 'FirstReportedDateTime') || prop(d, 'firstReportedDateTime') || ''))); var dpArr = prop(d, 'DriftedProperties') || prop(d, 'driftedProperties') || []; var propsText = []; for (var j = 0; j < dpArr.length; j++) { propsText.push(prop(dpArr[j], 'propertyName') || prop(dpArr[j], 'PropertyName') || ''); } row.appendChild(textEl('td', propsText.join(', '))); tbody.appendChild(row); } } table.appendChild(tbody); tableContainer.appendChild(table); // SVG Timeline using DOM var timelineContainer = document.getElementById('timeline-chart'); timelineContainer.textContent = ''; if (filtered.length === 0) { timelineContainer.appendChild(textEl('div', 'No drifts to display on timeline.', 'no-data')); return; } var dates = []; var rtList = []; var rtSet = {}; for (var i = 0; i < filtered.length; i++) { var dtStr = prop(filtered[i], 'FirstReportedDateTime') || prop(filtered[i], 'firstReportedDateTime') || ''; if (dtStr) { var dateObj = new Date(dtStr); if (!isNaN(dateObj.getTime())) dates.push(dateObj); } var rt = prop(filtered[i], 'ResourceType') || prop(filtered[i], 'resourceType') || 'Unknown'; if (!rtSet[rt]) { rtSet[rt] = true; rtList.push(rt); } } if (dates.length === 0) { timelineContainer.appendChild(textEl('div', 'No valid dates for timeline.', 'no-data')); return; } var minDate = dates[0], maxDate = dates[0]; for (var i = 1; i < dates.length; i++) { if (dates[i] < minDate) minDate = dates[i]; if (dates[i] > maxDate) maxDate = dates[i]; } var leftMargin = 240, rightMargin = 40, topMargin = 30, rowHeight = 36, chartWidth = 600; var svgWidth = leftMargin + chartWidth + rightMargin; var svgHeight = topMargin + rtList.length * rowHeight + 40; var dateRange = maxDate.getTime() - minDate.getTime(); if (dateRange === 0) dateRange = 86400000; var ns = 'http://www.w3.org/2000/svg'; var svg = document.createElementNS(ns, 'svg'); svg.setAttribute('width', svgWidth); svg.setAttribute('height', svgHeight); // Y-axis labels and grid lines for (var i = 0; i < rtList.length; i++) { var y = topMargin + i * rowHeight + rowHeight / 2; var lbl = document.createElementNS(ns, 'text'); lbl.setAttribute('x', leftMargin - 8); lbl.setAttribute('y', y + 4); lbl.setAttribute('text-anchor', 'end'); lbl.setAttribute('font-size', '11'); lbl.setAttribute('fill', '#323130'); lbl.textContent = rtList[i]; svg.appendChild(lbl); var line = document.createElementNS(ns, 'line'); line.setAttribute('x1', leftMargin); line.setAttribute('y1', y); line.setAttribute('x2', leftMargin + chartWidth); line.setAttribute('y2', y); line.setAttribute('stroke', '#edebe9'); line.setAttribute('stroke-width', '1'); svg.appendChild(line); } // X-axis date labels var numLabels = Math.min(5, filtered.length); for (var i = 0; i <= numLabels; i++) { var t = minDate.getTime() + (dateRange * i / numLabels); var d = new Date(t); var label = d.toISOString().substring(0, 10); var x = leftMargin + (chartWidth * i / numLabels); var dtLbl = document.createElementNS(ns, 'text'); dtLbl.setAttribute('x', x); dtLbl.setAttribute('y', svgHeight - 5); dtLbl.setAttribute('text-anchor', 'middle'); dtLbl.setAttribute('font-size', '10'); dtLbl.setAttribute('fill', '#a19f9d'); dtLbl.textContent = label; svg.appendChild(dtLbl); } // Drift circles for (var i = 0; i < filtered.length; i++) { var d = filtered[i]; var dtStr = prop(d, 'FirstReportedDateTime') || prop(d, 'firstReportedDateTime') || ''; var dateObj = new Date(dtStr); if (isNaN(dateObj.getTime())) continue; var rt = prop(d, 'ResourceType') || prop(d, 'resourceType') || 'Unknown'; var st = (prop(d, 'Status') || prop(d, 'status') || '').toLowerCase(); var rn = prop(d, 'BaselineResourceDisplayName') || prop(d, 'baselineResourceDisplayName') || ''; var rtIdx = rtList.indexOf(rt); var cx = leftMargin + ((dateObj.getTime() - minDate.getTime()) / dateRange) * chartWidth; var cy = topMargin + rtIdx * rowHeight + rowHeight / 2; var color = st === 'active' ? '#d83b01' : '#107c10'; var circle = document.createElementNS(ns, 'circle'); circle.setAttribute('cx', cx); circle.setAttribute('cy', cy); circle.setAttribute('r', '7'); circle.setAttribute('fill', color); circle.setAttribute('stroke', '#fff'); circle.setAttribute('stroke-width', '2'); circle.style.cursor = 'pointer'; // Store tooltip data as a data attribute var tipText = rn + ' (' + st + ') - ' + dtStr; circle.setAttribute('data-tip', tipText); circle.addEventListener('mouseenter', function(e) { tooltip.textContent = this.getAttribute('data-tip'); tooltip.style.display = 'block'; tooltip.style.left = (e.pageX + 12) + 'px'; tooltip.style.top = (e.pageY - 8) + 'px'; }); circle.addEventListener('mouseleave', function() { tooltip.style.display = 'none'; }); svg.appendChild(circle); } timelineContainer.appendChild(svg); } statusSelect.addEventListener('change', renderTimeline); rtSelect.addEventListener('change', renderTimeline); renderTimeline(); // Monitor Details Tab (DOM-based) var monitorCardsEl = document.getElementById('monitor-cards'); if (monitors.length === 0) { monitorCardsEl.appendChild(textEl('div', 'No monitors configured.', 'no-data')); } else { for (var i = 0; i < monitors.length; i++) { var m = monitors[i]; var mId = prop(m, 'Id') || prop(m, 'id'); var mName = prop(m, 'DisplayName') || prop(m, 'displayName') || ''; var mDesc = prop(m, 'Description') || prop(m, 'description') || ''; var mStatus = prop(m, 'Status') || prop(m, 'status') || ''; var mFreq = prop(m, 'MonitorRunFrequencyInHours') || prop(m, 'monitorRunFrequencyInHours') || '-'; var card = el('div', { className: 'monitor-card' }); var titleEl = document.createElement('h3'); titleEl.textContent = mName + ' '; var badge = textEl('span', mStatus, 'badge badge-status-active'); titleEl.appendChild(badge); card.appendChild(titleEl); card.appendChild(textEl('div', mDesc, 'meta')); card.appendChild(textEl('div', 'Frequency: every ' + mFreq + 'h | ID: ' + mId, 'meta')); // Last run result for (var j = 0; j < monitorResults.length; j++) { var rMid = prop(monitorResults[j], 'MonitorId') || prop(monitorResults[j], 'monitorId'); if (rMid === mId) { var runStatus = prop(monitorResults[j], 'RunStatus') || prop(monitorResults[j], 'runStatus') || '-'; var driftsCnt = prop(monitorResults[j], 'DriftsCount') || prop(monitorResults[j], 'driftsCount') || 0; var runStart = String(prop(monitorResults[j], 'RunInitiationDateTime') || prop(monitorResults[j], 'runInitiationDateTime') || ''); var rsBadge = 'badge-running'; if (runStatus === 'successful') rsBadge = 'badge-succeeded'; else if (runStatus === 'failed') rsBadge = 'badge-failed'; var resultDiv = el('div', { className: 'meta' }); resultDiv.textContent = 'Last Run: '; resultDiv.appendChild(textEl('span', runStatus, 'badge ' + rsBadge)); var runMeta = document.createTextNode(' | Drifts: ' + driftsCnt + ' | ' + runStart); resultDiv.appendChild(runMeta); card.appendChild(resultDiv); break; } } // Baseline resources for (var j = 0; j < baselines.length; j++) { var bl = baselines[j]; var resources = prop(bl, 'Resources') || prop(bl, 'resources') || []; if (resources.length > 0) { var expHeader = textEl('div', 'Baseline Resources (' + resources.length + ')', 'expandable-header'); expHeader.addEventListener('click', function() { this.classList.toggle('expanded'); var content = this.nextElementSibling; if (content) content.classList.toggle('expanded'); }); card.appendChild(expHeader); var expContent = el('div', { className: 'expandable-content' }); var ul = document.createElement('ul'); ul.className = 'resource-list'; for (var k = 0; k < resources.length; k++) { var resName = prop(resources[k], 'displayName') || prop(resources[k], 'DisplayName') || ''; var resType = prop(resources[k], 'resourceType') || prop(resources[k], 'ResourceType') || ''; var li = document.createElement('li'); li.appendChild(textEl('strong', resName)); li.appendChild(document.createTextNode(' (' + resType + ')')); ul.appendChild(li); } expContent.appendChild(ul); card.appendChild(expContent); break; } } monitorCardsEl.appendChild(card); } } // Snapshot Comparison Tab (DOM-based) var snapshotSection = document.getElementById('snapshot-section'); if (snapshots.length === 0) { snapshotSection.appendChild(textEl('div', 'No snapshots included. Use -IncludeSnapshots when generating the dashboard.', 'no-data')); } else { var controls = el('div', { className: 'comparison-controls' }); var groupA = el('div', { className: 'select-group' }); groupA.appendChild(textEl('label', 'Snapshot A:')); var selectA = el('select', { id: 'snap-a' }); for (var i = 0; i < snapshots.length; i++) { var sName = prop(snapshots[i], 'DisplayName') || prop(snapshots[i], 'displayName') || ''; var sId = prop(snapshots[i], 'Id') || prop(snapshots[i], 'id') || ''; selectA.appendChild(el('option', { value: sId }, sName)); } groupA.appendChild(selectA); controls.appendChild(groupA); var groupB = el('div', { className: 'select-group' }); groupB.appendChild(textEl('label', 'Snapshot B:')); var selectB = el('select', { id: 'snap-b' }); for (var i = 0; i < snapshots.length; i++) { var sName = prop(snapshots[i], 'DisplayName') || prop(snapshots[i], 'displayName') || ''; var sId = prop(snapshots[i], 'Id') || prop(snapshots[i], 'id') || ''; var opt = el('option', { value: sId }, sName); if (i === 1) opt.selected = true; selectB.appendChild(opt); } groupB.appendChild(selectB); controls.appendChild(groupB); var compareBtn = textEl('button', 'Compare'); compareBtn.addEventListener('click', doCompare); controls.appendChild(compareBtn); snapshotSection.appendChild(controls); var resultContainer = el('div', { id: 'comparison-result' }); snapshotSection.appendChild(resultContainer); } function doCompare() { var aId = document.getElementById('snap-a').value; var bId = document.getElementById('snap-b').value; var resultEl = document.getElementById('comparison-result'); resultEl.textContent = ''; var snapA = null, snapB = null; for (var i = 0; i < snapshots.length; i++) { var id = prop(snapshots[i], 'Id') || prop(snapshots[i], 'id'); if (id === aId) snapA = snapshots[i]; if (id === bId) snapB = snapshots[i]; } if (!snapA || !snapB) { resultEl.appendChild(textEl('div', 'Select two snapshots to compare.', 'no-data')); return; } var resA = prop(snapA, 'Resources') || prop(snapA, 'resources') || []; var resB = prop(snapB, 'Resources') || prop(snapB, 'resources') || []; var setA = {}, setB = {}; for (var i = 0; i < resA.length; i++) setA[resA[i]] = true; for (var i = 0; i < resB.length; i++) setB[resB[i]] = true; resultEl.appendChild(textEl('h2', 'Resource Coverage Comparison')); var table = document.createElement('table'); table.className = 'diff-table'; var thead = document.createElement('thead'); var hRow = document.createElement('tr'); hRow.appendChild(textEl('th', 'Resource Type')); hRow.appendChild(textEl('th', 'Snapshot A')); hRow.appendChild(textEl('th', 'Snapshot B')); thead.appendChild(hRow); table.appendChild(thead); var tbody = document.createElement('tbody'); var allRes = {}; for (var r in setA) allRes[r] = true; for (var r in setB) allRes[r] = true; var allKeys = []; for (var r in allRes) allKeys.push(r); allKeys.sort(); for (var i = 0; i < allKeys.length; i++) { var r = allKeys[i]; var inA = !!setA[r], inB = !!setB[r]; var row = document.createElement('tr'); if (inA && !inB) row.className = 'diff-removed'; else if (!inA && inB) row.className = 'diff-added'; row.appendChild(textEl('td', r)); row.appendChild(textEl('td', inA ? 'Yes' : '-')); row.appendChild(textEl('td', inB ? 'Yes' : '-')); tbody.appendChild(row); } table.appendChild(tbody); resultEl.appendChild(table); // Property-level diff var contentA = null, contentB = null; for (var i = 0; i < snapshotContents.length; i++) { var scId = prop(snapshotContents[i], 'SnapshotId') || prop(snapshotContents[i], 'snapshotId'); if (scId === aId) contentA = prop(snapshotContents[i], 'Content') || prop(snapshotContents[i], 'content'); if (scId === bId) contentB = prop(snapshotContents[i], 'Content') || prop(snapshotContents[i], 'content'); } if (contentA && contentB) { resultEl.appendChild(textEl('h2', 'Property-Level Differences')); var diffTable = document.createElement('table'); diffTable.className = 'diff-table'; var dThead = document.createElement('thead'); var dHRow = document.createElement('tr'); dHRow.appendChild(textEl('th', 'Resource Type')); dHRow.appendChild(textEl('th', 'Property')); dHRow.appendChild(textEl('th', 'Snapshot A')); dHRow.appendChild(textEl('th', 'Snapshot B')); dThead.appendChild(dHRow); diffTable.appendChild(dThead); var dTbody = document.createElement('tbody'); var hasDiffs = false; for (var i = 0; i < allKeys.length; i++) { var r = allKeys[i]; if (!setA[r] || !setB[r]) continue; var propsA = findResourceProps(contentA, r); var propsB = findResourceProps(contentB, r); if (!propsA && !propsB) continue; var allPropNames = {}; if (propsA) { for (var p in propsA) allPropNames[p] = true; } if (propsB) { for (var p in propsB) allPropNames[p] = true; } for (var p in allPropNames) { var vA = propsA ? (propsA[p] !== undefined ? String(propsA[p]) : '-') : '-'; var vB = propsB ? (propsB[p] !== undefined ? String(propsB[p]) : '-') : '-'; if (vA !== vB) { hasDiffs = true; var cls = (vA === '-') ? 'diff-added' : (vB === '-') ? 'diff-removed' : 'diff-changed'; var dRow = el('tr', { className: cls }); dRow.appendChild(textEl('td', r)); dRow.appendChild(textEl('td', p)); var cA = document.createElement('td'); cA.appendChild(textEl('code', vA)); dRow.appendChild(cA); var cB = document.createElement('td'); cB.appendChild(textEl('code', vB)); dRow.appendChild(cB); dTbody.appendChild(dRow); } } } if (!hasDiffs) { var noDiffRow = document.createElement('tr'); var noDiffCell = textEl('td', 'No property differences found.', 'no-data'); noDiffCell.setAttribute('colspan', '4'); noDiffRow.appendChild(noDiffCell); dTbody.appendChild(noDiffRow); } diffTable.appendChild(dTbody); resultEl.appendChild(diffTable); } } function findResourceProps(content, resourceType) { if (!content) return null; var resources = prop(content, 'Resources') || prop(content, 'resources') || prop(content, 'Content') || prop(content, 'content'); if (resources && resources.Resources) resources = resources.Resources; if (resources && resources.resources) resources = resources.resources; if (!Array.isArray(resources)) return null; for (var i = 0; i < resources.length; i++) { var rt = prop(resources[i], 'resourceType') || prop(resources[i], 'ResourceType'); if (rt === resourceType) { return prop(resources[i], 'properties') || prop(resources[i], 'Properties') || {}; } } return null; } })(); </script> </body> </html> "@ return $html } |