v0.1.28 — Umbenennen beim Import, UI-Fixes, Geraete-Export repariert
Build & Release MSI / build-msi (push) Canceled after 0s

Neu:
- Umbenennen beim Import: Policy-Name vor dem Anlegen anpassbar
  (Dialog "Policies importieren"), richtiges Namensfeld je Typ

Behoben:
- Geraete-Export im App Report: entfernter sync-Endpoint
  getDeviceInstallStatusReport (400) -> async Export-Job
  DeviceInstallStatusByApp; ZIP/CSV-Logik in Get-IntuneReportRows
- Darkmode-Kontrast im Zuweisungs-Dialog (Theme-Variablen statt fester Farben)
- Dialog-Breite: lange Policy-Namen brechen um statt H-Scroll

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 09:23:59 +02:00
co-authored by Claude Opus 4.8
parent c1fc6f8b94
commit 830dddd15a
7 changed files with 184 additions and 67 deletions
+16
View File
@@ -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 ## [0.1.27] - 2026-08-23
Policy-Import härter, Administrative Vorlagen, Zuweisung nach Import, Git-Snapshot, App-Registrierungs-Doku. Policy-Import härter, Administrative Vorlagen, Zuweisung nach Import, Git-Snapshot, App-Registrierungs-Doku.
+1 -1
View File
@@ -188,7 +188,7 @@ $script:State = [pscustomobject]@{
Session = @() # geplante Zuweisungen Session = @() # geplante Zuweisungen
} }
$script:ToolVersion = "0.1.27" $script:ToolVersion = "0.1.28"
$script:BuildStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $script:BuildStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Write-Host "" Write-Host ""
+1 -1
View File
@@ -12,7 +12,7 @@ if (Test-Path "$x64Dotnet\dotnet.exe") {
$env:DOTNET_ROOT = "C:\Program Files\dotnet" $env:DOTNET_ROOT = "C:\Program Files\dotnet"
} }
$version = "0.1.27" $version = "0.1.28"
$src = "\\Mac\Home\ClaudePRJ\Intune Manager" $src = "\\Mac\Home\ClaudePRJ\Intune Manager"
$stage = "$env:TEMP\IntuneManagerStage" $stage = "$env:TEMP\IntuneManagerStage"
$installerDir = "$src\installer" $installerDir = "$src\installer"
+66 -45
View File
@@ -1995,8 +1995,49 @@ function Get-AppInstallReportEndpoint {
return @{ items = $items; count = $items.Count } 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 { 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) param([hashtable]$Query)
$err = Test-Connected $err = Test-Connected
if ($err) { return $err } if ($err) { return $err }
@@ -2006,58 +2047,38 @@ function Get-AppDeviceStatusEndpoint {
return @{ __status = 400; error = "appId fehlt" } return @{ __status = 400; error = "appId fehlt" }
} }
Write-Host "[DEVSTATUS] Lade Install-Status fuer App $appId via Reports-API..." -ForegroundColor DarkCyan Write-Host "[DEVSTATUS] Export-Job (DeviceInstallStatusByApp) fuer App $appId..." -ForegroundColor DarkCyan
$pageSize = 50
$skip = 0
$allRows = @()
$cols = $null
do {
$bodyJson = "{""filter"":""(ApplicationId eq '$appId')"",""select"":[],""skip"":$skip,""top"":$pageSize,""orderBy"":[]}"
try { try {
$resp = Invoke-MgGraphRequestRetry ` $rows = @(Get-IntuneReportRows -ReportName 'DeviceInstallStatusByApp' -Filter "(ApplicationId eq '$appId')")
-Uri 'https://graph.microsoft.com/beta/deviceManagement/reports/getDeviceInstallStatusReport' `
-Method POST -Body $bodyJson -ContentType 'application/json'
} catch { } catch {
return @{ __status = 500; error = "Graph-Fehler: $($_.Exception.Message)" } $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 } }
if (-not $cols) { Write-Host " [DEVSTATUS] Spalten: $(($rows[0].PSObject.Properties.Name) -join ',')" -ForegroundColor DarkGray
$cols = @($resp.Schema | ForEach-Object { $_.Column })
Write-Host " [DEVSTATUS] Spalten: $($cols -join ',')" -ForegroundColor DarkGray # 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 }
} }
$rows = @($resp.Values) return ''
$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 = @($rows | 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 = $_ $r = $_
[pscustomobject]@{ [pscustomobject]@{
DeviceName = if ($iDevice -ge 0) { [string]$r[$iDevice] } else { '' } DeviceName = & $col $r @('DeviceName')
UserName = if ($iUser -ge 0) { [string]$r[$iUser] } else { '' } UserName = & $col $r @('UserName','UserPrincipalName')
InstallState = if ($iState -ge 0) { [string]$r[$iState] } else { '' } InstallState = & $col $r @('InstallState_loc','InstallState','AppInstallState_loc','AppInstallState')
InstallStateDetail = if ($iDetail -ge 0) { [string]$r[$iDetail] } else { '' } InstallStateDetail = & $col $r @('InstallStateDetail_loc','InstallStateDetail','AppInstallStateDetail_loc','AppInstallStateDetail')
ErrorCode = if ($iErr -ge 0) { [string]$r[$iErr] } else { '' } ErrorCode = & $col $r @('ErrorCode','HexErrorCode')
OsVersion = if ($iOs -ge 0) { [string]$r[$iOs] } else { '' } OsVersion = & $col $r @('OSVersion','OsVersion','Platform')
LastSyncDateTime = if ($iSync -ge 0) { [string]$r[$iSync] } else { '' } LastSyncDateTime = & $col $r @('LastModifiedDateTime','LastSyncDateTime')
} }
}) })
+17
View File
@@ -119,6 +119,15 @@ function Get-PolicyProp {
return $null 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. # Grobe Plattform-Bezeichnung fuer die Listenanzeige.
function Get-PolicyPlatformLabel { function Get-PolicyPlatformLabel {
param($Raw, [string]$Type) param($Raw, [string]$Type)
@@ -563,6 +572,14 @@ function Import-PoliciesEndpoint {
$results += @{ policyName = [string]$policyName; success = $false; error = 'Envelope ohne "policy"-Feld' } $results += @{ policyName = [string]$policyName; success = $false; error = 'Envelope ohne "policy"-Feld' }
continue 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 { try {
$imported = Import-GraphPolicyByExportType -ExportType $exportType -Policy $policy $imported = Import-GraphPolicyByExportType -ExportType $exportType -Policy $policy
$results += @{ policyName = [string]$policyName; exportType = $exportType; success = $true; newId = [string](Get-PolicyProp $imported 'id') } $results += @{ policyName = [string]$policyName; exportType = $exportType; success = $true; newId = [string](Get-PolicyProp $imported 'id') }
+36 -2
View File
@@ -5920,6 +5920,13 @@ function polExtractEnvelopes(parsed, fileName) {
return []; 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) { async function polHandleImportFiles(fileList) {
const files = [...fileList]; const files = [...fileList];
if (!files.length) return; if (!files.length) return;
@@ -5931,10 +5938,36 @@ async function polHandleImportFiles(fileList) {
envelopes = envelopes.concat(polExtractEnvelopes(parsed, f.name)); envelopes = envelopes.concat(polExtractEnvelopes(parsed, f.name));
} }
if (!envelopes.length) return; 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 `<div class="pol-import-row">
<input class="input pol-import-name" data-idx="${i}" value="${escapeHtml(polEnvName(env))}" placeholder="Policy-Name">
<span class="pol-type-badge">${escapeHtml(POL_TYPE_LABELS[type] || type || 'Policy')}</span>
</div>`;
}).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…'); setLoading('Importiere Policies…');
try { 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 results = res.results || [];
const ok = results.filter(r => r.success); const ok = results.filter(r => r.success);
const fail = results.filter(r => !r.success); const fail = results.filter(r => !r.success);
@@ -5954,6 +5987,7 @@ async function polHandleImportFiles(fileList) {
clearLoading(); clearLoading();
} }
} }
document.getElementById('polImportConfirm')?.addEventListener('click', polDoImport);
// ============================================================= // =============================================================
// Post-Import: Policies pro Stück Include-/Exclude-Gruppen zuweisen // Post-Import: Policies pro Stück Include-/Exclude-Gruppen zuweisen
+44 -15
View File
@@ -813,6 +813,29 @@
</div> </div>
</div> </div>
<!-- MODAL: Policy-Import (optional umbenennen) -->
<div id="modalPolImport" class="modal hidden">
<div class="modal-backdrop" data-close></div>
<div class="modal-box modal-lg">
<div class="modal-head">
<h3>Policies importieren</h3>
<button class="modal-close" data-close aria-label="Schließen">×</button>
</div>
<div class="modal-body">
<div class="form-hint" style="margin-bottom:12px;">
Namen können vor dem Import angepasst werden. Die Policies werden als
<b>Neuanlage</b> im verbundenen Tenant erstellt — bestehende bleiben unverändert.
</div>
<div id="polImportList"></div>
</div>
<div class="modal-foot">
<span id="polImportInfo" class="muted" style="margin-right:auto;"></span>
<button class="btn btn-secondary" data-close>Abbrechen</button>
<button class="btn btn-primary" id="polImportConfirm">Importieren</button>
</div>
</div>
</div>
<!-- MODAL: Policy-Zuweisung nach Import --> <!-- MODAL: Policy-Zuweisung nach Import -->
<div id="modalPolAssign" class="modal hidden"> <div id="modalPolAssign" class="modal hidden">
<div class="modal-backdrop" data-close></div> <div class="modal-backdrop" data-close></div>
@@ -836,27 +859,33 @@
</div> </div>
</div> </div>
<style> <style>
.pol-assign-row { border:1px solid var(--border, #d9dce3); border-radius:8px; padding:12px 14px; margin-bottom:12px; } /* Nutzt die Theme-Variablen der App -> passt sich Light/Dark automatisch an. */
.pol-assign-head { display:flex; align-items:center; gap:8px; margin-bottom:10px; } .pol-assign-row { border:1px solid var(--hairline); border-radius:8px; padding:12px 14px; margin-bottom:12px; overflow-wrap:anywhere; }
.pol-assign-head .pol-name { font-weight:600; } .pol-assign-head { display:flex; align-items:baseline; gap:8px; margin-bottom:10px; flex-wrap:wrap; }
.pol-assign-head .pol-name { font-weight:600; color:var(--ink); min-width:0; word-break:break-word; overflow-wrap:anywhere; }
.pol-assign-head .pol-type-badge { flex:0 0 auto; }
.pol-assign-pickers { display:flex; gap:16px; flex-wrap:wrap; } .pol-assign-pickers { display:flex; gap:16px; flex-wrap:wrap; }
.pol-assign-picker { flex:1; min-width:220px; } .pol-assign-picker { flex:1; min-width:220px; }
.pol-assign-picker > .lbl { display:block; font-size:12px; margin-bottom:4px; opacity:.85; } .pol-assign-picker > .lbl { display:block; font-size:12px; margin-bottom:4px; color:var(--body); }
.pol-assign-picker[data-kind="exclude"] > .lbl { color:#cb2a7a; } .pol-assign-picker[data-kind="exclude"] > .lbl { color:var(--pink); }
.pol-assign-chips { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:6px; } .pol-assign-chips { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:6px; }
.pol-assign-chip { display:inline-flex; align-items:center; gap:6px; padding:2px 8px; border-radius:12px; .pol-assign-chip { display:inline-flex; align-items:center; gap:6px; padding:2px 8px; border-radius:12px;
background:var(--chip-bg, #eef0f5); font-size:12px; } background:var(--surface-strong); color:var(--ink); font-size:12px; }
.pol-assign-chip.exclude { background:#fbe3ef; } .pol-assign-chip.exclude { background:rgba(236,72,153,0.18); color:var(--ink); }
.pol-assign-chip button { border:none; background:none; cursor:pointer; font-size:14px; line-height:1; padding:0; opacity:.6; } .pol-assign-chip button { border:none; background:none; cursor:pointer; font-size:14px; line-height:1; padding:0; color:var(--muted); }
.pol-assign-chip button:hover { opacity:1; } .pol-assign-chip button:hover { color:var(--ink); }
.pol-assign-search-wrap { position:relative; } .pol-assign-search-wrap { position:relative; }
.pol-assign-dropdown { position:absolute; z-index:20; left:0; right:0; top:100%; margin-top:2px; .pol-assign-dropdown { position:absolute; z-index:20; left:0; right:0; top:100%; margin-top:2px;
background:var(--panel, #fff); border:1px solid var(--border, #d9dce3); border-radius:6px; background:var(--canvas); border:1px solid var(--hairline); border-radius:6px;
max-height:200px; overflow-y:auto; box-shadow:0 6px 18px rgba(0,0,0,.12); } max-height:200px; overflow-y:auto; box-shadow:0 6px 18px rgba(0,0,0,.25); }
.pol-assign-dropdown .opt { padding:6px 10px; cursor:pointer; font-size:13px; } .pol-assign-dropdown .opt { padding:6px 10px; cursor:pointer; font-size:13px; color:var(--body); }
.pol-assign-dropdown .opt:hover { background:var(--chip-bg, #eef0f5); } .pol-assign-dropdown .opt:hover { background:var(--surface-soft); }
.pol-assign-dropdown .opt .desc { font-size:11px; opacity:.6; } .pol-assign-dropdown .opt .desc { font-size:11px; color:var(--muted); }
.pol-assign-dropdown .msg { padding:6px 10px; font-size:12px; opacity:.6; } .pol-assign-dropdown .msg { padding:6px 10px; font-size:12px; color:var(--muted); }
.pol-import-row { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid var(--hairline); }
.pol-import-row:last-child { border-bottom:none; }
.pol-import-name { flex:1; min-width:0; }
.pol-import-row .pol-type-badge { flex:0 0 auto; }
</style> </style>
<!-- MODAL: Hilfe --> <!-- MODAL: Hilfe -->