Files
KDS-Install-Agent/web/app.js
T

809 lines
52 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', officeSource: '',
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: [] },
wifi: [],
vpn: { config: '', user: '', password: '', dns: '' },
domain: { name: '', ou: '', user: '', password: '', admins: '', useVpn: false, vpnService: false, reboot: true },
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(); updateDirty(); }
// ---- Ungespeicherte Änderungen erkennen (Vergleich mit dem zuletzt gespeicherten/geladenen Stand)
const snap = o => JSON.stringify(o);
let savedSnap = null;
try { savedSnap = localStorage.getItem('kdsSaved'); } catch { }
if (savedSnap == null) savedSnap = snap(S);
const isDirty = () => snap(S) !== savedSnap;
function markSaved() { savedSnap = snap(S); try { localStorage.setItem('kdsSaved', savedSnap); } catch { } updateDirty(); }
function updateDirty() {
const d = isDirty();
$('#dirtyWarn')?.classList.toggle('hidden', !d);
$('#btnSideSave')?.classList.toggle('hidden', !d);
}
const confirmDiscard = () => !isDirty() || confirm('Es gibt ungespeicherte Änderungen an der aktuellen Blaupause. Trotzdem fortfahren und Änderungen verwerfen?');
window.addEventListener('beforeunload', e => { if (isDirty()) { e.preventDefault(); e.returnValue = ''; } });
// 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));
$('#officeSourceRow').classList.toggle('hidden', S.officeMode !== 'custom');
$('#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 === 'officeMode' || path === 'officeSource') { fillForms(); updateOfficeXml(); }
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(); }, wifi: loadWifi, vpn: loadVpn, 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>`,
arr(S.wifi).length ? `<b>${arr(S.wifi).length}</b> WLAN(s)` : '',
S.domain.name ? `Domäne: <b>${esc(S.domain.name)}</b>${S.domain.useVpn ? ' (per VPN)' : ''}` : '',
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 data = await withSecrets(S, S.steps.includes('domain'), S.steps.includes('domain') && S.domain.useVpn);
if (!data) return;
const r = await api('POST', '/api/run-profile', data); 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, sourcePath: S.officeMode === 'custom' ? S.officeSource : '' }, 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 () => {
if (S.officeMode === 'custom' && !S.officeSource) return toast('Pfad zur Office-Quelle angeben', true);
jobStarted((await api('POST', '/api/office/install', { office: S.office, mode: S.officeMode, sourcePath: S.officeSource, 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', {})); };
// ---------------------------------------------------------------- Kennwortabfrage
function askSecret(title, text) {
return new Promise(resolve => {
const d = $('#secretDlg');
$('#secretTitle').textContent = title; $('#secretText').textContent = text || ''; $('#secretInput').value = '';
d.onclose = () => resolve(d.returnValue === 'ok' ? $('#secretInput').value : null);
d.showModal(); $('#secretInput').focus();
});
}
// Fehlende Kennwörter (nicht in der Blaupause gespeichert) nur für diesen Lauf abfragen
async function withSecrets(state, needDomain, needVpn) {
const d = JSON.parse(JSON.stringify(state));
if (needDomain) {
if (!d.domain.name || !d.domain.user) { toast('Domäne und Benutzer angeben (Tab „VPN & Domäne“)', true); return null; }
if (!d.domain.password) {
const p = await askSecret('Domänen-Kennwort', `Kennwort für ${d.domain.user} (${d.domain.name})`); if (p == null) return null; d.domain.password = p;
}
}
if (needVpn) {
if (!d.vpn.config) { toast('Keine VPN-Konfiguration ausgewählt', true); return null; }
if (d.vpn.user && !d.vpn.password) {
const p = await askSecret('VPN-Kennwort', `Kennwort für VPN-Benutzer ${d.vpn.user}`); if (p == null) return null; d.vpn.password = p;
}
}
return d;
}
// ---------------------------------------------------------------- WLAN
$('#wfAuth').onchange = () => $('#wfKeyRow').classList.toggle('hidden', $('#wfAuth').value === 'open');
$('#wfShow').onclick = () => { const i = $('#wfKey'); i.type = i.type === 'password' ? 'text' : 'password'; };
$('#btnWfAdd').onclick = () => {
const w = { ssid: $('#wfSsid').value.trim(), auth: $('#wfAuth').value, key: $('#wfKey').value, auto: $('#wfAuto').checked, hidden: $('#wfHidden').checked, connect: $('#wfConnect').checked };
if (!w.ssid) return toast('SSID angeben', true);
if (w.auth !== 'open' && w.key.length < 8) return toast('Kennwort muss mind. 8 Zeichen haben', true);
S.wifi = arr(S.wifi).filter(x => x.ssid !== w.ssid).concat(w); persist(); renderWifi();
$('#wfSsid').value = ''; $('#wfKey').value = '';
};
const AUTH_NAMES = { WPA2PSK: 'WPA2', WPA3SAE: 'WPA3', open: 'offen' };
function renderWifi() {
const l = arr(S.wifi);
$('#wfCount').textContent = l.length;
$('#wfList').innerHTML = l.length ? l.map((w, i) => `<div class="item"><span>📶</span><div class="grow"><div>${esc(w.ssid)}</div>
<div class="sub">${AUTH_NAMES[w.auth] || w.auth}${w.hidden ? ' · versteckt' : ''}${w.auto === false ? ' · manuell' : ' · automatisch'}${w.connect ? ' · verbinden' : ''}</div></div>
<button class="small ghost" data-wedit="${i}" title="Bearbeiten">✎</button><button class="small ghost" data-wdel="${i}">✕</button></div>`).join('')
: '<p class="muted">Keine WLANs in der Blaupause.</p>';
}
$('#wfList').onclick = e => {
const { wdel, wedit } = e.target.dataset;
if (wdel != null) { S.wifi.splice(wdel, 1); persist(); renderWifi(); }
if (wedit != null) {
const w = S.wifi[wedit];
$('#wfSsid').value = w.ssid; $('#wfAuth').value = w.auth; $('#wfKey').value = w.key || '';
$('#wfAuto').checked = w.auto !== false; $('#wfHidden').checked = !!w.hidden; $('#wfConnect').checked = !!w.connect;
$('#wfAuth').onchange();
}
};
$('#btnWfApply').onclick = () => guard(async () => {
if (!arr(S.wifi).length) return toast('Keine WLANs in der Blaupause', true);
jobStarted((await api('POST', '/api/wifi/apply', { wifi: S.wifi })).job);
});
async function loadWifi() {
await guard(async () => {
const s = await api('GET', '/api/wifi');
$('#wfState').innerHTML = s.hasWifi ? (s.connected ? `Verbunden mit <b>${esc(s.connected)}</b>` : 'Nicht mit einem WLAN verbunden') : 'Kein WLAN-Adapter gefunden.';
$('#wfNetworks').innerHTML = arr(s.networks).map(n => `<option value="${esc(n)}">`).join('');
$('#wfDevice').innerHTML = arr(s.profiles).length ? arr(s.profiles).map(p => `<div class="item"><div class="grow">${esc(p)}</div>
<button class="small" data-wconn="${esc(p)}">Verbinden</button><button class="small danger" data-wrm="${esc(p)}">✕</button></div>`).join('')
: '<p class="muted small">Keine Profile gespeichert.</p>';
});
}
$('#wfDevice').onclick = e => guard(async () => {
const { wconn, wrm } = e.target.dataset;
if (wconn) { const r = await api('POST', '/api/wifi/connect', { ssid: wconn }); toast(r.ok ? `Verbinde mit ${wconn} …` : r.message, !r.ok); setTimeout(loadWifi, 4000); }
if (wrm && confirm(`WLAN-Profil "${wrm}" von diesem Gerät löschen?`)) { await api('DELETE', '/api/wifi?ssid=' + encodeURIComponent(wrm)); loadWifi(); }
});
$('#btnWfRefresh').onclick = loadWifi;
$('#btnWfImport').onclick = () => guard(async () => {
const list = arr(await api('POST', '/api/wifi/import', {}));
if (!list.length) return toast('Keine übernehmbaren WLAN-Profile gefunden', true);
const names = new Set(list.map(w => w.ssid));
S.wifi = arr(S.wifi).filter(x => !names.has(x.ssid)).concat(list); persist(); renderWifi();
toast(`${list.length} WLAN(s) übernommen – Blaupause speichern nicht vergessen`);
});
// ---------------------------------------------------------------- VPN & Domäne
async function loadVpn() {
await guard(async () => {
const v = await api('GET', '/api/vpn');
const p = v.portable;
$('#vpnPortable').innerHTML = p.portable && p.driver
? `<span class="okline">✓ Portables OpenVPN vorhanden</span> – ${esc(p.version)} <button class="small ghost" id="btnVpnBuild">aktualisieren</button>`
: `<span class="errline">Portables OpenVPN fehlt auf dem Stick.</span> <button class="small primary" id="btnVpnBuild">Jetzt erstellen</button>
<br>Lädt einmalig die offiziellen Pakete (OpenVPN + Microsoft-signierter TAP-Treiber) nach <code>tools\\openvpn\\</code>. Danach läuft alles ohne Installation vom Stick.`;
$('#btnVpnBuild').onclick = () => guard(async () => jobStarted((await api('POST', '/api/vpn/build', {})).job));
const files = arr(v.files).filter(f => /\.(ovpn|conf)$/i.test(f));
$('#vpnConfig').innerHTML = '<option value="">– keine –</option>' + files.map(f => `<option value="${esc(f)}">${esc(f)}</option>`).join('');
$('#vpnConfig').value = files.includes(S.vpn.config) ? S.vpn.config : '';
const s = v.status;
$('#vpnStatus').innerHTML = `<div>${s.connected ? `<span class="okline">● Verbunden</span> ${esc(s.ip)}` : s.running ? '<span style="color:var(--warn)">● Verbindungsaufbau …</span>' : '<span class="muted">○ Nicht verbunden</span>'}
${s.kdsService ? `<span class="muted small"> · Als Dienst eingerichtet: ${esc(s.service)}</span> <button class="small ghost" id="btnVpnSvcDel">Dienst-Konfiguration entfernen</button>` : ''}</div>
${s.log ? `<details><summary class="muted small">OpenVPN-Log</summary><pre class="code">${esc(s.log)}</pre></details>` : ''}`;
const del = $('#btnVpnSvcDel');
if (del) del.onclick = () => guard(async () => { if (confirm('Dauerhafte VPN-Konfiguration vom Gerät entfernen?')) { await api('DELETE', '/api/vpn/service'); loadVpn(); } });
});
}
$('#vpnUpload').onchange = async e => {
const up = await uploadMany('vpn', [...e.target.files]); e.target.value = '';
const ovpn = up.find(u => /\.(ovpn|conf)$/i.test(u.name));
if (ovpn) { S.vpn.config = ovpn.name; persist(); }
loadVpn();
};
$('#btnVpnConnect').onclick = () => guard(async () => {
const d = await withSecrets(S, false, true); if (!d) return;
const r = await api('POST', '/api/vpn/connect', { vpn: d.vpn }); jobStarted(r.job, true);
$('#vpnStatus').innerHTML = '<span style="color:var(--warn)">● Verbindungsaufbau …</span>';
const j = await waitJob(r.job);
toast(j.state === 'Fertig' ? 'VPN verbunden' : 'VPN-Verbindung fehlgeschlagen – siehe Log', j.state !== 'Fertig', () => { selJob = r.job; showTab('jobs'); });
loadVpn();
});
$('#btnVpnDisconnect').onclick = () => guard(async () => { await api('POST', '/api/vpn/disconnect', {}); toast('VPN getrennt'); loadVpn(); });
$('#btnVpnRefresh').onclick = loadVpn;
$('#btnDomTest').onclick = () => guard(async () => {
if (!S.domain.name) return toast('Domäne angeben', true);
$('#domTest').textContent = 'Teste …';
const t = await api('POST', '/api/domain/test', { name: S.domain.name });
$('#domTest').innerHTML = t.joined ? '<span class="okline">✓ Gerät ist bereits Mitglied dieser Domäne</span>'
: t.dns ? `<span class="okline">✓ DCs gefunden:</span> ${arr(t.dcs).map(esc).join(', ')} · LDAP ${t.ldap ? '<span class="okline">erreichbar</span>' : '<span class="errline">nicht erreichbar</span>'}`
: `<span class="errline">✗ Kein Domänencontroller per DNS gefunden.</span> VPN verbinden oder DNS-Server für den Tunnel setzen.`;
});
$('#btnDomJoin').onclick = () => guard(async () => {
const d = await withSecrets(S, true, S.domain.useVpn); if (!d) return;
if (!confirm(`Gerät jetzt der Domäne ${d.domain.name} hinzufügen?${d.domain.reboot ? '\nDanach wird neu gestartet.' : ''}`)) return;
jobStarted((await api('POST', '/api/domain/join', d)).job);
});
// ---------------------------------------------------------------- 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) {
if (!confirmDiscard()) { renderQuickSelect(); return; }
const data = await api('GET', '/api/blueprints/' + encodeURIComponent(id));
loadState({ ...data, id }); markSaved();
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));
markSaved();
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()); markSaved(); } };
// Speichern-Button in der Seitenleiste
$('#btnSideSave').onclick = () => guard(async () => {
if (!S.name) { showTab('profiles'); $('#profileSaveName').focus(); return toast('Bitte Kunde und Namen für die neue Blaupause angeben', true); }
if (!blueprints.length) await loadProfiles();
$('#bpCustomer').value = S.customer || ''; $('#profileSaveName').value = S.name; $('#bpDescription').value = S.description || '';
await saveBlueprint(false);
});
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 (confirmDiscard() && 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(); renderWifi(); 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(); updateDirty();
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();
})();