Blaupausen je Kunde, Datei-Uploads, RDP-Verknuepfungen, Bloatware-Entfernung

- Blaupausen (blueprints\) mit Kunde/Beschreibung, gruppierte Ansicht,
  Duplizieren/Export, Schnellauswahl auf der Uebersicht, 2 Beispiele
- Upload von Setups (EXE/MSI/MSIX/PS1/CMD) per Drag & Drop inkl.
  gespeicherter Silent-Parameter; Upload von Icons und .rdp-Dateien
- RDP-Verknuepfungen fuer alle Benutzer: Generator (Gateway, RemoteApp,
  Umleitungen) oder hochgeladene .rdp-Dateien
- Bloatware-Tab: Store-Apps, Hersteller-Tools, Testversionen,
  vorinstalliertes Office/OneNote (C2R) und OneDrive entfernen;
  Katalog in config/bloatware.json, auch als Schritt in Blaupausen
- Fix: Top-Level-JSON-Arrays (Vorlagen) wurden falsch serialisiert

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-09 16:00:25 +02:00
co-authored by Claude Opus 5.5
parent f4c2a16612
commit ebfec7000e
11 changed files with 940 additions and 89 deletions
+264 -36
View File
@@ -30,7 +30,7 @@ async function guard(fn) { try { return await fn(); } catch (e) { toast(e.messag
// ---------------------------------------------------------------- Zustand / Profil
const DEFAULTS = () => ({
name: '',
id: '', name: '', customer: '', description: '',
apps: [],
office: {
product: 'O365ProPlusRetail', channel: 'Current', arch: '64', languages: ['de-de'],
@@ -42,6 +42,7 @@ const DEFAULTS = () => ({
system: { computerName: '', timeZone: '', showExtensions: true, disableConsumer: true },
security: { defender: true, firewall: true, smartScreen: true, uac: false, disableSmb1: true, disableAutorun: true, lsaProtection: false, telemetryMin: false, rdp: 'keep', localAdminName: '', localAdminPassword: '' },
power: { plan: 'balanced', monitorAc: 15, monitorDc: 5, sleepAc: 0, sleepDc: 15, hibernateAc: 0, hibernateDc: 60, lidAc: 'nothing', lidDc: 'sleep', buttonAc: 'shutdown', buttonDc: 'shutdown', hibernate: '', fastStartup: 'off' },
bloatware: { recommended: true, ids: [], keep: [] },
bitlocker: { method: 'XtsAes256', usedSpaceOnly: true, skipHardwareTest: true, usePin: false, pin: '', backupAad: false, backupAd: false },
steps: ['system', 'security', 'power', 'apps', 'office', 'shortcuts', 'updates']
});
@@ -83,12 +84,13 @@ function fillForms() {
$$('input', box).forEach(i => i.checked = v.includes(i.value));
});
$$('#steps input').forEach(i => i.checked = arr(S.steps).includes(i.value));
$('#profileName').textContent = 'Profil: ' + (S.name || '(ungespeichert)');
$('#profileName').textContent = 'Blaupause: ' + ([S.customer, S.name].filter(Boolean).join(' / ') || '(ungespeichert)');
$$('.pinonly').forEach(e => e.classList.toggle('hidden', !S.bitlocker.usePin));
}
function onChange(path) {
if (path.startsWith('office')) { if (path === 'office.product') { renderOfficeChannels(); renderOfficeExtras(); } updateOfficeXml(); }
if (path === 'bitlocker.usePin') fillForms();
if (path === 'bloatware.recommended') renderBloat();
}
function loadState(obj) { S = deepMerge(DEFAULTS(), obj); persist(); fillForms(); renderAll(); }
@@ -96,7 +98,7 @@ function loadState(obj) { S = deepMerge(DEFAULTS(), obj); persist(); fillForms()
function showTab(t) {
$$('#nav a').forEach(a => a.classList.toggle('active', a.dataset.tab === t));
$$('.tab').forEach(s => s.classList.toggle('active', s.id === 'tab-' + t));
({ shortcuts: loadShortcuts, bitlocker: loadBitlocker, profiles: loadProfiles, jobs: pollJobs, power: loadPower, office: loadOfficeBuilds })[t]?.();
({ shortcuts: loadShortcuts, bitlocker: loadBitlocker, bloatware: () => { if (!bloat) scanBloat(); }, profiles: loadProfiles, jobs: pollJobs, power: loadPower, office: loadOfficeBuilds })[t]?.();
}
$('#nav').onclick = e => { const a = e.target.closest('a'); if (a) showTab(a.dataset.tab); };
@@ -114,7 +116,7 @@ function renderSummary() {
const o = S.office, prod = PRODUCTS.find(p => p.id === o.product);
$('#summary').innerHTML = [
`<b>${arr(S.apps).length}</b> Programme`, `Office: <b>${esc(prod?.name || o.product)}</b> (${o.arch}-Bit, ${esc(S.officeMode)})`,
`<b>${arr(S.shortcuts).length}</b> Desktop-Icons`, `Energieplan: <b>${esc(S.power.plan || 'unverändert')}</b>`,
`<b>${arr(S.shortcuts).length}</b> Desktop-Icons`, `Bloatware: <b>${S.bloatware.recommended ? 'Empfohlene' : ''}${S.bloatware.recommended && arr(S.bloatware.ids).length ? ' + ' : ''}${arr(S.bloatware.ids).length ? arr(S.bloatware.ids).length + ' gewählt' : (S.bloatware.recommended ? '' : 'keine')}</b>`, `Energieplan: <b>${esc(S.power.plan || 'unverändert')}</b>`,
S.system.computerName ? `Neuer Name: <b>${esc(S.system.computerName)}</b>` : ''
].filter(Boolean).join(' · ');
}
@@ -135,9 +137,76 @@ async function loadCatalog() {
$('#catalog').innerHTML = Object.entries(groups).map(([c, list]) => `<div class="cat"><h3>${esc(c)}</h3><div class="checks">${list.map(a =>
`<label><input type="checkbox" data-app="${esc(a.id)}"> ${esc(a.name)}</label>`).join('')}</div></div>`).join('');
syncCatalogChecks();
const local = arr(await api('GET', '/api/local-installers'));
$('#localFile').innerHTML = local.length ? local.map(f => `<option>${esc(f)}</option>`).join('') : '<option value="">(keine Dateien in installers\\)</option>';
await loadLocalInstallers();
}
// ---- Uploads (Installer, Icons, RDP) mit Fortschritt
function uploadFile(kind, file) {
return new Promise((resolve, reject) => {
const x = new XMLHttpRequest();
x.open('POST', `/api/upload?kind=${kind}&name=${encodeURIComponent(file.name)}`);
x.setRequestHeader('X-Token', TOKEN);
x.upload.onprogress = e => { if (e.lengthComputable) $('#uploadProgress').textContent = `${file.name}: ${Math.round(e.loaded / e.total * 100)} %`; };
x.onload = () => {
$('#uploadProgress').textContent = '';
let d = null; try { d = JSON.parse(x.responseText); } catch { }
x.status === 200 ? resolve(d) : reject(new Error(d?.error || `Upload fehlgeschlagen (${x.status})`));
};
x.onerror = () => reject(new Error('Upload fehlgeschlagen'));
x.send(file);
});
}
async function uploadMany(kind, files) {
const done = [];
for (const f of files) { try { done.push(await uploadFile(kind, f)); toast(`${f.name} hochgeladen`); } catch (e) { toast(`${f.name}: ${e.message}`, true); } }
return done;
}
let localInstallers = [];
async function loadLocalInstallers() {
localInstallers = arr(await api('GET', '/api/local-installers'));
$('#localList').innerHTML = localInstallers.length ? localInstallers.map((f, i) => `<div class="item">
<input type="checkbox" data-li="${i}" ${S.apps.some(a => a.id === 'local:' + f.file) ? 'checked' : ''} title="In Auswahl übernehmen">
<div class="grow"><div>${esc(f.name)}</div><div class="sub">${esc(f.file)} · ${f.sizeMB} MB</div></div>
<input class="args" data-la="${i}" value="${esc(f.args)}" placeholder="${/\.msi$/i.test(f.file) ? 'zusätzl. MSI-Eigenschaften' : 'Silent-Parameter'}">
<button class="small danger" data-ldel="${i}" title="Vom Stick löschen">✕</button></div>`).join('')
: '<p class="muted small">Noch keine eigenen Installer hochgeladen.</p>';
}
const localApp = f => ({ name: f.name, id: 'local:' + f.file, source: 'local', path: f.file, args: f.args });
$('#localList').onchange = e => guard(async () => {
const { li, la } = e.target.dataset;
if (li != null) { const f = localInstallers[li]; e.target.checked ? addApp(localApp(f)) : removeApp('local:' + f.file); }
if (la != null) {
const f = localInstallers[la]; f.args = e.target.value;
await api('POST', '/api/local-installers', { file: f.file, name: f.name, args: f.args });
const sel = S.apps.find(a => a.id === 'local:' + f.file); if (sel) { sel.args = f.args; persist(); }
toast('Parameter gespeichert');
}
});
$('#localList').onclick = e => {
const i = e.target.dataset.ldel; if (i == null) return;
const f = localInstallers[i];
if (!confirm(`"${f.file}" vom Stick löschen?`)) return;
guard(async () => { await api('DELETE', `/api/files?kind=installers&name=${encodeURIComponent(f.file.split('\\').pop())}`); removeApp('local:' + f.file); loadLocalInstallers(); });
};
let lastArgsInput = null;
$('#localList').addEventListener('focusin', e => { if (e.target.dataset.la != null) lastArgsInput = e.target; });
$$('.hint').forEach(h => h.onclick = () => {
if (!lastArgsInput) return toast('Erst in ein Parameter-Feld klicken', true);
lastArgsInput.value = h.dataset.hint; lastArgsInput.dispatchEvent(new Event('change', { bubbles: true }));
});
async function handleInstallerFiles(files) {
const up = await uploadMany('installers', files);
await loadLocalInstallers();
// frisch hochgeladene direkt auswählen
up.forEach(u => { const f = localInstallers.find(x => x.file === u.path); if (f) addApp(localApp(f)); });
loadLocalInstallers();
}
$('#instFile').onchange = e => { handleInstallerFiles([...e.target.files]); e.target.value = ''; };
const drop = $('#instDrop');
drop.ondragover = e => { e.preventDefault(); drop.classList.add('over'); };
drop.ondragleave = () => drop.classList.remove('over');
drop.ondrop = e => { e.preventDefault(); drop.classList.remove('over'); handleInstallerFiles([...e.dataTransfer.files]); };
$('#catalog').onchange = e => {
const id = e.target.dataset.app; if (!id) return;
const a = catalog.find(x => x.id === id);
@@ -153,6 +222,7 @@ function renderSelectedApps() {
<div class="sub">${esc(a.source)} · ${esc(a.source === 'local' ? a.path : a.id)}</div></div>
<button class="small ghost" data-rm="${esc(a.id)}">✕</button></div>`).join('') : '<p class="muted">Nichts ausgewählt.</p>';
syncCatalogChecks();
$$('#localList [data-li]').forEach(i => i.checked = S.apps.some(a => a.id === 'local:' + localInstallers[i.dataset.li]?.file));
}
$('#selectedApps').onclick = e => { const id = e.target.dataset.rm; if (id) removeApp(id); };
async function doSearch() {
@@ -167,10 +237,6 @@ async function doSearch() {
}
$('#btnSearch').onclick = doSearch;
$('#appSearch').onkeydown = e => { if (e.key === 'Enter') doSearch(); };
$('#btnAddLocal').onclick = () => {
const f = $('#localFile').value; if (!f) return;
addApp({ name: f.split('\\').pop(), id: 'local:' + f, source: 'local', path: f, args: $('#localArgs').value });
};
$('#btnInstallApps').onclick = () => guard(async () => {
if (!S.apps.length) return toast('Keine Programme ausgewählt', true);
jobStarted((await api('POST', '/api/apps/install', { apps: S.apps })).job);
@@ -229,24 +295,89 @@ async function loadOfficeBuilds() {
// ---------------------------------------------------------------- Desktop-Icons
let presets = [];
function scFromForm() {
return { type: $('#scType').value, name: $('#scName').value.trim(), target: $('#scTarget').value.trim(), args: $('#scArgs').value, icon: $('#scIcon').value.trim() };
const RDP_FIELDS = { host: 'rdpHost', port: 'rdpPort', user: 'rdpUser', gateway: 'rdpGateway', remoteApp: 'rdpRemoteApp', audio: 'rdpAudio', extra: 'rdpExtra' };
const RDP_CHECKS = { fullscreen: 'rdpFullscreen', multimon: 'rdpMultimon', clipboard: 'rdpClipboard', printers: 'rdpPrinters', drives: 'rdpDrives', smartcards: 'rdpSmartcards', microphone: 'rdpMicrophone', camera: 'rdpCamera', admin: 'rdpAdmin' };
function rdpFromForm() {
const r = {};
for (const [k, id] of Object.entries(RDP_FIELDS)) r[k] = $('#' + id).value.trim();
for (const [k, id] of Object.entries(RDP_CHECKS)) r[k] = $('#' + id).checked;
return r;
}
function scFromForm() {
const type = $('#scType').value;
const s = { type, name: $('#scName').value.trim() };
if (type === 'rdp') {
const file = $('#rdpSource').value;
if (file) { s.rdpFile = file; s.target = 'rdp\\' + file; }
else { s.rdp = rdpFromForm(); s.target = s.rdp.host + (s.rdp.remoteApp ? ` (RemoteApp ${s.rdp.remoteApp})` : ''); if (!s.rdp.host) s.target = ''; }
} else {
Object.assign(s, { target: $('#scTarget').value.trim(), args: type === 'lnk' ? $('#scArgs').value : '', icon: $('#scIcon').value.trim() });
}
return s;
}
function scValid(s) { if (!s.name || !s.target) { toast(s.type === 'rdp' ? 'Name und Server angeben' : 'Name und Ziel angeben', true); return false; } return true; }
function scTypeUi() {
const t = $('#scType').value;
$$('.t-url, .t-lnk, .t-rdp').forEach(e => e.classList.toggle('hidden', !e.classList.contains('t-' + t)));
$('#rdpForm').classList.toggle('hidden', !!$('#rdpSource').value);
if (t === 'rdp') updateRdpPreview();
}
$('#scType').onchange = scTypeUi;
$('#rdpSource').onchange = scTypeUi;
let rdpTimer;
function updateRdpPreview() {
clearTimeout(rdpTimer);
rdpTimer = setTimeout(async () => {
const r = rdpFromForm();
$('#rdpPreview').textContent = r.host ? await api('POST', '/api/rdp/preview', r, true) : '(Server angeben)';
}, 300);
}
$('#rdpForm').addEventListener('input', updateRdpPreview);
$('#rdpForm').addEventListener('change', updateRdpPreview);
$('#rdpUpload').onchange = async e => {
const up = await uploadMany('rdp', [...e.target.files]); e.target.value = '';
await loadRdpFiles();
if (up.length) { $('#rdpSource').value = up[0].name; if (!$('#scName').value) $('#scName').value = up[0].name.replace(/\.rdp$/i, ''); scTypeUi(); }
};
$('#iconUpload').onchange = async e => {
const up = await uploadMany('icons', [...e.target.files]); e.target.value = '';
if (up.length) $('#scIcon').value = up[0].name;
loadShortcuts();
};
async function loadRdpFiles() {
const files = arr(await api('GET', '/api/rdpfiles'));
const cur = $('#rdpSource').value;
$('#rdpSource').innerHTML = '<option value="">Neu konfigurieren</option>' + files.map(f => `<option value="${esc(f)}">Datei vom Stick: ${esc(f)}</option>`).join('');
$('#rdpSource').value = files.includes(cur) ? cur : '';
}
$('#scType').onchange = () => $$('.lnkonly').forEach(e => e.classList.toggle('hidden', $('#scType').value === 'url'));
$('#btnScCreate').onclick = () => guard(async () => {
const s = scFromForm(); if (!s.name || !s.target) return toast('Name und Ziel angeben', true);
const s = scFromForm(); if (!scValid(s)) return;
await api('POST', '/api/shortcuts', s); toast('Verknüpfung angelegt'); loadShortcuts();
});
$('#btnScPlan').onclick = () => {
const s = scFromForm(); if (!s.name || !s.target) return toast('Name und Ziel angeben', true);
const s = scFromForm(); if (!scValid(s)) return;
S.shortcuts = arr(S.shortcuts).filter(x => x.name !== s.name).concat(s); persist(); renderPlanned();
};
const SC_TYPES = { url: '🌐', lnk: '🔗', rdp: '🖥️' };
function renderPlanned() {
const l = arr(S.shortcuts);
$('#scPlanned').innerHTML = l.length ? l.map((s, i) => `<div class="item"><div class="grow"><div>${esc(s.name)}</div><div class="sub">${esc(s.target)}</div></div>
<button class="small ghost" data-i="${i}">✕</button></div>`).join('') : '<p class="muted">Keine geplant.</p>';
$('#scPlanned').innerHTML = l.length ? l.map((s, i) => `<div class="item"><span>${SC_TYPES[s.type] || ''}</span><div class="grow"><div>${esc(s.name)}</div><div class="sub">${esc(s.target)}</div></div>
<button class="small ghost" data-edit="${i}" title="Ins Formular laden">✎</button><button class="small ghost" data-i="${i}">✕</button></div>`).join('') : '<p class="muted">Keine geplant.</p>';
}
$('#scPlanned').onclick = e => { const i = e.target.dataset.i; if (i != null) { S.shortcuts.splice(i, 1); persist(); renderPlanned(); } };
$('#scPlanned').onclick = e => {
const { i, edit } = e.target.dataset;
if (i != null) { S.shortcuts.splice(i, 1); persist(); renderPlanned(); }
if (edit != null) {
const s = S.shortcuts[edit];
$('#scType').value = s.type || 'lnk'; $('#scName').value = s.name;
$('#scTarget').value = s.type === 'rdp' ? '' : s.target || ''; $('#scArgs').value = s.args || ''; $('#scIcon').value = s.icon || '';
$('#rdpSource').value = s.rdpFile || '';
const r = s.rdp || {};
for (const [k, id] of Object.entries(RDP_FIELDS)) $('#' + id).value = r[k] ?? (k === 'audio' ? 'local' : '');
for (const [k, id] of Object.entries(RDP_CHECKS)) if (k in r) $('#' + id).checked = !!r[k];
scTypeUi();
}
};
$('#btnScApplyAll').onclick = () => guard(async () => {
for (const s of arr(S.shortcuts)) { try { await api('POST', '/api/shortcuts', s); } catch (e) { toast(`${s.name}: ${e.message}`, true); } }
toast('Verknüpfungen angelegt'); loadShortcuts();
@@ -256,6 +387,7 @@ async function loadShortcuts() {
$('#scCurrent').innerHTML = arr(cur).length ? arr(cur).map(s => `<div class="item"><div class="grow"><div>${esc(s.name)}</div><div class="sub">${esc(s.target)}</div></div>
<button class="small danger" data-del="${esc(s.file)}">Löschen</button></div>`).join('') : '<p class="muted">Keine Verknüpfungen.</p>';
$('#iconList').innerHTML = arr(icons).map(i => `<option value="${esc(i)}">`).join('');
await loadRdpFiles();
}
$('#scCurrent').onclick = e => {
const f = e.target.dataset.del; if (!f || !confirm(`"${f}" vom Desktop aller Benutzer löschen?`)) return;
@@ -320,26 +452,121 @@ $('#btnVendor').onclick = () => guard(async () => jobStarted((await api('POST',
$('#btnWinget').onclick = () => guard(async () => jobStarted((await api('POST', '/api/winget/install', {})).job));
$('#btnReboot').onclick = () => { if (confirm('Computer jetzt neu starten?')) guard(() => api('POST', '/api/reboot', {})); };
// ---------------------------------------------------------------- Profile
async function loadProfiles() {
const l = arr(await api('GET', '/api/profiles'));
$('#profileList').innerHTML = l.length ? l.map(n => `<div class="item"><div class="grow">${esc(n)}</div>
<button class="small primary" data-load="${esc(n)}">Laden</button><button class="small danger" data-pdel="${esc(n)}">✕</button></div>`).join('') : '<p class="muted">Keine Profile gespeichert.</p>';
// ---------------------------------------------------------------- Bloatware
let bloat = null;
const bloatChecked = it => arr(S.bloatware.ids).includes(it.id) || (S.bloatware.recommended && it.recommended && !arr(S.bloatware.keep).includes(it.id));
async function scanBloat() {
$('#bloatList').innerHTML = '<div class="card muted">Scanne Gerät … (kann einige Sekunden dauern)</div>';
await guard(async () => { bloat = arr(await api('GET', '/api/bloatware')); renderBloat(); });
}
function renderBloat() {
if (!bloat) return;
const groups = {};
bloat.forEach((it, i) => (groups[it.category] ??= []).push([it, i]));
$('#bloatList').innerHTML = bloat.length ? Object.keys(groups).sort().map(c => `<div class="card"><h2>${esc(c)} <span class="badge">${groups[c].length}</span></h2>
<div class="list" style="max-height:none">${groups[c].map(([it, i]) => `<label class="item"><input type="checkbox" data-b="${i}" ${bloatChecked(it) ? 'checked' : ''}>
<div class="grow"><div>${esc(it.name)} ${it.recommended ? '<span class="state ok">empfohlen</span>' : ''}</div><div class="sub">${esc(it.detail)}</div></div></label>`).join('')}</div></div>`).join('')
: '<div class="card">🎉 Keine Bloatware gefunden.</div>';
}
$('#bloatList').onchange = e => {
const i = e.target.dataset.b; if (i == null) return;
const it = bloat[i], on = e.target.checked, b = S.bloatware;
b.ids = arr(b.ids).filter(x => x !== it.id); b.keep = arr(b.keep).filter(x => x !== it.id);
if (b.recommended && it.recommended) { if (!on) b.keep.push(it.id); } else if (on) b.ids.push(it.id);
persist();
};
$('#btnBloatScan').onclick = scanBloat;
$('#btnBloatRec').onclick = () => { S.bloatware.recommended = true; S.bloatware.keep = []; persist(); fillForms(); renderBloat(); };
$('#btnBloatNone').onclick = () => { S.bloatware.recommended = false; S.bloatware.ids = []; S.bloatware.keep = []; persist(); fillForms(); renderBloat(); };
$('#btnBloatRemove').onclick = () => guard(async () => {
if (!bloat) return toast('Bitte zuerst scannen', true);
const ids = bloat.filter(bloatChecked).map(it => it.id);
if (!ids.length) return toast('Nichts ausgewählt', true);
if (!confirm(`${ids.length} Einträge jetzt deinstallieren?`)) return;
jobStarted((await api('POST', '/api/bloatware', { bloatware: { ids, recommended: false } })).job);
});
// ---------------------------------------------------------------- Blaupausen
let blueprints = [];
const slug = s => s.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue').replace(/ß/g, 'ss')
.replace(/[^A-Za-z0-9\-_. ]/g, '_').trim();
const bpId = (customer, name) => slug(customer ? `${customer}__${name}` : name);
async function loadProfiles() {
blueprints = arr(await api('GET', '/api/blueprints'));
renderBlueprints(); renderQuickSelect();
$('#bpCustomers').innerHTML = [...new Set(blueprints.map(b => b.customer).filter(Boolean))].map(c => `<option value="${esc(c)}">`).join('');
$('#bpCustomer').value = S.customer || ''; $('#profileSaveName').value = S.name || ''; $('#bpDescription').value = S.description || '';
}
function renderBlueprints() {
const f = $('#bpFilter').value.trim().toLowerCase();
const list = blueprints.filter(b => !f || `${b.customer} ${b.name} ${b.description}`.toLowerCase().includes(f));
const groups = {};
list.forEach(b => (groups[b.customer || 'Ohne Kunde'] ??= []).push(b));
const keys = Object.keys(groups).sort((a, b) => a.localeCompare(b, 'de'));
$('#profileList').innerHTML = keys.length ? keys.map(c => `<div class="cust"><h3>${esc(c)}</h3><div class="bpgrid">${groups[c].map(b => `
<div class="bp ${b.id === S.id ? 'active' : ''}">
<div class="t">${esc(b.name)}</div>
<div class="d">${esc(b.description) || '<i>keine Beschreibung</i>'}</div>
<div class="meta">${b.apps} Programme · ${b.shortcuts} Icons${b.office ? ' · ' + esc(b.office) : ''} · ${esc(b.updated)}</div>
<div class="acts">
<button class="small primary" data-load="${esc(b.id)}">Laden</button>
<button class="small" data-run="${esc(b.id)}">▶ Einrichten</button>
<button class="small ghost" data-dup="${esc(b.id)}" title="Duplizieren">⧉</button>
<button class="small ghost" data-exp="${esc(b.id)}" title="Exportieren">⤓</button>
<button class="small danger" data-pdel="${esc(b.id)}" title="Löschen">✕</button>
</div></div>`).join('')}</div></div>`).join('')
: '<div class="card muted">Keine Blaupausen gefunden. Konfiguration in den Tabs zusammenstellen und rechts speichern.</div>';
}
function renderQuickSelect() {
const groups = {};
blueprints.forEach(b => (groups[b.customer || 'Ohne Kunde'] ??= []).push(b));
$('#bpQuick').innerHTML = '<option value="">– aktuelle Konfiguration –</option>' + Object.keys(groups).sort((a, b) => a.localeCompare(b, 'de')).map(c =>
`<optgroup label="${esc(c)}">${groups[c].map(b => `<option value="${esc(b.id)}">${esc(b.name)}</option>`).join('')}</optgroup>`).join('');
$('#bpQuick').value = blueprints.some(b => b.id === S.id) ? S.id : '';
$('#bpQuickDesc').textContent = S.description || '';
}
$('#bpFilter').oninput = renderBlueprints;
async function loadBlueprint(id) {
const data = await api('GET', '/api/blueprints/' + encodeURIComponent(id));
loadState({ ...data, id });
renderBlueprints(); renderQuickSelect();
$('#bpCustomer').value = S.customer || ''; $('#profileSaveName').value = S.name || ''; $('#bpDescription').value = S.description || '';
toast(`Blaupause "${[S.customer, S.name].filter(Boolean).join(' / ')}" geladen`);
}
$('#bpQuick').onchange = e => guard(async () => { if (e.target.value) await loadBlueprint(e.target.value); });
$('#profileList').onclick = e => guard(async () => {
const { load, pdel } = e.target.dataset;
if (load) { loadState(await api('GET', '/api/profiles/' + encodeURIComponent(load))); toast(`Profil "${load}" geladen`); }
if (pdel && confirm(`Profil "${pdel}" löschen?`)) { await api('DELETE', '/api/profiles/' + encodeURIComponent(pdel)); loadProfiles(); }
const { load, run, dup, exp, pdel } = e.target.dataset;
if (load) await loadBlueprint(load);
if (run) { await loadBlueprint(run); showTab('overview'); $('#btnRunAll').focus(); toast('Schritte prüfen und „Einrichtung starten“ klicken'); }
if (dup) {
const d = await api('GET', '/api/blueprints/' + encodeURIComponent(dup));
const name = prompt('Name der Kopie:', (d.name || dup) + ' (Kopie)'); if (!name) return;
const customer = prompt('Kunde:', d.customer || '') ?? d.customer;
d.name = name; d.customer = customer; d.id = bpId(customer, name);
await api('POST', '/api/blueprints/' + encodeURIComponent(d.id), JSON.stringify(d, null, 2)); loadProfiles();
}
if (exp) { const d = await api('GET', '/api/blueprints/' + encodeURIComponent(exp)); download(exp + '.json', JSON.stringify(d, null, 2), 'application/json'); }
if (pdel && confirm(`Blaupause "${pdel}" löschen?`)) { await api('DELETE', '/api/blueprints/' + encodeURIComponent(pdel)); if (S.id === pdel) S.id = ''; persist(); loadProfiles(); }
});
$('#btnProfileSave').onclick = () => guard(async () => {
const n = $('#profileSaveName').value.trim().replace(/[^\w\-. ]/g, '_');
if (!n) return toast('Namen angeben', true);
S.name = n; persist();
await api('POST', '/api/profiles/' + encodeURIComponent(n), JSON.stringify(S, null, 2));
toast(`Profil "${n}" gespeichert`); fillForms(); loadProfiles();
});
$('#btnExport').onclick = () => download((S.name || 'profil') + '.json', JSON.stringify(S, null, 2), 'application/json');
$('#importFile').onchange = async e => { const f = e.target.files[0]; if (f) guard(async () => { loadState(JSON.parse(await f.text())); toast('Profil importiert'); }); e.target.value = ''; };
async function saveBlueprint(asNew) {
const name = $('#profileSaveName').value.trim(), customer = $('#bpCustomer').value.trim();
if (!name) return toast('Namen angeben', true);
const id = bpId(customer, name);
if ((asNew || id !== S.id) && blueprints.some(b => b.id === id) && !confirm(`Blaupause "${name}" existiert bereits. Überschreiben?`)) return;
if (S.id && id !== S.id && !asNew && blueprints.some(b => b.id === S.id) && confirm('Alte Blaupause (vorheriger Name) entfernen?'))
await api('DELETE', '/api/blueprints/' + encodeURIComponent(S.id));
Object.assign(S, { id, name, customer, description: $('#bpDescription').value.trim() }); persist();
await api('POST', '/api/blueprints/' + encodeURIComponent(id), JSON.stringify(S, null, 2));
toast(`Blaupause "${name}" gespeichert`); fillForms(); loadProfiles();
}
$('#btnProfileSave').onclick = () => guard(() => saveBlueprint(false));
$('#btnProfileSaveAs').onclick = () => guard(() => saveBlueprint(true));
$('#btnExport').onclick = () => download((S.id || 'blaupause') + '.json', JSON.stringify(S, null, 2), 'application/json');
$('#importFile').onchange = async e => {
const f = e.target.files[0]; e.target.value = '';
if (f) guard(async () => { const d = JSON.parse(await f.text()); d.id = ''; loadState(d); loadProfiles(); toast('Importiert – zum Übernehmen „Speichern“ klicken'); });
};
$('#btnReset').onclick = () => { if (confirm('Alle Einstellungen auf Standard zurücksetzen?')) loadState(DEFAULTS()); };
function download(name, text, type) {
@@ -396,7 +623,7 @@ $('#btnShutdown').onclick = () => { if (confirm('Agent beenden? Laufende Aufgabe
function renderAll() { renderOffice(); renderSelectedApps(); renderPlanned(); renderSummary(); }
(async () => {
if (!TOKEN) { document.body.innerHTML = '<main><h1>Kein Zugriffstoken</h1><p>Bitte die vollständige URL (mit ?token=…) aus dem Agent-Fenster öffnen.</p></main>'; return; }
bindForms(); renderAll(); fillForms();
bindForms(); renderAll(); fillForms(); scTypeUi();
await guard(loadStatus);
await guard(loadCatalog);
await guard(async () => {
@@ -406,6 +633,7 @@ function renderAll() { renderOffice(); renderSelectedApps(); renderPlanned(); re
$('#tzSelect').insertAdjacentHTML('beforeend', arr(p.timezones).map(t => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join(''));
fillForms();
});
await guard(loadProfiles);
if (location.hash) showTab(location.hash.slice(1));
pollJobs();
})();