KDS Install Agent: Grundversion

PowerShell-HTTP-Server mit Web-/Desktop-Oberflaeche: Programme (winget/Store),
Office-ODT-Konfigurator, Desktop-Icons, System/Sicherheit, Energie, BitLocker,
Windows-/Treiber-Updates, Profile und Job-Queue.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-09 15:48:11 +02:00
co-authored by Claude Opus 5.5
commit f4c2a16612
15 changed files with 2060 additions and 0 deletions
+411
View File
@@ -0,0 +1,411 @@
'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 = () => ({
name: '',
apps: [],
office: {
product: 'O365ProPlusRetail', channel: 'Current', arch: '64', languages: ['de-de'],
exclude: ['Groove', 'Lync', 'Bing'], extraProducts: [], pidKey: '', company: '',
updates: true, removeMsi: true, migrateArch: true, sharedComputer: false, disableVba: true, ooxmlDefault: true, showUi: false
},
officeMode: 'online',
shortcuts: [],
system: { computerName: '', timeZone: '', showExtensions: true, disableConsumer: true },
security: { defender: true, firewall: true, smartScreen: true, uac: false, disableSmb1: true, disableAutorun: true, lsaProtection: false, telemetryMin: false, rdp: 'keep', localAdminName: '', localAdminPassword: '' },
power: { plan: 'balanced', monitorAc: 15, monitorDc: 5, sleepAc: 0, sleepDc: 15, hibernateAc: 0, hibernateDc: 60, lidAc: 'nothing', lidDc: 'sleep', buttonAc: 'shutdown', buttonDc: 'shutdown', hibernate: '', fastStartup: 'off' },
bitlocker: { method: 'XtsAes256', usedSpaceOnly: true, skipHardwareTest: true, usePin: false, pin: '', backupAad: false, backupAd: false },
steps: ['system', 'security', 'power', 'apps', 'office', 'shortcuts', 'updates']
});
let S = DEFAULTS();
try { const saved = JSON.parse(localStorage.getItem('kdsState') || 'null'); if (saved) S = deepMerge(DEFAULTS(), saved); } catch { }
function deepMerge(base, add) {
for (const k of Object.keys(add || {})) {
if (add[k] && typeof add[k] === 'object' && !Array.isArray(add[k]) && base[k] && typeof base[k] === 'object') deepMerge(base[k], add[k]);
else base[k] = add[k];
}
return base;
}
const getPath = p => p.split('.').reduce((o, k) => o?.[k], S);
function setPath(p, v) { const ks = p.split('.'); const last = ks.pop(); ks.reduce((o, k) => o[k] ??= {}, S)[last] = v; }
function persist() { try { localStorage.setItem('kdsState', JSON.stringify(S)); } catch { } renderSummary(); }
// generische Formularbindung: data-bind="pfad" und data-bind-list="pfad" (Checkbox-Gruppen)
function bindForms() {
$$('[data-bind]').forEach(el => {
el.addEventListener(el.type === 'checkbox' ? 'change' : 'input', () => {
let v = el.type === 'checkbox' ? el.checked : el.value;
if (el.type === 'number') v = el.value === '' ? '' : Number(el.value);
setPath(el.dataset.bind, v); persist(); onChange(el.dataset.bind);
});
if (el.tagName === 'SELECT') el.addEventListener('change', () => el.dispatchEvent(new Event('input')));
});
$$('[data-bind-list]').forEach(box => box.addEventListener('change', () => {
setPath(box.dataset.bindList, $$('input:checked', box).map(i => i.value)); persist(); onChange(box.dataset.bindList);
}));
}
function fillForms() {
$$('[data-bind]').forEach(el => {
const v = getPath(el.dataset.bind);
if (el.type === 'checkbox') el.checked = !!v; else el.value = v ?? '';
});
$$('[data-bind-list]').forEach(box => {
const v = arr(getPath(box.dataset.bindList));
$$('input', box).forEach(i => i.checked = v.includes(i.value));
});
$$('#steps input').forEach(i => i.checked = arr(S.steps).includes(i.value));
$('#profileName').textContent = 'Profil: ' + (S.name || '(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();
}
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, 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`, `Energieplan: <b>${esc(S.power.plan || 'unverändert')}</b>`,
S.system.computerName ? `Neuer Name: <b>${esc(S.system.computerName)}</b>` : ''
].filter(Boolean).join(' · ');
}
$('#steps').onchange = () => { S.steps = $$('#steps input:checked').map(i => i.value); persist(); };
$('#btnRunAll').onclick = () => guard(async () => {
if (!S.steps.length) return toast('Keine Schritte ausgewählt', true);
if (S.steps.includes('bitlocker')) S.bitlocker.action = 'enable';
if (!confirm(`Einrichtung mit ${S.steps.length} Schritten jetzt starten?`)) return;
const r = await api('POST', '/api/run-profile', S); jobStarted(r.job);
});
// ---------------------------------------------------------------- Programme
let catalog = [];
async function loadCatalog() {
catalog = arr((await api('GET', '/api/catalog'))?.apps);
const groups = {};
catalog.forEach(a => (groups[a.category] ??= []).push(a));
$('#catalog').innerHTML = Object.entries(groups).map(([c, list]) => `<div class="cat"><h3>${esc(c)}</h3><div class="checks">${list.map(a =>
`<label><input type="checkbox" data-app="${esc(a.id)}"> ${esc(a.name)}</label>`).join('')}</div></div>`).join('');
syncCatalogChecks();
const local = arr(await api('GET', '/api/local-installers'));
$('#localFile').innerHTML = local.length ? local.map(f => `<option>${esc(f)}</option>`).join('') : '<option value="">(keine Dateien in installers\\)</option>';
}
$('#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();
}
$('#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(); };
$('#btnAddLocal').onclick = () => {
const f = $('#localFile').value; if (!f) return;
addApp({ name: f.split('\\').pop(), id: 'local:' + f, source: 'local', path: f, args: $('#localArgs').value });
};
$('#btnInstallApps').onclick = () => guard(async () => {
if (!S.apps.length) return toast('Keine Programme ausgewählt', true);
jobStarted((await api('POST', '/api/apps/install', { apps: S.apps })).job);
});
$('#btnUpgradeAll').onclick = $('#btnWingetUpg').onclick = () => guard(async () => jobStarted((await api('POST', '/api/apps/upgrade-all', {})).job));
// ---------------------------------------------------------------- Office
const PRODUCTS = [
{ id: 'O365ProPlusRetail', name: 'Microsoft 365 Apps for enterprise', channels: ['Current', 'MonthlyEnterprise', 'SemiAnnual', 'SemiAnnualPreview', 'BetaChannel'], extras: ['VisioProRetail', 'ProjectProRetail'] },
{ id: 'O365BusinessRetail', name: 'Microsoft 365 Apps for business', channels: ['Current', 'MonthlyEnterprise', 'SemiAnnual'], extras: ['VisioProRetail', 'ProjectProRetail'] },
{ id: 'ProPlus2024Volume', name: 'Office LTSC Professional Plus 2024 (Volumenlizenz)', channels: ['PerpetualVL2024'], extras: ['VisioPro2024Volume', 'ProjectPro2024Volume'] },
{ id: 'Standard2024Volume', name: 'Office LTSC Standard 2024 (Volumenlizenz)', channels: ['PerpetualVL2024'], extras: ['VisioStd2024Volume', 'ProjectStd2024Volume'] },
{ id: 'ProPlus2021Volume', name: 'Office LTSC Professional Plus 2021 (Volumenlizenz)', channels: ['PerpetualVL2021'], extras: ['VisioPro2021Volume', 'ProjectPro2021Volume'] },
{ id: 'HomeBusiness2024Retail', name: 'Office Home & Business 2024', channels: ['Current'], extras: [] },
{ id: 'Home2024Retail', name: 'Office Home 2024', channels: ['Current'], extras: [] }
];
const CHANNEL_NAMES = { Current: 'Aktueller Kanal', MonthlyEnterprise: 'Monatlicher Enterprise-Kanal', SemiAnnual: 'Halbjährlicher Enterprise-Kanal', SemiAnnualPreview: 'Halbjährlich (Vorschau)', BetaChannel: 'Beta', PerpetualVL2024: 'LTSC 2024', PerpetualVL2021: 'LTSC 2021' };
const OFFICE_APPS = [['Word', 'Word'], ['Excel', 'Excel'], ['PowerPoint', 'PowerPoint'], ['Outlook', 'Outlook (klassisch)'], ['OutlookForWindows', 'Outlook (neu)'], ['OneNote', 'OneNote'],
['Access', 'Access'], ['Publisher', 'Publisher'], ['Teams', 'Teams'], ['OneDrive', 'OneDrive'], ['Lync', 'Skype for Business'], ['Groove', 'OneDrive for Business (alt)'], ['Bing', 'Bing-Suche in Chrome']];
function renderOffice() {
$('#officeProduct').innerHTML = PRODUCTS.map(p => `<option value="${p.id}">${esc(p.name)}</option>`).join('');
$('#officeApps').innerHTML = OFFICE_APPS.map(([id, n]) => `<label><input type="checkbox" value="${id}"> ${n}</label>`).join('');
$('#officeProduct').value = S.office.product;
renderOfficeChannels(); renderOfficeExtras(); renderOfficeApps(); updateOfficeXml();
}
function renderOfficeChannels() {
const p = PRODUCTS.find(x => x.id === S.office.product) || PRODUCTS[0];
$('#officeChannel').innerHTML = p.channels.map(c => `<option value="${c}">${CHANNEL_NAMES[c] || c}</option>`).join('');
if (!p.channels.includes(S.office.channel)) S.office.channel = p.channels[0];
$('#officeChannel').value = S.office.channel;
S.office.extraProducts = arr(S.office.extraProducts).filter(e => p.extras.includes(e));
}
function renderOfficeExtras() {
const p = PRODUCTS.find(x => x.id === S.office.product) || PRODUCTS[0];
const label = e => `${e.startsWith('Visio') ? 'Visio' : 'Project'} ${/Std/.test(e) ? 'Standard' : 'Professional'} ${(e.match(/\d{4}/) || [''])[0]}`;
$('#officeExtras').innerHTML = p.extras.length ? p.extras.map(e => `<label><input type="checkbox" value="${e}" ${arr(S.office.extraProducts).includes(e) ? 'checked' : ''}> ${label(e)}</label>`).join('') : '<span class="muted small">keine für dieses Produkt</span>';
}
function renderOfficeApps() { $$('#officeApps input').forEach(i => i.checked = !arr(S.office.exclude).includes(i.value)); }
$('#officeApps').onchange = () => { S.office.exclude = $$('#officeApps input:not(:checked)').map(i => i.value); persist(); updateOfficeXml(); };
$('#officeExtras').onchange = () => { S.office.extraProducts = $$('#officeExtras input:checked').map(i => i.value); persist(); updateOfficeXml(); };
let xmlTimer;
function updateOfficeXml() {
clearTimeout(xmlTimer);
xmlTimer = setTimeout(() => guard(async () => { $('#officeXml').textContent = await api('POST', '/api/office/xml', S.office, true); }), 250);
}
$('#btnXmlDownload').onclick = () => download('configuration.xml', $('#officeXml').textContent, 'application/xml');
$('#btnXmlCopy').onclick = () => navigator.clipboard.writeText($('#officeXml').textContent).then(() => toast('XML kopiert'));
$('#btnOffice').onclick = () => guard(async () => {
jobStarted((await api('POST', '/api/office/install', { office: S.office, mode: S.officeMode, name: S.name })).job);
});
async function loadOfficeBuilds() {
const b = arr(await api('GET', '/api/office/configs').catch(() => []));
$('#officeBuilds').innerHTML = b.length ? 'Vorhandene Builds auf dem Stick: ' + b.map(esc).join(', ') : 'Noch kein Offline-Build auf dem Stick.';
}
// ---------------------------------------------------------------- Desktop-Icons
let presets = [];
function scFromForm() {
return { type: $('#scType').value, name: $('#scName').value.trim(), target: $('#scTarget').value.trim(), args: $('#scArgs').value, icon: $('#scIcon').value.trim() };
}
$('#scType').onchange = () => $$('.lnkonly').forEach(e => e.classList.toggle('hidden', $('#scType').value === 'url'));
$('#btnScCreate').onclick = () => guard(async () => {
const s = scFromForm(); if (!s.name || !s.target) return toast('Name und Ziel angeben', true);
await api('POST', '/api/shortcuts', s); toast('Verknüpfung angelegt'); loadShortcuts();
});
$('#btnScPlan').onclick = () => {
const s = scFromForm(); if (!s.name || !s.target) return toast('Name und Ziel angeben', true);
S.shortcuts = arr(S.shortcuts).filter(x => x.name !== s.name).concat(s); persist(); renderPlanned();
};
function renderPlanned() {
const l = arr(S.shortcuts);
$('#scPlanned').innerHTML = l.length ? l.map((s, i) => `<div class="item"><div class="grow"><div>${esc(s.name)}</div><div class="sub">${esc(s.target)}</div></div>
<button class="small ghost" data-i="${i}">✕</button></div>`).join('') : '<p class="muted">Keine geplant.</p>';
}
$('#scPlanned').onclick = e => { const i = e.target.dataset.i; if (i != null) { S.shortcuts.splice(i, 1); persist(); renderPlanned(); } };
$('#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('');
}
$('#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', {})); };
// ---------------------------------------------------------------- Profile
async function loadProfiles() {
const l = arr(await api('GET', '/api/profiles'));
$('#profileList').innerHTML = l.length ? l.map(n => `<div class="item"><div class="grow">${esc(n)}</div>
<button class="small primary" data-load="${esc(n)}">Laden</button><button class="small danger" data-pdel="${esc(n)}">✕</button></div>`).join('') : '<p class="muted">Keine Profile gespeichert.</p>';
}
$('#profileList').onclick = e => guard(async () => {
const { load, pdel } = e.target.dataset;
if (load) { loadState(await api('GET', '/api/profiles/' + encodeURIComponent(load))); toast(`Profil "${load}" geladen`); }
if (pdel && confirm(`Profil "${pdel}" löschen?`)) { await api('DELETE', '/api/profiles/' + encodeURIComponent(pdel)); loadProfiles(); }
});
$('#btnProfileSave').onclick = () => guard(async () => {
const n = $('#profileSaveName').value.trim().replace(/[^\w\-. ]/g, '_');
if (!n) return toast('Namen angeben', true);
S.name = n; persist();
await api('POST', '/api/profiles/' + encodeURIComponent(n), JSON.stringify(S, null, 2));
toast(`Profil "${n}" gespeichert`); fillForms(); loadProfiles();
});
$('#btnExport').onclick = () => download((S.name || 'profil') + '.json', JSON.stringify(S, null, 2), 'application/json');
$('#importFile').onchange = async e => { const f = e.target.files[0]; if (f) guard(async () => { loadState(JSON.parse(await f.text())); toast('Profil importiert'); }); e.target.value = ''; };
$('#btnReset').onclick = () => { if (confirm('Alle Einstellungen auf Standard zurücksetzen?')) loadState(DEFAULTS()); };
function download(name, text, type) {
const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], { type })); a.download = name; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
// ---------------------------------------------------------------- Aufgaben
let selJob = null, jobTimer = null, knownStates = {};
const stateClass = s => s === 'Laeuft' ? 'run' : s === 'Fertig' ? 'ok' : s.includes('Fehler') ? 'warn' : /Fehlg|Abgeb/.test(s) ? 'err' : '';
const stateName = s => s === 'Laeuft' ? 'Läuft' : s;
function jobStarted(id, quiet) {
if (!quiet) toast(`Aufgabe ${id} gestartet – zum Protokoll klicken`, false, () => { selJob = id; showTab('jobs'); });
selJob ??= id; pollJobs();
}
async function pollJobs() {
clearTimeout(jobTimer);
try {
const jobs = arr(await api('GET', '/api/jobs')).reverse();
const active = jobs.filter(j => j.state === 'Laeuft' || j.state === 'Wartet').length;
$('#jobBadge').textContent = active; $('#jobBadge').classList.toggle('hidden', !active);
for (const j of jobs) {
if (knownStates[j.id] && knownStates[j.id] !== j.state && !/Laeuft|Wartet/.test(j.state))
toast(`${j.title}: ${j.state}`, /Fehl|Abgeb/.test(j.state), () => { selJob = j.id; showTab('jobs'); });
knownStates[j.id] = j.state;
}
$('#jobList').innerHTML = jobs.length ? jobs.map(j => `<div class="item click ${j.id === selJob ? 'sel' : ''}" data-j="${j.id}">
<div class="grow"><div>${esc(j.title)}</div><div class="sub">#${j.id} · ${j.created}</div></div><span class="state ${stateClass(j.state)}">${esc(stateName(j.state))}</span></div>`).join('')
: '<p class="muted">Noch keine Aufgaben.</p>';
if (selJob && $('#tab-jobs').classList.contains('active')) {
const j = await api('GET', '/api/jobs/' + selJob);
$('#jobTitle').textContent = `#${j.id} ${j.title} – ${stateName(j.state)}`;
const pre = $('#jobLog'), atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 30;
pre.innerHTML = esc(j.log || '').replace(/^.*\[(ERROR|WARN|OK)\].*$/gm, (l, lv) => `<span class="${lv}">${l}</span>`);
if (atBottom) pre.scrollTop = pre.scrollHeight;
$('#btnJobStop').classList.toggle('hidden', !/Laeuft|Wartet/.test(j.state));
}
jobTimer = setTimeout(pollJobs, active ? 1500 : 5000);
} catch { jobTimer = setTimeout(pollJobs, 5000); }
}
$('#jobList').onclick = e => { const it = e.target.closest('[data-j]'); if (it) { selJob = it.dataset.j; pollJobs(); } };
$('#btnJobStop').onclick = () => { if (selJob && confirm('Aufgabe abbrechen?')) guard(async () => { await api('DELETE', '/api/jobs/' + selJob); pollJobs(); }); };
async function waitJob(id) {
for (; ;) {
await new Promise(r => setTimeout(r, 2000));
const j = await api('GET', '/api/jobs/' + id);
if (!/Laeuft|Wartet/.test(j.state)) return j;
}
}
$('#btnShutdown').onclick = () => { if (confirm('Agent beenden? Laufende Aufgaben werden abgebrochen.')) api('POST', '/api/shutdown', {}).finally(() => { document.body.innerHTML = '<main><h1>Agent beendet.</h1><p>Das Fenster kann geschlossen werden.</p></main>'; }); };
// ---------------------------------------------------------------- Start
function renderAll() { renderOffice(); renderSelectedApps(); renderPlanned(); renderSummary(); }
(async () => {
if (!TOKEN) { document.body.innerHTML = '<main><h1>Kein Zugriffstoken</h1><p>Bitte die vollständige URL (mit ?token=…) aus dem Agent-Fenster öffnen.</p></main>'; return; }
bindForms(); renderAll(); fillForms();
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();
});
if (location.hash) showTab(location.hash.slice(1));
pollJobs();
})();
+350
View File
@@ -0,0 +1,350 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KDS Install Agent</title>
<link rel="stylesheet" href="style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='%230a64d6'/><path d='M4 8l3 3 5-6' stroke='white' stroke-width='2' fill='none'/></svg>">
</head>
<body>
<aside class="side">
<div class="brand">KDS <span>Install Agent</span></div>
<nav id="nav">
<a data-tab="overview" class="active">Übersicht</a>
<a data-tab="apps">Programme</a>
<a data-tab="office">Office</a>
<a data-tab="shortcuts">Desktop-Icons</a>
<a data-tab="system">System &amp; Sicherheit</a>
<a data-tab="power">Energie</a>
<a data-tab="bitlocker">BitLocker</a>
<a data-tab="updates">Updates &amp; Treiber</a>
<a data-tab="profiles">Profile</a>
<a data-tab="jobs">Aufgaben <b id="jobBadge" class="badge hidden">0</b></a>
</nav>
<div class="side-foot">
<div id="profileName" class="muted">Profil: (ungespeichert)</div>
<button class="ghost" id="btnShutdown">Agent beenden</button>
</div>
</aside>
<main>
<!-- Übersicht -->
<section id="tab-overview" class="tab active">
<h1>Übersicht</h1>
<div id="sysinfo" class="grid4"></div>
<div class="card">
<h2>Komplette Einrichtung mit einem Klick</h2>
<p class="muted">Führt die Einstellungen aus allen Tabs in sinnvoller Reihenfolge aus. Vorher in den Tabs konfigurieren oder ein Profil laden.</p>
<div class="checks" id="steps">
<label><input type="checkbox" value="system" checked> System</label>
<label><input type="checkbox" value="security" checked> Sicherheit</label>
<label><input type="checkbox" value="power" checked> Energie</label>
<label><input type="checkbox" value="apps" checked> Programme</label>
<label><input type="checkbox" value="office" checked> Office</label>
<label><input type="checkbox" value="shortcuts" checked> Desktop-Icons</label>
<label><input type="checkbox" value="drivers"> Hersteller-Treiber</label>
<label><input type="checkbox" value="updates" checked> Windows Update</label>
<label><input type="checkbox" value="bitlocker"> BitLocker</label>
</div>
<div id="summary" class="summary"></div>
<button class="primary big" id="btnRunAll">▶ Einrichtung starten</button>
</div>
</section>
<!-- Programme -->
<section id="tab-apps" class="tab">
<h1>Programme</h1>
<div class="cols">
<div>
<div class="card">
<h2>Suche in öffentlichen Bibliotheken</h2>
<div class="row">
<input id="appSearch" placeholder="z.B. firefox, acrobat, teamviewer …">
<select id="appSource"><option value="winget">winget (Community-Repo)</option><option value="msstore">Microsoft Store</option></select>
<button id="btnSearch">Suchen</button>
</div>
<div id="searchResults" class="list"></div>
</div>
<div class="card">
<h2>Schnellauswahl</h2>
<div id="catalog"></div>
</div>
<div class="card">
<h2>Eigene Installer vom Stick <span class="muted small">(Ordner <code>installers\</code>)</span></h2>
<div class="row">
<select id="localFile"></select>
<input id="localArgs" placeholder="Silent-Parameter, z.B. /S oder /quiet">
<button id="btnAddLocal">Hinzufügen</button>
</div>
</div>
</div>
<div>
<div class="card sticky">
<h2>Ausgewählt <span id="appCount" class="badge">0</span></h2>
<div id="selectedApps" class="list"></div>
<button class="primary" id="btnInstallApps">Jetzt installieren</button>
<button id="btnUpgradeAll">Alle installierten Programme aktualisieren</button>
</div>
</div>
</div>
</section>
<!-- Office -->
<section id="tab-office" class="tab">
<h1>Microsoft Office</h1>
<div class="cols">
<div class="card">
<h2>Konfiguration</h2>
<label class="f">Produkt<select id="officeProduct" data-bind="office.product"></select></label>
<label class="f">Update-Kanal<select id="officeChannel" data-bind="office.channel"></select></label>
<label class="f">Architektur
<select data-bind="office.arch"><option value="64">64-Bit (empfohlen)</option><option value="32">32-Bit</option></select></label>
<label class="f">Lizenzschlüssel (nur Volumenlizenz/MAK)<input data-bind="office.pidKey" placeholder="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"></label>
<label class="f">Firmenname<input data-bind="office.company" placeholder="optional"></label>
<h3>Sprachen</h3>
<div class="checks" data-bind-list="office.languages">
<label><input type="checkbox" value="de-de"> Deutsch</label>
<label><input type="checkbox" value="en-us"> Englisch</label>
<label><input type="checkbox" value="fr-fr"> Französisch</label>
<label><input type="checkbox" value="it-it"> Italienisch</label>
<label><input type="checkbox" value="es-es"> Spanisch</label>
<label><input type="checkbox" value="nl-nl"> Niederländisch</label>
<label><input type="checkbox" value="pl-pl"> Polnisch</label>
<label><input type="checkbox" value="tr-tr"> Türkisch</label>
<label><input type="checkbox" value="MatchOS"> Wie Windows</label>
</div>
<h3>Apps <span class="muted small">(abgewählte werden nicht installiert)</span></h3>
<div class="checks" id="officeApps"></div>
<h3>Zusatzprodukte</h3>
<div class="checks" id="officeExtras"></div>
<h3>Optionen</h3>
<div class="checks col">
<label><input type="checkbox" data-bind="office.updates"> Automatische Updates</label>
<label><input type="checkbox" data-bind="office.removeMsi"> Alte MSI-Office-Versionen entfernen</label>
<label><input type="checkbox" data-bind="office.migrateArch"> Architektur einer vorhandenen Installation migrieren</label>
<label><input type="checkbox" data-bind="office.sharedComputer"> Aktivierung für gemeinsam genutzte Computer (RDS)</label>
<label><input type="checkbox" data-bind="office.disableVba"> Makros aus dem Internet blockieren</label>
<label><input type="checkbox" data-bind="office.ooxmlDefault"> Erststart-Dialog unterdrücken</label>
<label><input type="checkbox" data-bind="office.showUi"> Installationsfortschritt anzeigen</label>
</div>
</div>
<div>
<div class="card">
<h2>Generierte configuration.xml</h2>
<pre id="officeXml" class="code"></pre>
<div class="row">
<button id="btnXmlDownload">XML herunterladen</button>
<button id="btnXmlCopy">Kopieren</button>
</div>
</div>
<div class="card">
<h2>Ausführen</h2>
<label class="f">Modus
<select data-bind="officeMode">
<option value="online">Online installieren (direkt von Microsoft)</option>
<option value="download">Nur Build erstellen (Dateien auf den Stick laden)</option>
<option value="offline">Vom Stick installieren (Build wird bei Bedarf erstellt)</option>
</select></label>
<p class="muted small">setup.exe des Office Deployment Tools wird bei Bedarf automatisch von Microsoft geladen und unter <code>office\</code> abgelegt. Builds liegen in <code>office\source\</code>.</p>
<div id="officeBuilds" class="muted small"></div>
<button class="primary" id="btnOffice">Ausführen</button>
</div>
</div>
</div>
</section>
<!-- Desktop-Icons -->
<section id="tab-shortcuts" class="tab">
<h1>Desktop-Icons für alle Benutzer</h1>
<div class="cols">
<div>
<div class="card">
<h2>Neue Verknüpfung</h2>
<label class="f">Typ<select id="scType"><option value="url">Webseite (URL)</option><option value="lnk">Programm / Datei / Ordner</option></select></label>
<label class="f">Name<input id="scName" placeholder="z.B. Intranet"></label>
<label class="f">Ziel<input id="scTarget" placeholder="https://… oder %ProgramFiles%\…\app.exe"></label>
<label class="f lnkonly">Argumente<input id="scArgs"></label>
<label class="f">Icon <span class="muted small">(Ordner <code>icons\</code> auf dem Stick oder Pfad)</span>
<input id="scIcon" list="iconList" placeholder="optional"></label>
<datalist id="iconList"></datalist>
<div class="row">
<button class="primary" id="btnScCreate">Sofort anlegen</button>
<button id="btnScPlan">Zum Profil hinzufügen</button>
</div>
</div>
<div class="card">
<h2>Vorlagen</h2>
<div id="scPresets" class="chips"></div>
</div>
</div>
<div>
<div class="card">
<h2>Im Profil geplant</h2>
<div id="scPlanned" class="list"></div>
<button class="primary" id="btnScApplyAll">Alle geplanten jetzt anlegen</button>
</div>
<div class="card">
<h2>Aktuell auf dem öffentlichen Desktop <button class="small ghost" id="btnScRefresh">↻</button></h2>
<div id="scCurrent" class="list"></div>
</div>
</div>
</div>
</section>
<!-- System & Sicherheit -->
<section id="tab-system" class="tab">
<h1>System &amp; Sicherheit</h1>
<div class="cols">
<div class="card">
<h2>System</h2>
<label class="f">Computername <span class="muted small">(leer = unverändert, max. 15 Zeichen)</span><input data-bind="system.computerName" maxlength="15"></label>
<label class="f">Zeitzone<select id="tzSelect" data-bind="system.timeZone"><option value="">unverändert</option></select></label>
<div class="checks col">
<label><input type="checkbox" data-bind="system.showExtensions"> Dateiendungen anzeigen (auch für neue Benutzer)</label>
<label><input type="checkbox" data-bind="system.disableConsumer"> Werbe-Apps / Consumer-Features deaktivieren</label>
</div>
<button class="primary" data-run="system">Systemeinstellungen anwenden</button>
</div>
<div class="card">
<h2>Sicherheit</h2>
<div class="checks col">
<label><input type="checkbox" data-bind="security.defender"> Defender: Echtzeit-, Cloud- &amp; PUA-Schutz</label>
<label><input type="checkbox" data-bind="security.firewall"> Firewall für alle Profile aktivieren</label>
<label><input type="checkbox" data-bind="security.smartScreen"> SmartScreen aktivieren</label>
<label><input type="checkbox" data-bind="security.uac"> UAC auf höchste Stufe</label>
<label><input type="checkbox" data-bind="security.disableSmb1"> SMBv1 deaktivieren</label>
<label><input type="checkbox" data-bind="security.disableAutorun"> Autorun / Autoplay deaktivieren</label>
<label><input type="checkbox" data-bind="security.lsaProtection"> LSA-Schutz (Credential-Diebstahl erschweren)</label>
<label><input type="checkbox" data-bind="security.telemetryMin"> Telemetrie minimieren</label>
</div>
<label class="f">Remotedesktop
<select data-bind="security.rdp"><option value="keep">unverändert</option><option value="enable">aktivieren (mit NLA)</option><option value="disable">deaktivieren</option></select></label>
<h3>Lokales Admin-Konto <span class="muted small">(optional)</span></h3>
<div class="row">
<input data-bind="security.localAdminName" placeholder="Benutzername">
<input data-bind="security.localAdminPassword" type="password" placeholder="Kennwort" autocomplete="new-password">
</div>
<p class="muted small">Hinweis: Das Kennwort wird beim Speichern im Profil im Klartext abgelegt.</p>
<button class="primary" data-run="security">Sicherheitseinstellungen anwenden</button>
</div>
</div>
</section>
<!-- Energie -->
<section id="tab-power" class="tab">
<h1>Energie</h1>
<div class="card">
<div id="powerState" class="muted small"></div>
<label class="f">Energieplan
<select data-bind="power.plan"><option value="">unverändert</option><option value="balanced">Ausbalanciert</option><option value="high">Höchstleistung</option><option value="ultimate">Ultimative Leistung</option></select></label>
<table class="ptable">
<tr><th></th><th>Netzbetrieb 🔌</th><th>Akku 🔋</th></tr>
<tr><td>Bildschirm aus nach (Min., 0 = nie)</td><td><input type="number" min="0" data-bind="power.monitorAc"></td><td><input type="number" min="0" data-bind="power.monitorDc"></td></tr>
<tr><td>Energiesparmodus nach</td><td><input type="number" min="0" data-bind="power.sleepAc"></td><td><input type="number" min="0" data-bind="power.sleepDc"></td></tr>
<tr><td>Ruhezustand nach</td><td><input type="number" min="0" data-bind="power.hibernateAc"></td><td><input type="number" min="0" data-bind="power.hibernateDc"></td></tr>
<tr><td>Deckel schließen</td><td><select data-bind="power.lidAc" class="act"></select></td><td><select data-bind="power.lidDc" class="act"></select></td></tr>
<tr><td>Netzschalter</td><td><select data-bind="power.buttonAc" class="act"></select></td><td><select data-bind="power.buttonDc" class="act"></select></td></tr>
</table>
<div class="row">
<label class="f">Ruhezustand<select data-bind="power.hibernate"><option value="">unverändert</option><option value="on">ein</option><option value="off">aus</option></select></label>
<label class="f">Schnellstart<select data-bind="power.fastStartup"><option value="">unverändert</option><option value="on">ein</option><option value="off">aus (empfohlen für Updates)</option></select></label>
</div>
<button class="primary" data-run="power">Energieeinstellungen anwenden</button>
</div>
</section>
<!-- BitLocker -->
<section id="tab-bitlocker" class="tab">
<h1>BitLocker</h1>
<div class="cols">
<div class="card">
<h2>Status <button class="small ghost" id="btnBlRefresh">↻</button></h2>
<div id="blStatus">Lade …</div>
</div>
<div class="card">
<h2>Aktivieren</h2>
<label class="f">Verschlüsselung<select data-bind="bitlocker.method"><option value="XtsAes256">XTS-AES 256 (empfohlen)</option><option value="XtsAes128">XTS-AES 128</option></select></label>
<div class="checks col">
<label><input type="checkbox" data-bind="bitlocker.usedSpaceOnly"> Nur belegten Speicher verschlüsseln (schnell, für neue Geräte)</label>
<label><input type="checkbox" data-bind="bitlocker.skipHardwareTest"> Hardwaretest überspringen (sofort starten)</label>
<label><input type="checkbox" data-bind="bitlocker.usePin"> Start-PIN zusätzlich zum TPM</label>
<label><input type="checkbox" data-bind="bitlocker.backupAad"> Schlüssel in Entra ID (Azure AD) sichern</label>
<label><input type="checkbox" data-bind="bitlocker.backupAd"> Schlüssel im Active Directory sichern</label>
</div>
<label class="f pinonly">PIN (6–20 Ziffern)<input data-bind="bitlocker.pin" type="password" inputmode="numeric"></label>
<p class="muted small">Der Wiederherstellungsschlüssel wird immer auf dem Stick unter <code>bitlocker-keys\</code> abgelegt (Computername + Seriennummer).</p>
<div class="row">
<button class="primary" data-bl="enable">BitLocker aktivieren</button>
<button data-bl="savekey">Schlüssel erneut sichern</button>
<button class="danger" data-bl="disable">Deaktivieren</button>
</div>
</div>
</div>
</section>
<!-- Updates -->
<section id="tab-updates" class="tab">
<h1>Updates &amp; Treiber</h1>
<div class="card">
<div class="row wrap">
<button class="primary" data-scan="Software">Windows-Updates suchen</button>
<button class="primary" data-scan="Driver">Treiber-Updates suchen</button>
<button data-scan="all">Alles suchen</button>
<span class="sep"></span>
<button id="btnVendor">Hersteller-Tool (Dell/Lenovo/HP)</button>
<button id="btnWingetUpg">Programme aktualisieren (winget)</button>
<button id="btnWinget">winget reparieren</button>
<span class="sep"></span>
<button class="danger" id="btnReboot">Neustart</button>
</div>
</div>
<div class="card">
<h2>Gefundene Updates <span id="updCount" class="badge">0</span></h2>
<div id="updList" class="list"><p class="muted">Noch keine Suche durchgeführt.</p></div>
<div class="row">
<button class="primary" id="btnUpdSel">Ausgewählte installieren</button>
<button id="btnUpdAll">Alle installieren</button>
</div>
</div>
</section>
<!-- Profile -->
<section id="tab-profiles" class="tab">
<h1>Profile</h1>
<div class="cols">
<div class="card">
<h2>Gespeicherte Profile <span class="muted small">(Ordner <code>profiles\</code>)</span></h2>
<div id="profileList" class="list"></div>
</div>
<div class="card">
<h2>Aktuelle Konfiguration speichern</h2>
<div class="row"><input id="profileSaveName" placeholder="z.B. Buero-Standard"><button class="primary" id="btnProfileSave">Speichern</button></div>
<h3>Import / Export</h3>
<div class="row">
<button id="btnExport">Als JSON exportieren</button>
<label class="button">Importieren<input type="file" id="importFile" accept=".json" hidden></label>
<button class="ghost" id="btnReset">Zurücksetzen</button>
</div>
</div>
</div>
</section>
<!-- Jobs -->
<section id="tab-jobs" class="tab">
<h1>Aufgaben</h1>
<div class="cols jobs">
<div class="card"><div id="jobList" class="list"></div></div>
<div class="card">
<h2 id="jobTitle">Protokoll</h2>
<pre id="jobLog" class="code log"></pre>
<div class="row"><button class="danger hidden" id="btnJobStop">Abbrechen</button></div>
</div>
</div>
</section>
</main>
<div id="toast"></div>
<script src="app.js"></script>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
:root {
--bg: #f3f5f8; --card: #fff; --text: #1b2330; --muted: #677284; --line: #e1e5eb;
--accent: #0a64d6; --accent-2: #e8f1fd; --ok: #138a4b; --warn: #b26a00; --err: #c62828;
--side: #121a27; --side-text: #c7d0dd;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #0f141c; --card: #182030; --text: #e4e9f0; --muted: #8d98a8; --line: #2a3446; --accent: #4c95ff; --accent-2: #1d2c45; --side: #0a0e14; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body { display: flex; font: 14px/1.45 "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif; background: var(--bg); color: var(--text); }
.side { width: 220px; flex: none; background: var(--side); color: var(--side-text); display: flex; flex-direction: column; }
.brand { padding: 20px 18px; font-size: 18px; font-weight: 700; color: #fff; }
.brand span { font-weight: 400; color: var(--side-text); }
nav { flex: 1; overflow: auto; }
nav a { display: flex; justify-content: space-between; align-items: center; padding: 9px 18px; cursor: pointer; border-left: 3px solid transparent; }
nav a:hover { background: rgba(255,255,255,.05); }
nav a.active { background: rgba(255,255,255,.08); border-left-color: var(--accent); color: #fff; }
.side-foot { padding: 14px 18px; display: grid; gap: 8px; }
main { flex: 1; overflow: auto; padding: 24px 28px 60px; }
h1 { margin: 0 0 18px; font-size: 24px; font-weight: 600; }
h2 { margin: 0 0 12px; font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
h3 { margin: 16px 0 6px; font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; }
.tab { display: none; } .tab.active { display: block; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 18px; margin-bottom: 16px; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
.cols.jobs { grid-template-columns: 340px 1fr; }
.grid4 { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 16px; }
.stat { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
.stat .k { font-size: 12px; color: var(--muted); } .stat .v { font-weight: 600; word-break: break-word; }
.sticky { position: sticky; top: 0; }
.muted { color: var(--muted); } .small { font-size: 12px; }
.row { display: flex; gap: 8px; align-items: end; margin-top: 10px; } .row.wrap { flex-wrap: wrap; } .row > input, .row > select { flex: 1; }
.sep { width: 1px; align-self: stretch; background: var(--line); margin: 0 4px; }
label.f { display: block; margin-bottom: 10px; font-size: 12px; color: var(--muted); }
label.f input, label.f select { display: block; width: 100%; margin-top: 3px; }
.row label.f { flex: 1; margin: 0; }
input, select { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 7px 9px; min-width: 0; }
input[type=checkbox] { padding: 0; accent-color: var(--accent); width: 16px; height: 16px; }
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
button, .button { font: inherit; cursor: pointer; border: 1px solid var(--line); background: var(--card); color: var(--text); padding: 7px 14px; border-radius: 6px; display: inline-block; }
button:hover, .button:hover { border-color: var(--accent); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover { filter: brightness(1.1); }
button.danger { color: var(--err); } button.danger:hover { border-color: var(--err); }
button.ghost { background: transparent; color: inherit; }
button.small { padding: 2px 8px; font-size: 12px; }
button.big { font-size: 16px; padding: 12px 26px; margin-top: 14px; }
button:disabled { opacity: .5; cursor: default; }
.card > button { margin-top: 12px; margin-right: 6px; }
.checks { display: flex; flex-wrap: wrap; gap: 6px 16px; } .checks.col { flex-direction: column; }
.checks label { display: flex; gap: 7px; align-items: center; cursor: pointer; }
.cat { margin-bottom: 12px; } .cat h3 { margin-top: 0; }
.list { display: flex; flex-direction: column; max-height: 420px; overflow: auto; }
.item { display: flex; align-items: center; gap: 10px; padding: 8px 4px; border-bottom: 1px solid var(--line); }
.item:last-child { border-bottom: 0; }
.item .grow { flex: 1; min-width: 0; } .item .sub { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item.click { cursor: pointer; } .item.click:hover, .item.sel { background: var(--accent-2); }
.badge { background: var(--accent); color: #fff; border-radius: 10px; padding: 0 7px; font-size: 11px; font-weight: 600; }
.hidden { display: none !important; }
.code { background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 12px; font: 12px/1.5 Consolas, "Cascadia Mono", monospace; white-space: pre-wrap; word-break: break-all; max-height: 460px; overflow: auto; margin: 0; }
.log { min-height: 380px; max-height: calc(100vh - 220px); }
.log .ERROR { color: var(--err); font-weight: 600; } .log .WARN { color: var(--warn); } .log .OK { color: var(--ok); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chips button { border-radius: 16px; padding: 4px 12px; }
.state { font-size: 11px; padding: 1px 8px; border-radius: 10px; background: var(--line); white-space: nowrap; }
.state.run { background: var(--accent); color: #fff; } .state.ok { background: var(--ok); color: #fff; }
.state.warn { background: var(--warn); color: #fff; } .state.err { background: var(--err); color: #fff; }
.ptable { border-collapse: collapse; margin: 6px 0 14px; }
.ptable td, .ptable th { padding: 5px 10px 5px 0; text-align: left; font-weight: 400; }
.ptable th { color: var(--muted); font-size: 12px; } .ptable input, .ptable select { width: 150px; }
.summary { margin-top: 12px; font-size: 13px; color: var(--muted); }
.vol { padding: 10px; border: 1px solid var(--line); border-radius: 8px; margin-bottom: 8px; }
.bar { height: 6px; background: var(--line); border-radius: 3px; overflow: hidden; margin-top: 6px; } .bar i { display: block; height: 100%; background: var(--ok); }
code { font-family: Consolas, monospace; font-size: 12px; }
#toast { position: fixed; right: 20px; bottom: 20px; display: flex; flex-direction: column; gap: 8px; z-index: 9; }
#toast div { background: var(--side); color: #fff; padding: 10px 16px; border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.25); cursor: pointer; max-width: 380px; }
#toast div.err { background: var(--err); }
@media (max-width: 900px) {
body { flex-direction: column; } .side { width: auto; } nav { display: flex; overflow-x: auto; } nav a { white-space: nowrap; border-left: 0; border-bottom: 3px solid transparent; }
nav a.active { border-bottom-color: var(--accent); } .side-foot { display: none; }
main { padding: 16px; } .cols, .cols.jobs { grid-template-columns: 1fr; }
}