feat(ui): replace configuration editors with graphical forms
CI / javascript-check (push) Successful in 14s
CI / container-policy (push) Successful in 4s
CI / python-tests (push) Successful in 1m48s
CI / container-verify (push) Skipped
CI / container-publish (push) Successful in 49s

This commit is contained in:
BartelLuis
2026-09-14 21:52:39 +02:00
parent 9a6dea34c4
commit 7b979e6243
22 changed files with 1603 additions and 81 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""Proxmox AIS: controlled installation and post-installation provisioning."""
__version__ = "0.9.2"
__version__ = "0.9.3"
+3 -2
View File
@@ -390,15 +390,16 @@ def create_app(settings: Settings | None = None):
return iso_command(unpack(connection.execute("SELECT * FROM iso_records WHERE id=?",(iso_id,)).fetchone()),connection)
@app.get("/api/v1/secrets")
def secrets_list(user=Depends(roles())):
def secrets_list(user=Depends(roles("author","operator"))):
with db.connection() as connection:
return [dict(r) for r in connection.execute("SELECT id,name,created_at FROM secrets ORDER BY name")]
@app.post("/api/v1/secrets",status_code=201)
def create_secret(payload:SecretCreate,user=Depends(roles())):
value = security.hash_installer_password(payload.value) if payload.kind == "root_password" else payload.value
with db.connection(write=True) as connection:
secret_id = new_id("secret")
connection.execute("INSERT INTO secrets VALUES(?,?,?,?)",(secret_id,payload.name,security.encrypt(payload.value),now_iso()))
connection.execute("INSERT INTO secrets VALUES(?,?,?,?)",(secret_id,payload.name,security.encrypt(value),now_iso()))
audit(connection,user["id"],"secret.created",secret_id)
return {"id":secret_id,"name":payload.name}
+15 -3
View File
@@ -1,9 +1,9 @@
from typing import Any, Literal
from typing import Annotated, Any, Literal
from ipaddress import ip_interface
import re
import uuid
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, ValidationInfo, field_validator
class Model(BaseModel):
@@ -156,7 +156,19 @@ class UserCreate(Model):
class SecretCreate(Model):
name: str = Field(min_length=1, max_length=120)
value: str = Field(min_length=1, max_length=16384)
kind: Literal["value", "root_password"] = "value"
value: Annotated[str, StringConstraints(strip_whitespace=False)] = Field(min_length=1, max_length=16384)
@field_validator("value", mode="before")
@classmethod
def secret_value(cls, value, info: ValidationInfo):
if not isinstance(value, str):
return value
if info.data.get("kind") == "root_password":
if not 12 <= len(value) <= 1024 or "\x00" in value:
raise ValueError("Root-Passwörter benötigen 12 bis 1024 Zeichen ohne Nullzeichen.")
return value
return value.strip()
class Enroll(Model):
+13
View File
@@ -8,6 +8,13 @@ import re
import secrets
from cryptography.fernet import Fernet
from passlib.hash import sha512_crypt
# The installer requires a Linux crypt hash. The builtin backend also works
# on Python 3.13+, where the standard-library crypt module no longer exists.
_installer_password_hash = sha512_crypt.using(rounds=656000)
_installer_password_hash.set_backend("builtin")
def canonical(value):
@@ -43,6 +50,12 @@ class Security:
result = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1)
return "scrypt$" + base64.b64encode(salt).decode() + "$" + base64.b64encode(result).decode()
@staticmethod
def hash_installer_password(password):
if not 12 <= len(password) <= 1024 or "\x00" in password:
raise ValueError("Root-Passwörter benötigen 12 bis 1024 Zeichen ohne Nullzeichen.")
return _installer_password_hash.hash(password)
@staticmethod
def verify_password(password, stored):
try:
+21 -65
View File
@@ -128,12 +128,12 @@ function dashboard(data) {
}
function hostsPage(hosts, discoveries=[]) {
const unassigned=discoveries.filter(d=>!hosts.some(h=>arr(h.identities).some(i=>arr(d.identities).some(v=>v.kind===i.kind && v.value.toLowerCase()===i.value.toLowerCase()))));
return header('Serverinventar','Serveridentitäten, Netzwerke und freigegebene Konfigurationen verwalten.',actionButton('Aktualisieren','refresh','refresh')+(canOperate()?actionButton('JSON importieren','import-hosts','file')+actionButton('Server hinzufügen','create-host','plus','',true):''),'VERWALTUNG / SERVER')+`<section class="card filterable">${toolbar('FQDN, IP-Adresse oder Tag suchen …',[...new Set(hosts.map(h=>h.site).filter(Boolean))],[...new Set(hosts.map(h=>h.blocked?'blocked':h.status).filter(Boolean))])}${hosts.length?table(['SERVER','STANDORT','STATUS','TAGS','LETZTER KONTAKT',''],hostRows(hosts)):empty('Noch keine Server erfasst','Erfassen Sie einen Host anhand seiner UUID, Seriennummer oder MAC-Adresse.','server',canOperate()?actionButton('Ersten Server hinzufügen','create-host','plus','',true):'')}</section>`+(unassigned.length?`<section class="card space-top"><div class="card-header"><div><h2>Entdeckte Hardware <span class="count-label">${unassigned.length}</span></h2><p>Noch nicht zugeordnete Geräte erhalten keine Installationskonfiguration.</p></div>${badge('discovered')}</div>${table(['IDENTITÄTEN','STANDORT / BUILD','ABLEHNUNGSGRUND',''],unassigned.map(d=>`<tr><td>${arr(d.identities).map(i=>`<div class="small-text mono">${esc(i.kind)}: ${esc(i.value)}</div>`).join('')}</td><td>${esc(d.site)}<br><span class="small-text muted">${esc(d.build)}</span></td><td>${esc(d.reason)}</td><td>${canOperate()?`<button class="button small" data-action="assign-discovery" data-id="${esc(d.id)}">Als Server erfassen</button>`:''}</td></tr>`))}</section>`:'');
return header('Serverinventar','Serveridentitäten, Netzwerke und freigegebene Konfigurationen verwalten.',actionButton('Aktualisieren','refresh','refresh')+(canOperate()?actionButton('Mehrere Server erfassen','import-hosts','file')+actionButton('Server hinzufügen','create-host','plus','',true):''),'VERWALTUNG / SERVER')+`<section class="card filterable">${toolbar('FQDN, IP-Adresse oder Tag suchen …',[...new Set(hosts.map(h=>h.site).filter(Boolean))],[...new Set(hosts.map(h=>h.blocked?'blocked':h.status).filter(Boolean))])}${hosts.length?table(['SERVER','STANDORT','STATUS','TAGS','LETZTER KONTAKT',''],hostRows(hosts)):empty('Noch keine Server erfasst','Erfassen Sie einen Host anhand seiner UUID, Seriennummer oder MAC-Adresse.','server',canOperate()?actionButton('Ersten Server hinzufügen','create-host','plus','',true):'')}</section>`+(unassigned.length?`<section class="card space-top"><div class="card-header"><div><h2>Entdeckte Hardware <span class="count-label">${unassigned.length}</span></h2><p>Noch nicht zugeordnete Geräte erhalten keine Installationskonfiguration.</p></div>${badge('discovered')}</div>${table(['IDENTITÄTEN','STANDORT / BUILD','ABLEHNUNGSGRUND',''],unassigned.map(d=>`<tr><td>${arr(d.identities).map(i=>`<div class="small-text mono">${esc(i.kind)}: ${esc(i.value)}</div>`).join('')}</td><td>${esc(d.site)}<br><span class="small-text muted">${esc(d.build)}</span></td><td>${esc(d.reason)}</td><td>${canOperate()?`<button class="button small" data-action="assign-discovery" data-id="${esc(d.id)}">Als Server erfassen</button>`:''}</td></tr>`))}</section>`:'');
}
function hostPage(host) {
const id=esc(host.id), runs=arr(host.runs), identities=arr(host.identities);
return header(host.fqdn||'Entdeckter Server',`${host.site||'Kein Standort'} · ${host.management_ip||'Keine Management-IP'}`,`<a class="button" href="#/hosts">${svg('back')}Inventar</a>${canOperate()?actionButton('Bearbeiten','edit-host','edit',`data-id="${id}"`)+actionButton('Installation freigeben','approve-host','shield',`data-id="${id}"`,true):''}`,'SERVERDETAIL / '+shortId(host.id))+
`<div class="detail-grid"><div class="stack"><section class="card"><div class="card-header"><h2>Serverkonfiguration</h2>${badge(host.blocked?'blocked':host.status)}</div><div class="card-content"><dl class="detail-list"><dt>FQDN</dt><dd>${esc(host.fqdn||'')}</dd><dt>Management-IP</dt><dd class="mono">${esc(host.management_ip||'')}</dd><dt>Standort</dt><dd>${esc(host.site||'')}</dd><dt>Tags</dt><dd>${(host.tags||[]).map(t=>`<span class="tag">${esc(t)}</span>`).join('')||''}</dd><dt>Letzter Kontakt</dt><dd>${esc(fmtDate(host.last_contact||host.last_seen))}</dd><dt>Versionsstand</dt><dd>${esc(host.version)}</dd></dl><h3 class="section-label">Hardware-Identitäten</h3>${identities.length?`<dl class="detail-list">${identities.map(i=>`<dt>${esc({uuid:'System-UUID',serial:'Seriennummer',mac:'MAC-Adresse'}[i.kind]||i.kind)}</dt><dd class="mono">${esc(i.value)}</dd>`).join('')}</dl>`:'<p class="muted small-text">Noch keine Identitäten hinterlegt.</p>'}</div><div class="card-footer"><span>Erfasst am ${esc(fmtDate(host.created_at))}</span>${canOperate()?`<button class="button-link" data-action="toggle-host" data-id="${id}">${host.blocked?'Host entsperren':'Host sperren'}</button>`:''}</div></section><section class="card"><div class="card-header"><h2>Installationshistorie</h2><span class="count-label">${runs.length}</span></div>${runs.length?table(['LAUF','STATUS','FORTSCHRITT',''],runRows(runs)):empty('Noch keine Installationsläufe','Nach einer Freigabe und dem ersten ISO-Kontakt erscheint hier der zugehörige Lauf.','activity')}</section></div><div class="stack"><section class="card"><div class="card-header"><h2>Profilzuordnung</h2>${svg('layers')}</div><div class="card-content"><dl class="detail-list"><dt>Installation</dt><dd>${esc(host.installation_profile_name||host.installation_profile_id||'Nicht zugewiesen')}</dd><dt>Postinstallation</dt><dd>${esc(host.postinstall_profile_name||host.postinstall_profile_id||'Nicht zugewiesen')}</dd><dt>ISO-Medium</dt><dd>${esc(host.iso_name||host.iso_id||'Nicht zugewiesen')}</dd></dl><p class="small-text muted space-top">Jeder Lauf bindet feste Profil- und Modulversionen. Spätere Änderungen wirken auf neue Läufe.</p>${actionButton('Aufgelöste Vorschau','preview-host','file',`data-id="${id}"`)}</div></section>${host.discovered_data?`<section class="card"><div class="card-header"><h2>Erkannte Systemdaten</h2></div><div class="card-content"><pre class="code-block light">${esc(json(host.discovered_data))}</pre></div></section>`:''}<section class="card"><div class="card-header"><h2>Hostüberschreibungen</h2></div><div class="card-content"><pre class="code-block light">${esc(json(host.overrides))}</pre></div></section></div></div>`;
`<div class="detail-grid"><div class="stack"><section class="card"><div class="card-header"><h2>Serverkonfiguration</h2>${badge(host.blocked?'blocked':host.status)}</div><div class="card-content"><dl class="detail-list"><dt>FQDN</dt><dd>${esc(host.fqdn||'')}</dd><dt>Management-IP</dt><dd class="mono">${esc(host.management_ip||'')}</dd><dt>Standort</dt><dd>${esc(host.site||'')}</dd><dt>Tags</dt><dd>${(host.tags||[]).map(t=>`<span class="tag">${esc(t)}</span>`).join('')||''}</dd><dt>Letzter Kontakt</dt><dd>${esc(fmtDate(host.last_contact||host.last_seen))}</dd><dt>Versionsstand</dt><dd>${esc(host.version)}</dd></dl><h3 class="section-label">Hardware-Identitäten</h3>${identities.length?`<dl class="detail-list">${identities.map(i=>`<dt>${esc({uuid:'System-UUID',serial:'Seriennummer',mac:'MAC-Adresse'}[i.kind]||i.kind)}</dt><dd class="mono">${esc(i.value)}</dd>`).join('')}</dl>`:'<p class="muted small-text">Noch keine Identitäten hinterlegt.</p>'}</div><div class="card-footer"><span>Erfasst am ${esc(fmtDate(host.created_at))}</span>${canOperate()?`<button class="button-link" data-action="toggle-host" data-id="${id}">${host.blocked?'Host entsperren':'Host sperren'}</button>`:''}</div></section><section class="card"><div class="card-header"><h2>Installationshistorie</h2><span class="count-label">${runs.length}</span></div>${runs.length?table(['LAUF','STATUS','FORTSCHRITT',''],runRows(runs)):empty('Noch keine Installationsläufe','Nach einer Freigabe und dem ersten ISO-Kontakt erscheint hier der zugehörige Lauf.','activity')}</section></div><div class="stack"><section class="card"><div class="card-header"><h2>Profilzuordnung</h2>${svg('layers')}</div><div class="card-content"><dl class="detail-list"><dt>Installation</dt><dd>${esc(host.installation_profile_name||host.installation_profile_id||'Nicht zugewiesen')}</dd><dt>Postinstallation</dt><dd>${esc(host.postinstall_profile_name||host.postinstall_profile_id||'Nicht zugewiesen')}</dd><dt>ISO-Medium</dt><dd>${esc(host.iso_name||host.iso_id||'Nicht zugewiesen')}</dd></dl><p class="small-text muted space-top">Jeder Lauf bindet feste Profil- und Modulversionen. Spätere Änderungen wirken auf neue Läufe.</p>${actionButton('Aufgelöste Vorschau','preview-host','file',`data-id="${id}"`)}</div></section>${host.discovered_data?`<section class="card"><div class="card-header"><h2>Erkannte Systemdaten</h2></div><div class="card-content">${dataView(host.discovered_data)}</div></section>`:''}<section class="card"><div class="card-header"><h2>Hostüberschreibungen</h2></div><div class="card-content">${dataView(host.overrides)}</div></section></div></div>`;
}
function profilesPage(profiles, kind) {
const installation=kind==='installation'; const list=profiles.filter(p=>p.kind===kind);
@@ -151,10 +151,10 @@ function runPage(run) {
const resumeAllowed=['needs_review','waiting_retry'].includes(run.status) && !run.cancel_requested;
const actions=`<a class="button" href="#/runs">${svg('back')}Alle Läufe</a>${canOperate()&&resumeAllowed?actionButton('Wiederaufnehmen','resume-run','play',`data-id="${esc(run.id)}"`,true):''}${canOperate()&&!terminal?actionButton('Abbrechen','cancel-run','stop',`data-id="${esc(run.id)}"`)+actionButton('Lauf abgleichen','reconcile-run','shield',`data-id="${esc(run.id)}"`):''}`;
let content='';
if(state.runTab==='steps') content=steps.length?`<ol class="run-steps">${steps.map((s,i)=>`<li class="run-step"><span class="step-number">${s.status==='succeeded'?'✓':i+1}</span><div class="run-step-copy"><strong>${esc(s.name || s.step_id || s.id || `Schritt ${i+1}`)}</strong><p>Modul ${esc(s.module_name || s.module_id || '')} · Versuch ${Number(s.attempt || s.attempts || 0)}${s.exit_code!=null?` · Exit ${Number(s.exit_code)}`:''}</p>${s.required===false?'<p>Optionaler Schritt</p>':''}${s.error?`<p>${esc(s.error)}</p>`:''}${s.verification&&Object.keys(s.verification).length?`<details><summary>Pr?fergebnis${s.status==='failed'?' / Fehlerursache':''}</summary><pre class="code-block light">${esc(json(s.verification))}</pre></details>`:''}${s.checkpoint?`<details><summary>Checkpoint</summary><pre class="code-block light">${esc(json(s.checkpoint))}</pre></details>`:''}</div>${badge(s.status)}</li>`).join('')}</ol>`:empty('Noch keine Schritte gemeldet','Die fixierten Schritte erscheinen mit dem Start der Nachkonfiguration.','workflow');
if(state.runTab==='steps') content=steps.length?`<ol class="run-steps">${steps.map((s,i)=>`<li class="run-step"><span class="step-number">${s.status==='succeeded'?'✓':i+1}</span><div class="run-step-copy"><strong>${esc(s.name || s.step_id || s.id || `Schritt ${i+1}`)}</strong><p>Modul ${esc(s.module_name || s.module_id || '')} · Versuch ${Number(s.attempt || s.attempts || 0)}${s.exit_code!=null?` · Exit ${Number(s.exit_code)}`:''}</p>${s.required===false?'<p>Optionaler Schritt</p>':''}${s.error?`<p>${esc(s.error)}</p>`:''}${s.verification&&Object.keys(s.verification).length?`<details><summary>Prüfergebnis${s.status==='failed'?' / Fehlerursache':''}</summary>${dataView(s.verification)}</details>`:''}${s.checkpoint?`<details><summary>Checkpoint</summary>${dataView(s.checkpoint)}</details>`:''}</div>${badge(s.status)}</li>`).join('')}</ol>`:empty('Noch keine Schritte gemeldet','Die fixierten Schritte erscheinen mit dem Start der Nachkonfiguration.','workflow');
if(state.runTab==='events') content=eventList(events,'Noch keine Laufereignisse');
if(state.runTab==='logs') content=logs.length?`<div class="card-content"><pre class="code-block">${esc(logs.map(l=>typeof l==='string'?l:`${l.created_at?fmtDate(l.created_at)+' ':''}${l.step_id?'['+l.step_id+'] ':''}${l.content||l.text||l.message||json(l)}`).join('\n'))}</pre></div>`:empty('Noch keine Protokolldaten','Der Runner übermittelt redigierte Protokolle während der Ausführung.','code');
return header(run.host_fqdn||run.fqdn||`Lauf ${shortId(run.id)}`,`Lauf ${run.id}`,actions,'INSTALLATIONSLAUF')+`<div class="detail-grid"><div class="stack"><section class="card"><div class="card-header"><h2>Ausführungsstatus</h2>${badge(run.status)}</div><div class="card-content"><div class="tabs" role="tablist" aria-label="Laufdetails">${[['steps','Schritte'],['events','Ereignisse'],['logs','Protokolle']].map(([id,label])=>`<button role="tab" type="button" aria-selected="${state.runTab===id}" class="tab${state.runTab===id?' active':''}" data-action="run-tab" data-tab="${id}">${label}${id==='steps'?` (${steps.length})`:''}</button>`).join('')}</div>${run.error||run.error_reason?`<div class="alert alert-danger">${esc(run.error||run.error_reason)}</div>`:''}</div>${content}</section></div><div class="stack"><section class="card"><div class="card-header"><h2>Laufdaten</h2></div><div class="card-content"><dl class="detail-list"><dt>Server</dt><dd><a href="#/hosts/${encodeURIComponent(run.host_id)}">${esc(run.host_fqdn||shortId(run.host_id))} ↗</a></dd><dt>Gestartet</dt><dd>${esc(fmtDate(run.started_at||run.created_at))}</dd><dt>Abgeschlossen</dt><dd>${run.finished_at||run.completed_at?esc(fmtDate(run.finished_at||run.completed_at)):''}</dd><dt>Letzter Heartbeat</dt><dd>${esc(fmtDate(run.last_heartbeat||run.last_contact||run.last_seen))}</dd><dt>Versionsstand</dt><dd>${esc(run.version)}</dd><dt>Manifest-Digest</dt><dd class="mono">${esc(run.manifest_digest||'Noch nicht erstellt')}</dd><dt>Antwort-Digest</dt><dd class="mono">${esc(run.answer_digest||run.answer_sha256||'')}</dd></dl></div></section><section class="card"><div class="card-header"><h2>Fixierte Konfiguration</h2>${svg('lock')}</div><div class="card-content"><p class="small-text muted">Profil- und Skriptversionen dieses Laufs bleiben nach der Reservierung unverändert.</p><pre class="code-block light">${esc(json(run.manifest || run.snapshot || run.profiles || {installation_profile_id:run.installation_profile_id,postinstall_profile_id:run.postinstall_profile_id}))}</pre></div></section></div></div>`;
return header(run.host_fqdn||run.fqdn||`Lauf ${shortId(run.id)}`,`Lauf ${run.id}`,actions,'INSTALLATIONSLAUF')+`<div class="detail-grid"><div class="stack"><section class="card"><div class="card-header"><h2>Ausführungsstatus</h2>${badge(run.status)}</div><div class="card-content"><div class="tabs" role="tablist" aria-label="Laufdetails">${[['steps','Schritte'],['events','Ereignisse'],['logs','Protokolle']].map(([id,label])=>`<button role="tab" type="button" aria-selected="${state.runTab===id}" class="tab${state.runTab===id?' active':''}" data-action="run-tab" data-tab="${id}">${label}${id==='steps'?` (${steps.length})`:''}</button>`).join('')}</div>${run.error||run.error_reason?`<div class="alert alert-danger">${esc(run.error||run.error_reason)}</div>`:''}</div>${content}</section></div><div class="stack"><section class="card"><div class="card-header"><h2>Laufdaten</h2></div><div class="card-content"><dl class="detail-list"><dt>Server</dt><dd><a href="#/hosts/${encodeURIComponent(run.host_id)}">${esc(run.host_fqdn||shortId(run.host_id))} ↗</a></dd><dt>Gestartet</dt><dd>${esc(fmtDate(run.started_at||run.created_at))}</dd><dt>Abgeschlossen</dt><dd>${run.finished_at||run.completed_at?esc(fmtDate(run.finished_at||run.completed_at)):''}</dd><dt>Letzter Heartbeat</dt><dd>${esc(fmtDate(run.last_heartbeat||run.last_contact||run.last_seen))}</dd><dt>Versionsstand</dt><dd>${esc(run.version)}</dd><dt>Manifest-Digest</dt><dd class="mono">${esc(run.manifest_digest||'Noch nicht erstellt')}</dd><dt>Antwort-Digest</dt><dd class="mono">${esc(run.answer_digest||run.answer_sha256||'')}</dd></dl></div></section><section class="card"><div class="card-header"><h2>Fixierte Konfiguration</h2>${svg('lock')}</div><div class="card-content"><p class="small-text muted">Profil- und Skriptversionen dieses Laufs bleiben nach der Reservierung unverändert.</p>${dataView(run.manifest || run.snapshot || run.profiles || {installation_profile_id:run.installation_profile_id,postinstall_profile_id:run.postinstall_profile_id})}</div></section></div></div>`;
}
function mediaPage(records, groups=[]) {
return header('Installationsmedien','Gemeinsame ISO-Medien registrieren, Zugriffe begrenzen und Kompatibilität belegen.',(canAdmin()?actionButton('Gruppentoken erstellen','create-group','key')+actionButton('ISO registrieren','create-iso','plus','',true):''),'KONFIGURATION / MEDIEN')+
@@ -180,7 +180,7 @@ function closeModal() {modal.close();modalSubmit=null;document.getElementById('m
function field(name,label,value='',options={}) {
const attrs=`name="${esc(name)}"${options.required?' required':''}${options.placeholder?` placeholder="${esc(options.placeholder)}"`:''}${options.min!==undefined?` min="${esc(options.min)}"`:''}${options.max!==undefined?` max="${esc(options.max)}"`:''}${options.autocomplete?` autocomplete="${esc(options.autocomplete)}"`:''}`;
let input;
if(options.type==='textarea'||options.type==='json') input=`<textarea ${attrs} class="${options.type==='json'?'code':''}"${options.rows?` rows="${Number(options.rows)}"`:''}>${esc(typeof value==='object'?json(value):value)}</textarea>`;
if(options.type==='textarea') input=`<textarea ${attrs}${options.rows?` rows="${Number(options.rows)}"`:''}>${esc(typeof value==='object'?json(value):value)}</textarea>`;
else if(options.type==='select') input=`<select ${attrs}>${(options.options||[]).map(o=>`<option value="${esc(o.value)}"${String(o.value)===String(value)?' selected':''}>${esc(o.label)}</option>`).join('')}</select>`;
else if(options.type==='checkbox') return `<label class="checkbox${options.full?' full':''}"><input type="checkbox" ${attrs}${value?' checked':''}><span>${esc(label)}${options.hint?`<br><small>${esc(options.hint)}</small>`:''}</span></label>`;
else input=`<input type="${esc(options.type||'text')}" ${attrs} value="${esc(value)}">`;
@@ -189,24 +189,10 @@ function field(name,label,value='',options={}) {
function form(fields,label='Speichern',intro='') {
return `${intro}<form id="modal-form"><div class="form-grid">${fields}</div><div class="form-error alert alert-danger" role="alert"></div><div class="form-actions"><button type="button" class="button" data-action="close-modal">Abbrechen</button><button type="submit" class="button primary">${esc(label)}</button></div></form>`;
}
function parseJSON(data, name, fallback={}) {try{return JSON.parse(data.get(name)||json(fallback));}catch{throw new Error(`Das Feld „${name}“ enthält kein gültiges JSON.`);}}
const split = value => String(value||'').split(/[,\n]/).map(s=>s.trim()).filter(Boolean);
const selectObjects = (objects, emptyLabel='Bitte auswählen') => [{value:'',label:emptyLabel},...objects.map(o=>({value:o.id,label:`${o.name || o.fqdn}${o.version?` · v${o.version}`:''}${o.build?` · ${o.build}`:''}`}))];
async function hostForm(existing=null, discovery=null) {
const [profiles, isos]=await Promise.all([api('/profiles'),api('/iso-records')]);
const h=existing||discovery||{};
const fields=field('fqdn','Vollständiger Hostname (FQDN)',h.fqdn,{required:true,placeholder:'pve-01.example.net'})+field('site','Standort',h.site,{required:true,placeholder:'Rechenzentrum Berlin'})+field('management_ip','Management-IP mit Präfix',h.management_ip,{required:true,placeholder:'192.0.2.10/24'})+field('tags','Tags',arr(h.tags).join(', '),{placeholder:'produktion, rack-a',hint:'Mehrere Tags mit Komma trennen.'})+field('identities','Hardware-Identitäten',arr(h.identities).map(i=>`${i.kind}:${i.value}`).join('\n'),{type:'textarea',required:true,full:true,placeholder:'serial:SERVER-SERIAL\nuuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\nmac:00:11:22:33:44:55',hint:'Eine Identität pro Zeile. Erlaubte Typen: serial, uuid, mac.'})+field('installation_profile_id','Installationsprofil',h.installation_profile_id,{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='installation'&&p.status==='published'),'Noch nicht zuweisen')})+field('postinstall_profile_id','Postinstallationsprofil',h.postinstall_profile_id,{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='postinstall'&&p.status==='published'),'Noch nicht zuweisen')})+field('iso_id','Installationsmedium',h.iso_id,{type:'select',full:true,options:selectObjects(arr(isos),'Noch nicht zuweisen')})+field('overrides','Hostüberschreibungen (JSON)',h.overrides||{},{type:'json',full:true,hint:'Spezifische Werte dieses Hosts. Geheimnisse ausschließlich per Referenz zuweisen.'});
showModal(existing?'Server bearbeiten':'Server hinzufügen',form(fields,existing?'Änderungen speichern':'Server anlegen'),async data=>{
const identities=String(data.get('identities')).split('\n').filter(l=>l.trim()).map(line=>{const colon=line.indexOf(':');if(colon<1)throw new Error('Jede Identität benötigt das Format typ:wert.');return {kind:line.slice(0,colon).trim(),value:line.slice(colon+1).trim()};});
const body={fqdn:data.get('fqdn'),site:data.get('site'),management_ip:data.get('management_ip'),tags:split(data.get('tags')),identities,installation_profile_id:data.get('installation_profile_id')||null,postinstall_profile_id:data.get('postinstall_profile_id')||null,iso_id:data.get('iso_id')||null,overrides:parseJSON(data,'overrides')};
if(existing){for(const key of Object.keys(body)){if(JSON.stringify(body[key])===JSON.stringify(existing[key]??null))delete body[key];}if(!Object.keys(body).length){closeModal();toast('Keine Änderungen vorhanden.');return;}body.expected_version=existing.version;}
await api(existing?`/hosts/${encodeURIComponent(existing.id)}`:'/hosts',{method:existing?'PATCH':'POST',body});closeModal();toast(existing?'Server aktualisiert.':'Server wurde angelegt.');await refresh();
},'INVENTAR');
}
function hostImportForm() {
const example=[{fqdn:'pve-01.example.net',site:'Berlin',management_ip:'192.0.2.10/24',identities:[{kind:'serial',value:'SERVER-SERIAL'}],tags:[]}];
showModal('Server aus JSON importieren',form(field('hosts','Serverliste (JSON)',example,{type:'json',full:true,required:true,rows:16,hint:'Liste von Hostobjekten. Die gesamte Liste wird zusammen validiert und gespeichert.'}),'Server importieren'),async data=>{const hosts=parseJSON(data,'hosts',[]);if(!Array.isArray(hosts)||!hosts.length)throw new Error('Eine nicht leere JSON-Liste von Servern ist erforderlich.');await api('/hosts/import',{method:'POST',body:hosts});closeModal();toast(`${hosts.length} Server importiert.`);await refresh();},'INVENTARIMPORT');
}
async function hostForm(existing=null, discovery=null) { return graphicalHostForm(existing,discovery); }
async function hostImportForm() { return graphicalHostImport(); }
async function moduleCatalog() {
const catalog=arr(await api('/modules/builtin'));
showModal('Basismodul als Entwurf übernehmen',`<div class="alert alert-info">Die Vorlagen sind Ausgangspunkte. Prüfen Sie die Parameter, tragen Sie den konkreten Zielbuild ein und dokumentieren Sie vor Veröffentlichung einen Test.</div>${catalog.map(m=>`<div class="run-step"><span class="step-number">${svg('code')}</span><div class="run-step-copy"><strong>${esc(m.name)}</strong><p>${esc(m.description)}</p></div><button type="button" class="button small" data-action="use-module-template" data-id="${esc(m.id)}">Übernehmen</button></div>`).join('')}<div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,'MODULVORLAGEN');
@@ -215,7 +201,7 @@ async function moduleCatalog() {
function previewContent(preview) {
const p=preview||{}, warnings=arr(p.warnings);
const network=p.resolved?.network||{};
return `${warnings.length?`<div class="alert">${warnings.map(w=>esc(typeof w==='string'?w:w.message||json(w))).join('<br>')}</div>`:''}<div class="modal-summary"><dl class="detail-list"><dt>ISO / Zielbuild</dt><dd>${esc(p.iso?.name||'')} · ${esc(p.iso?.build||'')}</dd><dt>Managementnetz</dt><dd>${esc(network.cidr||'')}<br>Gateway ${esc(network.gateway||'')} · DNS ${esc(network.dns||'')}<br><span class="mono">${esc(JSON.stringify(network.filter||{}))}</span></dd><dt>Zieldatenträger</dt><dd><pre class="code-block light">${esc(json(p.disks || p.resolved?.disk_setup || p.resolved?.['disk-setup'] || {}))}</pre></dd><dt>Profilversionen</dt><dd>${arr(p.profiles).map(profile=>`${esc(profile.name)} · v${esc(profile.version)}`).join('<br>')||''}</dd><dt>Skriptversionen</dt><dd>${arr(p.steps).map(step=>`${esc(step.name||step.id)} · v${esc(step.module_version||'')}`).join('<br>')||''}</dd><dt>Konfigurationsdigest</dt><dd class="mono break">${esc(p.digest||'')}</dd></dl></div><details><summary>Aufgelöste Konfiguration & Herkunft</summary><pre class="code-block light">${esc(json({resolved:p.resolved,provenance:p.provenance,profiles:p.profiles,steps:p.steps}))}</pre></details>`;
return `${warnings.length?`<div class="alert">${warnings.map(w=>esc(typeof w==='string'?w:w.message||json(w))).join('<br>')}</div>`:''}<div class="modal-summary"><dl class="detail-list"><dt>ISO / Zielbuild</dt><dd>${esc(p.iso?.name||'')} · ${esc(p.iso?.build||'')}</dd><dt>Managementnetz</dt><dd>${esc(network.cidr||'')}<br>Gateway ${esc(network.gateway||'')} · DNS ${esc(network.dns||'')}<br>${dataView(network.filter||{})}</dd><dt>Zieldatenträger</dt><dd>${dataView(p.disks || p.resolved?.disk_setup || p.resolved?.['disk-setup'] || {})}</dd><dt>Profilversionen</dt><dd>${arr(p.profiles).map(profile=>`${esc(profile.name)} · v${esc(profile.version)}`).join('<br>')||''}</dd><dt>Skriptversionen</dt><dd>${arr(p.steps).map(step=>`${esc(step.name||step.id)} · v${esc(step.module_version||'')}`).join('<br>')||''}</dd><dt>Konfigurationsdigest</dt><dd class="mono break">${esc(p.digest||'')}</dd></dl></div><details><summary>Aufgelöste Konfiguration & Herkunft</summary>${dataView({resolved:p.resolved,provenance:p.provenance,profiles:p.profiles,steps:p.steps})}</details>`;
}
async function approveHost(id, previewOnly=false) {
const [host, preview]=await Promise.all([api(`/hosts/${encodeURIComponent(id)}`),api(`/hosts/${encodeURIComponent(id)}/preview`)]);
@@ -228,30 +214,8 @@ async function approveHost(id, previewOnly=false) {
closeModal();toast('Installationsfreigabe erteilt. Der Server kann mit der zugewiesenen ISO gestartet werden.');await refresh();
},'ZEITLICH BEGRENZTE INSTALLATIONSFREIGABE');
}
const installationExample = {
global:{keyboard:'de',country:'de',timezone:'Europe/Berlin',mailto:'admin@example.net'},
network:{source:'from-answer',gateway:'192.0.2.1',dns:'192.0.2.53',filter:{ID_NET_NAME_MAC:'enx001122334455'}},
disk_setup:{filesystem:'ext4',filter:{ID_SERIAL:'EXPLICIT_DISK_SERIAL'},expected_count:1,expected_serials:['EXPLICIT_DISK_SERIAL'],inventory_evidence:'Referenz zur geprüften Hardwareinventarisierung'},
root_secret_id:'ID_DES_ROOT_PASSWORT_HASHES'
};
async function profileForm(kind, existing=null) {
const p=existing||{}, install=kind==='installation';
let info=install?`<div class="alert alert-info"><strong>Server mit einer Festplatte</strong><p>Die Vorlage verwendet alle vom Installer erkannten Zielplatten. Für Server mit genau einer Platte sind damit weder Seriennummer noch Gerätename nötig.</p>${actionButton('ZFS (RAID0) für Einzelplatte','use-single-disk-zfs','disc')}</div>`:'';
if(!install){const modules=arr(await api('/modules')).filter(m=>m.status==='published');info=`<div class="alert alert-info">Verfügbare veröffentlichte Module: ${modules.length?modules.map(m=>`${esc(m.name)} v${esc(m.version)}: <code>${esc(m.id)}</code>`).join('<br>'):'Noch keine. Erstellen und veröffentlichen Sie zuerst ein Skriptmodul.'}</div>`;}
const fields=field('name','Profilname',p.name,{required:true,full:true,placeholder:install?'PVE · Standardserver':'PVE · Basiskonfiguration',hint:'Die nächste Versionsnummer wird automatisch für diesen Namen vergeben.'})+field('target_builds','Unterstützte Zielbuilds',arr(p.target_builds).join(', '),{required:true,full:true,placeholder:'z. B. 9.1-1',hint:'Nur tatsächlich geprüfte Builds eintragen. Mehrere Werte mit Komma trennen.'})+field('values',install?'Installationskonfiguration (JSON)':'Profilparameter (JSON)',p.values||(install?installationExample:{}),{type:'json',full:true,rows:install?17:6,hint:install?'Beispielwerte an Ihr Netz und Ihre geprüfte Hardware anpassen. FQDN und Management-CIDR kommen vom Host.':'Parameter werden mit den Einstellungen der einzelnen Schritte aufgelöst.'})+(!install?field('steps','Geordnete Schritte (JSON)',p.steps||[{id:'final-check',module_id:'VEROEFFENTLICHTE_MODUL_ID',parameters:{},secret_refs:{},required:true}],{type:'json',full:true,rows:10,hint:'Jeder Schritt verweist auf eine veröffentlichte Modulversion. Die Listenreihenfolge ist die Ausführungsreihenfolge.'})+field('reboot_budget','Maximale geplante Neustarts',p.reboot_budget??1,{type:'number',min:0,max:5,full:true}):'')+field('reason','Änderungsgrund','',{full:true,placeholder:'Grund für diesen Profilstand'});
showModal(existing?'Neue Profilversion':'Profil erstellen',form(fields,'Entwurf speichern',info),async data=>{
await api('/profiles',{method:'POST',body:{name:data.get('name'),kind,target_builds:split(data.get('target_builds')),values:parseJSON(data,'values'),steps:install?[]:parseJSON(data,'steps',[]),reason:data.get('reason')||'',...(!install?{reboot_budget:Number(data.get('reboot_budget'))}:{})}});
closeModal();toast('Profilentwurf gespeichert. Eine Veröffentlichung benötigt einen Testnachweis.');await refresh();
},install?'INSTALLATIONSPROFIL':'POSTINSTALLATIONSPROFIL');
}
const moduleExample = '#!/usr/bin/env bash\nset -euo pipefail\n\ncheck() {\n systemctl is-active --quiet pveproxy\n}\n\napply() {\n # Nur erforderliche, geprüfte Änderungen ausführen.\n return 0\n}\n\nverify() {\n systemctl is-active --quiet pveproxy\n}\n\ncase "${1:-}" in\n check) check ;;\n apply) apply ;;\n verify) verify ;;\n *) echo "Usage: $0 {check|apply|verify}" >&2; exit 2 ;;\nesac\n';
function moduleForm(existing=null) {
const m=existing||{};
const fields=field('name','Modulname',m.name,{required:true,full:true,placeholder:'PVE-Dienste prüfen',hint:'Die nächste Versionsnummer wird automatisch für diesen Namen vergeben.'})+field('target_builds','Unterstützte Zielbuilds',arr(m.target_builds).join(', '),{required:true,placeholder:'z. B. 9.1-1'})+field('timeout_seconds','Timeout in Sekunden',m.timeout_seconds||300,{type:'number',required:true,min:1,max:7200})+field('source','Bash-Quelltext',m.source||moduleExample,{type:'json',required:true,full:true,rows:17,hint:'Aufruf: bash modul.sh check|apply|verify parameter.json. Parameter werden als JSON-Datei übergeben.'})+field('parameters_schema','Parameterschema (JSON Schema)',m.parameters_schema||{type:'object',properties:{},additionalProperties:false},{type:'json',full:true,rows:6})+field('dependencies','Abhängige Modulnamen',arr(m.dependencies).join(', '),{full:true,placeholder:'Optional: exakte Modulnamen, durch Komma getrennt'})+field('retry_safe','Apply darf nach Zustandsprüfung wiederholt werden.',m.retry_safe||false,{type:'checkbox',full:true,hint:'Nur aktivieren, wenn die Wiederholbarkeit im Test nachgewiesen wurde.'})+field('reason','Änderungsgrund','',{full:true,placeholder:'Grund für diesen Modulstand'});
showModal(existing?'Neue Modulversion':'Skriptmodul erstellen',form(fields,'Entwurf speichern'),async data=>{
await api('/modules',{method:'POST',body:{name:data.get('name'),source:data.get('source'),parameters_schema:parseJSON(data,'parameters_schema'),dependencies:split(data.get('dependencies')),target_builds:split(data.get('target_builds')),timeout_seconds:Number(data.get('timeout_seconds')),retry_safe:data.get('retry_safe')==='on',reason:data.get('reason')||''}});closeModal();toast('Modulentwurf gespeichert.');await refresh();
},'VERSIONIERTE SKRIPTMODULE');
}
async function profileForm(kind, existing=null) { return graphicalProfileForm(kind,existing); }
async function moduleForm(existing=null) { return graphicalModuleForm(existing); }
async function publishObject(type, id) {
const item=arr(state.data).find(x=>x.id===id) || await api(`/${type}/${encodeURIComponent(id)}`);
const intro=`<div class="alert alert-info">${esc(item.name)} · Version ${esc(item.version)}<br>Veröffentlichten Inhalt können Sie nicht mehr ändern. Dokumentieren Sie den praktischen Test auf einem passenden Testhost. Im Vieraugenmodus muss eine andere Person den Entwurf veröffentlichen.</div>`;
@@ -260,10 +224,10 @@ async function publishObject(type, id) {
},'VERÖFFENTLICHUNG');
}
function inspectProfile(p) {
showModal(p.name,`<div class="modal-summary"><dl class="detail-list"><dt>Version</dt><dd>v${esc(p.version)} ${badge(p.status)}</dd><dt>Zielbuilds</dt><dd>${esc((p.target_builds||[]).join(', ')||'')}</dd><dt>Profil-ID</dt><dd class="mono">${esc(p.id)}</dd><dt>Digest</dt><dd class="mono">${esc(p.digest||'Noch nicht veröffentlicht')}</dd></dl></div><pre class="code-block light">${esc(json({values:p.values,steps:p.steps}))}</pre><div class="form-actions">${canAuthor()?`<button class="button" data-action="version-profile" data-id="${esc(p.id)}">Neue Version</button>`:''}<button class="button" data-action="close-modal">Schließen</button></div>`,null,'PROFILDETAILS');
showModal(p.name,`<div class="modal-summary"><dl class="detail-list"><dt>Version</dt><dd>v${esc(p.version)} ${badge(p.status)}</dd><dt>Zielbuilds</dt><dd>${esc((p.target_builds||[]).join(', ')||'')}</dd><dt>Profil-ID</dt><dd class="mono">${esc(p.id)}</dd><dt>Digest</dt><dd class="mono">${esc(p.digest||'Noch nicht veröffentlicht')}</dd></dl></div>${dataView({values:p.values,steps:p.steps})}<div class="form-actions">${canAuthor()?`<button class="button" data-action="version-profile" data-id="${esc(p.id)}">Neue Version</button>`:''}<button class="button" data-action="close-modal">Schließen</button></div>`,null,'PROFILDETAILS');
}
function inspectModule(m) {
showModal(m.name,`<div class="modal-summary"><dl class="detail-list"><dt>Version / Status</dt><dd>v${esc(m.version)} ${badge(m.status)}</dd><dt>Modul-ID</dt><dd class="mono">${esc(m.id)}</dd><dt>Zielbuilds</dt><dd>${esc((m.target_builds||[]).join(', '))}</dd><dt>Digest</dt><dd class="mono">${esc(m.digest||'Noch nicht veröffentlicht')}</dd><dt>Testnachweis</dt><dd>${esc(m.test_evidence||'Noch nicht hinterlegt')}</dd></dl></div>${m.source?`<pre class="code-block">${esc(m.source)}</pre>`:'<div class="alert alert-info">Der Quelltext ist für Skriptautoren und Administratoren sichtbar.</div>'}<details><summary>Parameterschema & Abhängigkeiten</summary><pre class="code-block light">${esc(json({parameters_schema:m.parameters_schema,dependencies:m.dependencies,retry_safe:m.retry_safe,timeout_seconds:m.timeout_seconds}))}</pre></details><div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,'MODULDETAILS');
showModal(m.name,`<div class="modal-summary"><dl class="detail-list"><dt>Version / Status</dt><dd>v${esc(m.version)} ${badge(m.status)}</dd><dt>Modul-ID</dt><dd class="mono">${esc(m.id)}</dd><dt>Zielbuilds</dt><dd>${esc((m.target_builds||[]).join(', '))}</dd><dt>Digest</dt><dd class="mono">${esc(m.digest||'Noch nicht veröffentlicht')}</dd><dt>Testnachweis</dt><dd>${esc(m.test_evidence||'Noch nicht hinterlegt')}</dd></dl></div>${m.source?`<pre class="code-block">${esc(m.source)}</pre>`:'<div class="alert alert-info">Der Quelltext ist für Skriptautoren und Administratoren sichtbar.</div>'}<details><summary>Parameterschema & Abhängigkeiten</summary>${dataView({parameters_schema:m.parameters_schema,dependencies:m.dependencies,retry_safe:m.retry_safe,timeout_seconds:m.timeout_seconds})}</details><div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,'MODULDETAILS');
}
async function isoForm() {
const groups=arr(await api('/groups'));
@@ -294,9 +258,10 @@ function userForm() {
},'BENUTZER & ROLLEN');
}
function secretForm() {
showModal('Geheimnis hinterlegen',form(field('name','Bezeichnung','',{required:true,full:true,placeholder:'Root-Hash · PVE Berlin'})+field('value','Geheimniswert','',{required:true,type:'password',full:true,autocomplete:'new-password',hint:'Für den Root-Zugang einen von Ihrem Zielbuild unterstützten Passwort-Hash verwenden.'}),'Verschlüsselt speichern','<div class="alert alert-info">Verwenden Sie die nach dem Speichern angezeigte Referenz-ID im Profil. Der Geheimniswert wird nicht erneut ausgegeben.</div>'),async data=>{
const result=await api('/secrets',{method:'POST',body:{name:data.get('name'),value:data.get('value')}});closeModal();toast('Geheimnis verschlüsselt gespeichert.');state.settingsTab='secrets';await refresh();
if(result?.id)showModal('Geheimnis gespeichert',`<p class="small-text muted">Referenz für <code>root_secret_id</code> oder einen Schritt:</p><input id="copy-value" value="${esc(result.id)}" readonly aria-label="Geheimnisreferenz"><div class="form-actions"><button class="button" data-action="copy-value">${svg('copy')}ID kopieren</button><button class="button primary" data-action="close-modal">Fertig</button></div>`);
const fields=field('name','Bezeichnung','',{required:true,full:true,placeholder:'Root-Zugang · PVE Berlin'})+field('kind','Verwendung','root_password',{type:'select',full:true,options:[{value:'root_password',label:'Root-Passwort für die Proxmox-Installation'},{value:'value',label:'Anderes Geheimnis oder vorhandener Passwort-Hash'}]})+field('value','Passwort / Geheimnis','',{required:true,type:'password',full:true,autocomplete:'new-password',hint:'Für ein neues Root-Passwort mindestens 12 Zeichen eingeben. Das Tool bereitet es für den Installer auf.'});
showModal('Zugang hinterlegen',form(fields,'Verschlüsselt speichern','<div class="alert alert-info">Den gespeicherten Zugang anschließend im Profil oder Schritt nach seinem Namen auswählen.</div>'),async data=>{
if(data.get('kind')==='root_password'&&String(data.get('value')).length<12)throw new Error('Das Root-Passwort benötigt mindestens 12 Zeichen.');
await api('/secrets',{method:'POST',body:{name:data.get('name'),value:data.get('value'),kind:data.get('kind')}});closeModal();toast('Zugang gespeichert und in den Auswahllisten verfügbar.');state.settingsTab='secrets';await refresh();
},'BETRIEBSGEHEIMNIS');
}
async function runAction(id, action) {
@@ -359,7 +324,7 @@ async function handleAction(button) {
if(action==='logout'){await api('/auth/logout',{method:'POST'});window.location.href='/login';return;}
if(action==='refresh'){await refresh();return;}
if(action==='create-host'){await hostForm();return;}
if(action==='import-hosts'){hostImportForm();return;}
if(action==='import-hosts'){await hostImportForm();return;}
if(action==='assign-discovery'){await hostForm(null,state.data.discoveries.find(d=>d.id===id));return;}
if(action==='edit-host'){await hostForm(await api(`/hosts/${encodeURIComponent(id)}`));return;}
if(action==='approve-host'||action==='preview-host'){await approveHost(id,action==='preview-host');return;}
@@ -368,22 +333,13 @@ async function handleAction(button) {
await api(`/hosts/${encodeURIComponent(id)}`,{method:'PATCH',body:{expected_version:host.version,blocked:!host.blocked}});toast(host.blocked?'Host entsperrt.':'Host gesperrt.');await refresh();return;
}
if(action==='create-profile'){await profileForm(button.dataset.kind);return;}
if(action==='use-single-disk-zfs'){
const input=modal.querySelector('textarea[name="values"]');
const values=JSON.parse(input.value);
if(!values || typeof values!=='object' || Array.isArray(values))throw new Error('Installationskonfiguration muss ein JSON-Objekt sein.');
values.disk_setup={filesystem:'zfs',selection:'all',zfs:{raid:'raid0'}};
input.value=json(values);
toast('ZFS (RAID0) mit automatischer Plattenwahl eingetragen.');
return;
}
if(action==='version-profile'){const p=arr(state.data).find(x=>x.id===id)||await api(`/profiles/${encodeURIComponent(id)}`);await profileForm(p.kind,p);return;}
if(action==='view-profile'){const p=arr(state.data).find(x=>x.id===id)||await api(`/profiles/${encodeURIComponent(id)}`);inspectProfile(p);return;}
if(action==='publish-profile'){await publishObject('profiles',id);return;}
if(action==='create-module'){moduleForm();return;}
if(action==='create-module'){await moduleForm();return;}
if(action==='module-catalog'){await moduleCatalog();return;}
if(action==='use-module-template'){const selected=state.catalog.find(m=>m.id===id);moduleForm({...selected,dependencies:selected.dependencies.map(name=>state.catalog.find(m=>m.id===name)?.name||name),version:0});return;}
if(action==='version-module'||action==='view-module'){const m=arr(state.data).find(x=>x.id===id)||await api(`/modules/${encodeURIComponent(id)}`);if(action==='version-module')moduleForm(m);else inspectModule(m);return;}
if(action==='use-module-template'){const selected=state.catalog.find(m=>m.id===id);await moduleForm({...selected,version:0});return;}
if(action==='version-module'||action==='view-module'){const m=arr(state.data).find(x=>x.id===id)||await api(`/modules/${encodeURIComponent(id)}`);if(action==='version-module')await moduleForm(m);else inspectModule(m);return;}
if(action==='publish-module'){await publishObject('modules',id);return;}
if(action==='create-iso'){await isoForm();return;}
if(action==='view-iso'){inspectISO(state.data.records.find(x=>x.id===id));return;}
@@ -398,7 +354,7 @@ async function handleAction(button) {
const el=document.getElementById('copy-value');
try{await navigator.clipboard.writeText(el.value);toast('In die Zwischenablage kopiert.');}catch{el.focus();el.select();toast('Text markiert. Mit Strg+C kopieren.');}return;
}
if(action==='view-audit'){const e=arr(state.data).find(x=>String(x.id)===id);showModal('Auditereignis',`<pre class="code-block light">${esc(json(e))}</pre><div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,e?.action||'AUDIT');}
if(action==='view-audit'){const e=arr(state.data).find(x=>String(x.id)===id);showModal('Auditereignis',`${dataView(e)}<div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,e?.action||'AUDIT');}
}
document.addEventListener('click',async event=>{
const button=event.target.closest('[data-action]');
+132
View File
@@ -0,0 +1,132 @@
/* Graphical editors for typed settings; serialization stays inside the client. */
'use strict';
const editorTypes = {string:'Text',integer:'Ganze Zahl',number:'Zahl',boolean:'Ja / Nein',object:'Gruppe',array:'Liste',null:'Nicht gesetzt'};
const handledEditorEvents = new WeakSet();
const editorLabels = {global:'Allgemein',network:'Netzwerk',disk_setup:'Systemdatenträger',filesystem:'Dateisystem',selection:'Plattenauswahl',zfs:'ZFS',raid:'RAID-Modus',filter:'Geräteauswahl',filter_match:'Verknüpfung',expected_count:'Erwartete Plattenanzahl',expected_serials:'Bestätigte Plattenkennungen',inventory_evidence:'Inventarnachweis',root_secret_id:'Root-Zugang',keyboard:'Tastatur',country:'Land',timezone:'Zeitzone',mailto:'E-Mail',fqdn:'Hostname',cidr:'IP-Adresse mit Präfix',gateway:'Gateway',dns:'DNS-Server',source:'Quelle',values:'Einstellungen',steps:'Schritte',parameters:'Parameter',secret_refs:'Geheimnisse',required:'Erforderlich',name:'Name',version:'Version',status:'Status',id:'Kennung',properties:'Felder',items:'Listenelemente',type:'Typ',description:'Beschreibung',default:'Standardwert',enum:'Auswahlwerte',additionalProperties:'Weitere Felder erlauben',minimum:'Minimum',maximum:'Maximum',minLength:'Mindestlänge',maxLength:'Maximallänge',minItems:'Mindesteinträge',maxItems:'Maximale Einträge',uniqueItems:'Eindeutige Einträge',pattern:'Formatregel'};
const editorLabel = key => editorLabels[key] || String(key).replace(/[_-]/g,' ');
Object.assign(editorLabels,{allowed_versions:'Erlaubte PVE-Versionen',dns_names:'Zu prüfende DNS-Namen',minimum_free_mb:'Freier Speicher in MiB',url:'Adresse',suite:'Distribution',components:'Komponenten',keyring:'Signaturschlüssel',packages:'Pakete',users:'Benutzer',authorized_keys:'Öffentliche SSH-Schlüssel',servers:'Zeitserver',enabled:'Aktiviert',path:'Pfad',content:'Inhalte',storage_ids:'Storage-Namen',require_time_sync:'Zeitsynchronisation erforderlich',resolved:'Wirksame Einstellungen',provenance:'Herkunft der Einstellungen',profiles:'Profile',reboot_budget:'Erlaubte Neustarts',timeout_seconds:'Zeitlimit in Sekunden',retry_safe:'Wiederholung erlaubt'});
const editorType = value => value===null?'null':Array.isArray(value)?'array':typeof value==='number'?(Number.isInteger(value)?'integer':'number'):typeof value;
const editorDefault = (type,schema={}) => Object.hasOwn(schema,'default')?structuredClone(schema.default):schema.enum?.length?structuredClone(schema.enum[0]):({object:{},array:[],boolean:false,integer:0,number:0,null:null,string:''}[type]??'');
function editorSchemaType(schema,value) {
const declared=Array.isArray(schema.type)?schema.type:[schema.type];
const actual=editorType(value);
return declared.includes(actual)?actual:declared.includes('number')&&actual==='integer'?'number':declared.find(type=>Object.hasOwn(editorTypes,type))||actual;
}
function dataEditor(name,value={},options={}) {
return `<section class="data-editor full" data-editor="${esc(name)}" aria-label="${esc(options.label||editorLabel(name))}">${dataNode(value,options.schema||{},options.label||editorLabel(name))}</section>`;
}
function dataNode(value,schema={},label='Wert') {
schema=schema && typeof schema==='object'?schema:{};
const type=editorSchemaType(schema,value);
const declared=Array.isArray(schema.type)?schema.type:[schema.type];
const fixed=declared.filter(Boolean).length===1 || Array.isArray(schema.enum) || Object.hasOwn(schema,'const');
const choice=fixed?`<span class="data-kind">${esc(editorTypes[type]||type)}</span>`:`<label class="data-kind-picker"><span class="sr-only">Datentyp für ${esc(label)}</span><select data-value-type>${Object.entries(editorTypes).map(([key,text])=>`<option value="${key}"${key===type?' selected':''}>${text}</option>`).join('')}</select></label>`;
return `<div class="data-node" data-value-node data-value-kind="${esc(type)}" data-value-schema="${esc(json(schema))}"><div class="data-heading"><strong>${esc(label)}</strong>${choice}</div>${schema.description?`<p class="field-help">${esc(schema.description)}</p>`:''}<div class="data-node-content">${dataNodeContent(value,type,schema,label)}</div></div>`;
}
function dataNodeContent(value,type,schema,label) {
if(Object.hasOwn(schema,'const'))return `<p class="field-help">Fest vorgegeben: ${esc(String(schema.const))}</p>`;
if(Array.isArray(schema.enum)) {
const choices=[...schema.enum];
if(value!==undefined&&!choices.some(item=>json(item)===json(value)))choices.push(value);
return `<label><span class="sr-only">${esc(label)}</span><select data-enum-value>${choices.map(item=>`<option value="${esc(json(item))}"${json(item)===json(value)?' selected':''}>${esc(typeof item==='object'?editorType(item):String(item))}</option>`).join('')}</select></label>`;
}
if(type==='object'||type==='array') {
const object=type==='object';
const properties=schema.properties||{};
const entries=object?Object.entries(value&&typeof value==='object'&&!Array.isArray(value)?value:{}):Array.isArray(value)?value.map((item,index)=>[String(index),item]):[];
if(object)for(const key of schema.required||[])if(!entries.some(([name])=>name===key))entries.push([key,editorDefault(editorSchemaType(properties[key]||{},''),properties[key]||{})]);
const children=entries.map(([key,item],index)=>dataEntry(key,item,object,object?properties[key]||{}:schema.items||{},index,(schema.required||[]).includes(key),object&&Object.hasOwn(properties,key))).join('');
const allowed=object?Object.keys(properties):[];
const picker=object&&allowed.length?`<label class="data-add-choice"><span class="sr-only">Feld hinzufügen</span><select data-new-property>${allowed.map(key=>`<option value="${esc(key)}">${esc(properties[key]?.title||editorLabel(key))}</option>`).join('')}${schema.additionalProperties!==false?'<option value="">Eigenes Feld</option>':''}</select></label>`:'';
const addAllowed=!object||schema.additionalProperties!==false||allowed.length;
return `<div class="data-children">${children}</div>${addAllowed?`<div class="data-add">${picker}<button type="button" class="button small" data-edit-action="add">${svg('plus')}${object?'Feld hinzufügen':'Eintrag hinzufügen'}</button></div>`:''}`;
}
if(type==='boolean')return `<label class="checkbox"><input type="checkbox" data-scalar-value${value?' checked':''}><span>${esc(label)}</span></label>`;
if(type==='null')return '<p class="field-help">Kein Wert gesetzt.</p>';
if(type==='number'||type==='integer')return `<label><span class="sr-only">${esc(label)}</span><input type="number" data-scalar-value value="${esc(value??0)}" step="${type==='integer'?'1':'any'}"${schema.minimum!==undefined?` min="${esc(schema.minimum)}"`:''}${schema.maximum!==undefined?` max="${esc(schema.maximum)}"`:''} required></label>`;
return `<label><span class="sr-only">${esc(label)}</span><textarea data-scalar-value rows="${String(value??'').includes('\n')?'4':'1'}"${schema.minLength?` minlength="${Number(schema.minLength)}"`:''}${schema.maxLength?` maxlength="${Number(schema.maxLength)}"`:''}>${esc(value??'')}</textarea></label>`;
}
function dataEntry(key,value,object,schema,index,required=false,known=false) {
const title=schema.title|| (object?editorLabel(key):`Eintrag ${index+1}`);
return `<div class="data-entry" data-entry>${object?`<label class="data-key-label"${known?' hidden':''}>Feldname<input data-entry-key value="${esc(key)}" required${required||known?' readonly':''}></label>`:''}<div class="data-entry-value">${dataNode(value,schema,title)}</div><div class="data-entry-actions">${!object?'<button type="button" class="icon-button" data-edit-action="up" aria-label="Eintrag nach oben">↑</button><button type="button" class="icon-button" data-edit-action="down" aria-label="Eintrag nach unten">↓</button>':''}${!required?'<button type="button" class="icon-button" data-edit-action="remove" aria-label="Eintrag entfernen">×</button>':'<span class="field-help">Pflichtfeld</span>'}</div></div>`;
}
function readDataEditor(container,name) {
const editor=[...(container.matches?.('[data-editor]')?[container]:[]),...container.querySelectorAll('[data-editor]')].find(item=>item.dataset.editor===name);
if(!editor)throw new Error(`Das Eingabefeld „${editorLabel(name)}“ ist nicht verfügbar.`);
return readDataNode(editor.querySelector('[data-value-node]'));
}
function readDataNode(node) {
const type=node.dataset.valueKind;
const schema=JSON.parse(node.dataset.valueSchema||'{}');
if(Object.hasOwn(schema,'const'))return structuredClone(schema.const);
const content=node.querySelector(':scope > .data-node-content');
const enumInput=content.querySelector(':scope > label > [data-enum-value]');
if(enumInput)return JSON.parse(enumInput.value);
if(type==='object'||type==='array') {
const entries=[...content.querySelector(':scope > .data-children').children];
if(type==='array')return entries.map(entry=>readDataNode(entry.querySelector(':scope > .data-entry-value > [data-value-node]')));
const result={};
for(const entry of entries) {
const key=entry.querySelector(':scope > .data-key-label > [data-entry-key]').value.trim();
if(!key)throw new Error('Bitte jedem Feld einen Namen geben.');
if(Object.hasOwn(result,key))throw new Error(`Das Feld „${key}“ ist doppelt vorhanden.`);
Object.defineProperty(result,key,{value:readDataNode(entry.querySelector(':scope > .data-entry-value > [data-value-node]')),enumerable:true,writable:true,configurable:true});
}
return result;
}
if(type==='null')return null;
const input=content.querySelector('[data-scalar-value]');
if(type==='boolean')return input.checked;
if(type==='number'||type==='integer') {
const number=Number(input.value);
if(!input.value.trim()||!Number.isFinite(number)||(type==='integer'&&!Number.isInteger(number)))throw new Error('Bitte eine gültige Zahl eingeben.');
return number;
}
return input.value;
}
function wireDataEditors(container) {
if(container.dataset.dataEditorsBound)return;
container.dataset.dataEditorsBound='true';
container.addEventListener('click',event=>{
const button=event.target.closest('[data-edit-action]');
if(!button||!container.contains(button))return;
if(handledEditorEvents.has(event))return;
handledEditorEvents.add(event);
event.preventDefault();
const action=button.dataset.editAction;
const node=button.closest('[data-value-node]');
const schema=JSON.parse(node.dataset.valueSchema||'{}');
const entry=button.closest('[data-entry]');
if(action==='remove'){entry.remove();return;}
if(action==='up'){if(entry.previousElementSibling)entry.previousElementSibling.before(entry);return;}
if(action==='down'){if(entry.nextElementSibling)entry.nextElementSibling.after(entry);return;}
if(action==='add') {
const object=node.dataset.valueKind==='object';
const content=node.querySelector(':scope > .data-node-content');
const children=content.querySelector(':scope > .data-children');
const key=content.querySelector(':scope > .data-add [data-new-property]')?.value||'';
if(object&&key&&[...children.children].some(row=>row.querySelector('[data-entry-key]')?.value===key)){toast('Dieses Feld ist bereits vorhanden.',true);return;}
const childSchema=object?schema.properties?.[key]||{}:schema.items||{};
const type=editorSchemaType(childSchema,'');
children.insertAdjacentHTML('beforeend',dataEntry(key,editorDefault(type,childSchema),object,childSchema,children.children.length,(schema.required||[]).includes(key),object&&Object.hasOwn(schema.properties||{},key)));
children.lastElementChild.querySelector('input,select,textarea')?.focus();
}
});
container.addEventListener('change',event=>{
if(!event.target.matches('[data-value-type]'))return;
if(handledEditorEvents.has(event))return;
handledEditorEvents.add(event);
const node=event.target.closest('[data-value-node]');
const type=event.target.value;
node.dataset.valueKind=type;
node.querySelector(':scope > .data-node-content').innerHTML=dataNodeContent(editorDefault(type),type,JSON.parse(node.dataset.valueSchema||'{}'),'Wert');
});
}
function dataView(value) {
if(value===null||value===undefined)return '<span class="muted">Nicht gesetzt</span>';
if(typeof value==='boolean')return `<span class="badge ${value?'green':''}">${value?'Ja':'Nein'}</span>`;
if(Array.isArray(value))return value.length?`<ol class="data-view-list">${value.map(item=>`<li>${dataView(item)}</li>`).join('')}</ol>`:'<span class="muted">Keine Einträge</span>';
if(typeof value==='object')return Object.keys(value).length?`<dl class="data-view">${Object.entries(value).map(([key,item])=>`<dt>${esc(editorLabel(key))}</dt><dd>${dataView(item)}</dd>`).join('')}</dl>`:'<span class="muted">Keine zusätzlichen Einstellungen</span>';
return `<span class="data-text">${esc(value)}</span>`;
}
+16
View File
@@ -0,0 +1,16 @@
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
#modal{width:min(980px,calc(100vw - 28px));max-width:980px}
.form-section{min-width:0;border:1px solid var(--line);background:#fbfcfe;border-radius:10px;padding:20px;margin:0}
.form-section h3,.section-heading h3{font-size:14px;margin:0 0 14px;color:#36445a}
.form-section>.form-grid{margin-top:12px}.form-section>summary{font-weight:600;font-size:13px;cursor:pointer}.form-section[open]>summary{margin-bottom:16px}
.section-heading,.data-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}.section-heading h3{margin:0}.section-heading{margin-bottom:14px}
.field-help{font-size:11px;line-height:1.65;color:var(--muted);margin:6px 0 12px}
.data-editor{min-width:0}.data-node{min-width:0}.data-heading{margin-bottom:10px}.data-heading strong{font-size:12px;font-weight:600}.data-kind{font-size:10px;color:var(--muted);background:#eef1f7;padding:4px 8px;border-radius:4px}
.data-kind-picker select{font-size:10px;padding:5px 8px;min-height:28px;max-width:130px}.data-entry{display:flex;gap:12px;align-items:flex-start;min-width:0;background:#fff;border:1px solid #e6eaf1;border-radius:8px;padding:14px;margin:10px 0}
.data-key-label{flex:0 1 145px;min-width:85px;font-size:10px}.data-entry-value{flex:1;min-width:0}.data-entry-actions{display:flex;align-items:center;gap:2px;flex-shrink:0}.data-entry-actions .icon-button{font-size:20px;padding:4px;min-width:27px}
.data-add{display:flex;align-items:flex-end;gap:10px;margin-top:12px}.data-add-choice{max-width:260px;min-width:0}.data-add-choice select{min-height:32px;font-size:11px}.data-node textarea{resize:vertical;min-height:36px;font-size:12px}.data-node .checkbox{padding-top:5px}
.data-view{display:grid;grid-template-columns:minmax(100px,170px) minmax(0,1fr);gap:10px 18px;margin:8px 0;font-size:12px;line-height:1.7}.data-view dt{color:var(--muted);overflow-wrap:anywhere}.data-view dd{margin:0;min-width:0;overflow-wrap:anywhere}.data-view .data-view{border-left:2px solid #edf0f5;padding-left:14px;grid-template-columns:minmax(90px,140px) minmax(0,1fr)}.data-view-list{padding-left:20px;margin:0}.data-view-list>li{margin:4px 0}.data-text{white-space:pre-wrap;overflow-wrap:anywhere}
.identity-row{display:grid;grid-template-columns:160px minmax(0,1fr) 30px;align-items:end;gap:12px;margin-bottom:14px}.identity-row .icon-button{align-self:center;margin-top:16px}
.form-section .form-section{background:#fff;margin-top:14px}.form-section .data-editor{margin-top:10px}.source-status{padding:12px 14px;background:#eef7f2;border:1px solid #d9eadf;border-radius:7px;font-size:12px}.source-status:empty{display:none}.script-preview{max-height:240px;overflow:auto}
[data-postinstall-editor]{grid-column:1/-1;min-width:0}[data-step-key]{border:1px solid var(--line);border-radius:10px;padding:18px;margin:14px 0;background:#fbfcfe}
@media(max-width:760px){#modal{width:calc(100vw - 16px);max-height:calc(100dvh - 16px)}.form-section{padding:14px}.data-entry{flex-wrap:wrap;padding:10px;gap:10px}.data-key-label{flex-basis:calc(100% - 90px)}.data-entry-value{flex-basis:100%;order:2}.data-entry-actions{margin-left:auto}.data-view,.data-view .data-view{grid-template-columns:1fr;gap:3px}.data-view dd{margin-bottom:10px}.identity-row{grid-template-columns:minmax(0,1fr) 30px}.identity-row>label:first-child{grid-column:1/-1}.identity-row .icon-button{margin-top:0}.data-add{flex-wrap:wrap}.data-add-choice{max-width:100%}}
+55
View File
@@ -0,0 +1,55 @@
'use strict';
function identityRow(identity={kind:'serial',value:''}) {
return `<div class="identity-row" data-identity-row>${field('identity_kind','Identität',identity.kind,{type:'select',options:[{value:'serial',label:'Seriennummer'},{value:'uuid',label:'System-UUID'},{value:'mac',label:'MAC-Adresse'}]})}${field('identity_value','Wert',identity.value,{required:true})}<button type="button" class="icon-button" data-host-action="remove-identity" aria-label="Identität entfernen">×</button></div>`;
}
function identitiesEditor(identities=[]) {
return `<section class="form-section full" data-identities><h3>Hardware erkennen</h3><p class="field-help">Mindestens eine Kennung ordnet diesen Server beim Start zu.</p><div data-identity-rows>${(identities.length?identities:[{kind:'serial',value:''}]).map(identityRow).join('')}</div><button type="button" class="button small" data-host-action="add-identity">${svg('plus')}Kennung hinzufügen</button></section>`;
}
function readIdentities(container) {
const identities=[...container.querySelectorAll('[data-identity-row]')].map(row=>({kind:row.querySelector('[name="identity_kind"]').value,value:row.querySelector('[name="identity_value"]').value.trim()}));
if(!identities.length||identities.some(item=>!item.value))throw new Error('Bitte mindestens eine vollständige Hardware-Kennung eintragen.');
return identities;
}
function wireHostRows(container) {
if(container.dataset.hostRowsBound)return;
container.dataset.hostRowsBound='true';
container.addEventListener('click',event=>{
const button=event.target.closest('[data-host-action]');
if(!button)return;
event.preventDefault();
if(button.dataset.hostAction==='remove-identity')button.closest('[data-identity-row]').remove();
if(button.dataset.hostAction==='add-identity')button.closest('[data-identities]').querySelector('[data-identity-rows]').insertAdjacentHTML('beforeend',identityRow());
if(button.dataset.hostAction==='remove-host')button.closest('[data-import-host]').remove();
if(button.dataset.hostAction==='add-host')container.querySelector('[data-import-hosts]').insertAdjacentHTML('beforeend',importHostRow());
});
}
async function graphicalHostForm(existing=null,discovery=null) {
const [profiles,isos,secrets]=await Promise.all([api('/profiles'),api('/iso-records'),api('/secrets')]);
const h=existing||discovery||{};
const withMissing=(items,id)=>id&&!items.some(item=>item.id===id)?[...items,{id,name:'Bestehende Zuordnung (nicht mehr verfügbar)'}]:items;
const fields=`<section class="form-section full"><h3>Server</h3><div class="form-grid">${field('fqdn','Vollständiger Hostname (FQDN)',h.fqdn,{required:true,placeholder:'pve-01.example.net'})}${field('site','Standort',h.site,{required:true,placeholder:'Rechenzentrum Berlin'})}${field('management_ip','Management-IP mit Präfix',h.management_ip,{placeholder:'192.0.2.10/24',hint:'Zum Erfassen optional; vor der Installation erforderlich.',full:true})}${dataEditor('host-tags',arr(h.tags),{label:'Tags',schema:{type:'array',items:{type:'string'}}})}</div></section>${identitiesEditor(arr(h.identities))}<section class="form-section full"><h3>Installation zuweisen</h3><div class="form-grid">${field('installation_profile_id','Installationsprofil',h.installation_profile_id,{type:'select',options:selectObjects(withMissing(arr(profiles).filter(p=>p.kind==='installation'&&p.status==='published'),h.installation_profile_id),'Noch nicht zuweisen')})}${field('postinstall_profile_id','Postinstallationsprofil',h.postinstall_profile_id,{type:'select',options:selectObjects(withMissing(arr(profiles).filter(p=>p.kind==='postinstall'&&p.status==='published'),h.postinstall_profile_id),'Noch nicht zuweisen')})}${field('iso_id','Installationsmedium',h.iso_id,{type:'select',full:true,options:selectObjects(withMissing(arr(isos),h.iso_id),'Noch nicht zuweisen')})}</div></section><details class="form-section full"><summary>Abweichende Einstellungen für diesen Server</summary><p class="field-help">Ohne Abweichung gelten die Werte des zugewiesenen Profils.</p>${installationEditor(h.overrides||{},{prefix:'host-override',override:true,secrets:arr(secrets)})}</details>`;
showModal(existing?'Server bearbeiten':'Server hinzufügen',form(fields,existing?'Änderungen speichern':'Server anlegen'),async data=>{
const body={fqdn:data.get('fqdn'),site:data.get('site'),management_ip:data.get('management_ip')||null,tags:readDataEditor(modal,'host-tags'),identities:readIdentities(modal),installation_profile_id:data.get('installation_profile_id')||null,postinstall_profile_id:data.get('postinstall_profile_id')||null,iso_id:data.get('iso_id')||null,overrides:readInstallationEditor(modal,'host-override',h.overrides||{})};
if(existing){for(const key of Object.keys(body))if(JSON.stringify(body[key])===JSON.stringify(existing[key]??null))delete body[key];if(!Object.keys(body).length){closeModal();toast('Keine Änderungen vorhanden.');return;}body.expected_version=existing.version;}
await api(existing?`/hosts/${encodeURIComponent(existing.id)}`:'/hosts',{method:existing?'PATCH':'POST',body});closeModal();toast(existing?'Server aktualisiert.':'Server wurde angelegt.');await refresh();
},'INVENTAR');
wireDataEditors(modal);
wireHostRows(modal);
wireInstallationEditor(modal,'host-override');
}
function importHostRow() {
return `<section class="form-section" data-import-host><div class="section-heading"><h3>Server</h3><button type="button" class="icon-button" data-host-action="remove-host" aria-label="Server entfernen">×</button></div><div class="form-grid">${field('import_fqdn','Vollständiger Hostname','',{required:true,placeholder:'pve-01.example.net'})}${field('import_ip','Management-IP mit Präfix','',{placeholder:'192.0.2.10/24'})}${identitiesEditor()}</div></section>`;
}
async function graphicalHostImport() {
const [profiles,isos]=await Promise.all([api('/profiles'),api('/iso-records')]);
const fields=field('site','Gemeinsamer Standort','',{required:true,full:true})+field('installation_profile_id','Installationsprofil','',{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='installation'&&p.status==='published'),'Noch nicht zuweisen')})+field('postinstall_profile_id','Postinstallationsprofil','',{type:'select',options:selectObjects(arr(profiles).filter(p=>p.kind==='postinstall'&&p.status==='published'),'Noch nicht zuweisen')})+field('iso_id','Installationsmedium','',{type:'select',full:true,options:selectObjects(arr(isos),'Noch nicht zuweisen')})+`<div class="full stack" data-import-hosts>${importHostRow()}</div><div class="full"><button type="button" class="button" data-host-action="add-host">${svg('plus')}Weiteren Server hinzufügen</button></div>`;
showModal('Mehrere Server erfassen',form(fields,'Server anlegen'),async data=>{
const rows=[...modal.querySelectorAll('[data-import-host]')];
if(!rows.length||rows.length>100)throw new Error('Bitte zwischen einem und 100 Servern erfassen.');
const common={site:data.get('site'),installation_profile_id:data.get('installation_profile_id')||null,postinstall_profile_id:data.get('postinstall_profile_id')||null,iso_id:data.get('iso_id')||null};
const hosts=rows.map(row=>({...common,fqdn:row.querySelector('[name="import_fqdn"]').value,management_ip:row.querySelector('[name="import_ip"]').value||null,identities:readIdentities(row)}));
await api('/hosts/import',{method:'POST',body:hosts});closeModal();toast(`${hosts.length} Server angelegt.`);await refresh();
},'SERVERINVENTAR');
wireHostRows(modal);
}
+296
View File
@@ -0,0 +1,296 @@
/* Graphical installation settings. The server validates the resolved host configuration. */
const installationForm = (() => {
const clone = value => JSON.parse(JSON.stringify(value));
const object = (value, label) => {
if (value === undefined) return {};
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label}: Erwartet werden benannte Einstellungen.`);
return value;
};
const keyboards = [
['de','Deutsch'],['de-ch','Deutsch (Schweiz)'],['dk','Dänisch'],['en-gb','Englisch (Großbritannien)'],
['en-us','Englisch (USA)'],['es','Spanisch'],['fi','Finnisch'],['fr','Französisch'],
['fr-be','Französisch (Belgien)'],['fr-ca','Französisch (Kanada)'],['fr-ch','Französisch (Schweiz)'],
['hu','Ungarisch'],['is','Isländisch'],['it','Italienisch'],['jp','Japanisch'],['lt','Litauisch'],
['mk','Mazedonisch'],['nl','Niederländisch'],['no','Norwegisch'],['pl','Polnisch'],
['pt','Portugiesisch'],['pt-br','Portugiesisch (Brasilien)'],['se','Schwedisch'],['si','Slowenisch'],['tr','Türkisch']
];
const countryCodes = 'AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW'.split(' ');
const countries = (() => {
const names = typeof Intl.DisplayNames === 'function' ? new Intl.DisplayNames(['de'],{type:'region'}) : null;
return countryCodes.map(code => [code.toLowerCase(),names?.of(code) || ({DE:'Deutschland',AT:'Österreich',CH:'Schweiz'}[code] || code)])
.sort((a,b) => a[1].localeCompare(b[1],'de'));
})();
const timezones = (() => {
const regions = {Africa:'Afrika',America:'Amerika',Antarctica:'Antarktis',Arctic:'Arktis',Asia:'Asien',Atlantic:'Atlantik',Australia:'Australien',Europe:'Europa',Indian:'Indischer Ozean',Pacific:'Pazifik'};
const supported = typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : [];
return [...new Set(['Europe/Berlin','UTC',...supported])].map(zone => [zone,zone === 'UTC' ? 'UTC (koordinierte Weltzeit)' : zone.split('/').map((part,index) => index === 0 ? regions[part] || part : part.replaceAll('_',' ')).join(' / ')])
.sort((a,b) => a[1].localeCompare(b[1],'de'));
})();
const interfaces = [
['ID_NET_NAME_MAC','Name aus MAC-Adresse'], ['ID_NET_NAME_PATH','Name aus Gerätepfad'],
['ID_NET_NAME_SLOT','Name des Steckplatzes'], ['ID_NET_NAME_ONBOARD','Name der Onboard-Schnittstelle'],
['INTERFACE','Schnittstellenname'], ['ID_PATH','Gerätepfad'], ['ID_NET_DRIVER','Treiber']
];
const raids = [['raid0','RAID0 / Einzelplatte'],['raid1','RAID1 / Spiegel'],['raid10','RAID10'],['raidz-1','RAIDZ1'],['raidz-2','RAIDZ2'],['raidz-3','RAIDZ3']];
const zfsNumbers = [['ashift','Sektorgröße (ashift)',9,16,true],['arc-max','Maximaler ARC in MiB',64,1048576,true],['copies','Datenkopien',1,3,true],['hdsize','Verwendete Plattengröße in GiB',2,1000000,false]];
const lvmNumbers = [['hdsize','Verwendete Plattengröße in GiB',2],['swapsize','Swap-Größe in GiB',0],['maxroot','Maximale Root-Größe in GiB',2],['maxvz','Maximale Daten-Größe in GiB',0],['minfree','Freier Platz in GiB',0]];
const optionsFor = (entries, current, blank = 'Nicht festlegen') => {
const items = entries.map(item => Array.isArray(item) ? {value:item[0],label:item[1]} : {value:item,label:item});
if (current !== undefined && current !== '' && !items.some(item => String(item.value) === String(current))) items.push({value:current,label:`Bestehender Wert: ${current}`});
return [{value:'',label:blank}, ...items];
};
function root(container, prefix) {
const nodes = [...(container.matches?.('[data-installation-editor]') ? [container] : []), ...container.querySelectorAll('[data-installation-editor]')];
const found = nodes.find(node => node.dataset.installationEditor === prefix);
if (!found) throw new Error('Das Installationsformular wurde nicht gefunden. Bitte erneut öffnen.');
return found;
}
function text(value, label) {
if (value === undefined) return '';
if (typeof value !== 'string') throw new Error(`${label}: Der gespeicherte Wert muss Text sein.`);
return value;
}
function strings(value, label) {
if (value === undefined) return [];
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error(`${label}: Die gespeicherten Einträge müssen Text sein.`);
return value;
}
function numeric(value, label) {
if (value === undefined) return '';
if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`${label}: Der gespeicherte Wert muss eine Zahl sein.`);
return value;
}
function networkRow(prefix, key = 'ID_NET_NAME_MAC', value = '') {
const mac = key === 'ID_NET_NAME_MAC' && (!value || /^enx[0-9a-f]{12}$/.test(value));
const known = interfaces.some(item => item[0] === key);
const selected = mac ? '__mac' : known ? key : '__custom';
if (mac && value) value = value.slice(3).match(/../g).join(':');
return `<div class="form-grid full" data-installation-network-row>
${field(`${prefix}.interface-key`,'Schnittstelle erkennen über',selected,{type:'select',options:optionsFor([['__mac','MAC-Adresse'],...interfaces,['__custom','Anderes Merkmal']],selected)})}
${field(`${prefix}.interface-value`,'MAC-Adresse oder erwarteter Wert',text(value,'Schnittstellenmerkmal'),{placeholder:mac?'02:00:00:00:00:01':'Erwarteter Merkmalswert'})}
<div class="full" data-installation-custom-key${known?' hidden':''}>${field(`${prefix}.interface-custom`,'Name des anderen Merkmals',known?'':key,{full:true})}</div>
<button type="button" class="button small" data-installation-remove-interface>Merkmal entfernen</button>
</div>`;
}
function render(values = {}, options = {}) {
const prefix = options.prefix || 'installation', override = Boolean(options.override);
values = clone(object(values,'Installationskonfiguration'));
const fresh = !override && Object.keys(values).length === 0;
if (fresh) values = {global:{keyboard:'de',country:'de',timezone:'Europe/Berlin'},network:{source:'from-answer'},disk_setup:{filesystem:'zfs',selection:'all',zfs:{raid:'raid0'}}};
const global = object(values.global,'Allgemeine Einstellungen'), network = object(values.network,'Managementnetz');
const disks = object(values.disk_setup,'Datenträger'), zfs = object(disks.zfs,'ZFS'), lvm = object(disks.lvm,'LVM');
const netFilter = object(network.filter,'Schnittstellenmerkmale'), diskFilter = object(disks.filter,'Datenträgerfilter');
if (Object.keys(diskFilter).length > 1) throw new Error('Die Datenträgerauswahl enthält mehrere Filter. Es wird genau ein Seriennummern- oder WWN-Filter unterstützt.');
if (global['reboot-on-error'] !== undefined && typeof global['reboot-on-error'] !== 'boolean') throw new Error('Neustart bei Fehler: Der gespeicherte Wert muss Ja oder Nein sein.');
const input = (name,label,value,settings={}) => field(`${prefix}.${name}`,label,text(value,label),{...settings});
const select = (name,label,value,entries,settings={}) => input(name,label,value,{type:'select',options:optionsFor(entries,value,override?'Vom Profil übernehmen':'Nicht festlegen'),...settings});
const number = (name,label,value,min,max,integer=false) => field(`${prefix}.${name}`,label,numeric(value,label),{type:'number',min,max,hint:'Leer lassen: vorhandene Vorgabe verwenden.'}).replace('type="number"',`type="number" step="${integer?'1':'any'}"`);
const section = (name,title,body) => `<section class="full installation-section" data-installation-section="${esc(name)}">
${override?field(`${prefix}.enable.${name}`,`${title} für diesen Host festlegen`,Object.hasOwn(values,name),{type:'checkbox',full:true}):`<h3 class="section-label">${esc(title)}</h3>`}
<div class="form-grid" data-installation-section-body>${body}</div></section>`;
const required = !override;
const secretId = text(values.root_secret_id,'Root-Geheimnis');
const secrets = (options.secrets || []).map(secret => [secret.id, secret.name || secret.id]);
if (secretId && !secrets.some(secret => secret[0] === secretId)) secrets.push([secretId,`Bisherige Referenz (${secretId})`]);
const diskMode = disks.selection !== undefined ? disks.selection : Object.keys(disks).some(key => ['filter','filter_match','expected_serials','expected_count'].includes(key)) ? 'filtered' : '';
const diskKey = Object.keys(diskFilter)[0] || (override ? '' : 'ID_SERIAL_SHORT');
const diskPattern = diskKey ? diskFilter[diskKey] : undefined;
const networkRows = Object.entries(netFilter).map(([key,value]) => networkRow(prefix,key,value)).join('') || (override?'':networkRow(prefix));
const reboot = global['reboot-on-error'] === undefined ? '' : String(global['reboot-on-error']);
const general = select('global.keyboard','Tastatur',global.keyboard,keyboards,{required})
+ select('global.country','Land',global.country,countries,{required})
+ select('global.timezone','Zeitzone',global.timezone,timezones,{required})
+ input('global.mailto','E-Mail für Systemmeldungen',global.mailto,{required,type:'email'})
+ input('global.fqdn','Hostname-Vorgabe (optional)',global.fqdn,{hint:'Der tatsächlich zugewiesene Server liefert seinen vollständigen Hostnamen.'})
+ select('global.reboot-on-error','Bei Installationsfehler neu starten',reboot,[['false','Nein'],['true','Ja']])
+ dataEditor(`${prefix}.global.root-ssh-keys`,strings(global['root-ssh-keys'],'SSH-Schlüssel'),{label:'Öffentliche SSH-Schlüssel für root',schema:{type:'array',description:'Für jeden öffentlichen Schlüssel einen Eintrag hinzufügen. Keine privaten Schlüssel hinterlegen.',items:{type:'string',title:'Öffentlicher SSH-Schlüssel',minLength:1}}});
const networking = select('network.source','Netzwerkkonfiguration',network.source,[['from-answer','Festes Managementnetz aus diesem Profil']],{required})
+ input('network.gateway','Standardgateway',network.gateway,{required})
+ input('network.dns','DNS-Server',network.dns,{required})
+ input('network.cidr','Management-IP mit Präfix (optional)',network.cidr,{hint:'Die tatsächliche IP mit Präfix kommt aus dem Serverinventar.'})
+ `<div class="full"><h4>Management-Schnittstelle</h4><p class="small-text muted">Ein oder mehrere Merkmale der gewünschten Netzwerkkarte. Der Installer verwendet diese zur Auswahl.</p><div data-installation-network-rows>${networkRows}</div><button type="button" class="button small" data-installation-add-interface>Merkmal hinzufügen</button></div>`;
const filtering = select('disk.filter.key','Datenträger erkennen über',diskKey,[['ID_SERIAL_SHORT','Seriennummer'],['ID_SERIAL','Vollständige Serienkennung'],['ID_WWN','WWN']],{required})
+ input('disk.filter.value','Seriennummer oder Suchmuster',diskPattern,{required,hint:'Ein konkreter Wert oder ein Muster für die unten bestätigten Kennungen.'})
+ select('disk.filter_match','Filterverknüpfung',disks.filter_match,[['all','Alle Merkmale'],['any','Mindestens ein Merkmal']])
+ number('disk.expected_count','Anzahl bestätigter Datenträger',disks.expected_count,1,16,true)
+ dataEditor(`${prefix}.disk.expected_serials`,strings(disks.expected_serials,'Datenträgerkennungen'),{label:'Bestätigte Seriennummern oder WWNs',schema:{type:'array',description:'Jeden bestätigten Datenträger als eigenen Eintrag hinzufügen. Die Anzahl wird aus der Liste übernommen, wenn das Anzahlfeld leer bleibt; die tatsächliche Hardware wird dadurch nicht gezählt.',items:{type:'string',title:'Seriennummer oder WWN',minLength:1}}});
const zfsFields = select('disk.zfs.raid','ZFS-Verbund',zfs.raid,raids,{required})
+ zfsNumbers.map(([key,label,min,max,integer]) => number(`disk.zfs.${key}`,label,zfs[key],min,max,integer)).join('')
+ select('disk.zfs.checksum','Prüfsummen',zfs.checksum,[['on','Standard'],['fletcher4','Fletcher4'],['sha256','SHA-256']])
+ select('disk.zfs.compress','Kompression',zfs.compress,[['on','Standard'],['off','Aus'],['lzjb','LZJB'],['lz4','LZ4'],['zle','ZLE'],['gzip','Gzip'],['zstd','Zstandard']]);
const storage = select('disk.filesystem','Dateisystem',disks.filesystem,[['zfs','ZFS'],['ext4','ext4 / LVM'],['xfs','XFS / LVM']],{required})
+ select('disk.mode','Datenträgerauswahl',diskMode,[['all','Automatisch Server mit einer Zielplatte'],['filtered','Über Seriennummer oder WWN auswählen']],{required})
+ `<div class="full alert alert-info" data-installation-all-note>Für Server mit genau einer Zielplatte: Es werden alle vom Installer erkannten Zielplatten verwendet. Seriennummer und Gerätename sind nicht erforderlich. Die tatsächliche Anzahl wird nicht geprüft.</div>`
+ `<div class="form-grid full" data-installation-filter-fields>${filtering}</div>`
+ input('disk.inventory_evidence','Referenz zur Hardwareprüfung',disks.inventory_evidence,{full:true,hint:'Bei Auswahl per Seriennummer oder WWN erforderlich; bei automatischer Auswahl optional.'})
+ `<div class="form-grid full" data-installation-zfs-fields>${zfsFields}</div>`
+ `<div class="form-grid full" data-installation-lvm-fields>${lvmNumbers.map(([key,label,min]) => number(`disk.lvm.${key}`,label,lvm[key],min,1000000)).join('')}</div>`;
return `<div class="full installation-editor" data-installation-editor="${esc(prefix)}" data-installation-state="${esc(JSON.stringify({values,override}))}">
${override?'<p class="small-text muted">Nicht aktivierte Bereiche und leere Felder übernehmen die veröffentlichten Profilwerte. Gesperrte Profilfelder können nicht überschrieben werden.</p>':''}
<div class="form-grid">${section('global','Allgemeine Einstellungen',general)}
${section('root_secret_id','Root-Zugang',select('root_secret_id','Gespeichertes Root-Geheimnis',secretId,secrets,{required,full:true,hint:'Root-Zugang vorher unter Einstellungen → Geheimnisse anlegen.'}))}
${section('network','Managementnetz',networking)}${section('disk_setup','Systemdatenträger',storage)}</div></div>`;
}
function read(container, prefix = 'installation', previousValues = {}) {
const editor = root(container,prefix), state = JSON.parse(editor.dataset.installationState);
const values = clone(object(previousValues,'Installationskonfiguration'));
const before = state.values;
const control = name => [...editor.querySelectorAll('[name]')].find(node => node.name === `${prefix}.${name}`);
const value = name => (control(name)?.value || '').trim();
const enabled = name => !state.override || Boolean(control(`enable.${name}`)?.checked);
const assign = (target,key,next) => {if (next === undefined) delete target[key]; else target[key] = next;};
const string = name => value(name) || undefined;
const numericValue = (name,label,min,max,integer=false) => {
const node = control(name), raw = value(name);
if (node?.validity?.badInput) throw new Error(`${label}: Bitte eine gültige Zahl eingeben.`);
if (!raw) return undefined;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < min || parsed > max || (integer && !Number.isInteger(parsed))) throw new Error(`${label}: ${integer?'Eine ganze Zahl':'Eine Zahl'} zwischen ${min} und ${max} eingeben.`);
if (name.startsWith('disk.lvm.') && parsed >= 1000000) throw new Error(`${label}: Die Größe muss kleiner als 1000000 GiB sein.`);
return parsed;
};
const list = (name,previous) => {
const result = readDataEditor(editor,`${prefix}.${name}`);
if (!Array.isArray(result) || result.some(item => typeof item !== 'string' || !item.trim())) throw new Error(`${name.includes('ssh')?'SSH-Schlüssel':'Datenträgerkennungen'}: Jeden Eintrag ausfüllen oder den leeren Eintrag entfernen.`);
return result.length || Array.isArray(previous) && previous.length === 0 ? result : undefined;
};
const base = name => clone(object(Object.hasOwn(values,name)?values[name]:before[name],name));
if (enabled('global')) {
const global = base('global');
for (const key of ['keyboard','country','timezone','mailto','fqdn']) assign(global,key,string(`global.${key}`));
const reboot = value('global.reboot-on-error');
if (reboot && !['true','false'].includes(reboot)) throw new Error('Neustart bei Fehler: Bitte Ja, Nein oder Übernehmen wählen.');
assign(global,'reboot-on-error',reboot ? reboot === 'true' : undefined);
assign(global,'root-ssh-keys',list('global.root-ssh-keys',before.global?.['root-ssh-keys']));
values.global = global;
} else delete values.global;
if (enabled('root_secret_id')) assign(values,'root_secret_id',string('root_secret_id'));
else delete values.root_secret_id;
if (enabled('network')) {
const network = base('network');
for (const key of ['source','gateway','dns','cidr']) assign(network,key,string(`network.${key}`));
const filter = Object.create(null);
for (const row of editor.querySelectorAll('[data-installation-network-row]')) {
const input = suffix => [...row.querySelectorAll('[name]')].find(node => node.name === `${prefix}.${suffix}`)?.value.trim() || '';
const selected = input('interface-key'), key = selected === '__custom' ? input('interface-custom') : selected === '__mac' ? 'ID_NET_NAME_MAC' : selected;
let expected = input('interface-value');
if (!expected && selected !== '__custom') continue;
if (!key || !expected) throw new Error('Management-Schnittstelle: Merkmal und erwarteten Wert vollständig ausfüllen.');
if (selected === '__mac') {
const mac = expected.replace(/[:-]/g,'').toLowerCase();
if (!/^[0-9a-f]{12}$/.test(mac)) throw new Error('Management-Schnittstelle: Eine gültige MAC-Adresse wie 02:00:00:00:00:01 eingeben.');
expected = `enx${mac}`;
}
if (['__proto__','prototype','constructor'].includes(key) || Object.hasOwn(filter,key)) throw new Error('Management-Schnittstelle: Jedes gültige Merkmal darf nur einmal vorkommen.');
filter[key] = expected;
}
const keepEmptyFilter = before.network?.filter && Object.keys(before.network.filter).length === 0;
if (!state.override && !Object.keys(filter).length) throw new Error('Bitte die MAC-Adresse oder ein anderes Merkmal der Management-Schnittstelle angeben.');
assign(network,'filter',Object.keys(filter).length || keepEmptyFilter ? {...filter} : undefined);
values.network = network;
} else delete values.network;
if (enabled('disk_setup')) {
const disks = base('disk_setup'), filesystem = value('disk.filesystem'), mode = value('disk.mode');
if (filesystem && !['zfs','ext4','xfs'].includes(filesystem)) throw new Error('Bitte ZFS, ext4 oder XFS auswählen.');
if (mode && !['all','filtered'].includes(mode)) throw new Error('Bitte eine gültige Datenträgerauswahl treffen.');
assign(disks,'filesystem',filesystem || undefined);
assign(disks,'inventory_evidence',string('disk.inventory_evidence'));
if (mode === 'all') {
const raid = value('disk.zfs.raid');
if ((filesystem && filesystem !== 'zfs') || (raid && raid !== 'raid0') || (!state.override && (!filesystem || !raid))) throw new Error('Die automatische Datenträgerauswahl benötigt ZFS mit RAID0.');
disks.selection = 'all';
for (const key of ['filter','filter_match','expected_count','expected_serials']) delete disks[key];
} else {
delete disks.selection;
const key = value('disk.filter.key'), pattern = value('disk.filter.value');
if ((key && pattern) && !['ID_SERIAL','ID_SERIAL_SHORT','ID_WWN'].includes(key)) throw new Error('Bitte Seriennummer oder WWN zur Datenträgerauswahl verwenden.');
if (mode === 'filtered' && !state.override && (!key || !pattern)) throw new Error('Bitte ein Datenträgermerkmal und einen Seriennummern- oder WWN-Filter eingeben.');
assign(disks,'filter',key && pattern ? {[key]:pattern} : undefined);
assign(disks,'filter_match',string('disk.filter_match'));
const serials = list('disk.expected_serials',before.disk_setup?.expected_serials);
if (mode === 'filtered' && !state.override && !serials?.length) throw new Error('Bitte mindestens eine bestätigte Datenträgerkennung hinzufügen.');
if (serials && (serials.length > 16 || new Set(serials).size !== serials.length || serials.some(item => /[*?\[\]]/.test(item)))) throw new Error('Datenträgerkennungen: Höchstens 16 unterschiedliche, konkrete Seriennummern oder WWNs eingeben.');
const count = numericValue('disk.expected_count','Anzahl bestätigter Datenträger',1,16,true);
if (count !== undefined && serials && count !== serials.length) throw new Error('Die Anzahl muss zur Liste der bestätigten Datenträger passen.');
assign(disks,'expected_serials',serials);
assign(disks,'expected_count',count ?? (!state.override && serials?.length ? serials.length : undefined));
if (disks.filter_match && !['all','any'].includes(disks.filter_match)) throw new Error('Bitte eine gültige Filterverknüpfung wählen.');
if (disks.inventory_evidence && disks.inventory_evidence.length < 5) throw new Error('Die Referenz zur Hardwareprüfung muss mindestens fünf Zeichen enthalten.');
}
if (filesystem === 'zfs' || !filesystem && (before.disk_setup?.zfs || [...editor.querySelectorAll('[name]')].some(node => node.name.startsWith(`${prefix}.disk.zfs.`) && node.value))) {
const zfs = clone(object(disks.zfs,'ZFS'));
assign(zfs,'raid',string('disk.zfs.raid'));
for (const [key,label,min,max,integer] of zfsNumbers) assign(zfs,key,numericValue(`disk.zfs.${key}`,label,min,max,integer));
for (const key of ['checksum','compress']) assign(zfs,key,string(`disk.zfs.${key}`));
if (zfs.raid && !raids.some(([raid]) => raid === zfs.raid)) throw new Error('Bitte einen unterstützten ZFS-Verbund auswählen.');
if (zfs.checksum && !['on','fletcher4','sha256'].includes(zfs.checksum)) throw new Error('Bitte eine unterstützte ZFS-Prüfsumme auswählen.');
if (zfs.compress && !['on','off','lzjb','lz4','zle','gzip','zstd'].includes(zfs.compress)) throw new Error('Bitte eine unterstützte ZFS-Kompression auswählen.');
const count = disks.expected_serials?.length, minimum = {raid0:1,raid1:2,raid10:4,'raidz-1':3,'raidz-2':4,'raidz-3':5};
if (mode === 'filtered' && count && zfs.raid && (count < minimum[zfs.raid] || zfs.raid === 'raid10' && count % 2)) throw new Error('Die bestätigte Datenträgeranzahl passt nicht zum gewählten ZFS-Verbund.');
disks.zfs = zfs;
if (filesystem) delete disks.lvm;
} else if (filesystem) delete disks.zfs;
if (['ext4','xfs'].includes(filesystem) || !filesystem && (before.disk_setup?.lvm || [...editor.querySelectorAll('[name]')].some(node => node.name.startsWith(`${prefix}.disk.lvm.`) && node.value))) {
if (disks.expected_serials?.length > 1) throw new Error('ext4 und XFS benötigen genau einen bestätigten Systemdatenträger.');
const lvm = clone(object(disks.lvm,'LVM'));
for (const [key,label,min] of lvmNumbers) assign(lvm,key,numericValue(`disk.lvm.${key}`,label,min,1000000));
if (Object.keys(lvm).length || Object.hasOwn(before.disk_setup || {},'lvm')) disks.lvm = lvm;
else delete disks.lvm;
} else if (filesystem) delete disks.lvm;
values.disk_setup = disks;
} else delete values.disk_setup;
if (state.override) for (const key of ['global','network','disk_setup']) {
if (values[key] && !Object.keys(values[key]).length && (!Object.hasOwn(before,key) || Object.keys(before[key]).length)) delete values[key];
}
return values;
}
function wire(container, prefix = 'installation') {
const editor = root(container,prefix), state = JSON.parse(editor.dataset.installationState);
if (editor.dataset.installationWired) return;
editor.dataset.installationWired = 'true';
wireDataEditors(editor);
const control = name => [...editor.querySelectorAll('[name]')].find(node => node.name === `${prefix}.${name}`);
const visible = (node,show) => {node.hidden = !show; for (const item of node.querySelectorAll('input,select,textarea,button')) item.disabled = !show;};
function refresh() {
for (const section of editor.querySelectorAll('[data-installation-section]')) {
const enabled = !state.override || control(`enable.${section.dataset.installationSection}`).checked;
visible(section.querySelector('[data-installation-section-body]'),enabled);
}
const diskEnabled = !state.override || control('enable.disk_setup').checked;
const filesystem = control('disk.filesystem').value, mode = control('disk.mode').value;
visible(editor.querySelector('[data-installation-filter-fields]'),diskEnabled && mode !== 'all');
visible(editor.querySelector('[data-installation-zfs-fields]'),diskEnabled && (!filesystem || filesystem === 'zfs'));
visible(editor.querySelector('[data-installation-lvm-fields]'),diskEnabled && (!filesystem || ['ext4','xfs'].includes(filesystem)));
editor.querySelector('[data-installation-all-note]').hidden = !diskEnabled || mode !== 'all';
control('disk.inventory_evidence').required = !state.override && mode === 'filtered';
for (const row of editor.querySelectorAll('[data-installation-network-row]')) {
const select = [...row.querySelectorAll('[name]')].find(node => node.name === `${prefix}.interface-key`);
const expected = [...row.querySelectorAll('[name]')].find(node => node.name === `${prefix}.interface-value`);
expected.placeholder = select.value === '__mac' ? '02:00:00:00:00:01' : 'Erwarteter Merkmalswert';
visible(row.querySelector('[data-installation-custom-key]'),(!state.override || control('enable.network').checked) && select.value === '__custom');
}
}
editor.addEventListener('change', event => {
const name = event.target.name;
if (name === `${prefix}.disk.mode` && event.target.value === 'all') {
control('disk.filesystem').value = 'zfs';
control('disk.zfs.raid').value = 'raid0';
}
if (name === `${prefix}.disk.filesystem` && event.target.value && event.target.value !== 'zfs' && control('disk.mode').value === 'all') control('disk.mode').value = 'filtered';
if (name === `${prefix}.disk.zfs.raid` && event.target.value !== 'raid0' && control('disk.mode').value === 'all') control('disk.mode').value = 'filtered';
refresh();
});
editor.addEventListener('click', event => {
const add = event.target.closest('[data-installation-add-interface]'), remove = event.target.closest('[data-installation-remove-interface]');
if (add) editor.querySelector('[data-installation-network-rows]').insertAdjacentHTML('beforeend',networkRow(prefix));
if (remove) remove.closest('[data-installation-network-row]').remove();
if (add || remove) {event.preventDefault(); refresh();}
});
refresh();
}
return {render,read,wire};
})();
function installationEditor(values = {}, options = {}) { return installationForm.render(values,options); }
function readInstallationEditor(container,prefix = 'installation',previousValues = {}) { return installationForm.read(container,prefix,previousValues); }
function wireInstallationEditor(container,prefix = 'installation') { installationForm.wire(container,prefix); }
+392
View File
@@ -0,0 +1,392 @@
/* 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 = '') => `<button type="button" class="button small" data-module-action="${action}" ${attributes}>${esc(label)}</button>`;
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 `<div class="form-grid" data-step-secret data-secret-key="${key}">${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})}<div class="full">${button('Geheimniszuordnung entfernen', 'remove-secret')}</div></div>`;
}
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 `<section class="card" data-postinstall-step data-step-key="${key}" data-original="${metadata(step)}" data-current-module="${esc(step.module_id || '')}">
<div class="card-header"><h3 data-step-heading>Schritt</h3><div class="header-actions">${button('↑ Nach oben', 'step-up')}${button('↓ Nach unten', 'step-down')}${button('Entfernen', 'remove-step')}</div></div>
<div class="card-content stack"><div class="form-grid">${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})}</div>
<div data-step-parameters>${stepParameters(key, step.parameters || {}, module)}</div>
<details${Object.keys(step.secret_refs || {}).length ? ' open' : ''}><summary>Geheimnisse für diesen Schritt</summary><p class="small-text muted">Gespeicherte Geheimnisse anhand ihres Namens zuordnen. Die Werte bleiben verborgen.</p><div class="stack" data-step-secrets>${Object.entries(step.secret_refs || {}).map(([name, value]) => secretRow(name, value, secrets)).join('')}</div>${button('Geheimnis zuordnen', 'add-secret')}</details></div>
</section>`;
}
function postinstall(profile = {}, modules = [], secrets = []) {
return `<div class="full stack" data-postinstall-editor data-original="${metadata(profile)}">
${dataEditor('postinstall-values', profile.values || {}, {label:'Gemeinsame Profilparameter', schema:{type:'object'}})}
<div><h3 class="section-label">Ablauf der Nachkonfiguration</h3><p class="small-text muted">Module auswählen und in der gewünschten Reihenfolge anordnen. Abhängigkeiten müssen vor dem jeweiligen Modul stehen.</p></div>
${list(modules).some(item => item.status === 'published') ? '' : '<div class="alert alert-info">Es sind noch keine veröffentlichten Module verfügbar. Übernehmen Sie zunächst eine Modulvorlage und veröffentlichen Sie den geprüften Stand.</div>'}
<div class="stack" data-postinstall-steps>${list(profile.steps).map(step => stepCard(step, modules, secrets)).join('')}</div>
<p class="small-text muted" data-no-steps${list(profile.steps).length ? ' hidden' : ''}>Fügen Sie den ersten Schritt hinzu. Mindestens ein Schritt muss als Pflichtschritt markiert sein.</p>
<div>${button('Schritt hinzufügen', 'add-step')}</div>
${field('postinstall-reboot-budget', 'Maximale geplante Neustarts', profile.reboot_budget ?? 1, {type:'number', min:0, max:5, full:true})}
<div class="alert alert-danger" data-module-form-error role="alert" hidden></div>
</div>`;
}
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 `<section class="card" data-schema-property data-property-key="${key}"><div class="card-header"><strong>Parameter</strong>${button('Parameter entfernen', 'schema-remove-property')}</div><div class="card-content stack"><div class="form-grid">${field(`${key}-name`, 'Parametername', name, {required:true, placeholder:'z. B. servers'})}${field(`${key}-required`, 'Eingabe erforderlich', required, {type:'checkbox'})}</div>${schemaNode(schema, depth + 1)}</div></section>`;
}
function schemaBody(schema, type, key, depth) {
if (type === 'custom') return `<div class="stack" data-schema-body>${dataEditor(`${key}-custom`, schema, {label:'Weitere Parameterregeln grafisch bearbeiten'})}</div>`;
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 = `<div class="stack" data-schema-properties>${Object.entries(props).map(([name, rule]) => propertyCard(name, rule, required.has(name), depth)).join('')}</div><div>${button('Parameter hinzufügen', 'schema-add-property')}</div>${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'}]})}<div data-schema-additional${additional === 'schema' ? '' : ' hidden'}>${dataEditor(`${key}-additional-schema`, object(schema.additionalProperties) ? schema.additionalProperties : {}, {label:'Regeln für weitere Felder'})}</div>`;
} else if (type === 'array') {
specific = `${field(`${key}-has-items`, 'Einträge der Liste definieren', own(schema, 'items'), {type:'checkbox'})}<div data-schema-items${own(schema, 'items') ? '' : ' hidden'}>${schemaNode(schema.items ?? {type:'string'}, depth + 1)}</div>${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('<input ', '<input step="any" ')).join('');
return `<div class="stack" data-schema-body data-original="${metadata(schema)}">
<div class="form-grid">${field(`${key}-title`, 'Anzeigename (optional)', schema.title || '')}${field(`${key}-description`, 'Beschreibung (optional)', schema.description || '')}${limitFields}</div>
${specific}
<div>${field(`${key}-has-default`, 'Vorgabewert anbieten', own(schema, 'default'), {type:'checkbox'})}<div data-schema-default${own(schema, 'default') ? '' : ' hidden'}>${dataEditor(`${key}-default`, own(schema, 'default') ? schema.default : copy(typeDefaults[type]), {label:'Vorgabewert'})}</div></div>
<div>${field(`${key}-has-enum`, 'Auswahl auf vorgegebene Werte beschränken', own(schema, 'enum'), {type:'checkbox'})}<div data-schema-enum${own(schema, 'enum') ? '' : ' hidden'}>${dataEditor(`${key}-enum`, schema.enum || [], {label:'Erlaubte Auswahlwerte', schema:{type:'array'}})}</div></div>
<details${Object.keys(extras).length ? ' open' : ''}><summary>Weitere Regeln</summary>${dataEditor(`${key}-extras`, extras, {label:'Weitere Parameterregeln', schema:{type:'object'}})}</details>
</div>`;
}
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 `<div class="stack" data-schema-node data-schema-key="${key}" data-schema-depth="${depth}" data-current-type="${type}">${field(`${key}-type`, 'Werttyp', type, {type:'select', options})}${schemaBody(schema, type, key, depth)}</div>`;
}
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 `<div class="full stack" data-module-schema-editor><h3 class="section-label">Parameter des Moduls</h3><p class="small-text muted">Felder, Datentypen und erlaubte Werte festlegen. Diese Angaben werden im Ablaufeditor als Eingabefelder angezeigt.</p>${schemaNode(schema)}<div class="alert alert-danger" data-module-form-error role="alert" hidden></div></div>`;
}
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); }
+68
View File
@@ -0,0 +1,68 @@
'use strict';
async function graphicalProfileForm(kind,existing=null) {
const p=existing||{};
const install=kind==='installation';
const [secrets,modules]=await Promise.all([api('/secrets'),install?Promise.resolve([]):api('/modules')]);
const fields=field('name','Profilname',p.name,{required:true,full:true,placeholder:install?'PVE · Einzelplatte':'PVE · Basiskonfiguration',hint:'Beim Speichern entsteht eine neue Profilversion.'})+
dataEditor('profile-builds',arr(p.target_builds),{label:'Unterstützte Zielbuilds',schema:{type:'array',minItems:1,items:{type:'string',minLength:1}}})+
(install?installationEditor(p.values||{},{prefix:'installation',secrets:arr(secrets)}):postinstallEditor(p,arr(modules).filter(m=>m.status==='published'),arr(secrets)))+
field('reason','Änderungsgrund','',{full:true,placeholder:'Grund für diesen Profilstand'});
showModal(existing?'Neue Profilversion':'Profil erstellen',form(fields,'Entwurf speichern'),async data=>{
const builds=readDataEditor(modal,'profile-builds');
if(!builds.length||builds.some(build=>typeof build!=='string'||!build.trim()))throw new Error('Bitte mindestens einen tatsächlich geprüften Zielbuild angeben.');
const configuration=install?{values:readInstallationEditor(modal,'installation',p.values||{}),steps:[]}:readPostinstallEditor(modal,p);
const body={name:data.get('name'),kind,target_builds:builds.map(build=>build.trim()),...configuration,reason:data.get('reason')||''};
if(Object.hasOwn(p,'locked_fields'))body.locked_fields=p.locked_fields;
await api('/profiles',{method:'POST',body});closeModal();toast('Profilentwurf gespeichert.');await refresh();
},install?'INSTALLATIONSPROFIL':'POSTINSTALLATIONSPROFIL');
wireDataEditors(modal);
if(install)wireInstallationEditor(modal,'installation');else wirePostinstallEditor(modal,arr(modules).filter(m=>m.status==='published'),arr(secrets));
}
async function graphicalModuleForm(existing=null) {
const m=existing||{};
const [catalog,modules]=await Promise.all([api('/modules/builtin'),api('/modules')]);
const templates=arr(catalog);
let source=m.source||'';
let schema=structuredClone(m.parameters_schema||{type:'object',properties:{},additionalProperties:false});
let previousTemplateName='';
const currentTemplate=templates.find(item=>item.id===m.id||item.source===m.source);
const fields=field('name','Modulname',m.name,{required:true,full:true,placeholder:'PVE-Dienste prüfen'})+
`<section class="form-section full"><h3>Modul auswählen</h3><div class="form-grid">${field('module_template','Vorlage',currentTemplate?.id||'',{type:'select',full:true,options:[{value:'',label:source?'Vorhandenes Skript beibehalten':'Vorlage auswählen oder Skriptdatei laden'},...templates.map(item=>({value:item.id,label:item.name}))]})}<label class="full">Eigene Skriptdatei laden<input type="file" accept=".sh,text/plain,application/x-sh" data-module-file><small>Eine vorhandene Bash-Datei mit check-, apply- und verify-Aufrufen auswählen.</small></label></div><p class="source-status" data-source-status>${source?'Skript übernommen.':'Noch kein Skript ausgewählt.'}</p><details><summary>Skript ansehen</summary><pre class="code-block script-preview" data-source-preview>${esc(source)}</pre></details></section>`+
dataEditor('module-builds',arr(m.target_builds),{label:'Unterstützte Zielbuilds',schema:{type:'array',items:{type:'string'}}})+
field('timeout_seconds','Timeout in Sekunden',m.timeout_seconds??300,{type:'number',required:true,min:1,max:7200,full:true})+
`<div class="full" data-schema-container>${moduleSchemaEditor(schema)}</div>`+
`<div class="full" data-dependencies-container>${dataEditor('module-dependencies',arr(m.dependencies),{label:'Vorher benötigte Module',schema:{type:'array',items:{type:'string',enum:[...new Set([...arr(modules).map(item=>item.name),...templates.map(item=>item.name),...arr(m.dependencies)])]}}})}</div>`+
field('retry_safe','Apply darf nach Zustandsprüfung wiederholt werden.',m.retry_safe??false,{type:'checkbox',full:true,hint:'Nur aktivieren, wenn die Wiederholbarkeit nachgewiesen wurde.'})+
field('reason','Änderungsgrund','',{full:true});
showModal(existing?'Neue Modulversion':'Skriptmodul erstellen',form(fields,'Entwurf speichern'),async data=>{
if(!source)throw new Error('Bitte eine Vorlage oder Skriptdatei auswählen.');
const builds=readDataEditor(modal,'module-builds');
if(!builds.length||builds.some(build=>typeof build!=='string'||!build.trim()))throw new Error('Bitte mindestens einen geprüften Zielbuild angeben.');
await api('/modules',{method:'POST',body:{name:data.get('name'),source,parameters_schema:readModuleSchemaEditor(modal,schema),dependencies:readDataEditor(modal,'module-dependencies'),target_builds:builds.map(build=>build.trim()),timeout_seconds:Number(data.get('timeout_seconds')),retry_safe:data.get('retry_safe')==='on',reason:data.get('reason')||''}});closeModal();toast('Modulentwurf gespeichert.');await refresh();
},'VERSIONIERTE SKRIPTMODULE');
wireDataEditors(modal);
wireModuleSchemaEditor(modal);
const updateSource=label=>{modal.querySelector('[data-source-status]').textContent=label;modal.querySelector('[data-source-preview]').textContent=source;};
modal.querySelector('[name="module_template"]').addEventListener('change',event=>{
const template=templates.find(item=>item.id===event.target.value);
if(!template)return;
source=template.source;
const nameInput=modal.querySelector('[name="name"]');
if(!nameInput.value||nameInput.value===previousTemplateName)nameInput.value=template.name;
previousTemplateName=template.name;
schema=structuredClone(template.parameters_schema);
modal.querySelector('[data-schema-container]').innerHTML=moduleSchemaEditor(schema);
modal.querySelector('[data-dependencies-container]').innerHTML=dataEditor('module-dependencies',template.dependencies,{label:'Vorher benötigte Module',schema:{type:'array',items:{type:'string',enum:[...new Set([...arr(modules).map(item=>item.name),...templates.map(item=>item.name)])]}}});
modal.querySelector('[name="timeout_seconds"]').value=template.timeout_seconds;
modal.querySelector('[name="retry_safe"]').checked=template.retry_safe;
wireModuleSchemaEditor(modal);
updateSource(`${template.name}: Vorlage übernommen.`);
});
modal.querySelector('[data-module-file]').addEventListener('change',async event=>{
const file=event.target.files[0];
if(!file)return;
if(file.size>262144){toast('Die Skriptdatei darf höchstens 256 KiB groß sein.',true);event.target.value='';return;}
try{source=await file.text();modal.querySelector('[name="module_template"]').value='';updateSource(`${file.name}: Skript geladen.`);}catch{toast('Die Skriptdatei konnte nicht gelesen werden.',true);}
});
}
+6
View File
@@ -6,6 +6,12 @@
<meta name="color-scheme" content="light">
<title>Proxmox AIS · Provisionierung</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/forms.css">
<script src="/static/form-controls.js" defer></script>
<script src="/static/installation-form.js" defer></script>
<script src="/static/module-forms.js" defer></script>
<script src="/static/profile-forms.js" defer></script>
<script src="/static/host-forms.js" defer></script>
<script src="/static/app.js" defer></script>
</head>
<body>