diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dd1704..b96947e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/) — solan --- +## [0.1.28] - 2026-08-24 + +Umbenennen beim Import, UI-Fixes, Geräte-Export repariert. + +### Neu + +- **Umbenennen beim Import**: Vor dem Anlegen kann jeder importierten Policy im neuen Dialog „Policies importieren" ein anderer Name gegeben werden (das richtige Namensfeld je Typ — `name`/`displayName` — wird gesetzt). + +### Behoben + +- **Geräte-Export im App Report**: Der von Graph entfernte synchrone Endpoint `getDeviceInstallStatusReport` (400 „Resource not found for the segment") ist durch den asynchronen Export-Job **`DeviceInstallStatusByApp`** ersetzt (ZIP/CSV-Auswertung, tolerantes Spalten-Mapping). ZIP/CSV-Logik in `Get-IntuneReportRows` ausgelagert. +- **Darkmode-Kontrast** im Zuweisungs-Dialog: Gruppen-Chips nutzen jetzt die Theme-Variablen (`--surface-strong`/`--ink` statt fester heller Farben) — heller Text auf hellem Chip behoben. +- **Dialog-Breite**: Lange Policy-Namen im Zuweisungs-/Import-Dialog brechen jetzt um (`overflow-wrap`/`flex-wrap`) statt horizontal zu scrollen; das Fenster passt sich der Breite an. + +--- + ## [0.1.27] - 2026-08-23 Policy-Import härter, Administrative Vorlagen, Zuweisung nach Import, Git-Snapshot, App-Registrierungs-Doku. diff --git a/Start.ps1 b/Start.ps1 index ec2561d..ad1ced2 100644 --- a/Start.ps1 +++ b/Start.ps1 @@ -188,7 +188,7 @@ $script:State = [pscustomobject]@{ Session = @() # geplante Zuweisungen } -$script:ToolVersion = "0.1.27" +$script:ToolVersion = "0.1.28" $script:BuildStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" Write-Host "" diff --git a/installer/build-local.ps1 b/installer/build-local.ps1 index 8666646..1774c16 100644 --- a/installer/build-local.ps1 +++ b/installer/build-local.ps1 @@ -12,7 +12,7 @@ if (Test-Path "$x64Dotnet\dotnet.exe") { $env:DOTNET_ROOT = "C:\Program Files\dotnet" } -$version = "0.1.27" +$version = "0.1.28" $src = "\\Mac\Home\ClaudePRJ\Intune Manager" $stage = "$env:TEMP\IntuneManagerStage" $installerDir = "$src\installer" diff --git a/src/Api.ps1 b/src/Api.ps1 index dc0b6b4..e6a3159 100644 --- a/src/Api.ps1 +++ b/src/Api.ps1 @@ -1995,8 +1995,49 @@ function Get-AppInstallReportEndpoint { return @{ items = $items; count = $items.Count } } +# Intune-Report via asynchronem Export-Job (exportJobs) -> CSV-Zeilen. +# Kein 'select' (unbekannte Spalten wuerden 400 ausloesen) — der Aufrufer +# greift tolerant auf die tatsaechlich gelieferten Spalten zu. +function Get-IntuneReportRows { + param( + [Parameter(Mandatory=$true)][string]$ReportName, + [string]$Filter = '', + [int]$TimeoutSec = 120 + ) + $jobBody = @{ reportName = $ReportName; filter = $Filter } | ConvertTo-Json -Compress + $job = Invoke-MgGraphRequestRetry ` + -Uri 'https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs' ` + -Method POST -Body $jobBody -ContentType 'application/json' + $jobId = $job.id + $status = $job.status + $waited = 0 + while ($status -ne 'completed' -and $status -ne 'failed' -and $waited -lt $TimeoutSec) { + Start-Sleep -Seconds 3 + $waited += 3 + $job = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs/$jobId" + $status = $job.status + } + if ($status -ne 'completed') { throw "Export-Job '$ReportName' nicht abgeschlossen (Status: $status nach $waited s)" } + + $tmpZip = [System.IO.Path]::GetTempFileName() + '.zip' + $tmpDir = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "IntuneReport_$jobId") + try { + Invoke-WebRequest -Uri $job.url -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" } + return @(Import-Csv -Path $csv.FullName -Encoding UTF8) + } finally { + Remove-Item -Path $tmpZip -Force -ErrorAction SilentlyContinue + Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + function Get-AppDeviceStatusEndpoint { - # Verwendet getDeviceInstallStatusReport (synchron, paginiert) statt deviceStatuses. + # Per-Device-Installationsstatus einer App via Export-Job 'DeviceInstallStatusByApp'. + # (Der frueher genutzte synchrone Endpoint getDeviceInstallStatusReport existiert + # nicht mehr -> 400 "Resource not found for the segment".) param([hashtable]$Query) $err = Test-Connected if ($err) { return $err } @@ -2006,58 +2047,38 @@ function Get-AppDeviceStatusEndpoint { return @{ __status = 400; error = "appId fehlt" } } - 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 } + Write-Host "[DEVSTATUS] Export-Job (DeviceInstallStatusByApp) fuer App $appId..." -ForegroundColor DarkCyan + try { + $rows = @(Get-IntuneReportRows -ReportName 'DeviceInstallStatusByApp' -Filter "(ApplicationId eq '$appId')") + } catch { + $msg = $_.Exception.Message + try { if ($_.ErrorDetails.Message) { $msg = $_.ErrorDetails.Message } } catch {} + return @{ __status = 500; error = "Graph-Fehler: $msg" } } + Write-Host " -> $($rows.Count) Eintraege" -ForegroundColor DarkGray + if ($rows.Count -eq 0) { return @{ items = @(); count = 0 } } - 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' } + Write-Host " [DEVSTATUS] Spalten: $(($rows[0].PSObject.Properties.Name) -join ',')" -ForegroundColor DarkGray - $result = @($allRows | ForEach-Object { + # Spaltennamen des Reports koennen variieren -> tolerant mit Fallbacks lesen. + $col = { + param($row, [string[]]$names) + foreach ($n in $names) { + $p = $row.PSObject.Properties[$n] + if ($p -and $null -ne $p.Value -and [string]$p.Value -ne '') { return [string]$p.Value } + } + return '' + } + $result = @($rows | ForEach-Object { $r = $_ [pscustomobject]@{ - 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 { '' } + DeviceName = & $col $r @('DeviceName') + UserName = & $col $r @('UserName','UserPrincipalName') + InstallState = & $col $r @('InstallState_loc','InstallState','AppInstallState_loc','AppInstallState') + InstallStateDetail = & $col $r @('InstallStateDetail_loc','InstallStateDetail','AppInstallStateDetail_loc','AppInstallStateDetail') + ErrorCode = & $col $r @('ErrorCode','HexErrorCode') + OsVersion = & $col $r @('OSVersion','OsVersion','Platform') + LastSyncDateTime = & $col $r @('LastModifiedDateTime','LastSyncDateTime') } }) diff --git a/src/PolicyIO.ps1 b/src/PolicyIO.ps1 index 3bbe964..924202d 100644 --- a/src/PolicyIO.ps1 +++ b/src/PolicyIO.ps1 @@ -119,6 +119,15 @@ function Get-PolicyProp { return $null } +# Property setzen — Hashtable ODER PSCustomObject (fuer Import-Umbenennung). +function Set-PolicyProp { + param($Obj, [string]$Name, $Value) + if ($null -eq $Obj) { return } + if ($Obj -is [System.Collections.IDictionary]) { $Obj[$Name] = $Value; return } + if ($Obj.PSObject.Properties[$Name]) { $Obj.$Name = $Value } + else { $Obj | Add-Member -NotePropertyName $Name -NotePropertyValue $Value -Force } +} + # Grobe Plattform-Bezeichnung fuer die Listenanzeige. function Get-PolicyPlatformLabel { param($Raw, [string]$Type) @@ -563,6 +572,14 @@ function Import-PoliciesEndpoint { $results += @{ policyName = [string]$policyName; success = $false; error = 'Envelope ohne "policy"-Feld' } continue } + # Optionale Umbenennung: das richtige Namensfeld je Typ setzen. + $newName = [string](Get-PolicyProp $env 'newName') + if ($newName -and $newName.Trim()) { + $cfg = Get-PolicyTypeConfigByExportType $exportType + $nameField = if ($cfg) { $cfg.NameField } else { 'displayName' } + Set-PolicyProp $policy $nameField $newName.Trim() + $policyName = $newName.Trim() + } try { $imported = Import-GraphPolicyByExportType -ExportType $exportType -Policy $policy $results += @{ policyName = [string]$policyName; exportType = $exportType; success = $true; newId = [string](Get-PolicyProp $imported 'id') } diff --git a/www/app.js b/www/app.js index 86feacb..1366068 100644 --- a/www/app.js +++ b/www/app.js @@ -5920,6 +5920,13 @@ function polExtractEnvelopes(parsed, fileName) { return []; } +let _polImportEnvelopes = []; + +function polEnvName(env) { + return env.policyName || (env.policy && (env.policy.name || env.policy.displayName)) || '(ohne Name)'; +} + +// Dateien einlesen -> Rename-Dialog oeffnen (der eigentliche Import folgt dort). async function polHandleImportFiles(fileList) { const files = [...fileList]; if (!files.length) return; @@ -5931,10 +5938,36 @@ async function polHandleImportFiles(fileList) { envelopes = envelopes.concat(polExtractEnvelopes(parsed, f.name)); } if (!envelopes.length) return; - if (!window.confirm(`${envelopes.length} Policy(s) als NEUE Policies im verbundenen Tenant anlegen?\n\nBestehende Policies werden dabei nicht verändert.`)) return; + _polImportEnvelopes = envelopes; + renderPolImportList(); + openModal('modalPolImport'); +} + +function renderPolImportList() { + const list = document.getElementById('polImportList'); + if (!list) return; + list.innerHTML = _polImportEnvelopes.map((env, i) => { + const type = env.exportType || env.policyType || ''; + return `
+ + ${escapeHtml(POL_TYPE_LABELS[type] || type || 'Policy')} +
`; + }).join(''); + const info = document.getElementById('polImportInfo'); + if (info) info.textContent = `${_polImportEnvelopes.length} Policy(s)`; +} + +async function polDoImport() { + // Geänderte Namen übernehmen (nur wenn nicht leer und wirklich anders). + _polImportEnvelopes.forEach((env, i) => { + const input = document.querySelector(`.pol-import-name[data-idx="${i}"]`); + const nn = input ? input.value.trim() : ''; + if (nn && nn !== polEnvName(env)) env.newName = nn; else delete env.newName; + }); + closeModal('modalPolImport'); setLoading('Importiere Policies…'); try { - const res = await api('/api/policies/import', { method: 'POST', body: { policies: envelopes }, timeoutMs: 180000 }); + const res = await api('/api/policies/import', { method: 'POST', body: { policies: _polImportEnvelopes }, timeoutMs: 180000 }); const results = res.results || []; const ok = results.filter(r => r.success); const fail = results.filter(r => !r.success); @@ -5954,6 +5987,7 @@ async function polHandleImportFiles(fileList) { clearLoading(); } } +document.getElementById('polImportConfirm')?.addEventListener('click', polDoImport); // ============================================================= // Post-Import: Policies pro Stück Include-/Exclude-Gruppen zuweisen diff --git a/www/index.html b/www/index.html index 60db440..546a442 100644 --- a/www/index.html +++ b/www/index.html @@ -813,6 +813,29 @@ + + +