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();
})();
+94 -21
View File
@@ -15,15 +15,16 @@
<a data-tab="apps">Programme</a>
<a data-tab="office">Office</a>
<a data-tab="shortcuts">Desktop-Icons</a>
<a data-tab="bloatware">Bloatware</a>
<a data-tab="system">System &amp; Sicherheit</a>
<a data-tab="power">Energie</a>
<a data-tab="bitlocker">BitLocker</a>
<a data-tab="updates">Updates &amp; Treiber</a>
<a data-tab="profiles">Profile</a>
<a data-tab="profiles">Blaupausen</a>
<a data-tab="jobs">Aufgaben <b id="jobBadge" class="badge hidden">0</b></a>
</nav>
<div class="side-foot">
<div id="profileName" class="muted">Profil: (ungespeichert)</div>
<div id="profileName" class="muted">Blaupause: (ungespeichert)</div>
<button class="ghost" id="btnShutdown">Agent beenden</button>
</div>
</aside>
@@ -35,9 +36,14 @@
<div id="sysinfo" class="grid4"></div>
<div class="card">
<h2>Komplette Einrichtung mit einem Klick</h2>
<p class="muted">Führt die Einstellungen aus allen Tabs in sinnvoller Reihenfolge aus. Vorher in den Tabs konfigurieren oder ein Profil laden.</p>
<p class="muted">Blaupause wählen (oder in den Tabs selbst konfigurieren) und starten. Alle Einstellungen werden in sinnvoller Reihenfolge ausgeführt.</p>
<div class="row">
<label class="f">Blaupause / Kunde<select id="bpQuick"><option value="">– aktuelle Konfiguration –</option></select></label>
</div>
<p id="bpQuickDesc" class="muted small"></p>
<div class="checks" id="steps">
<label><input type="checkbox" value="system" checked> System</label>
<label><input type="checkbox" value="bloatware"> Bloatware</label>
<label><input type="checkbox" value="security" checked> Sicherheit</label>
<label><input type="checkbox" value="power" checked> Energie</label>
<label><input type="checkbox" value="apps" checked> Programme</label>
@@ -71,12 +77,20 @@
<div id="catalog"></div>
</div>
<div class="card">
<h2>Eigene Installer vom Stick <span class="muted small">(Ordner <code>installers\</code>)</span></h2>
<div class="row">
<select id="localFile"></select>
<input id="localArgs" placeholder="Silent-Parameter, z.B. /S oder /quiet">
<button id="btnAddLocal">Hinzufügen</button>
</div>
<h2>Eigene Installer <span class="muted small">(EXE, MSI, MSIX, PS1, CMD – Ordner <code>installers\</code>)</span></h2>
<label class="drop" id="instDrop">
<input type="file" id="instFile" multiple accept=".exe,.msi,.msix,.msixbundle,.appx,.appxbundle,.ps1,.cmd,.bat" hidden>
<b>Setup-Dateien hierher ziehen</b> oder klicken zum Auswählen
<span class="muted small">Die Dateien werden auf den Stick hochgeladen und stehen in allen Blaupausen zur Verfügung.</span>
</label>
<div id="uploadProgress" class="muted small"></div>
<div id="localList" class="list"></div>
<p class="muted small">Silent-Parameter (Vorschläge):
<code class="hint" data-hint="/S">/S</code> NSIS ·
<code class="hint" data-hint="/VERYSILENT /SUPPRESSMSGBOXES /NORESTART">/VERYSILENT …</code> Inno Setup ·
<code class="hint" data-hint="/quiet /norestart">/quiet /norestart</code> WiX/Burn ·
<code class="hint" data-hint="/s /v&quot;/qn&quot;">/s /v"/qn"</code> InstallShield ·
MSI: <code>/qn</code> wird automatisch gesetzt</p>
</div>
</div>
<div>
@@ -161,12 +175,48 @@
<div>
<div class="card">
<h2>Neue Verknüpfung</h2>
<label class="f">Typ<select id="scType"><option value="url">Webseite (URL)</option><option value="lnk">Programm / Datei / Ordner</option></select></label>
<label class="f">Name<input id="scName" placeholder="z.B. Intranet"></label>
<label class="f">Ziel<input id="scTarget" placeholder="https://… oder %ProgramFiles%\…\app.exe"></label>
<label class="f lnkonly">Argumente<input id="scArgs"></label>
<label class="f">Icon <span class="muted small">(Ordner <code>icons\</code> auf dem Stick oder Pfad)</span>
<input id="scIcon" list="iconList" placeholder="optional"></label>
<label class="f">Typ<select id="scType">
<option value="url">Webseite (URL)</option>
<option value="lnk">Programm / Datei / Ordner</option>
<option value="rdp">Remotedesktop (RDP)</option></select></label>
<label class="f">Name<input id="scName" placeholder="z.B. Intranet, Terminalserver"></label>
<label class="f t-url t-lnk">Ziel<input id="scTarget" placeholder="https://… oder %ProgramFiles%\…\app.exe"></label>
<label class="f t-lnk">Argumente<input id="scArgs"></label>
<div class="t-rdp">
<label class="f">Quelle<select id="rdpSource"><option value="">Neu konfigurieren</option></select></label>
<div id="rdpForm">
<div class="row">
<label class="f" style="flex:3">Server / Host<input id="rdpHost" placeholder="ts01.firma.local"></label>
<label class="f" style="flex:1">Port<input id="rdpPort" type="number" placeholder="3389"></label>
</div>
<div class="row">
<label class="f">Benutzer (optional)<input id="rdpUser" placeholder="FIRMA\benutzer"></label>
<label class="f">RD-Gateway (optional)<input id="rdpGateway" placeholder="gw.firma.de"></label>
</div>
<div class="row">
<label class="f">RemoteApp-Alias (optional)<input id="rdpRemoteApp" placeholder="z.B. DATEV"></label>
<label class="f">Audio<select id="rdpAudio"><option value="local">auf diesem PC</option><option value="remote">auf dem Server</option><option value="none">aus</option></select></label>
</div>
<div class="checks">
<label><input type="checkbox" id="rdpFullscreen" checked> Vollbild</label>
<label><input type="checkbox" id="rdpMultimon"> Alle Monitore</label>
<label><input type="checkbox" id="rdpClipboard" checked> Zwischenablage</label>
<label><input type="checkbox" id="rdpPrinters" checked> Drucker</label>
<label><input type="checkbox" id="rdpDrives"> Laufwerke</label>
<label><input type="checkbox" id="rdpSmartcards"> Smartcards</label>
<label><input type="checkbox" id="rdpMicrophone"> Mikrofon</label>
<label><input type="checkbox" id="rdpCamera"> Kamera</label>
<label><input type="checkbox" id="rdpAdmin"> Admin-Sitzung</label>
</div>
<label class="f" style="margin-top:10px">Weitere RDP-Zeilen (optional)<textarea id="rdpExtra" rows="2" placeholder="z.B. session bpp:i:32"></textarea></label>
<details><summary class="muted small">Vorschau der .rdp-Datei</summary><pre id="rdpPreview" class="code"></pre></details>
</div>
<p class="muted small">Vorhandene .rdp-Dateien hochladen:
<label class="button small">.rdp hochladen<input type="file" id="rdpUpload" accept=".rdp" multiple hidden></label></p>
</div>
<div class="f t-url t-lnk"><label class="f" for="scIcon" style="margin:0">Icon <span class="muted small">(aus <code>icons\</code> oder Pfad)</span></label>
<div class="row" style="margin-top:3px"><input id="scIcon" list="iconList" placeholder="optional">
<label class="button">.ico hochladen<input type="file" id="iconUpload" accept=".ico,.exe,.dll" hidden></label></div></div>
<datalist id="iconList"></datalist>
<div class="row">
<button class="primary" id="btnScCreate">Sofort anlegen</button>
@@ -192,6 +242,24 @@
</div>
</section>
<!-- Bloatware -->
<section id="tab-bloatware" class="tab">
<h1>Bloatware entfernen</h1>
<div class="card">
<div class="row wrap" style="margin-top:0">
<button id="btnBloatScan">↻ Gerät scannen</button>
<button id="btnBloatRec">Empfohlene auswählen</button>
<button id="btnBloatNone" class="ghost">Keine</button>
<span class="sep"></span>
<button class="primary" id="btnBloatRemove">Ausgewählte entfernen</button>
</div>
<label class="checks" style="margin-top:12px"><input type="checkbox" data-bind="bloatware.recommended">
<span>In der Blaupause: <b>alle empfohlenen</b> Einträge entfernen (plus die manuell angehakten, minus die abgewählten). So funktioniert die Blaupause auch auf anderen Geräten und bei anderen Herstellern.</span></label>
<p class="muted small">Gefunden werden Store-Apps (für alle Benutzer + Vorlage für neue Benutzer), Hersteller-Tools, Testversionen, vorinstallierte Office/OneNote-Versionen (Click-to-Run, z. B. „Microsoft 365 – fr-fr“) und OneDrive. Katalog erweiterbar in <code>config\bloatware.json</code>.</p>
</div>
<div id="bloatList"><div class="card muted">Noch nicht gescannt.</div></div>
</section>
<!-- System & Sicherheit -->
<section id="tab-system" class="tab">
<h1>System &amp; Sicherheit</h1>
@@ -311,15 +379,20 @@
<!-- Profile -->
<section id="tab-profiles" class="tab">
<h1>Profile</h1>
<h1>Blaupausen</h1>
<p class="muted">Eine Blaupause speichert die komplette Einrichtung (Programme, Office, Icons, RDP, Sicherheit, Energie, BitLocker, Schritte), zum Beispiel als Standard für einen Kunden. Ablage: <code>blueprints\</code> auf dem Stick.</p>
<div class="cols">
<div class="card">
<h2>Gespeicherte Profile <span class="muted small">(Ordner <code>profiles\</code>)</span></h2>
<div id="profileList" class="list"></div>
<div>
<div class="row" style="margin:0 0 12px"><input id="bpFilter" placeholder="Kunde oder Blaupause suchen …"></div>
<div id="profileList"></div>
</div>
<div class="card">
<div class="card sticky">
<h2>Aktuelle Konfiguration speichern</h2>
<div class="row"><input id="profileSaveName" placeholder="z.B. Buero-Standard"><button class="primary" id="btnProfileSave">Speichern</button></div>
<label class="f">Kunde<input id="bpCustomer" list="bpCustomers" placeholder="z.B. Müller GmbH"></label>
<datalist id="bpCustomers"></datalist>
<label class="f">Name der Blaupause<input id="profileSaveName" placeholder="z.B. Büro-Notebook, Außendienst"></label>
<label class="f">Beschreibung<textarea id="bpDescription" rows="3" placeholder="optional – Hinweise für Techniker"></textarea></label>
<div class="row"><button class="primary" id="btnProfileSave">Speichern</button><button id="btnProfileSaveAs">Als neue Blaupause</button></div>
<h3>Import / Export</h3>
<div class="row">
<button id="btnExport">Als JSON exportieren</button>
+16
View File
@@ -81,3 +81,19 @@ code { font-family: Consolas, monospace; font-size: 12px; }
nav a.active { border-bottom-color: var(--accent); } .side-foot { display: none; }
main { padding: 16px; } .cols, .cols.jobs { grid-template-columns: 1fr; }
}
textarea { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 7px 9px; width: 100%; resize: vertical; display: block; margin-top: 3px; }
.drop { display: flex; flex-direction: column; align-items: center; gap: 4px; text-align: center; border: 2px dashed var(--line); border-radius: 10px; padding: 18px; cursor: pointer; margin-bottom: 8px; }
.drop:hover, .drop.over { border-color: var(--accent); background: var(--accent-2); }
.hint { cursor: pointer; padding: 1px 4px; border-radius: 4px; background: var(--accent-2); }
.item input.args { flex: 1; max-width: 260px; padding: 4px 7px; font-size: 12px; }
.cust { margin-bottom: 18px; }
.cust > h3 { margin: 0 0 8px; }
.bpgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; }
.bp { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px; display: flex; flex-direction: column; gap: 6px; }
.bp.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.bp .t { font-weight: 600; } .bp .d { font-size: 12px; color: var(--muted); flex: 1; }
.bp .meta { font-size: 11px; color: var(--muted); }
.bp .acts { display: flex; gap: 4px; flex-wrap: wrap; }
details summary { cursor: pointer; margin: 6px 0; }
input[hidden] { display: none !important; }
div.f { margin-bottom: 10px; font-size: 12px; color: var(--muted); }