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/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; diff --git a/module/Private/IntuneAnomalies.ps1 b/module/Private/IntuneAnomalies.ps1 index e654bcc..dd2fae4 100644 --- a/module/Private/IntuneAnomalies.ps1 +++ b/module/Private/IntuneAnomalies.ps1 @@ -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) { @@ -1460,8 +1472,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 +1489,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) + } } } 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 } 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'