- Katalog: Mozilla Firefox ESR (DE) - Office-Modus "Offline aus eigenem Pfad" (Netzlaufwerk/Ordner) mit Pruefung auf Office\Data und SourcePath in der XML-Vorschau - Seitenleiste: Speichern-Button + Hinweis bei ungespeicherten Aenderungen; Rueckfrage vor Laden einer anderen Blaupause, Beenden und Schliessen Co-Authored-By: Claude Opus 5.5 <[email protected]>
809 lines
52 KiB
JavaScript
809 lines
52 KiB
JavaScript
'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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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('pulse', 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();
|
||
})();
|