Public/Export-TCMDriftReport.ps1
|
function Export-TCMDriftReport { <# .SYNOPSIS Generate an HTML drift report with admin portal deep links. .DESCRIPTION Creates a self-contained HTML report showing: - Monitor overview and status - Active drifts with property-level detail - Baseline resource inventory - Quota dashboard - Deep links to Entra/Exchange/Intune/Teams admin portals for remediation Works with or without active drifts — use it as a status dashboard. .PARAMETER OutputPath Path for the HTML file. Defaults to ./EasyTCM-Report-<timestamp>.html .PARAMETER Open Open the report in the default browser after generating. .PARAMETER MonitorId Report on a specific monitor. If omitted, reports on all monitors. .PARAMETER CompareBaseline Also detect new/deleted resources by running Compare-TCMBaseline. This takes a fresh snapshot (uses quota) and adds a Baseline Drift section to the report. Without this flag, only property drifts on existing monitored resources are shown. .EXAMPLE Export-TCMDriftReport -Open .EXAMPLE Export-TCMDriftReport -Open -CompareBaseline .EXAMPLE Export-TCMDriftReport -OutputPath "./reports/drift-report.html" -MonitorId $id #> [CmdletBinding()] param( [string]$OutputPath, [switch]$Open, [string]$MonitorId, [switch]$CompareBaseline ) $timestamp = Get-Date -Format 'yyyy-MM-dd_HHmmss' if (-not $OutputPath) { $OutputPath = "./EasyTCM-Report-$timestamp.html" } Write-Host 'Generating EasyTCM drift report...' -ForegroundColor Cyan # Gather data $monitors = if ($MonitorId) { @(Get-TCMMonitor -Id $MonitorId) } else { $all = Get-TCMMonitor if (-not $all) { @() } else { @($all) } } $drifts = @(Get-TCMDrift) $quota = Get-TCMQuota -PassThru # Optional: run Compare-TCMBaseline to detect new/deleted resources $baselineComparison = $null if ($CompareBaseline) { Write-Host 'Running baseline comparison (takes a snapshot)...' -ForegroundColor Cyan $compareParams = @{ Detailed = $true } if ($MonitorId) { $compareParams.MonitorId = $MonitorId } $baselineComparison = Compare-TCMBaseline @compareParams } # Build monitor details with baselines $monitorData = foreach ($m in $monitors) { $mId = if ($m -is [System.Collections.IDictionary]) { $m['id'] } else { $m.id } $mDn = if ($m -is [System.Collections.IDictionary]) { $m['displayName'] } else { $m.displayName } $mSt = if ($m -is [System.Collections.IDictionary]) { $m['status'] } else { $m.status } $mCreated = if ($m -is [System.Collections.IDictionary]) { $m['createdDateTime'] } else { $m.createdDateTime } $baseline = $null $resourceCount = 0 try { $baseline = Invoke-TCMGraphRequest -Endpoint "configurationMonitors/$mId/baseline" $resources = if ($baseline.Resources) { $baseline.Resources } elseif ($baseline.resources) { $baseline.resources } else { @() } $resourceCount = @($resources).Count } catch { Write-Debug "Could not retrieve baseline for monitor ${mId}: $_" } [PSCustomObject]@{ Id = $mId DisplayName = $mDn Status = $mSt Created = $mCreated ResourceCount = $resourceCount Resources = $resources Drifts = @($drifts | Where-Object { $did = if ($_ -is [System.Collections.IDictionary]) { $_['monitorId'] } else { $_.monitorId } $did -eq $mId }) } } # Admin portal deep links by resource type $portalLinks = @{ 'microsoft.entra.conditionalaccesspolicy' = 'https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/ConditionalAccessBlade/~/Policies' 'microsoft.entra.namedlocationpolicy' = 'https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/ConditionalAccessBlade/~/NamedLocations' 'microsoft.entra.authenticationmethodpolicy' = 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/AuthenticationMethodsMenuBlade/~/AdminAuthMethods' 'microsoft.entra.authorizationpolicy' = 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/TenantOverview.ReactView' 'microsoft.entra.crosstenantaccesspolicy' = 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/CompanyRelationshipsMenuBlade/~/CrossTenantAccessSettings' 'microsoft.entra.crosstenantaccesspolicyconfigurationpartner'= 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/CompanyRelationshipsMenuBlade/~/CrossTenantAccessSettings' 'microsoft.entra.roledefinition' = 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/RolesManagementMenuBlade/~/AllRoles' 'microsoft.entra.administrativeunit' = 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/AdminUnitsBlade' 'microsoft.entra.grouplifecyclepolicy' = 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/GroupsManagementMenuBlade/~/Lifecycle' 'microsoft.entra.externalidentitypolicy' = 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/CompanyRelationshipsMenuBlade/~/Settings' 'microsoft.exchange.transportrule' = 'https://admin.exchange.microsoft.com/#/transportrules' 'microsoft.exchange.accepteddomain' = 'https://admin.exchange.microsoft.com/#/accepteddomains' 'microsoft.exchange.antiphishpolicy' = 'https://security.microsoft.com/antiphishing' 'microsoft.exchange.safeattachmentpolicy' = 'https://security.microsoft.com/safeattachmentv2' 'microsoft.exchange.safelinkspolicy' = 'https://security.microsoft.com/safelinksv2' 'microsoft.exchange.hostedcontentfilterpolicy' = 'https://security.microsoft.com/antispam' 'microsoft.exchange.hostedoutboundspamfilterpolicy' = 'https://security.microsoft.com/antispam' 'microsoft.exchange.organizationconfig' = 'https://admin.exchange.microsoft.com/#/settings' 'microsoft.teams.meetingpolicy' = 'https://admin.teams.microsoft.com/policies/meetings' 'microsoft.teams.messagingpolicy' = 'https://admin.teams.microsoft.com/policies/messaging' 'microsoft.teams.apppermissionpolicy' = 'https://admin.teams.microsoft.com/policies/app-permission' 'microsoft.teams.meetingconfiguration' = 'https://admin.teams.microsoft.com/meetings/settings' 'microsoft.teams.federationconfiguration' = 'https://admin.teams.microsoft.com/company-wide-settings/external-communications' 'microsoft.teams.dialinconferencingtenantsettings' = 'https://admin.teams.microsoft.com/meetings/conference-bridges' 'microsoft.securityandcompliance.dlpcompliancepolicy' = 'https://compliance.microsoft.com/datalossprevention' 'microsoft.securityandcompliance.retentioncompliancepolicy' = 'https://compliance.microsoft.com/informationgovernance' 'microsoft.securityandcompliance.labelpolicy' = 'https://compliance.microsoft.com/informationprotection' } # Build HTML $totalDrifts = $drifts.Count $statusColor = if ($totalDrifts -gt 0) { '#e74c3c' } else { '#27ae60' } $statusIcon = if ($totalDrifts -gt 0) { '⚠' } else { '✔' } $statusText = if ($totalDrifts -gt 0) { "$totalDrifts Active Drift(s)" } else { 'No Active Drifts' } $quotaMonitorPct = if ($quota.MonitorLimit -gt 0) { [math]::Round(($quota.MonitorCount / $quota.MonitorLimit) * 100) } else { 0 } $quotaDailyPct = if ($quota.DailyResourceLimit -gt 0) { [math]::Round(($quota.DailyResourceUsage / $quota.DailyResourceLimit) * 100) } else { 0 } $quotaSnapPct = if ($quota.SnapshotJobLimit -gt 0) { [math]::Round(($quota.SnapshotJobCount / $quota.SnapshotJobLimit) * 100) } else { 0 } # Generate drift rows $driftRows = '' foreach ($monitor in $monitorData) { foreach ($drift in $monitor.Drifts) { $dDn = if ($drift -is [System.Collections.IDictionary]) { $drift['ResourceDisplay'] ?? $drift['baselineResourceDisplayName'] ?? $drift['displayName'] } else { $drift.ResourceDisplay ?? $drift.baselineResourceDisplayName ?? $drift.displayName } $dType = if ($drift -is [System.Collections.IDictionary]) { $drift['ResourceType'] ?? $drift['resourceType'] } else { $drift.ResourceType ?? $drift.resourceType } $dStatus = if ($drift -is [System.Collections.IDictionary]) { $drift['Status'] ?? $drift['status'] } else { $drift.Status ?? $drift.status } $dProps = if ($drift -is [System.Collections.IDictionary]) { $drift['DriftedProperties'] ?? $drift['driftedProperties'] } else { $drift.DriftedProperties ?? $drift.driftedProperties } $portalLink = if ($portalLinks.ContainsKey($dType)) { $portalLinks[$dType] } else { '#' } $propCount = @($dProps).Count $propDetails = '' foreach ($p in $dProps) { $pName = if ($p -is [System.Collections.IDictionary]) { $p['propertyName'] } else { $p.propertyName } $pExpectedRaw = if ($p -is [System.Collections.IDictionary]) { $p['desiredValue'] ?? $p['expectedValue'] } else { $p.desiredValue ?? $p.expectedValue } $pActualRaw = if ($p -is [System.Collections.IDictionary]) { $p['currentValue'] } else { $p.currentValue } $pExpected = if ($pExpectedRaw -is [System.Collections.IEnumerable] -and $pExpectedRaw -isnot [string]) { $pExpectedRaw -join ' ' } else { "$pExpectedRaw" } $pActual = if ($pActualRaw -is [System.Collections.IEnumerable] -and $pActualRaw -isnot [string]) { $pActualRaw -join ' ' } else { "$pActualRaw" } $propDetails += "<div class='prop-row'><span class='prop-name'>$([System.Web.HttpUtility]::HtmlEncode($pName))</span><span class='prop-expected'>$([System.Web.HttpUtility]::HtmlEncode("$pExpected"))</span><span class='prop-actual'>$([System.Web.HttpUtility]::HtmlEncode("$pActual"))</span></div>" } $driftRows += @" <tr> <td>$([System.Web.HttpUtility]::HtmlEncode($monitor.DisplayName))</td> <td>$([System.Web.HttpUtility]::HtmlEncode($dDn))</td> <td><code>$([System.Web.HttpUtility]::HtmlEncode($dType))</code></td> <td class="center">$propCount</td> <td><span class="badge badge-$dStatus">$dStatus</span></td> <td><a href="$portalLink" target="_blank" class="portal-link">Open Portal ↗</a></td> </tr> <tr class="prop-detail-row"><td colspan="6">$propDetails</td></tr> "@ } } if (-not $driftRows) { $driftRows = '<tr><td colspan="6" class="center no-drift">No active drifts detected. Your tenant configuration matches all baselines.</td></tr>' } # Generate monitor/baseline rows $monitorRows = '' foreach ($monitor in $monitorData) { $statusBadge = if ($monitor.Status -eq 'active') { 'active' } else { 'inactive' } $monitorRows += "<tr><td>$([System.Web.HttpUtility]::HtmlEncode($monitor.DisplayName))</td><td><span class='badge badge-$statusBadge'>$($monitor.Status)</span></td><td class='center'>$($monitor.ResourceCount)</td><td>$($monitor.Created)</td><td><code>$($monitor.Id)</code></td></tr>" # Resource breakdown if ($monitor.Resources) { $grouped = @($monitor.Resources) | Group-Object { if ($_ -is [System.Collections.IDictionary]) { $_['ResourceType'] ?? $_['resourceType'] } else { $_.ResourceType ?? $_.resourceType } } foreach ($g in $grouped | Sort-Object Name) { $portalLink = if ($portalLinks.ContainsKey($g.Name)) { "<a href='$($portalLinks[$g.Name])' target='_blank'>↗</a>" } else { '' } $monitorRows += "<tr class='resource-row'><td colspan='2' style='padding-left:2rem'><code>$([System.Web.HttpUtility]::HtmlEncode($g.Name))</code> $portalLink</td><td class='center'>$($g.Count)</td><td colspan='2'></td></tr>" } } } # Build baseline drift HTML section $baselineDriftSection = '' if ($baselineComparison) { $blNewRows = '' foreach ($r in $baselineComparison.NewResources) { $shortType = ($r.ResourceType -split '\.')[-1] $name = if ($r.DisplayName -and $r.DisplayName -ne $r.Id) { $r.DisplayName } else { $r.Id } $portalCell = if ($portalLinks.ContainsKey($r.ResourceType)) { "<a href='$($portalLinks[$r.ResourceType])' target='_blank' class='portal-link'>Open Portal ↗</a>" } else { '' } $blNewRows += "<tr><td><span class='badge badge-active'>+ New</span></td><td>$([System.Web.HttpUtility]::HtmlEncode($name))</td><td><code>$([System.Web.HttpUtility]::HtmlEncode($shortType))</code></td><td>$([System.Web.HttpUtility]::HtmlEncode($r.Id))</td><td>$portalCell</td></tr>" } foreach ($r in $baselineComparison.DeletedResources) { $shortType = ($r.ResourceType -split '\.')[-1] $name = if ($r.DisplayName -and $r.DisplayName -ne $r.Id) { $r.DisplayName } else { $r.Id } $portalCell = if ($portalLinks.ContainsKey($r.ResourceType)) { "<a href='$($portalLinks[$r.ResourceType])' target='_blank' class='portal-link'>Open Portal ↗</a>" } else { '' } $blNewRows += "<tr><td><span class='badge badge-inactive'>- Deleted</span></td><td>$([System.Web.HttpUtility]::HtmlEncode($name))</td><td><code>$([System.Web.HttpUtility]::HtmlEncode($shortType))</code></td><td>$([System.Web.HttpUtility]::HtmlEncode($r.Id))</td><td>$portalCell</td></tr>" } if (-not $blNewRows) { $blNewRows = '<tr><td colspan="5" class="center no-drift">No new or deleted resources detected. Baseline coverage is complete.</td></tr>' } $blSummary = "$($baselineComparison.NewCount) new, $($baselineComparison.DeletedCount) deleted, $($baselineComparison.MatchedCount) matched (baseline: $($baselineComparison.BaselineCount), current: $($baselineComparison.CurrentCount))" $baselineDriftSection = @" <section> <h2>📈 Baseline Drift (New / Deleted Resources)</h2> <p style='color:#666;font-size:0.9rem;margin-bottom:1rem'>$blSummary</p> <table> <thead><tr><th>Status</th><th>Resource</th><th>Type</th><th>Id</th><th>Portal</th></tr></thead> <tbody>$blNewRows</tbody> </table> </section> "@ } $html = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>EasyTCM Drift Report — $timestamp</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; background: #f0f2f5; color: #1a1a2e; line-height: 1.6; } .container { max-width: 1100px; margin: 0 auto; padding: 2rem 1rem; } header { text-align: center; margin-bottom: 2rem; } header h1 { font-size: 1.8rem; color: #1a1a2e; } header h1 span { color: #0078d4; } .subtitle { color: #666; font-size: 0.9rem; margin-top: 0.3rem; } .status-banner { background: $statusColor; color: white; padding: 1rem 1.5rem; border-radius: 10px; text-align: center; font-size: 1.3rem; margin-bottom: 2rem; } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 2rem; } .card { background: white; border-radius: 10px; padding: 1.2rem; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } .card h3 { font-size: 0.8rem; text-transform: uppercase; color: #888; letter-spacing: 0.05em; margin-bottom: 0.5rem; } .card .value { font-size: 1.8rem; font-weight: 700; } .card .detail { font-size: 0.8rem; color: #888; margin-top: 0.2rem; } .progress-bar { height: 6px; background: #e8e8e8; border-radius: 3px; margin-top: 0.5rem; overflow: hidden; } .progress-fill { height: 100%; border-radius: 3px; transition: width 0.3s; } .pf-green { background: #27ae60; } .pf-yellow { background: #f39c12; } .pf-red { background: #e74c3c; } section { margin-bottom: 2rem; } section h2 { font-size: 1.2rem; margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 2px solid #0078d4; } table { width: 100%; border-collapse: collapse; background: white; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); } th { background: #f8f9fa; text-align: left; padding: 0.8rem 1rem; font-size: 0.8rem; text-transform: uppercase; color: #666; letter-spacing: 0.03em; } td { padding: 0.7rem 1rem; border-top: 1px solid #eee; font-size: 0.9rem; } .center { text-align: center; } .badge { padding: 0.2rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; } .badge-active { background: #d4edda; color: #155724; } .badge-inactive { background: #f8d7da; color: #721c24; } .badge-fixed { background: #d1ecf1; color: #0c5460; } code { background: #f1f3f5; padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.8rem; } .portal-link { color: #0078d4; text-decoration: none; font-weight: 600; } .portal-link:hover { text-decoration: underline; } .resource-row td { background: #f8f9fa; font-size: 0.85rem; color: #555; } .prop-detail-row td { padding: 0.3rem 1rem 0.8rem 2rem; background: #fafbfc; } .prop-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem; padding: 0.2rem 0; font-size: 0.8rem; border-bottom: 1px solid #f0f0f0; } .prop-name { font-weight: 600; color: #333; } .prop-expected { color: #27ae60; } .prop-expected::before { content: 'Expected: '; font-weight: 600; } .prop-actual { color: #e74c3c; } .prop-actual::before { content: 'Actual: '; font-weight: 600; } .no-drift { color: #27ae60; font-weight: 600; padding: 2rem !important; font-size: 1rem; } footer { text-align: center; color: #999; font-size: 0.8rem; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd; } footer a { color: #0078d4; text-decoration: none; } @media (max-width: 600px) { .grid { grid-template-columns: 1fr; } .prop-row { grid-template-columns: 1fr; } } </style> </head> <body> <div class="container"> <header> <h1>🛡 <span>EasyTCM</span> Drift Report</h1> <div class="subtitle">Generated $(Get-Date -Format 'dddd, MMMM d, yyyy \a\t HH:mm:ss') UTC</div> </header> <div class="status-banner">$statusIcon $statusText</div> <div class="grid"> <div class="card"> <h3>Monitors</h3> <div class="value">$($quota.MonitorCount) <span style="font-size:0.9rem;color:#888">/ $($quota.MonitorLimit)</span></div> <div class="progress-bar"><div class="progress-fill $(if($quotaMonitorPct -gt 80){'pf-red'}elseif($quotaMonitorPct -gt 50){'pf-yellow'}else{'pf-green'})" style="width:$([math]::Min($quotaMonitorPct,100))%"></div></div> <div class="detail">$quotaMonitorPct% used</div> </div> <div class="card"> <h3>Daily Resources</h3> <div class="value">$($quota.DailyResourceUsage) <span style="font-size:0.9rem;color:#888">/ $($quota.DailyResourceLimit)</span></div> <div class="progress-bar"><div class="progress-fill $(if($quotaDailyPct -gt 80){'pf-red'}elseif($quotaDailyPct -gt 50){'pf-yellow'}else{'pf-green'})" style="width:$([math]::Min($quotaDailyPct,100))%"></div></div> <div class="detail">$quotaDailyPct% of 800/day quota</div> </div> <div class="card"> <h3>Snapshot Jobs</h3> <div class="value">$($quota.SnapshotJobCount) <span style="font-size:0.9rem;color:#888">/ $($quota.SnapshotJobLimit)</span></div> <div class="progress-bar"><div class="progress-fill $(if($quotaSnapPct -gt 80){'pf-red'}elseif($quotaSnapPct -gt 50){'pf-yellow'}else{'pf-green'})" style="width:$([math]::Min($quotaSnapPct,100))%"></div></div> <div class="detail">$quotaSnapPct% used</div> </div> <div class="card"> <h3>Active Drifts</h3> <div class="value" style="color:$statusColor">$totalDrifts</div> <div class="detail">across $(@($monitors).Count) monitor(s)</div> </div> </div> <section> <h2>⚠ Drifts</h2> <table> <thead><tr><th>Monitor</th><th>Resource</th><th>Type</th><th class="center">Properties</th><th>Status</th><th>Portal</th></tr></thead> <tbody>$driftRows</tbody> </table> </section> $baselineDriftSection <section> <h2>🔎 Monitors & Baseline Resources</h2> <table> <thead><tr><th>Monitor</th><th>Status</th><th class="center">Resources</th><th>Created</th><th>ID</th></tr></thead> <tbody>$monitorRows</tbody> </table> </section> <footer> Generated by <a href="https://github.com/kayasax/EasyTCM">EasyTCM</a> — TCM as Maester's Drift Engine<br> Monitor runs every 6 hours at fixed UTC times: 6 AM, 12 PM, 6 PM, 12 AM </footer> </div> </body> </html> "@ $html | Set-Content -Path $OutputPath -Encoding utf8 Write-Host "Report saved to: $OutputPath" -ForegroundColor Green if ($Open) { Start-Process $OutputPath } if (-not $CompareBaseline) { Write-Host '' Write-Host 'Tip: Use -CompareBaseline to also detect new/deleted resources (takes a snapshot).' -ForegroundColor DarkGray } [PSCustomObject]@{ Path = (Resolve-Path $OutputPath).Path Monitors = @($monitors).Count Drifts = $totalDrifts Generated = Get-Date } } |