Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/)



## [1.2.1] - 2026-06-18

Patch release - fixes a perf regression and a missing-data symptom in the Intune Anomalies report's compliance fetch, plus a layout fix that affects every report.

### Fixes

- **Noncompliant Reason column now shows the actual rule name.** On tenants where Microsoft Graph returned a `deviceCompliancePolicyStates` record more than once for the same device (which happens when a single policy resolves via more than one assignment path), the rule-detail fetch silently failed and the Reason column fell back to "Unknown". `Get-IntuneAnomaliesReport` now dedupes those records before fetching, so the real rule name shows up (e.g. `Windows10CompliancePolicy.SignatureOutOfDate`).
- **Report no longer hangs ~60s on tenants with noncompliant devices.** When the rule-detail fetch failed, the Microsoft Graph `$batch` retry loop sat on a permanent 400 with exponential backoff. The retry path now fast-fails non-transient HTTP errors and logs Graph's actual error body so future regressions are easier to spot. End-to-end report runtime on the reproduction tenant dropped from ~70s to ~10s.
- **Rule-detail fetch has a per-request fallback.** If the batch path ever fails again (for any reason), the report retries each rejected sub-request individually instead of returning empty data. Slower but correct.

### Polish

- **Long values no longer overflow table cells.** UPNs, long device names, and other unbreakable strings now wrap inside their column instead of bleeding into the neighbouring column. Shared report template fix - applies to every report, not just the Intune Anomalies one.

---

## [1.2.0] - 2026-06-17

