From 2f0af6e5776195e55acfb20365fe9fb4571624f4 Mon Sep 17 00:00:00 2001 From: Marco Wende Date: Wed, 22 Jul 2026 07:23:04 +0200 Subject: [PATCH] =?UTF-8?q?App=20Report=20via=20Export=20Jobs,=20Umlaut-Su?= =?UTF-8?q?che,=20Ger=C3=A4te-Export,=20Mitglieder=20entfernen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/Api.ps1 | 195 +++++++++++++++++++++++++++++++++---------------- src/Graph.ps1 | 7 +- www/app.js | 104 +++++++++++++++++++++++++- www/index.html | 4 +- www/styles.css | 16 +++- 5 files changed, 256 insertions(+), 70 deletions(-) diff --git a/src/Api.ps1 b/src/Api.ps1 index d9d3ae2..5697cf1 100644 --- a/src/Api.ps1 +++ b/src/Api.ps1 @@ -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 } } diff --git a/src/Graph.ps1 b/src/Graph.ps1 index d8b7791..61ad68b 100644 --- a/src/Graph.ps1 +++ b/src/Graph.ps1 @@ -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() diff --git a/www/app.js b/www/app.js index 0846f41..6c5ba60 100644 --- a/www/app.js +++ b/www/app.js @@ -3290,8 +3290,9 @@ function renderMembersList() { if (e.target.checked) mmContext.selectedIds.add(id); else mmContext.selectedIds.delete(id); row.classList.toggle('selected', e.target.checked); - document.getElementById('mmInfo').textContent = - mmContext.selectedIds.size === 0 ? 'Keine ausgewählt' : `${mmContext.selectedIds.size} ausgewählt`; + const n = mmContext.selectedIds.size; + document.getElementById('mmInfo').textContent = n === 0 ? 'Keine ausgewählt' : `${n} ausgewählt`; + document.getElementById('mmRemove').disabled = n === 0; }); }); } @@ -3314,6 +3315,43 @@ document.getElementById('mmClear')?.addEventListener('click', () => { mmContext.selectedIds.clear(); renderMembersList(); document.getElementById('mmInfo').textContent = 'Keine ausgewählt'; + document.getElementById('mmRemove').disabled = true; +}); + +document.getElementById('mmRemove')?.addEventListener('click', async () => { + const n = mmContext.selectedIds.size; + if (n === 0) return; + const names = mmContext.members + .filter(m => mmContext.selectedIds.has(m.Id)) + .map(m => m.DisplayName || m.UserPrincipalName) + .slice(0, 5).join('\n'); + const more = n > 5 ? `\n… und ${n - 5} weitere` : ''; + if (!confirm(`${n} Mitglied${n > 1 ? 'er' : ''} aus "${mmContext.groupName}" entfernen?\n\n${names}${more}`)) return; + + const btn = document.getElementById('mmRemove'); + btn.disabled = true; + btn.textContent = 'Entferne…'; + + const ids = [...mmContext.selectedIds]; + let ok = 0, fail = 0; + for (const userId of ids) { + try { + await api(`/api/groups/${encodeURIComponent(mmContext.groupId)}/members/${encodeURIComponent(userId)}`, { method: 'DELETE' }); + mmContext.members = mmContext.members.filter(m => m.Id !== userId); + mmContext.selectedIds.delete(userId); + ok++; + } catch (e) { + fail++; + console.error('Remove member failed:', userId, e); + } + } + + btn.textContent = 'Aus Gruppe entfernen'; + document.getElementById('mmInfo').textContent = 'Keine ausgewählt'; + renderMembersList(); + + if (fail === 0) toast(`${ok} Mitglied${ok > 1 ? 'er' : ''} entfernt`, 'ok'); + else toast(`${ok} entfernt, ${fail} fehlgeschlagen`, 'warn'); }); document.getElementById('mmApply')?.addEventListener('click', () => { @@ -4488,25 +4526,83 @@ function _gexRenderMembers() { if (m.duplicates > 0) html += ` (${m.duplicates} Duplikate aus verschachtelten Gruppen zusammengeführt)`; html += ''; + const directGroupName = m.groupName || ''; for (const [path, users] of byPath.entries()) { + const isDirect = path === directGroupName; html += `
${escapeHtml(path)} ${users.length}
`; for (const u of users) { const upn = u.UserPrincipalName || u.Mail || u.Id; const searchKey = `${(u.DisplayName || '').toLowerCase()} ${upn.toLowerCase()}`; - html += `
+ const cbx = isDirect + ? `` + : ``; + html += `
`; + `; } html += '
'; } area.innerHTML = html; if (GexState.memberFilter) _gexApplyFilter(); + + // Checkboxen verdrahten + const removeBtn = document.getElementById('gexRemoveBtn'); + if (removeBtn) removeBtn.style.display = ''; + area.querySelectorAll('.gex-member-chk').forEach(chk => { + chk.addEventListener('change', () => { + const n = area.querySelectorAll('.gex-member-chk:checked').length; + if (removeBtn) { + removeBtn.disabled = n === 0; + removeBtn.textContent = n > 0 ? `${n} Mitglied${n > 1 ? 'er' : ''} entfernen` : 'Aus Gruppe entfernen'; + } + }); + }); } +// ---- Mitglieder entfernen ---- +document.getElementById('gexRemoveBtn')?.addEventListener('click', async () => { + const area = document.getElementById('gexMembersArea'); + const checked = [...(area?.querySelectorAll('.gex-member-chk:checked') || [])]; + if (!checked.length || !GexState.selectedGroup) return; + + const names = checked.slice(0, 5).map(c => c.dataset.name).join('\n'); + const more = checked.length > 5 ? `\n… und ${checked.length - 5} weitere` : ''; + if (!confirm(`${checked.length} Mitglied${checked.length > 1 ? 'er' : ''} aus "${GexState.selectedGroup.DisplayName}" entfernen?\n\n${names}${more}`)) return; + + const btn = document.getElementById('gexRemoveBtn'); + btn.disabled = true; + btn.textContent = 'Entferne…'; + const groupId = GexState.selectedGroup.Id; + + let ok = 0, fail = 0; + for (const chk of checked) { + try { + await api(`/api/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(chk.dataset.uid)}`, { method: 'DELETE' }); + chk.closest('label').remove(); + ok++; + } catch (e) { + fail++; + console.error('Remove failed:', chk.dataset.uid, e); + } + } + + btn.textContent = 'Aus Gruppe entfernen'; + btn.disabled = true; + if (fail === 0) toast(`${ok} Mitglied${ok > 1 ? 'er' : ''} entfernt`, 'ok'); + else toast(`${ok} entfernt, ${fail} fehlgeschlagen`, 'warn'); + + // Mitgliederzahl aktualisieren + if (GexState.members) { + GexState.members.count = Math.max(0, GexState.members.count - ok); + document.getElementById('gexStats').textContent = `${GexState.members.count} Benutzer`; + } +}); + // ---- Benutzer-Suche im Add-Tab ---- let _gexUserSearchTimer = null; diff --git a/www/index.html b/www/index.html index 2b92f56..10b3d98 100644 --- a/www/index.html +++ b/www/index.html @@ -318,7 +318,8 @@
Gruppe auswählen um Mitglieder zu laden.
- + + +
diff --git a/www/styles.css b/www/styles.css index 253647c..e9fe16b 100644 --- a/www/styles.css +++ b/www/styles.css @@ -506,6 +506,14 @@ html[data-theme="dark"] .theme-ico-moon { opacity: 1; } border-color: var(--surface-strong); } +.btn-danger { + background: var(--error); + color: #fff; + border-color: var(--error); +} +.btn-danger:hover:not(:disabled) { filter: brightness(1.1); } +.btn-danger:disabled { opacity: .45; } + .btn-text { background: transparent; color: var(--muted); @@ -4674,12 +4682,18 @@ html[data-theme="dark"] .numInputWrapper span.arrowDown { .gex-user-item { display: flex; - align-items: baseline; + align-items: center; gap: 8px; padding: 6px 12px; border-bottom: 1px solid var(--hairline-soft); font-size: 13px; + cursor: default; } +.gex-user-item input[type="checkbox"] { + flex-shrink: 0; + cursor: pointer; +} +.gex-user-item input[type="checkbox"] + .gex-user-name { cursor: pointer; } .gex-user-item:last-child { border-bottom: none; } .gex-user-item.hidden-filter { display: none; }