Files
KDS-Install-Agent/web/app.js
T
MrBlakeandClaude Opus 5.5 ebfec7000e 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]>
2026-10-09 16:00:25 +02:00

640 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// ---------------------------------------------------------------- Basis
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const urlToken = new URLSearchParams(location.search).get('token');
if (urlToken) { sessionStorage.setItem('kdsToken', urlToken); history.replaceState(null, '', '/' + location.hash); }
const TOKEN = sessionStorage.getItem('kdsToken') || '';
async function api(method, path, body, raw) {
const r = await fetch(path, {
method, headers: { 'X-Token': TOKEN, 'Content-Type': 'application/json' },
body: body === undefined ? undefined : (typeof body === 'string' ? body : JSON.stringify(body))
});
if (raw) return r.text();
const data = await r.json().catch(() => null);
if (!r.ok) throw new Error(data?.error || `HTTP ${r.status}`);
return data;
}
const arr = v => v == null ? [] : Array.isArray(v) ? v : [v];
function toast(msg, isErr, onClick) {
const d = document.createElement('div');
d.textContent = msg; if (isErr) d.className = 'err';
d.onclick = () => { onClick?.(); d.remove(); };
$('#toast').append(d); setTimeout(() => d.remove(), isErr ? 8000 : 4000);
}
async function guard(fn) { try { return await fn(); } catch (e) { toast(e.message, true); } }
// ---------------------------------------------------------------- Zustand / Profil
const DEFAULTS = () => ({
id: '', name: '', customer: '', description: '',
apps: [],
office: {
product: 'O365ProPlusRetail', channel: 'Current', arch: '64', languages: ['de-de'],
exclude: ['Groove', 'Lync', 'Bing'], extraProducts: [], pidKey: '', company: '',
updates: true, removeMsi: true, migrateArch: true, sharedComputer: false, disableVba: true, ooxmlDefault: true, showUi: false
},
officeMode: 'online',
shortcuts: [],
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']
});
let S = DEFAULTS();
try { const saved = JSON.parse(localStorage.getItem('kdsState') || 'null'); if (saved) S = deepMerge(DEFAULTS(), saved); } catch { }
function deepMerge(base, add) {
for (const k of Object.keys(add || {})) {
if (add[k] && typeof add[k] === 'object' && !Array.isArray(add[k]) && base[k] && typeof base[k] === 'object') deepMerge(base[k], add[k]);
else base[k] = add[k];
}
return base;
}
const getPath = p => p.split('.').reduce((o, k) => o?.[k], S);
function setPath(p, v) { const ks = p.split('.'); const last = ks.pop(); ks.reduce((o, k) => o[k] ??= {}, S)[last] = v; }
function persist() { try { localStorage.setItem('kdsState', JSON.stringify(S)); } catch { } renderSummary(); }
// generische Formularbindung: data-bind="pfad" und data-bind-list="pfad" (Checkbox-Gruppen)
function bindForms() {
$$('[data-bind]').forEach(el => {
el.addEventListener(el.type === 'checkbox' ? 'change' : 'input', () => {
let v = el.type === 'checkbox' ? el.checked : el.value;
if (el.type === 'number') v = el.value === '' ? '' : Number(el.value);
setPath(el.dataset.bind, v); persist(); onChange(el.dataset.bind);
});
if (el.tagName === 'SELECT') el.addEventListener('change', () => el.dispatchEvent(new Event('input')));
});
$$('[data-bind-list]').forEach(box => box.addEventListener('change', () => {
setPath(box.dataset.bindList, $$('input:checked', box).map(i => i.value)); persist(); onChange(box.dataset.bindList);
}));
}
function fillForms() {
$$('[data-bind]').forEach(el => {
const v = getPath(el.dataset.bind);
if (el.type === 'checkbox') el.checked = !!v; else el.value = v ?? '';
});
$$('[data-bind-list]').forEach(box => {
const v = arr(getPath(box.dataset.bindList));
$$('input', box).forEach(i => i.checked = v.includes(i.value));
});
$$('#steps input').forEach(i => i.checked = arr(S.steps).includes(i.value));
$('#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(); }
// ---------------------------------------------------------------- Navigation
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, 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); };
// ---------------------------------------------------------------- Übersicht
async function loadStatus() {
const s = await api('GET', '/api/status');
const tpm = s.tpm ? (s.tpm.present ? (s.tpm.ready ? 'vorhanden, bereit' : 'vorhanden, nicht bereit') : 'nicht vorhanden') : 'unbekannt';
const items = [['Computer', s.computerName], ['Hersteller / Modell', `${s.manufacturer} ${s.model}`], ['Seriennummer', s.serial],
['Windows', `${s.os}<br><span class="muted small">${esc(s.build)}</span>`], ['Prozessor', s.cpu], ['RAM', s.ramGB + ' GB'],
['Systemlaufwerk', `${s.diskFreeGB} GB frei von ${s.diskSizeGB} GB`], ['TPM', tpm], ['Domäne', s.partOfDomain ? s.domain : 'Arbeitsgruppe'],
['winget', s.winget ? 'verfügbar' : '<span style="color:var(--err)">fehlt – wird automatisch installiert</span>'], ['Akku', s.battery || '–'], ['Agent-Pfad', s.root]];
$('#sysinfo').innerHTML = items.map(([k, v]) => `<div class="stat"><div class="k">${k}</div><div class="v">${/<|&/.test(v) && k !== 'Seriennummer' ? v : esc(v)}</div></div>`).join('');
}
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`, `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(' · ');
}
$('#steps').onchange = () => { S.steps = $$('#steps input:checked').map(i => i.value); persist(); };
$('#btnRunAll').onclick = () => guard(async () => {
if (!S.steps.length) return toast('Keine Schritte ausgewählt', true);
if (S.steps.includes('bitlocker')) S.bitlocker.action = 'enable';
if (!confirm(`Einrichtung mit ${S.steps.length} Schritten jetzt starten?`)) return;
const r = await api('POST', '/api/run-profile', S); jobStarted(r.job);
});
// ---------------------------------------------------------------- Programme
let catalog = [];
async function loadCatalog() {
catalog = arr((await api('GET', '/api/catalog'))?.apps);
const groups = {};
catalog.forEach(a => (groups[a.category] ??= []).push(a));
$('#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();
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);
if (e.target.checked) addApp({ name: a.name, id: a.id, source: a.source, override: a.override });
else removeApp(id);
};
function syncCatalogChecks() { $$('#catalog input').forEach(i => i.checked = S.apps.some(a => a.id === i.dataset.app)); }
function addApp(a) { if (S.apps.some(x => x.id === a.id)) return; S.apps.push(a); persist(); renderSelectedApps(); }
function removeApp(id) { S.apps = S.apps.filter(a => a.id !== id); persist(); renderSelectedApps(); }
function renderSelectedApps() {
$('#appCount').textContent = S.apps.length;
$('#selectedApps').innerHTML = S.apps.length ? S.apps.map(a => `<div class="item"><div class="grow"><div>${esc(a.name)}</div>
<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() {
const q = $('#appSearch').value.trim(); if (!q) return;
$('#searchResults').innerHTML = '<p class="muted">Suche …</p>';
await guard(async () => {
const res = arr(await api('GET', `/api/winget/search?q=${encodeURIComponent(q)}&source=${$('#appSource').value}`));
$('#searchResults').innerHTML = res.length ? res.map((r, i) => `<div class="item"><div class="grow"><div>${esc(r.name)}</div>
<div class="sub">${esc(r.id)} · ${esc(r.version)}</div></div><button class="small" data-add="${i}">+ Hinzufügen</button></div>`).join('') : '<p class="muted">Keine Treffer.</p>';
$('#searchResults').onclick = e => { const i = e.target.dataset.add; if (i != null) { addApp(res[i]); e.target.textContent = '✓'; } };
});
}
$('#btnSearch').onclick = doSearch;
$('#appSearch').onkeydown = e => { if (e.key === 'Enter') doSearch(); };
$('#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);
});
$('#btnUpgradeAll').onclick = $('#btnWingetUpg').onclick = () => guard(async () => jobStarted((await api('POST', '/api/apps/upgrade-all', {})).job));
// ---------------------------------------------------------------- Office
const PRODUCTS = [
{ id: 'O365ProPlusRetail', name: 'Microsoft 365 Apps for enterprise', channels: ['Current', 'MonthlyEnterprise', 'SemiAnnual', 'SemiAnnualPreview', 'BetaChannel'], extras: ['VisioProRetail', 'ProjectProRetail'] },
{ id: 'O365BusinessRetail', name: 'Microsoft 365 Apps for business', channels: ['Current', 'MonthlyEnterprise', 'SemiAnnual'], extras: ['VisioProRetail', 'ProjectProRetail'] },
{ id: 'ProPlus2024Volume', name: 'Office LTSC Professional Plus 2024 (Volumenlizenz)', channels: ['PerpetualVL2024'], extras: ['VisioPro2024Volume', 'ProjectPro2024Volume'] },
{ id: 'Standard2024Volume', name: 'Office LTSC Standard 2024 (Volumenlizenz)', channels: ['PerpetualVL2024'], extras: ['VisioStd2024Volume', 'ProjectStd2024Volume'] },
{ id: 'ProPlus2021Volume', name: 'Office LTSC Professional Plus 2021 (Volumenlizenz)', channels: ['PerpetualVL2021'], extras: ['VisioPro2021Volume', 'ProjectPro2021Volume'] },
{ id: 'HomeBusiness2024Retail', name: 'Office Home & Business 2024', channels: ['Current'], extras: [] },
{ id: 'Home2024Retail', name: 'Office Home 2024', channels: ['Current'], extras: [] }
];
const CHANNEL_NAMES = { Current: 'Aktueller Kanal', MonthlyEnterprise: 'Monatlicher Enterprise-Kanal', SemiAnnual: 'Halbjährlicher Enterprise-Kanal', SemiAnnualPreview: 'Halbjährlich (Vorschau)', BetaChannel: 'Beta', PerpetualVL2024: 'LTSC 2024', PerpetualVL2021: 'LTSC 2021' };
const OFFICE_APPS = [['Word', 'Word'], ['Excel', 'Excel'], ['PowerPoint', 'PowerPoint'], ['Outlook', 'Outlook (klassisch)'], ['OutlookForWindows', 'Outlook (neu)'], ['OneNote', 'OneNote'],
['Access', 'Access'], ['Publisher', 'Publisher'], ['Teams', 'Teams'], ['OneDrive', 'OneDrive'], ['Lync', 'Skype for Business'], ['Groove', 'OneDrive for Business (alt)'], ['Bing', 'Bing-Suche in Chrome']];
function renderOffice() {
$('#officeProduct').innerHTML = PRODUCTS.map(p => `<option value="${p.id}">${esc(p.name)}</option>`).join('');
$('#officeApps').innerHTML = OFFICE_APPS.map(([id, n]) => `<label><input type="checkbox" value="${id}"> ${n}</label>`).join('');
$('#officeProduct').value = S.office.product;
renderOfficeChannels(); renderOfficeExtras(); renderOfficeApps(); updateOfficeXml();
}
function renderOfficeChannels() {
const p = PRODUCTS.find(x => x.id === S.office.product) || PRODUCTS[0];
$('#officeChannel').innerHTML = p.channels.map(c => `<option value="${c}">${CHANNEL_NAMES[c] || c}</option>`).join('');
if (!p.channels.includes(S.office.channel)) S.office.channel = p.channels[0];
$('#officeChannel').value = S.office.channel;
S.office.extraProducts = arr(S.office.extraProducts).filter(e => p.extras.includes(e));
}
function renderOfficeExtras() {
const p = PRODUCTS.find(x => x.id === S.office.product) || PRODUCTS[0];
const label = e => `${e.startsWith('Visio') ? 'Visio' : 'Project'} ${/Std/.test(e) ? 'Standard' : 'Professional'} ${(e.match(/\d{4}/) || [''])[0]}`;
$('#officeExtras').innerHTML = p.extras.length ? p.extras.map(e => `<label><input type="checkbox" value="${e}" ${arr(S.office.extraProducts).includes(e) ? 'checked' : ''}> ${label(e)}</label>`).join('') : '<span class="muted small">keine für dieses Produkt</span>';
}
function renderOfficeApps() { $$('#officeApps input').forEach(i => i.checked = !arr(S.office.exclude).includes(i.value)); }
$('#officeApps').onchange = () => { S.office.exclude = $$('#officeApps input:not(:checked)').map(i => i.value); persist(); updateOfficeXml(); };
$('#officeExtras').onchange = () => { S.office.extraProducts = $$('#officeExtras input:checked').map(i => i.value); persist(); updateOfficeXml(); };
let xmlTimer;
function updateOfficeXml() {
clearTimeout(xmlTimer);
xmlTimer = setTimeout(() => guard(async () => { $('#officeXml').textContent = await api('POST', '/api/office/xml', S.office, true); }), 250);
}
$('#btnXmlDownload').onclick = () => download('configuration.xml', $('#officeXml').textContent, 'application/xml');
$('#btnXmlCopy').onclick = () => navigator.clipboard.writeText($('#officeXml').textContent).then(() => toast('XML kopiert'));
$('#btnOffice').onclick = () => guard(async () => {
jobStarted((await api('POST', '/api/office/install', { office: S.office, mode: S.officeMode, name: S.name })).job);
});
async function loadOfficeBuilds() {
const b = arr(await api('GET', '/api/office/configs').catch(() => []));
$('#officeBuilds').innerHTML = b.length ? 'Vorhandene Builds auf dem Stick: ' + b.map(esc).join(', ') : 'Noch kein Offline-Build auf dem Stick.';
}
// ---------------------------------------------------------------- Desktop-Icons
let presets = [];
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 : '';
}
$('#btnScCreate').onclick = () => guard(async () => {
const s = scFromForm(); if (!scValid(s)) return;
await api('POST', '/api/shortcuts', s); toast('Verknüpfung angelegt'); loadShortcuts();
});
$('#btnScPlan').onclick = () => {
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"><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, 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();
});
async function loadShortcuts() {
const [cur, icons] = await Promise.all([api('GET', '/api/shortcuts'), api('GET', '/api/icons')]);
$('#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;
guard(async () => { await api('DELETE', '/api/shortcuts?file=' + encodeURIComponent(f)); loadShortcuts(); });
};
$('#btnScRefresh').onclick = loadShortcuts;
$('#scPresets').onclick = e => {
const p = presets[e.target.dataset.p]; if (!p) return;
S.shortcuts = arr(S.shortcuts).filter(x => x.name !== p.name).concat({ ...p }); persist(); renderPlanned();
};
// ---------------------------------------------------------------- System / Sicherheit / Energie
$$('[data-run]').forEach(b => b.onclick = () => guard(async () => {
const k = b.dataset.run; jobStarted((await api('POST', '/api/' + k, { [k]: S[k] })).job);
}));
const ACTIONS = [['', 'unverändert'], ['nothing', 'Nichts unternehmen'], ['sleep', 'Energiesparmodus'], ['hibernate', 'Ruhezustand'], ['shutdown', 'Herunterfahren']];
$$('select.act').forEach(s => s.innerHTML = ACTIONS.map(([v, n]) => `<option value="${v}">${n}</option>`).join(''));
async function loadPower() { const p = await api('GET', '/api/power').catch(() => null); if (p) $('#powerState').textContent = 'Aktuell: ' + p.active; }
// ---------------------------------------------------------------- BitLocker
async function loadBitlocker() {
$('#blStatus').textContent = 'Lade …';
await guard(async () => {
const b = await api('GET', '/api/bitlocker');
let h = `<p class="small">${esc(b.edition)} · TPM: <b>${b.tpmPresent ? (b.tpmReady ? 'bereit' : 'nicht bereit') : 'nicht vorhanden'}</b></p>`;
if (!b.available) h += `<p style="color:var(--err)">${esc(b.message)}</p>`;
h += arr(b.volumes).map(v => `<div class="vol"><b>${esc(v.mount)}</b> <span class="muted small">${esc(v.type)}</span><br>
Status: ${esc(v.status)} · Schutz: <b style="color:${v.protection === 'On' ? 'var(--ok)' : 'var(--warn)'}">${esc(v.protection)}</b> · ${esc(v.method)}<br>
<span class="small muted">Schutzvorrichtungen: ${esc(arr(v.protectors).join(', ') || '–')}</span>
<div class="bar"><i style="width:${Number(v.percent) || 0}%"></i></div></div>`).join('');
$('#blStatus').innerHTML = h;
});
}
$('#btnBlRefresh').onclick = loadBitlocker;
$$('[data-bl]').forEach(b => b.onclick = () => guard(async () => {
const action = b.dataset.bl;
if (action === 'enable' && S.bitlocker.usePin && !/^\d{6,20}$/.test(S.bitlocker.pin || '')) return toast('PIN muss 6–20 Ziffern haben', true);
if (action === 'disable' && !confirm('BitLocker wirklich deaktivieren und das Laufwerk entschlüsseln?')) return;
jobStarted((await api('POST', '/api/bitlocker', { bitlocker: { ...S.bitlocker, action } })).job);
}));
// ---------------------------------------------------------------- Updates
let updates = [];
$$('[data-scan]').forEach(b => b.onclick = () => guard(async () => {
const type = b.dataset.scan;
const r = await api('POST', '/api/updates/scan', { type }); jobStarted(r.job, true);
$('#updList').innerHTML = '<p class="muted">Suche läuft … (kann einige Minuten dauern)</p>';
const j = await waitJob(r.job);
updates = arr(j.result);
$('#updCount').textContent = updates.length;
$('#updList').innerHTML = updates.length ? updates.map((u, i) => `<label class="item"><input type="checkbox" data-u="${i}" checked>
<div class="grow"><div>${esc(u.title)}</div><div class="sub">${u.type === 'Driver' ? 'Treiber' : 'Windows'} · ${esc(u.class)} ${u.kb ? '· KB' + esc(u.kb) : ''} · ${u.sizeMB} MB</div></div></label>`).join('')
: `<p class="muted">${j.state.startsWith('Fertig') ? 'Keine Updates gefunden – System ist aktuell.' : 'Suche fehlgeschlagen, siehe Aufgaben-Protokoll.'}</p>`;
}));
$('#btnUpdSel').onclick = () => guard(async () => {
const ids = $$('#updList input:checked').map(i => updates[i.dataset.u].id);
if (!ids.length) return toast('Nichts ausgewählt', true);
jobStarted((await api('POST', '/api/updates/install', { ids })).job);
});
$('#btnUpdAll').onclick = () => guard(async () => jobStarted((await api('POST', '/api/updates/install', { all: true, type: 'all' })).job));
$('#btnVendor').onclick = () => guard(async () => jobStarted((await api('POST', '/api/updates/vendor', {})).job));
$('#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', {})); };
// ---------------------------------------------------------------- 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, 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(); }
});
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) {
const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], { type })); a.download = name; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
// ---------------------------------------------------------------- Aufgaben
let selJob = null, jobTimer = null, knownStates = {};
const stateClass = s => s === 'Laeuft' ? 'run' : s === 'Fertig' ? 'ok' : s.includes('Fehler') ? 'warn' : /Fehlg|Abgeb/.test(s) ? 'err' : '';
const stateName = s => s === 'Laeuft' ? 'Läuft' : s;
function jobStarted(id, quiet) {
if (!quiet) toast(`Aufgabe ${id} gestartet – zum Protokoll klicken`, false, () => { selJob = id; showTab('jobs'); });
selJob ??= id; pollJobs();
}
async function pollJobs() {
clearTimeout(jobTimer);
try {
const jobs = arr(await api('GET', '/api/jobs')).reverse();
const active = jobs.filter(j => j.state === 'Laeuft' || j.state === 'Wartet').length;
$('#jobBadge').textContent = active; $('#jobBadge').classList.toggle('hidden', !active);
for (const j of jobs) {
if (knownStates[j.id] && knownStates[j.id] !== j.state && !/Laeuft|Wartet/.test(j.state))
toast(`${j.title}: ${j.state}`, /Fehl|Abgeb/.test(j.state), () => { selJob = j.id; showTab('jobs'); });
knownStates[j.id] = j.state;
}
$('#jobList').innerHTML = jobs.length ? jobs.map(j => `<div class="item click ${j.id === selJob ? 'sel' : ''}" data-j="${j.id}">
<div class="grow"><div>${esc(j.title)}</div><div class="sub">#${j.id} · ${j.created}</div></div><span class="state ${stateClass(j.state)}">${esc(stateName(j.state))}</span></div>`).join('')
: '<p class="muted">Noch keine Aufgaben.</p>';
if (selJob && $('#tab-jobs').classList.contains('active')) {
const j = await api('GET', '/api/jobs/' + selJob);
$('#jobTitle').textContent = `#${j.id} ${j.title} – ${stateName(j.state)}`;
const pre = $('#jobLog'), atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 30;
pre.innerHTML = esc(j.log || '').replace(/^.*\[(ERROR|WARN|OK)\].*$/gm, (l, lv) => `<span class="${lv}">${l}</span>`);
if (atBottom) pre.scrollTop = pre.scrollHeight;
$('#btnJobStop').classList.toggle('hidden', !/Laeuft|Wartet/.test(j.state));
}
jobTimer = setTimeout(pollJobs, active ? 1500 : 5000);
} catch { jobTimer = setTimeout(pollJobs, 5000); }
}
$('#jobList').onclick = e => { const it = e.target.closest('[data-j]'); if (it) { selJob = it.dataset.j; pollJobs(); } };
$('#btnJobStop').onclick = () => { if (selJob && confirm('Aufgabe abbrechen?')) guard(async () => { await api('DELETE', '/api/jobs/' + selJob); pollJobs(); }); };
async function waitJob(id) {
for (; ;) {
await new Promise(r => setTimeout(r, 2000));
const j = await api('GET', '/api/jobs/' + id);
if (!/Laeuft|Wartet/.test(j.state)) return j;
}
}
$('#btnShutdown').onclick = () => { if (confirm('Agent beenden? Laufende Aufgaben werden abgebrochen.')) api('POST', '/api/shutdown', {}).finally(() => { document.body.innerHTML = '<main><h1>Agent beendet.</h1><p>Das Fenster kann geschlossen werden.</p></main>'; }); };
// ---------------------------------------------------------------- Start
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(); scTypeUi();
await guard(loadStatus);
await guard(loadCatalog);
await guard(async () => {
const p = await api('GET', '/api/presets');
presets = arr(p.shortcuts);
$('#scPresets').innerHTML = presets.map((s, i) => `<button data-p="${i}">+ ${esc(s.name)}</button>`).join('');
$('#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();
})();