The Intune SOC Triage Console replaces manual portal navigation for device compliance triage. It combines device health, compliance policy states, app deployment status, and active alerts into a single always-on interface. Engineers can search, filter, sort, and drill into any device to view policy failures and trigger remote remediation actions without leaving the console.
The console uses four tabs: Devices (main triage list with detail panel), Alerts (critical and warning alert feed), Compliance (policy-level rollup with 6-month trend chart), and Apps (deployment status with per-app device breakdown). An AI Summary button in the device detail panel sends device context to Claude Sonnet for an automated SOC triage write-up.
| Filename | stack-intune-triage.html |
| Suite | MSP · CMD Design Suite — Stack Intelligence Layer |
| External Dependencies | Chart.js 4.4.1 (CDN), Google Fonts |
| Mode at Delivery | Demo — DEMO = true in JS; mock data via graphFetch() |
| Live Activation | Configure MSAL.js, set DEMO = false, uncomment graphFetch() production block |
| Refresh Interval | 30 seconds via startAutoRefresh(30000) |
| API Vendor | Microsoft Graph API v1.0 — graph.microsoft.com |
| Auth Method | Entra ID via MSAL.js (PKCE / popup flow); Bearer token injected per request |
| AI Feature | Anthropic API — claude-sonnet-4-20250514 — streaming response |
| State Persistence | localStorage key intune_triage_v2 — filter, sort, theme, notes |
DEVICES, POLICIES, APPS, ALERTS). The graphFetch() wrapper returns mock data when DEMO = true. The production fetch() block is written and commented out directly above the mock fallback in every fetch call site.| Data Source | Status | Live Graph Endpoint |
|---|---|---|
| Device list | Demo | GET /v1.0/deviceManagement/managedDevices |
| Device detail | Demo | GET /v1.0/deviceManagement/managedDevices/{id} |
| Policy compliance per device | Demo | GET /v1.0/deviceManagement/managedDevices/{id}/deviceCompliancePolicyStates |
| Compliance policies list | Demo | GET /v1.0/deviceManagement/compliancePolicies |
| App catalog | Demo | GET /v1.0/deviceAppManagement/mobileApps |
| App install status | Demo | GET /v1.0/deviceAppManagement/mobileApps/{id}/deviceStatuses |
| Alert feed | Beta only | GET /beta/deviceManagement/alerts — no v1.0 equivalent |
| Remote sync | Proxy Needed | POST /v1.0/deviceManagement/managedDevices/{id}/syncDevice |
| Remote wipe | Proxy Needed | POST /v1.0/deviceManagement/managedDevices/{id}/wipe |
| Remote reboot | Proxy Needed | POST /v1.0/deviceManagement/managedDevices/{id}/rebootNow |
| Retire device | Proxy Needed | POST /v1.0/deviceManagement/managedDevices/{id}/retire |
| Remote lock | Proxy Needed | POST /v1.0/deviceManagement/managedDevices/{id}/remoteLock |
| Rotate BitLocker key | Proxy Needed | POST /v1.0/deviceManagement/managedDevices/{id}/rotateBitLockerKeys |
| Collect diagnostics | Proxy Needed | POST /v1.0/deviceManagement/managedDevices/{id}/collectDiagnostics |
| Reset passcode | Proxy Needed | POST /v1.0/deviceManagement/managedDevices/{id}/resetPasscode |
| Compliance trend report | Demo | GET /v1.0/reports/getDeviceNonComplianceReport |
| Auth / user identity | MSAL Stub | GET /v1.0/me — requires MSAL.js configured app registration |
| AI Summary | Live (if key available) | Anthropic API — claude-sonnet-4-20250514 streaming |
| Variable | Type | Purpose |
|---|---|---|
DEMO | Boolean | Master mock/live switch. true = serve mock data; false = call Graph API via MSAL token |
allDevices | Array | Working copy of device list; always the full DEVICES set (filters are visual-only) |
activeFilter | String/null | Gauge filter currently applied (compliant/noncompliant/alerts/windows/ios/android) |
selectedDevice | String/null | ID of currently selected device row; drives detail panel render |
dismissedAlerts | Set | Alert IDs dismissed this session (not persisted) |
bulkSelected | Set | Device IDs checked for bulk actions |
currentUser | Object/null | Simulated authenticated user from MSAL.js stub; null in demo mode |
AUDIT_LOG | Array | In-memory array of all actions taken (logged to console) |
deviceNotes | Object | In-memory cache of per-device tech notes; persisted to localStorage |
| Function | Purpose |
|---|---|
loadDashboard() | Suite entry point. Called on modal close. Calls all six render functions and updates sync badge. |
refreshAll() | DEMO/live branch controller. Called by startAutoRefresh and the Sync button. In demo mode re-renders from static data; in live mode re-fetches from Graph API via graphFetch(). |
startAutoRefresh(30000) | Starts 30-second countdown. Fires refreshAll() on expiry. Updates sync badge with countdown warning in final 10 seconds. Self-restarting. |
graphFetch(endpoint) | Unified fetch wrapper. In demo mode returns mock data via setTimeout. Production block (commented out) acquires MSAL token and calls Graph API with Bearer auth. |
runLoadingSequence() | Animates the boot loading modal. Steps through device list, populates ring counter and progress bar. Calls loadDashboard() via closeModal() on completion. |
runRemoteAction(action, name) | Fires the action modal with step-by-step progress display. Each step logs the correct Graph API endpoint for the action. In production, each step should call the real POST endpoint and resolve on 204 response. |
runAiSummary(device, policies) | Calls Anthropic API with device context and streams response. Falls back to buildOfflineSummary() on network error. |
An animated boot sequence modal that appears on every page load. Iterates through the DEVICES array animating a ring counter and progress bar while cycling through 7 phase labels. Displays a live count of compliant, issue, and critical devices as they are “synced.” The modal-api-note block inside it shows the four Graph endpoints that will be called in production.
| Displayed Endpoint | Valid | Notes |
|---|---|---|
GET /v1.0/deviceManagement/managedDevices | ✓ | Correct — device list |
GET /v1.0/deviceManagement/compliancePolicies | ✓ | Correct — policy list |
GET /v1.0/deviceAppManagement/mobileApps | ✓ | Correct — app catalog |
GET /beta/deviceManagement/alerts | ⚠ Beta | No v1.0 equivalent — beta endpoint only. Audited and corrected from original. |
The fixed 56px header contains: Microsoft Intune logo mark, Demo Mode badge, tab navigation (Devices / Alerts / Compliance / Apps), theme switcher (5 themes), MSAL.js user badge with Sign In/Out, last sync badge, and the primary Sync button.
Five themes switchable at runtime: Blue (Intune default), Storm (cyan), Ember (orange), Slate (violet), Ghost (light mode). Theme persisted in localStorage via saveState(). System dark mode preference auto-selects on first load.
Sign-In button opens a modal showing required Entra ID scopes and PKCE flow details. Simulate Sign-In sets currentUser in memory only — no real MSAL token acquired. Production requires a registered Entra ID app with delegated DeviceManagementManagedDevices.Read.All permission.
| Tab | Element ID | Grid Layout |
|---|---|---|
| 📱 Devices | #tabDevices | 340px 1fr — list + detail |
| 🔔 Alerts | #tabAlerts | 1fr 1fr — critical + warnings |
| 📋 Compliance | #tabCompliance | 1fr 1fr — policies + trend chart |
| 📦 Apps | #tabApps | 1fr 1fr — app list + detail |
A 28px scrolling marquee bar below the header. Displays 15 live-computed statistics from allDevices including compliance rate, encryption off count, firewall off count, AV disabled count, stale sync count, and current timestamp. Items are duplicated for seamless looping. Scroll pauses on hover. Updated every 30 seconds via startAutoRefresh.
| Label | Source Expression | Color Rule |
|---|---|---|
| Total Devices | src.length | Cyan always |
| Compliant / Non-Compliant | Filter by compliance field | Green / Red |
| Compliance Rate % | compliant/total*100 | Green ≥80%, Warn <80% |
| Critical Risk | Filter by risk === 'critical' | Red if >0, Green if 0 |
| Active Alerts | reduce sum of d.alerts | Warn if >0 |
| Encryption Off | Filter !d.encrypted | Red if >0, Green if 0 |
| Firewall Off | Filter !d.firewall | Red if >0, Green if 0 |
| AV Disabled | Filter d.av === false | Red if >0, Green if 0 |
| Stale Sync | Filter lastSync contains “day”, “6 hrs”, “12 hrs” | Warn if >0 |
| Windows / iOS Non-Compliant | Platform + compliance filter | Red / Warn if >0 |
| Last Sync | new Date().toLocaleTimeString() | Cyan |
| Policies Active | POLICIES.length | Green |
| Apps Deployed | APPS.length | Green |
Seven donut gauge cards in a collapsible strip below the ticker. Each card is clickable and applies a filter to the device list. The active filter highlights with a colored border glow. A filter chip appears below the strip showing the active filter name with an × to clear. Keyboard shortcuts 1–7 trigger each gauge filter. The strip collapses/expands via the “Gauges” toggle button and remembers state in localStorage.
| Gauge | Filter Value | Source | Donut Color |
|---|---|---|---|
| Devices | all | allDevices.length | Cyan (--accent) |
| Compliant | compliant | Filter compliance === 'compliant' | Green (--ok) |
| Non-Compliant | noncompliant | Filter compliance === 'noncompliant' | Red (--danger) |
| Alerts | alerts | reduce sum d.alerts — max scale 20 | Amber (--warn) |
| Windows | windows | Filter platform === 'windows' | Cyan |
| iOS / macOS | ios | Filter platform === 'ios' | Info (--info) |
| Android | android | Filter platform === 'android' | Purple (--onboard) |
The primary triage view. A 340px left panel lists all devices; the right panel shows selected device detail. The list supports search (by name/user/OS), sort (status/name/user/lastSync/OS), bulk selection, and gauge-based filtering. Devices are sorted by risk severity by default (critical first).
| Field | Source | Visual Treatment |
|---|---|---|
| Status dot | d.risk + d.compliance | Red = critical/high, Amber = medium, Green = compliant, Grey = unknown |
| OS icon | d.platform | 🏟 Windows, 🍎 iOS/macOS, 🤖 Android |
| Device name | d.name | Bold, truncated with ellipsis |
| User + last sync | d.user, d.lastSync | Last sync colored: green <24h, amber 24–48h, red >48h |
| Risk badge | d.risk | Critical (red), High (red), Medium (amber), Low (green) |
| Alert badge | d.alerts | Amber badge with count; hidden if 0 |
| Checkbox | Bulk selection | Enables bulk toolbar on any selection |
Selecting one or more checkboxes shows the bulk toolbar with three actions that require confirmation: Sync All (syncDevice), Reboot All (rebootNow), and Collect Diag (collectDiagnostics). A confirm modal shows device names before executing. In live mode, each selected device would receive a separate POST request.
The Export button opens a dropdown with CSV and JSON options. Exports the currently visible (filtered) device list. Fields exported: name, user, OS, version, model, serial, compliance, lastSync, risk, alerts, encrypted, firewall, AV, patches, enrolled. No API call required — purely client-side from allDevices in memory.
Clicking a device row renders a full detail panel in the right column. The panel header shows the Graph API endpoint for the selected device (GET /managedDevices/{id}). Four sub-tabs provide drill-down: Policies, Apps, Remote Actions, and Ticket.
| Element | Source |
|---|---|
| Banner color/icon | d.compliance + d.risk — OK green / warn amber / critical red |
| OS, Model, Last Sync, Risk | d.os, d.osVer, d.model, d.lastSync, d.risk |
| Encrypted / Firewall / AV / Patches | d.encrypted, d.firewall, d.av, d.os_patch |
| 14-day compliance chart | Simulated per-device trend — no Graph Reporting API equivalent per-device |
Shows a policy compliance table from DEVICE_POLICIES[d.id] (or falls back to derived fields). Labeled with the corrected Graph endpoint: GET /managedDevices/{id}/deviceCompliancePolicyStates. In live mode this should be fetched per-device on detail expand.
Lists apps installed on the selected device from DEVICE_APPS[d.id]. Labeled with GET /deviceAppManagement/mobileApps?deviceId={id}. The correct live endpoint to achieve this is GET /v1.0/deviceAppManagement/mobileApps/{appId}/deviceStatuses filtered by device ID — there is no single Graph call that returns all apps for one device directly.
Auto-generates a plain-text triage ticket note from device fields and policy failures. Includes the correct Graph API calls used. A Tech Notes textarea auto-saves to localStorage keyed by device ID with a 600ms debounce. Copy Full Ticket combines the ticket and notes to clipboard.
Eight remote action buttons appear in the Remote Actions sub-tab of the device detail panel. Each fires runRemoteAction(action, deviceName) which shows an animated step-by-step modal displaying the correct Graph API endpoint for that action. All actions are POST with 204 No Content response in production.
| Button | Action Key | Graph API Endpoint | Notes |
|---|---|---|---|
| Sync Device | sync | POST /v1.0/deviceManagement/managedDevices/{id}/syncDevice | No request body required |
| Remote Wipe | wipe | POST /v1.0/deviceManagement/managedDevices/{id}/wipe | Optional body: keepEnrollmentData, keepUserData |
| Remote Reboot | reboot | POST /v1.0/deviceManagement/managedDevices/{id}/rebootNow | Windows 10+ only |
| Retire Device | retire | POST /v1.0/deviceManagement/managedDevices/{id}/retire | Removes corporate data, preserves personal data |
| Remote Lock | lock | POST /v1.0/deviceManagement/managedDevices/{id}/remoteLock | iOS/macOS/Android; Windows requires PIN policy |
| Rotate BitLocker Key | bitlocker | POST /v1.0/deviceManagement/managedDevices/{id}/rotateBitLockerKeys | Windows only; requires BitLocker enabled |
| Collect Diagnostics | collect | POST /v1.0/deviceManagement/managedDevices/{id}/collectDiagnostics | Returns 202 Accepted; bundle appears in Intune portal |
| Reset Passcode | reset | POST /v1.0/deviceManagement/managedDevices/{id}/resetPasscode | iOS / Android only |
wipe will factory-reset the device on next check-in. The action modal displays a warning step but does not currently require a typed confirmation. Consider adding a device-name confirmation input before enabling this in live mode.Two-column layout: Critical Alerts (left) and Warnings & Info (right). Alerts are sourced from the ALERTS constant (8 items in demo). Each alert has a Dismiss button which adds the alert ID to dismissedAlerts (session-only, not persisted) and decrements the header badge count. The tab header label correctly references /beta/deviceManagement/alerts.
GET /beta/deviceManagement/alerts is the only available Graph API endpoint for Intune alerts. There is no equivalent in v1.0. Production code should handle the possibility that this beta endpoint behavior or schema may change without notice.Two-column layout: Compliance Policies list (left) and 6-month compliance trend chart (right). Each policy card is expandable and shows compliant vs. non-compliant device counts with a progress bar. The trend chart uses Chart.js with three datasets: Compliant %, Non-Compliant %, and Critical Risk %. An API reference block in the right column correctly shows all three Graph endpoints.
| Chart type | Line chart, Chart.js 4.4.1 |
| Labels | 6 months hardcoded: Nov, Dec, Jan, Feb, Mar, Apr |
| Dataset 1 | Compliant % — green fill |
| Dataset 2 | Non-Compliant % — red fill |
| Dataset 3 | Critical Risk % — amber dashed line |
| Live data source | GET /v1.0/reports/getDeviceNonComplianceReport — returns aggregated snapshots |
| Limitation | Graph does not provide per-day/per-month compliance rate time-series natively; requires custom aggregation or Intune Analytics exports |
Two-column layout: app list (left) with install rate progress bars, and app detail panel (right) when an app is selected. The detail panel shows install statistics by platform (Windows/iOS/Android) with simulated per-platform percentages, and displays the three relevant Graph endpoints for that app.
GET /mobileApps/{id}/deviceStatuses. Grouping by platform requires client-side aggregation of those results. The random percentages shown are placeholders for this aggregation logic.The ✦ AI Summary button appears in the device detail compliance banner. It calls the Anthropic API directly from the browser using claude-sonnet-4-20250514 with streaming SSE. The prompt includes device name, user, OS, compliance state, risk level, active policy failures, and tech notes. The response streams character-by-character into the AI panel body.
| API | https://api.anthropic.com/v1/messages |
| Model | claude-sonnet-4-20250514 |
| Max tokens | 500 |
| Stream | true — SSE, reads content_block_delta events |
| Prompt inputs | Device name, user, OS, model, compliance, risk, last sync, encryption, firewall, AV, patches, alerts, policy failures, policy warnings, tech notes |
| Offline fallback | buildOfflineSummary() — generates structured text from device fields with simulated stream animation; no API call required |
| Error handling | Any network or API error triggers the offline fallback with a warning note in the AI panel footer |
| Copy button | Copies textContent of AI panel to clipboard |
learn.microsoft.com/graph/api/resources/intune-graph-overview. Base URL: https://graph.microsoft.com.$filter, $select, $orderby. Used to populate the device list, gauge counts, and ticker.GET /compliancePolicies?deviceId= which is not a valid Graph parameter.$filter=isAssigned eq true to return only deployed apps./v1.0/deviceManagement/alerts which does not exist. Both the loading modal and alerts tab header have been corrected./syncDevice suffix)
{"keepEnrollmentData": true, "keepUserData": false}. Irreversible. Returns 204. Device wipes on next check-in.required / available). Shown in the app detail endpoint reference block.Graph API calls require a Bearer token issued by Entra ID (Azure AD). The recommended approach for this browser-based console is MSAL.js with PKCE flow. The Sign-In modal shows the required setup details.
| Property | Value |
|---|---|
| Auth type | OAuth 2.0 PKCE via MSAL.js 3.x |
| Token type | Bearer (Entra ID JWT) |
| Read scope | DeviceManagementManagedDevices.Read.All |
| Write scope | DeviceManagementManagedDevices.ReadWrite.All (for remote actions) |
| App type | Public client (SPA) — no client secret required |
| Graph base URL | https://graph.microsoft.com |
| Token header | Authorization: Bearer {access_token} |
The graphFetch(endpoint) function is the single fetch entry point for all Graph calls. The production block (commented out) calls acquireMSALToken() and passes the Bearer token. Uncomment this block and comment out the mock fallback to go live.
GET /v1.0/deviceManagement/alerts does not exist in Microsoft Graph. Alerts are available only at GET /beta/deviceManagement/alerts. The original dashboard incorrectly showed this as a v1.0 endpoint in both the loading modal and the alerts tab. Both have been corrected to reference the beta endpoint with an explicit warning note.Math.random(). The closest real data source is GET /v1.0/reports/getDeviceNonComplianceReport which returns a current snapshot, not historical data. Historical compliance requires Intune Analytics (Power BI / Azure Monitor integration).GET /mobileApps?deviceId=. The correct approach requires fetching GET /mobileApps/{appId}/deviceStatuses per app and filtering for the device. The Apps tab in device detail uses a static DEVICE_APPS lookup; in live mode this requires one API call per tracked app.deviceActionResults field but no aggregate risk rating. The d.risk field in mock data is console-generated logic that must be implemented in the proxy when going live.Math.random() on every render. In live mode, this requires fetching GET /mobileApps/{id}/deviceStatuses and grouping results by device platform. The random values change on every app click in demo mode.dismissedAlerts Set is session-only (not in localStorage). Dismissing an alert and refreshing the page restores it. In live mode, alert dismissal state should be stored server-side or in localStorage keyed by alert ID.acquireMSALToken(). The graphFetch() wrapper already calls this function and the full implementation is shown in the Auth section above.DEMO = false.- Register a new application in the Entra ID portal (App registrations → New registration)
- Set platform: Single-page application (SPA) — add the serving origin as redirect URI
- Add delegated API permissions:
DeviceManagementManagedDevices.Read.All(minimum) - Add
DeviceManagementManagedDevices.ReadWrite.Allif remote actions are needed - Add
DeviceManagementApps.Read.Allfor app catalog - Grant admin consent for the organization on all added permissions
- Note the Application (client) ID and Directory (tenant) ID for MSAL config
- Add MSAL.js CDN script tag to the file head:
<script src="https://alcdn.msauth.net/browser/3.x.x/js/msal-browser.min.js"></script> - Implement
acquireMSALToken()usingPublicClientApplicationwithacquireTokenSilent→acquireTokenPopupfallback - Fill in
clientIdandauthorityin the MSAL config object - Wire the real Sign-In button to call
msalInstance.loginPopup()
- Open
stack-intune-triage.htmland locate theDEMOconstant near the top of the script block - Set
const DEMO = false; - In
graphFetch(), uncomment the 8-line production block (fetch with Bearer token) - Comment out (or delete) the mock
return new Promise(resolve => setTimeout(...))fallback block - Reload — Sign In prompt should appear; authenticate with a user who has Intune read access
- Verify device list populates with real device names (not
CORP-WIN-JSMITH01) - Verify gauge counts match the Intune portal total device count
- Test sync action on a non-critical test device; confirm 204 response in the action modal log
- Verify AI Summary works (Anthropic API key available in browser environment)
| Feature | Change |
|---|---|
| Device list | Real enrolled devices from the tenant; real names, users, compliance states |
| Gauge counts | Real compliant/non-compliant/alert totals from Graph API responses |
| Alert feed | Real Intune alert records from /beta/deviceManagement/alerts |
| Policy compliance per device | Actual policy states from /deviceCompliancePolicyStates |
| Remote actions | All POST actions execute against real devices — verify on test devices first |
| App install status | Real install state from /mobileApps/{id}/deviceStatuses |
| Auto-refresh | Re-fetches all data from Graph API every 30 seconds |
| Auth badge | Shows real user UPN and role from Entra ID |
| Field / Location | Default | Purpose |
|---|---|---|
DEMO constant | true | Master mock/live switch. Set false with MSAL token to go live. |
startAutoRefresh(30000) | 30000 ms | Refresh interval. Increase to 60000 for large tenants to avoid Graph throttling. |
STORAGE_KEY | 'intune_triage_v2' | localStorage key for persistent state. Change version suffix to reset all saved state. |
NOTES_KEY_PREFIX | 'intune_note_' | Prefix for per-device note keys in localStorage. |
| AI model | claude-sonnet-4-20250514 | In runAiSummary() fetch body. Update to newer model when available. |
| AI max_tokens | 500 | In runAiSummary() fetch body. Increase for longer summaries. |
| Stale sync threshold | 48h = stale, 24h = warn | In syncClass() — parseSyncHours(). Adjust to policy SLA requirements. |
| Ticker scroll speed | 55s | CSS .ticker-track animation duration. Increase to slow scroll. |
| Loading interval | 130ms per device | In runLoadingSequence() setInterval. Reduce for faster boot animation. |
| Gauge max (alerts) | 20 | In renderGauges() — setGauge('alerts', alertCount, 20). The denominator for the donut fill; tune to expected alert volume. |
| Main layout columns | 340px 1fr | CSS main grid-template-columns. Increase 340px to widen the device list panel. |
| Compliance history days | 14 | In renderDeviceDetail() Array.from({length:14}. Adjust label count if connecting to real telemetry. |
| Default sort | status | Sort select default value in HTML. Changes initial list order. |
| Theme default | blue (or OS pref) | In initTheme(). Change the fallback theme string. |
Cause: renderGauges() was called before allDevices was populated, or the loading modal was dismissed before closeModal() ran loadDashboard().
Fix: Check browser console for JS errors. In demo mode this should not occur. Confirm closeModal() calls loadDashboard() and that allDevices = [...DEVICES] runs before loadDashboard(). If the modal close button is clicked before the animation completes, runLoadingSequence interval may still be running — verify clearInterval is called on close.
Cause: An active gauge filter combined with search text returns no matches.
Fix: Click Show All in the list header or press the filter chip ×. Check if a gauge filter is active (highlighted border). Call clearAllFilters() from the console to reset everything. In live mode, confirm graphFetch('/v1.0/deviceManagement/managedDevices') returns a non-empty value array.
Cause: selectDevice(id) called DEVICES.find(x => x.id === id) but the ID did not match (possible if allDevices was replaced with a different object reference).
Fix: Confirm allDevices is always initialized to [...DEVICES]. Check that row data-id attributes match the d.id values in the DEVICES constant. In live mode, ensure the device IDs returned by Graph API are stored directly as d.id in the normalized device objects.
Cause: runRemoteAction(action, deviceName) called with an action key not in the steps object; falls through to the default branch which should still work. Or the action modal element is missing from the DOM.
Fix: Check browser console for errors. Verify document.getElementById('actionModal') returns the element. Confirm the action key is one of: sync, wipe, reboot, retire, lock, bitlocker, collect, reset. All are now explicitly defined in the steps object after the audit fix — none fall to the generic default anymore.
Cause: The fetch call to https://api.anthropic.com/v1/messages failed. Common reasons: no network access to Anthropic from the current environment, CORS policy rejection, or the Anthropic API key is not configured (the code currently does not send an API key — this requires proxy-side key injection).
Fix: Route AI calls through a server-side proxy that injects the x-api-key header. The offline fallback (buildOfflineSummary()) is fully functional and accurate — it reads the same device fields and produces a useful triage summary without any network call. Consider this sufficient for demo environments.
Cause: startAutoRefresh(30000) was not called, or the intervals were cleared without being restarted.
Fix: Check _refreshInterval and _countdownInterval are non-null after boot (log them from the console). Confirm the BOOT section at the bottom of the script calls startAutoRefresh(30000). Manually call refreshAll() from the console to confirm the data cycle works independently of the timer.
Cause: MSAL token is expired, not acquired, or the scope does not match the endpoint.
Fix: Verify the token is acquired before the graphFetch() call. Check that the Entra ID app registration has admin consent granted for all required scopes. Test the token manually: curl -H "Authorization: Bearer {token}" https://graph.microsoft.com/v1.0/me. For remote actions (POST endpoints), confirm the ReadWrite.All scope is present — Read.All alone will return 403 on action endpoints.
Cause: Chart.js CDN script failed to load, or destroyChart() was not called before re-initializing a chart on the same canvas element.
Fix: Check browser console for Chart is not defined error. Verify the CDN URL is accessible from the deployment environment (cdnjs.cloudflare.com). If deploying in an air-gapped environment, bundle Chart.js locally. The activeCharts object and destroyChart() function prevent canvas reuse errors — confirm they are both present and called before every chart initialization.