Manifestbasierter Production-Updater 3.3, Modulnavigation, optionales Produktbild Studio, Listing-Studio-Aktionsleiste sowie plattformübergreifende Windows-/Linux-Release-Gates.
192 lines
9.2 KiB
JavaScript
192 lines
9.2 KiB
JavaScript
const state = { data: null, filter: '', busy: false };
|
||
|
||
function csrfToken() {
|
||
const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/);
|
||
return match ? decodeURIComponent(match[1]) : '';
|
||
}
|
||
|
||
async function request(method, url, body, { multipart = false } = {}) {
|
||
const headers = { 'X-Vendoo-Request-ID': globalThis.crypto?.randomUUID?.() || `modules-${Date.now()}` };
|
||
const options = { method, credentials: 'same-origin', headers };
|
||
if (!['GET', 'HEAD'].includes(method)) headers['X-CSRF-Token'] = csrfToken();
|
||
if (body !== undefined) {
|
||
if (multipart) options.body = body;
|
||
else { headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(body); }
|
||
}
|
||
const response = await fetch(url, options);
|
||
if (response.status === 401) { location.href = '/login.html'; return null; }
|
||
const type = response.headers.get('content-type') || '';
|
||
const payload = type.includes('json') ? await response.json().catch(() => ({})) : await response.text();
|
||
if (!response.ok) throw new Error(payload?.error || payload?.message || payload || `HTTP ${response.status}`);
|
||
return payload;
|
||
}
|
||
|
||
function el(tag, className, text) {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text !== undefined) node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
function button(label, action, id, { danger = false, disabled = false } = {}) {
|
||
const node = el('button', danger ? 'secondary-btn danger' : 'secondary-btn', label);
|
||
node.type = 'button';
|
||
node.dataset.moduleAction = action;
|
||
node.dataset.moduleId = id;
|
||
node.disabled = disabled;
|
||
return node;
|
||
}
|
||
|
||
function renderSummary(data) {
|
||
const root = document.getElementById('module-manager-summary');
|
||
if (!root) return;
|
||
root.replaceChildren();
|
||
const items = [
|
||
['Module gesamt', data.summary.total], ['Aktiv', data.summary.enabled], ['Deaktiviert', data.summary.disabled],
|
||
['Nativ', data.summary.native], ['Installiert', data.summary.external],
|
||
];
|
||
for (const [label, value] of items) {
|
||
const card = el('article', 'module-summary-card');
|
||
card.append(el('span', '', label), el('strong', '', String(value)));
|
||
root.append(card);
|
||
}
|
||
const safety = document.getElementById('module-manager-safety');
|
||
if (safety) {
|
||
safety.className = `module-safety-card${data.safety.isolatedHostAvailable ? '' : ' is-warning'}`;
|
||
safety.replaceChildren();
|
||
const text = el('div');
|
||
text.append(el('strong', '', 'Sicherheitsmodus'), el('p', '', data.safety.isolatedHostAvailable
|
||
? 'Signierte Fremdmodule laufen ausschließlich im isolierten Extension-Host.'
|
||
: 'Deklarative Module sind aktivierbar. Ausführbarer Fremdcode bleibt bis zum isolierten Extension-Host quarantänisiert.'));
|
||
safety.append(text);
|
||
}
|
||
}
|
||
|
||
function renderModules() {
|
||
const root = document.getElementById('module-manager-list');
|
||
if (!root) return;
|
||
root.replaceChildren();
|
||
const query = state.filter.trim().toLowerCase();
|
||
const modules = (state.data?.modules || []).filter(item => !query || [item.id, item.name, item.description, item.status, item.installSource].join(' ').toLowerCase().includes(query));
|
||
if (!modules.length) { root.append(el('div', 'module-empty', 'Keine passenden Module gefunden.')); return; }
|
||
|
||
for (const item of modules) {
|
||
const card = el('article', `module-card ${item.enabled ? '' : 'is-disabled'} ${item.state === 'invalid' ? 'is-invalid' : ''}`.trim());
|
||
const head = el('header', 'module-card-head');
|
||
const titleWrap = el('div', 'module-card-title');
|
||
titleWrap.append(el('span', `module-state-dot is-${item.state || (item.enabled ? 'running' : 'disabled')}`));
|
||
const title = el('div');
|
||
title.append(el('h4', '', item.name || item.id), el('code', '', item.id));
|
||
titleWrap.append(title);
|
||
const version = el('strong', '', item.version ? `v${item.version}` : '—');
|
||
head.append(titleWrap, version);
|
||
card.append(head);
|
||
|
||
const badges = el('div', 'module-badges');
|
||
badges.append(el('span', 'module-badge', item.enabled ? 'Aktiv' : 'Deaktiviert'));
|
||
badges.append(el('span', 'module-badge', item.status === 'legacy-bridge' ? 'Legacy Bridge' : item.status === 'native' ? 'Nativ' : item.status));
|
||
badges.append(el('span', 'module-badge', item.installSource === 'builtin' ? 'Vendoo' : 'Installiert'));
|
||
if (item.protected) badges.append(el('span', 'module-badge', 'Geschützt'));
|
||
if (item.package?.trust?.signed) badges.append(el('span', 'module-badge', item.package.trust.trusted ? 'Signatur vertraut' : 'Signatur unbekannt'));
|
||
card.append(badges);
|
||
card.append(el('p', 'module-description', item.description || 'Keine Beschreibung vorhanden.'));
|
||
if (item.requires?.length) {
|
||
const deps = el('div', 'module-dependencies');
|
||
deps.append(el('strong', '', 'Benötigt: '), document.createTextNode(item.requires.join(', ')));
|
||
card.append(deps);
|
||
}
|
||
if (item.dependents?.length) {
|
||
const deps = el('div', 'module-dependencies');
|
||
deps.append(el('strong', '', 'Wird benötigt von: '), document.createTextNode(item.dependents.join(', ')));
|
||
card.append(deps);
|
||
}
|
||
if (item.package?.error || item.error) card.append(el('div', 'module-error', item.package?.error || item.error));
|
||
|
||
const actions = el('div', 'module-card-actions');
|
||
if (item.actions?.enable) actions.append(button('Aktivieren', 'enable', item.id));
|
||
if (item.actions?.disable) actions.append(button('Deaktivieren', 'disable', item.id, { danger: true }));
|
||
if (item.actions?.export) {
|
||
const link = el('a', 'secondary-btn', 'Exportieren');
|
||
link.href = `/api/admin/modules/${encodeURIComponent(item.id)}/export`;
|
||
link.download = `${item.id}.vmod`;
|
||
actions.append(link);
|
||
}
|
||
if (item.actions?.remove) actions.append(button('Löschen', 'remove', item.id, { danger: true }));
|
||
if (item.protected && !actions.childNodes.length) actions.append(el('span', 'module-dependencies', 'Kernmodul – Änderungen gesperrt'));
|
||
card.append(actions);
|
||
root.append(card);
|
||
}
|
||
}
|
||
|
||
async function load() {
|
||
const list = document.getElementById('module-manager-list');
|
||
if (!list) return;
|
||
list.replaceChildren(el('div', 'module-empty', 'Module werden geladen …'));
|
||
try {
|
||
state.data = await request('GET', '/api/admin/modules');
|
||
renderSummary(state.data);
|
||
renderModules();
|
||
} catch (error) {
|
||
list.replaceChildren(el('div', 'module-error', error.message));
|
||
}
|
||
}
|
||
|
||
async function perform(action, id) {
|
||
if (state.busy) return;
|
||
state.busy = true;
|
||
try {
|
||
if (action === 'enable') await request('POST', `/api/admin/modules/${encodeURIComponent(id)}/enable`, {});
|
||
if (action === 'disable') {
|
||
const item = state.data?.modules?.find(module => module.id === id);
|
||
const hasDependents = Boolean(item?.dependents?.length);
|
||
const message = hasDependents
|
||
? `Dieses Modul wird von ${item.dependents.join(', ')} benötigt. Abhängige optionale Module ebenfalls deaktivieren?`
|
||
: 'Modul wirklich deaktivieren? Die zugehörigen API-Funktionen stehen danach bis zur Reaktivierung nicht zur Verfügung.';
|
||
if (!confirm(message)) return;
|
||
await request('POST', `/api/admin/modules/${encodeURIComponent(id)}/disable`, { cascade: hasDependents });
|
||
}
|
||
if (action === 'remove') {
|
||
if (!confirm('Installiertes Modul dauerhaft löschen? Die exportierte .vmod-Datei sollte vorher gesichert werden.')) return;
|
||
await request('DELETE', `/api/admin/modules/${encodeURIComponent(id)}`);
|
||
}
|
||
await load();
|
||
window.dispatchEvent(new CustomEvent('vendoo:modules-changed', { detail: { action, id } }));
|
||
globalThis.toast?.(action === 'enable' ? 'Modul aktiviert.' : action === 'disable' ? 'Modul deaktiviert.' : 'Modul gelöscht.');
|
||
} catch (error) {
|
||
globalThis.toast?.(error.message);
|
||
if (!globalThis.toast) alert(error.message);
|
||
} finally { state.busy = false; }
|
||
}
|
||
|
||
async function install() {
|
||
const input = document.getElementById('module-manager-file');
|
||
const file = input?.files?.[0];
|
||
if (!file) { globalThis.toast?.('Bitte zuerst eine .vmod-Datei auswählen.'); return; }
|
||
if (!file.name.toLowerCase().endsWith('.vmod')) { globalThis.toast?.('Nur .vmod-Pakete werden akzeptiert.'); return; }
|
||
const form = new FormData();
|
||
form.append('module', file, file.name);
|
||
try {
|
||
await request('POST', '/api/admin/modules/install', form, { multipart: true });
|
||
input.value = '';
|
||
await load();
|
||
window.dispatchEvent(new CustomEvent('vendoo:modules-changed', { detail: { action: 'install' } }));
|
||
globalThis.toast?.('Modulpaket geprüft und installiert.');
|
||
} catch (error) {
|
||
globalThis.toast?.(error.message);
|
||
if (!globalThis.toast) alert(error.message);
|
||
}
|
||
}
|
||
|
||
function setup() {
|
||
document.getElementById('module-manager-refresh')?.addEventListener('click', load);
|
||
document.getElementById('module-manager-install')?.addEventListener('click', install);
|
||
document.getElementById('module-manager-search')?.addEventListener('input', event => { state.filter = event.target.value; renderModules(); });
|
||
document.getElementById('module-manager-list')?.addEventListener('click', event => {
|
||
const target = event.target.closest('[data-module-action]');
|
||
if (target) perform(target.dataset.moduleAction, target.dataset.moduleId);
|
||
});
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', setup, { once: true });
|
||
globalThis.VendooModuleManager = Object.freeze({ load });
|