From 94e7ea8fd0f0f024473842e3a5ce24cec0630de6 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Thu, 18 Jun 2026 11:25:54 +0200 Subject: [PATCH 1/5] fix(intune-anomalies): dedupe compliance setting-state pairs and add per-request fallback Graph occasionally returns the same deviceCompliancePolicyState id twice for one device (multi-assignment paths), producing duplicate sub-request ids in the batch and a 400 'Request Id ... has to be unique in a batch.' Dedupe pairs via HashSet at the build site. When a sub-request still returns non-200 (any reason), fall back to a per-request GET so the Noncompliant Reason column gets the real rule name instead of 'Unknown'. --- module/Private/IntuneAnomalies.ps1 | 43 +++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/module/Private/IntuneAnomalies.ps1 b/module/Private/IntuneAnomalies.ps1 index e654bcc..9f8c3dc 100644 --- a/module/Private/IntuneAnomalies.ps1 +++ b/module/Private/IntuneAnomalies.ps1 @@ -1439,8 +1439,13 @@ 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 per (deviceId, stateId) at the build site. $settingStatePairs = [System.Collections.Generic.List[object]]::new() + $seenPairIds = [System.Collections.Generic.HashSet[string]]::new() foreach ($resp in $policyStateResponses) { if ($resp.Status -ne 200 -or -not $resp.Body) { continue } $deviceId = $resp.Id -replace '^ps:', '' @@ -1448,8 +1453,10 @@ function Get-AllDeviceData { if ($states.Count -eq 0 -or $states.Count -gt 10) { continue } $ComplianceRulesByDevice[$deviceId] = [System.Collections.Generic.List[string]]::new() foreach ($s in $states) { + $pairId = "ss:$deviceId|$($s.id)" + if (-not $seenPairIds.Add($pairId)) { continue } $settingStatePairs.Add([PSCustomObject]@{ - Id = "ss:$deviceId|$($s.id)" + Id = $pairId Url = "/deviceManagement/managedDevices/$deviceId/deviceCompliancePolicyStates/$($s.id)/settingStates" }) } @@ -1460,8 +1467,16 @@ 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' }) @@ -1469,6 +1484,28 @@ function Get-AllDeviceData { 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) + } } } From 2ee7348b15423f56960800c5cb06edc8489b52d7 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Thu, 18 Jun 2026 11:26:00 +0200 Subject: [PATCH 2/5] fix(invoke-rkgraphbatch): fast-fail non-transient HTTP and log Graph error body The catch block previously retried any exception with exponential backoff, turning a permanent 400 BadRequest into a 62s hang (2+4+8+16+32). Now it inspects the HTTP status, retries only 408/429/5xx (and unknown/network errors), and surfaces $_.ErrorDetails.Message via Write-Verbose so the actual Graph rejection reason shows up in the trace. --- module/Private/Invoke-RKGraphBatch.ps1 | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/module/Private/Invoke-RKGraphBatch.ps1 b/module/Private/Invoke-RKGraphBatch.ps1 index e5cd9de..6d2e5aa 100644 --- a/module/Private/Invoke-RKGraphBatch.ps1 +++ b/module/Private/Invoke-RKGraphBatch.ps1 @@ -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 } From fa79c1d16a255264d0bea5ffefb6b6d63e2e636d Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Thu, 18 Jun 2026 11:26:08 +0200 Subject: [PATCH 3/5] fix(report-template): wrap long values in table cells across all reports Long unbreakable strings (UPNs, device serials, group GUIDs) overflowed their column and bled into the neighbouring cell. Add word-break / overflow-wrap to the shared .table tbody td rule so every report (not just Intune Anomalies) wraps long values inside their cell. --- module/Private/Get-RKSolutionsReportTemplate.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/module/Private/Get-RKSolutionsReportTemplate.ps1 b/module/Private/Get-RKSolutionsReportTemplate.ps1 index 0218f60..8fc23df 100644 --- a/module/Private/Get-RKSolutionsReportTemplate.ps1 +++ b/module/Private/Get-RKSolutionsReportTemplate.ps1 @@ -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; From 8f0b6aaaab942cb4accf62005f413bd0305c4d21 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Thu, 18 Jun 2026 11:26:12 +0200 Subject: [PATCH 4/5] release: bump ModuleVersion to 1.2.1 and cut dated CHANGELOG section --- CHANGELOG.md | 16 ++++++++++++++++ module/RKSolutions.psd1 | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1964dc..d8fe09b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/module/RKSolutions.psd1 b/module/RKSolutions.psd1 index e036a86..3fcab9e 100644 --- a/module/RKSolutions.psd1 +++ b/module/RKSolutions.psd1 @@ -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' From 86e8325609a2905350c458ddca6ad647f5c0c31f Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Thu, 18 Jun 2026 11:32:23 +0200 Subject: [PATCH 5/5] fix(intune-anomalies): dedupe policy-state records before the <=10 guard The <=10 cap was counting raw Graph results, so a device with duplicated policy-state entries (multi-assignment paths) could be skipped entirely even when its unique state count was within the limit. Dedupe per (deviceId, stateId) before the count guard; this also makes the in-loop HashSet check redundant since the pair build can no longer see duplicates. --- module/Private/IntuneAnomalies.ps1 | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/module/Private/IntuneAnomalies.ps1 b/module/Private/IntuneAnomalies.ps1 index 9f8c3dc..dd2fae4 100644 --- a/module/Private/IntuneAnomalies.ps1 +++ b/module/Private/IntuneAnomalies.ps1 @@ -1443,20 +1443,25 @@ function Get-AllDeviceData { # 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 per (deviceId, stateId) at the build site. + # 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() - $seenPairIds = [System.Collections.Generic.HashSet[string]]::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) { - $pairId = "ss:$deviceId|$($s.id)" - if (-not $seenPairIds.Add($pairId)) { continue } $settingStatePairs.Add([PSCustomObject]@{ - Id = $pairId + Id = "ss:$deviceId|$($s.id)" Url = "/deviceManagement/managedDevices/$deviceId/deviceCompliancePolicyStates/$($s.id)/settingStates" }) }