Adds BitLocker, Windows LAPS, and deprecated-settings detection to `Get-IntuneAnomaliesReport`, and significantly speeds the report up via Graph `$batch`.
Expand Down
2 changes: 2 additions & 0 deletions module/Private/Get-RKSolutionsReportTemplate.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,8 @@ table.dataTable, .table {
background-color: inherit !important;
font-family: 'Geist', -apple-system, sans-serif;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
word-break: break-word;
overflow-wrap: break-word;
}
.table tbody td.rk-mono {
font-family: 'Geist Mono', ui-monospace, monospace;
Expand Down
48 changes: 45 additions & 3 deletions module/Private/IntuneAnomalies.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -1439,12 +1439,24 @@ function Get-AllDeviceData {
$swStep.Stop()
Write-Verbose ("[Get-AllDeviceData] Compliance policy-states batch: {0:N2}s ({1} devices, {2} responses)" -f $swStep.Elapsed.TotalSeconds, $NonCompliantDevices.Count, $policyStateResponses.Count)

# Map deviceId -> array of nonCompliant/Error policy state ids (preserving the existing <=10 guard)
# Map deviceId -> array of nonCompliant/Error policy state ids (preserving the existing <=10 guard).
# Graph occasionally returns the same policy-state id twice for a single device (e.g. when the
# same policy resolves via more than one assignment path). Adding both would produce duplicate
# sub-request ids in the batch, which Graph rejects with 400 "Request Id ... has to be unique
# in a batch." Dedupe the state list per device by id BEFORE the <=10 guard so duplicates
# don't accidentally push a device past the cap and silently drop its compliance details.
$settingStatePairs = [System.Collections.Generic.List[object]]::new()
foreach ($resp in $policyStateResponses) {
if ($resp.Status -ne 200 -or -not $resp.Body) { continue }
$deviceId = $resp.Id -replace '^ps:', ''
$states = @($resp.Body.value | Where-Object { $_.State -eq 'nonCompliant' -or $_.State -eq 'Error' })
$rawStates = @($resp.Body.value | Where-Object { $_.State -eq 'nonCompliant' -or $_.State -eq 'Error' })

$seenStateIds = [System.Collections.Generic.HashSet[string]]::new()
$states = [System.Collections.Generic.List[object]]::new()
foreach ($s in $rawStates) {
if ($seenStateIds.Add($s.id)) { $states.Add($s) }
}

if ($states.Count -eq 0 -or $states.Count -gt 10) { continue }
$ComplianceRulesByDevice[$deviceId] = [System.Collections.Generic.List[string]]::new()
foreach ($s in $states) {
Expand All @@ -1460,15 +1472,45 @@ function Get-AllDeviceData {
$settingResponses = Invoke-RKGraphBatch -Requests @($settingStatePairs) -Activity "Compliance setting states"
$swStep.Stop()
Write-Verbose ("[Get-AllDeviceData] Compliance setting-states batch: {0:N2}s ({1} pairs)" -f $swStep.Elapsed.TotalSeconds, $settingStatePairs.Count)

$failedPairs = [System.Collections.Generic.List[object]]::new()
$pairIndex = @{}
foreach ($p in $settingStatePairs) { $pairIndex[$p.Id] = $p }

foreach ($resp in $settingResponses) {
if ($resp.Status -ne 200 -or -not $resp.Body) { continue }
if ($resp.Status -ne 200 -or -not $resp.Body) {
if ($pairIndex.ContainsKey($resp.Id)) { $failedPairs.Add($pairIndex[$resp.Id]) }
continue
}
$deviceId = ($resp.Id -replace '^ss:', '') -split '\|' | Select-Object -First 1
if (-not $ComplianceRulesByDevice.ContainsKey($deviceId)) { continue }
$details = @($resp.Body.value | Where-Object { $_.state -match 'nonCompliant' })
foreach ($det in $details) {
if ($det.setting) { $ComplianceRulesByDevice[$deviceId].Add($det.setting) }
}
}

# Fallback: sub-requests that didn't return 200 from $batch get retried as
# per-request GETs. Graph's $batch endpoint occasionally rejects this
# setting-states sub-route with 400 even though the individual GET works.
if ($failedPairs.Count -gt 0) {
$swStep.Restart()
foreach ($pair in $failedPairs) {
$deviceId = ($pair.Id -replace '^ss:', '') -split '\|' | Select-Object -First 1
if (-not $ComplianceRulesByDevice.ContainsKey($deviceId)) { continue }
try {
$direct = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta$($pair.Url)" -ErrorAction Stop
$details = @($direct.value | Where-Object { $_.state -match 'nonCompliant' })
foreach ($det in $details) {
if ($det.setting) { $ComplianceRulesByDevice[$deviceId].Add($det.setting) }
}
} catch {
Write-Verbose "Compliance setting-states fallback: per-request GET failed for $($pair.Id): $($_.Exception.Message)"
}
}
$swStep.Stop()
Write-Verbose ("[Get-AllDeviceData] Compliance setting-states fallback: {0:N2}s ({1} pairs retried individually)" -f $swStep.Elapsed.TotalSeconds, $failedPairs.Count)
}
}
}

Expand Down
25 changes: 23 additions & 2 deletions module/Private/Invoke-RKGraphBatch.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,32 @@ function Invoke-RKGraphBatch {
try {
$response = Invoke-MgGraphRequest -Method POST -Uri $batchUri -Body $payload -ContentType 'application/json' -OutputType PSObject -ErrorAction Stop
} catch {
# Inspect HTTP status. 4xx (except 408 / 429) is permanent: retrying wastes
# time. Only 408, 429, 5xx, and unknown (network / parse error) are retried.
$statusCode = 0
if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
$statusCode = [int]$_.Exception.Response.StatusCode
}
$errorBody = $null
if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $errorBody = $_.ErrorDetails.Message }

$isTransient = ($statusCode -eq 0 -or $statusCode -eq 408 -or $statusCode -eq 429 -or ($statusCode -ge 500 -and $statusCode -lt 600))

if (-not $isTransient) {
Write-Verbose "Invoke-RKGraphBatch: batch POST failed with non-transient HTTP $statusCode (not retried): $($_.Exception.Message)"
if ($errorBody) { Write-Verbose "Invoke-RKGraphBatch: response body: $errorBody" }
foreach ($p in $pending) {
$allResponses.Add([PSCustomObject]@{ Id = $p.id; Status = $statusCode; Body = $null; Headers = $null; Error = $_.Exception.Message })
}
break
}

$attempt++
if ($attempt -gt $MaxRetries) {
Write-Verbose "Invoke-RKGraphBatch: batch POST failed after $MaxRetries retries: $($_.Exception.Message)"
Write-Verbose "Invoke-RKGraphBatch: batch POST failed after $MaxRetries retries (HTTP $statusCode): $($_.Exception.Message)"
if ($errorBody) { Write-Verbose "Invoke-RKGraphBatch: response body: $errorBody" }
foreach ($p in $pending) {
$allResponses.Add([PSCustomObject]@{ Id = $p.id; Status = 0; Body = $null; Headers = $null; Error = $_.Exception.Message })
$allResponses.Add([PSCustomObject]@{ Id = $p.id; Status = $statusCode; Body = $null; Headers = $null; Error = $_.Exception.Message })
}
break
}
Expand Down
2 changes: 1 addition & 1 deletion module/RKSolutions.psd1
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@{
RootModule = 'RKSolutions.psm1'
ModuleVersion = '1.2.0'
ModuleVersion = '1.2.1'
GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
Author = 'Roy Klooster'
CompanyName = 'RK Solutions'
Expand Down