v0.1.33 — Offboarding-Report und Update-Hinweis
Build & Release MSI / build-msi (push) Canceled after 0s

- Offboarding: HTML-Report pro Geraet (New-OffboardHtmlReport) + reportUrl;
  Dialog-Ergebnis pro Geraet gruppiert + Report-Link
- Update-Check: GET /api/update/check (Gitea releases/latest, Compare-SemVer);
  Kopfzeilen-Badge bei neuer Version, Link zur Release-Seite; optionaler read-only Token

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 15:59:30 +02:00
co-authored by Claude Opus 4.8
parent 968f47b94f
commit e4efb0ac32
8 changed files with 349 additions and 6 deletions
+11
View File
@@ -5,6 +5,17 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/) — solan
--- ---
## [0.1.33] - 2026-09-13
Offboarding-Report und Update-Hinweis.
### Neu
- **Offboarding-Report**: Nach dem Ausführen erzeugt das Offboarding einen **HTML-Report** (`/reports/offboard-report-<zeit>.html`) im Stil des Zuweisungs-Reports — mit KPIs (Geräte / Aktionen / Erfolg / Fehler) und **pro Gerät** einer Tabelle der ausgeführten Löschungen (Dienst · Aktion · Status · Meldung). Das Ergebnis im Dialog ist jetzt ebenfalls **pro Gerät gruppiert**, plus ein Button „HTML-Report öffnen".
- **Update-Hinweis**: Beim Start prüft die App (nicht-blockierend) die neueste Release-Version über **Gitea** und zeigt bei einer neueren Version ein **Badge in der Kopfzeile** mit Link zur Release-Seite (dort MSI herunterladen → In-Place-Upgrade). Nur Lesezugriff; optionaler read-only Token via `settings.json → update.token`, falls das Repo privat ist. Neuer Endpoint `GET /api/update/check`.
---
## [0.1.32] - 2026-09-11 ## [0.1.32] - 2026-09-11
Bulk-Offboarding aus dem Geräte-Tab, Geräte-Ladefehler behoben, bessere Graph-Diagnose. Bulk-Offboarding aus dem Geräte-Tab, Geräte-Ladefehler behoben, bessere Graph-Diagnose.
+1 -1
View File
@@ -189,7 +189,7 @@ $script:State = [pscustomobject]@{
Session = @() # geplante Zuweisungen Session = @() # geplante Zuweisungen
} }
$script:ToolVersion = "0.1.32" $script:ToolVersion = "0.1.33"
$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.32" $version = "0.1.33"
$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"
+255
View File
@@ -21,6 +21,7 @@ function Invoke-ApiHandler {
"POST /api/settings/test" { return Test-SettingsEndpoint -Body $Body } "POST /api/settings/test" { return Test-SettingsEndpoint -Body $Body }
"POST /api/settings/logo" { return Save-LogoEndpoint -Body $Body } "POST /api/settings/logo" { return Save-LogoEndpoint -Body $Body }
"DELETE /api/settings/logo" { return Remove-LogoEndpoint } "DELETE /api/settings/logo" { return Remove-LogoEndpoint }
"GET /api/update/check" { return Get-UpdateCheckEndpoint }
"POST /api/connect" { return Invoke-ConnectEndpoint } "POST /api/connect" { return Invoke-ConnectEndpoint }
"POST /api/connect/token" { return Invoke-ConnectWithTokenEndpoint -Body $Body } "POST /api/connect/token" { return Invoke-ConnectWithTokenEndpoint -Body $Body }
"POST /api/connect/start" { return Start-DeviceCodeConnect } "POST /api/connect/start" { return Start-DeviceCodeConnect }
@@ -136,6 +137,65 @@ function Invoke-ApiHandler {
return $null return $null
} }
# ============================================================
# Update-Check (neueste Release-Version von Gitea vergleichen)
# ============================================================
# Semver-Vergleich: 1 wenn A>B, -1 wenn A<B, 0 gleich. Fuehrendes 'v' und
# Pre-Release/Build-Suffixe werden ignoriert; fehlende Teile zaehlen als 0.
function Compare-SemVer {
param([string]$A, [string]$B)
$clean = {
param($s)
$s = ([string]$s).Trim().TrimStart('v','V')
$s = ($s -split '[-+ ]')[0]
@($s -split '\.' | ForEach-Object { [int]([regex]::Match($_, '\d+').Value -as [int]) })
}
$pa = & $clean $A
$pb = & $clean $B
$len = [Math]::Max($pa.Count, $pb.Count)
for ($i = 0; $i -lt $len; $i++) {
$x = if ($i -lt $pa.Count) { [int]$pa[$i] } else { 0 }
$y = if ($i -lt $pb.Count) { [int]$pb[$i] } else { 0 }
if ($x -gt $y) { return 1 }
if ($x -lt $y) { return -1 }
}
return 0
}
# Prueft die neueste Release-Version. Quelle standardmaessig das Gitea-Repo;
# ueber Settings.update.apiUrl / .token ueberschreibbar (falls Repo privat).
# Braucht KEINE Graph-Verbindung. Fehler werden still zurueckgegeben (kein Banner).
function Get-UpdateCheckEndpoint {
$current = [string]$script:ToolVersion
$out = @{ current = $current; latest = ''; updateAvailable = $false; releaseUrl = ''; error = '' }
$api = 'https://git.wende.it/api/v1/repos/marco/Intune-Manager/releases/latest'
$token = ''
try {
if ($script:Settings.update) {
if ($script:Settings.update.apiUrl) { $api = [string]$script:Settings.update.apiUrl }
if ($script:Settings.update.token) { $token = [string]$script:Settings.update.token }
}
} catch {}
$headers = @{ 'User-Agent' = 'IntuneManager' }
if ($token) { $headers['Authorization'] = "token $token" }
try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch {}
try {
$rel = Invoke-RestMethod -Uri $api -Headers $headers -Method GET -TimeoutSec 8
$latest = ([string]$rel.tag_name).TrimStart('v','V')
$out.latest = $latest
$out.releaseUrl = [string]$rel.html_url
$out.updateAvailable = ((Compare-SemVer $latest $current) -gt 0)
} catch {
$out.error = $_.Exception.Message
Write-Host "[UPDATE] Versions-Check fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkGray
}
return $out
}
# ============================================================ # ============================================================
# Status & Connection # Status & Connection
# ============================================================ # ============================================================
@@ -3605,3 +3665,198 @@ footer code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;bac
[IO.File]::WriteAllText($path, $html, [System.Text.Encoding]::UTF8) [IO.File]::WriteAllText($path, $html, [System.Text.Encoding]::UTF8)
return $path return $path
} }
# HTML-Report fuers Geraete-Offboarding — pro Geraet die ausgefuehrten Loeschungen
# (Entra/Intune/Autopilot) mit Status + Meldung. Liegt in Api.ps1 (BOM) wegen Umlauten.
function New-OffboardHtmlReport {
param($Devices, $Results, [int]$Success, [int]$Errors, [int]$Total, [string]$EntraAction, [bool]$DoIntune, [bool]$DoAutopilot)
Add-Type -AssemblyName System.Web
function _e { param($s) if ($null -eq $s) { return "" } return [System.Web.HttpUtility]::HtmlEncode([string]$s) }
$ts = Get-Date -Format "yyyy-MM-dd_HH-mm-ss"
$tsHuman = Get-Date -Format "dd.MM.yyyy HH:mm:ss"
$name = "offboard-report-$ts.html"
$path = Join-Path $script:Config.ReportDir $name
$user = $env:USERNAME
$machine = $env:COMPUTERNAME
$tenant = if ($script:State.TenantId) { $script:State.TenantId } else { "" }
$account = if ($script:State.Account) { $script:State.Account } else { "" }
$devCount = @($Devices).Count
$bannerCls = if ($Errors -gt 0) { "banner-err" } else { "banner-ok" }
$bannerTxt = if ($Errors -gt 0) { "$Errors von $Total Aktion(en) fehlgeschlagen - Details unten" } else { "Alle $Success Aktion(en) erfolgreich" }
# Dienst-Zusammenfassung (was angefordert wurde)
$svcParts = @()
if ($EntraAction -eq 'delete') { $svcParts += "Entra ID: geloescht" }
elseif ($EntraAction -eq 'disable') { $svcParts += "Entra ID: deaktiviert" }
if ($DoIntune) { $svcParts += "Intune: geloescht" }
if ($DoAutopilot) { $svcParts += "Autopilot: geloescht" }
$svcTxt = if ($svcParts.Count -gt 0) { ($svcParts -join " &middot; ") } else { "keine" }
# Ergebnisse pro Geraet gruppieren (Reihenfolge = Geraeteliste)
$byDev = [ordered]@{}
foreach ($d in @($Devices)) { $dn = [string]$d.deviceName; if ($dn -and -not $byDev.Contains($dn)) { $byDev[$dn] = @() } }
foreach ($r in @($Results)) {
$dn = [string]$r.deviceName
if (-not $byDev.Contains($dn)) { $byDev[$dn] = @() }
$byDev[$dn] += $r
}
$devBlocks = ""
foreach ($dn in @($byDev.Keys)) {
$rows = @($byDev[$dn])
$okC = @($rows | Where-Object { $_.success }).Count
$errC = @($rows | Where-Object { -not $_.success }).Count
$rowsHtml = ""
foreach ($r in $rows) {
$cls = if ($r.success) { "ok" } else { "err" }
$ico = if ($r.success) { "&#10003;" } else { "&#33;" }
$lbl = if ($r.success) { "Erfolg" } else { "Fehler" }
$msg = if ($r.error) { _e $r.error } else { "" }
$rowsHtml += "<tr class='row-$cls'>" +
"<td class='c-status'><span class='status-pill status-$cls'>$ico $lbl</span></td>" +
"<td class='c-svc'>$(_e $r.service)</td>" +
"<td class='c-act'>$(_e $r.action)</td>" +
"<td class='c-msg'>$msg</td></tr>"
}
if (-not $rowsHtml) { $rowsHtml = "<tr><td colspan='4' class='c-msg'>Keine ausgefuehrten Aktionen.</td></tr>" }
$stats = ""
if ($okC -gt 0) { $stats += "<span class='hd-pill hd-ok'>$okC OK</span>" }
if ($errC -gt 0) { $stats += "<span class='hd-pill hd-err'>$errC Fehler</span>" }
$devBlocks += "<details class='dev-block' open><summary class='dev-head'>" +
"<span class='dev-name'>$(_e $dn)</span><span class='dev-stats'>$stats</span></summary>" +
"<div class='dev-body'><table class='res-table'><thead><tr>" +
"<th class='c-status'>Status</th><th class='c-svc'>Dienst</th><th class='c-act'>Aktion</th><th class='c-msg'>Meldung</th>" +
"</tr></thead><tbody>$rowsHtml</tbody></table></div></details>"
}
if (-not $devBlocks) { $devBlocks = "<div class='empty'>Keine Ergebnisse.</div>" }
$html = @"
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Intune Offboarding-Report - $tsHuman</title>
<link rel="preconnect" href="https://rsms.me/">
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<style>
:root{
--black-100:#0A0E15;--black-80:#373F4E;--black-60:#667085;
--white-100:#FFFFFF;--white-90:#F0F1F5;--white-80:#E0E4EB;--white-70:#D1D6E0;
--blue-60:#3566B6;--blue-10:#E0ECFF;
--success-100:#008072;--success-60:#A0E3D8;--success-10:#D0FBF5;
--error-100:#B21919;--error-60:#EA9E9E;--error-10:#FBE0E0;
--warning-100:#8F7200;--warning-60:#F8E081;--warning-10:#FFF3C2;
--bg:#F7FAFD;--surface:#FFF;--surface-soft:#F4F7FB;--hairline:var(--white-70);--hairline-soft:var(--white-90);
--text-primary:var(--black-100);--text-secondary:var(--black-80);--text-muted:var(--black-60);
--r-sm:6px;--r-md:10px;--r-lg:14px;--shadow-soft:0 1px 2px rgba(23,64,130,.04),0 2px 6px rgba(23,64,130,.04);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{font-family:'Inter',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--bg);color:var(--text-secondary);line-height:1.5;-webkit-font-smoothing:antialiased}
body{padding:32px 16px 80px}
.container{max-width:1100px;margin:0 auto}
.head{background:var(--surface);border:1px solid var(--hairline);border-radius:var(--r-lg);padding:24px 28px;margin-bottom:20px;box-shadow:var(--shadow-soft)}
.head-top{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px}
.brand{display:flex;align-items:center;gap:12px}
.logo{width:40px;height:40px;border-radius:9px;background:var(--blue-60);color:#fff;display:grid;place-items:center;font-size:20px;font-weight:700}
.title{font-size:22px;font-weight:700;letter-spacing:-.015em;color:var(--text-primary)}
.subtitle{font-size:13px;color:var(--text-muted);margin-top:2px}
.head-meta{display:flex;flex-wrap:wrap;gap:6px}
.meta-pill{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;background:var(--surface-soft);border:1px solid var(--hairline);padding:5px 10px;border-radius:999px;color:var(--text-secondary);font-weight:500}
.meta-pill .lbl{color:var(--text-muted);text-transform:uppercase;font-size:10px;letter-spacing:.04em;font-weight:600}
.meta-pill code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px}
.banner{margin-top:18px;padding:12px 16px;border-radius:var(--r-md);font-size:13.5px;font-weight:500;display:flex;align-items:center;gap:10px;border:1px solid transparent}
.banner-ok{background:var(--success-10);border-color:var(--success-60);color:var(--success-100)}
.banner-err{background:var(--error-10);border-color:var(--error-60);color:var(--error-100)}
.banner-icon{width:22px;height:22px;border-radius:50%;display:inline-grid;place-items:center;font-size:13px;font-weight:700}
.banner-ok .banner-icon{background:var(--success-60);color:var(--success-100)}
.banner-err .banner-icon{background:var(--error-60);color:var(--error-100)}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:20px}
.kpi{background:var(--surface);border:1px solid var(--hairline);border-radius:var(--r-md);padding:18px}
.kpi-num{font-size:30px;font-weight:700;line-height:1;letter-spacing:-.02em;font-variant-numeric:tabular-nums;color:var(--text-primary)}
.kpi-lbl{font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.04em;font-weight:600;margin-top:6px}
.kpi.ok .kpi-num{color:var(--success-100)}
.kpi.err .kpi-num{color:var(--error-100)}
.section{background:var(--surface);border:1px solid var(--hairline);border-radius:var(--r-lg);padding:22px 24px;margin-bottom:18px;box-shadow:var(--shadow-soft)}
.section-title{font-size:15px;font-weight:600;color:var(--text-primary);margin-bottom:4px}
.section-sub{font-size:12px;color:var(--text-muted);margin-bottom:14px}
.dev-block{border:1px solid var(--hairline);border-radius:var(--r-md);overflow:hidden;margin-bottom:10px;background:var(--surface)}
.dev-head{cursor:pointer;padding:12px 16px;display:flex;align-items:center;justify-content:space-between;gap:12px;list-style:none}
.dev-head::-webkit-details-marker{display:none}
.dev-name{font-size:14px;font-weight:600;color:var(--text-primary)}
.dev-stats{display:flex;gap:6px}
.hd-pill{font-size:10.5px;font-weight:700;padding:2px 8px;border-radius:999px}
.hd-ok{background:var(--success-10);color:var(--success-100)}
.hd-err{background:var(--error-10);color:var(--error-100)}
.dev-body{border-top:1px solid var(--hairline-soft)}
.res-table{width:100%;border-collapse:collapse;font-size:13px}
.res-table th{text-align:left;font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);font-weight:600;padding:8px 16px;background:var(--surface-soft);border-bottom:1px solid var(--hairline-soft)}
.res-table td{padding:9px 16px;border-bottom:1px solid var(--hairline-soft);vertical-align:top}
.res-table tr:last-child td{border-bottom:none}
.c-status{width:120px}.c-svc{width:120px;font-weight:600;color:var(--text-primary)}.c-act{width:130px}
.c-msg{color:var(--error-100)}
.status-pill{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;padding:3px 9px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
.status-ok{background:var(--success-10);color:var(--success-100);border-color:var(--success-60)}
.status-err{background:var(--error-10);color:var(--error-100);border-color:var(--error-60)}
.empty{padding:18px;text-align:center;color:var(--text-muted);font-size:13px;background:var(--surface-soft);border-radius:var(--r-md);border:1px dashed var(--hairline)}
footer{text-align:center;color:var(--text-muted);font-size:11.5px;margin-top:24px}
footer code{font-family:ui-monospace,Menlo,Consolas,monospace;background:var(--surface);padding:1px 6px;border-radius:4px;border:1px solid var(--hairline)}
@media print{body{background:#fff;padding:0}.section,.head,.kpi,.dev-block{box-shadow:none;break-inside:avoid}}
</style>
</head>
<body>
<div class="container">
<header class="head">
<div class="head-top">
<div class="brand">
<div class="logo">I</div>
<div>
<div class="title">Intune Offboarding-Report</div>
<div class="subtitle">$tsHuman &middot; ausgefuehrt von $(_e $user)</div>
</div>
</div>
<div class="head-meta">
"@
if ($account) { $html += "<span class='meta-pill'><span class='lbl'>Account</span> $(_e $account)</span>" }
if ($tenant) { $html += "<span class='meta-pill'><span class='lbl'>Tenant</span> <code>$(_e $tenant)</code></span>" }
$html += "<span class='meta-pill'><span class='lbl'>Host</span> $(_e $machine)</span>"
$html += @"
</div>
</div>
<div class="banner $bannerCls">
<span class="banner-icon">$(if ($Errors -gt 0) { '!' } else { '&#10003;' })</span>
<span>$bannerTxt</span>
</div>
</header>
<div class="kpis">
<div class="kpi tot"><div class="kpi-num">$devCount</div><div class="kpi-lbl">Geraete</div></div>
<div class="kpi tot"><div class="kpi-num">$Total</div><div class="kpi-lbl">Aktionen</div></div>
<div class="kpi ok"><div class="kpi-num">$Success</div><div class="kpi-lbl">Erfolgreich</div></div>
<div class="kpi err"><div class="kpi-num">$Errors</div><div class="kpi-lbl">Fehler</div></div>
</div>
<div class="section">
<div class="section-title">Angeforderte Dienste</div>
<div class="section-sub">$svcTxt</div>
</div>
<div class="section">
<div class="section-title">Ergebnisse pro Geraet</div>
<div class="section-sub">Jeder Block zeigt die ausgefuehrten Loeschungen/Aenderungen mit Status und Meldung.</div>
$devBlocks
</div>
<footer>
Intune Manager &middot; Web Edition <code>v$(_e $script:ToolVersion)</code> &middot; Offboarding-Report
</footer>
</div>
</body>
</html>
"@
[IO.File]::WriteAllText($path, $html, [System.Text.Encoding]::UTF8)
return $path
}
+13 -1
View File
@@ -282,6 +282,18 @@ function Invoke-OffboardExecuteEndpoint {
} }
} }
$okCount = @($results | Where-Object { $_.success }).Count $okCount = @($results | Where-Object { $_.success }).Count
$errCount = $results.Count - $okCount
Write-Host " -> $okCount/$($results.Count) erfolgreich" -ForegroundColor DarkGray Write-Host " -> $okCount/$($results.Count) erfolgreich" -ForegroundColor DarkGray
return @{ ok = $true; successCount = $okCount; total = $results.Count; results = @($results) }
# HTML-Report erzeugen (pro Geraet die ausgefuehrten Loeschungen) und verlinken.
$reportUrl = ""
try {
$reportFile = New-OffboardHtmlReport -Devices $devices -Results $results -Success $okCount -Errors $errCount -Total $results.Count -EntraAction $entraAction -DoIntune $doIntune -DoAutopilot $doAutopilot
if ($reportFile) { $reportUrl = "/reports/" + (Split-Path $reportFile -Leaf) }
Write-Host "[OFFBOARD] HTML-Report: $reportFile" -ForegroundColor DarkGray
} catch {
Write-Host "[OFFBOARD] Report-Erstellung fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor Red
}
return @{ ok = $true; successCount = $okCount; total = $results.Count; results = @($results); reportUrl = [string]$reportUrl }
} }
+42 -2
View File
@@ -4784,6 +4784,24 @@ document.getElementById('btnRestoreColors')?.addEventListener('click', () => {
// Init // Init
// ============================================================= // =============================================================
// Prueft (nicht-blockierend) auf eine neuere Release-Version und zeigt bei Bedarf
// das Update-Badge in der Kopfzeile mit Link zur Release-Seite.
async function checkForUpdate() {
try {
const res = await api('/api/update/check');
if (res && res.updateAvailable && res.releaseUrl) {
const badge = document.getElementById('updateBadge');
const txt = document.getElementById('updateBadgeText');
if (badge && txt) {
txt.textContent = `Update ${res.latest}`;
badge.href = res.releaseUrl;
badge.title = `Neue Version ${res.latest} verfügbar (aktuell ${res.current}) — Release-Seite öffnen`;
badge.classList.remove('hidden');
}
}
} catch (e) { /* still: Update-Check ist optional */ }
}
(async function init() { (async function init() {
renderConnection(); renderConnection();
renderTargets(); renderTargets();
@@ -4807,6 +4825,7 @@ document.getElementById('btnRestoreColors')?.addEventListener('click', () => {
} }
await refreshStatus(); await refreshStatus();
refreshTenantSwitcher(); refreshTenantSwitcher();
checkForUpdate(); // nicht-blockierend
if (State.connected) autoLoadAfterConnect(); if (State.connected) autoLoadAfterConnect();
// First-Run: wenn kritische Felder leer sind, Settings-Modal automatisch oeffnen. // First-Run: wenn kritische Felder leer sind, Settings-Modal automatisch oeffnen.
if (setupNeeded) { if (setupNeeded) {
@@ -7012,9 +7031,30 @@ async function offExecute() {
try { try {
const res = await api('/api/offboard/execute', { method: 'POST', body: { devices: payload, services }, timeoutMs: 180000 }); const res = await api('/api/offboard/execute', { method: 'POST', body: { devices: payload, services }, timeoutMs: 180000 });
const results = res.results || []; const results = res.results || [];
// Ergebnis pro Gerät gruppieren
const byDev = new Map();
for (const r of results) {
if (!byDev.has(r.deviceName)) byDev.set(r.deviceName, []);
byDev.get(r.deviceName).push(r);
}
const devHtml = [...byDev.entries()].map(([name, rows]) => {
const errs = rows.filter(r => !r.success).length;
const lines = rows.map(r =>
`<div class="off-result ${r.success ? 'ok' : 'fail'}">${r.success ? '✓' : '✗'} ${escapeHtml(r.service)} ${escapeHtml(r.action)}${r.error ? ': ' + escapeHtml(r.error) : ''}</div>`
).join('');
return `<div class="off-result-dev">
<div class="off-result-devname">${escapeHtml(name || '—')}${errs ? ` <span class="off-result-badge">${errs} Fehler</span>` : ''}</div>
${lines}
</div>`;
}).join('');
const reportLink = res.reportUrl
? `<a href="${escapeHtml(res.reportUrl)}" target="_blank" rel="noopener" class="btn btn-secondary btn-sm" style="margin-top:12px;display:inline-flex;align-items:center;gap:6px;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>
HTML-Report öffnen
</a>`
: '';
document.getElementById('offResults').innerHTML = document.getElementById('offResults').innerHTML =
`<div class="lbl">Ergebnis (${res.successCount}/${res.total})</div>` + `<div class="lbl">Ergebnis (${res.successCount}/${res.total})</div>${devHtml}${reportLink}`;
results.map(r => `<div class="off-result ${r.success ? 'ok' : 'fail'}">${r.success ? '✓' : '✗'} ${escapeHtml(r.deviceName)} — ${escapeHtml(r.service)} ${escapeHtml(r.action)}${r.error ? ': ' + escapeHtml(r.error) : ''}</div>`).join('');
toast(`${res.successCount}/${res.total} Aktionen erfolgreich.`, res.successCount === res.total ? 'ok' : 'warn', 'Offboarding'); toast(`${res.successCount}/${res.total} Aktionen erfolgreich.`, res.successCount === res.total ? 'ok' : 'warn', 'Offboarding');
// Erfolgreich vollständig offgeboardete Geräte aus der Liste nehmen. // Erfolgreich vollständig offgeboardete Geräte aus der Liste nehmen.
const doneNames = new Set(results.filter(r => r.success).map(r => r.deviceName)); const doneNames = new Set(results.filter(r => r.success).map(r => r.deviceName));
+7
View File
@@ -41,6 +41,10 @@
</nav> </nav>
<div class="header-actions"> <div class="header-actions">
<a id="updateBadge" class="update-badge hidden" href="#" target="_blank" rel="noopener" title="Update verfügbar — Release-Seite öffnen">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"/><polyline points="21 3 21 9 15 9"/></svg>
<span id="updateBadgeText">Update</span>
</a>
<button id="btnSettings" class="icon-btn icon-btn-header" title="Einstellungen" aria-label="Einstellungen öffnen"> <button id="btnSettings" class="icon-btn icon-btn-header" title="Einstellungen" aria-label="Einstellungen öffnen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.86l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.86-.34 1.7 1.7 0 0 0-1.04 1.57V21a2 2 0 0 1-4 0v-.09a1.7 1.7 0 0 0-1.1-1.57 1.7 1.7 0 0 0-1.86.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.86 1.7 1.7 0 0 0-1.57-1.04H3a2 2 0 0 1 0-4h.09A1.7 1.7 0 0 0 4.66 9a1.7 1.7 0 0 0-.34-1.86l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.86.34H9a1.7 1.7 0 0 0 1.04-1.57V3a2 2 0 0 1 4 0v.09c0 .68.42 1.29 1.04 1.57.62.27 1.36.13 1.86-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.86V9a1.7 1.7 0 0 0 1.57 1.04H21a2 2 0 0 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.86l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.86-.34 1.7 1.7 0 0 0-1.04 1.57V21a2 2 0 0 1-4 0v-.09a1.7 1.7 0 0 0-1.1-1.57 1.7 1.7 0 0 0-1.86.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.86 1.7 1.7 0 0 0-1.57-1.04H3a2 2 0 0 1 0-4h.09A1.7 1.7 0 0 0 4.66 9a1.7 1.7 0 0 0-.34-1.86l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.86.34H9a1.7 1.7 0 0 0 1.04-1.57V3a2 2 0 0 1 4 0v.09c0 .68.42 1.29 1.04 1.57.62.27 1.36.13 1.86-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.86V9a1.7 1.7 0 0 0 1.57 1.04H21a2 2 0 0 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1z"/></svg>
</button> </button>
@@ -1070,6 +1074,9 @@
.off-result { font-size:13px; padding:3px 0; overflow-wrap:anywhere; } .off-result { font-size:13px; padding:3px 0; overflow-wrap:anywhere; }
.off-result.ok { color:var(--success); } .off-result.ok { color:var(--success); }
.off-result.fail { color:var(--error); } .off-result.fail { color:var(--error); }
.off-result-dev { border:1px solid var(--hairline-soft, var(--hairline)); border-radius:8px; padding:8px 12px; margin-bottom:8px; }
.off-result-devname { font-weight:600; font-size:13px; margin-bottom:4px; display:flex; align-items:center; gap:8px; }
.off-result-badge { font-size:10.5px; font-weight:700; padding:1px 8px; border-radius:999px; background:rgba(245,80,80,0.18); color:var(--error); }
#offResults .lbl { display:block; font-size:12px; color:var(--body); margin:10px 0 4px; } #offResults .lbl { display:block; font-size:12px; color:var(--body); margin:10px 0 4px; }
</style> </style>
+18
View File
@@ -335,6 +335,24 @@ h1, h2, h3 {
.header-actions { display: flex; align-items: center; gap: 12px; } .header-actions { display: flex; align-items: center; gap: 12px; }
/* Update-Hinweis-Badge (neue Version verfuegbar) */
.update-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--brand-accent);
border: 1px solid var(--brand-accent);
font-size: 12px;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
transition: filter var(--tx, 0.15s ease-out);
}
.update-badge:hover { filter: brightness(0.96); }
/* Theme-Toggle — Slide-Switch */ /* Theme-Toggle — Slide-Switch */
.theme-toggle { .theme-toggle {
appearance: none; appearance: none;