← Back to all posts

Build a Real-Time Compliance Dashboard with Microsoft Graph and PowerShell

Why Build This?

When a customer asks "how many of our devices are compliant right now?" most engineers open the Intune portal and click through the compliance reports. That's fine for a one-off. But if you need to share this regularly, automate escalations, or build something a manager can refresh themselves — you need Graph.

This walkthrough builds a self-contained PowerShell script that queries Microsoft Graph and generates a clean HTML dashboard. No Power BI. No Azure subscription. Just Graph + PowerShell.

Prerequisites

  • Microsoft.Graph PowerShell module (or we'll use direct REST calls)
  • An app registration in Entra ID with DeviceManagementManagedDevices.Read.All permission
  • PowerShell 7+

Step 1 — App Registration

In the Entra admin center:

  1. Go to App registrationsNew registration
  2. Name it Intune-Reporting
  3. Under API permissions, add DeviceManagementManagedDevices.Read.All (Application permission)
  4. Grant admin consent
  5. Create a Client secret and note it down

Step 2 — Authenticate and Get a Token

$tenantId     = "your-tenant-id"
$clientId     = "your-app-client-id"
$clientSecret = "your-client-secret"

$body = @{
  grant_type    = "client_credentials"
  scope         = "https://graph.microsoft.com/.default"
  client_id     = $clientId
  client_secret = $clientSecret
}

$tokenResponse = Invoke-RestMethod `
  -Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" `
  -Method POST -Body $body

$token = $tokenResponse.access_token

Step 3 — Query Managed Devices

$headers = @{ Authorization = "Bearer $token" }

$uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices" +
       "?`$select=deviceName,complianceState,osVersion,userPrincipalName,lastSyncDateTime,operatingSystem" +
       "&`$top=999"

$devices = @()
do {
  $response = Invoke-RestMethod -Uri $uri -Headers $headers -Method GET
  $devices += $response.value
  $uri = $response.'@odata.nextLink'
} while ($uri)

Write-Host "Total devices: $($devices.Count)"

The do/while loop handles pagination — critical for tenants with 500+ devices.

Step 4 — Generate the HTML Report

$compliant    = ($devices | Where-Object complianceState -eq "compliant").Count
$nonCompliant = ($devices | Where-Object complianceState -eq "noncompliant").Count
$unknown      = ($devices | Where-Object complianceState -eq "unknown").Count
$total        = $devices.Count
$compliancePct = [math]::Round(($compliant / $total) * 100, 1)

$rows = $devices | Sort-Object complianceState | ForEach-Object {
  $stateColor = switch ($_.complianceState) {
    "compliant"    { "#15803d" }
    "noncompliant" { "#dc2626" }
    default        { "#9ca3af" }
  }
  "<tr>
    <td>$($_.deviceName)</td>
    <td>$($_.userPrincipalName)</td>
    <td>$($_.operatingSystem) $($_.osVersion)</td>
    <td style='color:$stateColor;font-weight:600'>$($_.complianceState)</td>
    <td>$($_.lastSyncDateTime)</td>
  </tr>"
}

$html = @"
<!DOCTYPE html>
<html>
<head><title>Intune Compliance Report</title></head>
<body style="font-family:sans-serif;max-width:1100px;margin:40px auto;padding:0 24px">
  <h1>Intune Compliance — $(Get-Date -Format 'MMMM dd, yyyy')</h1>
  <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:24px 0">
    <div style="padding:20px;background:#f9fafb;border-radius:8px;border:1px solid #e5e7eb">
      <div style="font-size:28px;font-weight:700">$total</div><div>Total Devices</div>
    </div>
    <div style="padding:20px;background:#f0fdf4;border-radius:8px;border:1px solid #bbf7d0">
      <div style="font-size:28px;font-weight:700;color:#15803d">$compliant</div><div>Compliant</div>
    </div>
    <div style="padding:20px;background:#fef2f2;border-radius:8px;border:1px solid #fecaca">
      <div style="font-size:28px;font-weight:700;color:#dc2626">$nonCompliant</div><div>Non-Compliant</div>
    </div>
    <div style="padding:20px;background:#eff6ff;border-radius:8px;border:1px solid #bfdbfe">
      <div style="font-size:28px;font-weight:700;color:#1d4ed8">$compliancePct%</div><div>Compliance Rate</div>
    </div>
  </div>
  <table style="width:100%;border-collapse:collapse;font-size:14px">
    <thead><tr style="background:#f3f4f6">
      <th style="padding:10px;text-align:left;border:1px solid #e5e7eb">Device</th>
      <th style="padding:10px;text-align:left;border:1px solid #e5e7eb">User</th>
      <th style="padding:10px;text-align:left;border:1px solid #e5e7eb">OS</th>
      <th style="padding:10px;text-align:left;border:1px solid #e5e7eb">State</th>
      <th style="padding:10px;text-align:left;border:1px solid #e5e7eb">Last Sync</th>
    </tr></thead>
    <tbody>$($rows -join '')</tbody>
  </table>
</body></html>
"@

$html | Out-File "compliance-report.html" -Encoding UTF8
Start-Process "compliance-report.html"

Automating Weekly Reports

Schedule this in Azure Automation or a simple Task Scheduler job on a management server. Store the client secret in Azure Key Vault and retrieve it at runtime — never hardcode secrets.

# Retrieve secret from Key Vault
$secret = Get-AzKeyVaultSecret -VaultName "YourVault" -Name "IntuneReportingSecret" -AsPlainText

What to Build Next

Once you have this baseline, you can extend it to:

  • Filter for devices not synced in 30+ days
  • Export non-compliant devices to a Teams channel via webhook
  • Compare compliance across device platforms (Windows vs iOS vs Android)
  • Trigger automated remediation scripts via Graph for specific policy failures
Related Posts
Why Hybrid AAD Join Breaks Silently — and How to Actually Fix It12 min read · Feb 2026