/* Graphical module parameters and ordered postinstallation steps. */ 'use strict'; const moduleFormTools = (() => { let sequence = 0; const uid = prefix => `${prefix}-${++sequence}`; const list = value => Array.isArray(value) ? value : value?.items || []; const own = (value, key) => Object.prototype.hasOwnProperty.call(value || {}, key); const copy = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value)); const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); const metadata = value => esc(JSON.stringify(value)); const original = element => JSON.parse(element.dataset.original || '{}'); const fieldValue = (container, name) => [...container.querySelectorAll('[name]')].find(item => item.name === name); const direct = (container, attribute) => [...container.children].find(item => item.hasAttribute(attribute)); const button = (label, action, attributes = '') => ``; const rootIn = (container, selector) => container.matches?.(selector) ? container : container.querySelector(selector); const moduleName = module => `${module.name || 'Modul'} · v${module.version ?? '–'}`; function ensureDataEditors(root) { for (let parent = root.parentElement; parent; parent = parent.parentElement) if (parent.dataset.dataEditorsBound) return; wireDataEditors(root); } function syncHiddenControls(root) { for (const input of root.querySelectorAll('input,select,textarea')) input.disabled = Boolean(input.closest('[hidden]')); } function showError(root, error) { const element = direct(root, 'data-module-form-error'); if (element) { element.textContent = error.message || String(error); element.hidden = false; } } function clearError(root) { const element = direct(root, 'data-module-form-error'); if (element) { element.textContent = ''; element.hidden = true; } } function secretRow(name, value, secrets) { const key = uid('step-secret'); const options = [{value:'', label:'Geheimnis auswählen'}, ...list(secrets).map(secret => ({value:secret.id, label:secret.name || 'Gespeichertes Geheimnis'}))]; if (value && !options.some(item => item.value === value)) options.push({value, label:'Bisheriges Geheimnis (nicht in der Liste verfügbar)'}); return `
${field(`${key}-name`, 'Bezeichnung im Modul', name, {placeholder:'z. B. API_KEY', hint:'Der vom Modul erwartete Parametername.'})}${field(`${key}-value`, 'Gespeichertes Geheimnis', value, {type:'select', options})}
${button('Geheimniszuordnung entfernen', 'remove-secret')}
`; } function stepParameters(key, values, module) { return dataEditor(`${key}-parameters`, values || {}, {label:'Parameter dieses Schritts', schema:module?.parameters_schema || {type:'object'}}); } function stepCard(step, modules, secrets) { const key = uid('postinstall-step'); const published = list(modules).filter(item => item.status === 'published'); const module = published.find(item => item.id === step.module_id); const options = [{value:'', label:'Veröffentlichtes Modul auswählen'}, ...published.map(item => ({value:item.id, label:moduleName(item)}))]; if (step.module_id && !module) options.push({value:step.module_id, label:'Bisherige Modulversion (nicht verfügbar)'}); return `

Schritt

${button('↑ Nach oben', 'step-up')}${button('↓ Nach unten', 'step-down')}${button('Entfernen', 'remove-step')}
${field(`${key}-module`, 'Modul und Version', step.module_id || '', {type:'select', full:true, options})}${field(`${key}-required`, 'Pflichtschritt: Der Lauf ist nur bei erfolgreicher Prüfung abgeschlossen.', step.required !== false, {type:'checkbox', full:true})}
${stepParameters(key, step.parameters || {}, module)}
Geheimnisse für diesen Schritt

Gespeicherte Geheimnisse anhand ihres Namens zuordnen. Die Werte bleiben verborgen.

${Object.entries(step.secret_refs || {}).map(([name, value]) => secretRow(name, value, secrets)).join('')}
${button('Geheimnis zuordnen', 'add-secret')}
`; } function postinstall(profile = {}, modules = [], secrets = []) { return `
${dataEditor('postinstall-values', profile.values || {}, {label:'Gemeinsame Profilparameter', schema:{type:'object'}})}

Module auswählen und in der gewünschten Reihenfolge anordnen. Abhängigkeiten müssen vor dem jeweiligen Modul stehen.

${list(modules).some(item => item.status === 'published') ? '' : '
Es sind noch keine veröffentlichten Module verfügbar. Übernehmen Sie zunächst eine Modulvorlage und veröffentlichen Sie den geprüften Stand.
'}
${list(profile.steps).map(step => stepCard(step, modules, secrets)).join('')}

Fügen Sie den ersten Schritt hinzu. Mindestens ein Schritt muss als Pflichtschritt markiert sein.

${button('Schritt hinzufügen', 'add-step')}
${field('postinstall-reboot-budget', 'Maximale geplante Neustarts', profile.reboot_budget ?? 1, {type:'number', min:0, max:5, full:true})}
`; } function readStep(card, index, modules = null) { const key = card.dataset.stepKey; const saved = original(card); const moduleId = fieldValue(card, `${key}-module`).value; if (!moduleId) throw new Error(`Wählen Sie für Schritt ${index + 1} ein veröffentlichtes Modul aus.`); if (modules && !list(modules).some(item => item.id === moduleId && item.status === 'published')) throw new Error(`Das Modul in Schritt ${index + 1} ist nicht verfügbar. Wählen Sie eine veröffentlichte Modulversion.`); const parameters = readDataEditor(card, `${key}-parameters`); if (!object(parameters)) throw new Error(`Die Parameter in Schritt ${index + 1} müssen eine Gruppe von Feldern sein.`); const secretRefs = {}; for (const row of card.querySelectorAll('[data-step-secret]')) { const name = fieldValue(row, `${row.dataset.secretKey}-name`).value.trim(); const value = fieldValue(row, `${row.dataset.secretKey}-value`).value; if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(name)) throw new Error(`Geben Sie für die Geheimniszuordnung in Schritt ${index + 1} einen gültigen Parameternamen ein (Buchstaben, Zahlen und Unterstrich).`); if (!value) throw new Error(`Wählen Sie für „${name}“ in Schritt ${index + 1} ein gespeichertes Geheimnis aus.`); if (own(secretRefs, name)) throw new Error(`Die Geheimnisbezeichnung „${name}“ ist in Schritt ${index + 1} doppelt vorhanden.`); Object.defineProperty(secretRefs, name, {value, enumerable:true, configurable:true, writable:true}); } return {id:saved.id || card.dataset.stepId || (card.dataset.stepId = `step-${Date.now().toString(36)}-${uid('new')}`), module_id:moduleId, parameters, secret_refs:secretRefs, required:fieldValue(card, `${key}-required`).checked}; } function readPostinstall(container, previous = {}) { const root = rootIn(container, '[data-postinstall-editor]'); if (!root) throw new Error('Der Editor für die Nachkonfiguration wurde nicht gefunden. Öffnen Sie das Formular erneut.'); const steps = [...root.querySelector('[data-postinstall-steps]').children].map((card, index) => readStep(card, index, root._moduleFormsModules)); if (!steps.length) throw new Error('Fügen Sie mindestens einen Schritt zur Nachkonfiguration hinzu.'); if (steps.length > 50) throw new Error('Ein Profil darf höchstens 50 Schritte enthalten.'); if (!steps.some(step => step.required)) throw new Error('Markieren Sie mindestens eine Abschlussprüfung als Pflichtschritt.'); if (new Set(steps.map(step => step.id)).size !== steps.length) throw new Error('Das Profil enthält doppelte Schrittkennungen. Entfernen Sie den doppelten Schritt und fügen Sie ihn neu hinzu.'); const values = readDataEditor(root, 'postinstall-values'); if (!object(values)) throw new Error('Die gemeinsamen Profilparameter müssen eine Gruppe von Feldern sein.'); const input = fieldValue(root, 'postinstall-reboot-budget'); const rebootBudget = input ? Number(input.value) : previous.reboot_budget ?? 1; if (input?.value === '' || !Number.isInteger(rebootBudget) || rebootBudget < 0 || rebootBudget > 5) throw new Error('Wählen Sie für geplante Neustarts eine ganze Zahl zwischen 0 und 5.'); return {values, steps, reboot_budget:rebootBudget}; } function renumberSteps(root) { const steps = [...root.querySelector('[data-postinstall-steps]').children]; steps.forEach((card, index) => { card.querySelector('[data-step-heading]').textContent = `Schritt ${index + 1}`; card.querySelector('[data-module-action="step-up"]').disabled = index === 0; card.querySelector('[data-module-action="step-down"]').disabled = index === steps.length - 1; }); root.querySelector('[data-no-steps]').hidden = steps.length > 0; root.querySelector('[data-module-action="add-step"]').disabled = steps.length >= 50; } function wirePostinstall(container, modules, secrets) { const root = rootIn(container, '[data-postinstall-editor]'); if (!root) return; root._moduleFormsModules = list(modules); root._moduleFormsSecrets = list(secrets); ensureDataEditors(root); renumberSteps(root); if (root.dataset.moduleFormsWired) return; root.dataset.moduleFormsWired = 'true'; root.addEventListener('click', event => { const control = event.target.closest('[data-module-action]'); if (!control || !root.contains(control)) return; event.preventDefault(); event.stopPropagation(); clearError(root); const card = control.closest('[data-postinstall-step]'); const steps = root.querySelector('[data-postinstall-steps]'); try { switch (control.dataset.moduleAction) { case 'add-step': if (steps.children.length < 50) steps.insertAdjacentHTML('beforeend', stepCard({id:`step-${Date.now().toString(36)}-${uid('new')}`, parameters:{}, secret_refs:{}, required:true}, root._moduleFormsModules, root._moduleFormsSecrets)); break; case 'remove-step': card.remove(); break; case 'step-up': if (card.previousElementSibling) steps.insertBefore(card, card.previousElementSibling); break; case 'step-down': if (card.nextElementSibling) steps.insertBefore(card.nextElementSibling, card); break; case 'add-secret': card.querySelector('[data-step-secrets]').insertAdjacentHTML('beforeend', secretRow('', '', root._moduleFormsSecrets)); break; case 'remove-secret': control.closest('[data-step-secret]').remove(); break; } renumberSteps(root); ensureDataEditors(root); } catch (error) { showError(root, error); } }); root.addEventListener('change', event => { const card = event.target.closest('[data-postinstall-step]'); if (!card || event.target.name !== `${card.dataset.stepKey}-module`) return; const holder = card.querySelector('[data-step-parameters]'); const cache = card._moduleParameterCache || (card._moduleParameterCache = new Map()); const previousId = card.dataset.currentModule; const nextId = event.target.value; const module = root._moduleFormsModules.find(item => item.id === nextId); // Keep the actual fields, including incomplete edits, when switching away. // Switching back restores their original DOM rather than recreating values. const fragment = document.createDocumentFragment(); let currentValues = {}; try { currentValues = readDataEditor(card, `${card.dataset.stepKey}-parameters`); } catch (_) { /* Preserve invalid edits in the cached fields. */ } while (holder.firstChild) fragment.append(holder.firstChild); cache.set(previousId, fragment); if (cache.has(nextId)) { holder.append(cache.get(nextId)); cache.delete(nextId); } else { const properties = module?.parameters_schema?.properties || {}; const values = copy(module?.default_parameters || {}); for (const [name, rule] of Object.entries(properties)) { if (own(currentValues, name)) Object.defineProperty(values, name, {value:copy(currentValues[name]), enumerable:true, configurable:true, writable:true}); else if (own(rule, 'default')) Object.defineProperty(values, name, {value:copy(rule.default), enumerable:true, configurable:true, writable:true}); } holder.innerHTML = stepParameters(card.dataset.stepKey, values, module); } card.dataset.currentModule = nextId; ensureDataEditors(root); clearError(root); }); } const types = [{value:'string',label:'Text'}, {value:'integer',label:'Ganze Zahl'}, {value:'number',label:'Zahl'}, {value:'boolean',label:'Ja / Nein'}, {value:'array',label:'Liste'}, {value:'object',label:'Gruppe von Feldern'}]; const typeDefaults = {string:'', integer:0, number:0, boolean:false, array:[], object:{}}; const limits = {string:[['minLength','Mindestlänge'],['maxLength','Höchstlänge']], number:[['minimum','Kleinster Wert'],['maximum','Größter Wert']], integer:[['minimum','Kleinster Wert'],['maximum','Größter Wert']], array:[['minItems','Mindestens so viele Einträge'],['maxItems','Höchstens so viele Einträge']]}; function propertyCard(name, schema, required, depth) { const key = uid('schema-property'); return `
Parameter${button('Parameter entfernen', 'schema-remove-property')}
${field(`${key}-name`, 'Parametername', name, {required:true, placeholder:'z. B. servers'})}${field(`${key}-required`, 'Eingabe erforderlich', required, {type:'checkbox'})}
${schemaNode(schema, depth + 1)}
`; } function schemaBody(schema, type, key, depth) { if (type === 'custom') return `
${dataEditor(`${key}-custom`, schema, {label:'Weitere Parameterregeln grafisch bearbeiten'})}
`; const managed = new Set(['type','title','description','default','enum', ...(limits[type] || []).map(item => item[0])]); if (type === 'object') ['properties','required','additionalProperties'].forEach(name => managed.add(name)); if (type === 'array') ['items','uniqueItems'].forEach(name => managed.add(name)); const extras = Object.fromEntries(Object.entries(schema).filter(([name]) => !managed.has(name))); let specific = ''; if (type === 'object') { const props = schema.properties || {}; const required = new Set(schema.required || []); const additional = !own(schema, 'additionalProperties') ? 'unset' : typeof schema.additionalProperties === 'boolean' ? String(schema.additionalProperties) : 'schema'; specific = `
${Object.entries(props).map(([name, rule]) => propertyCard(name, rule, required.has(name), depth)).join('')}
${button('Parameter hinzufügen', 'schema-add-property')}
${field(`${key}-additional`, 'Weitere Felder erlauben', additional, {type:'select', options:[{value:'unset',label:'Keine zusätzliche Einschränkung'}, {value:'false',label:'Nur die aufgeführten Felder'}, {value:'true',label:'Weitere Felder zulassen'}, {value:'schema',label:'Weitere Felder anhand eigener Regeln'}]})}
${dataEditor(`${key}-additional-schema`, object(schema.additionalProperties) ? schema.additionalProperties : {}, {label:'Regeln für weitere Felder'})}
`; } else if (type === 'array') { specific = `${field(`${key}-has-items`, 'Einträge der Liste definieren', own(schema, 'items'), {type:'checkbox'})}
${schemaNode(schema.items ?? {type:'string'}, depth + 1)}
${field(`${key}-unique`, 'Jeder Eintrag darf nur einmal vorkommen', schema.uniqueItems === true, {type:'checkbox'})}`; } const limitFields = (limits[type] || []).map(([name, label]) => field(`${key}-${name}`, label, schema[name] ?? '', {type:'number', ...(name.startsWith('min') || name.startsWith('max') ? (type === 'array' || type === 'string' ? {min:0} : {}) : {})}).replace('
${field(`${key}-title`, 'Anzeigename (optional)', schema.title || '')}${field(`${key}-description`, 'Beschreibung (optional)', schema.description || '')}${limitFields}
${specific}
${field(`${key}-has-default`, 'Vorgabewert anbieten', own(schema, 'default'), {type:'checkbox'})}
${dataEditor(`${key}-default`, own(schema, 'default') ? schema.default : copy(typeDefaults[type]), {label:'Vorgabewert'})}
${field(`${key}-has-enum`, 'Auswahl auf vorgegebene Werte beschränken', own(schema, 'enum'), {type:'checkbox'})}
${dataEditor(`${key}-enum`, schema.enum || [], {label:'Erlaubte Auswahlwerte', schema:{type:'array'}})}
Weitere Regeln${dataEditor(`${key}-extras`, extras, {label:'Weitere Parameterregeln', schema:{type:'object'}})} `; } function schemaNode(schema = {type:'string'}, depth = 0) { const key = uid('schema-node'); const supported = object(schema) && types.some(item => item.value === schema.type) && depth < 8; const type = supported ? schema.type : 'custom'; const options = [...types, {value:'custom', label:'Flexible / bestehende Regeln'}]; return `
${field(`${key}-type`, 'Werttyp', type, {type:'select', options})}${schemaBody(schema, type, key, depth)}
`; } function readSchemaNode(node, selectedType = null) { const key = node.dataset.schemaKey; const type = selectedType || fieldValue(node, `${key}-type`).value; const body = direct(node, 'data-schema-body'); if (type === 'custom') { const result = readDataEditor(body, `${key}-custom`); if (!object(result) && typeof result !== 'boolean') throw new Error('Parameterregeln müssen eine Gruppe von Feldern oder Ja / Nein sein.'); return result; } const previous = original(body); const extras = readDataEditor(body, `${key}-extras`); if (!object(extras)) throw new Error('Weitere Parameterregeln müssen eine Gruppe von Feldern sein.'); const result = {...extras, type}; for (const name of ['title','description']) { const value = fieldValue(body, `${key}-${name}`).value; if (value || own(previous, name)) result[name] = value; } for (const [name, label] of limits[type] || []) { const raw = fieldValue(body, `${key}-${name}`).value; if (raw !== '') { const value = Number(raw); if (!Number.isFinite(value) || ((type === 'string' || type === 'array') && (!Number.isInteger(value) || value < 0))) throw new Error(`„${label}“ benötigt ${type === 'string' || type === 'array' ? 'eine nicht negative ganze Zahl' : 'eine gültige Zahl'}.`); result[name] = value; } } const pair = limits[type]; if (pair && own(result, pair[0][0]) && own(result, pair[1][0]) && result[pair[0][0]] > result[pair[1][0]]) throw new Error('Der kleinste erlaubte Wert darf nicht größer als der größte erlaubte Wert sein.'); if (type === 'object') { const props = {}; const required = []; const previousProperties = previous.properties || {}; for (const card of direct(body, 'data-schema-properties').children) { const cardKey = card.dataset.propertyKey; const name = fieldValue(card, `${cardKey}-name`).value.trim(); if (!name) throw new Error('Geben Sie jedem Parameter einen Namen oder entfernen Sie die leere Parameterzeile.'); if (own(props, name)) throw new Error(`Der Parametername „${name}“ ist doppelt vorhanden.`); Object.defineProperty(props, name, {value:readSchemaNode(card.querySelector('[data-schema-node]')), enumerable:true, configurable:true, writable:true}); if (fieldValue(card, `${cardKey}-required`).checked) required.push(name); } // Required keys without a property definition are valid schema and remain intact. for (const name of previous.required || []) if (!own(previousProperties, name) && !required.includes(name)) required.push(name); if (Object.keys(props).length || own(previous, 'properties')) result.properties = props; if (required.length || own(previous, 'required')) result.required = required; const additional = fieldValue(body, `${key}-additional`).value; if (additional === 'true' || additional === 'false') result.additionalProperties = additional === 'true'; else if (additional === 'schema') { const rule = readDataEditor(body, `${key}-additional-schema`); if (!object(rule) && typeof rule !== 'boolean') throw new Error('Regeln für weitere Felder müssen eine Gruppe oder Ja / Nein sein.'); result.additionalProperties = rule; } } else if (type === 'array') { if (fieldValue(body, `${key}-has-items`).checked) result.items = readSchemaNode(direct(body, 'data-schema-items').firstElementChild); const unique = fieldValue(body, `${key}-unique`).checked; if (unique || own(previous, 'uniqueItems')) result.uniqueItems = unique; } if (fieldValue(body, `${key}-has-default`).checked) result.default = readDataEditor(body, `${key}-default`); if (fieldValue(body, `${key}-has-enum`).checked) { const values = readDataEditor(body, `${key}-enum`); if (!Array.isArray(values) || !values.length) throw new Error('Fügen Sie mindestens einen erlaubten Auswahlwert hinzu oder deaktivieren Sie die eingeschränkte Auswahl.'); if (new Set(values.map(value => JSON.stringify(value))).size !== values.length) throw new Error('Erlaubte Auswahlwerte dürfen nicht doppelt vorkommen.'); result.enum = values; } return result; } function moduleSchema(schema = {type:'object', properties:{}, additionalProperties:false}) { return `

Felder, Datentypen und erlaubte Werte festlegen. Diese Angaben werden im Ablaufeditor als Eingabefelder angezeigt.

${schemaNode(schema)}
`; } function readModuleSchema(container, previousSchema) { const root = rootIn(container, '[data-module-schema-editor]'); if (!root) { if (previousSchema !== undefined) return copy(previousSchema); throw new Error('Der Parametereditor wurde nicht gefunden. Öffnen Sie das Formular erneut.'); } const result = readSchemaNode(direct(root, 'data-schema-node')); if (!object(result)) throw new Error('Die Parameterdefinition des Moduls muss eine Gruppe von Regeln sein.'); return result; } function wireModuleSchema(container) { const root = rootIn(container, '[data-module-schema-editor]'); if (!root) return; ensureDataEditors(root); syncHiddenControls(root); if (root.dataset.moduleFormsWired) return; root.dataset.moduleFormsWired = 'true'; root.addEventListener('click', event => { const control = event.target.closest('[data-module-action]'); if (!control || !root.contains(control)) return; event.preventDefault(); event.stopPropagation(); clearError(root); const node = control.closest('[data-schema-node]'); if (control.dataset.moduleAction === 'schema-remove-property') control.closest('[data-schema-property]').remove(); if (control.dataset.moduleAction === 'schema-add-property') { const body = direct(node, 'data-schema-body'); direct(body, 'data-schema-properties').insertAdjacentHTML('beforeend', propertyCard('', {type:'string'}, false, Number(node.dataset.schemaDepth))); ensureDataEditors(root); } syncHiddenControls(root); }); root.addEventListener('change', event => { const node = event.target.closest('[data-schema-node]'); if (!node) return; const key = node.dataset.schemaKey; const body = direct(node, 'data-schema-body'); const name = event.target.name; clearError(root); if (name === `${key}-type`) { const nextType = event.target.value; const cache = node._moduleSchemaCache || (node._moduleSchemaCache = new Map()); if (nextType === 'custom' || node.dataset.currentType === 'custom') cache.delete(nextType); let next; if (!cache.has(nextType)) { try { const previousType = node.dataset.currentType; const shared = nextType === 'custom' ? readSchemaNode(node, previousType) : readDataEditor(body, `${key}-${previousType === 'custom' ? 'custom' : 'extras'}`); next = nextType === 'custom' ? shared : {...(object(shared) ? shared : {}), type:nextType}; if (nextType === 'object') { if (!own(next, 'properties')) next.properties = {}; if (!own(next, 'additionalProperties')) next.additionalProperties = false; } if (nextType === 'array' && !own(next, 'items')) next.items = {type:'string'}; if (object(next)) for (const attribute of ['title','description']) { const input = fieldValue(body, `${key}-${attribute}`); if (input?.value) next[attribute] = input.value; } } catch (error) { event.target.value = node.dataset.currentType; showError(root, error); return; } } cache.set(node.dataset.currentType, body); body.remove(); if (cache.has(nextType)) { node.append(cache.get(nextType)); cache.delete(nextType); } else { node.insertAdjacentHTML('beforeend', schemaBody(next, nextType, key, Number(node.dataset.schemaDepth))); } node.dataset.currentType = nextType; ensureDataEditors(root); } else { const toggles = {'has-items':'data-schema-items', 'has-default':'data-schema-default', 'has-enum':'data-schema-enum'}; for (const [suffix, attribute] of Object.entries(toggles)) if (name === `${key}-${suffix}`) { const section = [...body.querySelectorAll(`[${attribute}]`)].find(item => item.closest('[data-schema-node]') === node); if (section) section.hidden = !event.target.checked; } if (name === `${key}-additional`) direct(body, 'data-schema-additional').hidden = event.target.value !== 'schema'; } syncHiddenControls(root); }); } return {postinstall, readPostinstall, wirePostinstall, moduleSchema, readModuleSchema, wireModuleSchema}; })(); function postinstallEditor(profile, modules, secrets) { return moduleFormTools.postinstall(profile, modules, secrets); } function readPostinstallEditor(container, previousProfile = {}) { return moduleFormTools.readPostinstall(container, previousProfile); } function wirePostinstallEditor(container, modules, secrets) { return moduleFormTools.wirePostinstall(container, modules, secrets); } function moduleSchemaEditor(schema) { return moduleFormTools.moduleSchema(schema); } function readModuleSchemaEditor(container, previousSchema) { return moduleFormTools.readModuleSchema(container, previousSchema); } function wireModuleSchemaEditor(container) { return moduleFormTools.wireModuleSchema(container); }