App Report via Export Jobs, Umlaut-Suche, Geräte-Export, Mitglieder entfernen

- App Report: Zahlen per AppInstallStatusAggregate Export Job (ZIP/CSV)
- Geräte-Export: getDeviceInstallStatusReport statt deviceStatuses
- Benutzersuche: ConsistencyLevel+$count für Umlaut-Kompatibilität
- Gruppen: Mitglieder entfernen in modalMembers und Group Explorer (gex)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 07:23:04 +02:00
co-authored by Claude Sonnet 4.6
parent 0174ca1036
commit 2f0af6e577
5 changed files with 256 additions and 70 deletions
+133 -62
View File
@@ -1571,9 +1571,8 @@ function Get-AppCategoriesEndpoint {
}
function Get-AppInstallReportEndpoint {
# Faellt zurueck auf deviceStatuses pro App (via $batch), da installSummary
# und getAppInstallSummaryReport DeviceManagementManagedDevices.Read.All
# benoetigen (fehlt in dieser App-Registration -> 400).
# Verwendet den Intune Export-Job (AppInstallStatusAggregate).
# Benoetigt: DeviceManagementApps.Read.All
$err = Test-Connected
if ($err) { return $err }
@@ -1581,50 +1580,88 @@ function Get-AppInstallReportEndpoint {
$rawApps = @(Get-GraphMobileApps)
if ($rawApps.Count -eq 0) { return @{ items = @(); count = 0 } }
Write-Host "[REPORT] Zaehle Installationsstatus fuer $($rawApps.Count) Apps via deviceStatuses..." -ForegroundColor DarkCyan
Write-Host "[REPORT] Starte Export-Job (AppInstallStatusAggregate)..." -ForegroundColor DarkCyan
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$batchSize = 20
# appId -> hashtable mit Zaehler
$counts = @{}
foreach ($a in $rawApps) { $counts[[string]$a.id] = @{ ok=0; fail=0; pending=0; notInst=0; notAppl=0 } }
$summaries = @{} # appId -> Zaehler
$firstBatch = $true
for ($i = 0; $i -lt $rawApps.Count; $i += $batchSize) {
$chunk = $rawApps[$i .. [Math]::Min($i + $batchSize - 1, $rawApps.Count - 1)]
$requests = @($chunk | ForEach-Object {
$aid = [string]$_.id
@{ id = $aid; method = 'GET'; url = "/deviceAppManagement/mobileApps/$aid/deviceStatuses?`$select=installState&`$top=999" }
})
$body = @{ requests = $requests } | ConvertTo-Json -Depth 5 -Compress
try {
# 1. Export-Job anlegen — kein select damit falsche Spaltennamen keinen BadRequest ausloesen
$jobJson = '{"reportName":"AppInstallStatusAggregate","filter":""}'
$job = Invoke-MgGraphRequestRetry `
-Uri 'https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs' `
-Method POST -Body $jobJson -ContentType 'application/json'
$jobId = $job.id
Write-Host " [REPORT] Export-Job ID: $jobId" -ForegroundColor DarkGray
# 2. Auf Fertigstellung warten (max. 120s)
$status = $job.status
$waited = 0
while ($status -ne 'completed' -and $status -ne 'failed' -and $waited -lt 120) {
Start-Sleep -Seconds 3
$waited += 3
$job = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs/$jobId"
$status = $job.status
Write-Host " [REPORT] Status: $status ($waited s)" -ForegroundColor DarkGray
}
if ($status -ne 'completed') {
throw "Export-Job nicht abgeschlossen (Status: $status nach $waited s)"
}
$downloadUrl = $job.url
Write-Host " [REPORT] Download: $downloadUrl" -ForegroundColor DarkGray
# 3. ZIP herunterladen und CSV parsen
$tmpZip = [System.IO.Path]::GetTempFileName() + '.zip'
$tmpDir = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "IntuneReport_$jobId")
try {
$resp = Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/$batch' -Method POST -Body $body -ContentType 'application/json'
foreach ($r in @($resp.responses)) {
$status = [int]$r.status
if ($status -eq 200) {
$aid = [string]$r.id
foreach ($ds in @($r.body.value)) {
switch ([string]$ds.installState) {
'installed' { $counts[$aid].ok++ }
'failed' { $counts[$aid].fail++ }
'pendingInstall' { $counts[$aid].pending++ }
'notInstalled' { $counts[$aid].notInst++ }
'notApplicable' { $counts[$aid].notAppl++ }
}
}
Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpZip -UseBasicParsing
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory($tmpZip, $tmpDir)
$csv = Get-ChildItem -Path $tmpDir -Filter '*.csv' | Select-Object -First 1
if (-not $csv) { throw "Keine CSV im Export-ZIP gefunden" }
$rows = Import-Csv -Path $csv.FullName -Encoding UTF8
Write-Host " [REPORT] CSV: $($rows.Count) Zeilen, Spalten: $(($rows[0].PSObject.Properties.Name) -join ',')" -ForegroundColor DarkGray
foreach ($row in $rows) {
$aid = [string]$row.ApplicationId
if (-not $aid) { continue }
$instV = $row.InstalledDeviceCount; if (-not $instV) { $instV = 0 }
$failV = $row.FailedDeviceCount; if (-not $failV) { $failV = 0 }
$pendV = $row.PendingInstallDeviceCount; if (-not $pendV) { $pendV = 0 }
$notInstV = $row.NotInstalledDeviceCount; if (-not $notInstV) { $notInstV = 0 }
$notApplV = $row.NotApplicableDeviceCount;if (-not $notApplV) { $notApplV = 0 }
$summaries[$aid] = @{
inst = [int]$instV
fail = [int]$failV
pend = [int]$pendV
notInst = [int]$notInstV
notAppl = [int]$notApplV
}
}
if ($firstBatch) {
$f = $resp.responses[0]
Write-Host " [DEBUG] r[0]: status=$($f.status), value.count=$(@($f.body.value).Count)" -ForegroundColor Magenta
$firstBatch = $false
}
} catch {
Write-Host " [REPORT] Batch-Fehler: $($_.Exception.Message)" -ForegroundColor DarkYellow
}
if (($i / $batchSize) % 5 -eq 4) {
Write-Host " [REPORT] $([Math]::Min($i + $batchSize, $rawApps.Count))/$($rawApps.Count) Apps..." -ForegroundColor DarkGray
Write-Host " [REPORT] $($summaries.Count) Apps mit Install-Daten" -ForegroundColor Green
} finally {
Remove-Item -Path $tmpZip -Force -ErrorAction SilentlyContinue
Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
}
} catch {
$errMsg = $_.Exception.Message
# Graph-PS-Modul steckt den Response-Body in $_.Exception.Response
try {
$stream = $_.Exception.Response.GetResponseStream()
$reader = [System.IO.StreamReader]::new($stream)
$body = $reader.ReadToEnd()
if ($body) { $errMsg += " | Body: $body" }
} catch {}
try {
if ($_.ErrorDetails.Message) { $errMsg += " | Details: $($_.ErrorDetails.Message)" }
} catch {}
Write-Host " [REPORT] Export-Job fehlgeschlagen: $errMsg" -ForegroundColor Yellow
}
$sw.Stop()
@@ -1632,7 +1669,7 @@ function Get-AppInstallReportEndpoint {
$items = @($rawApps | ForEach-Object {
$aid = [string]$_.id
$c = $counts[$aid]
$s = $summaries[$aid]
$ver = if ($_.buildNumber) { $_.buildNumber } elseif ($_.versionNumber) { $_.versionNumber } elseif ($_.version) { $_.version } else { '' }
[pscustomobject]@{
AppId = $aid
@@ -1640,11 +1677,11 @@ function Get-AppInstallReportEndpoint {
AppType = ([string]$_.('@odata.type') -replace '#microsoft.graph.', '')
Publisher = [string]$_.publisher
Version = [string]$ver
InstalledDeviceCount = $c.ok
FailedDeviceCount = $c.fail
PendingInstallDeviceCount = $c.pending
NotInstalledDeviceCount = $c.notInst
NotApplicableDeviceCount = $c.notAppl
InstalledDeviceCount = if ($s) { $s.inst } else { 0 }
FailedDeviceCount = if ($s) { $s.fail } else { 0 }
PendingInstallDeviceCount = if ($s) { $s.pend } else { 0 }
NotInstalledDeviceCount = if ($s) { $s.notInst } else { 0 }
NotApplicableDeviceCount = if ($s) { $s.notAppl } else { 0 }
}
})
@@ -1652,6 +1689,7 @@ function Get-AppInstallReportEndpoint {
}
function Get-AppDeviceStatusEndpoint {
# Verwendet getDeviceInstallStatusReport (synchron, paginiert) statt deviceStatuses.
param([hashtable]$Query)
$err = Test-Connected
if ($err) { return $err }
@@ -1661,28 +1699,61 @@ function Get-AppDeviceStatusEndpoint {
return @{ __status = 400; error = "appId fehlt" }
}
Write-Host "[DEVSTATUS] Lade DeviceStatuses fuer App $appId..." -ForegroundColor DarkCyan
$uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$appId/deviceStatuses?`$top=999"
$items = @()
try {
$items = @(Get-GraphPaged -Uri $uri)
} catch {
return @{ __status = 500; error = "Graph-Fehler: $($_.Exception.Message)" }
Write-Host "[DEVSTATUS] Lade Install-Status fuer App $appId via Reports-API..." -ForegroundColor DarkCyan
$pageSize = 50
$skip = 0
$allRows = @()
$cols = $null
do {
$bodyJson = "{""filter"":""(ApplicationId eq '$appId')"",""select"":[],""skip"":$skip,""top"":$pageSize,""orderBy"":[]}"
try {
$resp = Invoke-MgGraphRequestRetry `
-Uri 'https://graph.microsoft.com/beta/deviceManagement/reports/getDeviceInstallStatusReport' `
-Method POST -Body $bodyJson -ContentType 'application/json'
} catch {
return @{ __status = 500; error = "Graph-Fehler: $($_.Exception.Message)" }
}
if (-not $cols) {
$cols = @($resp.Schema | ForEach-Object { $_.Column })
Write-Host " [DEVSTATUS] Spalten: $($cols -join ',')" -ForegroundColor DarkGray
}
$rows = @($resp.Values)
$allRows += $rows
$skip += $pageSize
} while ($rows.Count -eq $pageSize)
Write-Host " -> $($allRows.Count) Eintraege" -ForegroundColor DarkGray
if (-not $cols -or $allRows.Count -eq 0) {
return @{ items = @(); count = 0 }
}
$result = @($items | ForEach-Object {
function ColIdx($name) { [Array]::IndexOf($cols, $name) }
$iDevice = ColIdx 'DeviceName'
$iUser = ColIdx 'UserName'
$iState = ColIdx 'InstallState'
$iDetail = ColIdx 'InstallStateDetail'
$iErr = ColIdx 'ErrorCode'
$iOs = ColIdx 'OSVersion'
$iSync = ColIdx 'LastModifiedDateTime'
if ($iOs -lt 0) { $iOs = ColIdx 'OsVersion' }
if ($iSync -lt 0) { $iSync = ColIdx 'LastSyncDateTime' }
$result = @($allRows | ForEach-Object {
$r = $_
[pscustomobject]@{
DeviceName = [string]$_.deviceName
UserName = [string]$_.userName
InstallState = [string]$_.installState
InstallStateDetail = [string]$_.installStateDetail
ErrorCode = [string]$_.errorCode
LastSyncDateTime = [string]$_.lastSyncDateTime
OsVersion = [string]$_.osVersion
DeviceName = if ($iDevice -ge 0) { [string]$r[$iDevice] } else { '' }
UserName = if ($iUser -ge 0) { [string]$r[$iUser] } else { '' }
InstallState = if ($iState -ge 0) { [string]$r[$iState] } else { '' }
InstallStateDetail = if ($iDetail -ge 0) { [string]$r[$iDetail] } else { '' }
ErrorCode = if ($iErr -ge 0) { [string]$r[$iErr] } else { '' }
OsVersion = if ($iOs -ge 0) { [string]$r[$iOs] } else { '' }
LastSyncDateTime = if ($iSync -ge 0) { [string]$r[$iSync] } else { '' }
}
})
Write-Host " -> $($result.Count) Eintraege" -ForegroundColor DarkGray
return @{ items = $result; count = $result.Count }
}
+5 -2
View File
@@ -194,12 +194,15 @@ function Search-GraphUser {
if ($cfgFields.Count -eq 0) { $cfgFields = @('displayName','userPrincipalName','mail') }
# 1) startswith — schnell, deckt Praefix-Tippen ab (90%+ aller Suchen)
# Bei Umlauten/Nicht-ASCII: ConsistencyLevel + $count erforderlich,
# sonst liefert Graph leere Ergebnisse oder 400.
$hasNonAscii = $term -match '[^\x00-\x7F]'
try {
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$parts = @($cfgFields | ForEach-Object { "startswith($_,'$term')" })
$filter = $parts -join " or "
$uri = "https://graph.microsoft.com/v1.0/users?`$select=$select&`$filter=$([uri]::EscapeDataString($filter))&`$top=25"
$resp = Invoke-MgGraphRequest -Uri $uri -Method GET
$uri = "https://graph.microsoft.com/v1.0/users?`$select=$select&`$filter=$([uri]::EscapeDataString($filter))&`$top=25&`$count=true"
$resp = Invoke-MgGraphRequest -Uri $uri -Method GET -Headers @{ ConsistencyLevel = "eventual" }
$items = @()
if ($resp -and $resp.value) { $items = @($resp.value) }
$sw.Stop()