Policies-Tab: Export/Import (Settings Catalog, Compliance, Config)
Build & Release MSI / build-msi (push) Canceled after 0s
Build & Release MSI / build-msi (push) Canceled after 0s
Neuer Tab "Policies" zum Sichern und Wiederherstellen von Intune-Policies. - Backend src/PolicyIO.ps1: typ-getrieben fuer configurationPolicies (Settings Catalog), deviceCompliancePolicies und deviceConfigurations - Export als JSON-Download (einzeln/Bundle) + Server-Archiv unter %APPDATA% - Import per Datei-Upload, immer Neuanlage (nie ueberschreiben), robust fuer Hashtable (PS7) und PSCustomObject (PS5.1); im Read-Only-Modus ausgeblendet - Routen in src/Api.ps1; Config-Scope DeviceManagementConfiguration.* auch nach Settings-Save sichergestellt - Frontend: Tab + View, Suche/Typ-Filter/Sortierung, Auswahl, Download/Upload - Version 0.1.25 (Start.ps1, CHANGELOG, README) - build-local.ps1: x64-.NET-Runtime fuer wix auf ARM64-Windows + echter Desktop-Pfad Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+243
-1
@@ -4391,6 +4391,7 @@ function switchMainView(view) {
|
||||
const groupView = document.getElementById('groupMgmtView');
|
||||
const reportView = document.getElementById('appReportView');
|
||||
const devView = document.getElementById('devView');
|
||||
const polView = document.getElementById('policiesView');
|
||||
document.querySelectorAll('#topbarNav .nav-tab').forEach(t => {
|
||||
t.classList.toggle('active', t.dataset.view === view);
|
||||
t.setAttribute('aria-selected', t.dataset.view === view ? 'true' : 'false');
|
||||
@@ -4399,12 +4400,13 @@ function switchMainView(view) {
|
||||
groupView .classList.toggle('hidden', view !== 'groups');
|
||||
reportView.classList.toggle('hidden', view !== 'report');
|
||||
devView .classList.toggle('hidden', view !== 'devices');
|
||||
polView .classList.toggle('hidden', view !== 'policies');
|
||||
}
|
||||
|
||||
document.querySelectorAll('#topbarNav .nav-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const view = btn.dataset.view;
|
||||
if ((view === 'groups' || view === 'report' || view === 'devices') && !State.connected) {
|
||||
if ((view === 'groups' || view === 'report' || view === 'devices' || view === 'policies') && !State.connected) {
|
||||
toast('Bitte zuerst verbinden.', 'warn');
|
||||
return;
|
||||
}
|
||||
@@ -4412,6 +4414,7 @@ document.querySelectorAll('#topbarNav .nav-tab').forEach(btn => {
|
||||
if (view === 'groups') openGroupExportModal();
|
||||
if (view === 'report' && ReportState.items.length === 0) loadAppReport();
|
||||
if (view === 'devices') { devLoadAll(); setTimeout(() => document.getElementById('devSearch').focus(), 80); }
|
||||
if (view === 'policies' && PolState.items.length === 0) loadPolicies();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5509,3 +5512,242 @@ document.querySelectorAll('#devTable th.sortable').forEach(th => {
|
||||
devSort();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// Policies (Export / Import)
|
||||
// =============================================================
|
||||
const PolState = {
|
||||
items: [], // alle geladenen Policies (normalisiert)
|
||||
selected: new Set(), // Keys "type::id"
|
||||
sortCol: 'name',
|
||||
sortAsc: true,
|
||||
search: '',
|
||||
typeFilter: '',
|
||||
};
|
||||
|
||||
function polKey(p) { return `${p.type}::${p.id}`; }
|
||||
|
||||
function polFmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return '—';
|
||||
return d.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
}
|
||||
|
||||
async function loadPolicies() {
|
||||
const body = document.getElementById('polBody');
|
||||
body.innerHTML = `<tr><td colspan="5" class="pol-hint"><span class="spinner" style="width:14px;height:14px;border-width:2px;"></span> Lade Policies…</td></tr>`;
|
||||
// Drei Typen parallel laden; ein fehlschlagender Typ (z.B. fehlender Scope)
|
||||
// soll die anderen nicht blockieren.
|
||||
const endpoints = [
|
||||
['/api/policies/settingscatalog', 'Settings Catalog'],
|
||||
['/api/policies/compliance', 'Compliance'],
|
||||
['/api/policies/configuration', 'Konfigurationsprofile'],
|
||||
];
|
||||
const settled = await Promise.allSettled(endpoints.map(([url]) => api(url)));
|
||||
let items = [];
|
||||
const errors = [];
|
||||
settled.forEach((r, i) => {
|
||||
if (r.status === 'fulfilled') {
|
||||
items = items.concat(r.value.items || []);
|
||||
} else {
|
||||
errors.push(`${endpoints[i][1]}: ${r.reason?.message || 'Fehler'}`);
|
||||
}
|
||||
});
|
||||
PolState.items = items;
|
||||
// Auswahl auf noch existierende Policies eindampfen
|
||||
const valid = new Set(items.map(polKey));
|
||||
PolState.selected = new Set([...PolState.selected].filter(k => valid.has(k)));
|
||||
renderPolicies();
|
||||
if (errors.length) toast('Einige Typen konnten nicht geladen werden. ' + errors.join(' · '), 'warn');
|
||||
}
|
||||
|
||||
function polFiltered() {
|
||||
const q = PolState.search.toLowerCase();
|
||||
const tf = PolState.typeFilter;
|
||||
let list = PolState.items.filter(p => {
|
||||
if (tf && p.type !== tf) return false;
|
||||
if (!q) return true;
|
||||
return (p.name || '').toLowerCase().includes(q)
|
||||
|| (p.typeLabel || '').toLowerCase().includes(q)
|
||||
|| (p.platform || '').toLowerCase().includes(q);
|
||||
});
|
||||
const col = PolState.sortCol, asc = PolState.sortAsc ? 1 : -1;
|
||||
list.sort((a, b) => String(a[col] ?? '').localeCompare(String(b[col] ?? ''), 'de', { numeric: true }) * asc);
|
||||
return list;
|
||||
}
|
||||
|
||||
function renderPolicies() {
|
||||
const body = document.getElementById('polBody');
|
||||
const list = polFiltered();
|
||||
document.getElementById('polCount').textContent = list.length;
|
||||
if (!list.length) {
|
||||
body.innerHTML = `<tr><td colspan="5" class="pol-empty">${PolState.items.length ? 'Keine Policy passt zum Filter.' : 'Keine Policies gefunden.'}</td></tr>`;
|
||||
polUpdateSelCount();
|
||||
return;
|
||||
}
|
||||
body.innerHTML = list.map(p => {
|
||||
const key = polKey(p);
|
||||
const checked = PolState.selected.has(key) ? 'checked' : '';
|
||||
return `<tr data-key="${escapeHtml(key)}">
|
||||
<td class="pol-col-check"><input type="checkbox" class="pol-row-check" data-key="${escapeHtml(key)}" ${checked}></td>
|
||||
<td class="pol-name" title="${escapeHtml(p.name || '')}">${escapeHtml(p.name || '—')}</td>
|
||||
<td><span class="pol-type-badge">${escapeHtml(p.typeLabel || p.type)}</span></td>
|
||||
<td>${escapeHtml(p.platform || '—')}</td>
|
||||
<td>${polFmtDate(p.lastModifiedDateTime)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
body.querySelectorAll('.pol-row-check').forEach(cb => {
|
||||
cb.addEventListener('change', () => {
|
||||
if (cb.checked) PolState.selected.add(cb.dataset.key);
|
||||
else PolState.selected.delete(cb.dataset.key);
|
||||
polUpdateSelCount();
|
||||
polSyncCheckAll();
|
||||
});
|
||||
});
|
||||
// Klick auf Zeile toggelt die Checkbox (außer direkt auf die Checkbox)
|
||||
body.querySelectorAll('tr[data-key]').forEach(tr => {
|
||||
tr.addEventListener('click', e => {
|
||||
if (e.target.classList.contains('pol-row-check')) return;
|
||||
const cb = tr.querySelector('.pol-row-check');
|
||||
cb.checked = !cb.checked;
|
||||
cb.dispatchEvent(new Event('change'));
|
||||
});
|
||||
});
|
||||
polUpdateSelCount();
|
||||
polSyncCheckAll();
|
||||
}
|
||||
|
||||
function polUpdateSelCount() {
|
||||
const n = PolState.selected.size;
|
||||
document.getElementById('polSelCount').textContent = n;
|
||||
document.getElementById('btnPolExport').disabled = n === 0;
|
||||
}
|
||||
|
||||
function polSyncCheckAll() {
|
||||
const visible = polFiltered().map(polKey);
|
||||
const all = visible.length > 0 && visible.every(k => PolState.selected.has(k));
|
||||
const some = visible.some(k => PolState.selected.has(k));
|
||||
const cb = document.getElementById('polCheckAll');
|
||||
cb.checked = all;
|
||||
cb.indeterminate = !all && some;
|
||||
}
|
||||
|
||||
function polDownloadJson(obj, filename) {
|
||||
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function polExportSelected() {
|
||||
const sel = [...PolState.selected];
|
||||
if (!sel.length) return;
|
||||
const byType = {};
|
||||
for (const key of sel) {
|
||||
const idx = key.indexOf('::');
|
||||
const type = key.slice(0, idx), id = key.slice(idx + 2);
|
||||
(byType[type] ||= []).push(id);
|
||||
}
|
||||
setLoading('Exportiere Policies…');
|
||||
try {
|
||||
let allExports = [];
|
||||
for (const [type, ids] of Object.entries(byType)) {
|
||||
const res = await api('/api/policies/export', { method: 'POST', body: { type, ids }, timeoutMs: 120000 });
|
||||
if (res.exports) allExports = allExports.concat(res.exports);
|
||||
}
|
||||
if (!allExports.length) { toast('Nichts exportiert.', 'warn'); return; }
|
||||
if (allExports.length === 1) {
|
||||
polDownloadJson(allExports[0].data, allExports[0].fileName || 'policy.json');
|
||||
} else {
|
||||
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '').replace(/-/g, '');
|
||||
const bundle = {
|
||||
type: 'IntuneManagerPolicyBundle',
|
||||
exportDate: new Date().toISOString(),
|
||||
count: allExports.length,
|
||||
policies: allExports.map(e => e.data),
|
||||
};
|
||||
polDownloadJson(bundle, `intune-policies_${stamp}.json`);
|
||||
}
|
||||
toast(`${allExports.length} Policy(s) exportiert (auch im Server-Archiv abgelegt).`, 'ok');
|
||||
} catch (e) {
|
||||
toast('Export fehlgeschlagen: ' + e.message, 'err');
|
||||
} finally {
|
||||
clearLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// Datei-Inhalt -> Liste von Envelopes { exportType, policy, ... }
|
||||
function polExtractEnvelopes(parsed, fileName) {
|
||||
if (parsed && Array.isArray(parsed.policies)) return parsed.policies;
|
||||
if (parsed && parsed.exportType && parsed.policy) return [parsed];
|
||||
if (parsed && parsed.policy) return [parsed]; // nachsichtig
|
||||
toast(`${fileName}: unbekanntes Policy-Format.`, 'err');
|
||||
return [];
|
||||
}
|
||||
|
||||
async function polHandleImportFiles(fileList) {
|
||||
const files = [...fileList];
|
||||
if (!files.length) return;
|
||||
let envelopes = [];
|
||||
for (const f of files) {
|
||||
let parsed = null;
|
||||
try { parsed = JSON.parse(await f.text()); }
|
||||
catch { toast(`${f.name}: kein gültiges JSON.`, 'err'); continue; }
|
||||
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;
|
||||
setLoading('Importiere Policies…');
|
||||
try {
|
||||
const res = await api('/api/policies/import', { method: 'POST', body: { policies: envelopes }, timeoutMs: 180000 });
|
||||
const results = res.results || [];
|
||||
const ok = results.filter(r => r.success);
|
||||
const fail = results.filter(r => !r.success);
|
||||
if (fail.length) {
|
||||
const first = fail[0];
|
||||
toast(`${ok.length} importiert, ${fail.length} fehlgeschlagen. z.B. "${first.policyName || '?'}": ${first.error || 'Fehler'}`, 'err', 'Import');
|
||||
} else {
|
||||
toast(`${ok.length} Policy(s) importiert.`, 'ok', 'Import');
|
||||
}
|
||||
await loadPolicies();
|
||||
} catch (e) {
|
||||
toast('Import fehlgeschlagen: ' + e.message, 'err');
|
||||
} finally {
|
||||
clearLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Policies: Event-Wiring ---
|
||||
document.getElementById('btnPolRefresh')?.addEventListener('click', loadPolicies);
|
||||
document.getElementById('btnPolExport')?.addEventListener('click', polExportSelected);
|
||||
document.getElementById('btnPolImport')?.addEventListener('click', () => document.getElementById('polImportFile').click());
|
||||
document.getElementById('polImportFile')?.addEventListener('change', e => {
|
||||
polHandleImportFiles(e.target.files);
|
||||
e.target.value = ''; // erlaubt erneuten Import derselben Datei
|
||||
});
|
||||
document.getElementById('polCheckAll')?.addEventListener('change', e => {
|
||||
const visible = polFiltered().map(polKey);
|
||||
if (e.target.checked) visible.forEach(k => PolState.selected.add(k));
|
||||
else visible.forEach(k => PolState.selected.delete(k));
|
||||
renderPolicies();
|
||||
});
|
||||
let _polSearchTimer = null;
|
||||
document.getElementById('polSearch')?.addEventListener('input', e => {
|
||||
clearTimeout(_polSearchTimer);
|
||||
_polSearchTimer = setTimeout(() => { PolState.search = e.target.value.trim(); renderPolicies(); }, 200);
|
||||
});
|
||||
document.getElementById('polTypeFilter')?.addEventListener('change', e => {
|
||||
PolState.typeFilter = e.target.value;
|
||||
renderPolicies();
|
||||
});
|
||||
document.querySelectorAll('#polTable th.sortable').forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
if (PolState.sortCol === th.dataset.col) PolState.sortAsc = !PolState.sortAsc;
|
||||
else { PolState.sortCol = th.dataset.col; PolState.sortAsc = true; }
|
||||
renderPolicies();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<button class="nav-tab" data-view="groups" aria-selected="false">Group Management</button>
|
||||
<button class="nav-tab" data-view="report" aria-selected="false">App Report</button>
|
||||
<button class="nav-tab" data-view="devices" aria-selected="false">Geräte</button>
|
||||
<button class="nav-tab" data-view="policies" aria-selected="false">Policies</button>
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
@@ -558,6 +559,53 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- POLICIES VIEW -->
|
||||
<section class="pol-view hidden" id="policiesView">
|
||||
<div class="pol-toolbar">
|
||||
<div class="search-wrap">
|
||||
<svg class="search-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input type="search" id="polSearch" class="input input-search" placeholder="Policy suchen…" autocomplete="off">
|
||||
</div>
|
||||
<span id="polCount" class="count-badge">0</span>
|
||||
<select id="polTypeFilter" class="input" style="height:34px;max-width:220px;">
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="settingscatalog">Settings Catalog</option>
|
||||
<option value="compliance">Compliance</option>
|
||||
<option value="configuration">Konfigurationsprofil</option>
|
||||
</select>
|
||||
<span class="pol-toolbar-spacer" style="flex:1;"></span>
|
||||
<button class="btn btn-secondary btn-sm" id="btnPolRefresh" title="Policies neu laden">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
Neu laden
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm pol-write" id="btnPolImport" title="Policies aus JSON-Datei(en) importieren (Neuanlage)">
|
||||
<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 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
Import…
|
||||
</button>
|
||||
<input type="file" id="polImportFile" accept=".json,application/json" multiple style="display:none;">
|
||||
<button class="btn btn-primary btn-sm" id="btnPolExport" disabled title="Ausgewählte Policies als JSON exportieren">
|
||||
<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 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Export (<span id="polSelCount">0</span>)
|
||||
</button>
|
||||
</div>
|
||||
<div class="pol-table-wrap">
|
||||
<table class="pol-table" id="polTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="pol-col-check"><input type="checkbox" id="polCheckAll" title="Alle auswählen"></th>
|
||||
<th class="sortable" data-col="name">Name <span class="sort-ico"></span></th>
|
||||
<th class="sortable" data-col="typeLabel">Typ <span class="sort-ico"></span></th>
|
||||
<th class="sortable" data-col="platform">Plattform <span class="sort-ico"></span></th>
|
||||
<th class="sortable" data-col="lastModifiedDateTime">Geändert <span class="sort-ico"></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="polBody">
|
||||
<tr><td colspan="5" class="pol-hint">Klicke auf „Neu laden" um Policies zu laden.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- MODAL: Wipe -->
|
||||
<div class="modal-backdrop hidden" id="wipeModalBackdrop">
|
||||
<div class="modal" style="max-width:420px;">
|
||||
|
||||
+68
-1
@@ -4872,7 +4872,8 @@ body.read-only #btnApply,
|
||||
body.read-only .gex-add-pane,
|
||||
body.read-only .gex-tab[data-tab="add"],
|
||||
body.read-only .gex-tab[data-tab="import"],
|
||||
body.read-only #gexPaneImport {
|
||||
body.read-only #gexPaneImport,
|
||||
body.read-only .pol-write {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -5340,3 +5341,69 @@ body.read-only .app-row {
|
||||
.dev-actions-title { font-size: 11px; font-weight: 600; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.dev-action-btns { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.dev-action-btn { display: inline-flex; align-items: center; gap: 5px; }
|
||||
|
||||
/* =============================================================
|
||||
Policies View (Export / Import)
|
||||
============================================================= */
|
||||
.pol-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
height: calc(100vh - var(--topbar-h, 52px) - 32px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.pol-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pol-toolbar .search-wrap { flex: 0 1 320px; }
|
||||
.pol-table-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.pol-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.pol-table thead { position: sticky; top: 0; z-index: 2; background: var(--bg2); }
|
||||
.pol-table th {
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim, #888);
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
.pol-table th.sortable { cursor: pointer; }
|
||||
.pol-table th.sortable:hover { color: var(--text); background: var(--bg3, var(--hover)); }
|
||||
.pol-table td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.pol-table tbody tr:last-child td { border-bottom: none; }
|
||||
.pol-table tbody tr:hover { background: var(--hover, rgba(255,255,255,0.03)); }
|
||||
.pol-col-check { width: 34px; text-align: center; }
|
||||
.pol-table td.pol-col-check { text-align: center; }
|
||||
.pol-table td.pol-name { font-weight: 500; max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pol-type-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: var(--r-pill, 999px);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: var(--bg3, var(--hover));
|
||||
color: var(--text-dim, #999);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pol-hint, .pol-empty {
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
color: var(--text-dim, #888);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user